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,289 @@
1
+ /**
2
+ * Tests for plan_files join table (v7 migration).
3
+ *
4
+ * Validates:
5
+ * - Insert and query plan_files
6
+ * - CASCADE delete when plan is deleted
7
+ * - Composite primary key (plan_id, file_path)
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 { createPlan, deletePlan } from "./plans.ts";
17
+ import type { Plan } from "./types.ts";
18
+
19
+ let db: Database;
20
+
21
+ beforeEach(() => {
22
+ db = new Database(":memory:");
23
+ db.exec("PRAGMA foreign_keys = ON");
24
+ runMigrations(db);
25
+ });
26
+
27
+ function makePlan(overrides: Partial<Parameters<typeof createPlan>[1]> = {}): Plan {
28
+ return createPlan(db, {
29
+ id: crypto.randomUUID(),
30
+ slug: "test-plan",
31
+ title: "Test",
32
+ status: "draft",
33
+ priority: 2,
34
+ approvedAt: null,
35
+ completedAt: null,
36
+ sessionId: null,
37
+ overview: "test",
38
+ approach: null,
39
+ complexity: 3,
40
+ createdBy: "test",
41
+ updatedBy: "test",
42
+ sourceSessionId: null,
43
+ sourceMessageId: null,
44
+ category: null,
45
+ metadata: {},
46
+ archivedAt: null,
47
+ ...overrides,
48
+ });
49
+ }
50
+
51
+ describe("plan_files", () => {
52
+ test("insert and query plan files", () => {
53
+ const plan = makePlan();
54
+
55
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
56
+ plan.id,
57
+ "src/index.ts",
58
+ "input",
59
+ );
60
+
61
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
62
+ plan.id,
63
+ "README.md",
64
+ "reference",
65
+ );
66
+
67
+ const rows = db
68
+ .query("SELECT * FROM plan_files WHERE plan_id = ? ORDER BY file_path")
69
+ .all(plan.id) as Array<{ plan_id: string; file_path: string; role: string }>;
70
+
71
+ expect(rows).toHaveLength(2);
72
+ expect(rows[0]?.file_path).toBe("README.md");
73
+ expect(rows[0]?.role).toBe("reference");
74
+ expect(rows[1]?.file_path).toBe("src/index.ts");
75
+ expect(rows[1]?.role).toBe("input");
76
+ });
77
+
78
+ test("multi-role inserts allowed (same file_path, different roles)", () => {
79
+ const plan = makePlan();
80
+
81
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
82
+ plan.id,
83
+ "src/index.ts",
84
+ "input",
85
+ );
86
+
87
+ // v10: same file_path with different role is ALLOWED (multi-role PK)
88
+ expect(() => {
89
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
90
+ plan.id,
91
+ "src/index.ts",
92
+ "output",
93
+ );
94
+ }).not.toThrow();
95
+
96
+ // Both rows exist
97
+ const rows = db
98
+ .query("SELECT * FROM plan_files WHERE plan_id = ? AND file_path = ?")
99
+ .all(plan.id, "src/index.ts") as Array<{ role: string }>;
100
+ expect(rows).toHaveLength(2);
101
+ expect(rows.map((r) => r.role).sort()).toEqual(["input", "output"]);
102
+ });
103
+
104
+ test("CASCADE delete removes plan_files when plan is deleted", () => {
105
+ const plan = makePlan({ status: "approved" });
106
+
107
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
108
+ plan.id,
109
+ "src/index.ts",
110
+ "input",
111
+ );
112
+
113
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
114
+ plan.id,
115
+ "README.md",
116
+ "reference",
117
+ );
118
+
119
+ // Verify files exist
120
+ const before = db
121
+ .query("SELECT COUNT(*) as count FROM plan_files WHERE plan_id = ?")
122
+ .get(plan.id) as { count: number };
123
+ expect(before.count).toBe(2);
124
+
125
+ // Delete plan — CASCADE should remove plan_files
126
+ deletePlan(db, plan.id, { confirm: true });
127
+
128
+ // Verify files are gone
129
+ const after = db
130
+ .query("SELECT COUNT(*) as count FROM plan_files WHERE plan_id = ?")
131
+ .get(plan.id) as { count: number };
132
+ expect(after.count).toBe(0);
133
+ });
134
+
135
+ test("default role is 'input'", () => {
136
+ const plan = makePlan();
137
+
138
+ db.query("INSERT INTO plan_files (plan_id, file_path) VALUES (?, ?)").run(
139
+ plan.id,
140
+ "src/utils.ts",
141
+ );
142
+
143
+ const row = db
144
+ .query("SELECT role FROM plan_files WHERE plan_id = ? AND file_path = ?")
145
+ .get(plan.id, "src/utils.ts") as { role: string };
146
+
147
+ expect(row.role).toBe("input");
148
+ });
149
+
150
+ test("multi-role: same file_path with 3 roles all exist", () => {
151
+ const plan = makePlan();
152
+
153
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
154
+ plan.id,
155
+ "src/main.ts",
156
+ "input",
157
+ );
158
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
159
+ plan.id,
160
+ "src/main.ts",
161
+ "modified",
162
+ );
163
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
164
+ plan.id,
165
+ "src/main.ts",
166
+ "reviewed",
167
+ );
168
+
169
+ const rows = db
170
+ .query("SELECT * FROM plan_files WHERE plan_id = ? AND file_path = ?")
171
+ .all(plan.id, "src/main.ts") as Array<{ role: string }>;
172
+ expect(rows).toHaveLength(3);
173
+ expect(rows.map((r) => r.role).sort()).toEqual(["input", "modified", "reviewed"]);
174
+ });
175
+
176
+ test("created_at column auto-populated on insert", () => {
177
+ const plan = makePlan();
178
+ // strftime('%s','now')*1000 has second-precision (ends in 000ms)
179
+ // so floor before to second boundary for comparison
180
+ const beforeSec = Math.floor(Date.now() / 1000) * 1000;
181
+
182
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
183
+ plan.id,
184
+ "src/test.ts",
185
+ "input",
186
+ );
187
+
188
+ const afterSec = Math.ceil(Date.now() / 1000) * 1000;
189
+
190
+ const row = db
191
+ .query("SELECT created_at FROM plan_files WHERE plan_id = ? AND file_path = ?")
192
+ .get(plan.id, "src/test.ts") as { created_at: number };
193
+
194
+ expect(row.created_at).toBeGreaterThanOrEqual(beforeSec);
195
+ expect(row.created_at).toBeLessThanOrEqual(afterSec);
196
+ });
197
+
198
+ test("query by role filter returns only matching rows", () => {
199
+ const plan = makePlan();
200
+
201
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
202
+ plan.id,
203
+ "src/a.ts",
204
+ "input",
205
+ );
206
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
207
+ plan.id,
208
+ "src/a.ts",
209
+ "modified",
210
+ );
211
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
212
+ plan.id,
213
+ "src/b.ts",
214
+ "modified",
215
+ );
216
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
217
+ plan.id,
218
+ "src/c.ts",
219
+ "reviewed",
220
+ );
221
+
222
+ const modified = db
223
+ .query("SELECT * FROM plan_files WHERE plan_id = ? AND role = ?")
224
+ .all(plan.id, "modified") as Array<{ file_path: string }>;
225
+ expect(modified).toHaveLength(2);
226
+ expect(modified.map((r) => r.file_path).sort()).toEqual(["src/a.ts", "src/b.ts"]);
227
+ });
228
+
229
+ test("duplicate (plan_id, file_path, role) triple still throws", () => {
230
+ const plan = makePlan();
231
+
232
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
233
+ plan.id,
234
+ "src/main.ts",
235
+ "input",
236
+ );
237
+
238
+ // Same triple insert should throw (PK violation)
239
+ expect(() => {
240
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
241
+ plan.id,
242
+ "src/main.ts",
243
+ "input",
244
+ );
245
+ }).toThrow();
246
+ });
247
+
248
+ test("CASCADE delete removes all role rows for plan files", () => {
249
+ const plan = makePlan({ status: "approved" });
250
+
251
+ // Insert same file with 3 roles
252
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
253
+ plan.id,
254
+ "src/main.ts",
255
+ "input",
256
+ );
257
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
258
+ plan.id,
259
+ "src/main.ts",
260
+ "modified",
261
+ );
262
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
263
+ plan.id,
264
+ "src/main.ts",
265
+ "reviewed",
266
+ );
267
+ // Insert different file
268
+ db.query("INSERT INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
269
+ plan.id,
270
+ "README.md",
271
+ "reference",
272
+ );
273
+
274
+ // Verify 4 rows exist
275
+ const before = db
276
+ .query("SELECT COUNT(*) as count FROM plan_files WHERE plan_id = ?")
277
+ .get(plan.id) as { count: number };
278
+ expect(before.count).toBe(4);
279
+
280
+ // Delete plan — CASCADE should remove all plan_files
281
+ deletePlan(db, plan.id, { confirm: true });
282
+
283
+ // Verify all gone
284
+ const after = db
285
+ .query("SELECT COUNT(*) as count FROM plan_files WHERE plan_id = ?")
286
+ .get(plan.id) as { count: number };
287
+ expect(after.count).toBe(0);
288
+ });
289
+ });
@@ -0,0 +1,287 @@
1
+ /**
2
+ * ndomo DB — plan_update_status tool executor (T3.1).
3
+ *
4
+ * Pure function extracted from the MCP tool wrapper in src/plugin.ts
5
+ * to enable unit testing without spinning up the full MCP harness.
6
+ *
7
+ * Adds: readiness checks (blockers/warnings), atomic transaction,
8
+ * dryRun, force with plan_audit capture.
9
+ */
10
+
11
+ import type { Database } from "bun:sqlite";
12
+ import type { ArchiveResult } from "./plan-archive.ts";
13
+ import { archivePlan } from "./plan-archive.ts";
14
+ import { getPlan, updatePlanStatus } from "./plans.ts";
15
+ import type { Plan, PlanStatus } from "./types.ts";
16
+
17
+ // ─── Types ──────────────────────────────────────────────────────────────────
18
+
19
+ export interface PlanUpdateStatusArgs {
20
+ id: string;
21
+ status: PlanStatus;
22
+ dryRun?: boolean;
23
+ force?: boolean;
24
+ forceReason?: string;
25
+ }
26
+
27
+ export interface PlanUpdateStatusContext {
28
+ agent?: string;
29
+ sessionID?: string;
30
+ messageID?: string;
31
+ directory?: string;
32
+ worktree?: string;
33
+ }
34
+
35
+ export interface PlanUpdateStatusResult {
36
+ plan: Plan | null;
37
+ statusChanged: boolean;
38
+ blocked: boolean;
39
+ forced: boolean;
40
+ dryRun: boolean;
41
+ blockers: string[];
42
+ warnings: string[];
43
+ archived: ArchiveResult | null;
44
+ archiveError: string | null;
45
+ auditId: number | null;
46
+ }
47
+
48
+ // ─── Constants ──────────────────────────────────────────────────────────────
49
+
50
+ const TERMINAL_STATUSES = new Set<PlanStatus>(["completed", "failed", "abandoned"]);
51
+
52
+ const VALID_TRANSITIONS: Record<PlanStatus, PlanStatus[]> = {
53
+ draft: ["approved", "abandoned"],
54
+ approved: ["executing", "abandoned"],
55
+ executing: ["completed", "failed", "abandoned"],
56
+ completed: [],
57
+ failed: [],
58
+ abandoned: [],
59
+ };
60
+
61
+ // ─── Executor ───────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Execute plan_update_status with readiness checks, dryRun, force, and
65
+ * atomic transaction wrapping updatePlanStatus + archivePlan.
66
+ *
67
+ * @param db - Database instance
68
+ * @param args - Tool args (id, status, dryRun?, force?, forceReason?)
69
+ * @param ctx - Tool context (agent, sessionID, directory, worktree)
70
+ * @param archiveDir - Resolved archive directory for markdown output
71
+ */
72
+ export function planUpdateStatusExecutor(
73
+ db: Database,
74
+ args: PlanUpdateStatusArgs,
75
+ ctx: PlanUpdateStatusContext,
76
+ archiveDir: string,
77
+ ): PlanUpdateStatusResult {
78
+ // ── 1. Load current plan ────────────────────────────────────────────────
79
+ const currentPlan = getPlan(db, args.id);
80
+ if (!currentPlan) {
81
+ throw new Error(`ndomo: plan not found: ${args.id}`);
82
+ }
83
+ const currentStatus = currentPlan.status;
84
+
85
+ // ── 2. Compute readiness check ─────────────────────────────────────────
86
+ const blockers: string[] = [];
87
+ const warnings: string[] = [];
88
+
89
+ // tasks_pending
90
+ const pendingCount = (
91
+ db
92
+ .query(
93
+ "SELECT COUNT(*) as cnt FROM plan_tasks WHERE plan_id = ? AND status = 'pending' AND archived_at IS NULL",
94
+ )
95
+ .get(args.id) as { cnt: number }
96
+ ).cnt;
97
+ if (pendingCount > 0) blockers.push("tasks_pending");
98
+
99
+ // tasks_running
100
+ const runningCount = (
101
+ db
102
+ .query(
103
+ "SELECT COUNT(*) as cnt FROM plan_tasks WHERE plan_id = ? AND status = 'running' AND archived_at IS NULL",
104
+ )
105
+ .get(args.id) as { cnt: number }
106
+ ).cnt;
107
+ if (runningCount > 0) blockers.push("tasks_running");
108
+
109
+ // sessions_open
110
+ const openSessionsCount = (
111
+ db
112
+ .query(
113
+ "SELECT COUNT(*) as cnt FROM sessions WHERE plan_id = ? AND ended_at IS NULL AND archived_at IS NULL",
114
+ )
115
+ .get(args.id) as { cnt: number }
116
+ ).cnt;
117
+ if (openSessionsCount > 0) blockers.push("sessions_open");
118
+
119
+ // status_invalid — always a blocker (force cannot bypass)
120
+ const allowed = VALID_TRANSITIONS[currentStatus] ?? [];
121
+ if (args.status !== currentStatus && !allowed.includes(args.status)) {
122
+ blockers.push("status_invalid");
123
+ }
124
+
125
+ // orphan_plan — warning only
126
+ const totalTasks = (
127
+ db
128
+ .query(
129
+ "SELECT COUNT(*) as cnt FROM plan_tasks WHERE plan_id = ? AND archived_at IS NULL",
130
+ )
131
+ .get(args.id) as { cnt: number }
132
+ ).cnt;
133
+ if (totalTasks === 0) warnings.push("orphan_plan");
134
+
135
+ // executing→failed: downgrade blockers to warnings (except status_invalid)
136
+ const isExecutingToFailed = currentStatus === "executing" && args.status === "failed";
137
+ const hardBlockers = isExecutingToFailed
138
+ ? blockers.filter((b) => b === "status_invalid")
139
+ : blockers;
140
+ if (isExecutingToFailed) {
141
+ // Move non-status_invalid blockers to warnings
142
+ for (const b of blockers) {
143
+ if (b !== "status_invalid" && !warnings.includes(b)) {
144
+ warnings.push(b);
145
+ }
146
+ }
147
+ }
148
+
149
+ // status_invalid is always a hard blocker — force cannot bypass it
150
+ if (hardBlockers.includes("status_invalid")) {
151
+ return {
152
+ plan: currentPlan,
153
+ statusChanged: false,
154
+ blocked: true,
155
+ forced: false,
156
+ dryRun: false,
157
+ blockers: hardBlockers,
158
+ warnings,
159
+ archived: null,
160
+ archiveError: null,
161
+ auditId: null,
162
+ };
163
+ }
164
+
165
+ // ── 3. dryRun — return check results, no mutation ──────────────────────
166
+ if (args.dryRun) {
167
+ return {
168
+ plan: currentPlan,
169
+ statusChanged: false,
170
+ blocked: hardBlockers.length > 0,
171
+ forced: false,
172
+ dryRun: true,
173
+ blockers: hardBlockers,
174
+ warnings,
175
+ archived: null,
176
+ archiveError: null,
177
+ auditId: null,
178
+ };
179
+ }
180
+
181
+ // ── 4. Blockers without force ──────────────────────────────────────────
182
+ if (hardBlockers.length > 0 && !args.force) {
183
+ return {
184
+ plan: currentPlan,
185
+ statusChanged: false,
186
+ blocked: true,
187
+ forced: false,
188
+ dryRun: false,
189
+ blockers: hardBlockers,
190
+ warnings,
191
+ archived: null,
192
+ archiveError: null,
193
+ auditId: null,
194
+ };
195
+ }
196
+
197
+ // ── 5. force=true validation ───────────────────────────────────────────
198
+ if (args.force && (!args.forceReason || args.forceReason.trim() === "")) {
199
+ throw new Error("ndomo: force=true requires non-empty forceReason");
200
+ }
201
+
202
+ // ── 6. Atomic transaction: readiness check → audit → update → archive ─
203
+ const opts: {
204
+ sessionId?: string;
205
+ updatedBy: string;
206
+ executedByAgent?: string;
207
+ executedBySession?: string;
208
+ } = {
209
+ updatedBy: ctx.agent ?? "unknown",
210
+ };
211
+ if (ctx.sessionID) opts.sessionId = ctx.sessionID;
212
+ if (args.status === "executing") {
213
+ opts.executedByAgent = ctx.agent ?? "unknown";
214
+ if (ctx.sessionID) opts.executedBySession = ctx.sessionID;
215
+ }
216
+
217
+ const txn = db.transaction(() => {
218
+ // Re-check status inside transaction (another writer could have changed it)
219
+ const freshPlan = getPlan(db, args.id);
220
+ if (!freshPlan) {
221
+ throw new Error(`ndomo: plan not found during transaction: ${args.id}`);
222
+ }
223
+
224
+ // Re-validate status transition inside transaction
225
+ const freshAllowed = VALID_TRANSITIONS[freshPlan.status] ?? [];
226
+ if (args.status !== freshPlan.status && !freshAllowed.includes(args.status)) {
227
+ throw new Error(
228
+ `ndomo: invalid status transition '${freshPlan.status}' → '${args.status}'`,
229
+ );
230
+ }
231
+
232
+ // Force audit insert
233
+ let auditRowId: number | null = null;
234
+ if (args.force) {
235
+ const snapshot = JSON.stringify({
236
+ reason: args.forceReason,
237
+ forcedBy: ctx.agent ?? "unknown",
238
+ blockers: hardBlockers,
239
+ warnings,
240
+ previousStatus: currentStatus,
241
+ });
242
+ const result = db
243
+ .query(
244
+ "INSERT INTO plan_audit (plan_id, captured_at, snapshot, trigger) VALUES (?, ?, ?, 'force_close')",
245
+ )
246
+ .run(args.id, Date.now(), snapshot);
247
+ auditRowId = Number(result.lastInsertRowid);
248
+ }
249
+
250
+ // Update status (idempotent same-status is a no-op warning)
251
+ const updated = updatePlanStatus(db, args.id, args.status, opts);
252
+
253
+ // Auto-archive on terminal status
254
+ let archiveResult: ArchiveResult | null = null;
255
+ let archiveError: string | null = null;
256
+
257
+ if (updated && TERMINAL_STATUSES.has(args.status)) {
258
+ try {
259
+ archiveResult = archivePlan(db, updated.id, { memDir: archiveDir });
260
+ } catch (err) {
261
+ archiveError = err instanceof Error ? err.message : String(err);
262
+ console.warn(
263
+ `[ndomo] auto-archive failed for plan ${updated.id}: ${archiveError}`,
264
+ );
265
+ // Re-throw so the outer transaction rolls back (atomicity)
266
+ throw err;
267
+ }
268
+ }
269
+
270
+ return { updated, archiveResult, archiveError, auditRowId };
271
+ });
272
+
273
+ const { updated, archiveResult, archiveError, auditRowId } = txn();
274
+
275
+ return {
276
+ plan: updated,
277
+ statusChanged: updated?.status === args.status,
278
+ blocked: false,
279
+ forced: !!args.force,
280
+ dryRun: false,
281
+ blockers: hardBlockers,
282
+ warnings,
283
+ archived: archiveResult,
284
+ archiveError,
285
+ auditId: auditRowId,
286
+ };
287
+ }