@workflow-cannon/workspace-kit 0.15.0 → 0.16.1

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.
Files changed (28) hide show
  1. package/README.md +55 -106
  2. package/dist/cli/doctor-planning-issues.d.ts +6 -0
  3. package/dist/cli/doctor-planning-issues.js +37 -0
  4. package/dist/cli.js +3 -0
  5. package/dist/core/config-metadata.js +56 -1
  6. package/dist/modules/approvals/review-runtime.js +3 -11
  7. package/dist/modules/improvement/generate-recommendations-runtime.js +3 -11
  8. package/dist/modules/task-engine/doctor-planning-persistence.d.ts +9 -0
  9. package/dist/modules/task-engine/doctor-planning-persistence.js +77 -0
  10. package/dist/modules/task-engine/index.d.ts +2 -0
  11. package/dist/modules/task-engine/index.js +44 -46
  12. package/dist/modules/task-engine/migrate-task-persistence-runtime.d.ts +2 -0
  13. package/dist/modules/task-engine/migrate-task-persistence-runtime.js +192 -0
  14. package/dist/modules/task-engine/planning-config.d.ts +10 -0
  15. package/dist/modules/task-engine/planning-config.js +37 -0
  16. package/dist/modules/task-engine/planning-open.d.ts +16 -0
  17. package/dist/modules/task-engine/planning-open.js +34 -0
  18. package/dist/modules/task-engine/sqlite-dual-planning.d.ts +21 -0
  19. package/dist/modules/task-engine/sqlite-dual-planning.js +137 -0
  20. package/dist/modules/task-engine/store.d.ts +12 -3
  21. package/dist/modules/task-engine/store.js +62 -38
  22. package/dist/modules/task-engine/wishlist-store.d.ts +12 -3
  23. package/dist/modules/task-engine/wishlist-store.js +62 -40
  24. package/package.json +11 -2
  25. package/src/modules/documentation/README.md +1 -0
  26. package/src/modules/documentation/instructions/document-project.md +1 -0
  27. package/src/modules/documentation/instructions/generate-document.md +1 -0
  28. package/src/modules/documentation/templates/README.md +89 -0
@@ -1,11 +1,11 @@
1
1
  import crypto from "node:crypto";
2
2
  import { maybeSpawnTranscriptHookAfterCompletion } from "../../core/transcript-completion-hook.js";
3
- import { TaskStore } from "./store.js";
4
3
  import { TransitionService } from "./service.js";
5
4
  import { TaskEngineError, getAllowedTransitionsFrom } from "./transitions.js";
6
5
  import { getNextActions } from "./suggestions.js";
7
6
  import { readWorkspaceStatusSnapshot } from "./dashboard-status.js";
8
- import { WishlistStore } from "./wishlist-store.js";
7
+ import { openPlanningStores } from "./planning-open.js";
8
+ import { runMigrateTaskPersistence } from "./migrate-task-persistence-runtime.js";
9
9
  import { buildWishlistItemFromIntake, validateWishlistIntakePayload, validateWishlistUpdatePayload, WISHLIST_ID_RE } from "./wishlist-validation.js";
10
10
  export { TaskStore } from "./store.js";
11
11
  export { TransitionService } from "./service.js";
@@ -14,22 +14,8 @@ export { getNextActions } from "./suggestions.js";
14
14
  export { readWorkspaceStatusSnapshot } from "./dashboard-status.js";
15
15
  export { WishlistStore } from "./wishlist-store.js";
16
16
  export { validateWishlistIntakePayload, validateWishlistUpdatePayload, buildWishlistItemFromIntake, WISHLIST_ID_RE } from "./wishlist-validation.js";
17
- function taskStorePath(ctx) {
18
- const tasks = ctx.effectiveConfig?.tasks;
19
- if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) {
20
- return undefined;
21
- }
22
- const p = tasks.storeRelativePath;
23
- return typeof p === "string" && p.trim().length > 0 ? p.trim() : undefined;
24
- }
25
- function wishlistStorePath(ctx) {
26
- const tasks = ctx.effectiveConfig?.tasks;
27
- if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) {
28
- return undefined;
29
- }
30
- const p = tasks.wishlistStoreRelativePath;
31
- return typeof p === "string" && p.trim().length > 0 ? p.trim() : undefined;
32
- }
17
+ export { openPlanningStores } from "./planning-open.js";
18
+ export { getTaskPersistenceBackend, planningSqliteDatabaseRelativePath, planningTaskStoreRelativePath, planningWishlistStoreRelativePath } from "./planning-config.js";
33
19
  const TASK_ID_RE = /^T\d+$/;
