ndomo 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.
Files changed (247) hide show
  1. package/.bun-version +1 -0
  2. package/.dockerignore +79 -0
  3. package/.editorconfig +18 -0
  4. package/.env.example +19 -0
  5. package/.github/CODEOWNERS +8 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +2 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +34 -0
  9. package/.github/dependabot.yml +36 -0
  10. package/.github/pull_request_template.md +24 -0
  11. package/.github/release.yml +30 -0
  12. package/.github/workflows/gitleaks.yml +28 -0
  13. package/.github/workflows/release-please.yml +27 -0
  14. package/.github/workflows/smoke.yml +29 -0
  15. package/.husky/commit-msg +1 -0
  16. package/CHANGELOG.md +114 -0
  17. package/Dockerfile +32 -0
  18. package/README.es.md +174 -0
  19. package/README.md +187 -0
  20. package/agents/chronicler.md +98 -0
  21. package/agents/ci-smith.md +136 -0
  22. package/agents/craftsman.md +341 -0
  23. package/agents/deploy-smith.md +138 -0
  24. package/agents/foreman.md +377 -0
  25. package/agents/go-smith.md +164 -0
  26. package/agents/guild.md +188 -0
  27. package/agents/inspector.md +83 -0
  28. package/agents/js-smith.md +127 -0
  29. package/agents/ops-scout.md +173 -0
  30. package/agents/painter.md +200 -0
  31. package/agents/python-smith.md +120 -0
  32. package/agents/ranger.md +307 -0
  33. package/agents/release-smith.md +165 -0
  34. package/agents/rust-smith.md +159 -0
  35. package/agents/sage.md +178 -0
  36. package/agents/scout.md +144 -0
  37. package/agents/scribe.md +156 -0
  38. package/agents/smith.md +201 -0
  39. package/agents/vue-smith.md +155 -0
  40. package/agents/warden.md +216 -0
  41. package/agents/zig-smith.md +156 -0
  42. package/bin/ndomo-analyses.ts +4 -0
  43. package/bin/ndomo-status.ts +4 -0
  44. package/biome.json +57 -0
  45. package/bun.lock +514 -0
  46. package/commitlint.config.js +3 -0
  47. package/config/ndomo.config.json +258 -0
  48. package/config/ndomo.schema.json +166 -0
  49. package/docs/agents.md +375 -0
  50. package/docs/bugs/plan-create-orphan-fk.md +131 -0
  51. package/docs/bugs/task_create_batch-order-index-collision.md +158 -0
  52. package/docs/configuration.md +276 -0
  53. package/docs/database.md +364 -0
  54. package/docs/features/feature-flexible-builder-v1.md +724 -0
  55. package/docs/features/feature-flexible-builder-v2.md +882 -0
  56. package/docs/features/feature-flexible-builder.md +974 -0
  57. package/docs/http-server.md +244 -0
  58. package/docs/installation.md +259 -0
  59. package/docs/integrations.md +129 -0
  60. package/docs/operations/anti-pattern-sub-agent-verify-2026-06-21.md +32 -0
  61. package/docs/operations/audit-v1.md +417 -0
  62. package/docs/operations/audit-v2.md +197 -0
  63. package/docs/operations/audit-v3.md +306 -0
  64. package/docs/operations/db-optimize-foundations.md +123 -0
  65. package/docs/operations/verify-gate-architecture.md +82 -0
  66. package/docs/workflows.md +448 -0
  67. package/opencode.json +5 -0
  68. package/package.json +65 -0
  69. package/release-please-config.json +11 -0
  70. package/scripts/dev-bust-cache.sh +164 -0
  71. package/scripts/install.sh +688 -0
  72. package/scripts/smoke-e2e.ts +704 -0
  73. package/scripts/smoke-hot.ts +417 -0
  74. package/scripts/smoke-http.sh +228 -0
  75. package/scripts/smoke-v4.ts +256 -0
  76. package/scripts/smoke-v5.ts +397 -0
  77. package/scripts/smoke.sh +9 -0
  78. package/scripts/uninstall.sh +224 -0
  79. package/skills/api-security-best-practices/SKILL.md +915 -0
  80. package/skills/bash-scripting/SKILL.md +201 -0
  81. package/skills/bun/SKILL.md +313 -0
  82. package/skills/cavecrew/SKILL.md +82 -0
  83. package/skills/caveman/SKILL.md +74 -0
  84. package/skills/caveman-review/README.md +33 -0
  85. package/skills/caveman-review/SKILL.md +55 -0
  86. package/skills/find-skills/SKILL.md +142 -0
  87. package/skills/frontend-design/LICENSE.txt +177 -0
  88. package/skills/frontend-design/SKILL.md +55 -0
  89. package/skills/golang-patterns/SKILL.md +674 -0
  90. package/skills/golang-security/SKILL.md +185 -0
  91. package/skills/golang-security/evals/evals.json +595 -0
  92. package/skills/golang-security/references/architecture.md +268 -0
  93. package/skills/golang-security/references/checklist.md +80 -0
  94. package/skills/golang-security/references/cookies.md +200 -0
  95. package/skills/golang-security/references/cryptography.md +424 -0
  96. package/skills/golang-security/references/filesystem.md +285 -0
  97. package/skills/golang-security/references/injection.md +315 -0
  98. package/skills/golang-security/references/logging.md +163 -0
  99. package/skills/golang-security/references/memory-safety.md +241 -0
  100. package/skills/golang-security/references/network.md +253 -0
  101. package/skills/golang-security/references/secrets.md +189 -0
  102. package/skills/golang-security/references/third-party.md +159 -0
  103. package/skills/golang-security/references/threat-modeling.md +189 -0
  104. package/skills/golang-testing/SKILL.md +720 -0
  105. package/skills/grill-me/SKILL.md +7 -0
  106. package/skills/javascript-testing-patterns/SKILL.md +537 -0
  107. package/skills/javascript-testing-patterns/references/advanced-testing-patterns.md +513 -0
  108. package/skills/modern-javascript-patterns/SKILL.md +43 -0
  109. package/skills/modern-javascript-patterns/references/advanced-patterns.md +487 -0
  110. package/skills/modern-javascript-patterns/references/details.md +457 -0
  111. package/skills/python-anti-patterns/SKILL.md +349 -0
  112. package/skills/python-design-patterns/SKILL.md +85 -0
  113. package/skills/python-design-patterns/references/details.md +353 -0
  114. package/skills/python-error-handling/SKILL.md +193 -0
  115. package/skills/python-error-handling/references/details.md +171 -0
  116. package/skills/python-testing-patterns/SKILL.md +278 -0
  117. package/skills/python-testing-patterns/references/advanced-patterns.md +411 -0
  118. package/skills/python-testing-patterns/references/details.md +349 -0
  119. package/skills/rust-patterns/SKILL.md +500 -0
  120. package/skills/rust-testing/SKILL.md +501 -0
  121. package/skills/security-review/SKILL.md +504 -0
  122. package/skills/security-review/cloud-infrastructure-security.md +361 -0
  123. package/skills/vue-best-practices/SKILL.md +154 -0
  124. package/skills/vue-best-practices/references/animation-class-based-technique.md +254 -0
  125. package/skills/vue-best-practices/references/animation-state-driven-technique.md +291 -0
  126. package/skills/vue-best-practices/references/component-async.md +97 -0
  127. package/skills/vue-best-practices/references/component-data-flow.md +307 -0
  128. package/skills/vue-best-practices/references/component-fallthrough-attrs.md +174 -0
  129. package/skills/vue-best-practices/references/component-keep-alive.md +137 -0
  130. package/skills/vue-best-practices/references/component-slots.md +216 -0
  131. package/skills/vue-best-practices/references/component-suspense.md +228 -0
  132. package/skills/vue-best-practices/references/component-teleport.md +108 -0
  133. package/skills/vue-best-practices/references/component-transition-group.md +128 -0
  134. package/skills/vue-best-practices/references/component-transition.md +125 -0
  135. package/skills/vue-best-practices/references/composables.md +290 -0
  136. package/skills/vue-best-practices/references/directives.md +162 -0
  137. package/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +159 -0
  138. package/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +182 -0
  139. package/skills/vue-best-practices/references/perf-virtualize-large-lists.md +187 -0
  140. package/skills/vue-best-practices/references/plugins.md +166 -0
  141. package/skills/vue-best-practices/references/reactivity.md +344 -0
  142. package/skills/vue-best-practices/references/render-functions.md +201 -0
  143. package/skills/vue-best-practices/references/sfc.md +310 -0
  144. package/skills/vue-best-practices/references/state-management.md +135 -0
  145. package/skills/vue-best-practices/references/updated-hook-performance.md +187 -0
  146. package/skills/vue-pinia-best-practices/SKILL.md +21 -0
  147. package/skills/vue-pinia-best-practices/reference/pinia-no-active-pinia-error.md +248 -0
  148. package/skills/vue-pinia-best-practices/reference/pinia-setup-store-return-all-state.md +227 -0
  149. package/skills/vue-pinia-best-practices/reference/pinia-store-destructuring-breaks-reactivity.md +193 -0
  150. package/skills/vue-pinia-best-practices/reference/state-url-for-ephemeral-filters.md +238 -0
  151. package/skills/vue-pinia-best-practices/reference/state-use-pinia-for-large-apps.md +262 -0
  152. package/skills/vue-pinia-best-practices/reference/store-method-binding-parentheses.md +191 -0
  153. package/skills/zig-0.16/SKILL.md +840 -0
  154. package/skills/zig-0.16/scripts/check-zig-version.sh +21 -0
  155. package/src/cli/analyses.ts +280 -0
  156. package/src/cli/index.ts +108 -0
  157. package/src/cli/serve.ts +192 -0
  158. package/src/cli/smoke.ts +131 -0
  159. package/src/cli/status.test.ts +204 -0
  160. package/src/cli/status.ts +263 -0
  161. package/src/cli/vacuum.test.ts +82 -0
  162. package/src/cli/vacuum.ts +96 -0
  163. package/src/config/schema.test.ts +88 -0
  164. package/src/config/schema.ts +64 -0
  165. package/src/db/analyses-migration.test.ts +210 -0
  166. package/src/db/analyses.test.ts +466 -0
  167. package/src/db/analyses.ts +375 -0
  168. package/src/db/auto-checkpoint.ts +131 -0
  169. package/src/db/client.test.ts +129 -0
  170. package/src/db/client.ts +55 -0
  171. package/src/db/fts-escape.ts +20 -0
  172. package/src/db/incidents.test.ts +201 -0
  173. package/src/db/incidents.ts +93 -0
  174. package/src/db/index.ts +86 -0
  175. package/src/db/migrations-v13.test.ts +141 -0
  176. package/src/db/migrations-v8.test.ts +301 -0
  177. package/src/db/migrations.ts +147 -0
  178. package/src/db/plan-archive.test.ts +180 -0
  179. package/src/db/plan-archive.ts +274 -0
  180. package/src/db/plan-create.test.ts +276 -0
  181. package/src/db/plan-create.ts +78 -0
  182. package/src/db/plan-files.test.ts +289 -0
  183. package/src/db/plan-update-status.ts +287 -0
  184. package/src/db/plans.test.ts +490 -0
  185. package/src/db/plans.ts +534 -0
  186. package/src/db/resolve-project-dir.test.ts +143 -0
  187. package/src/db/resolve-project-dir.ts +75 -0
  188. package/src/db/rollbacks.test.ts +150 -0
  189. package/src/db/rollbacks.ts +67 -0
  190. package/src/db/schema.ts +907 -0
  191. package/src/db/sessions.test.ts +80 -0
  192. package/src/db/sessions.ts +135 -0
  193. package/src/db/shutdown.test.ts +147 -0
  194. package/src/db/shutdown.ts +45 -0
  195. package/src/db/tasks.test.ts +921 -0
  196. package/src/db/tasks.ts +747 -0
  197. package/src/db/types.ts +619 -0
  198. package/src/http/__tests__/auth.test.ts +196 -0
  199. package/src/http/__tests__/routes.test.ts +465 -0
  200. package/src/http/__tests__/sse.test.ts +317 -0
  201. package/src/http/auth.ts +72 -0
  202. package/src/http/middleware/cors.ts +53 -0
  203. package/src/http/middleware/security-headers.ts +21 -0
  204. package/src/http/routes/events.ts +112 -0
  205. package/src/http/routes/health.ts +51 -0
  206. package/src/http/routes/plans.ts +66 -0
  207. package/src/http/routes/sessions.ts +50 -0
  208. package/src/http/routes/tasks.ts +60 -0
  209. package/src/http/server.ts +95 -0
  210. package/src/http/sse.ts +116 -0
  211. package/src/index.ts +37 -0
  212. package/src/lib.ts +65 -0
  213. package/src/mem/scoped.ts +65 -0
  214. package/src/orchestrator/background.test.ts +268 -0
  215. package/src/orchestrator/background.ts +293 -0
  216. package/src/orchestrator/memory-hook.ts +182 -0
  217. package/src/orchestrator/reconciler.ts +123 -0
  218. package/src/orchestrator/scheduler.test.ts +300 -0
  219. package/src/orchestrator/scheduler.ts +243 -0
  220. package/src/plugin.test.ts +2574 -0
  221. package/src/plugin.ts +1690 -0
  222. package/src/sdk/client.ts +66 -0
  223. package/src/worktrees/manager.ts +236 -0
  224. package/src/worktrees/state.ts +87 -0
  225. package/tests/integration/ranger-flow.test.ts +257 -0
  226. package/tools/analysis_archive.ts +28 -0
  227. package/tools/analysis_create.ts +55 -0
  228. package/tools/analysis_get.ts +33 -0
  229. package/tools/analysis_link_plan.ts +44 -0
  230. package/tools/analysis_list.ts +48 -0
  231. package/tools/analysis_search.ts +36 -0
  232. package/tools/analysis_update.ts +44 -0
  233. package/tools/plan_approve.ts +31 -0
  234. package/tools/plan_create.ts +58 -0
  235. package/tools/plan_get.ts +40 -0
  236. package/tools/plan_list.ts +37 -0
  237. package/tools/plan_search.ts +34 -0
  238. package/tools/plan_update_status.ts +71 -0
  239. package/tools/session_checkpoint.ts +31 -0
  240. package/tools/session_end.ts +26 -0
  241. package/tools/session_start.ts +43 -0
  242. package/tools/task_create_batch.ts +70 -0
  243. package/tools/task_list.ts +35 -0
  244. package/tools/task_next_for_agent.ts +30 -0
  245. package/tools/task_search.ts +34 -0
  246. package/tools/task_update_status.ts +37 -0
  247. package/tsconfig.json +31 -0
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Tests for v6+v8 migrations — write-once audit fields.
3
+ *
4
+ * Validates:
5
+ * - original_plan_data is set on plan creation and NOT overwritten on status updates
6
+ * - created_by_agent is set on plan creation
7
+ * - executed_by_agent/session can be set but are not overwritten if already set
8
+ *
9
+ * Uses in-memory SQLite via bun:sqlite. Each test gets a fresh DB
10
+ * with the full schema applied by runMigrations.
11
+ */
12
+
13
+ import { Database } from "bun:sqlite";
14
+ import { beforeEach, describe, expect, test } from "bun:test";
15
+ import { runMigrations } from "./migrations.ts";
16
+ import { approvePlan, createPlan, getPlan, getPlanProgress, updatePlanStatus } from "./plans.ts";
17
+ import { createTasksBatch, getTask, updateTaskStatus } from "./tasks.ts";
18
+ import type { Plan } from "./types.ts";
19
+
20
+ let db: Database;
21
+
22
+ beforeEach(() => {
23
+ db = new Database(":memory:");
24
+ db.exec("PRAGMA foreign_keys = ON");
25
+ runMigrations(db);
26
+ });
27
+
28
+ function makePlan(overrides: Partial<Parameters<typeof createPlan>[1]> = {}): Plan {
29
+ return createPlan(db, {
30
+ id: crypto.randomUUID(),
31
+ slug: "test-plan",
32
+ title: "Test",
33
+ status: "draft",
34
+ priority: 2,
35
+ approvedAt: null,
36
+ completedAt: null,
37
+ sessionId: null,
38
+ overview: "test",
39
+ approach: null,
40
+ complexity: 3,
41
+ createdBy: "test",
42
+ updatedBy: "test",
43
+ sourceSessionId: null,
44
+ sourceMessageId: null,
45
+ category: null,
46
+ metadata: {},
47
+ archivedAt: null,
48
+ ...overrides,
49
+ });
50
+ }
51
+
52
+ describe("v6: original_plan_data (write-once)", () => {
53
+ test("plan has original_plan_data after creation", () => {
54
+ const plan = makePlan();
55
+ const fetched = getPlan(db, plan.id);
56
+
57
+ expect(fetched).not.toBeNull();
58
+ const opd = fetched?.originalPlanData;
59
+ expect(opd).not.toBeNull();
60
+
61
+ const data = JSON.parse(opd as string);
62
+ expect(data.id).toBe(plan.id);
63
+ expect(data.slug).toBe("test-plan");
64
+ expect(data.title).toBe("Test");
65
+ expect(data.overview).toBe("test");
66
+ expect(data.createdBy).toBe("test");
67
+ });
68
+
69
+ test("original_plan_data is NOT overwritten on status update", () => {
70
+ const plan = makePlan();
71
+ const beforeRaw = getPlan(db, plan.id);
72
+ expect(beforeRaw).not.toBeNull();
73
+ const before = beforeRaw as Plan;
74
+ const originalData = before.originalPlanData;
75
+
76
+ // Update status — should NOT touch original_plan_data
77
+ updatePlanStatus(db, plan.id, "approved", { updatedBy: "admin" });
78
+
79
+ const afterRaw = getPlan(db, plan.id);
80
+ expect(afterRaw).not.toBeNull();
81
+ const after = afterRaw as Plan;
82
+ expect(after.originalPlanData).toBe(originalData);
83
+ });
84
+
85
+ test("original_plan_data is NOT overwritten on approve", () => {
86
+ const plan = makePlan();
87
+ const beforeRaw = getPlan(db, plan.id);
88
+ expect(beforeRaw).not.toBeNull();
89
+ const before = beforeRaw as Plan;
90
+ const originalData = before.originalPlanData;
91
+
92
+ approvePlan(db, plan.id, { updatedBy: "admin" });
93
+
94
+ const afterRaw = getPlan(db, plan.id);
95
+ expect(afterRaw).not.toBeNull();
96
+ const after = afterRaw as Plan;
97
+ expect(after.originalPlanData).toBe(originalData);
98
+ });
99
+
100
+ test("task has original_plan_data after creation", () => {
101
+ const plan = makePlan();
102
+ const tasks = createTasksBatch(db, plan.id, [
103
+ {
104
+ orderIndex: 0,
105
+ description: "test task",
106
+ agent: "js-smith",
107
+ files: ["src/index.ts"],
108
+ complexity: 2,
109
+ dependencies: [],
110
+ createdBy: "foreman",
111
+ updatedBy: "foreman",
112
+ sourceSessionId: null,
113
+ sourceMessageId: null,
114
+ reviewedBy: null,
115
+ tokensUsed: null,
116
+ durationMs: null,
117
+ artifacts: [],
118
+ metadata: {},
119
+ },
120
+ ]);
121
+
122
+ const taskOpd = tasks[0]?.originalPlanData;
123
+ expect(taskOpd).not.toBeNull();
124
+ const data = JSON.parse(taskOpd as string);
125
+ expect(data.description).toBe("test task");
126
+ expect(data.agent).toBe("js-smith");
127
+ expect(data.files).toEqual(["src/index.ts"]);
128
+ });
129
+
130
+ test("task original_plan_data is NOT overwritten on status update", () => {
131
+ const plan = makePlan();
132
+ const tasks = createTasksBatch(db, plan.id, [
133
+ {
134
+ orderIndex: 0,
135
+ description: "test task",
136
+ agent: "js-smith",
137
+ files: [],
138
+ complexity: 1,
139
+ dependencies: [],
140
+ createdBy: "test",
141
+ updatedBy: "test",
142
+ sourceSessionId: null,
143
+ sourceMessageId: null,
144
+ reviewedBy: null,
145
+ tokensUsed: null,
146
+ durationMs: null,
147
+ artifacts: [],
148
+ metadata: {},
149
+ },
150
+ ]);
151
+
152
+ const originalData = tasks[0]?.originalPlanData;
153
+
154
+ // Update task status — should NOT touch original_plan_data
155
+ const t0id = tasks[0]?.id as string;
156
+ updateTaskStatus(db, t0id, "running");
157
+
158
+ const updated = getTask(db, t0id);
159
+
160
+ expect(updated?.originalPlanData).toBe(originalData);
161
+ });
162
+ });
163
+
164
+ describe("v8: created_by_agent (write-once)", () => {
165
+ test("plan has created_by_agent after creation", () => {
166
+ const plan = makePlan({ createdByAgent: "foreman" });
167
+ const fetched = getPlan(db, plan.id);
168
+
169
+ expect(fetched).not.toBeNull();
170
+ expect(fetched?.createdByAgent).toBe("foreman");
171
+ });
172
+
173
+ test("created_by_agent defaults to null when not provided", () => {
174
+ const plan = makePlan(); // no createdByAgent
175
+ const fetched = getPlan(db, plan.id);
176
+
177
+ expect(fetched?.createdByAgent).toBeNull();
178
+ });
179
+ });
180
+
181
+ describe("v8: executed_by_agent/session", () => {
182
+ test("executed_by_agent and executed_by_session are null on creation", () => {
183
+ const plan = makePlan();
184
+ const fetched = getPlan(db, plan.id);
185
+
186
+ expect(fetched?.executedByAgent).toBeNull();
187
+ expect(fetched?.executedBySession).toBeNull();
188
+ });
189
+
190
+ test("can be set via direct SQL update", () => {
191
+ const plan = makePlan();
192
+
193
+ db.query("UPDATE plans SET executed_by_agent = ?, executed_by_session = ? WHERE id = ?").run(
194
+ "craftsman",
195
+ "ses_123",
196
+ plan.id,
197
+ );
198
+
199
+ const fetched = getPlan(db, plan.id);
200
+ expect(fetched?.executedByAgent).toBe("craftsman");
201
+ expect(fetched?.executedBySession).toBe("ses_123");
202
+ });
203
+
204
+ test("updatePlanStatus sets executed_by_agent on first executing transition", () => {
205
+ const plan = makePlan();
206
+ // createPlan returns the spread input which may have undefined for optional fields
207
+ // The DB stores null, but the TS object preserves the input shape
208
+ expect(plan.executedByAgent ?? null).toBeNull();
209
+
210
+ updatePlanStatus(db, plan.id, "executing", {
211
+ executedByAgent: "craftsman",
212
+ executedBySession: "ses_exec_1",
213
+ });
214
+
215
+ const fetched = getPlan(db, plan.id);
216
+ expect(fetched?.executedByAgent).toBe("craftsman");
217
+ expect(fetched?.executedBySession).toBe("ses_exec_1");
218
+ });
219
+
220
+ test("updatePlanStatus does NOT overwrite executed_by_agent on second executing", () => {
221
+ const plan = makePlan();
222
+
223
+ // First executing
224
+ updatePlanStatus(db, plan.id, "executing", {
225
+ executedByAgent: "craftsman",
226
+ executedBySession: "ses_first",
227
+ });
228
+
229
+ // Second executing with different agent — should NOT overwrite
230
+ updatePlanStatus(db, plan.id, "executing", {
231
+ executedByAgent: "go-smith",
232
+ executedBySession: "ses_second",
233
+ });
234
+
235
+ const fetched = getPlan(db, plan.id);
236
+ expect(fetched?.executedByAgent).toBe("craftsman");
237
+ expect(fetched?.executedBySession).toBe("ses_first");
238
+ });
239
+ });
240
+
241
+ // ─── Critical 2: v9 migration — plan_progress excludes archived ─────────────
242
+
243
+ describe("v9: plan_progress view fix", () => {
244
+ test("DB with schema_version=5 → runMigrations → schema_version=15", () => {
245
+ // Fresh DB already runs all migrations up to v15
246
+ const row = db.query("SELECT MAX(version) as version FROM schema_version").get() as {
247
+ version: number;
248
+ };
249
+ expect(row.version).toBe(15);
250
+ });
251
+
252
+ test("plan_progress excludes archived plans", () => {
253
+ const active = createPlan(db, {
254
+ id: crypto.randomUUID(),
255
+ slug: "active",
256
+ title: "Active",
257
+ status: "draft",
258
+ priority: 2,
259
+ approvedAt: null,
260
+ completedAt: null,
261
+ sessionId: null,
262
+ overview: "test",
263
+ approach: null,
264
+ complexity: 3,
265
+ createdBy: "test",
266
+ updatedBy: "test",
267
+ sourceSessionId: null,
268
+ sourceMessageId: null,
269
+ category: null,
270
+ metadata: {},
271
+ archivedAt: null,
272
+ });
273
+ const archived = createPlan(db, {
274
+ id: crypto.randomUUID(),
275
+ slug: "archived",
276
+ title: "Archived",
277
+ status: "draft",
278
+ priority: 2,
279
+ approvedAt: null,
280
+ completedAt: null,
281
+ sessionId: null,
282
+ overview: "test",
283
+ approach: null,
284
+ complexity: 3,
285
+ createdBy: "test",
286
+ updatedBy: "test",
287
+ sourceSessionId: null,
288
+ sourceMessageId: null,
289
+ category: null,
290
+ metadata: {},
291
+ archivedAt: null,
292
+ });
293
+
294
+ // Archive second plan
295
+ db.query("UPDATE plans SET archived_at = ? WHERE id = ?").run(Date.now(), archived.id);
296
+
297
+ const progress = getPlanProgress(db);
298
+ expect(progress).toHaveLength(1);
299
+ expect(progress[0]?.planId).toBe(active.id);
300
+ });
301
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * ndomo DB — Migration runner.
3
+ *
4
+ * Reads MIGRATIONS from schema.ts, compares against schema_version,
5
+ * and applies pending migrations in a transaction.
6
+ */
7
+
8
+ import type { Database } from "bun:sqlite";
9
+ import { MIGRATIONS } from "./schema.ts";
10
+
11
+ /**
12
+ * Add a column to a table if it doesn't already exist.
13
+ * Uses PRAGMA table_info to check — the only reliable idempotent pattern
14
+ * for SQLite 3.45 which lacks ALTER TABLE ADD COLUMN IF NOT EXISTS.
15
+ */
16
+ function addColumnIfMissing(db: Database, table: string, column: string, type: string): void {
17
+ const cols = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
18
+ if (!cols.some((c) => c.name === column)) {
19
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
20
+ }
21
+ }
22
+
23
+ /**
24
+ * v15: backfill — rename finding keys inside analyses.findings_json.
25
+ *
26
+ * - description → observation
27
+ * - recommendation → proposedAction
28
+ *
29
+ * Idempotent: rows already renamed are skipped (presence of `observation`
30
+ * key signals the rename has already happened for that finding). Returns
31
+ * the number of findings renamed.
32
+ *
33
+ * Pure data migration — no DDL. Safe to call repeatedly.
34
+ */
35
+ export function backfillAnalysisFindings(db: Database): number {
36
+ const rows = db
37
+ .query("SELECT id, findings_json FROM analyses")
38
+ .all() as Array<{ id: string; findings_json: string }>;
39
+
40
+ let renamed = 0;
41
+ const txn = db.transaction(() => {
42
+ for (const row of rows) {
43
+ let findings: unknown;
44
+ try {
45
+ findings = JSON.parse(row.findings_json);
46
+ } catch {
47
+ // Skip malformed JSON — leave row untouched
48
+ continue;
49
+ }
50
+ if (!Array.isArray(findings) || findings.length === 0) continue;
51
+
52
+ let mutated = false;
53
+ const next = findings.map((f: unknown) => {
54
+ if (f === null || typeof f !== "object") return f;
55
+ const obj = f as Record<string, unknown>;
56
+ // Skip if already renamed
57
+ if ("observation" in obj) return f;
58
+
59
+ const renamed_finding: Record<string, unknown> = { ...obj };
60
+ if ("description" in renamed_finding) {
61
+ renamed_finding.observation = renamed_finding.description;
62
+ delete renamed_finding.description;
63
+ mutated = true;
64
+ }
65
+ if ("recommendation" in renamed_finding) {
66
+ renamed_finding.proposedAction = renamed_finding.recommendation;
67
+ delete renamed_finding.recommendation;
68
+ mutated = true;
69
+ }
70
+ return renamed_finding;
71
+ });
72
+
73
+ if (mutated) {
74
+ renamed += next.length;
75
+ db.query("UPDATE analyses SET findings_json = ? WHERE id = ?").run(
76
+ JSON.stringify(next),
77
+ row.id,
78
+ );
79
+ }
80
+ }
81
+ });
82
+ txn();
83
+ return renamed;
84
+ }
85
+
86
+ export function runMigrations(db: Database): void {
87
+ // Ensure schema_version table exists first
88
+ db.exec(
89
+ "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, description TEXT);",
90
+ );
91
+
92
+ // Get current version
93
+ const row = db
94
+ .query<{ version: number }, []>("SELECT MAX(version) as version FROM schema_version")
95
+ .get();
96
+ const current = row?.version ?? 0;
97
+
98
+ // Apply pending migrations
99
+ for (const m of MIGRATIONS) {
100
+ if (m.version > current) {
101
+ const txn = db.transaction(() => {
102
+ // v5: add archived_at columns BEFORE running SQL that creates indexes
103
+ // on those columns. SQLite 3.45 lacks ADD COLUMN IF NOT EXISTS, so we
104
+ // check PRAGMA table_info first (addColumnIfMissing helper).
105
+ if (m.version === 5) {
106
+ addColumnIfMissing(db, "plans", "archived_at", "INTEGER");
107
+ addColumnIfMissing(db, "plan_tasks", "archived_at", "INTEGER");
108
+ addColumnIfMissing(db, "sessions", "archived_at", "INTEGER");
109
+ }
110
+
111
+ // v6: write-once audit trail — original_plan_data on plans + plan_tasks
112
+ if (m.version === 6) {
113
+ addColumnIfMissing(db, "plans", "original_plan_data", "TEXT");
114
+ addColumnIfMissing(db, "plan_tasks", "original_plan_data", "TEXT");
115
+ }
116
+
117
+ // v8: agent execution tracking on plans
118
+ if (m.version === 8) {
119
+ addColumnIfMissing(db, "plans", "created_by_agent", "TEXT");
120
+ addColumnIfMissing(db, "plans", "executed_by_agent", "TEXT");
121
+ addColumnIfMissing(db, "plans", "executed_by_session", "TEXT");
122
+ }
123
+
124
+ // v15: backfill finding keys (description→observation, recommendation→proposedAction)
125
+ // Data-only migration — runs in same transaction so a failure rolls back the
126
+ // schema_version insert (which would otherwise leave an inconsistent state).
127
+ if (m.version === 15) {
128
+ backfillAnalysisFindings(db);
129
+ }
130
+
131
+ // Execute SQL only if it contains actual statements (not just comments)
132
+ const hasStatements = m.sql.split("\n").some((line) => {
133
+ const trimmed = line.trim();
134
+ return trimmed.length > 0 && !trimmed.startsWith("--");
135
+ });
136
+ if (hasStatements) {
137
+ db.exec(m.sql);
138
+ }
139
+
140
+ db.query(
141
+ "INSERT INTO schema_version (version, applied_at, description) VALUES (?, ?, ?)",
142
+ ).run(m.version, Date.now(), m.description);
143
+ });
144
+ txn();
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Tests for plan-archive serialization — verifies original_plan_data inclusion.
3
+ *
4
+ * Uses in-memory SQLite via bun:sqlite. Each test gets a fresh DB
5
+ * with the full schema applied by runMigrations.
6
+ */
7
+
8
+ import { Database } from "bun:sqlite";
9
+ import { beforeEach, describe, expect, test } from "bun:test";
10
+ import { runMigrations } from "./migrations.ts";
11
+ import { serializePlanToMarkdown } from "./plan-archive.ts";
12
+ import { createPlan } from "./plans.ts";
13
+ import type { Plan } from "./types.ts";
14
+
15
+ let db: Database;
16
+
17
+ beforeEach(() => {
18
+ db = new Database(":memory:");
19
+ db.exec("PRAGMA foreign_keys = ON");
20
+ runMigrations(db);
21
+ });
22
+
23
+ function makePlan(overrides: Partial<Parameters<typeof createPlan>[1]> = {}): Plan {
24
+ return createPlan(db, {
25
+ id: crypto.randomUUID(),
26
+ slug: "test-plan",
27
+ title: "Test",
28
+ status: "draft",
29
+ priority: 2,
30
+ approvedAt: null,
31
+ completedAt: null,
32
+ sessionId: null,
33
+ overview: "test overview",
34
+ approach: null,
35
+ complexity: 3,
36
+ createdBy: "test",
37
+ updatedBy: "test",
38
+ sourceSessionId: null,
39
+ sourceMessageId: null,
40
+ category: null,
41
+ metadata: {},
42
+ archivedAt: null,
43
+ ...overrides,
44
+ });
45
+ }
46
+
47
+ describe("serializePlanToMarkdown", () => {
48
+ test("includes original_plan_data section when present", () => {
49
+ const plan = makePlan();
50
+ // originalPlanData is set by createPlan
51
+ const opd = plan.originalPlanData;
52
+ expect(opd).not.toBeNull();
53
+
54
+ const md = serializePlanToMarkdown(plan, [], [], Date.now());
55
+
56
+ expect(md).toContain("## Original Plan Data (write-once)");
57
+ expect(md).toContain("```json");
58
+ expect(md).toContain(opd as string);
59
+ });
60
+
61
+ test("omits original_plan_data section when null", () => {
62
+ // Construct a raw Plan object without going through createPlan
63
+ // (createPlan always sets originalPlanData)
64
+ const plan: Plan = {
65
+ id: crypto.randomUUID(),
66
+ slug: "no-opd",
67
+ title: "No OPD",
68
+ status: "draft",
69
+ priority: 2,
70
+ createdAt: Date.now(),
71
+ updatedAt: Date.now(),
72
+ approvedAt: null,
73
+ completedAt: null,
74
+ sessionId: null,
75
+ overview: "test",
76
+ approach: null,
77
+ complexity: 3,
78
+ createdBy: "test",
79
+ updatedBy: "test",
80
+ sourceSessionId: null,
81
+ sourceMessageId: null,
82
+ category: null,
83
+ metadata: {},
84
+ archivedAt: null,
85
+ originalPlanData: null,
86
+ };
87
+
88
+ const md = serializePlanToMarkdown(plan, [], [], Date.now());
89
+
90
+ expect(md).not.toContain("## Original Plan Data (write-once)");
91
+ });
92
+
93
+ test("includes agent trail section when createdByAgent is set", () => {
94
+ const plan = makePlan({ createdByAgent: "foreman" });
95
+
96
+ const md = serializePlanToMarkdown(plan, [], [], Date.now());
97
+
98
+ expect(md).toContain("## Agent Trail");
99
+ expect(md).toContain("**Created by agent:** foreman");
100
+ });
101
+
102
+ test("includes executed_by_agent when set", () => {
103
+ const plan = makePlan({
104
+ createdByAgent: "foreman",
105
+ executedByAgent: "craftsman",
106
+ executedBySession: "ses_123",
107
+ });
108
+
109
+ const md = serializePlanToMarkdown(plan, [], [], Date.now());
110
+
111
+ expect(md).toContain("**Executed by agent:** craftsman");
112
+ expect(md).toContain("**Executed by session:** ses_123");
113
+ });
114
+
115
+ test("JSON with triple backticks does not break markdown", () => {
116
+ // Construct plan with originalPlanData containing triple backticks
117
+ const plan: Plan = {
118
+ id: crypto.randomUUID(),
119
+ slug: "backtick-test",
120
+ title: "Backtick Test",
121
+ status: "draft",
122
+ priority: 2,
123
+ createdAt: Date.now(),
124
+ updatedAt: Date.now(),
125
+ approvedAt: null,
126
+ completedAt: null,
127
+ sessionId: null,
128
+ overview: "test",
129
+ approach: null,
130
+ complexity: 3,
131
+ createdBy: "test",
132
+ updatedBy: "test",
133
+ sourceSessionId: null,
134
+ sourceMessageId: null,
135
+ category: null,
136
+ metadata: {},
137
+ archivedAt: null,
138
+ originalPlanData: '{"code":"```js\\nconsole.log(1)\\n```"}',
139
+ };
140
+
141
+ const md = serializePlanToMarkdown(plan, [], [], Date.now());
142
+
143
+ // Should contain the sanitized version
144
+ expect(md).toContain("## Original Plan Data (write-once)");
145
+ // The triple backticks should be escaped so inner code blocks don't break markdown
146
+ expect(md).not.toContain("```js\nconsole.log(1)\n```");
147
+ // Verify the escaped version is present instead
148
+ expect(md).toContain("\\`\\`\\`js");
149
+ });
150
+
151
+ test("JSON without triple backticks is unmodified", () => {
152
+ const plan: Plan = {
153
+ id: crypto.randomUUID(),
154
+ slug: "normal-test",
155
+ title: "Normal Test",
156
+ status: "draft",
157
+ priority: 2,
158
+ createdAt: Date.now(),
159
+ updatedAt: Date.now(),
160
+ approvedAt: null,
161
+ completedAt: null,
162
+ sessionId: null,
163
+ overview: "test",
164
+ approach: null,
165
+ complexity: 3,
166
+ createdBy: "test",
167
+ updatedBy: "test",
168
+ sourceSessionId: null,
169
+ sourceMessageId: null,
170
+ category: null,
171
+ metadata: {},
172
+ archivedAt: null,
173
+ originalPlanData: '{"slug":"test","title":"Test"}',
174
+ };
175
+
176
+ const md = serializePlanToMarkdown(plan, [], [], Date.now());
177
+
178
+ expect(md).toContain('{"slug":"test","title":"Test"}');
179
+ });
180
+ });