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,747 @@
1
+ /**
2
+ * ndomo DB — Task CRUD + FTS5 search.
3
+ *
4
+ * All functions take a Database instance and return camelCase TS types.
5
+ * createTasksBatch is transactional.
6
+ */
7
+
8
+ import type { Database, SQLQueryBindings } from "bun:sqlite";
9
+ import { escapeFtsQuery } from "./fts-escape.ts";
10
+ import { setExecutedByOnce } from "./plans.ts";
11
+ import { ensureSession } from "./sessions.ts";
12
+ import type { PlanTask, TaskMetadata, TaskStatus } from "./types.ts";
13
+ import { taskFromRow } from "./types.ts";
14
+
15
+ // ─── M7: Cross-stack file splitting ─────────────────────────────────────────
16
+
17
+ /** Map file extension → stack key. Unrecognized extensions → 'other'. */
18
+ const STACK_MAP: Record<string, string> = {
19
+ ".go": "go",
20
+ ".vue": "vue",
21
+ ".ts": "js",
22
+ ".tsx": "js",
23
+ ".js": "js",
24
+ ".jsx": "js",
25
+ ".py": "python",
26
+ ".rs": "rust",
27
+ ".zig": "zig",
28
+ };
29
+
30
+ /** Map stack key → default agent for that stack. */
31
+ const STACK_AGENT_MAP: Record<string, string> = {
32
+ go: "go-smith",
33
+ vue: "js-smith",
34
+ js: "js-smith",
35
+ python: "python-smith",
36
+ rust: "rust-smith",
37
+ zig: "zig-smith",
38
+ other: "smith",
39
+ };
40
+
41
+ /**
42
+ * Group files by their stack (determined by extension).
43
+ * Returns a record of stackKey → file paths.
44
+ *
45
+ * @example splitFilesByStack(["main.go", "app.ts"]) → { go: ["main.go"], js: ["app.ts"] }
46
+ */
47
+ export function splitFilesByStack(files: string[]): Record<string, string[]> {
48
+ const result: Record<string, string[]> = {};
49
+ for (const f of files) {
50
+ const dotIdx = f.lastIndexOf(".");
51
+ const ext = dotIdx >= 0 ? f.slice(dotIdx).toLowerCase() : "";
52
+ const stack = STACK_MAP[ext] ?? "other";
53
+ if (!result[stack]) result[stack] = [];
54
+ result[stack].push(f);
55
+ }
56
+ return result;
57
+ }
58
+
59
+ // ─── M6: Truncation types ───────────────────────────────────────────────────
60
+
61
+ /** Metadata returned when result/error is truncated to 16 KB. */
62
+ export interface TaskTruncationInfo {
63
+ truncated: boolean;
64
+ originalLength?: number;
65
+ truncatedLength?: number;
66
+ }
67
+
68
+ /** Return type for updateTaskStatus — includes truncation metadata. */
69
+ export type TaskUpdateResult = (PlanTask & { truncation: TaskTruncationInfo }) | null;
70
+
71
+ export function createTasksBatch(
72
+ db: Database,
73
+ planId: string,
74
+ tasks: Array<
75
+ Omit<
76
+ PlanTask,
77
+ | "id"
78
+ | "planId"
79
+ | "status"
80
+ | "startedAt"
81
+ | "completedAt"
82
+ | "result"
83
+ | "error"
84
+ | "archivedAt"
85
+ | "originalPlanData"
86
+ | "orderIndex"
87
+ > & {
88
+ /** Preferred order_index slot. If omitted or occupied, core allocates dynamically. */
89
+ orderIndex?: number;
90
+ }
91
+ >,
92
+ ): PlanTask[] {
93
+ // v6: soft warning for large task batches
94
+ if (tasks.length > 5) {
95
+ console.warn(
96
+ `ndomo: creating ${tasks.length} tasks in batch for plan ${planId} — consider splitting large plans`,
97
+ );
98
+ }
99
+
100
+ // F1: pre-dispatch overlap check — skip tasks with same (planId, agent, description)
101
+ // that already exist (any status, not archived). Prevents duplicate task creation.
102
+ const existingSignatures = new Set<string>();
103
+ const existingRows = db
104
+ .query("SELECT agent, description FROM plan_tasks WHERE plan_id = ? AND archived_at IS NULL")
105
+ .all(planId) as Array<{ agent: string; description: string }>;
106
+ for (const row of existingRows) {
107
+ existingSignatures.add(`${row.agent}::${row.description}`);
108
+ }
109
+
110
+ // ─── order_index collision-safe allocation (fix: UNIQUE constraint on retries) ──
111
+ // The UNIQUE(plan_id, order_index) constraint covers ALL rows (archived or not).
112
+ // Callers may pass t.orderIndex as a preferred slot, but it's only a hint —
113
+ // the core reassigns if the slot is occupied. This makes task_create_batch safe
114
+ // to call 2+ times on the same plan (retry, cross-step dispatch, etc.).
115
+ const MAX_RETRIES = 10;
116
+
117
+ // Collect ALL existing order_indices (including archived) for collision detection.
118
+ const usedOrderIndices = new Set<number>();
119
+ const allOrderRows = db
120
+ .query("SELECT order_index FROM plan_tasks WHERE plan_id = ?")
121
+ .all(planId) as Array<{ order_index: number }>;
122
+ for (const row of allOrderRows) {
123
+ usedOrderIndices.add(row.order_index);
124
+ }
125
+
126
+ // nextFreeInteger starts after the MAX of non-archived tasks (so new tasks
127
+ // fill in after the active set, not after archived outliers).
128
+ const maxRow = db
129
+ .query("SELECT MAX(order_index) as m FROM plan_tasks WHERE plan_id = ? AND archived_at IS NULL")
130
+ .get(planId) as { m: number | null } | undefined;
131
+ let nextFreeInteger = Math.floor(maxRow?.m ?? -1) + 1;
132
+
133
+ /**
134
+ * Allocate a unique order_index.
135
+ * Tries the preferred slot first; if occupied (or undefined), falls back to
136
+ * nextFreeInteger, incrementing until a free slot is found.
137
+ * Marks the slot as used in usedOrderIndices before returning.
138
+ */
139
+ function allocateOrderIndex(preferred: number | undefined): number {
140
+ if (preferred !== undefined && !usedOrderIndices.has(preferred)) {
141
+ usedOrderIndices.add(preferred);
142
+ if (Math.floor(preferred) >= nextFreeInteger) {
143
+ nextFreeInteger = Math.floor(preferred) + 1;
144
+ }
145
+ return preferred;
146
+ }
147
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
148
+ const candidate = nextFreeInteger;
149
+ if (!usedOrderIndices.has(candidate)) {
150
+ usedOrderIndices.add(candidate);
151
+ nextFreeInteger = candidate + 1;
152
+ return candidate;
153
+ }
154
+ nextFreeInteger++;
155
+ }
156
+ throw new Error(
157
+ `ndomo: could not allocate unique order_index after ${MAX_RETRIES} retries for plan ${planId}`,
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Allocate order_index for a split sub-task.
163
+ * stackIdx=0 → parent slot (integer).
164
+ * stackIdx>0 → parentOrder + stackIdx*0.1 (decimal); if occupied, escalate
165
+ * to next free integer (no further decimal attempts).
166
+ */
167
+ function allocateSplitOrderIndex(parentOrder: number, stackIdx: number): number {
168
+ if (stackIdx === 0) {
169
+ // parentOrder was already allocated and marked as used in the pre-loop.
170
+ // Return directly — do NOT re-allocate (would see slot as occupied).
171
+ return parentOrder;
172
+ }
173
+ const decimalCandidate = parentOrder + stackIdx * 0.1;
174
+ if (!usedOrderIndices.has(decimalCandidate)) {
175
+ usedOrderIndices.add(decimalCandidate);
176
+ return decimalCandidate;
177
+ }
178
+ // Decimal occupied → escalate to next free integer
179
+ return allocateOrderIndex(undefined);
180
+ }
181
+
182
+ const results: PlanTask[] = [];
183
+ const txn = db.transaction(() => {
184
+ for (const t of tasks) {
185
+ // M7: split cross-stack files into sub-tasks
186
+ const filesByStack = t.files && t.files.length > 1 ? splitFilesByStack(t.files) : null;
187
+ const stackKeys = filesByStack ? Object.keys(filesByStack) : [];
188
+ const needsSplit = filesByStack !== null && stackKeys.length > 1;
189
+
190
+ // For split tasks: allocate parent order_index first (used as base for decimals).
191
+ // For non-split tasks: orderIndex is allocated per sub-task below.
192
+ const parentOrder = needsSplit ? allocateOrderIndex(t.orderIndex) : undefined;
193
+
194
+ // Generate sub-tasks: either the original or split children
195
+ // (orderIndex is allocated below via helpers — not set here)
196
+ const subTasks = needsSplit
197
+ ? stackKeys.map((stack) => ({
198
+ ...t,
199
+ description: t.description,
200
+ agent: STACK_AGENT_MAP[stack] ?? "smith",
201
+ files: filesByStack[stack] ?? [],
202
+ metadata: {
203
+ ...t.metadata,
204
+ splitFrom: null as string | null, // filled after first insert
205
+ splitReason: "cross-stack" as const,
206
+ },
207
+ }))
208
+ : [t];
209
+
210
+ let firstSubTaskId: string | null = null;
211
+
212
+ for (let stackIdx = 0; stackIdx < subTasks.length; stackIdx++) {
213
+ const effectiveTask = subTasks[stackIdx];
214
+ if (effectiveTask === undefined) continue;
215
+
216
+ // Skip if task with same (agent, description) already exists for this plan
217
+ const sig = `${effectiveTask.agent}::${effectiveTask.description}`;
218
+ if (existingSignatures.has(sig)) {
219
+ continue;
220
+ }
221
+
222
+ // Allocate order_index via collision-safe helpers
223
+ const orderIndex = needsSplit
224
+ ? allocateSplitOrderIndex(parentOrder as number, stackIdx)
225
+ : allocateOrderIndex(effectiveTask.orderIndex);
226
+
227
+ const id = crypto.randomUUID();
228
+
229
+ // Wire splitFrom to first sub-task id
230
+ if (needsSplit && firstSubTaskId === null) {
231
+ firstSubTaskId = id;
232
+ }
233
+ const taskMetadata = needsSplit
234
+ ? { ...effectiveTask.metadata, splitFrom: firstSubTaskId }
235
+ : (effectiveTask.metadata ?? {});
236
+
237
+ // Defense-in-depth: try/catch UNIQUE constraint with retry on order_index.
238
+ // The pre-loop allocation should prevent collisions, but if a race or
239
+ // edge case triggers SQLITE_CONSTRAINT, reassign order_index and retry.
240
+ let currentOrderIndex = orderIndex;
241
+ let originalPlanData = JSON.stringify({
242
+ description: effectiveTask.description,
243
+ agent: effectiveTask.agent,
244
+ files: effectiveTask.files ?? [],
245
+ complexity: effectiveTask.complexity,
246
+ dependencies: effectiveTask.dependencies ?? [],
247
+ metadata: taskMetadata,
248
+ orderIndex: currentOrderIndex,
249
+ createdBy: effectiveTask.createdBy,
250
+ });
251
+ let inserted = false;
252
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
253
+ try {
254
+ db.query(
255
+ `INSERT INTO plan_tasks (id, plan_id, order_index, description, agent, files, complexity, status, dependencies, metadata, created_by, updated_by, source_session_id, source_message_id, reviewed_by, tokens_used, duration_ms, artifacts, original_plan_data)
256
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
257
+ ).run(
258
+ id,
259
+ planId,
260
+ currentOrderIndex,
261
+ effectiveTask.description,
262
+ effectiveTask.agent,
263
+ JSON.stringify(effectiveTask.files ?? []),
264
+ effectiveTask.complexity,
265
+ JSON.stringify(effectiveTask.dependencies ?? []),
266
+ JSON.stringify(taskMetadata),
267
+ effectiveTask.createdBy,
268
+ effectiveTask.updatedBy ?? effectiveTask.createdBy,
269
+ effectiveTask.sourceSessionId ?? null,
270
+ effectiveTask.sourceMessageId ?? null,
271
+ effectiveTask.reviewedBy ?? null,
272
+ effectiveTask.tokensUsed ?? null,
273
+ effectiveTask.durationMs ?? null,
274
+ JSON.stringify(effectiveTask.artifacts ?? []),
275
+ originalPlanData,
276
+ );
277
+ inserted = true;
278
+ break;
279
+ } catch (err) {
280
+ if (
281
+ attempt < MAX_RETRIES - 1 &&
282
+ err instanceof Error &&
283
+ err.message.includes("UNIQUE")
284
+ ) {
285
+ // order_index collision — reassign and rebuild snapshot
286
+ currentOrderIndex = allocateOrderIndex(undefined);
287
+ originalPlanData = JSON.stringify({
288
+ description: effectiveTask.description,
289
+ agent: effectiveTask.agent,
290
+ files: effectiveTask.files ?? [],
291
+ complexity: effectiveTask.complexity,
292
+ dependencies: effectiveTask.dependencies ?? [],
293
+ metadata: taskMetadata,
294
+ orderIndex: currentOrderIndex,
295
+ createdBy: effectiveTask.createdBy,
296
+ });
297
+ continue;
298
+ }
299
+ throw err;
300
+ }
301
+ }
302
+ if (!inserted) {
303
+ throw new Error(
304
+ `ndomo: failed to insert task after ${MAX_RETRIES} retries for plan ${planId}`,
305
+ );
306
+ }
307
+
308
+ results.push({
309
+ id,
310
+ planId,
311
+ orderIndex: currentOrderIndex,
312
+ description: effectiveTask.description,
313
+ agent: effectiveTask.agent,
314
+ files: effectiveTask.files ?? [],
315
+ complexity: effectiveTask.complexity,
316
+ status: "pending",
317
+ startedAt: null,
318
+ completedAt: null,
319
+ result: null,
320
+ error: null,
321
+ dependencies: effectiveTask.dependencies ?? [],
322
+ createdBy: effectiveTask.createdBy,
323
+ updatedBy: effectiveTask.updatedBy ?? effectiveTask.createdBy ?? "unknown",
324
+ sourceSessionId: effectiveTask.sourceSessionId ?? null,
325
+ sourceMessageId: effectiveTask.sourceMessageId ?? null,
326
+ reviewedBy: effectiveTask.reviewedBy ?? null,
327
+ tokensUsed: effectiveTask.tokensUsed ?? null,
328
+ durationMs: effectiveTask.durationMs ?? null,
329
+ artifacts: effectiveTask.artifacts ?? [],
330
+ metadata: taskMetadata as TaskMetadata,
331
+ archivedAt: null,
332
+ originalPlanData,
333
+ });
334
+
335
+ // Track signature to prevent in-batch duplicates
336
+ existingSignatures.add(sig);
337
+
338
+ // Issue 4: insert task files into plan_files with role='modified' (spec v2 §7.2)
339
+ if (effectiveTask.files && effectiveTask.files.length > 0) {
340
+ for (const filePath of effectiveTask.files) {
341
+ db.query(
342
+ "INSERT OR IGNORE INTO plan_files (plan_id, file_path, role) VALUES (?, ?, 'modified')",
343
+ ).run(planId, filePath);
344
+ }
345
+ }
346
+ }
347
+ }
348
+ });
349
+ txn();
350
+ return results;
351
+ }
352
+
353
+ export function getTask(db: Database, id: string): PlanTask | null {
354
+ const row = db.query("SELECT * FROM plan_tasks WHERE id = ?").get(id);
355
+ return row != null ? taskFromRow(row) : null;
356
+ }
357
+
358
+ export function listTasksByPlan(
359
+ db: Database,
360
+ planId: string,
361
+ opts: { status?: TaskStatus; includeArchived?: boolean } = {},
362
+ ): PlanTask[] {
363
+ const conditions: string[] = ["plan_id = ?"];
364
+ const params: SQLQueryBindings[] = [planId];
365
+
366
+ if (opts.status !== undefined) {
367
+ conditions.push("status = ?");
368
+ params.push(opts.status);
369
+ }
370
+ if (!opts.includeArchived) {
371
+ conditions.push("archived_at IS NULL");
372
+ }
373
+
374
+ const rows = db
375
+ .query(`SELECT * FROM plan_tasks WHERE ${conditions.join(" AND ")} ORDER BY order_index`)
376
+ .all(...params);
377
+ return (rows as unknown[]).map(taskFromRow);
378
+ }
379
+
380
+ const MAX_RESULT_BYTES = 16 * 1024;
381
+ const TRUNC_SUFFIX = "…[truncated]";
382
+
383
+ /**
384
+ * Truncate strings exceeding 16 KB to prevent unbounded storage growth.
385
+ * Returns [truncated, wasTruncated] tuple for metadata tracking.
386
+ */
387
+ function truncWithInfo(s: string | undefined): [string | undefined, boolean, number | undefined] {
388
+ if (s === undefined) return [undefined, false, undefined];
389
+ if (s.length > MAX_RESULT_BYTES) {
390
+ const truncated = `${s.slice(0, MAX_RESULT_BYTES - TRUNC_SUFFIX.length)}${TRUNC_SUFFIX}`;
391
+ return [truncated, true, s.length];
392
+ }
393
+ return [s, false, undefined];
394
+ }
395
+
396
+ /**
397
+ * Deep merge source into target. Nested objects merge recursively,
398
+ * arrays are replaced (not concatenated), primitives overwrite.
399
+ */
400
+ function deepMerge(
401
+ target: Record<string, unknown>,
402
+ source: Record<string, unknown>,
403
+ ): Record<string, unknown> {
404
+ const result: Record<string, unknown> = { ...target };
405
+ for (const key of Object.keys(source)) {
406
+ const srcVal = source[key];
407
+ const tgtVal = result[key];
408
+ if (
409
+ srcVal !== null &&
410
+ typeof srcVal === "object" &&
411
+ !Array.isArray(srcVal) &&
412
+ tgtVal !== null &&
413
+ typeof tgtVal === "object" &&
414
+ !Array.isArray(tgtVal)
415
+ ) {
416
+ result[key] = deepMerge(tgtVal as Record<string, unknown>, srcVal as Record<string, unknown>);
417
+ } else {
418
+ result[key] = srcVal;
419
+ }
420
+ }
421
+ return result;
422
+ }
423
+
424
+ /**
425
+ * Truncate artifacts array so JSON.stringify fits within MAX_RESULT_BYTES.
426
+ * Keeps first N elements that fit. Returns [truncatedArray, wasTruncated].
427
+ */
428
+ function truncateArtifacts(artifacts: string[]): [string[], boolean] {
429
+ const fullJson = JSON.stringify(artifacts);
430
+ if (fullJson.length <= MAX_RESULT_BYTES) return [artifacts, false];
431
+ const truncated: string[] = [];
432
+ for (const item of artifacts) {
433
+ const candidate = [...truncated, item];
434
+ if (JSON.stringify(candidate).length > MAX_RESULT_BYTES) break;
435
+ truncated.push(item);
436
+ }
437
+ return [truncated, true];
438
+ }
439
+
440
+ export function updateTaskStatus(
441
+ db: Database,
442
+ id: string,
443
+ status: TaskStatus,
444
+ fields?: {
445
+ result?: string;
446
+ error?: string;
447
+ artifacts?: string[];
448
+ metadataPatch?: Record<string, unknown>;
449
+ reviewedBy?: string;
450
+ reviewedVerdict?: string;
451
+ },
452
+ updatedBy?: string,
453
+ ctx?: { agent?: string; sessionId?: string },
454
+ ): TaskUpdateResult {
455
+ const now = Date.now();
456
+ const setClauses: string[] = ["status = ?"];
457
+ const params: SQLQueryBindings[] = [status];
458
+
459
+ if (status === "running") {
460
+ setClauses.push("started_at = ?");
461
+ params.push(now);
462
+
463
+ // Issue 2: write-once executed_by_agent/session on plan when task starts
464
+ if (ctx?.agent) {
465
+ const task = db.query("SELECT plan_id FROM plan_tasks WHERE id = ?").get(id) as
466
+ | { plan_id: string }
467
+ | undefined;
468
+ if (task?.plan_id) {
469
+ // HIGH 3: ensure session exists before FK write
470
+ if (ctx.sessionId) {
471
+ ensureSession(db, ctx.sessionId, "auto-created for task execution", ctx.agent);
472
+ }
473
+ setExecutedByOnce(db, task.plan_id, ctx.agent, ctx.sessionId ?? null);
474
+ }
475
+ }
476
+ }
477
+ if (status === "done" || status === "failed") {
478
+ setClauses.push("completed_at = ?");
479
+ params.push(now);
480
+ }
481
+
482
+ // M6: truncate result/error to 16 KB max with warning + metadata
483
+ const truncationInfo: TaskTruncationInfo = { truncated: false };
484
+ const fieldDefs: Array<[string, string | undefined]> = [
485
+ ["result", fields?.result],
486
+ ["error", fields?.error],
487
+ ];
488
+ for (const [col, val] of fieldDefs) {
489
+ if (val !== undefined) {
490
+ const [truncated, wasTruncated, originalLength] = truncWithInfo(val);
491
+ if (wasTruncated) {
492
+ truncationInfo.truncated = true;
493
+ if (originalLength !== undefined) truncationInfo.originalLength = originalLength;
494
+ truncationInfo.truncatedLength = MAX_RESULT_BYTES;
495
+ console.warn(
496
+ `ndomo: task_update_status ${id} — ${col} truncated from ${originalLength} to ${MAX_RESULT_BYTES} bytes`,
497
+ );
498
+ }
499
+ setClauses.push(`${col} = ?`);
500
+ params.push(truncated as SQLQueryBindings);
501
+ }
502
+ }
503
+
504
+ // T1: artifacts — JSON.stringify, truncate array if >16KB
505
+ if (fields?.artifacts !== undefined) {
506
+ const [truncatedArr, wasTruncated] = truncateArtifacts(fields.artifacts);
507
+ if (wasTruncated) {
508
+ truncationInfo.truncated = true;
509
+ truncationInfo.originalLength = JSON.stringify(fields.artifacts).length;
510
+ truncationInfo.truncatedLength = MAX_RESULT_BYTES;
511
+ console.warn(
512
+ `ndomo: task_update_status ${id} — artifacts truncated from ${truncationInfo.originalLength} to ${MAX_RESULT_BYTES} bytes`,
513
+ );
514
+ }
515
+ setClauses.push("artifacts = ?");
516
+ params.push(JSON.stringify(truncatedArr));
517
+ }
518
+
519
+ // T1: reviewed_by column
520
+ if (fields?.reviewedBy !== undefined) {
521
+ setClauses.push("reviewed_by = ?");
522
+ params.push(fields.reviewedBy);
523
+ }
524
+
525
+ // T1: metadataPatch (deep merge) + reviewedVerdict (stored in metadata)
526
+ if (fields?.metadataPatch !== undefined || fields?.reviewedVerdict !== undefined) {
527
+ const currentRow = db.query("SELECT metadata FROM plan_tasks WHERE id = ?").get(id) as
528
+ | { metadata: string | null }
529
+ | undefined;
530
+ const currentMetadata: Record<string, unknown> = currentRow?.metadata
531
+ ? JSON.parse(currentRow.metadata)
532
+ : {};
533
+ const patch: Record<string, unknown> = { ...(fields.metadataPatch ?? {}) };
534
+ if (fields?.reviewedVerdict !== undefined) {
535
+ patch.reviewedVerdict = fields.reviewedVerdict;
536
+ }
537
+ const merged = deepMerge(currentMetadata, patch);
538
+ setClauses.push("metadata = ?");
539
+ params.push(JSON.stringify(merged));
540
+ }
541
+
542
+ if (updatedBy !== undefined) {
543
+ setClauses.push("updated_by = ?");
544
+ params.push(updatedBy);
545
+ }
546
+
547
+ params.push(id);
548
+ db.query(`UPDATE plan_tasks SET ${setClauses.join(", ")} WHERE id = ?`).run(...params);
549
+ const task = getTask(db, id);
550
+ if (!task) return null;
551
+ return { ...task, truncation: truncationInfo };
552
+ }
553
+
554
+ export function searchTasks(
555
+ db: Database,
556
+ query: string,
557
+ limit = 20,
558
+ opts: { includeArchived?: boolean } = {},
559
+ ): PlanTask[] {
560
+ const archiveFilter = opts.includeArchived ? "" : "AND t.archived_at IS NULL";
561
+ const rows = db
562
+ .query(
563
+ `SELECT t.* FROM plan_tasks t
564
+ JOIN tasks_fts fts ON t.rowid = fts.rowid
565
+ WHERE tasks_fts MATCH ? ${archiveFilter}
566
+ ORDER BY rank
567
+ LIMIT ?`,
568
+ )
569
+ .all(escapeFtsQuery(query), limit);
570
+ return (rows as unknown[]).map(taskFromRow);
571
+ }
572
+
573
+ /**
574
+ * Resolve a task's dependencies: classify each dep ID by its current status.
575
+ * Throws if taskId is not found in the database.
576
+ *
577
+ * @returns Object with `canStart` (true iff all deps are 'done') and
578
+ * arrays categorizing each dep by status.
579
+ */
580
+ export function resolveTaskDependencies(
581
+ db: Database,
582
+ taskId: string,
583
+ ): {
584
+ canStart: boolean;
585
+ pendingDeps: string[];
586
+ runningDeps: string[];
587
+ failedDeps: string[];
588
+ blockedDeps: string[];
589
+ doneDeps: string[];
590
+ missingDeps: string[];
591
+ dependencies: string[];
592
+ } {
593
+ const row = db
594
+ .query("SELECT dependencies FROM plan_tasks WHERE id = ?")
595
+ .get(taskId) as { dependencies: string } | undefined;
596
+ if (!row) throw new Error(`ndomo: task ${taskId} not found`);
597
+
598
+ const dependencies: string[] = (JSON.parse(row.dependencies) as string[]) ?? [];
599
+ if (dependencies.length === 0) {
600
+ return {
601
+ canStart: true,
602
+ pendingDeps: [],
603
+ runningDeps: [],
604
+ failedDeps: [],
605
+ blockedDeps: [],
606
+ doneDeps: [],
607
+ missingDeps: [],
608
+ dependencies,
609
+ };
610
+ }
611
+
612
+ const pendingDeps: string[] = [];
613
+ const runningDeps: string[] = [];
614
+ const failedDeps: string[] = [];
615
+ const blockedDeps: string[] = [];
616
+ const doneDeps: string[] = [];
617
+ const missingDeps: string[] = [];
618
+
619
+ // Batch-fetch all dep statuses in one query
620
+ const placeholders = dependencies.map(() => "?").join(",");
621
+ const depRows = db
622
+ .query(`SELECT id, status FROM plan_tasks WHERE id IN (${placeholders})`)
623
+ .all(...dependencies) as Array<{ id: string; status: string }>;
624
+
625
+ const statusMap = new Map<string, string>();
626
+ for (const dr of depRows) {
627
+ statusMap.set(dr.id, dr.status);
628
+ }
629
+
630
+ for (const depId of dependencies) {
631
+ const st = statusMap.get(depId);
632
+ if (st === undefined) {
633
+ missingDeps.push(depId);
634
+ } else if (st === "done") {
635
+ doneDeps.push(depId);
636
+ } else if (st === "pending") {
637
+ pendingDeps.push(depId);
638
+ } else if (st === "running") {
639
+ runningDeps.push(depId);
640
+ } else if (st === "failed") {
641
+ failedDeps.push(depId);
642
+ } else if (st === "blocked") {
643
+ blockedDeps.push(depId);
644
+ }
645
+ }
646
+
647
+ const canStart =
648
+ doneDeps.length === dependencies.length;
649
+
650
+ return {
651
+ canStart,
652
+ pendingDeps,
653
+ runningDeps,
654
+ failedDeps,
655
+ blockedDeps,
656
+ doneDeps,
657
+ missingDeps,
658
+ dependencies,
659
+ };
660
+ }
661
+
662
+ /**
663
+ * Atomically claim next pending task for agent.
664
+ * Uses transaction (SELECT + UPDATE) to prevent race condition.
665
+ * SQLite transactions are SERIALIZABLE — no concurrent claim possible.
666
+ *
667
+ * Respects task dependencies: a pending task is only claimed if all
668
+ * entries in its `dependencies` JSON array have status='done'.
669
+ */
670
+ export function nextTaskForAgent(
671
+ db: Database,
672
+ agent: string,
673
+ opts: { planId?: string; includeArchived?: boolean } = {},
674
+ ): PlanTask | null {
675
+ const now = Date.now();
676
+ const archiveFilter = opts.includeArchived ? "" : "AND archived_at IS NULL";
677
+
678
+ return db.transaction(() => {
679
+ // Fetch candidate pending tasks (cap at 100 for efficiency)
680
+ const rows =
681
+ opts.planId !== undefined
682
+ ? (db
683
+ .query(
684
+ `SELECT * FROM plan_tasks
685
+ WHERE agent = ? AND plan_id = ? AND status = 'pending' ${archiveFilter}
686
+ ORDER BY order_index LIMIT 100`,
687
+ )
688
+ .all(agent, opts.planId) as unknown[])
689
+ : (db
690
+ .query(
691
+ `SELECT * FROM plan_tasks
692
+ WHERE agent = ? AND status = 'pending' ${archiveFilter}
693
+ ORDER BY order_index LIMIT 100`,
694
+ )
695
+ .all(agent) as unknown[]);
696
+
697
+ for (const row of rows) {
698
+ const task = taskFromRow(row);
699
+
700
+ // Check dependencies: all must be 'done'
701
+ if (task.dependencies.length > 0) {
702
+ const placeholders = task.dependencies.map(() => "?").join(",");
703
+ const depRows = db
704
+ .query(`SELECT id, status FROM plan_tasks WHERE id IN (${placeholders})`)
705
+ .all(...task.dependencies) as Array<{ id: string; status: string }>;
706
+
707
+ const statusMap = new Map<string, string>();
708
+ for (const dr of depRows) {
709
+ statusMap.set(dr.id, dr.status);
710
+ }
711
+
712
+ const allDone = task.dependencies.every((depId) => statusMap.get(depId) === "done");
713
+ if (!allDone) continue; // skip — deps not met
714
+ }
715
+
716
+ // Claim this task
717
+ db.query(
718
+ `UPDATE plan_tasks SET status = 'running', started_at = ?, updated_by = ? WHERE id = ?`,
719
+ ).run(now, agent, task.id);
720
+ return { ...task, status: "running" as const, startedAt: now, updatedBy: agent };
721
+ }
722
+
723
+ return null;
724
+ })();
725
+ }
726
+
727
+ // ─── Tag helpers ─────────────────────────────────────────────────────────────
728
+
729
+ export function addTaskTag(db: Database, taskId: string, tag: string, addedBy: string): void {
730
+ db.query(
731
+ "INSERT OR IGNORE INTO task_tags (task_id, tag, added_by, added_at) VALUES (?, ?, ?, ?)",
732
+ ).run(taskId, tag, addedBy, Date.now());
733
+ }
734
+
735
+ export function removeTaskTag(db: Database, taskId: string, tag: string): void {
736
+ db.query("DELETE FROM task_tags WHERE task_id = ? AND tag = ?").run(taskId, tag);
737
+ }
738
+
739
+ export function getTaskTags(
740
+ db: Database,
741
+ taskId: string,
742
+ ): Array<{ tag: string; addedBy: string; addedAt: number }> {
743
+ const rows = db
744
+ .query("SELECT tag, added_by, added_at FROM task_tags WHERE task_id = ? ORDER BY tag")
745
+ .all(taskId) as Array<{ tag: string; added_by: string; added_at: number }>;
746
+ return rows.map((r) => ({ tag: r.tag, addedBy: r.added_by, addedAt: r.added_at }));
747
+ }