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,534 @@
1
+ /**
2
+ * ndomo DB — Plan CRUD + FTS5 search.
3
+ *
4
+ * All functions take a Database instance and return camelCase TS types.
5
+ * Mutations that touch multiple rows use db.transaction().
6
+ */
7
+
8
+ import type { Database, SQLQueryBindings } from "bun:sqlite";
9
+ import { escapeFtsQuery } from "./fts-escape.ts";
10
+ import { ensureSession } from "./sessions.ts";
11
+ import type { Plan, PlanCategory, PlanStatus } from "./types.ts";
12
+ import { planFromRow, planWithFilesFromRow } from "./types.ts";
13
+
14
+ // Terminal statuses that should set completed_at when entered
15
+ const TERMINAL_STATUSES = new Set<PlanStatus>(["completed", "failed", "abandoned"]);
16
+
17
+ export function createPlan(db: Database, plan: Omit<Plan, "createdAt" | "updatedAt">): Plan {
18
+ const now = Date.now();
19
+ // v6: build original_plan_data snapshot (write-once) — M5: added files + metadata
20
+ const originalPlanData = JSON.stringify({
21
+ id: plan.id,
22
+ slug: plan.slug,
23
+ title: plan.title,
24
+ overview: plan.overview,
25
+ approach: plan.approach,
26
+ priority: plan.priority,
27
+ complexity: plan.complexity,
28
+ category: plan.category,
29
+ createdBy: plan.createdBy,
30
+ sourceSessionId: plan.sourceSessionId,
31
+ sourceMessageId: plan.sourceMessageId,
32
+ files: plan.files ?? [],
33
+ metadata: plan.metadata ?? {},
34
+ createdAt: now,
35
+ });
36
+ db.query(
37
+ `INSERT INTO plans (id, slug, title, status, priority, created_at, updated_at, approved_at, completed_at, session_id, overview, approach, complexity, metadata, created_by, updated_by, source_session_id, source_message_id, category, original_plan_data, created_by_agent, executed_by_agent, executed_by_session)
38
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
39
+ ).run(
40
+ plan.id,
41
+ plan.slug,
42
+ plan.title,
43
+ plan.status,
44
+ plan.priority,
45
+ now,
46
+ now,
47
+ plan.approvedAt ?? null,
48
+ plan.completedAt ?? null,
49
+ plan.sessionId ?? null,
50
+ plan.overview,
51
+ plan.approach ?? null,
52
+ plan.complexity,
53
+ JSON.stringify(plan.metadata),
54
+ plan.createdBy,
55
+ plan.updatedBy,
56
+ plan.sourceSessionId ?? null,
57
+ plan.sourceMessageId ?? null,
58
+ plan.category ?? null,
59
+ originalPlanData,
60
+ plan.createdByAgent ?? null,
61
+ plan.executedByAgent ?? null,
62
+ plan.executedBySession ?? null,
63
+ );
64
+ return { ...plan, createdAt: now, updatedAt: now, originalPlanData };
65
+ }
66
+
67
+ export function getPlan(db: Database, id: string): Plan | null {
68
+ const row = db.query("SELECT * FROM plans WHERE id = ?").get(id);
69
+ if (row == null) return null;
70
+ const fileRows = db
71
+ .query("SELECT file_path, role FROM plan_files WHERE plan_id = ? ORDER BY file_path")
72
+ .all(id);
73
+ return planWithFilesFromRow(row, fileRows);
74
+ }
75
+
76
+ export function getPlanBySlug(db: Database, slug: string): Plan | null {
77
+ const row = db.query("SELECT * FROM plans WHERE slug = ?").get(slug);
78
+ if (row == null) return null;
79
+ // Need to get the plan id to query plan_files
80
+ const planId = (row as { id: string }).id;
81
+ const fileRows = db
82
+ .query("SELECT file_path, role FROM plan_files WHERE plan_id = ? ORDER BY file_path")
83
+ .all(planId);
84
+ return planWithFilesFromRow(row, fileRows);
85
+ }
86
+
87
+ export function listPlans(
88
+ db: Database,
89
+ opts: { status?: PlanStatus; sessionId?: string; limit?: number; includeArchived?: boolean } = {},
90
+ ): Plan[] {
91
+ const conditions: string[] = [];
92
+ const params: SQLQueryBindings[] = [];
93
+
94
+ if (opts.status !== undefined) {
95
+ conditions.push("status = ?");
96
+ params.push(opts.status);
97
+ }
98
+ if (opts.sessionId !== undefined) {
99
+ conditions.push("session_id = ?");
100
+ params.push(opts.sessionId);
101
+ }
102
+ if (!opts.includeArchived) {
103
+ conditions.push("archived_at IS NULL");
104
+ }
105
+
106
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
107
+ const limit = opts.limit ?? 100;
108
+
109
+ const rows = db
110
+ .query(`SELECT * FROM plans ${where} ORDER BY created_at DESC LIMIT ?`)
111
+ .all(...params, limit);
112
+
113
+ if (rows.length === 0) return [];
114
+
115
+ // Get all plan IDs
116
+ const planIds = (rows as Array<{ id: string }>).map((r) => r.id);
117
+
118
+ // Fetch all files for these plans in one query
119
+ const placeholders = planIds.map(() => "?").join(",");
120
+ const fileRows = db
121
+ .query(
122
+ `SELECT plan_id, file_path, role FROM plan_files WHERE plan_id IN (${placeholders}) ORDER BY plan_id, file_path`,
123
+ )
124
+ .all(...planIds) as Array<{ plan_id: string; file_path: string; role: string }>;
125
+
126
+ // Group files by plan_id
127
+ const filesByPlanId = new Map<string, Array<{ file_path: string; role: string }>>();
128
+ for (const f of fileRows) {
129
+ const existing = filesByPlanId.get(f.plan_id) ?? [];
130
+ existing.push({ file_path: f.file_path, role: f.role });
131
+ filesByPlanId.set(f.plan_id, existing);
132
+ }
133
+
134
+ // Map plans with their files
135
+ return (rows as unknown[]).map((row) => {
136
+ const planId = (row as { id: string }).id;
137
+ const planFiles = filesByPlanId.get(planId) ?? [];
138
+ return planWithFilesFromRow(row, planFiles);
139
+ });
140
+ }
141
+
142
+ export function searchPlans(
143
+ db: Database,
144
+ query: string,
145
+ limit = 20,
146
+ opts: { includeArchived?: boolean } = {},
147
+ ): Plan[] {
148
+ const archiveFilter = opts.includeArchived ? "" : "AND p.archived_at IS NULL";
149
+ const rows = db
150
+ .query(
151
+ `SELECT p.* FROM plans p
152
+ JOIN plans_fts_v2 fts ON p.id = fts.id
153
+ WHERE plans_fts_v2 MATCH ?
154
+ ${archiveFilter}
155
+ ORDER BY rank
156
+ LIMIT ?`,
157
+ )
158
+ .all(escapeFtsQuery(query), limit);
159
+ return (rows as unknown[]).map(planFromRow);
160
+ }
161
+
162
+ /**
163
+ * Update a plan's status. Optionally links a session and records who made the change.
164
+ *
165
+ * When transitioning to "executing" or "approved" with a sessionId provided,
166
+ * the session is auto-linked to the plan (Fix #8). The sessionId is only
167
+ * validated/auto-created when it will actually be linked (executing/approved);
168
+ * terminal statuses (completed/failed/abandoned) skip FK check since sessionId
169
+ * is just metadata there (Fix #1 hybrid — scoped ensureSession).
170
+ *
171
+ * @see Plan.sessionId — foreign key constraint enforced at app level (Fix #1)
172
+ * @see ensureSession — idempotent auto-creation of session rows (hybrid fix)
173
+ */
174
+ export function updatePlanStatus(
175
+ db: Database,
176
+ id: string,
177
+ status: PlanStatus,
178
+ opts: {
179
+ updatedBy?: string;
180
+ sessionId?: string;
181
+ executedByAgent?: string;
182
+ executedBySession?: string;
183
+ } = {},
184
+ ): Plan | null {
185
+ const now = Date.now();
186
+
187
+ // Fix #1 (scoped): validate sessionId only when status will actually link it (Fix #8)
188
+ if (opts.sessionId !== undefined && (status === "executing" || status === "approved")) {
189
+ ensureSession(db, opts.sessionId, "auto-created for plan transition");
190
+ }
191
+
192
+ // Fix #8: auto-link session when entering executing or approved status
193
+ const linkSession =
194
+ (status === "executing" || status === "approved") && opts.sessionId !== undefined;
195
+
196
+ // v8: write-once executed_by_agent/session — only set on first executing transition
197
+ const setExecutedBy = status === "executing" && opts.executedByAgent !== undefined;
198
+ // Guaranteed non-undefined inside setExecutedBy branches (see guard above)
199
+ const executedAgent = opts.executedByAgent ?? null;
200
+ const executedSession = opts.executedBySession ?? null;
201
+
202
+ if (linkSession) {
203
+ // opts.sessionId is guaranteed non-undefined here (linkSession check above)
204
+ const sid = opts.sessionId as string;
205
+ if (setExecutedBy) {
206
+ db.query(
207
+ `UPDATE plans SET status = ?, updated_at = ?, updated_by = ?, session_id = ?,
208
+ executed_by_agent = COALESCE(executed_by_agent, ?),
209
+ executed_by_session = COALESCE(executed_by_session, ?)
210
+ WHERE id = ?`,
211
+ ).run(status, now, opts.updatedBy ?? "unknown", sid, executedAgent, executedSession, id);
212
+ } else {
213
+ db.query(
214
+ "UPDATE plans SET status = ?, updated_at = ?, updated_by = ?, session_id = ? WHERE id = ?",
215
+ ).run(status, now, opts.updatedBy ?? "unknown", sid, id);
216
+ }
217
+ } else if (opts.updatedBy !== undefined) {
218
+ if (setExecutedBy) {
219
+ db.query(
220
+ `UPDATE plans SET status = ?, updated_at = ?, updated_by = ?,
221
+ executed_by_agent = COALESCE(executed_by_agent, ?),
222
+ executed_by_session = COALESCE(executed_by_session, ?)
223
+ WHERE id = ?`,
224
+ ).run(status, now, opts.updatedBy, executedAgent, executedSession, id);
225
+ } else {
226
+ db.query("UPDATE plans SET status = ?, updated_at = ?, updated_by = ? WHERE id = ?").run(
227
+ status,
228
+ now,
229
+ opts.updatedBy,
230
+ id,
231
+ );
232
+ }
233
+ } else {
234
+ if (setExecutedBy) {
235
+ db.query(
236
+ `UPDATE plans SET status = ?, updated_at = ?,
237
+ executed_by_agent = COALESCE(executed_by_agent, ?),
238
+ executed_by_session = COALESCE(executed_by_session, ?)
239
+ WHERE id = ?`,
240
+ ).run(status, now, executedAgent, executedSession, id);
241
+ } else {
242
+ db.query("UPDATE plans SET status = ?, updated_at = ? WHERE id = ?").run(status, now, id);
243
+ }
244
+ }
245
+
246
+ // Set completed_at on terminal status (idempotent — only if NULL)
247
+ if (TERMINAL_STATUSES.has(status)) {
248
+ db.query("UPDATE plans SET completed_at = ? WHERE id = ? AND completed_at IS NULL").run(now, id);
249
+ }
250
+
251
+ return getPlan(db, id);
252
+ }
253
+
254
+ /**
255
+ * Approve a plan. Optionally links a session (Fix #8) and records who approved it.
256
+ *
257
+ * The sessionId is validated/auto-created when linking (approve always links).
258
+ * Uses ensureSession for idempotent FK integrity (Fix #1 hybrid).
259
+ *
260
+ * @see Plan.sessionId — foreign key constraint enforced at app level (Fix #1)
261
+ * @see ensureSession — idempotent auto-creation of session rows (hybrid fix)
262
+ */
263
+ export function approvePlan(
264
+ db: Database,
265
+ id: string,
266
+ opts: { updatedBy?: string; sessionId?: string } = {},
267
+ ): Plan | null {
268
+ const now = Date.now();
269
+
270
+ // Fix #1 (scoped): validate sessionId when linking (Fix #8 — approve always links)
271
+ if (opts.sessionId !== undefined) {
272
+ ensureSession(db, opts.sessionId, "auto-created for plan approval");
273
+ }
274
+
275
+ // Fix #8: auto-link session when approving
276
+ if (opts.sessionId !== undefined) {
277
+ db.query(
278
+ "UPDATE plans SET status = 'approved', approved_at = ?, updated_at = ?, updated_by = ?, session_id = ? WHERE id = ?",
279
+ ).run(now, now, opts.updatedBy ?? "unknown", opts.sessionId, id);
280
+ } else if (opts.updatedBy !== undefined) {
281
+ db.query(
282
+ "UPDATE plans SET status = 'approved', approved_at = ?, updated_at = ?, updated_by = ? WHERE id = ?",
283
+ ).run(now, now, opts.updatedBy, id);
284
+ } else {
285
+ db.query(
286
+ "UPDATE plans SET status = 'approved', approved_at = ?, updated_at = ? WHERE id = ?",
287
+ ).run(now, now, id);
288
+ }
289
+ return getPlan(db, id);
290
+ }
291
+
292
+ // ─── Plan deletion ──────────────────────────────────────────────────────────
293
+
294
+ export interface DeletePlanOpts {
295
+ /** Required confirmation flag — must be true to proceed */
296
+ confirm: boolean;
297
+ }
298
+
299
+ export interface DeletePlanResult {
300
+ planId: string;
301
+ slug: string;
302
+ tasksDeleted: number;
303
+ sessionsUnlinked: number;
304
+ filesDeleted: number;
305
+ }
306
+
307
+ /**
308
+ * Delete a plan and all associated data (CASCADE).
309
+ *
310
+ * Guards:
311
+ * - Requires confirm: true
312
+ * - Rejects if plan.status === 'draft' (use abandonPlan instead)
313
+ * - Rejects if any tasks are 'pending' or 'running'
314
+ *
315
+ * The actual deletion relies on ON DELETE CASCADE in the schema:
316
+ * - plan_tasks (FK plan_id)
317
+ * - plan_files (FK plan_id)
318
+ * - plan_tags (FK plan_id)
319
+ * - sessions.plan_id (SET NULL)
320
+ */
321
+ export function deletePlan(db: Database, planId: string, opts: DeletePlanOpts): DeletePlanResult {
322
+ if (!opts.confirm) {
323
+ throw new Error("ndomo: deletePlan requires confirm: true");
324
+ }
325
+
326
+ const plan = getPlan(db, planId);
327
+ if (!plan) {
328
+ throw new Error(`ndomo: plan not found: ${planId}`);
329
+ }
330
+
331
+ if (plan.status === "draft") {
332
+ throw new Error("ndomo: cannot delete a draft plan — use abandonPlan or approve first");
333
+ }
334
+
335
+ // Check for active tasks
336
+ const activeTasks = db
337
+ .query<{ count: number }, [string]>(
338
+ "SELECT COUNT(*) as count FROM plan_tasks WHERE plan_id = ? AND status IN ('pending', 'running') AND archived_at IS NULL",
339
+ )
340
+ .get(planId);
341
+
342
+ if (activeTasks && activeTasks.count > 0) {
343
+ throw new Error(
344
+ `ndomo: cannot delete plan with ${activeTasks.count} active task(s) — complete or fail them first`,
345
+ );
346
+ }
347
+
348
+ // Count related records before deletion
349
+ const tasksCount =
350
+ db
351
+ .query<{ count: number }, [string]>(
352
+ "SELECT COUNT(*) as count FROM plan_tasks WHERE plan_id = ?",
353
+ )
354
+ .get(planId)?.count ?? 0;
355
+
356
+ const sessionsCount =
357
+ db
358
+ .query<{ count: number }, [string]>(
359
+ "SELECT COUNT(*) as count FROM sessions WHERE plan_id = ?",
360
+ )
361
+ .get(planId)?.count ?? 0;
362
+
363
+ const filesCount =
364
+ db
365
+ .query<{ count: number }, [string]>(
366
+ "SELECT COUNT(*) as count FROM plan_files WHERE plan_id = ?",
367
+ )
368
+ .get(planId)?.count ?? 0;
369
+
370
+ // Delete — CASCADE handles plan_tasks, plan_files, plan_tags
371
+ // sessions.plan_id is SET NULL per schema
372
+ db.query("DELETE FROM plans WHERE id = ?").run(planId);
373
+
374
+ return {
375
+ planId,
376
+ slug: plan.slug,
377
+ tasksDeleted: tasksCount,
378
+ sessionsUnlinked: sessionsCount,
379
+ filesDeleted: filesCount,
380
+ };
381
+ }
382
+
383
+ // ─── Write-once executed_by helpers ──────────────────────────────────────────
384
+
385
+ /**
386
+ * Write-once: set executed_by_agent and executed_by_session on a plan.
387
+ * Uses COALESCE so the first write wins — subsequent calls are no-ops.
388
+ */
389
+ export function setExecutedByOnce(
390
+ db: Database,
391
+ planId: string,
392
+ agent: string,
393
+ sessionId?: string | null,
394
+ ): void {
395
+ db.query(
396
+ `UPDATE plans SET
397
+ executed_by_agent = COALESCE(executed_by_agent, ?),
398
+ executed_by_session = COALESCE(executed_by_session, ?)
399
+ WHERE id = ? AND archived_at IS NULL`,
400
+ ).run(agent, sessionId ?? null, planId);
401
+ }
402
+
403
+ // ─── Tag helpers ─────────────────────────────────────────────────────────────
404
+
405
+ export function addPlanTag(db: Database, planId: string, tag: string, addedBy: string): void {
406
+ db.query(
407
+ "INSERT OR IGNORE INTO plan_tags (plan_id, tag, added_by, added_at) VALUES (?, ?, ?, ?)",
408
+ ).run(planId, tag, addedBy, Date.now());
409
+ }
410
+
411
+ export function removePlanTag(db: Database, planId: string, tag: string): void {
412
+ db.query("DELETE FROM plan_tags WHERE plan_id = ? AND tag = ?").run(planId, tag);
413
+ }
414
+
415
+ export function getPlanTags(
416
+ db: Database,
417
+ planId: string,
418
+ ): Array<{ tag: string; addedBy: string; addedAt: number }> {
419
+ const rows = db
420
+ .query("SELECT tag, added_by, added_at FROM plan_tags WHERE plan_id = ? ORDER BY tag")
421
+ .all(planId) as Array<{ tag: string; added_by: string; added_at: number }>;
422
+ return rows.map((r) => ({ tag: r.tag, addedBy: r.added_by, addedAt: r.added_at }));
423
+ }
424
+
425
+ export function findPlansByTag(
426
+ db: Database,
427
+ tag: string,
428
+ limit = 20,
429
+ opts: { includeArchived?: boolean } = {},
430
+ ): Plan[] {
431
+ const archiveFilter = opts.includeArchived ? "" : "AND p.archived_at IS NULL";
432
+ const rows = db
433
+ .query(
434
+ `SELECT p.* FROM plans p
435
+ JOIN plan_tags pt ON p.id = pt.plan_id
436
+ WHERE pt.tag = ? ${archiveFilter}
437
+ ORDER BY p.created_at DESC
438
+ LIMIT ?`,
439
+ )
440
+ .all(tag, limit);
441
+ return (rows as unknown[]).map(planFromRow);
442
+ }
443
+
444
+ export function findPlansByCategory(
445
+ db: Database,
446
+ category: PlanCategory,
447
+ limit = 20,
448
+ opts: { includeArchived?: boolean } = {},
449
+ ): Plan[] {
450
+ const archiveFilter = opts.includeArchived ? "" : "AND archived_at IS NULL";
451
+ const rows = db
452
+ .query(
453
+ `SELECT * FROM plans WHERE category = ? ${archiveFilter} ORDER BY created_at DESC LIMIT ?`,
454
+ )
455
+ .all(category, limit);
456
+ return (rows as unknown[]).map(planFromRow);
457
+ }
458
+
459
+ // ─── Plan progress (v4) ──────────────────────────────────────────────────────
460
+
461
+ export interface PlanProgress {
462
+ planId: string;
463
+ slug: string;
464
+ title: string;
465
+ status: string;
466
+ totalTasks: number;
467
+ done: number;
468
+ failed: number;
469
+ running: number;
470
+ pending: number;
471
+ blocked: number;
472
+ progressPct: number;
473
+ }
474
+
475
+ interface PlanProgressRow {
476
+ plan_id: string;
477
+ slug: string;
478
+ title: string;
479
+ status: string;
480
+ total_tasks: number;
481
+ done: number;
482
+ failed: number;
483
+ running: number;
484
+ pending: number;
485
+ blocked: number;
486
+ progress_pct: number;
487
+ }
488
+
489
+ function planProgressFromRow(row: unknown): PlanProgress {
490
+ const r = row as PlanProgressRow;
491
+ return {
492
+ planId: r.plan_id,
493
+ slug: r.slug,
494
+ title: r.title,
495
+ status: r.status,
496
+ totalTasks: r.total_tasks,
497
+ done: r.done,
498
+ failed: r.failed,
499
+ running: r.running,
500
+ pending: r.pending,
501
+ blocked: r.blocked,
502
+ progressPct: r.progress_pct,
503
+ };
504
+ }
505
+
506
+ /**
507
+ * Query the plan_progress view for task aggregation.
508
+ *
509
+ * @param db - Database instance
510
+ * @param planId - optional plan id to filter by; omit for all plans
511
+ */
512
+ export function getPlanProgress(db: Database, planId?: string): PlanProgress[] {
513
+ const sql =
514
+ planId !== undefined
515
+ ? "SELECT * FROM plan_progress_active WHERE plan_id = ?"
516
+ : "SELECT * FROM plan_progress_active";
517
+ const rows = planId !== undefined ? db.query(sql).all(planId) : db.query(sql).all();
518
+ return (rows as unknown[]).map(planProgressFromRow);
519
+ }
520
+
521
+ /**
522
+ * Query the plan_progress_historical view for ALL plans (including archived).
523
+ *
524
+ * @param db - Database instance
525
+ * @param planId - optional plan id to filter by; omit for all plans
526
+ */
527
+ export function getPlanProgressHistorical(db: Database, planId?: string): PlanProgress[] {
528
+ const sql =
529
+ planId !== undefined
530
+ ? "SELECT * FROM plan_progress_historical WHERE plan_id = ?"
531
+ : "SELECT * FROM plan_progress_historical";
532
+ const rows = planId !== undefined ? db.query(sql).all(planId) : db.query(sql).all();
533
+ return (rows as unknown[]).map(planProgressFromRow);
534
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Tests for resolveProjectDir() — plan bb805ff9.
3
+ *
4
+ * Validates the full resolution chain: worktree → directory → process.cwd(),
5
+ * including the pgadmin bug scenario (ctx.directory="/" → cwd fallback).
6
+ * Uses mkdtempSync + process.chdir for deterministic cwd mocking; original
7
+ * cwd is always restored in afterEach.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { resolveProjectDir } from "./resolve-project-dir.ts";
15
+
16
+ describe("resolveProjectDir", () => {
17
+ let originalCwd: string;
18
+ let tmpDir: string;
19
+
20
+ beforeEach(() => {
21
+ originalCwd = process.cwd();
22
+ tmpDir = mkdtempSync(join(tmpdir(), "ndomo-resolve-test-"));
23
+ });
24
+
25
+ afterEach(() => {
26
+ process.chdir(originalCwd);
27
+ rmSync(tmpDir, { recursive: true, force: true });
28
+ });
29
+
30
+ // ─── Happy path: worktree / directory precedence ─────────────────────────
31
+
32
+ test("(a) worktree wins over directory + cwd", () => {
33
+ process.chdir(tmpDir);
34
+ const result = resolveProjectDir({
35
+ worktree: "/home/user/worktree",
36
+ directory: "/home/user/project",
37
+ });
38
+ expect(result).toBe("/home/user/worktree");
39
+ });
40
+
41
+ test("(b) directory wins when worktree is undefined", () => {
42
+ process.chdir(tmpDir);
43
+ const result = resolveProjectDir({
44
+ worktree: undefined,
45
+ directory: "/home/user/project",
46
+ });
47
+ expect(result).toBe("/home/user/project");
48
+ });
49
+
50
+ test("(b2) directory wins when worktree is empty string", () => {
51
+ process.chdir(tmpDir);
52
+ const result = resolveProjectDir({
53
+ worktree: "",
54
+ directory: "/home/user/project",
55
+ });
56
+ expect(result).toBe("/home/user/project");
57
+ });
58
+
59
+ test("(b3) directory wins when worktree is '/' (invalid)", () => {
60
+ process.chdir(tmpDir);
61
+ const result = resolveProjectDir({
62
+ worktree: "/",
63
+ directory: "/home/user/project",
64
+ });
65
+ expect(result).toBe("/home/user/project");
66
+ });
67
+
68
+ // ─── Fallback to process.cwd() ───────────────────────────────────────────
69
+
70
+ test("(c) cwd fallback when directory is '/' (pgadmin bug)", () => {
71
+ process.chdir(tmpDir);
72
+ const result = resolveProjectDir({
73
+ worktree: undefined,
74
+ directory: "/",
75
+ });
76
+ expect(result).toBe(tmpDir);
77
+ });
78
+
79
+ test("(c2) cwd fallback is silent (no console.warn)", () => {
80
+ process.chdir(tmpDir);
81
+ const originalWarn = console.warn;
82
+ const warnings: string[] = [];
83
+ console.warn = (...args: unknown[]) => {
84
+ warnings.push(args.map(String).join(" "));
85
+ };
86
+ try {
87
+ const result = resolveProjectDir({ worktree: undefined, directory: "/" });
88
+ expect(result).toBe(tmpDir);
89
+ expect(warnings.length).toBe(0);
90
+ } finally {
91
+ console.warn = originalWarn;
92
+ }
93
+ });
94
+
95
+ test("(d) cwd fallback when directory is undefined", () => {
96
+ process.chdir(tmpDir);
97
+ const result = resolveProjectDir({
98
+ worktree: undefined,
99
+ directory: undefined,
100
+ });
101
+ expect(result).toBe(tmpDir);
102
+ });
103
+
104
+ test("(e) cwd fallback when directory is empty string", () => {
105
+ process.chdir(tmpDir);
106
+ const result = resolveProjectDir({
107
+ worktree: undefined,
108
+ directory: "",
109
+ });
110
+ expect(result).toBe(tmpDir);
111
+ });
112
+
113
+ test("(e2) cwd fallback when both worktree and directory are '/'", () => {
114
+ process.chdir(tmpDir);
115
+ const result = resolveProjectDir({
116
+ worktree: "/",
117
+ directory: "/",
118
+ });
119
+ expect(result).toBe(tmpDir);
120
+ });
121
+
122
+ // ─── Total failure ───────────────────────────────────────────────────────
123
+
124
+ test("(f) throws when even cwd is invalid ('/')", () => {
125
+ process.chdir("/");
126
+ expect(() =>
127
+ resolveProjectDir({
128
+ worktree: undefined,
129
+ directory: "/",
130
+ }),
131
+ ).toThrow(/no valid project directory/);
132
+ });
133
+
134
+ test("(f2) throws when both ctx paths and cwd are all invalid", () => {
135
+ process.chdir("/");
136
+ expect(() =>
137
+ resolveProjectDir({
138
+ worktree: "/",
139
+ directory: "",
140
+ }),
141
+ ).toThrow(/no valid project directory/);
142
+ });
143
+ });