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,921 @@
1
+ /**
2
+ * Tests for task CRUD, executed_by write-once, and plan_files insertion.
3
+ *
4
+ * Uses in-memory SQLite via bun:sqlite. Each test gets a fresh DB
5
+ * with the full schema applied by runMigrations.
6
+ */
7
+
8
+ import { Database } from "bun:sqlite";
9
+ import { beforeEach, describe, expect, test } from "bun:test";
10
+ import { runMigrations } from "./migrations.ts";
11
+ import { createPlan, getPlan } from "./plans.ts";
12
+ import { getSession } from "./sessions.ts";
13
+ import {
14
+ createTasksBatch,
15
+ getTask,
16
+ listTasksByPlan,
17
+ nextTaskForAgent,
18
+ splitFilesByStack,
19
+ updateTaskStatus,
20
+ } from "./tasks.ts";
21
+ import type { Plan } from "./types.ts";
22
+
23
+ let db: Database;
24
+
25
+ beforeEach(() => {
26
+ db = new Database(":memory:");
27
+ db.exec("PRAGMA foreign_keys = ON");
28
+ runMigrations(db);
29
+ });
30
+
31
+ function makePlan(overrides: Partial<Parameters<typeof createPlan>[1]> = {}): Plan {
32
+ return createPlan(db, {
33
+ id: crypto.randomUUID(),
34
+ slug: "test-plan",
35
+ title: "Test",
36
+ status: "draft",
37
+ priority: 2,
38
+ approvedAt: null,
39
+ completedAt: null,
40
+ sessionId: null,
41
+ overview: "test",
42
+ approach: null,
43
+ complexity: 3,
44
+ createdBy: "test",
45
+ updatedBy: "test",
46
+ sourceSessionId: null,
47
+ sourceMessageId: null,
48
+ category: null,
49
+ metadata: {},
50
+ archivedAt: null,
51
+ ...overrides,
52
+ });
53
+ }
54
+
55
+ function makeTask(overrides: Record<string, unknown> = {}) {
56
+ return {
57
+ orderIndex: 0,
58
+ description: "test task",
59
+ agent: "js-smith",
60
+ files: [] as string[],
61
+ complexity: 1,
62
+ dependencies: [] as string[],
63
+ createdBy: "foreman",
64
+ updatedBy: "foreman",
65
+ sourceSessionId: null as string | null,
66
+ sourceMessageId: null as string | null,
67
+ reviewedBy: null as string | null,
68
+ tokensUsed: null as number | null,
69
+ durationMs: null as number | null,
70
+ artifacts: [] as string[],
71
+ metadata: {},
72
+ ...overrides,
73
+ };
74
+ }
75
+
76
+ // ─── Issue 2: executed_by write-once ─────────────────────────────────────────
77
+
78
+ describe("updateTaskStatus — executed_by write-once", () => {
79
+ test("transition to 'running' sets plan.executed_by_agent", () => {
80
+ const plan = makePlan();
81
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
82
+ const taskId = tasks[0]?.id as string;
83
+
84
+ updateTaskStatus(db, taskId, "running", undefined, "js-smith", {
85
+ agent: "js-smith",
86
+ sessionId: "ses_123",
87
+ });
88
+
89
+ const updated = getPlan(db, plan.id);
90
+ expect(updated?.executedByAgent).toBe("js-smith");
91
+ expect(updated?.executedBySession).toBe("ses_123");
92
+ });
93
+
94
+ test("two 'running' transitions — no overwrite (write-once)", () => {
95
+ const plan = makePlan();
96
+ const tasks = createTasksBatch(db, plan.id, [makeTask(), makeTask({ orderIndex: 1 })]);
97
+
98
+ // First task starts
99
+ updateTaskStatus(db, tasks[0]?.id as string, "running", undefined, "agent-A", {
100
+ agent: "agent-A",
101
+ sessionId: "ses_A",
102
+ });
103
+
104
+ // Second task starts — should NOT overwrite
105
+ updateTaskStatus(db, tasks[1]?.id as string, "running", undefined, "agent-B", {
106
+ agent: "agent-B",
107
+ sessionId: "ses_B",
108
+ });
109
+
110
+ const updated = getPlan(db, plan.id);
111
+ expect(updated?.executedByAgent).toBe("agent-A");
112
+ expect(updated?.executedBySession).toBe("ses_A");
113
+ });
114
+
115
+ test("transition to 'done' does NOT change plan.executed_by_agent", () => {
116
+ const plan = makePlan();
117
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
118
+
119
+ // First set running
120
+ updateTaskStatus(db, tasks[0]?.id as string, "running", undefined, "js-smith", {
121
+ agent: "js-smith",
122
+ sessionId: "ses_123",
123
+ });
124
+
125
+ // Then set done
126
+ updateTaskStatus(db, tasks[0]?.id as string, "done", { result: "ok" }, "js-smith", {
127
+ agent: "js-smith",
128
+ sessionId: "ses_123",
129
+ });
130
+
131
+ const updated = getPlan(db, plan.id);
132
+ expect(updated?.executedByAgent).toBe("js-smith"); // unchanged from running
133
+ expect(updated?.executedBySession).toBe("ses_123");
134
+ });
135
+
136
+ test("no ctx provided — no executed_by write", () => {
137
+ const plan = makePlan();
138
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
139
+
140
+ updateTaskStatus(db, tasks[0]?.id as string, "running", undefined, "js-smith");
141
+
142
+ const updated = getPlan(db, plan.id);
143
+ expect(updated?.executedByAgent).toBeNull();
144
+ expect(updated?.executedBySession).toBeNull();
145
+ });
146
+ });
147
+
148
+ // ─── Issue 4: createTasksBatch plan_files insertion ──────────────────────────
149
+
150
+ describe("createTasksBatch — plan_files insertion", () => {
151
+ test("task.files inserts rows with role='modified'", () => {
152
+ const plan = makePlan();
153
+ createTasksBatch(db, plan.id, [makeTask({ files: ["src/a.ts", "src/b.ts"] })]);
154
+
155
+ const rows = db
156
+ .query("SELECT * FROM plan_files WHERE plan_id = ? ORDER BY file_path")
157
+ .all(plan.id) as Array<{ plan_id: string; file_path: string; role: string }>;
158
+
159
+ expect(rows).toHaveLength(2);
160
+ expect(rows[0]?.file_path).toBe("src/a.ts");
161
+ expect(rows[0]?.role).toBe("modified");
162
+ expect(rows[1]?.file_path).toBe("src/b.ts");
163
+ expect(rows[1]?.role).toBe("modified");
164
+ });
165
+
166
+ test("duplicate files across tasks — no break (INSERT OR IGNORE)", () => {
167
+ const plan = makePlan();
168
+ createTasksBatch(db, plan.id, [
169
+ makeTask({ files: ["src/shared.ts"], description: "task A" }),
170
+ makeTask({ orderIndex: 1, files: ["src/shared.ts", "src/other.ts"], description: "task B" }),
171
+ ]);
172
+
173
+ const rows = db.query("SELECT * FROM plan_files WHERE plan_id = ?").all(plan.id) as Array<{
174
+ file_path: string;
175
+ }>;
176
+
177
+ // shared.ts appears once (PK dedup), other.ts once = 2 total
178
+ expect(rows).toHaveLength(2);
179
+ });
180
+
181
+ test("no files — 0 rows in plan_files", () => {
182
+ const plan = makePlan();
183
+ createTasksBatch(db, plan.id, [makeTask()]);
184
+
185
+ const rows = db.query("SELECT * FROM plan_files WHERE plan_id = ?").all(plan.id);
186
+
187
+ expect(rows).toHaveLength(0);
188
+ });
189
+ });
190
+
191
+ // ─── High 3: ensureSession before setExecutedByOnce ─────────────────────────
192
+
193
+ describe("updateTaskStatus — ensureSession FK safety", () => {
194
+ test("running with non-existent sessionId creates session automatically", () => {
195
+ const plan = makePlan();
196
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
197
+ const taskId = tasks[0]?.id as string;
198
+ const sessionId = `ses_${crypto.randomUUID()}`;
199
+
200
+ // Session should NOT exist yet
201
+ expect(getSession(db, sessionId)).toBeNull();
202
+
203
+ updateTaskStatus(db, taskId, "running", undefined, "js-smith", {
204
+ agent: "js-smith",
205
+ sessionId,
206
+ });
207
+
208
+ // Session should now exist (auto-created by ensureSession)
209
+ const session = getSession(db, sessionId);
210
+ expect(session).not.toBeNull();
211
+ expect(session?.id).toBe(sessionId);
212
+
213
+ // Plan executed_by should also be set
214
+ const updated = getPlan(db, plan.id);
215
+ expect(updated?.executedByAgent).toBe("js-smith");
216
+ expect(updated?.executedBySession).toBe(sessionId);
217
+ });
218
+ });
219
+
220
+ // ─── P3: nextTaskForAgent atomic claim (race condition fix) ─────────────────
221
+
222
+ describe("nextTaskForAgent — atomic claim", () => {
223
+ test("returns pending task and sets status=running atomically", () => {
224
+ const plan = makePlan();
225
+ createTasksBatch(db, plan.id, [
226
+ makeTask({ orderIndex: 0, agent: "js-smith" }),
227
+ makeTask({ orderIndex: 1, agent: "js-smith" }),
228
+ ]);
229
+
230
+ const task = nextTaskForAgent(db, "js-smith", { planId: plan.id });
231
+ expect(task).not.toBeNull();
232
+ expect(task?.status).toBe("running");
233
+ expect(task?.startedAt).toBeGreaterThan(0);
234
+ expect(task?.orderIndex).toBe(0);
235
+ });
236
+
237
+ test("second call claims next pending task (not already-running)", () => {
238
+ const plan = makePlan();
239
+ createTasksBatch(db, plan.id, [
240
+ makeTask({ orderIndex: 0, agent: "js-smith", description: "task A" }),
241
+ makeTask({ orderIndex: 1, agent: "js-smith", description: "task B" }),
242
+ ]);
243
+
244
+ const first = nextTaskForAgent(db, "js-smith", { planId: plan.id });
245
+ const second = nextTaskForAgent(db, "js-smith", { planId: plan.id });
246
+
247
+ expect(first).not.toBeNull();
248
+ expect(second).not.toBeNull();
249
+ expect(first?.id).not.toBe(second?.id);
250
+ expect(first?.orderIndex).toBe(0);
251
+ expect(second?.orderIndex).toBe(1);
252
+ expect(second?.status).toBe("running");
253
+ });
254
+
255
+ test("returns null when no pending tasks for agent", () => {
256
+ const plan = makePlan();
257
+ createTasksBatch(db, plan.id, [makeTask({ agent: "other-agent" })]);
258
+
259
+ const task = nextTaskForAgent(db, "js-smith", { planId: plan.id });
260
+ expect(task).toBeNull();
261
+ });
262
+
263
+ test("claims across plans when planId omitted", () => {
264
+ const plan1 = makePlan({ slug: "plan-1" });
265
+ const plan2 = makePlan({ slug: "plan-2" });
266
+ createTasksBatch(db, plan1.id, [makeTask({ agent: "js-smith" })]);
267
+ createTasksBatch(db, plan2.id, [makeTask({ agent: "js-smith" })]);
268
+
269
+ const task = nextTaskForAgent(db, "js-smith");
270
+ expect(task).not.toBeNull();
271
+ expect(task?.status).toBe("running");
272
+ });
273
+
274
+ test("skips archived tasks", () => {
275
+ const plan = makePlan();
276
+ const tasks = createTasksBatch(db, plan.id, [makeTask({ agent: "js-smith" })]);
277
+
278
+ // Archive the task
279
+ const archivedTask = tasks[0];
280
+ if (!archivedTask) throw new Error("test setup: expected at least one task");
281
+ db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(Date.now(), archivedTask.id);
282
+
283
+ const task = nextTaskForAgent(db, "js-smith", { planId: plan.id });
284
+ expect(task).toBeNull();
285
+ });
286
+ });
287
+
288
+ // ─── F1: createTasksBatch no-overlap pre-dispatch ───────────────────────────
289
+
290
+ describe("createTasksBatch — no-overlap pre-dispatch", () => {
291
+ test("skips task with same (agent, description) already exists", () => {
292
+ const plan = makePlan();
293
+ const first = createTasksBatch(db, plan.id, [
294
+ makeTask({ agent: "js-smith", description: "build auth module" }),
295
+ ]);
296
+ expect(first).toHaveLength(1);
297
+
298
+ // Attempt duplicate — should be skipped
299
+ const second = createTasksBatch(db, plan.id, [
300
+ makeTask({ agent: "js-smith", description: "build auth module" }),
301
+ ]);
302
+ expect(second).toHaveLength(0);
303
+
304
+ // Verify only one task in DB
305
+ const rows = db
306
+ .query("SELECT * FROM plan_tasks WHERE plan_id = ? AND archived_at IS NULL")
307
+ .all(plan.id);
308
+ expect(rows).toHaveLength(1);
309
+ });
310
+
311
+ test("different agent same description — allowed", () => {
312
+ const plan = makePlan();
313
+ createTasksBatch(db, plan.id, [
314
+ makeTask({ agent: "js-smith", description: "build auth module" }),
315
+ ]);
316
+
317
+ const result = createTasksBatch(db, plan.id, [
318
+ makeTask({ agent: "reviewer", description: "build auth module", orderIndex: 1 }),
319
+ ]);
320
+ expect(result).toHaveLength(1);
321
+ });
322
+
323
+ test("same agent different description — allowed", () => {
324
+ const plan = makePlan();
325
+ createTasksBatch(db, plan.id, [
326
+ makeTask({ agent: "js-smith", description: "build auth module" }),
327
+ ]);
328
+
329
+ const result = createTasksBatch(db, plan.id, [
330
+ makeTask({ agent: "js-smith", description: "build auth tests", orderIndex: 1 }),
331
+ ]);
332
+ expect(result).toHaveLength(1);
333
+ });
334
+
335
+ test("in-batch duplicates also skipped", () => {
336
+ const plan = makePlan();
337
+ const result = createTasksBatch(db, plan.id, [
338
+ makeTask({ agent: "js-smith", description: "build auth module", orderIndex: 0 }),
339
+ makeTask({ agent: "js-smith", description: "build auth module", orderIndex: 1 }),
340
+ ]);
341
+ // First insert, second skipped
342
+ expect(result).toHaveLength(1);
343
+ });
344
+
345
+ test("overlap check includes non-archived tasks only", () => {
346
+ const plan = makePlan();
347
+ const first = createTasksBatch(db, plan.id, [
348
+ makeTask({ agent: "js-smith", description: "build auth module" }),
349
+ ]);
350
+
351
+ // Archive it
352
+ const archivedFirst = first[0];
353
+ if (!archivedFirst) throw new Error("test setup: expected at least one task");
354
+ db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
355
+ Date.now(),
356
+ archivedFirst.id,
357
+ );
358
+
359
+ // Re-create same task — should succeed (archived doesn't block)
360
+ // Use different orderIndex since archived row still occupies (plan_id, order_index)
361
+ const second = createTasksBatch(db, plan.id, [
362
+ makeTask({ agent: "js-smith", description: "build auth module", orderIndex: 1 }),
363
+ ]);
364
+ expect(second).toHaveLength(1);
365
+ });
366
+ });
367
+
368
+ // ─── M5: original_plan_data snapshot completeness ───────────────────────────
369
+
370
+ describe("createTasksBatch — original_plan_data snapshot (M5)", () => {
371
+ test("snapshot includes files", () => {
372
+ const plan = makePlan();
373
+ const tasks = createTasksBatch(db, plan.id, [makeTask({ files: ["src/a.ts", "src/b.ts"] })]);
374
+ const snapshot = JSON.parse(tasks[0]?.originalPlanData ?? "{}");
375
+
376
+ expect(snapshot.files).toEqual(["src/a.ts", "src/b.ts"]);
377
+ });
378
+
379
+ test("snapshot includes metadata", () => {
380
+ const plan = makePlan();
381
+ const tasks = createTasksBatch(db, plan.id, [
382
+ makeTask({ metadata: { reviewedBy: "chronicler", tokensUsed: 1500 } }),
383
+ ]);
384
+ const snapshot = JSON.parse(tasks[0]?.originalPlanData ?? "{}");
385
+
386
+ expect(snapshot.metadata).toEqual({ reviewedBy: "chronicler", tokensUsed: 1500 });
387
+ });
388
+
389
+ test("snapshot includes dependencies", () => {
390
+ const plan = makePlan();
391
+ const tasks = createTasksBatch(db, plan.id, [makeTask({ dependencies: ["dep-1", "dep-2"] })]);
392
+ const snapshot = JSON.parse(tasks[0]?.originalPlanData ?? "{}");
393
+
394
+ expect(snapshot.dependencies).toEqual(["dep-1", "dep-2"]);
395
+ });
396
+
397
+ test("snapshot includes empty arrays/objects when fields omitted", () => {
398
+ const plan = makePlan();
399
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
400
+ const snapshot = JSON.parse(tasks[0]?.originalPlanData ?? "{}");
401
+
402
+ expect(snapshot.files).toEqual([]);
403
+ expect(snapshot.dependencies).toEqual([]);
404
+ expect(snapshot.metadata).toEqual({});
405
+ });
406
+ });
407
+
408
+ // ─── M6: truncation warning + return metadata ───────────────────────────────
409
+
410
+ describe("updateTaskStatus — truncation metadata (M6)", () => {
411
+ test("result > 16KB → truncated:true with correct lengths", () => {
412
+ const plan = makePlan();
413
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
414
+ const taskId = tasks[0]?.id as string;
415
+ const bigResult = "x".repeat(17 * 1024); // 17 KB
416
+
417
+ const originalWarn = console.warn;
418
+ const warnCalls: unknown[][] = [];
419
+ console.warn = (...args: unknown[]) => warnCalls.push(args);
420
+
421
+ const result = updateTaskStatus(db, taskId, "done", { result: bigResult }, "test");
422
+
423
+ expect(result?.truncation.truncated).toBe(true);
424
+ expect(result?.truncation.originalLength).toBe(17 * 1024);
425
+ expect(result?.truncation.truncatedLength).toBe(16 * 1024);
426
+ expect(result?.result).toContain("…[truncated]");
427
+ expect(warnCalls.length).toBeGreaterThan(0);
428
+ expect(String(warnCalls[0]?.[0])).toContain(`task_update_status ${taskId}`);
429
+
430
+ console.warn = originalWarn;
431
+ });
432
+
433
+ test("result exactly 16KB → truncated:false", () => {
434
+ const plan = makePlan();
435
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
436
+ const taskId = tasks[0]?.id as string;
437
+ const exactResult = "y".repeat(16 * 1024); // exactly 16 KB
438
+
439
+ const result = updateTaskStatus(db, taskId, "done", { result: exactResult }, "test");
440
+
441
+ expect(result?.truncation.truncated).toBe(false);
442
+ expect(result?.truncation.originalLength).toBeUndefined();
443
+ expect(result?.result).toBe(exactResult);
444
+ });
445
+
446
+ test("error field also truncated with same behavior", () => {
447
+ const plan = makePlan();
448
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
449
+ const taskId = tasks[0]?.id as string;
450
+ const bigError = "e".repeat(20 * 1024); // 20 KB
451
+
452
+ const originalWarn = console.warn;
453
+ console.warn = () => {};
454
+
455
+ const result = updateTaskStatus(db, taskId, "failed", { error: bigError }, "test");
456
+
457
+ expect(result?.truncation.truncated).toBe(true);
458
+ expect(result?.truncation.originalLength).toBe(20 * 1024);
459
+ expect(result?.truncation.truncatedLength).toBe(16 * 1024);
460
+ expect(result?.error).toContain("…[truncated]");
461
+
462
+ console.warn = originalWarn;
463
+ });
464
+ });
465
+
466
+ // ─── M7: cross-stack file splitting ─────────────────────────────────────────
467
+
468
+ describe("splitFilesByStack (M7)", () => {
469
+ test("groups files by extension stack", () => {
470
+ const result = splitFilesByStack(["main.go", "app.ts", "style.vue"]);
471
+
472
+ expect(Object.keys(result)).toHaveLength(3);
473
+ expect(result.go).toEqual(["main.go"]);
474
+ expect(result.js).toEqual(["app.ts"]);
475
+ expect(result.vue).toEqual(["style.vue"]);
476
+ });
477
+
478
+ test("unknown extension → 'other'", () => {
479
+ const result = splitFilesByStack(["file.xyz", "main.go"]);
480
+
481
+ expect(result.other).toEqual(["file.xyz"]);
482
+ expect(result.go).toEqual(["main.go"]);
483
+ });
484
+
485
+ test("single file → single stack", () => {
486
+ const result = splitFilesByStack(["main.go"]);
487
+
488
+ expect(Object.keys(result)).toHaveLength(1);
489
+ expect(result.go).toEqual(["main.go"]);
490
+ });
491
+
492
+ test("same extension → no split (one stack)", () => {
493
+ const result = splitFilesByStack(["a.go", "b.go"]);
494
+
495
+ expect(Object.keys(result)).toHaveLength(1);
496
+ expect(result.go).toEqual(["a.go", "b.go"]);
497
+ });
498
+
499
+ test("tsx/jsx/ts/js all map to 'js'", () => {
500
+ const result = splitFilesByStack(["a.ts", "b.tsx", "c.js", "d.jsx"]);
501
+
502
+ expect(Object.keys(result)).toHaveLength(1);
503
+ expect(result.js).toEqual(["a.ts", "b.tsx", "c.js", "d.jsx"]);
504
+ });
505
+ });
506
+
507
+ describe("createTasksBatch — cross-stack split (M7)", () => {
508
+ test("task with main.go + app.ts → 2 tasks (go-smith, js-smith)", () => {
509
+ const plan = makePlan();
510
+ const tasks = createTasksBatch(db, plan.id, [
511
+ makeTask({ files: ["main.go", "app.ts"], description: "build feature" }),
512
+ ]);
513
+
514
+ expect(tasks).toHaveLength(2);
515
+
516
+ const goTask = tasks.find((t) => t.agent === "go-smith");
517
+ const jsTask = tasks.find((t) => t.agent === "js-smith");
518
+
519
+ expect(goTask).toBeDefined();
520
+ expect(jsTask).toBeDefined();
521
+ expect(goTask?.files).toEqual(["main.go"]);
522
+ expect(jsTask?.files).toEqual(["app.ts"]);
523
+ });
524
+
525
+ test("task with main.go + main.go → no split (same stack, dedup files)", () => {
526
+ const plan = makePlan();
527
+ const tasks = createTasksBatch(db, plan.id, [
528
+ makeTask({ files: ["main.go", "main.go"], description: "build backend" }),
529
+ ]);
530
+
531
+ // Same stack → no split, original task used as-is
532
+ expect(tasks).toHaveLength(1);
533
+ expect(tasks[0]?.agent).toBe("js-smith"); // original agent preserved
534
+ });
535
+
536
+ test("task with single file → 1 task, no split", () => {
537
+ const plan = makePlan();
538
+ const tasks = createTasksBatch(db, plan.id, [
539
+ makeTask({ files: ["main.go"], description: "build backend" }),
540
+ ]);
541
+
542
+ expect(tasks).toHaveLength(1);
543
+ expect(tasks[0]?.agent).toBe("js-smith"); // original agent preserved
544
+ });
545
+
546
+ test("task with file.unknown + main.go → 2 tasks (other + go)", () => {
547
+ const plan = makePlan();
548
+ const tasks = createTasksBatch(db, plan.id, [
549
+ makeTask({ files: ["README.xyz", "main.go"], description: "build docs" }),
550
+ ]);
551
+
552
+ expect(tasks).toHaveLength(2);
553
+
554
+ const otherTask = tasks.find((t) => t.agent === "smith");
555
+ const goTask = tasks.find((t) => t.agent === "go-smith");
556
+
557
+ expect(otherTask).toBeDefined();
558
+ expect(goTask).toBeDefined();
559
+ expect(otherTask?.files).toEqual(["README.xyz"]);
560
+ expect(goTask?.files).toEqual(["main.go"]);
561
+ });
562
+
563
+ test("splitReason='cross-stack' in metadata of sub-tasks", () => {
564
+ const plan = makePlan();
565
+ const tasks = createTasksBatch(db, plan.id, [
566
+ makeTask({ files: ["main.go", "app.ts"], description: "build feature" }),
567
+ ]);
568
+
569
+ expect(tasks).toHaveLength(2);
570
+ for (const task of tasks) {
571
+ expect((task.metadata as Record<string, unknown>).splitReason).toBe("cross-stack");
572
+ }
573
+ });
574
+ });
575
+
576
+ // ─── T1: updateTaskStatus — extended fields ──────────────────────────────────
577
+
578
+ describe("updateTaskStatus — extended fields (T1)", () => {
579
+ test("artifacts write", () => {
580
+ const plan = makePlan();
581
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
582
+ const taskId = tasks[0]?.id as string;
583
+
584
+ updateTaskStatus(db, taskId, "done", { artifacts: ["file1.ts", "file2.ts"] });
585
+
586
+ const row = db.query("SELECT artifacts FROM plan_tasks WHERE id = ?").get(taskId) as {
587
+ artifacts: string;
588
+ };
589
+ expect(JSON.parse(row.artifacts)).toEqual(["file1.ts", "file2.ts"]);
590
+ });
591
+
592
+ test("artifacts truncation", () => {
593
+ const plan = makePlan();
594
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
595
+ const taskId = tasks[0]?.id as string;
596
+
597
+ // Build artifacts array whose JSON > 16KB
598
+ const longStrings = Array.from({ length: 200 }, (_, i) => `file${"x".repeat(100)}_${i}.ts`);
599
+ const warnSpy = { calls: [] as string[] };
600
+ const origWarn = console.warn;
601
+ console.warn = (...args: unknown[]) => {
602
+ warnSpy.calls.push(args.join(" "));
603
+ origWarn(...args);
604
+ };
605
+
606
+ try {
607
+ const result = updateTaskStatus(db, taskId, "done", { artifacts: longStrings });
608
+
609
+ expect(result?.truncation.truncated).toBe(true);
610
+ expect(warnSpy.calls.some((m) => m.includes("artifacts truncated"))).toBe(true);
611
+
612
+ const row = db.query("SELECT artifacts FROM plan_tasks WHERE id = ?").get(taskId) as {
613
+ artifacts: string;
614
+ };
615
+ const stored: string[] = JSON.parse(row.artifacts);
616
+ expect(stored.length).toBeLessThan(longStrings.length);
617
+ // Stored JSON must fit within 16KB
618
+ expect(JSON.stringify(stored).length).toBeLessThanOrEqual(16 * 1024);
619
+ } finally {
620
+ console.warn = origWarn;
621
+ }
622
+ });
623
+
624
+ test("metadataPatch deep merge", () => {
625
+ const plan = makePlan();
626
+ const tasks = createTasksBatch(db, plan.id, [
627
+ makeTask({ metadata: { a: 1, b: { x: 1 } } as unknown as Record<string, unknown> }),
628
+ ]);
629
+ const taskId = tasks[0]?.id as string;
630
+
631
+ updateTaskStatus(db, taskId, "done", { metadataPatch: { b: { y: 2 }, c: 3 } });
632
+
633
+ const task = getTask(db, taskId);
634
+ expect(task?.metadata as unknown as Record<string, unknown>).toEqual({
635
+ a: 1,
636
+ b: { x: 1, y: 2 },
637
+ c: 3,
638
+ });
639
+ });
640
+
641
+ test("reviewedBy write", () => {
642
+ const plan = makePlan();
643
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
644
+ const taskId = tasks[0]?.id as string;
645
+
646
+ updateTaskStatus(db, taskId, "done", { reviewedBy: "inspector" });
647
+
648
+ const row = db.query("SELECT reviewed_by FROM plan_tasks WHERE id = ?").get(taskId) as {
649
+ reviewed_by: string;
650
+ };
651
+ expect(row.reviewed_by).toBe("inspector");
652
+ });
653
+
654
+ test("reviewedVerdict stored in metadata", () => {
655
+ const plan = makePlan();
656
+ const tasks = createTasksBatch(db, plan.id, [makeTask()]);
657
+ const taskId = tasks[0]?.id as string;
658
+
659
+ updateTaskStatus(db, taskId, "done", { reviewedVerdict: "approved" });
660
+
661
+ const task = getTask(db, taskId);
662
+ expect((task?.metadata as Record<string, unknown>).reviewedVerdict).toBe("approved");
663
+ });
664
+
665
+ test("retrocompat — undefined fields = no-write", () => {
666
+ const plan = makePlan();
667
+ const tasks = createTasksBatch(db, plan.id, [makeTask({ artifacts: ["existing.ts"] })]);
668
+ const taskId = tasks[0]?.id as string;
669
+
670
+ // Update with only result — artifacts/metadata/reviewedBy should stay unchanged
671
+ updateTaskStatus(db, taskId, "done", { result: "ok" });
672
+
673
+ const row = db
674
+ .query("SELECT artifacts, reviewed_by, metadata FROM plan_tasks WHERE id = ?")
675
+ .get(taskId) as { artifacts: string; reviewed_by: string | null; metadata: string };
676
+ expect(JSON.parse(row.artifacts)).toEqual(["existing.ts"]);
677
+ expect(row.reviewed_by).toBeNull();
678
+ });
679
+
680
+ test("combined — all fields at once", () => {
681
+ const plan = makePlan();
682
+ const tasks = createTasksBatch(db, plan.id, [makeTask({ metadata: { existing: true } })]);
683
+ const taskId = tasks[0]?.id as string;
684
+
685
+ const result = updateTaskStatus(db, taskId, "done", {
686
+ result: "final output",
687
+ artifacts: ["a.ts", "b.ts"],
688
+ metadataPatch: { newKey: 42 },
689
+ reviewedBy: "warden",
690
+ reviewedVerdict: "pass",
691
+ });
692
+
693
+ expect(result?.result).toBe("final output");
694
+
695
+ const row = db
696
+ .query("SELECT artifacts, reviewed_by, metadata FROM plan_tasks WHERE id = ?")
697
+ .get(taskId) as { artifacts: string; reviewed_by: string; metadata: string };
698
+ expect(JSON.parse(row.artifacts)).toEqual(["a.ts", "b.ts"]);
699
+ expect(row.reviewed_by).toBe("warden");
700
+ const meta = JSON.parse(row.metadata);
701
+ expect(meta.existing).toBe(true);
702
+ expect(meta.newKey).toBe(42);
703
+ expect(meta.reviewedVerdict).toBe("pass");
704
+ });
705
+ });
706
+
707
+ // ─── order_index collision-safe allocation (fix: plan ca69222a) ──────────────
708
+
709
+ describe("createTasksBatch — order_index collision-safe allocation", () => {
710
+ test("(a) batch con plan pre-poblado — nueva task asigna order_index=1 sin colisión", () => {
711
+ const plan = makePlan();
712
+ // Pre-populate with 1 task at order_index=0
713
+ createTasksBatch(db, plan.id, [makeTask({ description: "existing task" })]);
714
+
715
+ // New batch — caller passes orderIndex=0 (collides with existing)
716
+ const result = createTasksBatch(db, plan.id, [
717
+ makeTask({ description: "new task", orderIndex: 0 }),
718
+ ]);
719
+
720
+ expect(result).toHaveLength(1);
721
+ expect(result[0]?.orderIndex).toBe(1);
722
+ expect(result[0]?.description).toBe("new task");
723
+ });
724
+
725
+ test("(b) split cross-stack con plan pre-poblado — parent=1, decimal 1.1", () => {
726
+ const plan = makePlan();
727
+ // Pre-populate with 1 task at order_index=0
728
+ createTasksBatch(db, plan.id, [makeTask({ description: "existing task" })]);
729
+
730
+ // New batch with cross-stack files: .py+.py → python, .md → other = 2 stacks
731
+ const result = createTasksBatch(db, plan.id, [
732
+ makeTask({ files: ["a.py", "b.py", "c.md"], description: "split task" }),
733
+ ]);
734
+
735
+ expect(result).toHaveLength(2);
736
+ // Parent (first sub-task) → order_index=1 (next free after 0)
737
+ // Second sub-task → 1 + 0.1 = 1.1
738
+ const orderIndices = result.map((t) => t.orderIndex).sort((a, b) => a - b);
739
+ expect(orderIndices).toEqual([1, 1.1]);
740
+ });
741
+
742
+ test("(c) split cross-stack — decimales ocupados → escala a integer libre", () => {
743
+ const plan = makePlan();
744
+ // Pre-populate with tasks at 0.1 and 0.2 (but NOT 0)
745
+ createTasksBatch(db, plan.id, [
746
+ makeTask({ description: "task at 0.1", orderIndex: 0.1, agent: "agent-X" }),
747
+ makeTask({ description: "task at 0.2", orderIndex: 0.2, agent: "agent-Y" }),
748
+ ]);
749
+
750
+ // New batch with 3-stack split, caller passes orderIndex=0
751
+ // .go + .ts + .py → 3 stacks → 3 sub-tasks
752
+ const result = createTasksBatch(db, plan.id, [
753
+ makeTask({ files: ["a.go", "b.ts", "c.py"], description: "split task", orderIndex: 0 }),
754
+ ]);
755
+
756
+ expect(result).toHaveLength(3);
757
+ // stackIdx=0 → parentOrder=0 (free)
758
+ // stackIdx=1 → 0.1 (occupied) → escalate to next free integer = 1
759
+ // stackIdx=2 → 0.2 (occupied) → escalate to next free integer = 2
760
+ const orderIndices = result.map((t) => t.orderIndex).sort((a, b) => a - b);
761
+ expect(orderIndices).toEqual([0, 1, 2]);
762
+ });
763
+
764
+ test("(d) caller pasa orderIndex colisionante → core reasigna a siguiente libre", () => {
765
+ const plan = makePlan();
766
+ // Pre-populate with task at order_index=0
767
+ createTasksBatch(db, plan.id, [makeTask({ description: "existing task" })]);
768
+
769
+ // New batch — caller passes orderIndex=0 (collides)
770
+ const result = createTasksBatch(db, plan.id, [
771
+ makeTask({ description: "new task", orderIndex: 0 }),
772
+ ]);
773
+
774
+ expect(result).toHaveLength(1);
775
+ expect(result[0]?.orderIndex).toBe(1); // reassigned to next free
776
+ });
777
+
778
+ test("(e) reproduce bug 18252705 — 2nd batch with colliding orderIndex succeeds", () => {
779
+ const plan = makePlan();
780
+ // 1st batch — 1 task at order_index=0 (simulates old caller passing idx=0)
781
+ const first = createTasksBatch(db, plan.id, [
782
+ makeTask({ description: "task 0", orderIndex: 0 }),
783
+ ]);
784
+ expect(first).toHaveLength(1);
785
+ expect(first[0]?.orderIndex).toBe(0);
786
+
787
+ // 2nd batch — 4 tasks. OLD callers passed orderIndex 0,1,2,3 (idx from map).
788
+ // With fix, even if caller passes colliding indices, core reassigns.
789
+ const second = createTasksBatch(db, plan.id, [
790
+ makeTask({ description: "task A", orderIndex: 0 }), // collides → 1
791
+ makeTask({ description: "task B", orderIndex: 1 }), // collides → 2
792
+ makeTask({ description: "task C", orderIndex: 2 }), // collides → 3
793
+ makeTask({ description: "task D", orderIndex: 3 }), // collides → 4
794
+ ]);
795
+
796
+ expect(second).toHaveLength(4);
797
+ const orderIndices = second.map((t) => t.orderIndex).sort((a, b) => a - b);
798
+ expect(orderIndices).toEqual([1, 2, 3, 4]);
799
+ });
800
+
801
+ test("(f) caller omite orderIndex — core asigna secuencial desde MAX+1", () => {
802
+ const plan = makePlan();
803
+ // 1st batch — 2 tasks, no orderIndex specified
804
+ const first = createTasksBatch(db, plan.id, [
805
+ makeTask({ description: "task X" }),
806
+ makeTask({ description: "task Y" }),
807
+ ]);
808
+ expect(first).toHaveLength(2);
809
+ expect(first[0]?.orderIndex).toBe(0);
810
+ expect(first[1]?.orderIndex).toBe(1);
811
+
812
+ // 2nd batch — 2 more tasks, no orderIndex
813
+ const second = createTasksBatch(db, plan.id, [
814
+ makeTask({ description: "task Z" }),
815
+ makeTask({ description: "task W" }),
816
+ ]);
817
+ expect(second).toHaveLength(2);
818
+ expect(second[0]?.orderIndex).toBe(2);
819
+ expect(second[1]?.orderIndex).toBe(3);
820
+ });
821
+
822
+ test("(g) archived task ocupa slot — nueva task evita colisión", () => {
823
+ const plan = makePlan();
824
+ // Create and archive a task at order_index=0
825
+ const created = createTasksBatch(db, plan.id, [makeTask({ description: "archived task" })]);
826
+ db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
827
+ Date.now(),
828
+ created[0]?.id as string,
829
+ );
830
+
831
+ // New batch — caller passes orderIndex=0 (collides with archived row)
832
+ const result = createTasksBatch(db, plan.id, [
833
+ makeTask({ description: "new task", orderIndex: 0 }),
834
+ ]);
835
+
836
+ expect(result).toHaveLength(1);
837
+ // Archived row still occupies (plan_id, order_index) in UNIQUE constraint.
838
+ // Core detects collision via usedOrderIndices (includes archived) → reassigns.
839
+ expect(result[0]?.orderIndex).not.toBe(0);
840
+ });
841
+ });
842
+
843
+ // ─── listTasksByPlan — includeArchived flag (plan 76a12c8d) ──────────────────
844
+
845
+ describe("listTasksByPlan — includeArchived flag", () => {
846
+ test("(a) sin flag — omite tasks con archived_at IS NOT NULL", () => {
847
+ const plan = makePlan();
848
+ // Create 2 tasks: one live, one to be archived
849
+ const created = createTasksBatch(db, plan.id, [
850
+ makeTask({ description: "live task" }),
851
+ makeTask({ description: "doomed task" }),
852
+ ]);
853
+ expect(created).toHaveLength(2);
854
+
855
+ // Archive the 2nd task
856
+ db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
857
+ Date.now(),
858
+ created[1]?.id as string,
859
+ );
860
+
861
+ // Default call (no includeArchived) → only live task
862
+ const tasks = listTasksByPlan(db, plan.id);
863
+ expect(tasks).toHaveLength(1);
864
+ expect(tasks[0]?.description).toBe("live task");
865
+ expect(tasks[0]?.archivedAt).toBeNull();
866
+ });
867
+
868
+ test("(b) includeArchived=true — retorna tasks archivadas también", () => {
869
+ const plan = makePlan();
870
+ const created = createTasksBatch(db, plan.id, [
871
+ makeTask({ description: "live task" }),
872
+ makeTask({ description: "archived task" }),
873
+ ]);
874
+ expect(created).toHaveLength(2);
875
+
876
+ db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
877
+ Date.now(),
878
+ created[1]?.id as string,
879
+ );
880
+
881
+ // With includeArchived=true → both tasks returned
882
+ const tasks = listTasksByPlan(db, plan.id, { includeArchived: true });
883
+ expect(tasks).toHaveLength(2);
884
+ const descriptions = tasks.map((t) => t.description).sort();
885
+ expect(descriptions).toEqual(["archived task", "live task"]);
886
+ // Archived task carries archivedAt timestamp
887
+ const archived = tasks.find((t) => t.description === "archived task");
888
+ expect(archived?.archivedAt).not.toBeNull();
889
+ });
890
+
891
+ test("(c) includeArchived=true + status filter — combina ambos filtros", () => {
892
+ const plan = makePlan();
893
+ const created = createTasksBatch(db, plan.id, [
894
+ makeTask({ description: "live pending" }),
895
+ makeTask({ description: "archived pending" }),
896
+ makeTask({ description: "live done" }),
897
+ ]);
898
+ // Archive 2nd task, mark 3rd as done
899
+ db.query("UPDATE plan_tasks SET archived_at = ? WHERE id = ?").run(
900
+ Date.now(),
901
+ created[1]?.id as string,
902
+ );
903
+ updateTaskStatus(db, created[2]?.id as string, "done", { result: "ok" }, "test");
904
+
905
+ // status=pending + includeArchived=true → both pending tasks (live + archived)
906
+ const pendingAll = listTasksByPlan(db, plan.id, {
907
+ status: "pending",
908
+ includeArchived: true,
909
+ });
910
+ expect(pendingAll).toHaveLength(2);
911
+ expect(pendingAll.map((t) => t.description).sort()).toEqual([
912
+ "archived pending",
913
+ "live pending",
914
+ ]);
915
+
916
+ // status=pending without includeArchived → only live pending
917
+ const pendingLive = listTasksByPlan(db, plan.id, { status: "pending" });
918
+ expect(pendingLive).toHaveLength(1);
919
+ expect(pendingLive[0]?.description).toBe("live pending");
920
+ });
921
+ });