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,375 @@
1
+ /**
2
+ * ndomo DB — Analysis CRUD with FTS5 search.
3
+ *
4
+ * All functions take a Database instance and return camelCase TS types.
5
+ * Uses TEXT timestamps (ISO datetime) unlike INTEGER epoch in other tables.
6
+ */
7
+
8
+ import type { Database } from "bun:sqlite";
9
+ import { randomUUID } from "node:crypto";
10
+ import { escapeFtsQuery } from "./fts-escape.ts";
11
+ import type { Analysis, InsertAnalysis } from "./types.ts";
12
+ import { analysisFromRow } from "./types.ts";
13
+
14
+ // ─── Validation helpers ─────────────────────────────────────────────────────
15
+
16
+ function validateSlug(slug: string): void {
17
+ if (!slug || slug.trim().length === 0) {
18
+ throw new Error("ndomo: analysis slug cannot be empty");
19
+ }
20
+ }
21
+
22
+ function validateProjectPath(projectPath: string): void {
23
+ if (!projectPath || projectPath.trim().length === 0) {
24
+ throw new Error("ndomo: analysis projectPath cannot be empty");
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Agent-aware validation of analysis findings JSON.
30
+ *
31
+ * Boundary policy (ndomo agent contract):
32
+ * - `ranger` emits observation-only findings (factual, descriptive).
33
+ * Rangers MUST NOT include `proposedAction` because they lack decision authority.
34
+ * - `foreman` and other decision-capable agents MAY include `proposedAction`.
35
+ *
36
+ * Behavior:
37
+ * - If findingsJson is undefined → no-op (field not being set).
38
+ * - If findingsJson is not valid JSON → no-op (let the existing JSON.parse
39
+ * in the tool layer surface the parse error). This helper focuses on the
40
+ * semantic boundary check, not syntax.
41
+ * - If findingsJson parses to a non-array (or empty array) → no-op.
42
+ * - If agent === 'ranger' AND any parsed finding has a `proposedAction` key
43
+ * → throw a clear validation error.
44
+ *
45
+ * Pure: no DB access. Safe to call from plugin.ts tool handlers before write.
46
+ */
47
+ export function validateAnalysisFindings(
48
+ findingsJson: string | undefined,
49
+ agent: string | undefined,
50
+ ): void {
51
+ if (findingsJson === undefined) return;
52
+ if (agent !== "ranger") return;
53
+
54
+ let parsed: unknown;
55
+ try {
56
+ parsed = JSON.parse(findingsJson);
57
+ } catch {
58
+ // Defer parse errors to the JSON.parse call in the tool handler.
59
+ return;
60
+ }
61
+ if (!Array.isArray(parsed) || parsed.length === 0) return;
62
+
63
+ for (const item of parsed) {
64
+ if (item !== null && typeof item === "object" && "proposedAction" in item) {
65
+ throw new Error(
66
+ "ndomo: ranger cannot emit proposedAction on analysis findings (observation-only contract); foreman/decision-capable agents only",
67
+ );
68
+ }
69
+ }
70
+ }
71
+
72
+ // ─── CRUD ───────────────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Create a new analysis. Validates slug uniqueness per project_path.
76
+ * Throws if slug already exists for the same project_path.
77
+ */
78
+ export function createAnalysis(db: Database, input: InsertAnalysis): Analysis {
79
+ validateSlug(input.slug);
80
+ validateProjectPath(input.projectPath);
81
+
82
+ const id = randomUUID();
83
+ const slug = input.slug.trim();
84
+ const title = input.title.trim();
85
+ const projectPath = input.projectPath.trim();
86
+ const summary = input.summary?.trim() ?? "";
87
+ const findingsJson = input.findingsJson ?? "[]";
88
+ const agent = input.agent ?? "ranger";
89
+
90
+ // Check uniqueness (slug, project_path)
91
+ const existing = db
92
+ .query("SELECT id FROM analyses WHERE slug = ? AND project_path = ?")
93
+ .get(slug, projectPath) as { id: string } | null;
94
+ if (existing) {
95
+ throw new Error(
96
+ `ndomo: analysis with slug '${slug}' already exists for project '${projectPath}'`,
97
+ );
98
+ }
99
+
100
+ db.query(
101
+ `INSERT INTO analyses (id, slug, title, project_path, summary, findings_json, source_plan_id, agent, session_id, created_by)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
103
+ ).run(
104
+ id,
105
+ slug,
106
+ title,
107
+ projectPath,
108
+ summary,
109
+ findingsJson,
110
+ input.sourcePlanId ?? null,
111
+ agent,
112
+ input.sessionId ?? null,
113
+ input.createdBy ?? null,
114
+ );
115
+
116
+ return getAnalysis(db, id)!;
117
+ }
118
+
119
+ /**
120
+ * Get analysis by id. Returns null if not found or archived.
121
+ * Set includeArchived=true to include soft-deleted.
122
+ */
123
+ export function getAnalysis(
124
+ db: Database,
125
+ id: string,
126
+ opts?: { includeArchived?: boolean },
127
+ ): Analysis | null {
128
+ const includeArchived = opts?.includeArchived ?? false;
129
+ const sql = includeArchived
130
+ ? "SELECT * FROM analyses WHERE id = ?"
131
+ : "SELECT * FROM analyses WHERE id = ? AND archived_at IS NULL";
132
+ const row = db.query(sql).get(id);
133
+ return row ? analysisFromRow(row) : null;
134
+ }
135
+
136
+ /**
137
+ * Get analysis by slug + project_path. Returns null if not found.
138
+ */
139
+ export function getAnalysisBySlug(
140
+ db: Database,
141
+ slug: string,
142
+ projectPath: string,
143
+ opts?: { includeArchived?: boolean },
144
+ ): Analysis | null {
145
+ const includeArchived = opts?.includeArchived ?? false;
146
+ const sql = includeArchived
147
+ ? "SELECT * FROM analyses WHERE slug = ? AND project_path = ?"
148
+ : "SELECT * FROM analyses WHERE slug = ? AND project_path = ? AND archived_at IS NULL";
149
+ const row = db.query(sql).get(slug, projectPath);
150
+ return row ? analysisFromRow(row) : null;
151
+ }
152
+
153
+ /**
154
+ * List analyses with optional filters.
155
+ */
156
+ export function listAnalyses(
157
+ db: Database,
158
+ filters?: {
159
+ sourcePlanId?: string;
160
+ agent?: string;
161
+ archived?: boolean;
162
+ projectPath?: string;
163
+ limit?: number;
164
+ },
165
+ ): Analysis[] {
166
+ const clauses: string[] = [];
167
+ const params: (string | number)[] = [];
168
+
169
+ if (filters?.sourcePlanId) {
170
+ clauses.push("source_plan_id = ?");
171
+ params.push(filters.sourcePlanId);
172
+ }
173
+ if (filters?.agent) {
174
+ clauses.push("agent = ?");
175
+ params.push(filters.agent);
176
+ }
177
+ if (filters?.projectPath) {
178
+ clauses.push("project_path = ?");
179
+ params.push(filters.projectPath);
180
+ }
181
+ if (!filters?.archived) {
182
+ clauses.push("archived_at IS NULL");
183
+ }
184
+
185
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
186
+ const limit = filters?.limit ?? 50;
187
+ params.push(limit);
188
+
189
+ const rows = db
190
+ .query(`SELECT * FROM analyses ${where} ORDER BY created_at DESC LIMIT ?`)
191
+ .all(...params);
192
+ return (rows as unknown[]).map(analysisFromRow);
193
+ }
194
+
195
+ /**
196
+ * FTS5 search over title+summary+findings_json.
197
+ * Uses escapeFtsQuery to safely wrap query terms.
198
+ */
199
+ export function searchAnalyses(
200
+ db: Database,
201
+ query: string,
202
+ filters?: {
203
+ sourcePlanId?: string;
204
+ agent?: string;
205
+ archived?: boolean;
206
+ limit?: number;
207
+ },
208
+ ): Analysis[] {
209
+ const clauses: string[] = [];
210
+ const params: (string | number)[] = [];
211
+
212
+ if (filters?.sourcePlanId) {
213
+ clauses.push("a.source_plan_id = ?");
214
+ params.push(filters.sourcePlanId);
215
+ }
216
+ if (filters?.agent) {
217
+ clauses.push("a.agent = ?");
218
+ params.push(filters.agent);
219
+ }
220
+ if (!filters?.archived) {
221
+ clauses.push("a.archived_at IS NULL");
222
+ }
223
+
224
+ const extraWhere = clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "";
225
+ const limit = filters?.limit ?? 20;
226
+ params.push(limit);
227
+
228
+ const rows = db
229
+ .query(
230
+ `SELECT a.* FROM analyses a
231
+ JOIN analyses_fts fts ON a.rowid = fts.rowid
232
+ WHERE analyses_fts MATCH ?
233
+ ${extraWhere}
234
+ ORDER BY rank
235
+ LIMIT ?`,
236
+ )
237
+ .all(escapeFtsQuery(query), ...params);
238
+ return (rows as unknown[]).map(analysisFromRow);
239
+ }
240
+
241
+ /**
242
+ * Update analysis fields. Returns updated analysis.
243
+ * Automatically bumps updated_at. Throws if analysis not found.
244
+ */
245
+ export function updateAnalysis(
246
+ db: Database,
247
+ id: string,
248
+ updates: Partial<InsertAnalysis>,
249
+ ): Analysis {
250
+ const existing = getAnalysis(db, id);
251
+ if (!existing) {
252
+ throw new Error(`ndomo: analysis '${id}' not found`);
253
+ }
254
+
255
+ const setClauses: string[] = [];
256
+ const params: (string | null)[] = [];
257
+
258
+ if (updates.slug !== undefined) {
259
+ validateSlug(updates.slug);
260
+ setClauses.push("slug = ?");
261
+ params.push(updates.slug.trim());
262
+ }
263
+ if (updates.title !== undefined) {
264
+ setClauses.push("title = ?");
265
+ params.push(updates.title.trim());
266
+ }
267
+ if (updates.projectPath !== undefined) {
268
+ validateProjectPath(updates.projectPath);
269
+ setClauses.push("project_path = ?");
270
+ params.push(updates.projectPath.trim());
271
+ }
272
+ if (updates.summary !== undefined) {
273
+ setClauses.push("summary = ?");
274
+ params.push(updates.summary);
275
+ }
276
+ if (updates.findingsJson !== undefined) {
277
+ setClauses.push("findings_json = ?");
278
+ params.push(updates.findingsJson);
279
+ }
280
+ if (updates.sourcePlanId !== undefined) {
281
+ setClauses.push("source_plan_id = ?");
282
+ params.push(updates.sourcePlanId ?? null);
283
+ }
284
+ if (updates.agent !== undefined) {
285
+ setClauses.push("agent = ?");
286
+ params.push(updates.agent);
287
+ }
288
+ if (updates.sessionId !== undefined) {
289
+ setClauses.push("session_id = ?");
290
+ params.push(updates.sessionId ?? null);
291
+ }
292
+ if (updates.createdBy !== undefined) {
293
+ setClauses.push("created_by = ?");
294
+ params.push(updates.createdBy ?? null);
295
+ }
296
+
297
+ if (setClauses.length === 0) return existing; // no-op
298
+
299
+ // Always bump updated_at
300
+ setClauses.push("updated_at = datetime('now')");
301
+ params.push(id);
302
+
303
+ db.query(
304
+ `UPDATE analyses SET ${setClauses.join(", ")} WHERE id = ?`,
305
+ ).run(...params);
306
+
307
+ return getAnalysis(db, id)!;
308
+ }
309
+
310
+ /**
311
+ * Soft-delete: set archived_at to current timestamp.
312
+ */
313
+ export function archiveAnalysis(db: Database, id: string): Analysis {
314
+ const existing = getAnalysis(db, id, { includeArchived: true });
315
+ if (!existing) {
316
+ throw new Error(`ndomo: analysis '${id}' not found`);
317
+ }
318
+ // Idempotent: if already archived, just return
319
+ if (existing.archivedAt) return existing;
320
+
321
+ db.query(
322
+ "UPDATE analyses SET archived_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
323
+ ).run(id);
324
+
325
+ return getAnalysis(db, id, { includeArchived: true })!;
326
+ }
327
+
328
+ /**
329
+ * Link analysis to a plan (set source_plan_id).
330
+ * Use when analysis is created standalone and later linked to a plan.
331
+ */
332
+ export function linkAnalysisToPlan(
333
+ db: Database,
334
+ id: string,
335
+ planId: string,
336
+ ): Analysis {
337
+ const existing = getAnalysis(db, id);
338
+ if (!existing) {
339
+ throw new Error(`ndomo: analysis '${id}' not found`);
340
+ }
341
+
342
+ // FK enforces plan existence, but validate at app level for better error message
343
+ const plan = db.query("SELECT id FROM plans WHERE id = ?").get(planId) as
344
+ | { id: string }
345
+ | null;
346
+ if (!plan) {
347
+ throw new Error(`ndomo: plan '${planId}' not found (FK violation)`);
348
+ }
349
+
350
+ db.query(
351
+ "UPDATE analyses SET source_plan_id = ?, updated_at = datetime('now') WHERE id = ?",
352
+ ).run(planId, id);
353
+
354
+ return getAnalysis(db, id)!;
355
+ }
356
+
357
+ /**
358
+ * Unlink analysis from its source plan (set source_plan_id to NULL).
359
+ * Idempotent: if already unlinked, just returns the analysis.
360
+ */
361
+ export function unlinkAnalysisFromPlan(
362
+ db: Database,
363
+ id: string,
364
+ ): Analysis {
365
+ const existing = getAnalysis(db, id);
366
+ if (!existing) {
367
+ throw new Error(`ndomo: analysis '${id}' not found`);
368
+ }
369
+
370
+ db.query(
371
+ "UPDATE analyses SET source_plan_id = NULL, updated_at = datetime('now') WHERE id = ?",
372
+ ).run(id);
373
+
374
+ return getAnalysis(db, id)!;
375
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ndomo DB — Auto-checkpoint dispatcher.
3
+ *
4
+ * Captures orchestrator state into session checkpoints on configurable
5
+ * triggers (phase_transition, task_batch_complete). Debounced, async,
6
+ * loop-safe.
7
+ */
8
+
9
+ import type { Database } from "bun:sqlite"
10
+ import { getPlan } from "./plans.ts"
11
+ import { checkpointSession, ensureSession } from "./sessions.ts"
12
+ import { listTasksByPlan } from "./tasks.ts"
13
+
14
+ // ─── Types ──────────────────────────────────────────────────────────────────
15
+
16
+ export interface AutoCheckpointConfig {
17
+ enabled?: boolean
18
+ triggers?: string[]
19
+ minIntervalMs?: number
20
+ captureState?: {
21
+ completedTasks?: boolean
22
+ currentPhase?: boolean
23
+ blockers?: boolean
24
+ }
25
+ }
26
+
27
+ export interface AutoCheckpointContext {
28
+ planId?: string
29
+ sessionId?: string
30
+ blockers?: string[] | undefined
31
+ }
32
+
33
+ // ─── Defaults ───────────────────────────────────────────────────────────────
34
+
35
+ const DEFAULT_ENABLED = true
36
+ const DEFAULT_TRIGGERS = ["phase_transition", "task_batch_complete"]
37
+ const DEFAULT_MIN_INTERVAL_MS = 30_000
38
+ const DEFAULT_CAPTURE_COMPLETED = true
39
+ const DEFAULT_CAPTURE_PHASE = true
40
+ const DEFAULT_CAPTURE_BLOCKERS = true
41
+
42
+ // ─── Dispatcher ─────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Async, debounced checkpoint dispatcher.
46
+ *
47
+ * Instantiate with a Database + optional config. Call `dispatch()` from
48
+ * tool executors after a successful state mutation. The actual
49
+ * `checkpointSession` call runs in a microtask (non-blocking) with
50
+ * error swallowing so it never breaks the caller.
51
+ *
52
+ * Loop prevention: an `isAutoCheckpointing` flag prevents re-entrant
53
+ * dispatch. Since `checkpointSession` only writes to the sessions
54
+ * table (not plans/tasks), loops are structurally impossible — but the
55
+ * flag is a safety net.
56
+ */
57
+ export class AutoCheckpointDispatcher {
58
+ private lastCheckpointAt = 0
59
+ private isAutoCheckpointing = false
60
+
61
+ private readonly enabled: boolean
62
+ private readonly triggers: Set<string>
63
+ private readonly minIntervalMs: number
64
+ private readonly captureCompleted: boolean
65
+ private readonly capturePhase: boolean
66
+ private readonly captureBlockers: boolean
67
+ private readonly db: Database
68
+
69
+ constructor(db: Database, config?: AutoCheckpointConfig) {
70
+ this.db = db
71
+ this.enabled = config?.enabled ?? DEFAULT_ENABLED
72
+ this.triggers = new Set(config?.triggers ?? DEFAULT_TRIGGERS)
73
+ this.minIntervalMs = config?.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
74
+ this.captureCompleted = config?.captureState?.completedTasks ?? DEFAULT_CAPTURE_COMPLETED
75
+ this.capturePhase = config?.captureState?.currentPhase ?? DEFAULT_CAPTURE_PHASE
76
+ this.captureBlockers = config?.captureState?.blockers ?? DEFAULT_CAPTURE_BLOCKERS
77
+ }
78
+
79
+ /**
80
+ * Fire an auto-checkpoint if conditions are met.
81
+ *
82
+ * Checks (in order): loop guard → enabled → trigger allowed → debounce → sessionId present.
83
+ * If all pass, schedules async checkpoint via microtask.
84
+ */
85
+ dispatch(trigger: string, ctx: AutoCheckpointContext): void {
86
+ if (this.isAutoCheckpointing) return
87
+ if (!this.enabled) return
88
+ if (!this.triggers.has(trigger)) return
89
+
90
+ const now = Date.now()
91
+ if (now - this.lastCheckpointAt < this.minIntervalMs) return
92
+ if (!ctx.sessionId) return
93
+
94
+ this.lastCheckpointAt = now
95
+ this.isAutoCheckpointing = true
96
+
97
+ // Async dispatch — does NOT block the caller
98
+ Promise.resolve().then(() => {
99
+ try {
100
+ ensureSession(this.db, ctx.sessionId!, "auto-checkpoint")
101
+
102
+ const state: Record<string, unknown> = { trigger }
103
+
104
+ if (ctx.planId) {
105
+ if (this.captureCompleted) {
106
+ const doneTasks = listTasksByPlan(this.db, ctx.planId, { status: "done" })
107
+ state.completedTasks = doneTasks.length
108
+ }
109
+ if (this.capturePhase) {
110
+ const plan = getPlan(this.db, ctx.planId)
111
+ state.currentPhase = plan?.status ?? "unknown"
112
+ }
113
+ }
114
+
115
+ if (this.captureBlockers && ctx.blockers && ctx.blockers.length > 0) {
116
+ state.blockers = ctx.blockers
117
+ }
118
+
119
+ checkpointSession(this.db, ctx.sessionId!, state)
120
+ } catch (err) {
121
+ // Auto-checkpoint must never break the caller
122
+ console.error(
123
+ "[ndomo] auto_checkpoint failed:",
124
+ err instanceof Error ? err.message : String(err),
125
+ )
126
+ } finally {
127
+ this.isAutoCheckpointing = false
128
+ }
129
+ })
130
+ }
131
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Tests for openDb() input validation — plan 4dc34202.
3
+ *
4
+ * Validates that openDb rejects invalid projectDir values (empty, "/",
5
+ * relative) with a clear Error BEFORE attempting mkdirSync, and that the
6
+ * happy path (valid absolute path from mkdtempSync) still creates
7
+ * `.ndomo/state.db` with foreign keys enabled.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { closeDb, openDb } from "./client.ts";
15
+
16
+ describe("openDb — input validation", () => {
17
+ test('openDb("") throws clear Error', () => {
18
+ expect(() => openDb("")).toThrow(/invalid projectDir/);
19
+ });
20
+
21
+ test('openDb("/") throws clear Error', () => {
22
+ expect(() => openDb("/")).toThrow(/invalid projectDir/);
23
+ });
24
+
25
+ test('openDb("./relative") throws clear Error', () => {
26
+ expect(() => openDb("./relative")).toThrow(/invalid projectDir/);
27
+ });
28
+
29
+ test("openDb rejects other relative paths", () => {
30
+ expect(() => openDb("relative/no-slash")).toThrow(/invalid projectDir/);
31
+ expect(() => openDb("../parent")).toThrow(/invalid projectDir/);
32
+ });
33
+
34
+ test("error message includes the offending value for debuggability", () => {
35
+ try {
36
+ openDb("/");
37
+ expect.unreachable("should have thrown");
38
+ } catch (err) {
39
+ expect(err).toBeInstanceOf(Error);
40
+ expect((err as Error).message).toContain('"/"');
41
+ expect((err as Error).message).toContain("absolute");
42
+ }
43
+ });
44
+ });
45
+
46
+ describe("openDb — happy path", () => {
47
+ let tmpDir: string;
48
+
49
+ beforeEach(() => {
50
+ tmpDir = mkdtempSync(join(tmpdir(), "ndomo-client-test-"));
51
+ });
52
+
53
+ afterEach(() => {
54
+ rmSync(tmpDir, { recursive: true, force: true });
55
+ });
56
+
57
+ test("openDb(valid absolute path) creates .ndomo/state.db", () => {
58
+ const db = openDb(tmpDir);
59
+ const dbPath = join(tmpDir, ".ndomo", "state.db");
60
+ expect(existsSync(dbPath)).toBe(true);
61
+ closeDb(db);
62
+ });
63
+
64
+ test("openDb enables PRAGMA foreign_keys = ON", () => {
65
+ const db = openDb(tmpDir);
66
+ const fk = db.query("PRAGMA foreign_keys").get() as Record<string, unknown> | null;
67
+ expect(fk).not.toBeNull();
68
+ expect(fk?.foreign_keys).toBe(1);
69
+ closeDb(db);
70
+ });
71
+
72
+ test("openDb is idempotent — second call reuses existing .ndomo dir", () => {
73
+ const db1 = openDb(tmpDir);
74
+ closeDb(db1);
75
+ // Second call on same dir should not throw (mkdirSync recursive is idempotent)
76
+ const db2 = openDb(tmpDir);
77
+ const dbPath = join(tmpDir, ".ndomo", "state.db");
78
+ expect(existsSync(dbPath)).toBe(true);
79
+ closeDb(db2);
80
+ });
81
+ });
82
+
83
+ describe("openDb — PRAGMA config (plan fcb12dc5 #4)", () => {
84
+ let tmpDir: string;
85
+
86
+ beforeEach(() => {
87
+ tmpDir = mkdtempSync(join(tmpdir(), "ndomo-pragma-test-"));
88
+ });
89
+
90
+ afterEach(() => {
91
+ rmSync(tmpDir, { recursive: true, force: true });
92
+ });
93
+
94
+ test("PRAGMA journal_mode = WAL (sticky, applies to file-based DBs)", () => {
95
+ const db = openDb(tmpDir);
96
+ const result = db.query("PRAGMA journal_mode").get() as Record<string, unknown> | null;
97
+ expect(result).not.toBeNull();
98
+ expect(result?.journal_mode).toBe("wal");
99
+ closeDb(db);
100
+ });
101
+
102
+ test("PRAGMA synchronous = NORMAL (safe with WAL, faster than FULL)", () => {
103
+ const db = openDb(tmpDir);
104
+ const result = db.query("PRAGMA synchronous").get() as Record<string, unknown> | null;
105
+ expect(result).not.toBeNull();
106
+ // SQLite reports 0=OFF, 1=NORMAL, 2=FULL
107
+ expect(Number(result?.synchronous)).toBe(1);
108
+ closeDb(db);
109
+ });
110
+
111
+ test("PRAGMA auto_vacuum = INCREMENTAL (prevents unbounded growth)", () => {
112
+ const db = openDb(tmpDir);
113
+ const result = db.query("PRAGMA auto_vacuum").get() as Record<string, unknown> | null;
114
+ expect(result).not.toBeNull();
115
+ // SQLite reports 0=NONE, 1=FULL, 2=INCREMENTAL
116
+ expect(Number(result?.auto_vacuum)).toBe(2);
117
+ closeDb(db);
118
+ });
119
+
120
+ test("PRAGMA settings persist across reopen (sticky migration)", () => {
121
+ const db1 = openDb(tmpDir);
122
+ closeDb(db1);
123
+ // Reopen — journal_mode is sticky in DB file
124
+ const db2 = openDb(tmpDir);
125
+ const journal = db2.query("PRAGMA journal_mode").get() as Record<string, unknown> | null;
126
+ expect(journal?.journal_mode).toBe("wal");
127
+ closeDb(db2);
128
+ });
129
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * ndomo DB — SQLite client factory.
3
+ *
4
+ * Creates a project-level database in `.ndomo/state.db`.
5
+ * Uses bun:sqlite (built-in, zero deps).
6
+ *
7
+ * NOTE on "async" terminology: bun:sqlite ops are SYNCHRONOUS (no async I/O).
8
+ * In ndomo architecture, "async" refers to inter-agent coordination via DB
9
+ * state + manual TUI switch between primaries (foreman↔craftsman), NOT to
10
+ * async database I/O. All db.exec / db.prepare / stmt.run calls are sync.
11
+ */
12
+
13
+ import { Database } from "bun:sqlite";
14
+ import { mkdirSync } from "node:fs";
15
+ import { isAbsolute, join } from "node:path";
16
+
17
+ const NDOMO_DIR = ".ndomo";
18
+ const DB_FILE = "state.db";
19
+
20
+ export function openDb(projectDir: string): Database {
21
+ // Defensive validation: reject paths that would place .ndomo at the
22
+ // filesystem root (e.g. projectDir="/" → "/.ndomo" → EACCES) or relative
23
+ // paths that resolve against CWD unpredictably. See plan
24
+ // 4dc34202 (harden-open-db-path-validation).
25
+ if (projectDir === "" || projectDir === "/" || !isAbsolute(projectDir)) {
26
+ throw new Error(
27
+ `openDb: invalid projectDir ${JSON.stringify(projectDir)} — must be a non-root absolute path (e.g. /home/user/project). Received "/" or a relative path would create ".ndomo" at the filesystem root or an unpredictable CWD-relative location.`,
28
+ );
29
+ }
30
+ const dir = join(projectDir, NDOMO_DIR);
31
+ mkdirSync(dir, { recursive: true });
32
+ const path = join(dir, DB_FILE);
33
+ const db = new Database(path, { create: true });
34
+ // Enable foreign key enforcement (OFF by default in SQLite/bun:sqlite)
35
+ db.exec("PRAGMA foreign_keys = ON");
36
+ // INCREMENTAL auto_vacuum — reclaims space from deleted rows on demand via
37
+ // PRAGMA incremental_vacuum (run by `ndomo vacuum` CLI). Prevents unbounded
38
+ // growth of .ndomo/state.db on long-running installs (audit fcb12dc5 #4).
39
+ // NOTE: must be set BEFORE journal_mode = WAL — SQLite/bun:sqlite silently
40
+ // ignores auto_vacuum when set after WAL is enabled on a fresh DB. Empirically
41
+ // confirmed via /tmp/test-combo.ts (2026-06-23, plan fcb12dc5).
42
+ db.exec("PRAGMA auto_vacuum = INCREMENTAL");
43
+ // WAL journal mode — better concurrency, persistent across opens (sticky
44
+ // in DB file). For long-running installs this prevents the .ndomo/state.db
45
+ // from blocking readers while a writer is active.
46
+ db.exec("PRAGMA journal_mode = WAL");
47
+ // NORMAL synchronous — safe with WAL (durability on checkpoint, not on every
48
+ // commit). Faster than FULL on write-heavy workloads.
49
+ db.exec("PRAGMA synchronous = NORMAL");
50
+ return db;
51
+ }
52
+
53
+ export function closeDb(db: Database): void {
54
+ db.close();
55
+ }