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,2574 @@
1
+ /**
2
+ * Tests for plugin helpers: escalateToForeman (M2) and reconcileAbandonedPlans (M3).
3
+ *
4
+ * Uses in-memory SQLite via bun:sqlite. Each test gets a fresh DB
5
+ * with the full schema applied by runMigrations.
6
+ */
7
+
8
+ import { Database } from "bun:sqlite";
9
+ import { beforeEach, describe, expect, test } from "bun:test";
10
+ import { AutoCheckpointDispatcher } from "./db/auto-checkpoint.ts";
11
+ import {
12
+ archiveAnalysis,
13
+ createAnalysis,
14
+ getAnalysis,
15
+ linkAnalysisToPlan,
16
+ listAnalyses,
17
+ searchAnalyses,
18
+ unlinkAnalysisFromPlan,
19
+ updateAnalysis,
20
+ } from "./db/analyses.ts";
21
+ import { createIncident } from "./db/incidents.ts";
22
+ import { runMigrations } from "./db/migrations.ts";
23
+ import { planCreateExecutor } from "./db/plan-create.ts";
24
+ import { planUpdateStatusExecutor } from "./db/plan-update-status.ts";
25
+ import { createPlan, getPlan } from "./db/plans.ts";
26
+ import { recordRollback } from "./db/rollbacks.ts";
27
+ import { getSession, startSession } from "./db/sessions.ts";
28
+ import {
29
+ createTasksBatch,
30
+ listTasksByPlan,
31
+ nextTaskForAgent,
32
+ resolveTaskDependencies,
33
+ updateTaskStatus,
34
+ } from "./db/tasks.ts";
35
+ import type { Plan } from "./db/types.ts";
36
+ import { escalateToForeman, FileLock, reconcileAbandonedPlans } from "./plugin.ts";
37
+
38
+ let db: Database;
39
+
40
+ beforeEach(() => {
41
+ db = new Database(":memory:");
42
+ db.exec("PRAGMA foreign_keys = ON");
43
+ runMigrations(db);
44
+ });
45
+
46
+ // ─── escalateToForeman (M2) ──────────────────────────────────────────────────
47
+
48
+ describe("escalateToForeman", () => {
49
+ test("creates plan stub with metadata.escalatedFrom=null when no sourcePlanId", () => {
50
+ const result = escalateToForeman(
51
+ db,
52
+ { agent: "craftsman", sessionID: "ses_esc_1" },
53
+ { reason: "too complex for craftsman" },
54
+ );
55
+
56
+ expect(result.escalationPlanId).toBeTruthy();
57
+ expect(result.notificationSent).toBe(true);
58
+
59
+ const plan = getPlan(db, result.escalationPlanId);
60
+ expect(plan).not.toBeNull();
61
+ expect(plan?.title).toBe("Escalation: too complex for craftsman");
62
+ expect(plan?.overview).toBe("too complex for craftsman");
63
+ expect(plan?.status).toBe("draft");
64
+ // Metadata should have escalatedFrom=null
65
+ const meta = plan?.metadata as Record<string, unknown>;
66
+ expect(meta.escalatedFrom).toBeNull();
67
+ expect(meta.escalatedBy).toBe("craftsman");
68
+ expect(meta.reason).toBe("too complex for craftsman");
69
+ });
70
+
71
+ test("creates plan stub referencing original plan in metadata", () => {
72
+ const result = escalateToForeman(
73
+ db,
74
+ { agent: "craftsman", sessionID: "ses_esc_2" },
75
+ { sourcePlanId: "plan_original_123", reason: "needs DB migration" },
76
+ );
77
+
78
+ const plan = getPlan(db, result.escalationPlanId);
79
+ expect(plan).not.toBeNull();
80
+ const meta = plan?.metadata as Record<string, unknown> | undefined;
81
+ expect(meta?.escalatedFrom).toBe("plan_original_123");
82
+ expect(meta?.escalatedBy).toBe("craftsman");
83
+ });
84
+
85
+ test("creates foreman task when sourceTaskId is provided", () => {
86
+ const result = escalateToForeman(
87
+ db,
88
+ { agent: "craftsman", sessionID: "ses_esc_3" },
89
+ {
90
+ sourcePlanId: "plan_orig",
91
+ sourceTaskId: "task_orig_456",
92
+ reason: "cross-stack refactor",
93
+ },
94
+ );
95
+
96
+ const tasks = listTasksByPlan(db, result.escalationPlanId);
97
+ expect(tasks).toHaveLength(1);
98
+ expect(tasks[0]?.agent).toBe("foreman");
99
+ expect(tasks[0]?.description).toBe("cross-stack refactor");
100
+ expect(tasks[0]?.status).toBe("pending");
101
+ });
102
+
103
+ test("does NOT create task when sourceTaskId is absent", () => {
104
+ const result = escalateToForeman(
105
+ db,
106
+ { agent: "craftsman", sessionID: "ses_esc_4" },
107
+ { reason: "just escalate" },
108
+ );
109
+
110
+ const tasks = listTasksByPlan(db, result.escalationPlanId);
111
+ expect(tasks).toHaveLength(0);
112
+ });
113
+
114
+ test("creates session_checkpoint with escalation reason", () => {
115
+ const sessionId = "ses_esc_5";
116
+ startSession(db, { id: sessionId, goal: "original goal" });
117
+
118
+ escalateToForeman(
119
+ db,
120
+ { agent: "craftsman", sessionID: sessionId },
121
+ { reason: "complex DB migration needed" },
122
+ );
123
+
124
+ const session = getSession(db, sessionId);
125
+ expect(session).not.toBeNull();
126
+ expect(session?.keyDecisions).toContain("escalated by craftsman: complex DB migration needed");
127
+ });
128
+
129
+ test("skips checkpoint when no sessionID", () => {
130
+ // Should not throw even without sessionID
131
+ const result = escalateToForeman(db, { agent: "craftsman" }, { reason: "no session" });
132
+ expect(result.notificationSent).toBe(true);
133
+ });
134
+
135
+ test("stores suggestedApproach in plan", () => {
136
+ const result = escalateToForeman(
137
+ db,
138
+ { agent: "craftsman", sessionID: "ses_esc_6" },
139
+ { reason: "complex", suggestedApproach: "use factory pattern" },
140
+ );
141
+
142
+ const plan = getPlan(db, result.escalationPlanId);
143
+ expect(plan?.approach).toBe("use factory pattern");
144
+ });
145
+
146
+ test("generates unique slug per escalation", () => {
147
+ const r1 = escalateToForeman(
148
+ db,
149
+ { agent: "craftsman", sessionID: "ses_esc_7a" },
150
+ { reason: "first" },
151
+ );
152
+ const r2 = escalateToForeman(
153
+ db,
154
+ { agent: "craftsman", sessionID: "ses_esc_7b" },
155
+ { reason: "second" },
156
+ );
157
+
158
+ const p1 = getPlan(db, r1.escalationPlanId);
159
+ const p2 = getPlan(db, r2.escalationPlanId);
160
+ expect(p1?.slug).not.toBe(p2?.slug);
161
+ expect(p1?.slug).toMatch(/^escalation-/);
162
+ expect(p2?.slug).toMatch(/^escalation-/);
163
+ });
164
+ });
165
+
166
+ // ─── reconcileAbandonedPlans (M3) ────────────────────────────────────────────
167
+
168
+ describe("reconcileAbandonedPlans", () => {
169
+ test("abandons executing plans in the session", () => {
170
+ const sessionId = "ses_recon_1";
171
+ startSession(db, { id: sessionId, goal: "test" });
172
+
173
+ // Create a plan in executing status linked to this session
174
+ const plan = escalateToForeman(
175
+ db,
176
+ { agent: "craftsman", sessionID: sessionId },
177
+ { reason: "test escalation" },
178
+ );
179
+ // Move it to executing
180
+ db.query("UPDATE plans SET status = 'executing', session_id = ? WHERE id = ?").run(
181
+ sessionId,
182
+ plan.escalationPlanId,
183
+ );
184
+
185
+ const count = reconcileAbandonedPlans(db, sessionId, "foreman");
186
+ expect(count).toBe(1);
187
+
188
+ const abandoned = getPlan(db, plan.escalationPlanId);
189
+ expect(abandoned?.status).toBe("abandoned");
190
+ const meta = abandoned?.metadata as Record<string, unknown> | undefined;
191
+ expect(meta?.reason).toBe("session_ended");
192
+ expect(meta?.endedBy).toBe("foreman");
193
+ });
194
+
195
+ test("abandons approved plans in the session", () => {
196
+ const sessionId = "ses_recon_2";
197
+ startSession(db, { id: sessionId, goal: "test" });
198
+
199
+ const plan = escalateToForeman(
200
+ db,
201
+ { agent: "craftsman", sessionID: sessionId },
202
+ { reason: "test" },
203
+ );
204
+ db.query("UPDATE plans SET status = 'approved', session_id = ? WHERE id = ?").run(
205
+ sessionId,
206
+ plan.escalationPlanId,
207
+ );
208
+
209
+ const count = reconcileAbandonedPlans(db, sessionId, "agent-x");
210
+ expect(count).toBe(1);
211
+
212
+ const abandoned = getPlan(db, plan.escalationPlanId);
213
+ expect(abandoned?.status).toBe("abandoned");
214
+ });
215
+
216
+ test("does NOT abandon completed plans", () => {
217
+ const sessionId = "ses_recon_3";
218
+ startSession(db, { id: sessionId, goal: "test" });
219
+
220
+ const plan = escalateToForeman(
221
+ db,
222
+ { agent: "craftsman", sessionID: sessionId },
223
+ { reason: "test" },
224
+ );
225
+ db.query("UPDATE plans SET status = 'completed', session_id = ? WHERE id = ?").run(
226
+ sessionId,
227
+ plan.escalationPlanId,
228
+ );
229
+
230
+ const count = reconcileAbandonedPlans(db, sessionId, "foreman");
231
+ expect(count).toBe(0);
232
+
233
+ const unchanged = getPlan(db, plan.escalationPlanId);
234
+ expect(unchanged?.status).toBe("completed");
235
+ });
236
+
237
+ test("does NOT abandon failed plans", () => {
238
+ const sessionId = "ses_recon_4";
239
+ startSession(db, { id: sessionId, goal: "test" });
240
+
241
+ const plan = escalateToForeman(
242
+ db,
243
+ { agent: "craftsman", sessionID: sessionId },
244
+ { reason: "test" },
245
+ );
246
+ db.query("UPDATE plans SET status = 'failed', session_id = ? WHERE id = ?").run(
247
+ sessionId,
248
+ plan.escalationPlanId,
249
+ );
250
+
251
+ const count = reconcileAbandonedPlans(db, sessionId, "foreman");
252
+ expect(count).toBe(0);
253
+ });
254
+
255
+ test("does NOT touch plans from other sessions", () => {
256
+ const sessionId = "ses_recon_5";
257
+ const otherSessionId = "ses_other_5";
258
+ startSession(db, { id: sessionId, goal: "test" });
259
+ startSession(db, { id: otherSessionId, goal: "other" });
260
+
261
+ const plan = escalateToForeman(
262
+ db,
263
+ { agent: "craftsman", sessionID: otherSessionId },
264
+ { reason: "other session plan" },
265
+ );
266
+ db.query("UPDATE plans SET status = 'executing', session_id = ? WHERE id = ?").run(
267
+ otherSessionId,
268
+ plan.escalationPlanId,
269
+ );
270
+
271
+ const count = reconcileAbandonedPlans(db, sessionId, "foreman");
272
+ expect(count).toBe(0);
273
+
274
+ const untouched = getPlan(db, plan.escalationPlanId);
275
+ expect(untouched?.status).toBe("executing");
276
+ });
277
+
278
+ test("returns 0 when no plans match", () => {
279
+ const sessionId = "ses_recon_6";
280
+ startSession(db, { id: sessionId, goal: "test" });
281
+
282
+ const count = reconcileAbandonedPlans(db, sessionId, "foreman");
283
+ expect(count).toBe(0);
284
+ });
285
+
286
+ test("abandons multiple plans at once", () => {
287
+ const sessionId = "ses_recon_7";
288
+ startSession(db, { id: sessionId, goal: "test" });
289
+
290
+ const p1 = escalateToForeman(
291
+ db,
292
+ { agent: "craftsman", sessionID: sessionId },
293
+ { reason: "first" },
294
+ );
295
+ const p2 = escalateToForeman(
296
+ db,
297
+ { agent: "craftsman", sessionID: sessionId },
298
+ { reason: "second" },
299
+ );
300
+ db.query("UPDATE plans SET status = 'executing', session_id = ? WHERE id = ?").run(
301
+ sessionId,
302
+ p1.escalationPlanId,
303
+ );
304
+ db.query("UPDATE plans SET status = 'approved', session_id = ? WHERE id = ?").run(
305
+ sessionId,
306
+ p2.escalationPlanId,
307
+ );
308
+
309
+ const count = reconcileAbandonedPlans(db, sessionId, "foreman");
310
+ expect(count).toBe(2);
311
+ });
312
+ });
313
+
314
+ // ─── Helper ──────────────────────────────────────────────────────────────────
315
+
316
+ function makePlan(overrides: Partial<Parameters<typeof createPlan>[1]> = {}): Plan {
317
+ return createPlan(db, {
318
+ id: crypto.randomUUID(),
319
+ slug: "test-plan",
320
+ title: "Test",
321
+ status: "draft",
322
+ priority: 2,
323
+ approvedAt: null,
324
+ completedAt: null,
325
+ sessionId: null,
326
+ overview: "test",
327
+ approach: null,
328
+ complexity: 3,
329
+ createdBy: "test",
330
+ updatedBy: "test",
331
+ sourceSessionId: null,
332
+ sourceMessageId: null,
333
+ category: null,
334
+ metadata: {},
335
+ archivedAt: null,
336
+ ...overrides,
337
+ });
338
+ }
339
+
340
+ // ─── plan_create — created_by_agent default (T1) ─────────────────────────────
341
+
342
+ describe("plan_create — created_by_agent default (T1)", () => {
343
+ test("sets created_by_agent from ctx.agent", () => {
344
+ const plan = planCreateExecutor(
345
+ db,
346
+ { slug: "agent-test", title: "Agent Test", overview: "test", priority: 2 },
347
+ { agent: "craftsman" },
348
+ );
349
+
350
+ const fetched = db.query("SELECT created_by_agent FROM plans WHERE id = ?").get(plan.id) as {
351
+ created_by_agent: string | null;
352
+ };
353
+ expect(fetched.created_by_agent).toBe("craftsman");
354
+ });
355
+
356
+ test("sets created_by_agent to null when ctx.agent undefined", () => {
357
+ const plan = planCreateExecutor(
358
+ db,
359
+ { slug: "no-agent", title: "No Agent", overview: "test", priority: 2 },
360
+ {},
361
+ );
362
+
363
+ const fetched = db.query("SELECT created_by_agent FROM plans WHERE id = ?").get(plan.id) as {
364
+ created_by_agent: string | null;
365
+ };
366
+ expect(fetched.created_by_agent).toBeNull();
367
+ });
368
+
369
+ test("plugin wrapper forces 'unknown' when ctx.agent missing", () => {
370
+ // The plugin wraps: planCreateExecutor(db, args, { ...ctx, agent: ctx.agent ?? "unknown" })
371
+ // So calling with agent: "unknown" simulates the wrapper behavior
372
+ const plan = planCreateExecutor(
373
+ db,
374
+ { slug: "wrapper-test", title: "Wrapper", overview: "test", priority: 2 },
375
+ { agent: "unknown" },
376
+ );
377
+
378
+ const fetched = db.query("SELECT created_by_agent FROM plans WHERE id = ?").get(plan.id) as {
379
+ created_by_agent: string | null;
380
+ };
381
+ expect(fetched.created_by_agent).toBe("unknown");
382
+ });
383
+ });
384
+
385
+ // ─── task_peek_for_agent logic (T1) ──────────────────────────────────────────
386
+
387
+ describe("task_peek_for_agent logic (T1)", () => {
388
+ const PEEK_SQL = `SELECT * FROM plan_tasks WHERE agent = ? AND status = 'pending' AND archived_at IS NULL ORDER BY order_index`;
389
+ const PEEK_SQL_WITH_PLAN = `SELECT * FROM plan_tasks WHERE agent = ? AND plan_id = ? AND status = 'pending' AND archived_at IS NULL ORDER BY order_index`;
390
+
391
+ test("returns pending tasks for agent without claiming", () => {
392
+ const plan = makePlan({ slug: "peek-1" });
393
+ createTasksBatch(db, plan.id, [
394
+ {
395
+ orderIndex: 0,
396
+ description: "task a",
397
+ agent: "js-smith",
398
+ files: [],
399
+ complexity: 1,
400
+ dependencies: [],
401
+ createdBy: "test",
402
+ updatedBy: "test",
403
+ sourceSessionId: null,
404
+ sourceMessageId: null,
405
+ reviewedBy: null,
406
+ tokensUsed: null,
407
+ durationMs: null,
408
+ artifacts: [],
409
+ metadata: {},
410
+ },
411
+ {
412
+ orderIndex: 1,
413
+ description: "task b",
414
+ agent: "js-smith",
415
+ files: [],
416
+ complexity: 1,
417
+ dependencies: [],
418
+ createdBy: "test",
419
+ updatedBy: "test",
420
+ sourceSessionId: null,
421
+ sourceMessageId: null,
422
+ reviewedBy: null,
423
+ tokensUsed: null,
424
+ durationMs: null,
425
+ artifacts: [],
426
+ metadata: {},
427
+ },
428
+ ]);
429
+
430
+ const rows = db.query(PEEK_SQL).all("js-smith") as Array<{ status: string }>;
431
+ expect(rows).toHaveLength(2);
432
+ expect(rows[0]!.status).toBe("pending");
433
+ expect(rows[1]!.status).toBe("pending");
434
+ });
435
+
436
+ test("filters by planId when provided", () => {
437
+ const plan1 = makePlan({ slug: "peek-plan-1" });
438
+ const plan2 = makePlan({ slug: "peek-plan-2" });
439
+ createTasksBatch(db, plan1.id, [
440
+ {
441
+ orderIndex: 0,
442
+ description: "task 1",
443
+ agent: "js-smith",
444
+ files: [],
445
+ complexity: 1,
446
+ dependencies: [],
447
+ createdBy: "test",
448
+ updatedBy: "test",
449
+ sourceSessionId: null,
450
+ sourceMessageId: null,
451
+ reviewedBy: null,
452
+ tokensUsed: null,
453
+ durationMs: null,
454
+ artifacts: [],
455
+ metadata: {},
456
+ },
457
+ ]);
458
+ createTasksBatch(db, plan2.id, [
459
+ {
460
+ orderIndex: 0,
461
+ description: "task 2",
462
+ agent: "js-smith",
463
+ files: [],
464
+ complexity: 1,
465
+ dependencies: [],
466
+ createdBy: "test",
467
+ updatedBy: "test",
468
+ sourceSessionId: null,
469
+ sourceMessageId: null,
470
+ reviewedBy: null,
471
+ tokensUsed: null,
472
+ durationMs: null,
473
+ artifacts: [],
474
+ metadata: {},
475
+ },
476
+ ]);
477
+
478
+ const rows = db.query(PEEK_SQL_WITH_PLAN).all("js-smith", plan1.id) as Array<{
479
+ plan_id: string;
480
+ }>;
481
+ expect(rows).toHaveLength(1);
482
+ expect(rows[0]!.plan_id).toBe(plan1.id);
483
+ });
484
+
485
+ test("excludes archived tasks", () => {
486
+ const plan = makePlan({ slug: "peek-archived" });
487
+ const tasks = createTasksBatch(db, plan.id, [
488
+ {
489
+ orderIndex: 0,
490
+ description: "archived task",
491
+ agent: "js-smith",
492
+ files: [],
493
+ complexity: 1,
494
+ dependencies: [],
495
+ createdBy: "test",
496
+ updatedBy: "test",
497
+ sourceSessionId: null,
498
+ sourceMessageId: null,
499
+ reviewedBy: null,
500
+ tokensUsed: null,
501
+ durationMs: null,
502
+ artifacts: [],
503
+ metadata: {},
504
+ },
505
+ ]);
506
+ const taskId = tasks[0]!.id;
507
+ db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(Date.now(), taskId);
508
+
509
+ const rows = db.query(PEEK_SQL).all("js-smith") as Array<unknown>;
510
+ expect(rows).toHaveLength(0);
511
+ });
512
+
513
+ test("excludes non-pending tasks", () => {
514
+ const plan = makePlan({ slug: "peek-running" });
515
+ const tasks = createTasksBatch(db, plan.id, [
516
+ {
517
+ orderIndex: 0,
518
+ description: "running task",
519
+ agent: "js-smith",
520
+ files: [],
521
+ complexity: 1,
522
+ dependencies: [],
523
+ createdBy: "test",
524
+ updatedBy: "test",
525
+ sourceSessionId: null,
526
+ sourceMessageId: null,
527
+ reviewedBy: null,
528
+ tokensUsed: null,
529
+ durationMs: null,
530
+ artifacts: [],
531
+ metadata: {},
532
+ },
533
+ ]);
534
+ updateTaskStatus(db, tasks[0]!.id, "running", {}, "test", { agent: "js-smith" });
535
+
536
+ const rows = db.query(PEEK_SQL).all("js-smith") as Array<unknown>;
537
+ expect(rows).toHaveLength(0);
538
+ });
539
+
540
+ test("respects limit", () => {
541
+ const plan = makePlan({ slug: "peek-limit" });
542
+ const tasks = Array.from({ length: 5 }, (_, i) => ({
543
+ orderIndex: i,
544
+ description: `task ${i}`,
545
+ agent: "js-smith",
546
+ files: [] as string[],
547
+ complexity: 1,
548
+ dependencies: [] as string[],
549
+ createdBy: "test",
550
+ updatedBy: "test",
551
+ sourceSessionId: null as string | null,
552
+ sourceMessageId: null as string | null,
553
+ reviewedBy: null as string | null,
554
+ tokensUsed: null as number | null,
555
+ durationMs: null as number | null,
556
+ artifacts: [] as string[],
557
+ metadata: {},
558
+ }));
559
+ createTasksBatch(db, plan.id, tasks);
560
+
561
+ const rows = db.query(`${PEEK_SQL} LIMIT ?`).all("js-smith", 2) as Array<unknown>;
562
+ expect(rows).toHaveLength(2);
563
+ });
564
+ });
565
+
566
+ // ─── task_add_artifact logic (T1) ────────────────────────────────────────────
567
+
568
+ describe("task_add_artifact logic (T1)", () => {
569
+ function addArtifact(taskId: string, artifact: string, role?: string) {
570
+ const row = db.query("SELECT artifacts, plan_id FROM plan_tasks WHERE id = ?").get(taskId) as
571
+ | { artifacts: string; plan_id: string }
572
+ | undefined;
573
+ if (!row) throw new Error(`ndomo: task ${taskId} not found`);
574
+ const current = JSON.parse(row.artifacts) as string[];
575
+ if (current.includes(artifact)) {
576
+ return { task: null, added: false, reason: "artifact already exists" };
577
+ }
578
+ const updated = [...current, artifact];
579
+ db.query("UPDATE plan_tasks SET artifacts = ? WHERE id = ?").run(
580
+ JSON.stringify(updated),
581
+ taskId,
582
+ );
583
+ if (role) {
584
+ db.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
585
+ row.plan_id,
586
+ artifact,
587
+ role,
588
+ );
589
+ }
590
+ const updatedRow = db.query("SELECT * FROM plan_tasks WHERE id = ?").get(taskId);
591
+ return { task: updatedRow, added: true };
592
+ }
593
+
594
+ test("appends artifact to existing empty array", () => {
595
+ const plan = makePlan({ slug: "artifact-1" });
596
+ const tasks = createTasksBatch(db, plan.id, [
597
+ {
598
+ orderIndex: 0,
599
+ description: "task",
600
+ agent: "js-smith",
601
+ files: [],
602
+ complexity: 1,
603
+ dependencies: [],
604
+ createdBy: "test",
605
+ updatedBy: "test",
606
+ sourceSessionId: null,
607
+ sourceMessageId: null,
608
+ reviewedBy: null,
609
+ tokensUsed: null,
610
+ durationMs: null,
611
+ artifacts: [],
612
+ metadata: {},
613
+ },
614
+ ]);
615
+
616
+ const result = addArtifact(tasks[0]!.id, "output.ts");
617
+ expect(result.added).toBe(true);
618
+ const task = db.query("SELECT artifacts FROM plan_tasks WHERE id = ?").get(tasks[0]!.id) as {
619
+ artifacts: string;
620
+ };
621
+ expect(JSON.parse(task.artifacts)).toEqual(["output.ts"]);
622
+ });
623
+
624
+ test("appends to non-empty array", () => {
625
+ const plan = makePlan({ slug: "artifact-2" });
626
+ const tasks = createTasksBatch(db, plan.id, [
627
+ {
628
+ orderIndex: 0,
629
+ description: "task",
630
+ agent: "js-smith",
631
+ files: [],
632
+ complexity: 1,
633
+ dependencies: [],
634
+ createdBy: "test",
635
+ updatedBy: "test",
636
+ sourceSessionId: null,
637
+ sourceMessageId: null,
638
+ reviewedBy: null,
639
+ tokensUsed: null,
640
+ durationMs: null,
641
+ artifacts: ["a.ts"],
642
+ metadata: {},
643
+ },
644
+ ]);
645
+
646
+ addArtifact(tasks[0]!.id, "b.ts");
647
+ const task = db.query("SELECT artifacts FROM plan_tasks WHERE id = ?").get(tasks[0]!.id) as {
648
+ artifacts: string;
649
+ };
650
+ expect(JSON.parse(task.artifacts)).toEqual(["a.ts", "b.ts"]);
651
+ });
652
+
653
+ test("dedup — returns added:false if artifact exists", () => {
654
+ const plan = makePlan({ slug: "artifact-3" });
655
+ const tasks = createTasksBatch(db, plan.id, [
656
+ {
657
+ orderIndex: 0,
658
+ description: "task",
659
+ agent: "js-smith",
660
+ files: [],
661
+ complexity: 1,
662
+ dependencies: [],
663
+ createdBy: "test",
664
+ updatedBy: "test",
665
+ sourceSessionId: null,
666
+ sourceMessageId: null,
667
+ reviewedBy: null,
668
+ tokensUsed: null,
669
+ durationMs: null,
670
+ artifacts: ["a.ts"],
671
+ metadata: {},
672
+ },
673
+ ]);
674
+
675
+ const result = addArtifact(tasks[0]!.id, "a.ts");
676
+ expect(result.added).toBe(false);
677
+ expect(result.reason).toBe("artifact already exists");
678
+ });
679
+
680
+ test("with role — inserts into plan_files", () => {
681
+ const plan = makePlan({ slug: "artifact-4" });
682
+ const tasks = createTasksBatch(db, plan.id, [
683
+ {
684
+ orderIndex: 0,
685
+ description: "task",
686
+ agent: "js-smith",
687
+ files: [],
688
+ complexity: 1,
689
+ dependencies: [],
690
+ createdBy: "test",
691
+ updatedBy: "test",
692
+ sourceSessionId: null,
693
+ sourceMessageId: null,
694
+ reviewedBy: null,
695
+ tokensUsed: null,
696
+ durationMs: null,
697
+ artifacts: [],
698
+ metadata: {},
699
+ },
700
+ ]);
701
+
702
+ addArtifact(tasks[0]!.id, "src/x.ts", "output");
703
+ const fileRow = db
704
+ .query("SELECT * FROM plan_files WHERE plan_id = ? AND file_path = ? AND role = ?")
705
+ .get(plan.id, "src/x.ts", "output");
706
+ expect(fileRow).not.toBeNull();
707
+ });
708
+
709
+ test("task not found — throws", () => {
710
+ expect(() => addArtifact("nonexistent-id", "file.ts")).toThrow("not found");
711
+ });
712
+ });
713
+
714
+ // ─── task_review logic (T1) ──────────────────────────────────────────────────
715
+
716
+ describe("task_review logic (T1)", () => {
717
+ function reviewTask(taskId: string, reviewedBy: string, verdict: string) {
718
+ const row = db.query("SELECT status, metadata FROM plan_tasks WHERE id = ?").get(taskId) as
719
+ | { status: string; metadata: string | null }
720
+ | undefined;
721
+ if (!row) throw new Error(`ndomo: task ${taskId} not found`);
722
+ if (row.status !== "done")
723
+ throw new Error(`ndomo: task_review requires status='done', got '${row.status}'`);
724
+ const currentMeta = row.metadata ? JSON.parse(row.metadata) : {};
725
+ const updatedMeta = { ...currentMeta, reviewedVerdict: verdict };
726
+ db.query("UPDATE plan_tasks SET reviewed_by = ?, metadata = ? WHERE id = ?").run(
727
+ reviewedBy,
728
+ JSON.stringify(updatedMeta),
729
+ taskId,
730
+ );
731
+ return db.query("SELECT * FROM plan_tasks WHERE id = ?").get(taskId);
732
+ }
733
+
734
+ test("sets reviewed_by on done task", () => {
735
+ const plan = makePlan({ slug: "review-1" });
736
+ const tasks = createTasksBatch(db, plan.id, [
737
+ {
738
+ orderIndex: 0,
739
+ description: "task",
740
+ agent: "js-smith",
741
+ files: [],
742
+ complexity: 1,
743
+ dependencies: [],
744
+ createdBy: "test",
745
+ updatedBy: "test",
746
+ sourceSessionId: null,
747
+ sourceMessageId: null,
748
+ reviewedBy: null,
749
+ tokensUsed: null,
750
+ durationMs: null,
751
+ artifacts: [],
752
+ metadata: {},
753
+ },
754
+ ]);
755
+ updateTaskStatus(db, tasks[0]!.id, "done", { result: "ok" }, "test", { agent: "js-smith" });
756
+
757
+ reviewTask(tasks[0]!.id, "inspector", "approved");
758
+ const row = db.query("SELECT reviewed_by FROM plan_tasks WHERE id = ?").get(tasks[0]!.id) as {
759
+ reviewed_by: string | null;
760
+ };
761
+ expect(row.reviewed_by).toBe("inspector");
762
+ });
763
+
764
+ test("stores reviewedVerdict in metadata", () => {
765
+ const plan = makePlan({ slug: "review-2" });
766
+ const tasks = createTasksBatch(db, plan.id, [
767
+ {
768
+ orderIndex: 0,
769
+ description: "task",
770
+ agent: "js-smith",
771
+ files: [],
772
+ complexity: 1,
773
+ dependencies: [],
774
+ createdBy: "test",
775
+ updatedBy: "test",
776
+ sourceSessionId: null,
777
+ sourceMessageId: null,
778
+ reviewedBy: null,
779
+ tokensUsed: null,
780
+ durationMs: null,
781
+ artifacts: [],
782
+ metadata: {},
783
+ },
784
+ ]);
785
+ updateTaskStatus(db, tasks[0]!.id, "done", { result: "ok" }, "test", { agent: "js-smith" });
786
+
787
+ reviewTask(tasks[0]!.id, "inspector", "approved");
788
+ const row = db.query("SELECT metadata FROM plan_tasks WHERE id = ?").get(tasks[0]!.id) as {
789
+ metadata: string;
790
+ };
791
+ const meta = JSON.parse(row.metadata);
792
+ expect(meta.reviewedVerdict).toBe("approved");
793
+ });
794
+
795
+ test("preserves existing metadata", () => {
796
+ const plan = makePlan({ slug: "review-3" });
797
+ const tasks = createTasksBatch(db, plan.id, [
798
+ {
799
+ orderIndex: 0,
800
+ description: "task",
801
+ agent: "js-smith",
802
+ files: [],
803
+ complexity: 1,
804
+ dependencies: [],
805
+ createdBy: "test",
806
+ updatedBy: "test",
807
+ sourceSessionId: null,
808
+ sourceMessageId: null,
809
+ reviewedBy: null,
810
+ tokensUsed: null,
811
+ durationMs: null,
812
+ artifacts: [],
813
+ metadata: { tokensUsed: 42 },
814
+ },
815
+ ]);
816
+ updateTaskStatus(db, tasks[0]!.id, "done", { result: "ok" }, "test", { agent: "js-smith" });
817
+
818
+ reviewTask(tasks[0]!.id, "inspector", "approved");
819
+ const row = db.query("SELECT metadata FROM plan_tasks WHERE id = ?").get(tasks[0]!.id) as {
820
+ metadata: string;
821
+ };
822
+ const meta = JSON.parse(row.metadata);
823
+ expect(meta.tokensUsed).toBe(42);
824
+ expect(meta.reviewedVerdict).toBe("approved");
825
+ });
826
+
827
+ test("rejects non-done task", () => {
828
+ const plan = makePlan({ slug: "review-4" });
829
+ const tasks = createTasksBatch(db, plan.id, [
830
+ {
831
+ orderIndex: 0,
832
+ description: "task",
833
+ agent: "js-smith",
834
+ files: [],
835
+ complexity: 1,
836
+ dependencies: [],
837
+ createdBy: "test",
838
+ updatedBy: "test",
839
+ sourceSessionId: null,
840
+ sourceMessageId: null,
841
+ reviewedBy: null,
842
+ tokensUsed: null,
843
+ durationMs: null,
844
+ artifacts: [],
845
+ metadata: {},
846
+ },
847
+ ]);
848
+
849
+ expect(() => reviewTask(tasks[0]!.id, "inspector", "approved")).toThrow(
850
+ "task_review requires status='done'",
851
+ );
852
+ });
853
+
854
+ test("task not found — throws", () => {
855
+ expect(() => reviewTask("nonexistent-id", "inspector", "approved")).toThrow("not found");
856
+ });
857
+ });
858
+
859
+ // ─── plan_progress logic (T1) ────────────────────────────────────────────────
860
+
861
+ describe("plan_progress logic (T1)", () => {
862
+ test("returns all plans progress", () => {
863
+ const plan1 = makePlan({ slug: "prog-1" });
864
+ const plan2 = makePlan({ slug: "prog-2" });
865
+ createTasksBatch(db, plan1.id, [
866
+ {
867
+ orderIndex: 0,
868
+ description: "t1",
869
+ agent: "js-smith",
870
+ files: [],
871
+ complexity: 1,
872
+ dependencies: [],
873
+ createdBy: "test",
874
+ updatedBy: "test",
875
+ sourceSessionId: null,
876
+ sourceMessageId: null,
877
+ reviewedBy: null,
878
+ tokensUsed: null,
879
+ durationMs: null,
880
+ artifacts: [],
881
+ metadata: {},
882
+ },
883
+ ]);
884
+ createTasksBatch(db, plan2.id, [
885
+ {
886
+ orderIndex: 0,
887
+ description: "t2",
888
+ agent: "js-smith",
889
+ files: [],
890
+ complexity: 1,
891
+ dependencies: [],
892
+ createdBy: "test",
893
+ updatedBy: "test",
894
+ sourceSessionId: null,
895
+ sourceMessageId: null,
896
+ reviewedBy: null,
897
+ tokensUsed: null,
898
+ durationMs: null,
899
+ artifacts: [],
900
+ metadata: {},
901
+ },
902
+ {
903
+ orderIndex: 1,
904
+ description: "t3",
905
+ agent: "js-smith",
906
+ files: [],
907
+ complexity: 1,
908
+ dependencies: [],
909
+ createdBy: "test",
910
+ updatedBy: "test",
911
+ sourceSessionId: null,
912
+ sourceMessageId: null,
913
+ reviewedBy: null,
914
+ tokensUsed: null,
915
+ durationMs: null,
916
+ artifacts: [],
917
+ metadata: {},
918
+ },
919
+ ]);
920
+
921
+ const rows = db.query("SELECT * FROM plan_progress_active").all() as Array<{
922
+ plan_id: string;
923
+ total_tasks: number;
924
+ }>;
925
+ expect(rows.length).toBeGreaterThanOrEqual(2);
926
+ const p1 = rows.find((r) => r.plan_id === plan1.id);
927
+ const p2 = rows.find((r) => r.plan_id === plan2.id);
928
+ expect(p1!.total_tasks).toBe(1);
929
+ expect(p2!.total_tasks).toBe(2);
930
+ });
931
+
932
+ test("filters by planId", () => {
933
+ const plan = makePlan({ slug: "prog-filter" });
934
+ createTasksBatch(db, plan.id, [
935
+ {
936
+ orderIndex: 0,
937
+ description: "t",
938
+ agent: "js-smith",
939
+ files: [],
940
+ complexity: 1,
941
+ dependencies: [],
942
+ createdBy: "test",
943
+ updatedBy: "test",
944
+ sourceSessionId: null,
945
+ sourceMessageId: null,
946
+ reviewedBy: null,
947
+ tokensUsed: null,
948
+ durationMs: null,
949
+ artifacts: [],
950
+ metadata: {},
951
+ },
952
+ ]);
953
+
954
+ const rows = db
955
+ .query("SELECT * FROM plan_progress_active WHERE plan_id = ?")
956
+ .all(plan.id) as Array<{ plan_id: string }>;
957
+ expect(rows).toHaveLength(1);
958
+ expect(rows[0]!.plan_id).toBe(plan.id);
959
+ });
960
+
961
+ test("filters by owner via json_extract", () => {
962
+ makePlan({
963
+ slug: "prog-owner-c",
964
+ metadata: { category: "feature", ownedBy: "craftsman" } as never,
965
+ });
966
+ makePlan({
967
+ slug: "prog-owner-w",
968
+ metadata: { category: "feature", ownedBy: "warden" } as never,
969
+ });
970
+
971
+ const rows = db
972
+ .query(
973
+ `SELECT pp.* FROM plan_progress_active pp
974
+ JOIN plans p ON pp.plan_id = p.id
975
+ WHERE json_extract(p.metadata, '$.ownedBy') = ?`,
976
+ )
977
+ .all("craftsman") as Array<{ plan_id: string }>;
978
+ expect(rows).toHaveLength(1);
979
+ expect(rows[0]!.plan_id).toBe(
980
+ (db.query("SELECT id FROM plans WHERE slug = ?").get("prog-owner-c") as { id: string }).id,
981
+ );
982
+ });
983
+
984
+ test("progress_pct calculation", () => {
985
+ const plan = makePlan({ slug: "prog-pct" });
986
+ const tasks = createTasksBatch(db, plan.id, [
987
+ {
988
+ orderIndex: 0,
989
+ description: "t1",
990
+ agent: "js-smith",
991
+ files: [],
992
+ complexity: 1,
993
+ dependencies: [],
994
+ createdBy: "test",
995
+ updatedBy: "test",
996
+ sourceSessionId: null,
997
+ sourceMessageId: null,
998
+ reviewedBy: null,
999
+ tokensUsed: null,
1000
+ durationMs: null,
1001
+ artifacts: [],
1002
+ metadata: {},
1003
+ },
1004
+ {
1005
+ orderIndex: 1,
1006
+ description: "t2",
1007
+ agent: "js-smith",
1008
+ files: [],
1009
+ complexity: 1,
1010
+ dependencies: [],
1011
+ createdBy: "test",
1012
+ updatedBy: "test",
1013
+ sourceSessionId: null,
1014
+ sourceMessageId: null,
1015
+ reviewedBy: null,
1016
+ tokensUsed: null,
1017
+ durationMs: null,
1018
+ artifacts: [],
1019
+ metadata: {},
1020
+ },
1021
+ ]);
1022
+ updateTaskStatus(db, tasks[0]!.id, "done", { result: "ok" }, "test", { agent: "js-smith" });
1023
+
1024
+ const row = db.query("SELECT * FROM plan_progress_active WHERE plan_id = ?").get(plan.id) as {
1025
+ progress_pct: number;
1026
+ done: number;
1027
+ pending: number;
1028
+ };
1029
+ expect(row.done).toBe(1);
1030
+ expect(row.pending).toBe(1);
1031
+ expect(row.progress_pct).toBe(50);
1032
+ });
1033
+
1034
+ test("excludes archived plans", () => {
1035
+ const plan = makePlan({ slug: "prog-archived" });
1036
+ createTasksBatch(db, plan.id, [
1037
+ {
1038
+ orderIndex: 0,
1039
+ description: "t",
1040
+ agent: "js-smith",
1041
+ files: [],
1042
+ complexity: 1,
1043
+ dependencies: [],
1044
+ createdBy: "test",
1045
+ updatedBy: "test",
1046
+ sourceSessionId: null,
1047
+ sourceMessageId: null,
1048
+ reviewedBy: null,
1049
+ tokensUsed: null,
1050
+ durationMs: null,
1051
+ artifacts: [],
1052
+ metadata: {},
1053
+ },
1054
+ ]);
1055
+ db.query("UPDATE plans SET archived_at = ? WHERE id = ?").run(Date.now(), plan.id);
1056
+
1057
+ const rows = db
1058
+ .query("SELECT * FROM plan_progress_active WHERE plan_id = ?")
1059
+ .all(plan.id) as Array<unknown>;
1060
+ expect(rows).toHaveLength(0);
1061
+ });
1062
+ });
1063
+
1064
+ // ─── plan_files_write logic (T1) ─────────────────────────────────────────────
1065
+
1066
+ describe("plan_files_write logic (T1)", () => {
1067
+ test("inserts new files with roles", () => {
1068
+ const plan = makePlan({ slug: "files-1" });
1069
+ db.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
1070
+ plan.id,
1071
+ "src/a.ts",
1072
+ "input",
1073
+ );
1074
+ db.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
1075
+ plan.id,
1076
+ "src/b.ts",
1077
+ "output",
1078
+ );
1079
+
1080
+ const rows = db
1081
+ .query("SELECT * FROM plan_files WHERE plan_id = ?")
1082
+ .all(plan.id) as Array<unknown>;
1083
+ expect(rows).toHaveLength(2);
1084
+ });
1085
+
1086
+ test("idempotent — INSERT OR IGNORE for same (plan, file, role)", () => {
1087
+ const plan = makePlan({ slug: "files-2" });
1088
+ db.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
1089
+ plan.id,
1090
+ "src/a.ts",
1091
+ "input",
1092
+ );
1093
+ db.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
1094
+ plan.id,
1095
+ "src/a.ts",
1096
+ "input",
1097
+ );
1098
+
1099
+ const rows = db
1100
+ .query("SELECT * FROM plan_files WHERE plan_id = ?")
1101
+ .all(plan.id) as Array<unknown>;
1102
+ expect(rows).toHaveLength(1);
1103
+ });
1104
+
1105
+ test("same file different role — both inserted", () => {
1106
+ const plan = makePlan({ slug: "files-3" });
1107
+ db.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
1108
+ plan.id,
1109
+ "x.ts",
1110
+ "input",
1111
+ );
1112
+ db.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
1113
+ plan.id,
1114
+ "x.ts",
1115
+ "modified",
1116
+ );
1117
+
1118
+ const rows = db
1119
+ .query("SELECT * FROM plan_files WHERE plan_id = ?")
1120
+ .all(plan.id) as Array<unknown>;
1121
+ expect(rows).toHaveLength(2);
1122
+ });
1123
+
1124
+ test("non-existent plan — FK violation", () => {
1125
+ expect(() => {
1126
+ db.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
1127
+ "fake-plan-id",
1128
+ "file.ts",
1129
+ "output",
1130
+ );
1131
+ }).toThrow();
1132
+ });
1133
+ });
1134
+
1135
+ // ─── Integration: task_create_batch → task_add_artifact → task_review → plan_progress ──
1136
+
1137
+ describe("integration — task_create_batch → task_add_artifact → task_review → plan_progress (T1)", () => {
1138
+ test("full flow with owner filter and artifact/review tracking", () => {
1139
+ // 1. Create plan with metadata.ownedBy
1140
+ const plan = makePlan({
1141
+ slug: "integration-flow",
1142
+ metadata: { category: "feature", ownedBy: "craftsman" } as never,
1143
+ });
1144
+
1145
+ // 2. Create 2 tasks
1146
+ const tasks = createTasksBatch(db, plan.id, [
1147
+ {
1148
+ orderIndex: 0,
1149
+ description: "implement feature",
1150
+ agent: "js-smith",
1151
+ files: [],
1152
+ complexity: 2,
1153
+ dependencies: [],
1154
+ createdBy: "craftsman",
1155
+ updatedBy: "craftsman",
1156
+ sourceSessionId: null,
1157
+ sourceMessageId: null,
1158
+ reviewedBy: null,
1159
+ tokensUsed: null,
1160
+ durationMs: null,
1161
+ artifacts: [],
1162
+ metadata: {},
1163
+ },
1164
+ {
1165
+ orderIndex: 1,
1166
+ description: "write tests",
1167
+ agent: "js-smith",
1168
+ files: [],
1169
+ complexity: 2,
1170
+ dependencies: [],
1171
+ createdBy: "craftsman",
1172
+ updatedBy: "craftsman",
1173
+ sourceSessionId: null,
1174
+ sourceMessageId: null,
1175
+ reviewedBy: null,
1176
+ tokensUsed: null,
1177
+ durationMs: null,
1178
+ artifacts: [],
1179
+ metadata: {},
1180
+ },
1181
+ ]);
1182
+ expect(tasks).toHaveLength(2);
1183
+
1184
+ // 3. Move first task through running → done
1185
+ updateTaskStatus(db, tasks[0]!.id, "running", {}, "craftsman", { agent: "js-smith" });
1186
+ updateTaskStatus(db, tasks[0]!.id, "done", { result: "feature implemented" }, "craftsman", {
1187
+ agent: "js-smith",
1188
+ });
1189
+
1190
+ // 4. Add artifact to done task
1191
+ const artRow = db
1192
+ .query("SELECT artifacts, plan_id FROM plan_tasks WHERE id = ?")
1193
+ .get(tasks[0]!.id) as { artifacts: string; plan_id: string };
1194
+ const currentArtifacts = JSON.parse(artRow.artifacts) as string[];
1195
+ const updatedArtifacts = [...currentArtifacts, "output.ts"];
1196
+ db.query("UPDATE plan_tasks SET artifacts = ? WHERE id = ?").run(
1197
+ JSON.stringify(updatedArtifacts),
1198
+ tasks[0]!.id,
1199
+ );
1200
+ db.query("INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, ?)").run(
1201
+ plan.id,
1202
+ "output.ts",
1203
+ "output",
1204
+ );
1205
+
1206
+ // 5. Review the done task
1207
+ const doneRow = db
1208
+ .query("SELECT status, metadata FROM plan_tasks WHERE id = ?")
1209
+ .get(tasks[0]!.id) as { status: string; metadata: string | null };
1210
+ expect(doneRow.status).toBe("done");
1211
+ const currentMeta = doneRow.metadata ? JSON.parse(doneRow.metadata) : {};
1212
+ const updatedMeta = { ...currentMeta, reviewedVerdict: "approved" };
1213
+ db.query("UPDATE plan_tasks SET reviewed_by = ?, metadata = ? WHERE id = ?").run(
1214
+ "inspector",
1215
+ JSON.stringify(updatedMeta),
1216
+ tasks[0]!.id,
1217
+ );
1218
+
1219
+ // 6. Query plan_progress_active
1220
+ const progress = db
1221
+ .query("SELECT * FROM plan_progress_active WHERE plan_id = ?")
1222
+ .get(plan.id) as {
1223
+ total_tasks: number;
1224
+ done: number;
1225
+ pending: number;
1226
+ progress_pct: number;
1227
+ };
1228
+ expect(progress.total_tasks).toBe(2);
1229
+ expect(progress.done).toBe(1);
1230
+ expect(progress.pending).toBe(1);
1231
+ expect(progress.progress_pct).toBe(50);
1232
+
1233
+ // 7. Query with owner filter
1234
+ const ownerRows = db
1235
+ .query(
1236
+ `SELECT pp.* FROM plan_progress_active pp
1237
+ JOIN plans p ON pp.plan_id = p.id
1238
+ WHERE json_extract(p.metadata, '$.ownedBy') = ?`,
1239
+ )
1240
+ .all("craftsman") as Array<{ plan_id: string }>;
1241
+ expect(ownerRows.length).toBeGreaterThanOrEqual(1);
1242
+ expect(ownerRows.some((r) => r.plan_id === plan.id)).toBe(true);
1243
+
1244
+ // 8. Verify the done task has artifacts + review
1245
+ const finalTask = db.query("SELECT * FROM plan_tasks WHERE id = ?").get(tasks[0]!.id) as {
1246
+ artifacts: string;
1247
+ reviewed_by: string;
1248
+ metadata: string;
1249
+ };
1250
+ expect(JSON.parse(finalTask.artifacts)).toEqual(["output.ts"]);
1251
+ expect(finalTask.reviewed_by).toBe("inspector");
1252
+ expect(JSON.parse(finalTask.metadata).reviewedVerdict).toBe("approved");
1253
+ });
1254
+ });
1255
+
1256
+ // ─── T2 helpers ──────────────────────────────────────────────────────────────
1257
+
1258
+ function createTestDeployment(db: Database): string {
1259
+ db.query(
1260
+ "INSERT INTO environments (id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
1261
+ ).run("e1", "prod", "prod", Date.now(), Date.now());
1262
+ db.query("INSERT INTO releases (id, version, title, created_at) VALUES (?, ?, ?, ?)").run(
1263
+ "r1",
1264
+ "1.0.0",
1265
+ "rel",
1266
+ Date.now(),
1267
+ );
1268
+ db.query(
1269
+ "INSERT INTO deployments (id, release_id, environment_id, status, created_at) VALUES (?, ?, ?, ?, ?)",
1270
+ ).run("d1", "r1", "e1", "planned", Date.now());
1271
+ return "d1";
1272
+ }
1273
+
1274
+ // ─── incident_create tool logic (T2) ─────────────────────────────────────────
1275
+
1276
+ describe("incident_create tool logic (T2)", () => {
1277
+ test("happy path — creates incident with severity + title", () => {
1278
+ createTestDeployment(db);
1279
+ const incident = createIncident(db, {
1280
+ title: "API 500 errors",
1281
+ severity: "sev2",
1282
+ summary: "Users getting 500 on /api/data",
1283
+ });
1284
+ expect(incident.title).toBe("API 500 errors");
1285
+ expect(incident.severity).toBe("sev2");
1286
+ expect(incident.status).toBe("open");
1287
+ expect(incident.summary).toBe("Users getting 500 on /api/data");
1288
+ });
1289
+
1290
+ test("sets metadata.created_by from ctx.agent", () => {
1291
+ createTestDeployment(db);
1292
+ const incident = createIncident(db, {
1293
+ title: "test incident",
1294
+ severity: "sev3",
1295
+ metadata: { created_by: "warden" },
1296
+ });
1297
+ expect(incident.metadata?.created_by).toBe("warden");
1298
+ });
1299
+
1300
+ test("defaults created_by to 'unknown' when ctx.agent undefined", () => {
1301
+ createTestDeployment(db);
1302
+ const incident = createIncident(db, {
1303
+ title: "test incident",
1304
+ severity: "sev3",
1305
+ metadata: { created_by: "unknown" },
1306
+ });
1307
+ expect(incident.metadata?.created_by).toBe("unknown");
1308
+ });
1309
+
1310
+ test("FK error — non-existent triggeredByDeploymentId", () => {
1311
+ expect(() =>
1312
+ createIncident(db, {
1313
+ title: "bad FK",
1314
+ severity: "sev1",
1315
+ triggeredByDeploymentId: "nonexistent",
1316
+ }),
1317
+ ).toThrow("deployment 'nonexistent' not found");
1318
+ });
1319
+
1320
+ test("valid FK — triggeredByDeploymentId links to deployment", () => {
1321
+ createTestDeployment(db);
1322
+ const incident = createIncident(db, {
1323
+ title: "linked incident",
1324
+ severity: "sev2",
1325
+ triggeredByDeploymentId: "d1",
1326
+ });
1327
+ expect(incident.triggeredByDeploymentId).toBe("d1");
1328
+ });
1329
+
1330
+ test("invalid severity — throws", () => {
1331
+ expect(() =>
1332
+ createIncident(db, {
1333
+ title: "bad severity",
1334
+ severity: "sev5" as never,
1335
+ }),
1336
+ ).toThrow("invalid incident severity");
1337
+ });
1338
+ });
1339
+
1340
+ // ─── rollback_record tool logic (T2) ─────────────────────────────────────────
1341
+
1342
+ describe("rollback_record tool logic (T2)", () => {
1343
+ test("happy path — creates rollback with default status='planned'", () => {
1344
+ createTestDeployment(db);
1345
+ const rb = recordRollback(db, {
1346
+ deploymentId: "d1",
1347
+ plan: "rollback to v1.0",
1348
+ });
1349
+ expect(rb.status).toBe("planned");
1350
+ expect(rb.deploymentId).toBe("d1");
1351
+ expect(rb.plan).toBe("rollback to v1.0");
1352
+ });
1353
+
1354
+ test("explicit status='approved'", () => {
1355
+ createTestDeployment(db);
1356
+ const rb = recordRollback(db, {
1357
+ deploymentId: "d1",
1358
+ plan: "rollback approved",
1359
+ status: "approved",
1360
+ });
1361
+ expect(rb.status).toBe("approved");
1362
+ });
1363
+
1364
+ test("sets metadata.executed_by_agent from ctx.agent", () => {
1365
+ createTestDeployment(db);
1366
+ const rb = recordRollback(db, {
1367
+ deploymentId: "d1",
1368
+ plan: "test rollback",
1369
+ metadata: { executed_by_agent: "warden" },
1370
+ });
1371
+ expect(rb.metadata?.executed_by_agent).toBe("warden");
1372
+ });
1373
+
1374
+ test("FK error — non-existent deploymentId", () => {
1375
+ expect(() =>
1376
+ recordRollback(db, {
1377
+ deploymentId: "nonexistent",
1378
+ plan: "should fail",
1379
+ }),
1380
+ ).toThrow("deployment 'nonexistent' not found");
1381
+ });
1382
+
1383
+ test("FK error — non-existent incidentId", () => {
1384
+ createTestDeployment(db);
1385
+ expect(() =>
1386
+ recordRollback(db, {
1387
+ deploymentId: "d1",
1388
+ plan: "bad incident FK",
1389
+ incidentId: "nonexistent",
1390
+ }),
1391
+ ).toThrow("incident 'nonexistent' not found");
1392
+ });
1393
+
1394
+ test("FK error — non-existent newDeploymentId", () => {
1395
+ createTestDeployment(db);
1396
+ expect(() =>
1397
+ recordRollback(db, {
1398
+ deploymentId: "d1",
1399
+ plan: "bad new deploy FK",
1400
+ newDeploymentId: "nonexistent",
1401
+ }),
1402
+ ).toThrow("new_deployment 'nonexistent' not found");
1403
+ });
1404
+
1405
+ test("valid incidentId FK", () => {
1406
+ createTestDeployment(db);
1407
+ const incident = createIncident(db, {
1408
+ title: "linked",
1409
+ severity: "sev1",
1410
+ });
1411
+ const rb = recordRollback(db, {
1412
+ deploymentId: "d1",
1413
+ plan: "rollback for incident",
1414
+ incidentId: incident.id,
1415
+ });
1416
+ expect(rb.incidentId).toBe(incident.id);
1417
+ });
1418
+
1419
+ test("valid newDeploymentId FK", () => {
1420
+ db.query(
1421
+ "INSERT INTO environments (id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
1422
+ ).run("e1", "prod", "prod", Date.now(), Date.now());
1423
+ db.query("INSERT INTO releases (id, version, title, created_at) VALUES (?, ?, ?, ?)").run(
1424
+ "r1",
1425
+ "1.0.0",
1426
+ "rel",
1427
+ Date.now(),
1428
+ );
1429
+ db.query("INSERT INTO releases (id, version, title, created_at) VALUES (?, ?, ?, ?)").run(
1430
+ "r2",
1431
+ "1.0.1",
1432
+ "rel2",
1433
+ Date.now(),
1434
+ );
1435
+ db.query(
1436
+ "INSERT INTO deployments (id, release_id, environment_id, status, created_at) VALUES (?, ?, ?, ?, ?)",
1437
+ ).run("d1", "r1", "e1", "planned", Date.now());
1438
+ db.query(
1439
+ "INSERT INTO deployments (id, release_id, environment_id, status, created_at) VALUES (?, ?, ?, ?, ?)",
1440
+ ).run("d2", "r2", "e1", "planned", Date.now());
1441
+ const rb = recordRollback(db, {
1442
+ deploymentId: "d1",
1443
+ plan: "rollback to new deploy",
1444
+ newDeploymentId: "d2",
1445
+ });
1446
+ expect(rb.newDeploymentId).toBe("d2");
1447
+ });
1448
+
1449
+ test("empty plan — throws", () => {
1450
+ createTestDeployment(db);
1451
+ expect(() =>
1452
+ recordRollback(db, {
1453
+ deploymentId: "d1",
1454
+ plan: " ",
1455
+ }),
1456
+ ).toThrow("rollback plan cannot be empty");
1457
+ });
1458
+
1459
+ test("idempotency — re-record creates new row", () => {
1460
+ createTestDeployment(db);
1461
+ const rb1 = recordRollback(db, { deploymentId: "d1", plan: "first" });
1462
+ const rb2 = recordRollback(db, { deploymentId: "d1", plan: "first" });
1463
+ expect(rb1.id).not.toBe(rb2.id);
1464
+ expect(rb1.plan).toBe(rb2.plan);
1465
+ });
1466
+ });
1467
+
1468
+ // ─── Integration: incident_create → rollback_record flow (T2) ────────────────
1469
+
1470
+ describe("integration — incident_create → rollback_record flow (T2)", () => {
1471
+ test("full ops flow: deployment → incident → rollback", () => {
1472
+ // 1. Create test deployment
1473
+ createTestDeployment(db);
1474
+
1475
+ // 2. Create incident linked to deployment
1476
+ const incident = createIncident(db, {
1477
+ title: "prod down",
1478
+ severity: "sev1",
1479
+ summary: "api 500ing",
1480
+ triggeredByDeploymentId: "d1",
1481
+ });
1482
+ expect(incident.triggeredByDeploymentId).toBe("d1");
1483
+ expect(incident.severity).toBe("sev1");
1484
+
1485
+ // 3. Record rollback tied to incident
1486
+ const rb = recordRollback(db, {
1487
+ deploymentId: "d1",
1488
+ incidentId: incident.id,
1489
+ plan: "rollback to v1.0.0",
1490
+ status: "executing",
1491
+ });
1492
+ expect(rb.deploymentId).toBe("d1");
1493
+ expect(rb.incidentId).toBe(incident.id);
1494
+ expect(rb.status).toBe("executing");
1495
+
1496
+ // 4. Verify cross-links
1497
+ const incidentCheck = db.query("SELECT * FROM incidents WHERE id = ?").get(incident.id) as {
1498
+ triggered_by_deployment_id: string | null;
1499
+ };
1500
+ expect(incidentCheck.triggered_by_deployment_id).toBe("d1");
1501
+ });
1502
+ });
1503
+
1504
+ // ─── plan_update_status extended (T3.1) ─────────────────────────────────────
1505
+
1506
+ describe("plan_update_status extended (T3.1)", () => {
1507
+ /** Helper: create a plan in a given status with tasks and optional session. */
1508
+ function setupPlan(opts: {
1509
+ status: string;
1510
+ slug?: string;
1511
+ taskStatuses?: string[];
1512
+ openSessions?: string[];
1513
+ }) {
1514
+ const plan = makePlan({ slug: opts.slug ?? "t3-test" });
1515
+ // Set target status directly (makePlan creates as draft)
1516
+ if (opts.status !== "draft") {
1517
+ db.query("UPDATE plans SET status = ? WHERE id = ?").run(opts.status, plan.id);
1518
+ }
1519
+ if (opts.taskStatuses) {
1520
+ const tasks = createTasksBatch(
1521
+ db,
1522
+ plan.id,
1523
+ opts.taskStatuses.map((_, i) => ({
1524
+ orderIndex: i,
1525
+ description: `task ${i}`,
1526
+ agent: "js-smith",
1527
+ files: [] as string[],
1528
+ complexity: 1,
1529
+ dependencies: [] as string[],
1530
+ createdBy: "test",
1531
+ updatedBy: "test",
1532
+ sourceSessionId: null as string | null,
1533
+ sourceMessageId: null as string | null,
1534
+ reviewedBy: null as string | null,
1535
+ tokensUsed: null as number | null,
1536
+ durationMs: null as number | null,
1537
+ artifacts: [] as string[],
1538
+ metadata: {},
1539
+ })),
1540
+ );
1541
+ for (let i = 0; i < tasks.length; i++) {
1542
+ const st = opts.taskStatuses[i]!;
1543
+ if (st !== "pending") {
1544
+ updateTaskStatus(
1545
+ db,
1546
+ tasks[i]!.id,
1547
+ st as "running" | "done" | "failed" | "blocked",
1548
+ {},
1549
+ "test",
1550
+ { agent: "js-smith" },
1551
+ );
1552
+ }
1553
+ }
1554
+ }
1555
+ if (opts.openSessions) {
1556
+ for (const sid of opts.openSessions) {
1557
+ startSession(db, { id: sid, goal: "test session", planId: plan.id });
1558
+ }
1559
+ }
1560
+ return plan;
1561
+ }
1562
+
1563
+ const ARCHIVE_DIR = "/tmp/ndomo-test-archives-t3";
1564
+
1565
+ test("happy path — all tasks done, no open sessions → completed, archived", () => {
1566
+ const plan = setupPlan({ status: "executing", taskStatuses: ["done", "done"] });
1567
+ const result = planUpdateStatusExecutor(
1568
+ db,
1569
+ { id: plan.id, status: "completed" },
1570
+ { agent: "craftsman" },
1571
+ ARCHIVE_DIR,
1572
+ );
1573
+
1574
+ expect(result.statusChanged).toBe(true);
1575
+ expect(result.blocked).toBe(false);
1576
+ expect(result.forced).toBe(false);
1577
+ expect(result.dryRun).toBe(false);
1578
+ expect(result.blockers).toEqual([]);
1579
+ expect(result.archived).toBeTruthy();
1580
+ expect(result.archived!.planId).toBe(plan.id);
1581
+ expect(result.archiveError).toBeNull();
1582
+ expect(result.plan!.status).toBe("completed");
1583
+ });
1584
+
1585
+ test("completed_at set on terminal status — completed, failed, abandoned", () => {
1586
+ // completed
1587
+ const plan1 = setupPlan({
1588
+ status: "executing",
1589
+ taskStatuses: ["done"],
1590
+ slug: "t3-term-completed",
1591
+ });
1592
+ const r1 = planUpdateStatusExecutor(
1593
+ db,
1594
+ { id: plan1.id, status: "completed" },
1595
+ { agent: "craftsman" },
1596
+ ARCHIVE_DIR,
1597
+ );
1598
+ expect(r1.plan!.completedAt).not.toBeNull();
1599
+ expect(r1.plan!.completedAt!).toBeGreaterThan(0);
1600
+
1601
+ // failed
1602
+ const plan2 = setupPlan({
1603
+ status: "executing",
1604
+ taskStatuses: ["pending"],
1605
+ slug: "t3-term-failed",
1606
+ });
1607
+ const r2 = planUpdateStatusExecutor(
1608
+ db,
1609
+ { id: plan2.id, status: "failed" },
1610
+ { agent: "craftsman" },
1611
+ ARCHIVE_DIR,
1612
+ );
1613
+ expect(r2.plan!.completedAt).not.toBeNull();
1614
+ expect(r2.plan!.completedAt!).toBeGreaterThan(0);
1615
+
1616
+ // abandoned
1617
+ const plan3 = setupPlan({
1618
+ status: "executing",
1619
+ taskStatuses: ["done"],
1620
+ slug: "t3-term-abandoned",
1621
+ });
1622
+ const r3 = planUpdateStatusExecutor(
1623
+ db,
1624
+ { id: plan3.id, status: "abandoned" },
1625
+ { agent: "craftsman" },
1626
+ ARCHIVE_DIR,
1627
+ );
1628
+ expect(r3.plan!.completedAt).not.toBeNull();
1629
+ expect(r3.plan!.completedAt!).toBeGreaterThan(0);
1630
+ });
1631
+
1632
+ test("completed_at NOT set on non-terminal status — approved, executing", () => {
1633
+ const plan = setupPlan({ status: "draft", taskStatuses: [], slug: "t3-nonterm" });
1634
+ const r1 = planUpdateStatusExecutor(
1635
+ db,
1636
+ { id: plan.id, status: "approved" },
1637
+ { agent: "craftsman" },
1638
+ ARCHIVE_DIR,
1639
+ );
1640
+ expect(r1.plan!.completedAt).toBeNull();
1641
+
1642
+ const r2 = planUpdateStatusExecutor(
1643
+ db,
1644
+ { id: plan.id, status: "executing" },
1645
+ { agent: "craftsman" },
1646
+ ARCHIVE_DIR,
1647
+ );
1648
+ expect(r2.plan!.completedAt).toBeNull();
1649
+ });
1650
+
1651
+ test("dryRun — does NOT mutate status, returns blockers/warnings", () => {
1652
+ const plan = setupPlan({ status: "executing", taskStatuses: ["done", "done"] });
1653
+ const result = planUpdateStatusExecutor(
1654
+ db,
1655
+ { id: plan.id, status: "completed", dryRun: true },
1656
+ { agent: "craftsman" },
1657
+ ARCHIVE_DIR,
1658
+ );
1659
+
1660
+ expect(result.dryRun).toBe(true);
1661
+ expect(result.statusChanged).toBe(false);
1662
+ expect(result.blockers).toEqual([]);
1663
+ expect(result.warnings).toEqual([]);
1664
+ expect(result.archived).toBeNull();
1665
+ expect(result.archiveError).toBeNull();
1666
+
1667
+ // Verify plan status unchanged
1668
+ const fresh = getPlan(db, plan.id);
1669
+ expect(fresh!.status).toBe("executing");
1670
+ });
1671
+
1672
+ test("dryRun with blockers — pending tasks reported as blockers", () => {
1673
+ const plan = setupPlan({ status: "executing", taskStatuses: ["pending", "done"] });
1674
+ const result = planUpdateStatusExecutor(
1675
+ db,
1676
+ { id: plan.id, status: "completed", dryRun: true },
1677
+ { agent: "craftsman" },
1678
+ ARCHIVE_DIR,
1679
+ );
1680
+
1681
+ expect(result.dryRun).toBe(true);
1682
+ expect(result.blocked).toBe(true);
1683
+ expect(result.blockers).toContain("tasks_pending");
1684
+ expect(result.statusChanged).toBe(false);
1685
+
1686
+ // Verify plan status unchanged
1687
+ const fresh = getPlan(db, plan.id);
1688
+ expect(fresh!.status).toBe("executing");
1689
+ });
1690
+
1691
+ test("force with reason — bypasses blockers, creates plan_audit row", () => {
1692
+ const plan = setupPlan({ status: "executing", taskStatuses: ["pending", "running"] });
1693
+ const result = planUpdateStatusExecutor(
1694
+ db,
1695
+ { id: plan.id, status: "completed", force: true, forceReason: "testing force" },
1696
+ { agent: "warden" },
1697
+ ARCHIVE_DIR,
1698
+ );
1699
+
1700
+ expect(result.statusChanged).toBe(true);
1701
+ expect(result.forced).toBe(true);
1702
+ expect(result.blocked).toBe(false);
1703
+ expect(result.blockers).toContain("tasks_pending");
1704
+ expect(result.blockers).toContain("tasks_running");
1705
+ expect(result.auditId).toBeTruthy();
1706
+ expect(typeof result.auditId).toBe("number");
1707
+ expect(result.plan!.status).toBe("completed");
1708
+ expect(result.archived).toBeTruthy();
1709
+
1710
+ // Verify plan_audit row
1711
+ const audit = db.query("SELECT * FROM plan_audit WHERE plan_id = ?").get(plan.id) as {
1712
+ trigger: string;
1713
+ snapshot: string;
1714
+ } | null;
1715
+ expect(audit).not.toBeNull();
1716
+ expect(audit!.trigger).toBe("force_close");
1717
+ const snapshot = JSON.parse(audit!.snapshot);
1718
+ expect(snapshot.reason).toBe("testing force");
1719
+ expect(snapshot.forcedBy).toBe("warden");
1720
+ expect(snapshot.blockers).toContain("tasks_pending");
1721
+ expect(snapshot.previousStatus).toBe("executing");
1722
+ });
1723
+
1724
+ test("force without reason rejected — throws Error", () => {
1725
+ const plan = setupPlan({ status: "executing", taskStatuses: ["pending"] });
1726
+ expect(() =>
1727
+ planUpdateStatusExecutor(
1728
+ db,
1729
+ { id: plan.id, status: "completed", force: true },
1730
+ { agent: "craftsman" },
1731
+ ARCHIVE_DIR,
1732
+ ),
1733
+ ).toThrow(/forceReason/);
1734
+ });
1735
+
1736
+ test("force does NOT bypass status_invalid", () => {
1737
+ const plan = setupPlan({ status: "completed", taskStatuses: ["done"] });
1738
+ const result = planUpdateStatusExecutor(
1739
+ db,
1740
+ { id: plan.id, status: "executing", force: true, forceReason: "need re-execute" },
1741
+ { agent: "craftsman" },
1742
+ ARCHIVE_DIR,
1743
+ );
1744
+
1745
+ expect(result.blocked).toBe(true);
1746
+ expect(result.statusChanged).toBe(false);
1747
+ expect(result.blockers).toContain("status_invalid");
1748
+ expect(result.plan!.status).toBe("completed");
1749
+ });
1750
+
1751
+ test("blockers block update (no force)", () => {
1752
+ const plan = setupPlan({ status: "executing", taskStatuses: ["pending", "done"] });
1753
+ const result = planUpdateStatusExecutor(
1754
+ db,
1755
+ { id: plan.id, status: "completed" },
1756
+ { agent: "craftsman" },
1757
+ ARCHIVE_DIR,
1758
+ );
1759
+
1760
+ expect(result.blocked).toBe(true);
1761
+ expect(result.statusChanged).toBe(false);
1762
+ expect(result.blockers).toContain("tasks_pending");
1763
+ expect(result.plan!.status).toBe("executing");
1764
+ expect(result.archived).toBeNull();
1765
+ });
1766
+
1767
+ test("orphan plan warning — 0 tasks, warning only, status changes", () => {
1768
+ const plan = setupPlan({ status: "executing", taskStatuses: [] });
1769
+ const result = planUpdateStatusExecutor(
1770
+ db,
1771
+ { id: plan.id, status: "completed" },
1772
+ { agent: "craftsman" },
1773
+ ARCHIVE_DIR,
1774
+ );
1775
+
1776
+ expect(result.statusChanged).toBe(true);
1777
+ expect(result.blocked).toBe(false);
1778
+ expect(result.blockers).toEqual([]);
1779
+ expect(result.warnings).toContain("orphan_plan");
1780
+ expect(result.plan!.status).toBe("completed");
1781
+ });
1782
+
1783
+ test("executing→failed warnings only — pending tasks become warnings, not blockers", () => {
1784
+ const plan = setupPlan({ status: "executing", taskStatuses: ["pending", "running"] });
1785
+ const result = planUpdateStatusExecutor(
1786
+ db,
1787
+ { id: plan.id, status: "failed" },
1788
+ { agent: "craftsman" },
1789
+ ARCHIVE_DIR,
1790
+ );
1791
+
1792
+ expect(result.statusChanged).toBe(true);
1793
+ expect(result.blocked).toBe(false);
1794
+ expect(result.blockers).toEqual([]);
1795
+ expect(result.warnings).toContain("tasks_pending");
1796
+ expect(result.warnings).toContain("tasks_running");
1797
+ expect(result.plan!.status).toBe("failed");
1798
+ });
1799
+
1800
+ test("archive atomicity — if archivePlan throws, status update rolls back", () => {
1801
+ // Create plan with all tasks done
1802
+ const plan = setupPlan({ status: "executing", taskStatuses: ["done"] });
1803
+
1804
+ // Pre-set archived_at to make archivePlan throw "already archived"
1805
+ db.query("UPDATE plans SET archived_at = ? WHERE id = ?").run(Date.now(), plan.id);
1806
+
1807
+ // Call should throw because archivePlan throws "already archived"
1808
+ expect(() =>
1809
+ planUpdateStatusExecutor(
1810
+ db,
1811
+ { id: plan.id, status: "completed" },
1812
+ { agent: "craftsman" },
1813
+ ARCHIVE_DIR,
1814
+ ),
1815
+ ).toThrow(/already archived/);
1816
+
1817
+ // Status should NOT have changed (rolled back by outer transaction)
1818
+ const fresh = getPlan(db, plan.id);
1819
+ expect(fresh!.status).toBe("executing");
1820
+ });
1821
+ });
1822
+
1823
+ // ─── task_dependency_resolver + task_next_for_agent deps (T3.2) ─────────────
1824
+
1825
+ describe("task_dependency_resolver + task_next_for_agent deps (T3.2)", () => {
1826
+ /** Helper: create a plan with tasks that have explicit dependencies. */
1827
+ function setupDepsPlan(taskDefs: Array<{ orderIndex: number; deps: string[]; agent?: string }>) {
1828
+ const plan = makePlan({ slug: "deps-test" });
1829
+ db.query("UPDATE plans SET status = ? WHERE id = ?").run("executing", plan.id);
1830
+ const tasks = createTasksBatch(
1831
+ db,
1832
+ plan.id,
1833
+ taskDefs.map((td) => ({
1834
+ orderIndex: td.orderIndex,
1835
+ description: `task ${td.orderIndex}`,
1836
+ agent: td.agent ?? "js-smith",
1837
+ files: [] as string[],
1838
+ complexity: 1,
1839
+ dependencies: td.deps,
1840
+ createdBy: "test",
1841
+ updatedBy: "test",
1842
+ sourceSessionId: null as string | null,
1843
+ sourceMessageId: null as string | null,
1844
+ reviewedBy: null as string | null,
1845
+ tokensUsed: null as number | null,
1846
+ durationMs: null as number | null,
1847
+ artifacts: [] as string[],
1848
+ metadata: {},
1849
+ })),
1850
+ );
1851
+ return { plan, tasks };
1852
+ }
1853
+
1854
+ // ── resolveTaskDependencies ────────────────────────────────────────────
1855
+
1856
+ test("resolveTaskDependencies — no deps → canStart=true, empty arrays", () => {
1857
+ const { tasks } = setupDepsPlan([{ orderIndex: 0, deps: [] }]);
1858
+ const result = resolveTaskDependencies(db, tasks[0]!.id);
1859
+
1860
+ expect(result.canStart).toBe(true);
1861
+ expect(result.dependencies).toEqual([]);
1862
+ expect(result.doneDeps).toEqual([]);
1863
+ expect(result.pendingDeps).toEqual([]);
1864
+ expect(result.missingDeps).toEqual([]);
1865
+ });
1866
+
1867
+ test("resolveTaskDependencies — all deps done → canStart=true", () => {
1868
+ const { tasks } = setupDepsPlan([
1869
+ { orderIndex: 0, deps: [] },
1870
+ { orderIndex: 1, deps: [] },
1871
+ { orderIndex: 2, deps: [] },
1872
+ ]);
1873
+ // Mark deps as done
1874
+ updateTaskStatus(db, tasks[0]!.id, "done", {}, "test");
1875
+ updateTaskStatus(db, tasks[1]!.id, "done", {}, "test");
1876
+
1877
+ // Task 2 depends on 0 and 1 — wire deps manually via DB
1878
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
1879
+ JSON.stringify([tasks[0]!.id, tasks[1]!.id]),
1880
+ tasks[2]!.id,
1881
+ );
1882
+
1883
+ const result = resolveTaskDependencies(db, tasks[2]!.id);
1884
+ expect(result.canStart).toBe(true);
1885
+ expect(result.doneDeps).toEqual([tasks[0]!.id, tasks[1]!.id]);
1886
+ expect(result.pendingDeps).toEqual([]);
1887
+ });
1888
+
1889
+ test("resolveTaskDependencies — deps pending → canStart=false", () => {
1890
+ const { tasks } = setupDepsPlan([
1891
+ { orderIndex: 0, deps: [] },
1892
+ { orderIndex: 1, deps: [] },
1893
+ ]);
1894
+ // Task 1 depends on task 0 (still pending)
1895
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
1896
+ JSON.stringify([tasks[0]!.id]),
1897
+ tasks[1]!.id,
1898
+ );
1899
+
1900
+ const result = resolveTaskDependencies(db, tasks[1]!.id);
1901
+ expect(result.canStart).toBe(false);
1902
+ expect(result.pendingDeps).toEqual([tasks[0]!.id]);
1903
+ expect(result.doneDeps).toEqual([]);
1904
+ });
1905
+
1906
+ test("resolveTaskDependencies — deps failed → canStart=false", () => {
1907
+ const { tasks } = setupDepsPlan([
1908
+ { orderIndex: 0, deps: [] },
1909
+ { orderIndex: 1, deps: [] },
1910
+ ]);
1911
+ updateTaskStatus(db, tasks[0]!.id, "failed", { error: "boom" }, "test");
1912
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
1913
+ JSON.stringify([tasks[0]!.id]),
1914
+ tasks[1]!.id,
1915
+ );
1916
+
1917
+ const result = resolveTaskDependencies(db, tasks[1]!.id);
1918
+ expect(result.canStart).toBe(false);
1919
+ expect(result.failedDeps).toEqual([tasks[0]!.id]);
1920
+ });
1921
+
1922
+ test("resolveTaskDependencies — deps running → canStart=false", () => {
1923
+ const { tasks } = setupDepsPlan([
1924
+ { orderIndex: 0, deps: [] },
1925
+ { orderIndex: 1, deps: [] },
1926
+ ]);
1927
+ updateTaskStatus(db, tasks[0]!.id, "running", {}, "test");
1928
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
1929
+ JSON.stringify([tasks[0]!.id]),
1930
+ tasks[1]!.id,
1931
+ );
1932
+
1933
+ const result = resolveTaskDependencies(db, tasks[1]!.id);
1934
+ expect(result.canStart).toBe(false);
1935
+ expect(result.runningDeps).toEqual([tasks[0]!.id]);
1936
+ });
1937
+
1938
+ test("resolveTaskDependencies — deps blocked → canStart=false", () => {
1939
+ const { tasks } = setupDepsPlan([
1940
+ { orderIndex: 0, deps: [] },
1941
+ { orderIndex: 1, deps: [] },
1942
+ ]);
1943
+ updateTaskStatus(db, tasks[0]!.id, "blocked", {}, "test");
1944
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
1945
+ JSON.stringify([tasks[0]!.id]),
1946
+ tasks[1]!.id,
1947
+ );
1948
+
1949
+ const result = resolveTaskDependencies(db, tasks[1]!.id);
1950
+ expect(result.canStart).toBe(false);
1951
+ expect(result.blockedDeps).toEqual([tasks[0]!.id]);
1952
+ });
1953
+
1954
+ test("resolveTaskDependencies — missing dep IDs → canStart=false, missingDeps populated", () => {
1955
+ const { tasks } = setupDepsPlan([{ orderIndex: 0, deps: [] }]);
1956
+ const fakeDepId = crypto.randomUUID();
1957
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
1958
+ JSON.stringify([fakeDepId]),
1959
+ tasks[0]!.id,
1960
+ );
1961
+
1962
+ const result = resolveTaskDependencies(db, tasks[0]!.id);
1963
+ expect(result.canStart).toBe(false);
1964
+ expect(result.missingDeps).toEqual([fakeDepId]);
1965
+ });
1966
+
1967
+ test("resolveTaskDependencies — mixed dep states", () => {
1968
+ const { tasks } = setupDepsPlan([
1969
+ { orderIndex: 0, deps: [] },
1970
+ { orderIndex: 1, deps: [] },
1971
+ { orderIndex: 2, deps: [] },
1972
+ { orderIndex: 3, deps: [] },
1973
+ { orderIndex: 4, deps: [] },
1974
+ ]);
1975
+ // 0=done, 1=failed, 2=running, 3=pending, 4=target
1976
+ updateTaskStatus(db, tasks[0]!.id, "done", {}, "test");
1977
+ updateTaskStatus(db, tasks[1]!.id, "failed", { error: "x" }, "test");
1978
+ updateTaskStatus(db, tasks[2]!.id, "running", {}, "test");
1979
+ // 3 stays pending
1980
+
1981
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
1982
+ JSON.stringify([tasks[0]!.id, tasks[1]!.id, tasks[2]!.id, tasks[3]!.id]),
1983
+ tasks[4]!.id,
1984
+ );
1985
+
1986
+ const result = resolveTaskDependencies(db, tasks[4]!.id);
1987
+ expect(result.canStart).toBe(false);
1988
+ expect(result.doneDeps).toEqual([tasks[0]!.id]);
1989
+ expect(result.failedDeps).toEqual([tasks[1]!.id]);
1990
+ expect(result.runningDeps).toEqual([tasks[2]!.id]);
1991
+ expect(result.pendingDeps).toEqual([tasks[3]!.id]);
1992
+ });
1993
+
1994
+ test("resolveTaskDependencies — taskId not found → throws", () => {
1995
+ expect(() => resolveTaskDependencies(db, "nonexistent-id")).toThrow(/not found/);
1996
+ });
1997
+
1998
+ // ── nextTaskForAgent dependency gating ─────────────────────────────────
1999
+
2000
+ test("nextTaskForAgent — no deps → claims task (backward compat)", () => {
2001
+ setupDepsPlan([{ orderIndex: 0, deps: [] }]);
2002
+
2003
+ const claimed = nextTaskForAgent(db, "js-smith");
2004
+ expect(claimed).not.toBeNull();
2005
+ expect(claimed!.status).toBe("running");
2006
+ });
2007
+
2008
+ test("nextTaskForAgent — all deps done → claims task", () => {
2009
+ const { tasks } = setupDepsPlan([
2010
+ { orderIndex: 0, deps: [] },
2011
+ { orderIndex: 1, deps: [] },
2012
+ ]);
2013
+ updateTaskStatus(db, tasks[0]!.id, "done", {}, "test");
2014
+
2015
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
2016
+ JSON.stringify([tasks[0]!.id]),
2017
+ tasks[1]!.id,
2018
+ );
2019
+
2020
+ const claimed = nextTaskForAgent(db, "js-smith");
2021
+ // Should claim task 1 (task 0 is done, not pending)
2022
+ expect(claimed).not.toBeNull();
2023
+ expect(claimed!.id).toBe(tasks[1]!.id);
2024
+ expect(claimed!.status).toBe("running");
2025
+ });
2026
+
2027
+ test("nextTaskForAgent — deps pending → skips, returns null", () => {
2028
+ const { tasks, plan } = setupDepsPlan([
2029
+ { orderIndex: 0, deps: [] },
2030
+ { orderIndex: 1, deps: [] },
2031
+ ]);
2032
+ // Wire task 1 to depend on task 0 (both pending)
2033
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
2034
+ JSON.stringify([tasks[0]!.id]),
2035
+ tasks[1]!.id,
2036
+ );
2037
+
2038
+ const claimed = nextTaskForAgent(db, "js-smith", { planId: plan.id });
2039
+ // Task 0 has no deps → eligible, gets claimed first
2040
+ expect(claimed).not.toBeNull();
2041
+ expect(claimed!.id).toBe(tasks[0]!.id);
2042
+ });
2043
+
2044
+ test("nextTaskForAgent — deps failed → skips task with failed deps", () => {
2045
+ const { tasks, plan } = setupDepsPlan([
2046
+ { orderIndex: 0, deps: [] },
2047
+ { orderIndex: 1, deps: [] },
2048
+ ]);
2049
+ updateTaskStatus(db, tasks[0]!.id, "failed", { error: "boom" }, "test");
2050
+
2051
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
2052
+ JSON.stringify([tasks[0]!.id]),
2053
+ tasks[1]!.id,
2054
+ );
2055
+
2056
+ const claimed = nextTaskForAgent(db, "js-smith", { planId: plan.id });
2057
+ // Task 1 depends on failed task 0 → not eligible → null
2058
+ expect(claimed).toBeNull();
2059
+ });
2060
+
2061
+ test("nextTaskForAgent — deps running → skips task with running deps", () => {
2062
+ const { tasks, plan } = setupDepsPlan([
2063
+ { orderIndex: 0, deps: [] },
2064
+ { orderIndex: 1, deps: [] },
2065
+ ]);
2066
+ updateTaskStatus(db, tasks[0]!.id, "running", {}, "test");
2067
+
2068
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
2069
+ JSON.stringify([tasks[0]!.id]),
2070
+ tasks[1]!.id,
2071
+ );
2072
+
2073
+ const claimed = nextTaskForAgent(db, "js-smith", { planId: plan.id });
2074
+ expect(claimed).toBeNull();
2075
+ });
2076
+
2077
+ test("nextTaskForAgent — deps blocked → skips task with blocked deps", () => {
2078
+ const { tasks, plan } = setupDepsPlan([
2079
+ { orderIndex: 0, deps: [] },
2080
+ { orderIndex: 1, deps: [] },
2081
+ ]);
2082
+ updateTaskStatus(db, tasks[0]!.id, "blocked", {}, "test");
2083
+
2084
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
2085
+ JSON.stringify([tasks[0]!.id]),
2086
+ tasks[1]!.id,
2087
+ );
2088
+
2089
+ const claimed = nextTaskForAgent(db, "js-smith", { planId: plan.id });
2090
+ expect(claimed).toBeNull();
2091
+ });
2092
+
2093
+ test("nextTaskForAgent — missing dep IDs → skips (deps not found in DB)", () => {
2094
+ const { tasks, plan } = setupDepsPlan([{ orderIndex: 0, deps: [] }]);
2095
+ const fakeDepId = crypto.randomUUID();
2096
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
2097
+ JSON.stringify([fakeDepId]),
2098
+ tasks[0]!.id,
2099
+ );
2100
+
2101
+ const claimed = nextTaskForAgent(db, "js-smith", { planId: plan.id });
2102
+ expect(claimed).toBeNull();
2103
+ });
2104
+
2105
+ test("nextTaskForAgent — mixed candidates: claims first eligible by order_index", () => {
2106
+ const { tasks, plan } = setupDepsPlan([
2107
+ { orderIndex: 0, deps: [] },
2108
+ { orderIndex: 1, deps: [] },
2109
+ { orderIndex: 2, deps: [] },
2110
+ ]);
2111
+ // task 0 has unmet deps (pointing to a fake ID)
2112
+ const fakeDepId = crypto.randomUUID();
2113
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
2114
+ JSON.stringify([fakeDepId]),
2115
+ tasks[0]!.id,
2116
+ );
2117
+ // task 1 has deps on task 0 (which is pending)
2118
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
2119
+ JSON.stringify([tasks[0]!.id]),
2120
+ tasks[1]!.id,
2121
+ );
2122
+ // task 2 has no deps → eligible
2123
+
2124
+ const claimed = nextTaskForAgent(db, "js-smith", { planId: plan.id });
2125
+ expect(claimed).not.toBeNull();
2126
+ expect(claimed!.id).toBe(tasks[2]!.id);
2127
+ expect(claimed!.status).toBe("running");
2128
+ });
2129
+
2130
+ // ── task_dependency_resolver tool shape ────────────────────────────────
2131
+
2132
+ test("task_dependency_resolver tool — returns correct shape via resolveTaskDependencies", () => {
2133
+ const { tasks } = setupDepsPlan([
2134
+ { orderIndex: 0, deps: [] },
2135
+ { orderIndex: 1, deps: [] },
2136
+ { orderIndex: 2, deps: [] },
2137
+ ]);
2138
+ updateTaskStatus(db, tasks[0]!.id, "done", {}, "test");
2139
+ updateTaskStatus(db, tasks[1]!.id, "failed", { error: "x" }, "test");
2140
+
2141
+ db.query("UPDATE plan_tasks SET dependencies = ? WHERE id = ?").run(
2142
+ JSON.stringify([tasks[0]!.id, tasks[1]!.id, crypto.randomUUID()]),
2143
+ tasks[2]!.id,
2144
+ );
2145
+
2146
+ const result = resolveTaskDependencies(db, tasks[2]!.id);
2147
+
2148
+ // Shape checks
2149
+ expect(typeof result.canStart).toBe("boolean");
2150
+ expect(Array.isArray(result.pendingDeps)).toBe(true);
2151
+ expect(Array.isArray(result.runningDeps)).toBe(true);
2152
+ expect(Array.isArray(result.failedDeps)).toBe(true);
2153
+ expect(Array.isArray(result.blockedDeps)).toBe(true);
2154
+ expect(Array.isArray(result.doneDeps)).toBe(true);
2155
+ expect(Array.isArray(result.missingDeps)).toBe(true);
2156
+ expect(Array.isArray(result.dependencies)).toBe(true);
2157
+
2158
+ // Value checks
2159
+ expect(result.canStart).toBe(false);
2160
+ expect(result.doneDeps).toEqual([tasks[0]!.id]);
2161
+ expect(result.failedDeps).toEqual([tasks[1]!.id]);
2162
+ expect(result.missingDeps.length).toBe(1);
2163
+ expect(result.dependencies.length).toBe(3);
2164
+ });
2165
+ });
2166
+
2167
+ // ─── auto_checkpoint hook (T3.3) ────────────────────────────────────────────
2168
+
2169
+ describe("auto_checkpoint hook (T3.3)", () => {
2170
+ /** Helper: create a plan with N tasks, return plan + tasks. */
2171
+ function setupPlanWithTasks(taskCount: number) {
2172
+ const plan = makePlan({ slug: `acp-${crypto.randomUUID().slice(0, 8)}` });
2173
+ db.query("UPDATE plans SET status = ? WHERE id = ?").run("executing", plan.id);
2174
+
2175
+ const taskDescs = Array.from({ length: taskCount }, (_, i) => ({
2176
+ orderIndex: i,
2177
+ description: `Task ${i}`,
2178
+ agent: "js-smith",
2179
+ files: [] as string[],
2180
+ complexity: 1,
2181
+ dependencies: [] as string[],
2182
+ createdBy: "test",
2183
+ updatedBy: "test",
2184
+ sourceSessionId: null as string | null,
2185
+ sourceMessageId: null as string | null,
2186
+ reviewedBy: null as string | null,
2187
+ tokensUsed: null as number | null,
2188
+ durationMs: null as number | null,
2189
+ artifacts: [] as string[],
2190
+ metadata: {},
2191
+ }));
2192
+ const tasks = createTasksBatch(db, plan.id, taskDescs);
2193
+ return { plan, tasks };
2194
+ }
2195
+
2196
+ test("trigger fires on phase_transition — checkpointSession updates session", async () => {
2197
+ const { plan } = setupPlanWithTasks(1);
2198
+ startSession(db, { id: "ses_acp_1", goal: "test auto-checkpoint" });
2199
+ // mark task done so plan can transition to completed
2200
+ const tasks = listTasksByPlan(db, plan.id);
2201
+ updateTaskStatus(db, tasks[0]!.id, "done", {}, "test");
2202
+
2203
+ const dispatcher = new AutoCheckpointDispatcher(db, { minIntervalMs: 0 });
2204
+ dispatcher.dispatch("phase_transition", {
2205
+ planId: plan.id,
2206
+ sessionId: "ses_acp_1",
2207
+ blockers: ["tasks_pending"],
2208
+ });
2209
+
2210
+ // Flush microtask
2211
+ await new Promise((r) => setTimeout(r, 10));
2212
+
2213
+ const sess = getSession(db, "ses_acp_1");
2214
+ expect(sess).not.toBeNull();
2215
+ const state = sess!.state;
2216
+ expect(state.trigger).toBe("phase_transition");
2217
+ expect(state.completedTasks).toBe(1);
2218
+ expect(state.currentPhase).toBe("executing");
2219
+ expect(state.blockers).toEqual(["tasks_pending"]);
2220
+ });
2221
+
2222
+ test("debounce works — two rapid calls produce only one checkpoint", async () => {
2223
+ const { plan } = setupPlanWithTasks(1);
2224
+ startSession(db, { id: "ses_acp_deb", goal: "debounce test" });
2225
+
2226
+ const dispatcher = new AutoCheckpointDispatcher(db, { minIntervalMs: 5000 });
2227
+
2228
+ // First call — should fire
2229
+ dispatcher.dispatch("phase_transition", { planId: plan.id, sessionId: "ses_acp_deb" });
2230
+ // Second call immediately — should be debounced
2231
+ dispatcher.dispatch("phase_transition", { planId: plan.id, sessionId: "ses_acp_deb" });
2232
+
2233
+ await new Promise((r) => setTimeout(r, 10));
2234
+
2235
+ const sess = getSession(db, "ses_acp_deb");
2236
+ expect(sess).not.toBeNull();
2237
+ // Only one checkpoint written — state should reflect the first call
2238
+ const state = sess!.state;
2239
+ expect(state.trigger).toBe("phase_transition");
2240
+ });
2241
+
2242
+ test("disabled config = no-op — no checkpoint written", async () => {
2243
+ startSession(db, { id: "ses_acp_dis", goal: "disabled test" });
2244
+ const { plan } = setupPlanWithTasks(1);
2245
+
2246
+ const dispatcher = new AutoCheckpointDispatcher(db, { enabled: false, minIntervalMs: 0 });
2247
+ dispatcher.dispatch("phase_transition", { planId: plan.id, sessionId: "ses_acp_dis" });
2248
+
2249
+ await new Promise((r) => setTimeout(r, 10));
2250
+
2251
+ const sess = getSession(db, "ses_acp_dis");
2252
+ expect(sess).not.toBeNull();
2253
+ // state should be the default empty object (no checkpoint written)
2254
+ expect(sess!.state).toEqual({});
2255
+ });
2256
+
2257
+ test("no loop — checkpointSession does NOT trigger plan_update_status", async () => {
2258
+ const { plan } = setupPlanWithTasks(1);
2259
+ startSession(db, { id: "ses_acp_loop", goal: "loop test" });
2260
+
2261
+ // Record plan status before
2262
+ const before = getPlan(db, plan.id);
2263
+ const statusBefore = before!.status;
2264
+
2265
+ const dispatcher = new AutoCheckpointDispatcher(db, { minIntervalMs: 0 });
2266
+ dispatcher.dispatch("phase_transition", { planId: plan.id, sessionId: "ses_acp_loop" });
2267
+
2268
+ await new Promise((r) => setTimeout(r, 10));
2269
+
2270
+ // Plan status must NOT have changed — checkpointSession only touches sessions table
2271
+ const after = getPlan(db, plan.id);
2272
+ expect(after!.status).toBe(statusBefore);
2273
+ });
2274
+
2275
+ test("task_batch_complete fires when last task done", async () => {
2276
+ const { plan, tasks } = setupPlanWithTasks(2);
2277
+ startSession(db, { id: "ses_acp_batch", goal: "batch test" });
2278
+
2279
+ // Complete both tasks
2280
+ updateTaskStatus(db, tasks[0]!.id, "done", {}, "test");
2281
+ updateTaskStatus(db, tasks[1]!.id, "done", {}, "test");
2282
+
2283
+ // Verify no pending tasks remain
2284
+ const pending = listTasksByPlan(db, plan.id, { status: "pending" });
2285
+ expect(pending.length).toBe(0);
2286
+
2287
+ // Simulate what plugin does: dispatch task_batch_complete
2288
+ const dispatcher = new AutoCheckpointDispatcher(db, { minIntervalMs: 0 });
2289
+ dispatcher.dispatch("task_batch_complete", { planId: plan.id, sessionId: "ses_acp_batch" });
2290
+
2291
+ await new Promise((r) => setTimeout(r, 10));
2292
+
2293
+ const sess = getSession(db, "ses_acp_batch");
2294
+ expect(sess).not.toBeNull();
2295
+ const state = sess!.state;
2296
+ expect(state.trigger).toBe("task_batch_complete");
2297
+ expect(state.completedTasks).toBe(2);
2298
+ });
2299
+
2300
+ test("task_batch_complete does NOT fire when non-last task done", async () => {
2301
+ const { plan, tasks } = setupPlanWithTasks(2);
2302
+ startSession(db, { id: "ses_acp_partial", goal: "partial batch test" });
2303
+
2304
+ // Complete only the first task
2305
+ updateTaskStatus(db, tasks[0]!.id, "done", {}, "test");
2306
+
2307
+ // There IS still a pending task — so batch is NOT complete
2308
+ const pending = listTasksByPlan(db, plan.id, { status: "pending" });
2309
+ expect(pending.length).toBe(1);
2310
+
2311
+ // Simulate what plugin does: do NOT dispatch because pending > 0
2312
+ // (In real code, the trigger is conditional on pending.length === 0)
2313
+ // We verify here that the session was NOT checkpointed
2314
+ const sess = getSession(db, "ses_acp_partial");
2315
+ expect(sess).not.toBeNull();
2316
+ expect(sess!.state).toEqual({});
2317
+ });
2318
+
2319
+ test("no sessionId = skip — no checkpoint written", async () => {
2320
+ const { plan } = setupPlanWithTasks(1);
2321
+ startSession(db, { id: "ses_acp_nosess", goal: "no-session test" });
2322
+
2323
+ const dispatcher = new AutoCheckpointDispatcher(db, { minIntervalMs: 0 });
2324
+ // dispatch without sessionId
2325
+ dispatcher.dispatch("phase_transition", { planId: plan.id });
2326
+
2327
+ await new Promise((r) => setTimeout(r, 10));
2328
+
2329
+ const sess = getSession(db, "ses_acp_nosess");
2330
+ expect(sess!.state).toEqual({});
2331
+ });
2332
+
2333
+ test("unknown trigger = skip — no checkpoint written", async () => {
2334
+ startSession(db, { id: "ses_acp_unknown", goal: "unknown trigger test" });
2335
+
2336
+ const dispatcher = new AutoCheckpointDispatcher(db, { minIntervalMs: 0 });
2337
+ dispatcher.dispatch("some_random_trigger", { sessionId: "ses_acp_unknown" });
2338
+
2339
+ await new Promise((r) => setTimeout(r, 10));
2340
+
2341
+ const sess = getSession(db, "ses_acp_unknown");
2342
+ expect(sess!.state).toEqual({});
2343
+ });
2344
+
2345
+ test("captureState options — selective capture", async () => {
2346
+ const { plan } = setupPlanWithTasks(1);
2347
+ startSession(db, { id: "ses_acp_sel", goal: "selective capture test" });
2348
+ updateTaskStatus(db, listTasksByPlan(db, plan.id)[0]!.id, "done", {}, "test");
2349
+
2350
+ const dispatcher = new AutoCheckpointDispatcher(db, {
2351
+ minIntervalMs: 0,
2352
+ captureState: { completedTasks: true, currentPhase: false, blockers: false },
2353
+ });
2354
+ dispatcher.dispatch("phase_transition", {
2355
+ planId: plan.id,
2356
+ sessionId: "ses_acp_sel",
2357
+ blockers: ["some_blocker"],
2358
+ });
2359
+
2360
+ await new Promise((r) => setTimeout(r, 10));
2361
+
2362
+ const sess = getSession(db, "ses_acp_sel");
2363
+ const state = sess!.state;
2364
+ expect(state.trigger).toBe("phase_transition");
2365
+ expect(state.completedTasks).toBe(1);
2366
+ // currentPhase and blockers should NOT be captured
2367
+ expect(state.currentPhase).toBeUndefined();
2368
+ expect(state.blockers).toBeUndefined();
2369
+ });
2370
+ });
2371
+
2372
+ // ─── FileLock (activeWrites TTL — plan fcb12dc5 #3) ──────────────────────────
2373
+
2374
+ describe("FileLock", () => {
2375
+ test("acquire returns null when filepath is unlocked", () => {
2376
+ const lock = new FileLock(60_000);
2377
+ expect(lock.acquire("/tmp/a.ts", "k1")).toBeNull();
2378
+ expect(lock.has("/tmp/a.ts")).toBe(true);
2379
+ });
2380
+
2381
+ test("acquire returns existing key when filepath is locked by another", () => {
2382
+ const lock = new FileLock(60_000);
2383
+ lock.acquire("/tmp/a.ts", "k1");
2384
+ expect(lock.acquire("/tmp/a.ts", "k2")).toBe("k1");
2385
+ // k2 should NOT have overwritten
2386
+ expect(lock.acquire("/tmp/a.ts", "k1")).toBeNull();
2387
+ });
2388
+
2389
+ test("release with matching key removes the lock", () => {
2390
+ const lock = new FileLock(60_000);
2391
+ lock.acquire("/tmp/a.ts", "k1");
2392
+ lock.release("/tmp/a.ts", "k1");
2393
+ expect(lock.has("/tmp/a.ts")).toBe(false);
2394
+ expect(lock.acquire("/tmp/a.ts", "k2")).toBeNull();
2395
+ });
2396
+
2397
+ test("release with non-matching key is a no-op (defensive)", () => {
2398
+ const lock = new FileLock(60_000);
2399
+ lock.acquire("/tmp/a.ts", "k1");
2400
+ lock.release("/tmp/a.ts", "k2");
2401
+ expect(lock.has("/tmp/a.ts")).toBe(true);
2402
+ });
2403
+
2404
+ test("forceRelease drops any lock regardless of key (admin recovery)", () => {
2405
+ const lock = new FileLock(60_000);
2406
+ lock.acquire("/tmp/a.ts", "k1");
2407
+ expect(lock.forceRelease("/tmp/a.ts")).toBe(true);
2408
+ expect(lock.has("/tmp/a.ts")).toBe(false);
2409
+ // Releasing an unheld path returns false
2410
+ expect(lock.forceRelease("/tmp/missing.ts")).toBe(false);
2411
+ });
2412
+
2413
+ test("TTL sweep releases expired locks (regression: SDK hook-miss leak)", () => {
2414
+ const lock = new FileLock(10);
2415
+ lock.acquire("/tmp/a.ts", "k1");
2416
+ expect(lock.has("/tmp/a.ts")).toBe(true);
2417
+
2418
+ // Wait past TTL — Date.now() advances
2419
+ return new Promise<void>((resolve) => {
2420
+ setTimeout(() => {
2421
+ const swept = lock.sweep();
2422
+ expect(swept).toBe(1);
2423
+ expect(lock.has("/tmp/a.ts")).toBe(false);
2424
+ // Subsequent acquire succeeds (regression: must not leak across SDK failures)
2425
+ expect(lock.acquire("/tmp/a.ts", "k2")).toBeNull();
2426
+ resolve();
2427
+ }, 25);
2428
+ });
2429
+ });
2430
+
2431
+ test("TTL sweep keeps fresh locks intact", () => {
2432
+ const lock = new FileLock(60_000);
2433
+ lock.acquire("/tmp/a.ts", "k1");
2434
+ const swept = lock.sweep();
2435
+ expect(swept).toBe(0);
2436
+ expect(lock.has("/tmp/a.ts")).toBe(true);
2437
+ });
2438
+
2439
+ test("acquire auto-sweeps expired entries before checking (lazy cleanup)", () => {
2440
+ const lock = new FileLock(10);
2441
+ lock.acquire("/tmp/a.ts", "k1");
2442
+
2443
+ return new Promise<void>((resolve) => {
2444
+ setTimeout(() => {
2445
+ // Even without explicit sweep(), acquire auto-sweeps and proceeds
2446
+ expect(lock.acquire("/tmp/a.ts", "k2")).toBeNull();
2447
+ resolve();
2448
+ }, 25);
2449
+ });
2450
+ });
2451
+
2452
+ test("keys() returns all currently-locked filepaths (compaction context)", () => {
2453
+ const lock = new FileLock(60_000);
2454
+ lock.acquire("/tmp/a.ts", "k1");
2455
+ lock.acquire("/tmp/b.ts", "k2");
2456
+ expect(lock.keys().sort()).toEqual(["/tmp/a.ts", "/tmp/b.ts"]);
2457
+ });
2458
+
2459
+ test("size() reflects current lock count", () => {
2460
+ const lock = new FileLock(60_000);
2461
+ expect(lock.size()).toBe(0);
2462
+ lock.acquire("/tmp/a.ts", "k1");
2463
+ lock.acquire("/tmp/b.ts", "k2");
2464
+ expect(lock.size()).toBe(2);
2465
+ lock.release("/tmp/a.ts", "k1");
2466
+ expect(lock.size()).toBe(1);
2467
+ });
2468
+ });
2469
+
2470
+ // ─── analysis tools (v14) ────────────────────────────────────────────────────
2471
+
2472
+ describe("analysis tools", () => {
2473
+ function makeAnalysis(overrides?: Record<string, unknown>) {
2474
+ return createAnalysis(db, {
2475
+ slug: "test-analysis",
2476
+ title: "Test Analysis",
2477
+ projectPath: "/test/project",
2478
+ summary: "A test analysis",
2479
+ findingsJson: JSON.stringify([{ id: 1, text: "finding one" }]),
2480
+ agent: "ranger",
2481
+ createdBy: "test",
2482
+ ...overrides,
2483
+ });
2484
+ }
2485
+
2486
+ test("analysis_create — happy path, returns shape with id", () => {
2487
+ const result = makeAnalysis();
2488
+ expect(result.id).toBeTruthy();
2489
+ expect(result.slug).toBe("test-analysis");
2490
+ expect(result.title).toBe("Test Analysis");
2491
+ expect(result.projectPath).toBe("/test/project");
2492
+ expect(result.agent).toBe("ranger");
2493
+ expect(result.createdBy).toBe("test");
2494
+ });
2495
+
2496
+ test("analysis_list — returns created row, excludes archived by default", () => {
2497
+ makeAnalysis({ slug: "list-a" });
2498
+ makeAnalysis({ slug: "list-b" });
2499
+ archiveAnalysis(db, getAnalysis(db, getAnalysis(db, (listAnalyses(db, { limit: 10 }).find(a => a.slug === "list-b")!).id)!.id)!.id);
2500
+
2501
+ const results = listAnalyses(db);
2502
+ expect(results.length).toBeGreaterThanOrEqual(1);
2503
+ expect(results.every((r) => r.archivedAt === null)).toBe(true);
2504
+ });
2505
+
2506
+ test("analysis_get — returns single row, throws on missing", () => {
2507
+ const created = makeAnalysis({ slug: "get-test" });
2508
+ const fetched = getAnalysis(db, created.id);
2509
+ expect(fetched).not.toBeNull();
2510
+ expect(fetched!.id).toBe(created.id);
2511
+
2512
+ const missing = getAnalysis(db, "nonexistent-id");
2513
+ expect(missing).toBeNull();
2514
+ });
2515
+
2516
+ test("analysis_search — finds by title word", () => {
2517
+ makeAnalysis({ slug: "search-test", title: "Architecture Review Q3" });
2518
+ const results = searchAnalyses(db, "Architecture");
2519
+ expect(results.length).toBeGreaterThanOrEqual(1);
2520
+ expect(results.some((r) => r.title.includes("Architecture"))).toBe(true);
2521
+ });
2522
+
2523
+ test("analysis_update — bumps updated_at", () => {
2524
+ const created = makeAnalysis({ slug: "update-test" });
2525
+
2526
+ const updated = updateAnalysis(db, created.id, { title: "Updated Title" });
2527
+ expect(updated.title).toBe("Updated Title");
2528
+ // updated_at should be a valid ISO string (datetime('now'))
2529
+ expect(updated.updatedAt).toBeTruthy();
2530
+ });
2531
+
2532
+ test("analysis_archive — sets archived_at, then list excludes by default", () => {
2533
+ const created = makeAnalysis({ slug: "archive-test" });
2534
+ expect(created.archivedAt).toBeNull();
2535
+
2536
+ const archived = archiveAnalysis(db, created.id);
2537
+ expect(archived.archivedAt).not.toBeNull();
2538
+
2539
+ const results = listAnalyses(db);
2540
+ expect(results.every((r) => r.id !== created.id || r.archivedAt !== null)).toBe(true);
2541
+ });
2542
+
2543
+ test("analysis_link_plan — sets source_plan_id, then unlink clears it", () => {
2544
+ const analysis = makeAnalysis({ slug: "link-test" });
2545
+ const plan = createPlan(db, {
2546
+ id: "plan-for-link",
2547
+ slug: "plan-link",
2548
+ title: "Plan for Link",
2549
+ status: "draft",
2550
+ priority: 1,
2551
+ overview: "test",
2552
+ complexity: 3,
2553
+ createdBy: "test",
2554
+ updatedBy: "test",
2555
+ sessionId: null,
2556
+ approvedAt: null,
2557
+ completedAt: null,
2558
+ approach: null,
2559
+ sourceSessionId: null,
2560
+ sourceMessageId: null,
2561
+ category: null,
2562
+ metadata: {},
2563
+ archivedAt: null,
2564
+ });
2565
+
2566
+ // Link
2567
+ const linked = linkAnalysisToPlan(db, analysis.id, plan.id);
2568
+ expect(linked.sourcePlanId).toBe(plan.id);
2569
+
2570
+ // Unlink
2571
+ const unlinked = unlinkAnalysisFromPlan(db, analysis.id);
2572
+ expect(unlinked.sourcePlanId).toBeNull();
2573
+ });
2574
+ });