@workflow-cannon/workspace-kit 0.14.0 → 0.16.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.
Files changed (28) hide show
  1. package/README.md +55 -106
  2. package/dist/modules/approvals/review-runtime.js +3 -11
  3. package/dist/modules/improvement/generate-recommendations-runtime.js +3 -11
  4. package/dist/modules/index.d.ts +2 -0
  5. package/dist/modules/index.js +1 -0
  6. package/dist/modules/task-engine/index.d.ts +5 -0
  7. package/dist/modules/task-engine/index.js +327 -17
  8. package/dist/modules/task-engine/migrate-task-persistence-runtime.d.ts +2 -0
  9. package/dist/modules/task-engine/migrate-task-persistence-runtime.js +192 -0
  10. package/dist/modules/task-engine/planning-config.d.ts +10 -0
  11. package/dist/modules/task-engine/planning-config.js +37 -0
  12. package/dist/modules/task-engine/planning-open.d.ts +16 -0
  13. package/dist/modules/task-engine/planning-open.js +34 -0
  14. package/dist/modules/task-engine/sqlite-dual-planning.d.ts +21 -0
  15. package/dist/modules/task-engine/sqlite-dual-planning.js +137 -0
  16. package/dist/modules/task-engine/store.d.ts +12 -3
  17. package/dist/modules/task-engine/store.js +62 -38
  18. package/dist/modules/task-engine/wishlist-store.d.ts +23 -0
  19. package/dist/modules/task-engine/wishlist-store.js +108 -0
  20. package/dist/modules/task-engine/wishlist-types.d.ts +33 -0
  21. package/dist/modules/task-engine/wishlist-types.js +5 -0
  22. package/dist/modules/task-engine/wishlist-validation.d.ts +16 -0
  23. package/dist/modules/task-engine/wishlist-validation.js +96 -0
  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,23 +1,21 @@
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";
7
+ import { openPlanningStores } from "./planning-open.js";
8
+ import { runMigrateTaskPersistence } from "./migrate-task-persistence-runtime.js";
9
+ import { buildWishlistItemFromIntake, validateWishlistIntakePayload, validateWishlistUpdatePayload, WISHLIST_ID_RE } from "./wishlist-validation.js";
8
10
  export { TaskStore } from "./store.js";
9
11
  export { TransitionService } from "./service.js";
10
12
  export { TaskEngineError, TransitionValidator, isTransitionAllowed, getTransitionAction, resolveTargetState, getAllowedTransitionsFrom, stateValidityGuard, dependencyCheckGuard } from "./transitions.js";
11
13
  export { getNextActions } from "./suggestions.js";
12
14
  export { readWorkspaceStatusSnapshot } from "./dashboard-status.js";
13
- function taskStorePath(ctx) {
14
- const tasks = ctx.effectiveConfig?.tasks;
15
- if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) {
16
- return undefined;
17
- }
18
- const p = tasks.storeRelativePath;
19
- return typeof p === "string" && p.trim().length > 0 ? p.trim() : undefined;
20
- }
15
+ export { WishlistStore } from "./wishlist-store.js";
16
+ export { validateWishlistIntakePayload, validateWishlistUpdatePayload, buildWishlistItemFromIntake, WISHLIST_ID_RE } from "./wishlist-validation.js";
17
+ export { openPlanningStores } from "./planning-open.js";
18
+ export { getTaskPersistenceBackend, planningSqliteDatabaseRelativePath, planningTaskStoreRelativePath, planningWishlistStoreRelativePath } from "./planning-config.js";
21
19
  const TASK_ID_RE = /^T\d+$/;