34
20
  const MUTABLE_TASK_FIELDS = new Set([
35
21
  "title",
@@ -126,7 +112,7 @@ function mutationEvidence(mutationType, taskId, actor, details) {
126
112
  export const taskEngineModule = {
127
113
  registration: {
128
114
  id: "task-engine",
129
- version: "0.5.0",
115
+ version: "0.6.0",
130
116
  contractVersion: "1",
131
117
  capabilities: ["task-engine"],
132
118
  dependsOn: [],
@@ -249,6 +235,11 @@ export const taskEngineModule = {
249
235
  file: "get-next-actions.md",
250
236
  description: "Get prioritized next-action suggestions with blocking analysis."
251
237
  },
238
+ {
239
+ name: "migrate-task-persistence",
240
+ file: "migrate-task-persistence.md",
241
+ description: "Copy task + wishlist state between JSON files and a single SQLite database (offline migration)."
242
+ },
252
243
  {
253
244
  name: "dashboard-summary",
254
245
  file: "dashboard-summary.md",
@@ -259,9 +250,12 @@ export const taskEngineModule = {
259
250
  },
260
251
  async onCommand(command, ctx) {
261
252
  const args = command.args ?? {};
262
- const store = new TaskStore(ctx.workspacePath, taskStorePath(ctx));
253
+ if (command.name === "migrate-task-persistence") {
254
+ return runMigrateTaskPersistence(ctx, args);
255
+ }
256
+ let planning;
263
257
  try {
264
- await store.load();
258
+ planning = await openPlanningStores(ctx);
265
259
  }
266
260
  catch (err) {
267
261
  if (err instanceof TaskEngineError) {
@@ -270,9 +264,10 @@ export const taskEngineModule = {
270
264
  return {
271
265
  ok: false,
272
266
  code: "storage-read-error",
273
- message: `Failed to load task store: ${err.message}`
267
+ message: `Failed to open task planning stores: ${err.message}`
274
268
  };
275
269
  }
270
+ const store = planning.taskStore;
276
271
  if (command.name === "run-transition") {
277
272
  const taskId = typeof args.taskId === "string" ? args.taskId : undefined;
278
273
  const action = typeof args.action === "string" ? args.action : undefined;
@@ -566,14 +561,14 @@ export const taskEngineModule = {
566
561
  phase: t.phase ?? null
567
562
  }));
568
563
  const blockedTop = suggestion.blockingAnalysis.slice(0, 15);
569
- const wishlistStore = new WishlistStore(ctx.workspacePath, wishlistStorePath(ctx));
564
+ let wishlistItems = [];
570
565
  try {
571
- await wishlistStore.load();
566
+ const wishlistStore = await planning.openWishlist();
567
+ wishlistItems = wishlistStore.getAllItems();
572
568
  }
573
569
  catch {
574
570
  /* wishlist store optional */
575
571
  }
576
- const wishlistItems = wishlistStore.getAllItems();
577
572
  const wishlistOpenCount = wishlistItems.filter((i) => i.status === "open").length;
578
573
  const data = {
579
574
  schemaVersion: 1,
@@ -690,8 +685,7 @@ export const taskEngineModule = {
690
685
  };
691
686
  }
692
687
  if (command.name === "create-wishlist") {
693
- const wishlistStore = new WishlistStore(ctx.workspacePath, wishlistStorePath(ctx));
694
- await wishlistStore.load();
688
+ const wishlistStore = await planning.openWishlist();
695
689
  const raw = args;
696
690
  const v = validateWishlistIntakePayload(raw);
697
691
  if (!v.ok) {
@@ -717,8 +711,7 @@ export const taskEngineModule = {
717
711
  };
718
712
  }
719
713
  if (command.name === "list-wishlist") {
720
- const wishlistStore = new WishlistStore(ctx.workspacePath, wishlistStorePath(ctx));
721
- await wishlistStore.load();
714
+ const wishlistStore = await planning.openWishlist();
722
715
  const statusFilter = typeof args.status === "string" ? args.status : undefined;
723
716
  let items = wishlistStore.getAllItems();
724
717
  if (statusFilter && ["open", "converted", "cancelled"].includes(statusFilter)) {
@@ -740,8 +733,7 @@ export const taskEngineModule = {
740
733
  if (!wishlistId) {
741
734
  return { ok: false, code: "invalid-task-schema", message: "get-wishlist requires 'wishlistId' or 'id'" };
742
735
  }
743
- const wishlistStore = new WishlistStore(ctx.workspacePath, wishlistStorePath(ctx));
744
- await wishlistStore.load();
736
+ const wishlistStore = await planning.openWishlist();
745
737
  const item = wishlistStore.getItem(wishlistId);
746
738
  if (!item) {
747
739
  return { ok: false, code: "task-not-found", message: `Wishlist item '${wishlistId}' not found` };
@@ -758,8 +750,7 @@ export const taskEngineModule = {
758
750
  if (!wishlistId || !updates) {
759
751
  return { ok: false, code: "invalid-task-schema", message: "update-wishlist requires wishlistId and updates" };
760
752
  }
761
- const wishlistStore = new WishlistStore(ctx.workspacePath, wishlistStorePath(ctx));
762
- await wishlistStore.load();
753
+ const wishlistStore = await planning.openWishlist();
763
754
  const existing = wishlistStore.getItem(wishlistId);
764
755
  if (!existing) {
765
756
  return { ok: false, code: "task-not-found", message: `Wishlist item '${wishlistId}' not found` };
@@ -817,8 +808,7 @@ export const taskEngineModule = {
817
808
  message: "convert-wishlist requires non-empty tasks array"
818
809
  };
819
810
  }
820
- const wishlistStore = new WishlistStore(ctx.workspacePath, wishlistStorePath(ctx));
821
- await wishlistStore.load();
811
+ const wishlistStore = await planning.openWishlist();
822
812
  const wlItem = wishlistStore.getItem(wishlistId);
823
813
  if (!wlItem) {
824
814
  return { ok: false, code: "task-not-found", message: `Wishlist item '${wishlistId}' not found` };
@@ -854,14 +844,6 @@ export const taskEngineModule = {
854
844
  }
855
845
  built.push(bt.task);
856
846
  }
857
- for (const t of built) {
858
- store.addTask(t);
859
- store.addMutationEvidence(mutationEvidence("create-task", t.id, actor, {
860
- initialStatus: t.status,
861
- source: "convert-wishlist",
862
- wishlistId
863
- }));
864
- }
865
847
  const convertedIds = built.map((t) => t.id);
866
848
  const updatedWishlist = {
867
849
  ...wlItem,
@@ -871,9 +853,25 @@ export const taskEngineModule = {
871
853
  convertedToTaskIds: convertedIds,
872
854
  conversionDecomposition: dec.value
873
855
  };
874
- wishlistStore.updateItem(updatedWishlist);
875
- await store.save();
876
- await wishlistStore.save();
856
+ const applyConvertMutations = () => {
857
+ for (const t of built) {
858
+ store.addTask(t);
859
+ store.addMutationEvidence(mutationEvidence("create-task", t.id, actor, {
860
+ initialStatus: t.status,
861
+ source: "convert-wishlist",
862
+ wishlistId
863
+ }));
864
+ }
865
+ wishlistStore.updateItem(updatedWishlist);
866
+ };
867
+ if (planning.kind === "sqlite") {
868
+ planning.sqliteDual.withTransaction(applyConvertMutations);
869
+ }
870
+ else {
871
+ applyConvertMutations();
872
+ await store.save();
873
+ await wishlistStore.save();
874
+ }
877
875
  return {
878
876
  ok: true,
879
877
  code: "wishlist-converted",
@@ -0,0 +1,2 @@
1
+ import type { ModuleCommandResult, ModuleLifecycleContext } from "../../contracts/module-contract.js";
2
+ export declare function runMigrateTaskPersistence(ctx: ModuleLifecycleContext, args: Record<string, unknown>): Promise<ModuleCommandResult>;
@@ -0,0 +1,192 @@
1
+ import fs from "node:fs/promises";
2
+ import fsSync from "node:fs";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { TaskEngineError } from "./transitions.js";
6
+ import { SqliteDualPlanningStore } from "./sqlite-dual-planning.js";
7
+ import { planningSqliteDatabaseRelativePath, planningTaskStoreRelativePath, planningWishlistStoreRelativePath } from "./planning-config.js";
8
+ import { DEFAULT_TASK_STORE_PATH } from "./store.js";
9
+ import { DEFAULT_WISHLIST_PATH } from "./wishlist-store.js";
10
+ function emptyTaskDoc() {
11
+ return {
12
+ schemaVersion: 1,
13
+ tasks: [],
14
+ transitionLog: [],
15
+ mutationLog: [],
16
+ lastUpdated: new Date().toISOString()
17
+ };
18
+ }
19
+ function emptyWishDoc() {
20
+ return {
21
+ schemaVersion: 1,
22
+ items: [],
23
+ lastUpdated: new Date().toISOString()
24
+ };
25
+ }
26
+ async function atomicWriteJson(targetPath, body) {
27
+ const dir = path.dirname(targetPath);
28
+ const tmpPath = `${targetPath}.${crypto.randomUUID().slice(0, 8)}.tmp`;
29
+ await fs.mkdir(dir, { recursive: true });
30
+ await fs.writeFile(tmpPath, body, "utf8");
31
+ await fs.rename(tmpPath, targetPath);
32
+ }
33
+ export async function runMigrateTaskPersistence(ctx, args) {
34
+ const direction = typeof args.direction === "string" ? args.direction.trim() : "";
35
+ if (direction !== "json-to-sqlite" && direction !== "sqlite-to-json") {
36
+ return {
37
+ ok: false,
38
+ code: "invalid-task-schema",
39
+ message: "migrate-task-persistence requires direction: 'json-to-sqlite' | 'sqlite-to-json'"
40
+ };
41
+ }
42
+ const dryRun = args.dryRun === true;
43
+ const force = args.force === true;
44
+ const taskRel = planningTaskStoreRelativePath(ctx) ?? DEFAULT_TASK_STORE_PATH;
45
+ const wishRel = planningWishlistStoreRelativePath(ctx) ?? DEFAULT_WISHLIST_PATH;
46
+ const taskPath = path.resolve(ctx.workspacePath, taskRel);
47
+ const wishPath = path.resolve(ctx.workspacePath, wishRel);
48
+ const dbRel = planningSqliteDatabaseRelativePath(ctx);
49
+ const dual = new SqliteDualPlanningStore(ctx.workspacePath, dbRel);
50
+ if (direction === "json-to-sqlite") {
51
+ if (fsSync.existsSync(dual.dbPath) && !force) {
52
+ return {
53
+ ok: false,
54
+ code: "storage-write-error",
55
+ message: `SQLite database already exists at ${dual.dbPath} (pass force:true to overwrite)`
56
+ };
57
+ }
58
+ let taskDoc = emptyTaskDoc();
59
+ try {
60
+ const raw = await fs.readFile(taskPath, "utf8");
61
+ const parsed = JSON.parse(raw);
62
+ if (parsed.schemaVersion !== 1) {
63
+ throw new TaskEngineError("import-parse-error", `Unsupported task schema ${parsed.schemaVersion}`);
64
+ }
65
+ if (!Array.isArray(parsed.mutationLog)) {
66
+ parsed.mutationLog = [];
67
+ }
68
+ taskDoc = parsed;
69
+ }
70
+ catch (err) {
71
+ if (err.code === "ENOENT") {
72
+ taskDoc = emptyTaskDoc();
73
+ }
74
+ else if (err instanceof TaskEngineError) {
75
+ return { ok: false, code: err.code, message: err.message };
76
+ }
77
+ else {
78
+ return {
79
+ ok: false,
80
+ code: "import-parse-error",
81
+ message: `Failed to read task JSON: ${err.message}`
82
+ };
83
+ }
84
+ }
85
+ let wishDoc = emptyWishDoc();
86
+ try {
87
+ const raw = await fs.readFile(wishPath, "utf8");
88
+ const parsed = JSON.parse(raw);
89
+ if (parsed.schemaVersion !== 1) {
90
+ throw new TaskEngineError("import-parse-error", `Unsupported wishlist schema ${parsed.schemaVersion}`);
91
+ }
92
+ if (!Array.isArray(parsed.items)) {
93
+ throw new TaskEngineError("import-parse-error", "Wishlist items must be an array");
94
+ }
95
+ wishDoc = parsed;
96
+ }
97
+ catch (err) {
98
+ if (err.code === "ENOENT") {
99
+ wishDoc = emptyWishDoc();
100
+ }
101
+ else if (err instanceof TaskEngineError) {
102
+ return { ok: false, code: err.code, message: err.message };
103
+ }
104
+ else {
105
+ return {
106
+ ok: false,
107
+ code: "import-parse-error",
108
+ message: `Failed to read wishlist JSON: ${err.message}`
109
+ };
110
+ }
111
+ }
112
+ if (dryRun) {
113
+ return {
114
+ ok: true,
115
+ code: "migrate-dry-run",
116
+ message: "Dry run: would import JSON task/wishlist documents into SQLite",
117
+ data: {
118
+ dbPath: dual.dbPath,
119
+ taskPath,
120
+ wishPath,
121
+ taskCount: taskDoc.tasks.length,
122
+ wishlistCount: wishDoc.items.length
123
+ }
124
+ };
125
+ }
126
+ dual.seedFromDocuments(taskDoc, wishDoc);
127
+ try {
128
+ dual.persistSync();
129
+ }
130
+ catch (err) {
131
+ return {
132
+ ok: false,
133
+ code: "storage-write-error",
134
+ message: `Failed to write SQLite database: ${err.message}`
135
+ };
136
+ }
137
+ return {
138
+ ok: true,
139
+ code: "migrated-json-to-sqlite",
140
+ message: `Imported task and wishlist JSON into ${dual.dbPath}`,
141
+ data: { dbPath: dual.dbPath, taskPath, wishPath }
142
+ };
143
+ }
144
+ // sqlite-to-json
145
+ if (!fsSync.existsSync(dual.dbPath)) {
146
+ return {
147
+ ok: false,
148
+ code: "storage-read-error",
149
+ message: `SQLite database not found at ${dual.dbPath}`
150
+ };
151
+ }
152
+ dual.loadFromDisk();
153
+ if (!force && (fsSync.existsSync(taskPath) || fsSync.existsSync(wishPath))) {
154
+ return {
155
+ ok: false,
156
+ code: "storage-write-error",
157
+ message: `Target JSON path already exists (task or wishlist); pass force:true to overwrite`,
158
+ data: { taskPath, wishPath }
159
+ };
160
+ }
161
+ if (dryRun) {
162
+ return {
163
+ ok: true,
164
+ code: "migrate-dry-run",
165
+ message: "Dry run: would export SQLite documents to JSON files",
166
+ data: {
167
+ dbPath: dual.dbPath,
168
+ taskPath,
169
+ wishPath,
170
+ taskCount: dual.taskDocument.tasks.length,
171
+ wishlistCount: dual.wishlistDocument.items.length
172
+ }
173
+ };
174
+ }
175
+ try {
176
+ await atomicWriteJson(taskPath, JSON.stringify(dual.taskDocument, null, 2) + "\n");
177
+ await atomicWriteJson(wishPath, JSON.stringify(dual.wishlistDocument, null, 2) + "\n");
178
+ }
179
+ catch (err) {
180
+ return {
181
+ ok: false,
182
+ code: "storage-write-error",
183
+ message: `Failed to write JSON export: ${err.message}`
184
+ };
185
+ }
186
+ return {
187
+ ok: true,
188
+ code: "migrated-sqlite-to-json",
189
+ message: `Exported SQLite planning state to ${taskPath} and ${wishPath}`,
190
+ data: { dbPath: dual.dbPath, taskPath, wishPath }
191
+ };
192
+ }
@@ -0,0 +1,10 @@
1
+ import type { ModuleLifecycleContext } from "../../contracts/module-contract.js";
2
+ export type TaskPersistenceBackend = "json" | "sqlite";
3
+ export declare function getTaskPersistenceBackend(config: Record<string, unknown> | undefined): TaskPersistenceBackend;
4
+ export declare function planningTaskStoreRelativePath(ctx: {
5
+ effectiveConfig?: Record<string, unknown>;
6
+ }): string | undefined;
7
+ export declare function planningWishlistStoreRelativePath(ctx: {
8
+ effectiveConfig?: Record<string, unknown>;
9
+ }): string | undefined;
10
+ export declare function planningSqliteDatabaseRelativePath(ctx: ModuleLifecycleContext): string;
@@ -0,0 +1,37 @@
1
+ export function getTaskPersistenceBackend(config) {
2
+ const tasks = config?.tasks;
3
+ if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) {
4
+ return "json";
5
+ }
6
+ const b = tasks.persistenceBackend;
7
+ if (b === "sqlite") {
8
+ return "sqlite";
9
+ }
10
+ return "json";
11
+ }
12
+ export function planningTaskStoreRelativePath(ctx) {
13
+ const tasks = ctx.effectiveConfig?.tasks;
14
+ if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) {
15
+ return undefined;
16
+ }
17
+ const p = tasks.storeRelativePath;
18
+ return typeof p === "string" && p.trim().length > 0 ? p.trim() : undefined;
19
+ }
20
+ export function planningWishlistStoreRelativePath(ctx) {
21
+ const tasks = ctx.effectiveConfig?.tasks;
22
+ if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) {
23
+ return undefined;
24
+ }
25
+ const p = tasks.wishlistStoreRelativePath;
26
+ return typeof p === "string" && p.trim().length > 0 ? p.trim() : undefined;
27
+ }
28
+ export function planningSqliteDatabaseRelativePath(ctx) {
29
+ const tasks = ctx.effectiveConfig?.tasks;
30
+ if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) {
31
+ return ".workspace-kit/tasks/workspace-kit.db";
32
+ }
33
+ const p = tasks.sqliteDatabaseRelativePath;
34
+ return typeof p === "string" && p.trim().length > 0
35
+ ? p.trim()
36
+ : ".workspace-kit/tasks/workspace-kit.db";
37
+ }
@@ -0,0 +1,16 @@
1
+ import type { ModuleLifecycleContext } from "../../contracts/module-contract.js";
2
+ import { TaskStore } from "./store.js";
3
+ import { WishlistStore } from "./wishlist-store.js";
4
+ import { SqliteDualPlanningStore } from "./sqlite-dual-planning.js";
5
+ export type OpenedPlanningStores = {
6
+ kind: "json";
7
+ taskStore: TaskStore;
8
+ sqliteDual: null;
9
+ openWishlist: () => Promise<WishlistStore>;
10
+ } | {
11
+ kind: "sqlite";
12
+ taskStore: TaskStore;
13
+ sqliteDual: SqliteDualPlanningStore;
14
+ openWishlist: () => Promise<WishlistStore>;
15
+ };
16
+ export declare function openPlanningStores(ctx: ModuleLifecycleContext): Promise<OpenedPlanningStores>;
@@ -0,0 +1,34 @@
1
+ import { TaskStore } from "./store.js";
2
+ import { WishlistStore } from "./wishlist-store.js";
3
+ import { SqliteDualPlanningStore } from "./sqlite-dual-planning.js";
4
+ import { getTaskPersistenceBackend, planningSqliteDatabaseRelativePath, planningTaskStoreRelativePath, planningWishlistStoreRelativePath } from "./planning-config.js";
5
+ export async function openPlanningStores(ctx) {
6
+ if (getTaskPersistenceBackend(ctx.effectiveConfig) === "sqlite") {
7
+ const dual = new SqliteDualPlanningStore(ctx.workspacePath, planningSqliteDatabaseRelativePath(ctx));
8
+ dual.loadFromDisk();
9
+ const taskStore = TaskStore.forSqliteDual(dual);
10
+ await taskStore.load(); // binds task document reference from dual
11
+ return {
12
+ kind: "sqlite",
13
+ sqliteDual: dual,
14
+ taskStore,
15
+ openWishlist: async () => {
16
+ const w = WishlistStore.forSqliteDual(dual);
17
+ await w.load();
18
+ return w;
19
+ }
20
+ };
21
+ }
22
+ const taskStore = TaskStore.forJsonFile(ctx.workspacePath, planningTaskStoreRelativePath(ctx));
23
+ await taskStore.load();
24
+ return {
25
+ kind: "json",
26
+ sqliteDual: null,
27
+ taskStore,
28
+ openWishlist: async () => {
29
+ const w = WishlistStore.forJsonFile(ctx.workspacePath, planningWishlistStoreRelativePath(ctx));
30
+ await w.load();
31
+ return w;
32
+ }
33
+ };
34
+ }
@@ -0,0 +1,21 @@
1
+ import type { TaskStoreDocument } from "./types.js";
2
+ import type { WishlistStoreDocument } from "./wishlist-types.js";
3
+ /** Single-file SQLite backing for task + wishlist JSON documents (atomic convert-wishlist). */
4
+ export declare class SqliteDualPlanningStore {
5
+ private db;
6
+ readonly dbPath: string;
7
+ private _taskDoc;
8
+ private _wishlistDoc;
9
+ constructor(workspacePath: string, databaseRelativePath: string);
10
+ get taskDocument(): TaskStoreDocument;
11
+ get wishlistDocument(): WishlistStoreDocument;
12
+ getDisplayPath(): string;
13
+ private ensureDb;
14
+ /** Load documents from an existing database file; otherwise start empty (no file created). */
15
+ loadFromDisk(): void;
16
+ /** Replace in-memory documents (used by migrate). */
17
+ seedFromDocuments(taskDoc: TaskStoreDocument, wishlistDoc: WishlistStoreDocument): void;
18
+ persistSync(): void;
19
+ /** Run synchronous work inside one SQLite transaction and flush both blobs at the end. */
20
+ withTransaction(work: () => void): void;
21
+ }
@@ -0,0 +1,137 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import Database from "better-sqlite3";
4
+ import { TaskEngineError } from "./transitions.js";
5
+ const DDL = `
6
+ CREATE TABLE IF NOT EXISTS workspace_planning_state (
7
+ id INTEGER PRIMARY KEY CHECK (id = 1),
8
+ task_store_json TEXT NOT NULL,
9
+ wishlist_store_json TEXT NOT NULL
10
+ );
11
+ `;
12
+ function emptyTaskStoreDocument() {
13
+ return {
14
+ schemaVersion: 1,
15
+ tasks: [],
16
+ transitionLog: [],
17
+ mutationLog: [],
18
+ lastUpdated: new Date().toISOString()
19
+ };
20
+ }
21
+ function emptyWishlistDocument() {
22
+ return {
23
+ schemaVersion: 1,
24
+ items: [],
25
+ lastUpdated: new Date().toISOString()
26
+ };
27
+ }
28
+ /** Single-file SQLite backing for task + wishlist JSON documents (atomic convert-wishlist). */
29
+ export class SqliteDualPlanningStore {
30
+ db = null;
31
+ dbPath;
32
+ _taskDoc;
33
+ _wishlistDoc;
34
+ constructor(workspacePath, databaseRelativePath) {
35
+ this.dbPath = path.resolve(workspacePath, databaseRelativePath);
36
+ this._taskDoc = emptyTaskStoreDocument();
37
+ this._wishlistDoc = emptyWishlistDocument();
38
+ }
39
+ get taskDocument() {
40
+ return this._taskDoc;
41
+ }
42
+ get wishlistDocument() {
43
+ return this._wishlistDoc;
44
+ }
45
+ getDisplayPath() {
46
+ return this.dbPath;
47
+ }
48
+ ensureDb() {
49
+ if (!this.db) {
50
+ const dir = path.dirname(this.dbPath);
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ this.db = new Database(this.dbPath);
53
+ this.db.pragma("journal_mode = WAL");
54
+ this.db.exec(DDL);
55
+ }
56
+ return this.db;
57
+ }
58
+ /** Load documents from an existing database file; otherwise start empty (no file created). */
59
+ loadFromDisk() {
60
+ if (!fs.existsSync(this.dbPath)) {
61
+ this._taskDoc = emptyTaskStoreDocument();
62
+ this._wishlistDoc = emptyWishlistDocument();
63
+ return;
64
+ }
65
+ const db = this.ensureDb();
66
+ const row = db
67
+ .prepare("SELECT task_store_json, wishlist_store_json FROM workspace_planning_state WHERE id = 1")
68
+ .get();
69
+ if (!row) {
70
+ this._taskDoc = emptyTaskStoreDocument();
71
+ this._wishlistDoc = emptyWishlistDocument();
72
+ return;
73
+ }
74
+ try {
75
+ const taskParsed = JSON.parse(row.task_store_json);
76
+ const wishParsed = JSON.parse(row.wishlist_store_json);
77
+ if (taskParsed.schemaVersion !== 1) {
78
+ throw new TaskEngineError("storage-read-error", `Unsupported task store schema in SQLite: ${taskParsed.schemaVersion}`);
79
+ }
80
+ if (wishParsed.schemaVersion !== 1) {
81
+ throw new TaskEngineError("storage-read-error", `Unsupported wishlist schema in SQLite: ${wishParsed.schemaVersion}`);
82
+ }
83
+ if (!Array.isArray(taskParsed.mutationLog)) {
84
+ taskParsed.mutationLog = [];
85
+ }
86
+ if (!Array.isArray(wishParsed.items)) {
87
+ throw new TaskEngineError("storage-read-error", "Wishlist items missing in SQLite row");
88
+ }
89
+ this._taskDoc = taskParsed;
90
+ this._wishlistDoc = wishParsed;
91
+ }
92
+ catch (err) {
93
+ if (err instanceof TaskEngineError) {
94
+ throw err;
95
+ }
96
+ throw new TaskEngineError("storage-read-error", `Failed to parse SQLite planning row: ${err.message}`);
97
+ }
98
+ }
99
+ /** Replace in-memory documents (used by migrate). */
100
+ seedFromDocuments(taskDoc, wishlistDoc) {
101
+ this._taskDoc = taskDoc;
102
+ this._wishlistDoc = wishlistDoc;
103
+ }
104
+ persistSync() {
105
+ this._taskDoc.lastUpdated = new Date().toISOString();
106
+ this._wishlistDoc.lastUpdated = new Date().toISOString();
107
+ const db = this.ensureDb();
108
+ const t = JSON.stringify(this._taskDoc);
109
+ const w = JSON.stringify(this._wishlistDoc);
110
+ const exists = db.prepare("SELECT 1 AS ok FROM workspace_planning_state WHERE id = 1").get();
111
+ if (exists) {
112
+ db.prepare("UPDATE workspace_planning_state SET task_store_json = ?, wishlist_store_json = ? WHERE id = 1").run(t, w);
113
+ }
114
+ else {
115
+ db.prepare("INSERT INTO workspace_planning_state (id, task_store_json, wishlist_store_json) VALUES (1, ?, ?)").run(t, w);
116
+ }
117
+ }
118
+ /** Run synchronous work inside one SQLite transaction and flush both blobs at the end. */
119
+ withTransaction(work) {
120
+ const db = this.ensureDb();
121
+ const txn = db.transaction(() => {
122
+ work();
123
+ this._taskDoc.lastUpdated = new Date().toISOString();
124
+ this._wishlistDoc.lastUpdated = new Date().toISOString();
125
+ const t = JSON.stringify(this._taskDoc);
126
+ const w = JSON.stringify(this._wishlistDoc);
127
+ const exists = db.prepare("SELECT 1 AS ok FROM workspace_planning_state WHERE id = 1").get();
128
+ if (exists) {
129
+ db.prepare("UPDATE workspace_planning_state SET task_store_json = ?, wishlist_store_json = ? WHERE id = 1").run(t, w);
130
+ }
131
+ else {
132
+ db.prepare("INSERT INTO workspace_planning_state (id, task_store_json, wishlist_store_json) VALUES (1, ?, ?)").run(t, w);
133
+ }
134
+ });
135
+ txn();
136
+ }
137
+ }
@@ -1,8 +1,17 @@
1
- import type { TaskEntity, TaskMutationEvidence, TransitionEvidence } from "./types.js";
1
+ import type { TaskEntity, TaskMutationEvidence, TaskStoreDocument, TransitionEvidence } from "./types.js";
2
+ import type { SqliteDualPlanningStore } from "./sqlite-dual-planning.js";
3
+ export declare const DEFAULT_TASK_STORE_PATH = ".workspace-kit/tasks/state.json";
4
+ export type TaskStorePersistence = {
5
+ loadDocument: () => Promise<TaskStoreDocument>;
6
+ saveDocument: (doc: TaskStoreDocument) => Promise<void>;
7
+ pathLabel: string;
8
+ };
2
9
  export declare class TaskStore {
3
10
  private document;
4
- private readonly filePath;
5
- constructor(workspacePath: string, storePath?: string);
11
+ private readonly persistence;
12
+ constructor(persistence: TaskStorePersistence);
13
+ static forJsonFile(workspacePath: string, storeRelativePath?: string): TaskStore;
14
+ static forSqliteDual(dual: SqliteDualPlanningStore): TaskStore;
6
15
  load(): Promise<void>;
7
16
  save(): Promise<void>;
8
17
  getAllTasks(): TaskEntity[];