jorgex-stack 1.0.2 → 1.0.4

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.
@@ -0,0 +1,906 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { GoalDb } from "./db.js";
3
+ import { assertGoalTransition, GOAL_STATUSES, isTerminalGoalStatus } from "./state.js";
4
+ import {
5
+ GOAL_STORE_SCHEMA_VERSION,
6
+ type GoalArtifactInput,
7
+ type GoalArtifactKind,
8
+ type GoalArtifactRecord,
9
+ type GoalEventInput,
10
+ type GoalEventRecord,
11
+ type GoalInput,
12
+ type GoalRecord,
13
+ type GoalStatus,
14
+ type GoalStore,
15
+ type GoalStoreOptions,
16
+ type GoalTransitionInput,
17
+ type NextAction,
18
+ type PhaseInput,
19
+ type PhaseRecord,
20
+ type PullRequestInput,
21
+ type PullRequestStatus,
22
+ type PullRequestMergeInput,
23
+ type PullRequestRecord,
24
+ type WorktreeInput,
25
+ type WorktreeRecord,
26
+ } from "./types.js";
27
+
28
+ export { GOAL_STORE_SCHEMA_VERSION } from "./types.js";
29
+
30
+ type Row = Record<string, unknown>;
31
+
32
+ export function createGoalStore(options: GoalStoreOptions): GoalStore {
33
+ return new GoalStoreImpl(options);
34
+ }
35
+
36
+ function createRecordId(prefix: string): string {
37
+ return `${prefix}_${randomUUID()}`;
38
+ }
39
+
40
+ function readText(row: Row, key: string): string {
41
+ const value = row[key];
42
+ if (typeof value !== "string") {
43
+ throw new Error(`Invalid SQLite row: ${key} must be a string.`);
44
+ }
45
+ return value;
46
+ }
47
+
48
+ function readNumber(row: Row, key: string): number {
49
+ const value = row[key];
50
+ if (typeof value !== "number") {
51
+ throw new Error(`Invalid SQLite row: ${key} must be a number.`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ function readOptionalText(row: Row, key: string): string | undefined {
57
+ const value = row[key];
58
+ if (value === null || value === undefined) return undefined;
59
+ if (typeof value !== "string") {
60
+ throw new Error(`Invalid SQLite row: ${key} must be a string.`);
61
+ }
62
+ return value;
63
+ }
64
+
65
+ const PULL_REQUEST_STATUSES = ["open", "merged", "closed"] as const satisfies readonly PullRequestStatus[];
66
+ const ARTIFACT_KINDS = ["prd", "plan"] as const satisfies readonly GoalArtifactKind[];
67
+
68
+ function readGoalStatus(row: Row, key: string): GoalStatus {
69
+ const value = readText(row, key);
70
+ if (!GOAL_STATUSES.includes(value as GoalStatus)) {
71
+ throw new Error(`Invalid SQLite row: ${key} has unknown goal status ${value}.`);
72
+ }
73
+ return value as GoalStatus;
74
+ }
75
+
76
+ function readPullRequestStatus(row: Row, key: string): PullRequestStatus {
77
+ const value = readText(row, key);
78
+ if (!PULL_REQUEST_STATUSES.includes(value as PullRequestStatus)) {
79
+ throw new Error(`Invalid SQLite row: ${key} has unknown pull request status ${value}.`);
80
+ }
81
+ return value as PullRequestStatus;
82
+ }
83
+
84
+ function readArtifactKind(row: Row, key: string): GoalArtifactKind {
85
+ const value = readText(row, key);
86
+ if (!ARTIFACT_KINDS.includes(value as GoalArtifactKind)) {
87
+ throw new Error(`Invalid SQLite row: ${key} has unknown artifact kind ${value}.`);
88
+ }
89
+ return value as GoalArtifactKind;
90
+ }
91
+
92
+ function encodeJson(value: unknown): string | null {
93
+ return value === undefined ? null : JSON.stringify(value);
94
+ }
95
+
96
+ function decodeJson(value: string | undefined): unknown {
97
+ return value === undefined ? undefined : JSON.parse(value);
98
+ }
99
+
100
+ function mapGoal(row: Row): GoalRecord {
101
+ return {
102
+ id: readText(row, "id"),
103
+ project: readText(row, "project"),
104
+ objective: readText(row, "objective"),
105
+ status: readGoalStatus(row, "status"),
106
+ createdAt: readText(row, "created_at"),
107
+ updatedAt: readText(row, "updated_at"),
108
+ };
109
+ }
110
+
111
+ function mapEvent(row: Row): GoalEventRecord {
112
+ return {
113
+ id: readText(row, "id"),
114
+ goalId: readText(row, "goal_id"),
115
+ type: readText(row, "type"),
116
+ message: readText(row, "message"),
117
+ data: decodeJson(readOptionalText(row, "data")),
118
+ createdAt: readText(row, "created_at"),
119
+ sequence: readNumber(row, "sequence"),
120
+ };
121
+ }
122
+
123
+ function mapPhase(row: Row): PhaseRecord {
124
+ return {
125
+ id: readText(row, "id"),
126
+ goalId: readText(row, "goal_id"),
127
+ name: readText(row, "name"),
128
+ objective: readText(row, "objective"),
129
+ status: readGoalStatus(row, "status"),
130
+ createdAt: readText(row, "created_at"),
131
+ updatedAt: readText(row, "updated_at"),
132
+ };
133
+ }
134
+
135
+ function mapWorktree(row: Row): WorktreeRecord {
136
+ return {
137
+ id: readText(row, "id"),
138
+ goalId: readText(row, "goal_id"),
139
+ phaseId: readText(row, "phase_id"),
140
+ path: readText(row, "path"),
141
+ branch: readText(row, "branch"),
142
+ status: readGoalStatus(row, "status"),
143
+ createdAt: readText(row, "created_at"),
144
+ updatedAt: readText(row, "updated_at"),
145
+ };
146
+ }
147
+
148
+ function mapPullRequest(row: Row): PullRequestRecord {
149
+ return {
150
+ id: readText(row, "id"),
151
+ goalId: readText(row, "goal_id"),
152
+ phaseId: readText(row, "phase_id"),
153
+ worktreeId: readText(row, "worktree_id"),
154
+ number: readNumber(row, "number"),
155
+ url: readText(row, "url"),
156
+ branch: readText(row, "branch"),
157
+ base: readText(row, "base"),
158
+ status: readPullRequestStatus(row, "status"),
159
+ createdAt: readText(row, "created_at"),
160
+ updatedAt: readText(row, "updated_at"),
161
+ mergedAt: readOptionalText(row, "merged_at"),
162
+ mergeCommit: readOptionalText(row, "merge_commit"),
163
+ };
164
+ }
165
+
166
+ function mapArtifact(row: Row): GoalArtifactRecord {
167
+ return {
168
+ id: readText(row, "id"),
169
+ goalId: readText(row, "goal_id"),
170
+ kind: readArtifactKind(row, "kind"),
171
+ path: readText(row, "path"),
172
+ createdAt: readText(row, "created_at"),
173
+ updatedAt: readText(row, "updated_at"),
174
+ };
175
+ }
176
+
177
+ class GoalStoreImpl implements GoalStore {
178
+ private readonly db: GoalDb;
179
+ private readonly now: () => string;
180
+ private isClosed = false;
181
+ private inTransaction = false;
182
+
183
+ constructor(options: GoalStoreOptions) {
184
+ this.db = new GoalDb(options.databasePath);
185
+ this.now = options.now ?? (() => new Date().toISOString());
186
+ }
187
+
188
+ migrate(): void {
189
+ this.ensureOpen();
190
+ this.db.exec(`
191
+ CREATE TABLE IF NOT EXISTS goals (
192
+ id TEXT PRIMARY KEY,
193
+ project TEXT NOT NULL,
194
+ objective TEXT NOT NULL,
195
+ status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'blocked', 'waiting_for_merge', 'budget_limited', 'failed', 'complete', 'cancelled')),
196
+ created_at TEXT NOT NULL,
197
+ updated_at TEXT NOT NULL
198
+ ) STRICT;
199
+
200
+ CREATE INDEX IF NOT EXISTS idx_goals_project_status_updated
201
+ ON goals(project, status, updated_at);
202
+
203
+ UPDATE goals
204
+ SET status = 'cancelled'
205
+ WHERE status NOT IN ('failed', 'complete', 'cancelled')
206
+ AND EXISTS (
207
+ SELECT 1
208
+ FROM goals AS newer
209
+ WHERE newer.project = goals.project
210
+ AND newer.status NOT IN ('failed', 'complete', 'cancelled')
211
+ AND (
212
+ newer.updated_at > goals.updated_at
213
+ OR (newer.updated_at = goals.updated_at AND newer.created_at > goals.created_at)
214
+ OR (
215
+ newer.updated_at = goals.updated_at
216
+ AND newer.created_at = goals.created_at
217
+ AND newer.id > goals.id
218
+ )
219
+ )
220
+ );
221
+
222
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_goals_one_open_per_project
223
+ ON goals(project)
224
+ WHERE status NOT IN ('failed', 'complete', 'cancelled');
225
+
226
+ CREATE TABLE IF NOT EXISTS goal_events (
227
+ id TEXT PRIMARY KEY,
228
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
229
+ type TEXT NOT NULL,
230
+ message TEXT NOT NULL,
231
+ data TEXT,
232
+ created_at TEXT NOT NULL,
233
+ sequence INTEGER NOT NULL UNIQUE
234
+ ) STRICT;
235
+
236
+ CREATE INDEX IF NOT EXISTS idx_goal_events_goal_sequence
237
+ ON goal_events(goal_id, sequence);
238
+
239
+ CREATE TABLE IF NOT EXISTS goal_phases (
240
+ id TEXT PRIMARY KEY,
241
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
242
+ name TEXT NOT NULL,
243
+ objective TEXT NOT NULL,
244
+ status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'blocked', 'waiting_for_merge', 'budget_limited', 'failed', 'complete', 'cancelled')),
245
+ created_at TEXT NOT NULL,
246
+ updated_at TEXT NOT NULL
247
+ ) STRICT;
248
+
249
+ CREATE TABLE IF NOT EXISTS goal_worktrees (
250
+ id TEXT PRIMARY KEY,
251
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
252
+ phase_id TEXT NOT NULL REFERENCES goal_phases(id) ON DELETE CASCADE,
253
+ path TEXT NOT NULL,
254
+ branch TEXT NOT NULL,
255
+ status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'blocked', 'waiting_for_merge', 'budget_limited', 'failed', 'complete', 'cancelled')),
256
+ created_at TEXT NOT NULL,
257
+ updated_at TEXT NOT NULL
258
+ ) STRICT;
259
+
260
+ CREATE TABLE IF NOT EXISTS goal_prs (
261
+ id TEXT PRIMARY KEY,
262
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
263
+ phase_id TEXT NOT NULL REFERENCES goal_phases(id) ON DELETE CASCADE,
264
+ worktree_id TEXT NOT NULL REFERENCES goal_worktrees(id) ON DELETE CASCADE,
265
+ number INTEGER NOT NULL,
266
+ url TEXT NOT NULL,
267
+ branch TEXT NOT NULL,
268
+ base TEXT NOT NULL,
269
+ status TEXT NOT NULL CHECK (status IN ('open', 'merged', 'closed')),
270
+ created_at TEXT NOT NULL,
271
+ updated_at TEXT NOT NULL,
272
+ merged_at TEXT,
273
+ merge_commit TEXT
274
+ ) STRICT;
275
+
276
+ CREATE INDEX IF NOT EXISTS idx_goal_prs_goal_status_created
277
+ ON goal_prs(goal_id, status, created_at);
278
+
279
+ CREATE TABLE IF NOT EXISTS goal_artifacts (
280
+ id TEXT PRIMARY KEY,
281
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
282
+ kind TEXT NOT NULL CHECK (kind IN ('prd', 'plan')),
283
+ path TEXT NOT NULL,
284
+ created_at TEXT NOT NULL,
285
+ updated_at TEXT NOT NULL,
286
+ UNIQUE(goal_id, kind)
287
+ ) STRICT;
288
+
289
+ CREATE INDEX IF NOT EXISTS idx_goal_artifacts_goal_kind
290
+ ON goal_artifacts(goal_id, kind);
291
+
292
+ PRAGMA user_version = ${GOAL_STORE_SCHEMA_VERSION};
293
+ `);
294
+ }
295
+
296
+ schemaVersion(): number {
297
+ this.ensureOpen();
298
+ const row = this.db.get("PRAGMA user_version;");
299
+ return typeof row?.user_version === "number" ? row.user_version : 0;
300
+ }
301
+
302
+ createGoal(input: GoalInput): GoalRecord {
303
+ this.ensureOpen();
304
+ this.assertText(input.objective, "objective");
305
+ this.assertText(input.project, "project");
306
+
307
+ const existing = this.getCurrentGoal(input.project);
308
+ if (existing) {
309
+ throw new Error(`Project ${input.project} already has an open goal: ${existing.objective}.`);
310
+ }
311
+
312
+ const createdAt = this.now();
313
+ const goal: GoalRecord = {
314
+ id: createRecordId("goal"),
315
+ project: input.project,
316
+ objective: input.objective,
317
+ status: "active",
318
+ createdAt,
319
+ updatedAt: createdAt,
320
+ };
321
+
322
+ this.db.run(
323
+ `INSERT INTO goals (id, project, objective, status, created_at, updated_at)
324
+ VALUES (?, ?, ?, ?, ?, ?)`,
325
+ goal.id,
326
+ goal.project,
327
+ goal.objective,
328
+ goal.status,
329
+ goal.createdAt,
330
+ goal.updatedAt,
331
+ );
332
+
333
+ return goal;
334
+ }
335
+
336
+ getGoal(goalId: string): GoalRecord | undefined {
337
+ this.ensureOpen();
338
+ const row = this.db.get("SELECT * FROM goals WHERE id = ?", goalId);
339
+ return row ? mapGoal(row) : undefined;
340
+ }
341
+
342
+ getActiveGoal(project: string): GoalRecord | undefined {
343
+ this.ensureOpen();
344
+ const row = this.db.get(
345
+ `SELECT * FROM goals
346
+ WHERE project = ? AND status = 'active'
347
+ ORDER BY updated_at DESC
348
+ LIMIT 1`,
349
+ project,
350
+ );
351
+ return row ? mapGoal(row) : undefined;
352
+ }
353
+
354
+ getCurrentGoal(project: string): GoalRecord | undefined {
355
+ this.ensureOpen();
356
+ const row = this.db.get(
357
+ `SELECT * FROM goals
358
+ WHERE project = ? AND status NOT IN ('failed', 'complete', 'cancelled')
359
+ ORDER BY updated_at DESC
360
+ LIMIT 1`,
361
+ project,
362
+ );
363
+ return row ? mapGoal(row) : undefined;
364
+ }
365
+
366
+ appendEvent(goalId: string, input: GoalEventInput): GoalEventRecord {
367
+ return this.atomically(() => {
368
+ this.ensureOpen();
369
+ const goal = this.requireGoal(goalId);
370
+ this.assertText(input.type, "type");
371
+ this.assertText(input.message, "message");
372
+
373
+ const sequenceRow = this.db.get("SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM goal_events");
374
+ const sequence =
375
+ typeof sequenceRow?.next_sequence === "number" ? sequenceRow.next_sequence : 1;
376
+ const createdAt = this.now();
377
+ const event: GoalEventRecord = {
378
+ id: createRecordId("event"),
379
+ goalId,
380
+ type: input.type,
381
+ message: input.message,
382
+ data: input.data,
383
+ createdAt,
384
+ sequence,
385
+ };
386
+
387
+ this.db.run(
388
+ `INSERT INTO goal_events (id, goal_id, type, message, data, created_at, sequence)
389
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
390
+ event.id,
391
+ event.goalId,
392
+ event.type,
393
+ event.message,
394
+ encodeJson(event.data),
395
+ event.createdAt,
396
+ event.sequence,
397
+ );
398
+ this.touchGoal(goal.id, createdAt);
399
+ return event;
400
+ });
401
+ }
402
+
403
+ listEvents(goalId: string): GoalEventRecord[] {
404
+ this.ensureOpen();
405
+ return this.db
406
+ .all("SELECT * FROM goal_events WHERE goal_id = ? ORDER BY sequence ASC", goalId)
407
+ .map(mapEvent);
408
+ }
409
+
410
+ transitionGoal(
411
+ goalId: string,
412
+ status: GoalStatus,
413
+ input: GoalTransitionInput,
414
+ ): GoalRecord {
415
+ return this.atomically(() => {
416
+ this.ensureOpen();
417
+ const goal = this.requireGoal(goalId);
418
+ this.assertText(input.reason, "reason");
419
+
420
+ const targetStatus =
421
+ status === "active" && this.getOpenPullRequest(goalId) ? "waiting_for_merge" : status;
422
+
423
+ if (goal.status === targetStatus) return goal;
424
+
425
+ assertGoalTransition(goal.status, targetStatus);
426
+ const updatedAt = this.now();
427
+ this.db.run(
428
+ "UPDATE goals SET status = ?, updated_at = ? WHERE id = ?",
429
+ targetStatus,
430
+ updatedAt,
431
+ goalId,
432
+ );
433
+ this.insertEvent(goalId, `goal.${targetStatus}`, input.reason, { from: goal.status, to: targetStatus }, updatedAt);
434
+ return this.requireGoal(goalId);
435
+ });
436
+ }
437
+
438
+ addPhase(goalId: string, input: PhaseInput): PhaseRecord {
439
+ return this.atomically(() => {
440
+ this.ensureOpen();
441
+ this.requireGoal(goalId);
442
+ this.assertText(input.name, "name");
443
+ this.assertText(input.objective, "objective");
444
+
445
+ const createdAt = this.now();
446
+ const phase: PhaseRecord = {
447
+ id: createRecordId("phase"),
448
+ goalId,
449
+ name: input.name,
450
+ objective: input.objective,
451
+ status: input.status,
452
+ createdAt,
453
+ updatedAt: createdAt,
454
+ };
455
+
456
+ this.db.run(
457
+ `INSERT INTO goal_phases (id, goal_id, name, objective, status, created_at, updated_at)
458
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
459
+ phase.id,
460
+ phase.goalId,
461
+ phase.name,
462
+ phase.objective,
463
+ phase.status,
464
+ phase.createdAt,
465
+ phase.updatedAt,
466
+ );
467
+ this.insertEvent(goalId, "goal.phase_added", "Goal phase added", {
468
+ phaseId: phase.id,
469
+ name: phase.name,
470
+ status: phase.status,
471
+ }, createdAt);
472
+ this.touchGoal(goalId, createdAt);
473
+ return phase;
474
+ });
475
+ }
476
+
477
+ addWorktree(goalId: string, input: WorktreeInput): WorktreeRecord {
478
+ return this.atomically(() => {
479
+ this.ensureOpen();
480
+ this.requireGoal(goalId);
481
+ this.requirePhase(goalId, input.phaseId);
482
+ this.assertText(input.path, "path");
483
+ this.assertText(input.branch, "branch");
484
+
485
+ const createdAt = this.now();
486
+ const worktree: WorktreeRecord = {
487
+ id: createRecordId("worktree"),
488
+ goalId,
489
+ phaseId: input.phaseId,
490
+ path: input.path,
491
+ branch: input.branch,
492
+ status: input.status,
493
+ createdAt,
494
+ updatedAt: createdAt,
495
+ };
496
+
497
+ this.db.run(
498
+ `INSERT INTO goal_worktrees (id, goal_id, phase_id, path, branch, status, created_at, updated_at)
499
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
500
+ worktree.id,
501
+ worktree.goalId,
502
+ worktree.phaseId,
503
+ worktree.path,
504
+ worktree.branch,
505
+ worktree.status,
506
+ worktree.createdAt,
507
+ worktree.updatedAt,
508
+ );
509
+ this.insertEvent(goalId, "goal.worktree_added", "Goal worktree added", {
510
+ worktreeId: worktree.id,
511
+ phaseId: worktree.phaseId,
512
+ branch: worktree.branch,
513
+ status: worktree.status,
514
+ }, createdAt);
515
+ this.touchGoal(goalId, createdAt);
516
+ return worktree;
517
+ });
518
+ }
519
+
520
+ recordPullRequest(goalId: string, input: PullRequestInput): PullRequestRecord {
521
+ return this.atomically(() => {
522
+ this.ensureOpen();
523
+ const goal = this.requireGoal(goalId);
524
+ this.requirePhase(goalId, input.phaseId);
525
+ this.requireWorktree(goalId, input.phaseId, input.worktreeId);
526
+ this.assertText(input.url, "url");
527
+ this.assertText(input.branch, "branch");
528
+ this.assertText(input.base, "base");
529
+ if (!Number.isInteger(input.number) || input.number <= 0) {
530
+ throw new Error("Invalid pull request number.");
531
+ }
532
+ if (!PULL_REQUEST_STATUSES.includes(input.status)) {
533
+ throw new Error(`Invalid pull request status: ${input.status}`);
534
+ }
535
+
536
+ if (isTerminalGoalStatus(goal.status)) {
537
+ throw new Error(`Cannot register a pull request on terminal goal ${goalId}.`);
538
+ }
539
+
540
+ const createdAt = this.now();
541
+ const pullRequest: PullRequestRecord = {
542
+ id: createRecordId("pr"),
543
+ goalId,
544
+ phaseId: input.phaseId,
545
+ worktreeId: input.worktreeId,
546
+ number: input.number,
547
+ url: input.url,
548
+ branch: input.branch,
549
+ base: input.base,
550
+ status: input.status,
551
+ createdAt,
552
+ updatedAt: createdAt,
553
+ };
554
+
555
+ this.db.run(
556
+ `INSERT INTO goal_prs
557
+ (id, goal_id, phase_id, worktree_id, number, url, branch, base, status, created_at, updated_at)
558
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
559
+ pullRequest.id,
560
+ pullRequest.goalId,
561
+ pullRequest.phaseId,
562
+ pullRequest.worktreeId,
563
+ pullRequest.number,
564
+ pullRequest.url,
565
+ pullRequest.branch,
566
+ pullRequest.base,
567
+ pullRequest.status,
568
+ pullRequest.createdAt,
569
+ pullRequest.updatedAt,
570
+ );
571
+
572
+ if (input.status === "open") {
573
+ this.db.run(
574
+ "UPDATE goals SET status = 'waiting_for_merge', updated_at = ? WHERE id = ?",
575
+ createdAt,
576
+ goalId,
577
+ );
578
+ this.insertEvent(goalId, "goal.waiting_for_merge", "Pull request opened", {
579
+ pullRequestId: pullRequest.id,
580
+ number: pullRequest.number,
581
+ }, createdAt);
582
+ } else {
583
+ this.insertEvent(goalId, "goal.pull_request_recorded", "Pull request recorded", {
584
+ pullRequestId: pullRequest.id,
585
+ number: pullRequest.number,
586
+ status: pullRequest.status,
587
+ }, createdAt);
588
+ this.touchGoal(goalId, createdAt);
589
+ }
590
+
591
+ return pullRequest;
592
+ });
593
+ }
594
+
595
+ getPullRequest(pullRequestId: string): PullRequestRecord | undefined {
596
+ this.ensureOpen();
597
+ const row = this.db.get("SELECT * FROM goal_prs WHERE id = ?", pullRequestId);
598
+ return row ? mapPullRequest(row) : undefined;
599
+ }
600
+
601
+ getOpenPullRequest(goalId: string): PullRequestRecord | undefined {
602
+ this.ensureOpen();
603
+ const row = this.db.get(
604
+ `SELECT * FROM goal_prs
605
+ WHERE goal_id = ? AND status = 'open'
606
+ ORDER BY created_at ASC
607
+ LIMIT 1`,
608
+ goalId,
609
+ );
610
+ return row ? mapPullRequest(row) : undefined;
611
+ }
612
+
613
+ listPullRequests(goalId: string): PullRequestRecord[] {
614
+ this.ensureOpen();
615
+ return this.db
616
+ .all("SELECT * FROM goal_prs WHERE goal_id = ? ORDER BY created_at ASC", goalId)
617
+ .map(mapPullRequest);
618
+ }
619
+
620
+ recordArtifact(goalId: string, input: GoalArtifactInput): GoalArtifactRecord {
621
+ return this.atomically(() => {
622
+ this.ensureOpen();
623
+ this.requireGoal(goalId);
624
+ this.assertText(input.path, "path");
625
+ if (input.kind !== "prd" && input.kind !== "plan") {
626
+ throw new Error(`Invalid artifact kind: ${input.kind}`);
627
+ }
628
+
629
+ const existing = this.getArtifact(goalId, input.kind);
630
+ const updatedAt = this.now();
631
+ if (existing) {
632
+ this.db.run(
633
+ "UPDATE goal_artifacts SET path = ?, updated_at = ? WHERE id = ?",
634
+ input.path,
635
+ updatedAt,
636
+ existing.id,
637
+ );
638
+ this.insertEvent(goalId, "goal.artifact_recorded", "Goal artifact updated", {
639
+ artifactId: existing.id,
640
+ kind: input.kind,
641
+ }, updatedAt);
642
+ this.touchGoal(goalId, updatedAt);
643
+ return this.getArtifact(goalId, input.kind)!;
644
+ }
645
+
646
+ const artifact: GoalArtifactRecord = {
647
+ id: createRecordId("artifact"),
648
+ goalId,
649
+ kind: input.kind,
650
+ path: input.path,
651
+ createdAt: updatedAt,
652
+ updatedAt,
653
+ };
654
+
655
+ this.db.run(
656
+ `INSERT INTO goal_artifacts (id, goal_id, kind, path, created_at, updated_at)
657
+ VALUES (?, ?, ?, ?, ?, ?)`,
658
+ artifact.id,
659
+ artifact.goalId,
660
+ artifact.kind,
661
+ artifact.path,
662
+ artifact.createdAt,
663
+ artifact.updatedAt,
664
+ );
665
+ this.insertEvent(goalId, "goal.artifact_recorded", "Goal artifact recorded", {
666
+ artifactId: artifact.id,
667
+ kind: artifact.kind,
668
+ }, updatedAt);
669
+ this.touchGoal(goalId, updatedAt);
670
+ return artifact;
671
+ });
672
+ }
673
+
674
+ listArtifacts(goalId: string): GoalArtifactRecord[] {
675
+ this.ensureOpen();
676
+ return this.db
677
+ .all("SELECT * FROM goal_artifacts WHERE goal_id = ? ORDER BY kind ASC", goalId)
678
+ .map(mapArtifact);
679
+ }
680
+
681
+ getArtifact(goalId: string, kind: GoalArtifactKind): GoalArtifactRecord | undefined {
682
+ this.ensureOpen();
683
+ const row = this.db.get(
684
+ "SELECT * FROM goal_artifacts WHERE goal_id = ? AND kind = ?",
685
+ goalId,
686
+ kind,
687
+ );
688
+ return row ? mapArtifact(row) : undefined;
689
+ }
690
+
691
+ nextAction(goalId: string): NextAction {
692
+ this.ensureOpen();
693
+ const goal = this.requireGoal(goalId);
694
+
695
+ if (!isTerminalGoalStatus(goal.status)) {
696
+ const pullRequest = this.getOpenPullRequest(goalId);
697
+ if (pullRequest) {
698
+ return { type: "wait_for_merge", pullRequestId: pullRequest.id };
699
+ }
700
+ }
701
+
702
+ return { type: "continue" };
703
+ }
704
+
705
+ recordPullRequestMerged(
706
+ pullRequestId: string,
707
+ input: PullRequestMergeInput,
708
+ ): PullRequestRecord {
709
+ return this.atomically(() => {
710
+ this.ensureOpen();
711
+ const pullRequest = this.requirePullRequest(pullRequestId);
712
+ this.assertText(input.mergedAt, "mergedAt");
713
+ this.assertText(input.mergeCommit, "mergeCommit");
714
+ if (pullRequest.status !== "open") {
715
+ throw new Error(`Pull request ${pullRequestId} is not open.`);
716
+ }
717
+
718
+ this.db.run(
719
+ `UPDATE goal_prs
720
+ SET status = 'merged', merged_at = ?, merge_commit = ?, updated_at = ?
721
+ WHERE id = ?`,
722
+ input.mergedAt,
723
+ input.mergeCommit,
724
+ input.mergedAt,
725
+ pullRequestId,
726
+ );
727
+
728
+ const goal = this.requireGoal(pullRequest.goalId);
729
+ if (!isTerminalGoalStatus(goal.status)) {
730
+ const nextOpenPullRequest = this.nextOpenPullRequest(pullRequest.goalId);
731
+ const nextStatus =
732
+ goal.status === "waiting_for_merge"
733
+ ? nextOpenPullRequest
734
+ ? "waiting_for_merge"
735
+ : "active"
736
+ : goal.status;
737
+
738
+ this.db.run(
739
+ "UPDATE goals SET status = ?, updated_at = ? WHERE id = ?",
740
+ nextStatus,
741
+ input.mergedAt,
742
+ pullRequest.goalId,
743
+ );
744
+ this.insertEvent(pullRequest.goalId, "goal.merge_detected", "Pull request merged externally", {
745
+ pullRequestId,
746
+ mergeCommit: input.mergeCommit,
747
+ nextOpenPullRequestId: nextOpenPullRequest ? readText(nextOpenPullRequest, "id") : undefined,
748
+ }, input.mergedAt);
749
+ }
750
+
751
+ return this.requirePullRequest(pullRequestId);
752
+ });
753
+ }
754
+
755
+ private nextOpenPullRequest(goalId: string): Row | undefined {
756
+ return this.db.get(
757
+ `SELECT id FROM goal_prs
758
+ WHERE goal_id = ? AND status = 'open'
759
+ ORDER BY created_at ASC
760
+ LIMIT 1`,
761
+ goalId,
762
+ );
763
+ }
764
+
765
+ transaction<T>(operation: () => T): T {
766
+ this.ensureOpen();
767
+ if (this.inTransaction) {
768
+ throw new Error("Nested goal store transactions are not supported.");
769
+ }
770
+
771
+ this.db.exec("BEGIN IMMEDIATE;");
772
+ this.inTransaction = true;
773
+ try {
774
+ const result = operation();
775
+ if (result && typeof (result as unknown as PromiseLike<unknown>).then === "function") {
776
+ throw new Error("Goal store transactions must be synchronous.");
777
+ }
778
+ this.db.exec("COMMIT;");
779
+ return result;
780
+ } catch (error) {
781
+ try {
782
+ this.db.exec("ROLLBACK;");
783
+ } catch (rollbackError) {
784
+ throw new Error(
785
+ `Goal store transaction failed: ${error instanceof Error ? error.message : String(error)}. Rollback also failed: ${
786
+ rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
787
+ }`,
788
+ );
789
+ }
790
+ throw error;
791
+ } finally {
792
+ this.inTransaction = false;
793
+ }
794
+ }
795
+
796
+ listPhases(goalId: string): PhaseRecord[] {
797
+ this.ensureOpen();
798
+ return this.db
799
+ .all("SELECT * FROM goal_phases WHERE goal_id = ? ORDER BY created_at ASC", goalId)
800
+ .map(mapPhase);
801
+ }
802
+
803
+ listWorktrees(goalId: string): WorktreeRecord[] {
804
+ this.ensureOpen();
805
+ return this.db
806
+ .all("SELECT * FROM goal_worktrees WHERE goal_id = ? ORDER BY created_at ASC", goalId)
807
+ .map(mapWorktree);
808
+ }
809
+
810
+ close(): void {
811
+ if (this.isClosed) return;
812
+ this.db.close();
813
+ this.isClosed = true;
814
+ }
815
+
816
+ private insertEvent(
817
+ goalId: string,
818
+ type: string,
819
+ message: string,
820
+ data: unknown,
821
+ createdAt: string,
822
+ ): void {
823
+ const sequenceRow = this.db.get("SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM goal_events");
824
+ const sequence =
825
+ typeof sequenceRow?.next_sequence === "number" ? sequenceRow.next_sequence : 1;
826
+ this.db.run(
827
+ `INSERT INTO goal_events (id, goal_id, type, message, data, created_at, sequence)
828
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
829
+ createRecordId("event"),
830
+ goalId,
831
+ type,
832
+ message,
833
+ encodeJson(data),
834
+ createdAt,
835
+ sequence,
836
+ );
837
+ }
838
+
839
+ private touchGoal(goalId: string, updatedAt: string): void {
840
+ this.db.run("UPDATE goals SET updated_at = ? WHERE id = ?", updatedAt, goalId);
841
+ }
842
+
843
+ private atomically<T>(operation: () => T): T {
844
+ if (this.inTransaction) return operation();
845
+ return this.transaction(operation);
846
+ }
847
+
848
+ private requireGoal(goalId: string): GoalRecord {
849
+ const goal = this.getGoal(goalId);
850
+ if (!goal) {
851
+ throw new Error(`Goal ${goalId} not found.`);
852
+ }
853
+ return goal;
854
+ }
855
+
856
+ private requirePhase(goalId: string, phaseId: string): PhaseRecord {
857
+ this.assertText(phaseId, "phaseId");
858
+ const row = this.db.get(
859
+ "SELECT * FROM goal_phases WHERE id = ? AND goal_id = ?",
860
+ phaseId,
861
+ goalId,
862
+ );
863
+ if (!row) {
864
+ throw new Error(`Phase ${phaseId} does not belong to goal ${goalId}.`);
865
+ }
866
+ return mapPhase(row);
867
+ }
868
+
869
+ private requireWorktree(
870
+ goalId: string,
871
+ phaseId: string,
872
+ worktreeId: string,
873
+ ): WorktreeRecord {
874
+ this.assertText(worktreeId, "worktreeId");
875
+ const row = this.db.get(
876
+ "SELECT * FROM goal_worktrees WHERE id = ? AND goal_id = ? AND phase_id = ?",
877
+ worktreeId,
878
+ goalId,
879
+ phaseId,
880
+ );
881
+ if (!row) {
882
+ throw new Error(`Worktree ${worktreeId} does not belong to phase ${phaseId}.`);
883
+ }
884
+ return mapWorktree(row);
885
+ }
886
+
887
+ private requirePullRequest(pullRequestId: string): PullRequestRecord {
888
+ const pullRequest = this.getPullRequest(pullRequestId);
889
+ if (!pullRequest) {
890
+ throw new Error(`Pull request ${pullRequestId} not found.`);
891
+ }
892
+ return pullRequest;
893
+ }
894
+
895
+ private ensureOpen(): void {
896
+ if (this.isClosed) {
897
+ throw new Error("Goal store is closed.");
898
+ }
899
+ }
900
+
901
+ private assertText(value: string, field: string): void {
902
+ if (typeof value !== "string" || value.trim().length === 0) {
903
+ throw new Error(`Invalid ${field}.`);
904
+ }
905
+ }
906
+ }