22
20
  const MUTABLE_TASK_FIELDS = new Set([
23
21
  "title",
@@ -35,6 +33,72 @@ const MUTABLE_TASK_FIELDS = new Set([
35
33
  function nowIso() {
36
34
  return new Date().toISOString();
37
35
  }
36
+ function parseConversionDecomposition(raw) {
37
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
38
+ return { ok: false, message: "convert-wishlist requires 'decomposition' object" };
39
+ }
40
+ const o = raw;
41
+ const rationale = typeof o.rationale === "string" ? o.rationale.trim() : "";
42
+ const boundaries = typeof o.boundaries === "string" ? o.boundaries.trim() : "";
43
+ const dependencyIntent = typeof o.dependencyIntent === "string" ? o.dependencyIntent.trim() : "";
44
+ if (!rationale || !boundaries || !dependencyIntent) {
45
+ return {
46
+ ok: false,
47
+ message: "decomposition requires non-empty rationale, boundaries, and dependencyIntent"
48
+ };
49
+ }
50
+ return { ok: true, value: { rationale, boundaries, dependencyIntent } };
51
+ }
52
+ function buildTaskFromConversionPayload(row, timestamp) {
53
+ const id = typeof row.id === "string" ? row.id.trim() : "";
54
+ if (!TASK_ID_RE.test(id)) {
55
+ return { ok: false, message: "Each converted task requires 'id' matching T<number>" };
56
+ }
57
+ const title = typeof row.title === "string" ? row.title.trim() : "";
58
+ if (!title) {
59
+ return { ok: false, message: `Task '${id}' requires non-empty title` };
60
+ }
61
+ const phase = typeof row.phase === "string" ? row.phase.trim() : "";
62
+ if (!phase) {
63
+ return { ok: false, message: `Task '${id}' requires 'phase' for workable tasks` };
64
+ }
65
+ const type = typeof row.type === "string" && row.type.trim() ? row.type.trim() : "workspace-kit";
66
+ const priority = typeof row.priority === "string" && ["P1", "P2", "P3"].includes(row.priority)
67
+ ? row.priority
68
+ : undefined;
69
+ const approach = typeof row.approach === "string" ? row.approach.trim() : "";
70
+ if (!approach) {
71
+ return { ok: false, message: `Task '${id}' requires 'approach'` };
72
+ }
73
+ const technicalScope = Array.isArray(row.technicalScope)
74
+ ? row.technicalScope.filter((x) => typeof x === "string")
75
+ : [];
76
+ const acceptanceCriteria = Array.isArray(row.acceptanceCriteria)
77
+ ? row.acceptanceCriteria.filter((x) => typeof x === "string")
78
+ : [];
79
+ if (technicalScope.length === 0) {
80
+ return { ok: false, message: `Task '${id}' requires non-empty technicalScope array` };
81
+ }
82
+ if (acceptanceCriteria.length === 0) {
83
+ return { ok: false, message: `Task '${id}' requires non-empty acceptanceCriteria array` };
84
+ }
85
+ const task = {
86
+ id,
87
+ title,
88
+ type,
89
+ status: "proposed",
90
+ createdAt: timestamp,
91
+ updatedAt: timestamp,
92
+ priority,
93
+ dependsOn: Array.isArray(row.dependsOn) ? row.dependsOn.filter((x) => typeof x === "string") : undefined,
94
+ unblocks: Array.isArray(row.unblocks) ? row.unblocks.filter((x) => typeof x === "string") : undefined,
95
+ phase,
96
+ approach,
97
+ technicalScope,
98
+ acceptanceCriteria
99
+ };
100
+ return { ok: true, task };
101
+ }
38
102
  function mutationEvidence(mutationType, taskId, actor, details) {
39
103
  return {
40
104
  mutationId: `${mutationType}-${taskId}-${nowIso()}-${crypto.randomUUID().slice(0, 8)}`,
@@ -48,7 +112,7 @@ function mutationEvidence(mutationType, taskId, actor, details) {
48
112
  export const taskEngineModule = {
49
113
  registration: {
50
114
  id: "task-engine",
51
- version: "0.5.0",
115
+ version: "0.6.0",
52
116
  contractVersion: "1",
53
117
  capabilities: ["task-engine"],
54
118
  dependsOn: [],
@@ -81,6 +145,11 @@ export const taskEngineModule = {
81
145
  file: "update-task.md",
82
146
  description: "Update mutable task fields without lifecycle bypass."
83
147
  },
148
+ {
149
+ name: "update-wishlist",
150
+ file: "update-wishlist.md",
151
+ description: "Update mutable fields on an open Wishlist item."
152
+ },
84
153
  {
85
154
  name: "archive-task",
86
155
  file: "archive-task.md",
@@ -126,6 +195,21 @@ export const taskEngineModule = {
126
195
  file: "create-task-from-plan.md",
127
196
  description: "Promote planning output into a canonical task."
128
197
  },
198
+ {
199
+ name: "convert-wishlist",
200
+ file: "convert-wishlist.md",
201
+ description: "Convert a Wishlist item into one or more phased tasks and close the wishlist item."
202
+ },
203
+ {
204
+ name: "create-wishlist",
205
+ file: "create-wishlist.md",
206
+ description: "Create a Wishlist ideation item with strict required fields (separate namespace from tasks)."
207
+ },
208
+ {
209
+ name: "get-wishlist",
210
+ file: "get-wishlist.md",
211
+ description: "Retrieve a single Wishlist item by ID."
212
+ },
129
213
  {
130
214
  name: "get-task",
131
215
  file: "get-task.md",
@@ -136,6 +220,11 @@ export const taskEngineModule = {
136
220
  file: "list-tasks.md",
137
221
  description: "List tasks with optional status/phase filters."
138
222
  },
223
+ {
224
+ name: "list-wishlist",
225
+ file: "list-wishlist.md",
226
+ description: "List Wishlist items (ideation-only; not part of task execution queues)."
227
+ },
139
228
  {
140
229
  name: "get-ready-queue",
141
230
  file: "get-ready-queue.md",
@@ -146,6 +235,11 @@ export const taskEngineModule = {
146
235
  file: "get-next-actions.md",
147
236
  description: "Get prioritized next-action suggestions with blocking analysis."
148
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
+ },
149
243
  {
150
244
  name: "dashboard-summary",
151
245
  file: "dashboard-summary.md",
@@ -156,9 +250,12 @@ export const taskEngineModule = {
156
250
  },
157
251
  async onCommand(command, ctx) {
158
252
  const args = command.args ?? {};
159
- const store = new TaskStore(ctx.workspacePath, taskStorePath(ctx));
253
+ if (command.name === "migrate-task-persistence") {
254
+ return runMigrateTaskPersistence(ctx, args);
255
+ }
256
+ let planning;
160
257
  try {
161
- await store.load();
258
+ planning = await openPlanningStores(ctx);
162
259
  }
163
260
  catch (err) {
164
261
  if (err instanceof TaskEngineError) {
@@ -167,9 +264,10 @@ export const taskEngineModule = {
167
264
  return {
168
265
  ok: false,
169
266
  code: "storage-read-error",
170
- message: `Failed to load task store: ${err.message}`
267
+ message: `Failed to open task planning stores: ${err.message}`
171
268
  };
172
269
  }
270
+ const store = planning.taskStore;
173
271
  if (command.name === "run-transition") {
174
272
  const taskId = typeof args.taskId === "string" ? args.taskId : undefined;
175
273
  const action = typeof args.action === "string" ? args.action : undefined;
@@ -463,6 +561,15 @@ export const taskEngineModule = {
463
561
  phase: t.phase ?? null
464
562
  }));
465
563
  const blockedTop = suggestion.blockingAnalysis.slice(0, 15);
564
+ let wishlistItems = [];
565
+ try {
566
+ const wishlistStore = await planning.openWishlist();
567
+ wishlistItems = wishlistStore.getAllItems();
568
+ }
569
+ catch {
570
+ /* wishlist store optional */
571
+ }
572
+ const wishlistOpenCount = wishlistItems.filter((i) => i.status === "open").length;
466
573
  const data = {
467
574
  schemaVersion: 1,
468
575
  taskStoreLastUpdated: store.getLastUpdated(),
@@ -470,6 +577,12 @@ export const taskEngineModule = {
470
577
  stateSummary: suggestion.stateSummary,
471
578
  readyQueueTop: readyTop,
472
579
  readyQueueCount: suggestion.readyQueue.length,
580
+ executionPlanningScope: "tasks-only",
581
+ wishlist: {
582
+ schemaVersion: 1,
583
+ openCount: wishlistOpenCount,
584
+ totalCount: wishlistItems.length
585
+ },
473
586
  blockedSummary: {
474
587
  count: suggestion.blockingAnalysis.length,
475
588
  top: blockedTop
@@ -507,7 +620,7 @@ export const taskEngineModule = {
507
620
  ok: true,
508
621
  code: "tasks-listed",
509
622
  message: `Found ${tasks.length} tasks`,
510
- data: { tasks, count: tasks.length }
623
+ data: { tasks, count: tasks.length, scope: "tasks-only" }
511
624
  };
512
625
  }
513
626
  if (command.name === "get-ready-queue") {
@@ -523,7 +636,7 @@ export const taskEngineModule = {
523
636
  ok: true,
524
637
  code: "ready-queue-retrieved",
525
638
  message: `${ready.length} tasks in ready queue`,
526
- data: { tasks: ready, count: ready.length }
639
+ data: { tasks: ready, count: ready.length, scope: "tasks-only" }
527
640
  };
528
641
  }
529
642
  if (command.name === "get-next-actions") {
@@ -535,7 +648,7 @@ export const taskEngineModule = {
535
648
  message: suggestion.suggestedNext
536
649
  ? `Suggested next: ${suggestion.suggestedNext.id} — ${suggestion.suggestedNext.title}`
537
650
  : "No tasks in ready queue",
538
- data: suggestion
651
+ data: { ...suggestion, scope: "tasks-only" }
539
652
  };
540
653
  }
541
654
  if (command.name === "get-task-summary") {
@@ -545,6 +658,7 @@ export const taskEngineModule = {
545
658
  ok: true,
546
659
  code: "task-summary",
547
660
  data: {
661
+ scope: "tasks-only",
548
662
  stateSummary: suggestion.stateSummary,
549
663
  readyQueueCount: suggestion.readyQueue.length,
550
664
  suggestedNext: suggestion.suggestedNext
@@ -565,8 +679,204 @@ export const taskEngineModule = {
565
679
  code: "blocked-summary",
566
680
  data: {
567
681
  blockedCount: suggestion.blockingAnalysis.length,
568
- blockedItems: suggestion.blockingAnalysis
682
+ blockedItems: suggestion.blockingAnalysis,
683
+ scope: "tasks-only"
684
+ }
685
+ };
686
+ }
687
+ if (command.name === "create-wishlist") {
688
+ const wishlistStore = await planning.openWishlist();
689
+ const raw = args;
690
+ const v = validateWishlistIntakePayload(raw);
691
+ if (!v.ok) {
692
+ return { ok: false, code: "invalid-task-schema", message: v.errors.join(" ") };
693
+ }
694
+ const ts = nowIso();
695
+ const item = buildWishlistItemFromIntake(raw, ts);
696
+ try {
697
+ wishlistStore.addItem(item);
698
+ }
699
+ catch (err) {
700
+ if (err instanceof TaskEngineError) {
701
+ return { ok: false, code: err.code, message: err.message };
569
702
  }
703
+ throw err;
704
+ }
705
+ await wishlistStore.save();
706
+ return {
707
+ ok: true,
708
+ code: "wishlist-created",
709
+ message: `Created wishlist '${item.id}'`,
710
+ data: { item }
711
+ };
712
+ }
713
+ if (command.name === "list-wishlist") {
714
+ const wishlistStore = await planning.openWishlist();
715
+ const statusFilter = typeof args.status === "string" ? args.status : undefined;
716
+ let items = wishlistStore.getAllItems();
717
+ if (statusFilter && ["open", "converted", "cancelled"].includes(statusFilter)) {
718
+ items = items.filter((i) => i.status === statusFilter);
719
+ }
720
+ return {
721
+ ok: true,
722
+ code: "wishlist-listed",
723
+ message: `Found ${items.length} wishlist items`,
724
+ data: { items, count: items.length, scope: "wishlist-only" }
725
+ };
726
+ }
727
+ if (command.name === "get-wishlist") {
728
+ const wishlistId = typeof args.wishlistId === "string" && args.wishlistId.trim().length > 0
729
+ ? args.wishlistId.trim()
730
+ : typeof args.id === "string" && args.id.trim().length > 0
731
+ ? args.id.trim()
732
+ : "";
733
+ if (!wishlistId) {
734
+ return { ok: false, code: "invalid-task-schema", message: "get-wishlist requires 'wishlistId' or 'id'" };
735
+ }
736
+ const wishlistStore = await planning.openWishlist();
737
+ const item = wishlistStore.getItem(wishlistId);
738
+ if (!item) {
739
+ return { ok: false, code: "task-not-found", message: `Wishlist item '${wishlistId}' not found` };
740
+ }
741
+ return {
742
+ ok: true,
743
+ code: "wishlist-retrieved",
744
+ data: { item }
745
+ };
746
+ }
747
+ if (command.name === "update-wishlist") {
748
+ const wishlistId = typeof args.wishlistId === "string" ? args.wishlistId.trim() : "";
749
+ const updates = typeof args.updates === "object" && args.updates !== null ? args.updates : undefined;
750
+ if (!wishlistId || !updates) {
751
+ return { ok: false, code: "invalid-task-schema", message: "update-wishlist requires wishlistId and updates" };
752
+ }
753
+ const wishlistStore = await planning.openWishlist();
754
+ const existing = wishlistStore.getItem(wishlistId);
755
+ if (!existing) {
756
+ return { ok: false, code: "task-not-found", message: `Wishlist item '${wishlistId}' not found` };
757
+ }
758
+ if (existing.status !== "open") {
759
+ return { ok: false, code: "invalid-transition", message: "Only open wishlist items can be updated" };
760
+ }
761
+ const uv = validateWishlistUpdatePayload(updates);
762
+ if (!uv.ok) {
763
+ return { ok: false, code: "invalid-task-schema", message: uv.errors.join(" ") };
764
+ }
765
+ const merged = { ...existing, updatedAt: nowIso() };
766
+ const mutable = [
767
+ "title",
768
+ "problemStatement",
769
+ "expectedOutcome",
770
+ "impact",
771
+ "constraints",
772
+ "successSignals",
773
+ "requestor",
774
+ "evidenceRef"
775
+ ];
776
+ for (const key of mutable) {
777
+ if (key in updates && typeof updates[key] === "string") {
778
+ merged[key] = updates[key].trim();
779
+ }
780
+ }
781
+ wishlistStore.updateItem(merged);
782
+ await wishlistStore.save();
783
+ return {
784
+ ok: true,
785
+ code: "wishlist-updated",
786
+ message: `Updated wishlist '${wishlistId}'`,
787
+ data: { item: merged }
788
+ };
789
+ }
790
+ if (command.name === "convert-wishlist") {
791
+ const wishlistId = typeof args.wishlistId === "string" ? args.wishlistId.trim() : "";
792
+ if (!wishlistId || !WISHLIST_ID_RE.test(wishlistId)) {
793
+ return {
794
+ ok: false,
795
+ code: "invalid-task-schema",
796
+ message: "convert-wishlist requires wishlistId matching W<number>"
797
+ };
798
+ }
799
+ const dec = parseConversionDecomposition(args.decomposition);
800
+ if (!dec.ok) {
801
+ return { ok: false, code: "invalid-task-schema", message: dec.message };
802
+ }
803
+ const tasksRaw = args.tasks;
804
+ if (!Array.isArray(tasksRaw) || tasksRaw.length === 0) {
805
+ return {
806
+ ok: false,
807
+ code: "invalid-task-schema",
808
+ message: "convert-wishlist requires non-empty tasks array"
809
+ };
810
+ }
811
+ const wishlistStore = await planning.openWishlist();
812
+ const wlItem = wishlistStore.getItem(wishlistId);
813
+ if (!wlItem) {
814
+ return { ok: false, code: "task-not-found", message: `Wishlist item '${wishlistId}' not found` };
815
+ }
816
+ if (wlItem.status !== "open") {
817
+ return {
818
+ ok: false,
819
+ code: "invalid-transition",
820
+ message: "Only open wishlist items can be converted"
821
+ };
822
+ }
823
+ const actor = typeof args.actor === "string"
824
+ ? args.actor
825
+ : ctx.resolvedActor !== undefined
826
+ ? ctx.resolvedActor
827
+ : undefined;
828
+ const timestamp = nowIso();
829
+ const built = [];
830
+ for (const row of tasksRaw) {
831
+ if (!row || typeof row !== "object" || Array.isArray(row)) {
832
+ return { ok: false, code: "invalid-task-schema", message: "Each task must be an object" };
833
+ }
834
+ const bt = buildTaskFromConversionPayload(row, timestamp);
835
+ if (!bt.ok) {
836
+ return { ok: false, code: "invalid-task-schema", message: bt.message };
837
+ }
838
+ if (store.getTask(bt.task.id)) {
839
+ return {
840
+ ok: false,
841
+ code: "duplicate-task-id",
842
+ message: `Task '${bt.task.id}' already exists`
843
+ };
844
+ }
845
+ built.push(bt.task);
846
+ }
847
+ const convertedIds = built.map((t) => t.id);
848
+ const updatedWishlist = {
849
+ ...wlItem,
850
+ status: "converted",
851
+ updatedAt: timestamp,
852
+ convertedAt: timestamp,
853
+ convertedToTaskIds: convertedIds,
854
+ conversionDecomposition: dec.value
855
+ };
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
+ }
875
+ return {
876
+ ok: true,
877
+ code: "wishlist-converted",
878
+ message: `Converted wishlist '${wishlistId}' to tasks: ${convertedIds.join(", ")}`,
879
+ data: { wishlist: updatedWishlist, createdTasks: built }
570
880
  };
571
881
  }
572
882
  return {
@@ -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;