jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,1086 @@
1
+ //! Work item CRUD and status management.
2
+ //!
3
+ //! Port of `lib/work-tracking/index.js` — the core data operations for
4
+ //! creating, querying, and updating work items. Complex cascading logic
5
+ //! (worktree merging, session management) lives in their respective modules.
6
+
7
+
8
+ use crate::db::Database;
9
+ use crate::error::{CoreError, Result, WorkError};
10
+ use rusqlite::params;
11
+ use serde::{Deserialize, Serialize};
12
+ use strum::{AsRefStr, Display, EnumString};
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /// Work item type.
19
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, Display, EnumString)]
20
+ #[serde(rename_all = "snake_case")]
21
+ #[strum(serialize_all = "snake_case")]
22
+ pub enum ItemType {
23
+ Epic,
24
+ Feature,
25
+ Chore,
26
+ Bug,
27
+ }
28
+
29
+ /// Work item status.
30
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, Display, EnumString)]
31
+ #[serde(rename_all = "snake_case")]
32
+ #[strum(serialize_all = "snake_case")]
33
+ pub enum Status {
34
+ Backlog,
35
+ Todo,
36
+ #[serde(rename = "in_progress")]
37
+ #[strum(serialize = "in_progress")]
38
+ InProgress,
39
+ Blocked,
40
+ Done,
41
+ Cancelled,
42
+ }
43
+
44
+ /// Work item mode — controls the workflow progression.
45
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, Display, EnumString)]
46
+ #[serde(rename_all = "snake_case")]
47
+ #[strum(serialize_all = "snake_case")]
48
+ pub enum Mode {
49
+ Discovery,
50
+ Speed,
51
+ Stable,
52
+ Production,
53
+ }
54
+
55
+ /// Work item phase — tracks discovery vs implementation.
56
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, AsRefStr, Display, EnumString)]
57
+ #[serde(rename_all = "snake_case")]
58
+ #[strum(serialize_all = "snake_case")]
59
+ pub enum Phase {
60
+ Discovery,
61
+ Implementation,
62
+ }
63
+
64
+ /// A complete work item row from the database.
65
+ #[derive(Debug, Clone, Serialize, Deserialize)]
66
+ pub struct WorkItem {
67
+ pub id: i64,
68
+ #[serde(rename = "type")]
69
+ pub item_type: ItemType,
70
+ pub title: String,
71
+ pub description: Option<String>,
72
+ pub status: Status,
73
+ pub parent_id: Option<i64>,
74
+ pub epic_id: Option<i64>,
75
+ pub branch_name: Option<String>,
76
+ pub mode: Option<Mode>,
77
+ pub current: bool,
78
+ pub phase: Option<Phase>,
79
+ pub scenario_file: Option<String>,
80
+ pub completed_at: Option<String>,
81
+ pub created_at: Option<String>,
82
+ pub display_order: Option<i64>,
83
+ pub needs_discovery: bool,
84
+ pub ready_for_review: bool,
85
+ pub conversational: bool,
86
+ pub plan_at_creation: Option<String>,
87
+ pub rejection_count: i64,
88
+ pub rejection_round: Option<i64>,
89
+ pub rejection_history: Option<String>,
90
+ pub rejection_reason: Option<String>,
91
+ pub rejected_at: Option<String>,
92
+ pub qa_steps: Option<String>,
93
+ }
94
+
95
+ /// Options for creating a work item.
96
+ #[derive(Debug, Default)]
97
+ pub struct CreateOptions {
98
+ pub description: String,
99
+ pub parent_id: Option<i64>,
100
+ pub mode: Option<Mode>,
101
+ pub needs_discovery: bool,
102
+ pub conversational: bool,
103
+ }
104
+
105
+ /// A discovery decision for an epic.
106
+ #[derive(Debug, Clone, Serialize, Deserialize)]
107
+ pub struct DiscoveryDecision {
108
+ pub id: i64,
109
+ pub work_item_id: i64,
110
+ pub aspect: String,
111
+ pub decision: String,
112
+ pub rationale: String,
113
+ pub created_at: Option<String>,
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Title sanitization
118
+ // ---------------------------------------------------------------------------
119
+
120
+ const MAX_TITLE_LEN: usize = 60;
121
+
122
+ /// Sanitize a work item title — strip HTML, dangerous chars, and truncate.
123
+ pub fn sanitize_title(title: &str) -> String {
124
+ // Strip HTML tags
125
+ let no_html = strip_html_tags(title);
126
+ // Remove dangerous characters: $ ` \
127
+ let safe: String = no_html
128
+ .chars()
129
+ .filter(|c| *c != '$' && *c != '`' && *c != '\\')
130
+ .collect();
131
+ // Remove control characters and normalize whitespace
132
+ let normalized: String = safe
133
+ .chars()
134
+ .map(|c| if c.is_control() || c == '\n' || c == '\r' { ' ' } else { c })
135
+ .collect();
136
+ let trimmed = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
137
+
138
+ // Truncate to max length
139
+ if trimmed.len() > MAX_TITLE_LEN {
140
+ trimmed[..MAX_TITLE_LEN].to_string()
141
+ } else {
142
+ trimmed
143
+ }
144
+ }
145
+
146
+ fn strip_html_tags(s: &str) -> String {
147
+ let mut result = String::with_capacity(s.len());
148
+ let mut in_tag = false;
149
+ for c in s.chars() {
150
+ match c {
151
+ '<' => in_tag = true,
152
+ '>' => in_tag = false,
153
+ _ if !in_tag => result.push(c),
154
+ _ => {}
155
+ }
156
+ }
157
+ result
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // CRUD operations
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /// Create a new work item. Returns the new item's ID.
165
+ pub fn create(
166
+ db: &Database,
167
+ item_type: ItemType,
168
+ title: &str,
169
+ opts: CreateOptions,
170
+ ) -> Result<i64> {
171
+ let title = sanitize_title(title);
172
+ if title.is_empty() {
173
+ return Err(WorkError::EmptyTitle.into());
174
+ }
175
+
176
+ let conn = db.conn();
177
+
178
+ // Resolve epic_id from parent chain
179
+ let (epic_id, rejection_round) = if let Some(pid) = opts.parent_id {
180
+ let parent: (String, Option<i64>, i64) = conn.query_row(
181
+ "SELECT type, epic_id, rejection_count FROM work_items WHERE id = ?",
182
+ params![pid],
183
+ |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
184
+ ).map_err(|_| WorkError::NotFound(pid))?;
185
+
186
+ let (parent_type, parent_epic_id, parent_rejection_count) = parent;
187
+ let parent_item_type = parent_type.parse::<ItemType>().map_err(|_|
188
+ WorkError::Other(format!("invalid parent type: {parent_type}")))?;
189
+ let eid = match parent_item_type {
190
+ ItemType::Epic => Some(pid),
191
+ _ => parent_epic_id,
192
+ };
193
+ let rr = if parent_rejection_count > 0 {
194
+ Some(parent_rejection_count)
195
+ } else {
196
+ None
197
+ };
198
+ (eid, rr)
199
+ } else {
200
+ (None, None)
201
+ };
202
+
203
+ // Determine phase for features
204
+ let phase = match item_type {
205
+ ItemType::Feature => {
206
+ if opts.mode.is_some() {
207
+ Some(Phase::Implementation)
208
+ } else {
209
+ Some(Phase::Discovery)
210
+ }
211
+ }
212
+ _ => None,
213
+ };
214
+
215
+ // Chores and bugs never have modes
216
+ let mode = match item_type {
217
+ ItemType::Chore | ItemType::Bug | ItemType::Epic => None,
218
+ ItemType::Feature => opts.mode,
219
+ };
220
+
221
+ let plan = crate::auth::current_plan();
222
+
223
+ conn.execute(
224
+ "INSERT INTO work_items (type, title, description, parent_id, epic_id, mode, \
225
+ needs_discovery, phase, status, plan_at_creation, rejection_round, conversational) \
226
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'backlog', ?9, ?10, ?11)",
227
+ params![
228
+ item_type.as_ref(),
229
+ title,
230
+ opts.description,
231
+ opts.parent_id,
232
+ epic_id,
233
+ mode.map(|m| m.to_string()),
234
+ opts.needs_discovery as i32,
235
+ phase.map(|p| p.to_string()),
236
+ plan,
237
+ rejection_round,
238
+ opts.conversational as i32,
239
+ ],
240
+ )?;
241
+
242
+ Ok(conn.last_insert_rowid())
243
+ }
244
+
245
+ /// Get a single work item by ID.
246
+ pub fn get(db: &Database, id: i64) -> Result<WorkItem> {
247
+ db.conn()
248
+ .query_row("SELECT * FROM work_items WHERE id = ?", params![id], row_to_work_item)
249
+ .map_err(|e| match e {
250
+ rusqlite::Error::QueryReturnedNoRows => {
251
+ CoreError::Work(WorkError::NotFound(id))
252
+ }
253
+ other => CoreError::Database(other),
254
+ })
255
+ }
256
+
257
+ /// Get all children of a work item.
258
+ pub fn get_children(db: &Database, parent_id: i64) -> Result<Vec<WorkItem>> {
259
+ let mut stmt = db.conn().prepare(
260
+ "SELECT * FROM work_items WHERE parent_id = ? ORDER BY id",
261
+ )?;
262
+ let items = stmt
263
+ .query_map(params![parent_id], row_to_work_item)?
264
+ .collect::<std::result::Result<Vec<_>, _>>()?;
265
+ Ok(items)
266
+ }
267
+
268
+ /// Get the full work item tree, optionally including completed items.
269
+ pub fn get_tree(db: &Database, include_completed: bool) -> Result<Vec<WorkItem>> {
270
+ get_tree_limited(db, include_completed, None)
271
+ }
272
+
273
+ /// Like [`get_tree`] but with an optional cap on completed items.
274
+ /// When `completed_limit` is `Some(n)`, only the `n` most-recently completed
275
+ /// items are included (by `completed_at DESC`). Active items always included.
276
+ pub fn get_tree_limited(
277
+ db: &Database,
278
+ include_completed: bool,
279
+ completed_limit: Option<i64>,
280
+ ) -> Result<Vec<WorkItem>> {
281
+ let sql = match (include_completed, completed_limit) {
282
+ (true, None) => "SELECT * FROM work_items ORDER BY id".to_string(),
283
+ (false, _) => "SELECT * FROM work_items WHERE status NOT IN ('done', 'cancelled') ORDER BY id".to_string(),
284
+ (true, Some(n)) => format!(
285
+ "SELECT * FROM work_items WHERE status NOT IN ('done', 'cancelled') \
286
+ UNION ALL \
287
+ SELECT * FROM ( \
288
+ SELECT * FROM work_items WHERE status IN ('done', 'cancelled') \
289
+ ORDER BY completed_at DESC NULLS LAST, id DESC \
290
+ LIMIT {n} \
291
+ ) ORDER BY id"
292
+ ),
293
+ };
294
+
295
+ let mut stmt = db.conn().prepare(&sql)?;
296
+ let items = stmt
297
+ .query_map([], row_to_work_item)?
298
+ .collect::<std::result::Result<Vec<_>, _>>()?;
299
+ Ok(items)
300
+ }
301
+
302
+ /// Walk up the parent chain to find the epic for a work item.
303
+ ///
304
+ /// Uses a recursive CTE to traverse the parent chain in a single query
305
+ /// instead of N sequential lookups.
306
+ pub fn find_epic(db: &Database, item_id: i64) -> Result<Option<WorkItem>> {
307
+ let sql = "\
308
+ WITH RECURSIVE ancestors(id, depth) AS ( \
309
+ SELECT id, 0 FROM work_items WHERE id = ?1 \
310
+ UNION ALL \
311
+ SELECT w.parent_id, a.depth + 1 \
312
+ FROM work_items w \
313
+ JOIN ancestors a ON w.id = a.id \
314
+ WHERE w.parent_id IS NOT NULL AND a.depth < 20 \
315
+ ) \
316
+ SELECT w.* FROM work_items w \
317
+ JOIN ancestors a ON w.id = a.id \
318
+ WHERE w.type = 'epic' \
319
+ LIMIT 1";
320
+
321
+ let mut stmt = db.conn().prepare(sql)?;
322
+ let mut rows = stmt.query_map(params![item_id], row_to_work_item)?;
323
+ match rows.next() {
324
+ Some(Ok(item)) => Ok(Some(item)),
325
+ Some(Err(e)) => Err(e.into()),
326
+ None => Ok(None),
327
+ }
328
+ }
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Update operations
332
+ // ---------------------------------------------------------------------------
333
+
334
+ /// Update a single column on a work item.
335
+ fn update_field(db: &Database, id: i64, column: &str, value: &dyn rusqlite::types::ToSql) -> Result<()> {
336
+ let sql = format!("UPDATE work_items SET {} = ? WHERE id = ?", column);
337
+ db.conn().execute(&sql, rusqlite::params![value, id])?;
338
+ Ok(())
339
+ }
340
+
341
+ /// Update a work item's status.
342
+ pub fn update_status(db: &Database, id: i64, status: Status) -> Result<()> {
343
+ let completed_at = if status == Status::Done {
344
+ Some(chrono::Utc::now().to_rfc3339())
345
+ } else {
346
+ None
347
+ };
348
+
349
+ db.conn().execute(
350
+ "UPDATE work_items SET status = ?, completed_at = ? WHERE id = ?",
351
+ params![status.as_ref(), completed_at, id],
352
+ )?;
353
+
354
+ // Clear current flag if completing/cancelling
355
+ if status == Status::Done || status == Status::Cancelled {
356
+ db.conn().execute(
357
+ "UPDATE work_items SET current = 0 WHERE id = ? AND current = 1",
358
+ params![id],
359
+ )?;
360
+ }
361
+
362
+ // Clear ready_for_review when moving back to in_progress (rejection)
363
+ if status == Status::InProgress {
364
+ db.conn().execute(
365
+ "UPDATE work_items SET ready_for_review = 0 WHERE id = ? AND ready_for_review = 1",
366
+ params![id],
367
+ )?;
368
+ }
369
+
370
+ Ok(())
371
+ }
372
+
373
+ /// Set a work item's branch name.
374
+ pub fn set_branch(db: &Database, id: i64, branch: &str) -> Result<()> {
375
+ update_field(db, id, "branch_name", &branch)
376
+ }
377
+
378
+ /// Set a work item's scenario file path.
379
+ pub fn set_scenario(db: &Database, id: i64, scenario_file: &str) -> Result<()> {
380
+ update_field(db, id, "scenario_file", &scenario_file)
381
+ }
382
+
383
+ /// Set a work item's mode (features only).
384
+ pub fn set_mode(db: &Database, id: i64, mode: &str) -> Result<()> {
385
+ let item_type: String = db.conn().query_row(
386
+ "SELECT type FROM work_items WHERE id = ?",
387
+ params![id],
388
+ |row| row.get(0),
389
+ ).map_err(|_| WorkError::NotFound(id))?;
390
+
391
+ let parsed_type = item_type.parse::<ItemType>().map_err(|_|
392
+ WorkError::Other(format!("invalid item type: {item_type}")))?;
393
+
394
+ if parsed_type != ItemType::Feature {
395
+ return Err(WorkError::Other(format!(
396
+ "Only features have modes (#{} is a {})",
397
+ id, parsed_type
398
+ ))
399
+ .into());
400
+ }
401
+
402
+ update_field(db, id, "mode", &mode)
403
+ }
404
+
405
+ /// Set a work item as the current active item.
406
+ pub fn set_current(db: &Database, id: i64) -> Result<()> {
407
+ let conn = db.conn();
408
+ conn.execute("UPDATE work_items SET current = 0 WHERE current = 1", [])?;
409
+ conn.execute(
410
+ "UPDATE work_items SET current = 1 WHERE id = ?",
411
+ params![id],
412
+ )?;
413
+ Ok(())
414
+ }
415
+
416
+ /// Update a work item's description.
417
+ pub fn set_description(db: &Database, id: i64, description: &str) -> Result<()> {
418
+ update_field(db, id, "description", &description)
419
+ }
420
+
421
+ /// Mark a work item as ready for review.
422
+ pub fn set_ready_for_review(db: &Database, id: i64) -> Result<()> {
423
+ update_field(db, id, "ready_for_review", &1i32)
424
+ }
425
+
426
+ /// Set a work item's QA steps (JSON string).
427
+ pub fn set_qa_steps(db: &Database, id: i64, qa_steps: &str) -> Result<()> {
428
+ update_field(db, id, "qa_steps", &qa_steps)
429
+ }
430
+
431
+ /// Update a work item's title (sanitizes input).
432
+ pub fn set_title(db: &Database, id: i64, title: &str) -> Result<()> {
433
+ let sanitized = sanitize_title(title);
434
+ if sanitized.is_empty() {
435
+ return Err(WorkError::EmptyTitle.into());
436
+ }
437
+ update_field(db, id, "title", &sanitized)
438
+ }
439
+
440
+ /// Set a work item's parent epic (or clear it with None).
441
+ pub fn set_epic(db: &Database, id: i64, epic_id: Option<i64>) -> Result<()> {
442
+ update_field(db, id, "epic_id", &epic_id)
443
+ }
444
+
445
+ /// Batch-update display orders within a transaction.
446
+ pub fn set_display_orders(db: &Database, orders: &[(i64, i64)]) -> Result<()> {
447
+ let conn = db.conn();
448
+ let tx = conn.unchecked_transaction()?;
449
+ for (id, order) in orders {
450
+ tx.execute(
451
+ "UPDATE work_items SET display_order = ? WHERE id = ?",
452
+ params![order, id],
453
+ )?;
454
+ }
455
+ tx.commit()?;
456
+ Ok(())
457
+ }
458
+
459
+ /// Transition a feature from discovery to implementation.
460
+ pub fn complete_discovery(
461
+ db: &Database,
462
+ id: i64,
463
+ scenario_file: Option<&str>,
464
+ discovery_winner: Option<&str>,
465
+ discovery_rationale: Option<&str>,
466
+ ) -> Result<()> {
467
+ db.conn().execute(
468
+ "UPDATE work_items SET \
469
+ phase = 'implementation', mode = 'speed', status = 'todo', \
470
+ scenario_file = ?1, discovery_winner = ?2, \
471
+ discovery_rationale = ?3, \
472
+ discovery_completed_at = datetime('now') \
473
+ WHERE id = ?4",
474
+ params![scenario_file, discovery_winner, discovery_rationale, id],
475
+ )?;
476
+ Ok(())
477
+ }
478
+
479
+ /// Increment the rejection count for a work item and append to rejection history.
480
+ pub fn increment_rejection(db: &Database, id: i64, reason: Option<&str>) -> Result<()> {
481
+ let conn = db.conn();
482
+
483
+ // Read current history and count so we can append
484
+ let (current_history, current_count): (Option<String>, i64) = conn.query_row(
485
+ "SELECT rejection_history, rejection_count FROM work_items WHERE id = ?1",
486
+ params![id],
487
+ |row| Ok((row.get(0)?, row.get(1)?)),
488
+ )?;
489
+
490
+ // Build updated history JSON array
491
+ let mut history: Vec<serde_json::Value> = current_history
492
+ .as_deref()
493
+ .and_then(|h| serde_json::from_str(h).ok())
494
+ .unwrap_or_default();
495
+
496
+ // Get current timestamp from SQLite for consistency
497
+ let now: String = conn.query_row("SELECT datetime('now')", [], |row| row.get(0))?;
498
+
499
+ let new_round = current_count + 1;
500
+ history.push(serde_json::json!({
501
+ "round": new_round,
502
+ "reason": reason,
503
+ "at": now,
504
+ }));
505
+
506
+ let history_json = serde_json::to_string(&history)?;
507
+
508
+ conn.execute(
509
+ "UPDATE work_items SET \
510
+ rejection_count = rejection_count + 1, \
511
+ rejection_reason = ?1, \
512
+ rejected_at = datetime('now'), \
513
+ rejection_history = ?3, \
514
+ ready_for_review = 0, \
515
+ status = 'in_progress' \
516
+ WHERE id = ?2",
517
+ params![reason, id, history_json],
518
+ )?;
519
+ Ok(())
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Onboarding seeding
524
+ // ---------------------------------------------------------------------------
525
+
526
+ /// Seed the "Project Planning" epic and onboarding chores for a blank project.
527
+ ///
528
+ /// Returns `Some((epic_id, chore_ids))` if items were created, or `None` if
529
+ /// a "Project Planning" epic already exists.
530
+ pub fn seed_onboarding(db: &Database) -> Result<Option<(i64, Vec<i64>)>> {
531
+ // Check if onboarding epic already exists
532
+ let exists: bool = db.conn().query_row(
533
+ "SELECT EXISTS(SELECT 1 FROM work_items WHERE type = 'epic' AND title = 'Project Planning')",
534
+ [],
535
+ |row| row.get(0),
536
+ )?;
537
+ if exists {
538
+ return Ok(None);
539
+ }
540
+
541
+ let epic_id = create(
542
+ db,
543
+ ItemType::Epic,
544
+ "Project Planning",
545
+ CreateOptions {
546
+ description: "Get your project set up and planned. Work through these chores one at a time \
547
+ — each one is a short conversation.".to_string(),
548
+ ..Default::default()
549
+ },
550
+ )?;
551
+
552
+ let chores = onboarding_chores(epic_id);
553
+ let mut chore_ids = Vec::with_capacity(chores.len());
554
+ for (title, description) in &chores {
555
+ let id = create(
556
+ db,
557
+ ItemType::Chore,
558
+ title,
559
+ CreateOptions {
560
+ description: description.to_string(),
561
+ parent_id: Some(epic_id),
562
+ ..Default::default()
563
+ },
564
+ )?;
565
+ chore_ids.push(id);
566
+ }
567
+
568
+ Ok(Some((epic_id, chore_ids)))
569
+ }
570
+
571
+ /// Onboarding chore definitions — mirrors `lib/seed-onboarding.js`.
572
+ fn onboarding_chores(epic_id: i64) -> Vec<(&'static str, String)> {
573
+ let tone = "HOW TO RESPOND:\n\
574
+ You are a collaborator, not an AI assistant. You're a knowledgeable partner having a real conversation.\n\
575
+ - Do NOT open with a summary or recap of what you've been asked to do\n\
576
+ - Do NOT list steps or outline a plan before starting\n\
577
+ - Do NOT say \"I'll help you with...\", \"Let me guide you through...\", or \"Great question!\"\n\
578
+ - Do NOT use headers, bullet lists, or formatted option blocks in your first message\n\
579
+ - DO jump straight into conversation like a person who just sat down across the table\n\
580
+ - DO ask one question at a time, then actually listen to the response\n\
581
+ - DO keep responses short — a few sentences, not paragraphs\n\
582
+ - DO respond naturally to what the user says, adapting your follow-ups accordingly";
583
+
584
+ vec![
585
+ ("Align on the user journey", format!(
586
+ "{tone}\n\n\
587
+ YOUR ROLE: Help the user articulate what their product does and how people will use it.\n\n\
588
+ CONVERSATION FLOW:\n\n\
589
+ 1. OPEN — Jump straight in. Something like:\n \
590
+ \"So — what are you building? Walk me through what happens when someone opens this thing up.\"\n \
591
+ (Adapt to context. Be casual. No preamble.)\n\n\
592
+ 2. LISTEN AND DIG — Based on what they say, ask natural follow-ups. One at a time:\n \
593
+ - \"And then what? What happens after [thing they mentioned]?\"\n \
594
+ - \"How do they find that the first time?\"\n \
595
+ - \"What's the moment where it clicks — like, this is actually useful?\"\n \
596
+ Don't rush this. Let them think. Some people need 2-3 exchanges to articulate their vision.\n\n\
597
+ 3. IF THEY'RE VAGUE — Don't present a formatted options list. Talk through possibilities naturally:\n \
598
+ \"I could see this going a few ways. You could do [approach A] — really simple, users just [action]. \
599
+ Or something more like [approach B] where they [different flow]. What feels closer?\"\n \
600
+ If they're really stuck, describe 2-3 directions conversationally and ask what resonates.\n\n\
601
+ 4. CONFIRM — Once the journey is clear, reflect it back casually:\n \
602
+ \"Cool, so basically: user shows up, [does X], then [does Y], and the payoff is [Z]. That feel right?\"\n \
603
+ Wait for them to confirm or adjust.\n\n\
604
+ 5. RECORD — After they confirm, run:\n \
605
+ jettypod work epic-implement {epic_id} --aspect=\"User Journey\" --decision=\"<summary of chosen journey>\" --rationale=\"<why this flow>\"\n\n\
606
+ 6. HAND OFF — Say something like: \"Nice. Close this chat and start the next chore in your backlog to keep going.\""
607
+ )),
608
+ ("Explore UX approaches", format!(
609
+ "{tone}\n\n\
610
+ YOUR ROLE: Help the user decide how their product should FEEL to use. This is about the experience, not the tech.\n\n\
611
+ CONTEXT: Read the user journey decision first:\n \
612
+ jettypod decisions --epic={epic_id}\n\n\
613
+ CONVERSATION FLOW:\n\n\
614
+ 1. OPEN — Reference what they already decided and pivot naturally:\n \
615
+ \"So last time you said users would [brief journey recap]. Now I'm curious — what should that actually \
616
+ feel like? Like, when someone [key action], are you imagining something minimal and fast, or more guided and hand-holdy?\"\n \
617
+ (Use their actual words from the decision. Don't be generic.)\n\n\
618
+ 2. EXPLORE THROUGH CONVERSATION — Don't dump three formatted options. Talk through directions:\n \
619
+ \"One direction would be something really stripped down — [describe the feel]. That works well when [context]. \
620
+ But you could also go more toward [different feel] where [description]. The trade-off is [honest assessment].\"\n \
621
+ Let them react. Follow their energy.\n\n\
622
+ 3. GET SPECIFIC — Once a direction emerges, push on it:\n \
623
+ \"OK so if we go that route, the main screen would probably be [description]. Does that feel right or is that too [much/little]?\"\n\n\
624
+ 4. OFFER PROTOTYPES (if it would help) — If they're torn or want to see it:\n \
625
+ \"Want me to throw together a quick prototype of that? Nothing fancy — just enough to feel it out.\"\n \
626
+ If yes, use: jettypod project prototype start <approach-name>\n \
627
+ Build a minimal throwaway prototype, then: jettypod project prototype merge\n \
628
+ After they try it: \"What did you think? Does that feel like the right direction, or should we adjust?\"\n\n\
629
+ 5. CONFIRM — Lock in the winner:\n \
630
+ \"Alright, so the feel is [description]. I'll record that.\"\n\n\
631
+ 6. RECORD — Run:\n \
632
+ jettypod work epic-implement {epic_id} --aspect=\"UX Approach\" --decision=\"<chosen approach>\" --rationale=\"<why>\"\n\n\
633
+ 7. HAND OFF — \"Close this chat and start the next chore from your backlog.\""
634
+ )),
635
+ ("Choose a tech stack", format!(
636
+ "{tone}\n\n\
637
+ YOUR ROLE: Help the user pick the right tech stack based on what they're building and how it should feel.\n\n\
638
+ CONTEXT: Read previous decisions first:\n \
639
+ jettypod decisions --epic={epic_id}\n\n\
640
+ CONVERSATION FLOW:\n\n\
641
+ 1. OPEN — Connect the dots from previous decisions:\n \
642
+ \"You're building [journey] and you want it to feel [UX approach]. That actually narrows down the tech quite a bit.\"\n \
643
+ Then lead with your honest take:\n \
644
+ \"I'd probably go with [recommendation] for this. Here's why — [reason].\"\n \
645
+ (Have an opinion. Don't just present options neutrally.)\n\n\
646
+ 2. HAVE A REAL DISCUSSION — After giving your take, open it up:\n \
647
+ \"That said — do you have a preference? Are you already comfortable with something, or is there a stack you've been wanting to try?\"\n \
648
+ Respond to what they say. If they push back, engage with it honestly:\n \
649
+ \"Yeah, [their preference] could work. The main thing you'd lose is [trade-off]. The main thing you'd gain is [benefit]. \
650
+ Honestly, either would be fine for this.\"\n\n\
651
+ 3. IF THEY WANT OPTIONS — Talk through 2-3 stacks conversationally, not as formatted comparison blocks:\n \
652
+ \"[Stack A] would be the fastest to get going — you'd have [benefit] out of the box. \
653
+ [Stack B] is more flexible but you'd need to wire up [thing] yourself. \
654
+ And then there's [Stack C] which is honestly overkill here unless you specifically want [thing].\"\n\n\
655
+ 4. LAND ON A DECISION — Once they choose:\n \
656
+ \"Solid choice. [One sentence of genuine validation — why it fits their specific project, not generic praise].\"\n\n\
657
+ 5. RECORD — Run:\n \
658
+ jettypod work epic-implement {epic_id} --aspect=\"Tech Stack\" --decision=\"<chosen stack>\" --rationale=\"<why>\"\n\n\
659
+ 6. HAND OFF — \"One more chore left — close this chat and start it from your backlog.\""
660
+ )),
661
+ ("Break the project into epics", format!(
662
+ "{tone}\n\n\
663
+ YOUR ROLE: Break the project into buildable phases (epics) based on everything they've decided.\n\n\
664
+ CONTEXT: Read all previous decisions first:\n \
665
+ jettypod decisions --epic={epic_id}\n\n\
666
+ CONVERSATION FLOW:\n\n\
667
+ 1. OPEN — Bring it all together and jump straight to the breakdown:\n \
668
+ \"Alright — you're building [journey], it should feel [UX approach], and you're using [tech stack]. \
669
+ Let me suggest how to break this into phases.\"\n \
670
+ Then just propose the epics conversationally:\n \
671
+ \"I'd start with [Epic 1] — that's the foundation, stuff like [examples]. \
672
+ Then [Epic 2] which covers [what]. And then [Epic 3] for [what].\"\n \
673
+ (Don't over-explain what an epic is. Keep it natural.)\n\n\
674
+ 2. QUICK CONTEXT (only if needed) — If they seem unsure about the structure:\n \
675
+ \"Each of these is a phase. Inside each one we'll define specific features — things users can actually do. \
676
+ You don't need to think about that yet though.\"\n\n\
677
+ 3. GET FEEDBACK — After proposing:\n \
678
+ \"Does that breakdown make sense? Anything you'd add, cut, or reorder?\"\n \
679
+ Adjust based on their input. Don't be precious about your proposal.\n\n\
680
+ 4. CREATE — Once they confirm, create each epic:\n \
681
+ jettypod work create epic \"<title>\" \"<description>\"\n \
682
+ Do this for each one.\n\n\
683
+ 5. RECOMMEND A STARTING POINT:\n \
684
+ \"If I were you, I'd start with [Epic name] — [brief reason, like 'everything else depends on it' \
685
+ or 'it's the quickest win to get something real'].\"\n\n\
686
+ 6. CLOSE — \"Your project is planned. Pick an epic from the backlog and tell Claude 'Let's plan [epic name]' to start building.\""
687
+ )),
688
+ ]
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // Discovery decisions
693
+ // ---------------------------------------------------------------------------
694
+
695
+ /// Record an architectural/design decision for an epic.
696
+ pub fn add_decision(
697
+ db: &Database,
698
+ work_item_id: i64,
699
+ aspect: &str,
700
+ decision: &str,
701
+ rationale: &str,
702
+ ) -> Result<i64> {
703
+ db.conn().execute(
704
+ "INSERT INTO discovery_decisions (work_item_id, aspect, decision, rationale) \
705
+ VALUES (?1, ?2, ?3, ?4)",
706
+ params![work_item_id, aspect, decision, rationale],
707
+ )?;
708
+ Ok(db.conn().last_insert_rowid())
709
+ }
710
+
711
+ /// Get a single decision by ID.
712
+ pub fn get_decision(db: &Database, id: i64) -> Result<Option<DiscoveryDecision>> {
713
+ let mut stmt = db.conn().prepare(
714
+ "SELECT id, work_item_id, aspect, decision, rationale, created_at \
715
+ FROM discovery_decisions WHERE id = ?",
716
+ )?;
717
+ let mut rows = stmt
718
+ .query_map(params![id], |row| {
719
+ Ok(DiscoveryDecision {
720
+ id: row.get(0)?,
721
+ work_item_id: row.get(1)?,
722
+ aspect: row.get(2)?,
723
+ decision: row.get(3)?,
724
+ rationale: row.get(4)?,
725
+ created_at: row.get(5)?,
726
+ })
727
+ })?;
728
+ match rows.next() {
729
+ Some(Ok(d)) => Ok(Some(d)),
730
+ Some(Err(e)) => Err(e.into()),
731
+ None => Ok(None),
732
+ }
733
+ }
734
+
735
+ /// Get all decisions for a work item.
736
+ pub fn get_decisions(db: &Database, work_item_id: i64) -> Result<Vec<DiscoveryDecision>> {
737
+ let mut stmt = db.conn().prepare(
738
+ "SELECT id, work_item_id, aspect, decision, rationale, created_at \
739
+ FROM discovery_decisions WHERE work_item_id = ? ORDER BY created_at",
740
+ )?;
741
+ let decisions = stmt
742
+ .query_map(params![work_item_id], |row| {
743
+ Ok(DiscoveryDecision {
744
+ id: row.get(0)?,
745
+ work_item_id: row.get(1)?,
746
+ aspect: row.get(2)?,
747
+ decision: row.get(3)?,
748
+ rationale: row.get(4)?,
749
+ created_at: row.get(5)?,
750
+ })
751
+ })?
752
+ .collect::<std::result::Result<Vec<_>, _>>()?;
753
+ Ok(decisions)
754
+ }
755
+
756
+ // ---------------------------------------------------------------------------
757
+ // Row mapping
758
+ // ---------------------------------------------------------------------------
759
+
760
+ /// Parse a required string column into a typed enum.
761
+ fn parse_column<T: std::str::FromStr>(row: &rusqlite::Row, idx: &str) -> rusqlite::Result<T> {
762
+ let s: String = row.get(idx)?;
763
+ s.parse::<T>().map_err(|_| rusqlite::Error::FromSqlConversionFailure(
764
+ 0, rusqlite::types::Type::Text, format!("invalid value: {s}").into(),
765
+ ))
766
+ }
767
+
768
+ /// Parse an optional string column into a typed enum.
769
+ fn parse_opt_column<T: std::str::FromStr>(row: &rusqlite::Row, idx: &str) -> rusqlite::Result<Option<T>> {
770
+ let val: Option<String> = row.get(idx)?;
771
+ match val {
772
+ Some(s) if !s.is_empty() => s.parse::<T>()
773
+ .map(Some)
774
+ .map_err(|_| rusqlite::Error::FromSqlConversionFailure(
775
+ 0, rusqlite::types::Type::Text, format!("invalid value: {s}").into(),
776
+ )),
777
+ _ => Ok(None),
778
+ }
779
+ }
780
+
781
+ fn row_to_work_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorkItem> {
782
+ Ok(WorkItem {
783
+ id: row.get("id")?,
784
+ item_type: parse_column::<ItemType>(row, "type")?,
785
+ title: row.get("title")?,
786
+ description: row.get("description")?,
787
+ status: parse_column::<Status>(row, "status")?,
788
+ parent_id: row.get("parent_id")?,
789
+ epic_id: row.get("epic_id")?,
790
+ branch_name: row.get("branch_name")?,
791
+ mode: parse_opt_column::<Mode>(row, "mode")?,
792
+ current: row.get::<_, i32>("current")? != 0,
793
+ phase: parse_opt_column::<Phase>(row, "phase")?,
794
+ scenario_file: row.get("scenario_file")?,
795
+ completed_at: row.get("completed_at")?,
796
+ created_at: row.get("created_at")?,
797
+ display_order: row.get("display_order")?,
798
+ needs_discovery: row.get::<_, i32>("needs_discovery")? != 0,
799
+ ready_for_review: row.get::<_, i32>("ready_for_review")? != 0,
800
+ conversational: row.get::<_, i32>("conversational")? != 0,
801
+ plan_at_creation: row.get("plan_at_creation")?,
802
+ rejection_count: row.get("rejection_count")?,
803
+ rejection_round: row.get("rejection_round")?,
804
+ rejection_history: row.get("rejection_history")?,
805
+ rejection_reason: row.get("rejection_reason")?,
806
+ rejected_at: row.get("rejected_at")?,
807
+ qa_steps: row.get("qa_steps")?,
808
+ })
809
+ }
810
+
811
+ // ---------------------------------------------------------------------------
812
+ // Tests
813
+ // ---------------------------------------------------------------------------
814
+
815
+ #[cfg(test)]
816
+ mod tests {
817
+ use super::*;
818
+ use crate::db::Database;
819
+
820
+ fn test_db() -> (tempfile::TempDir, Database) {
821
+ let dir = tempfile::tempdir().unwrap();
822
+ let db_path = dir.path().join("test.db");
823
+ let db = Database::open_path_unchecked(&db_path).unwrap();
824
+ (dir, db)
825
+ }
826
+
827
+ #[test]
828
+ fn sanitize_title_strips_html() {
829
+ assert_eq!(sanitize_title("<b>Hello</b> world"), "Hello world");
830
+ }
831
+
832
+ #[test]
833
+ fn sanitize_title_removes_dangerous_chars() {
834
+ assert_eq!(sanitize_title("test $VAR `cmd` back\\slash"), "test VAR cmd backslash");
835
+ }
836
+
837
+ #[test]
838
+ fn sanitize_title_truncates() {
839
+ let long = "a".repeat(100);
840
+ assert_eq!(sanitize_title(&long).len(), MAX_TITLE_LEN);
841
+ }
842
+
843
+ #[test]
844
+ fn sanitize_title_normalizes_whitespace() {
845
+ assert_eq!(sanitize_title(" hello world "), "hello world");
846
+ }
847
+
848
+ #[test]
849
+ fn create_and_get() {
850
+ let (_dir, db) = test_db();
851
+ let id = create(
852
+ &db,
853
+ ItemType::Epic,
854
+ "Test Epic",
855
+ CreateOptions::default(),
856
+ )
857
+ .unwrap();
858
+
859
+ let item = get(&db, id).unwrap();
860
+ assert_eq!(item.title, "Test Epic");
861
+ assert_eq!(item.item_type, ItemType::Epic);
862
+ assert_eq!(item.status, Status::Backlog);
863
+ assert!(item.mode.is_none());
864
+ }
865
+
866
+ #[test]
867
+ fn create_feature_with_discovery_phase() {
868
+ let (_dir, db) = test_db();
869
+ let id = create(
870
+ &db,
871
+ ItemType::Feature,
872
+ "Test Feature",
873
+ CreateOptions::default(),
874
+ )
875
+ .unwrap();
876
+
877
+ let item = get(&db, id).unwrap();
878
+ assert_eq!(item.phase, Some(Phase::Discovery));
879
+ assert!(item.mode.is_none());
880
+ }
881
+
882
+ #[test]
883
+ fn create_chore_has_no_mode() {
884
+ let (_dir, db) = test_db();
885
+ let epic_id = create(
886
+ &db,
887
+ ItemType::Epic,
888
+ "Parent Epic",
889
+ CreateOptions::default(),
890
+ )
891
+ .unwrap();
892
+
893
+ let chore_id = create(
894
+ &db,
895
+ ItemType::Chore,
896
+ "Test Chore",
897
+ CreateOptions {
898
+ parent_id: Some(epic_id),
899
+ mode: Some(Mode::Speed), // Should be ignored for chores
900
+ ..Default::default()
901
+ },
902
+ )
903
+ .unwrap();
904
+
905
+ let item = get(&db, chore_id).unwrap();
906
+ assert!(item.mode.is_none());
907
+ assert_eq!(item.epic_id, Some(epic_id));
908
+ }
909
+
910
+ #[test]
911
+ fn create_with_parent_sets_epic_id() {
912
+ let (_dir, db) = test_db();
913
+ let epic_id = create(&db, ItemType::Epic, "Epic", CreateOptions::default()).unwrap();
914
+ let feature_id = create(
915
+ &db,
916
+ ItemType::Feature,
917
+ "Feature",
918
+ CreateOptions {
919
+ parent_id: Some(epic_id),
920
+ ..Default::default()
921
+ },
922
+ )
923
+ .unwrap();
924
+ let chore_id = create(
925
+ &db,
926
+ ItemType::Chore,
927
+ "Chore",
928
+ CreateOptions {
929
+ parent_id: Some(feature_id),
930
+ ..Default::default()
931
+ },
932
+ )
933
+ .unwrap();
934
+
935
+ // Feature under epic: epic_id = epic
936
+ let feature = get(&db, feature_id).unwrap();
937
+ assert_eq!(feature.epic_id, Some(epic_id));
938
+
939
+ // Chore under feature: inherits epic_id from feature
940
+ let chore = get(&db, chore_id).unwrap();
941
+ assert_eq!(chore.epic_id, Some(epic_id));
942
+ }
943
+
944
+ #[test]
945
+ fn update_status_sets_completed_at() {
946
+ let (_dir, db) = test_db();
947
+ let id = create(&db, ItemType::Chore, "Task", CreateOptions::default()).unwrap();
948
+
949
+ update_status(&db, id, Status::Done).unwrap();
950
+ let item = get(&db, id).unwrap();
951
+ assert_eq!(item.status, Status::Done);
952
+ assert!(item.completed_at.is_some());
953
+
954
+ // Reset clears completed_at
955
+ update_status(&db, id, Status::InProgress).unwrap();
956
+ let item = get(&db, id).unwrap();
957
+ assert!(item.completed_at.is_none());
958
+ }
959
+
960
+ #[test]
961
+ fn set_current_clears_previous() {
962
+ let (_dir, db) = test_db();
963
+ let id1 = create(&db, ItemType::Chore, "Task 1", CreateOptions::default()).unwrap();
964
+ let id2 = create(&db, ItemType::Chore, "Task 2", CreateOptions::default()).unwrap();
965
+
966
+ set_current(&db, id1).unwrap();
967
+ assert!(get(&db, id1).unwrap().current);
968
+
969
+ set_current(&db, id2).unwrap();
970
+ assert!(!get(&db, id1).unwrap().current);
971
+ assert!(get(&db, id2).unwrap().current);
972
+ }
973
+
974
+ #[test]
975
+ fn get_children_works() {
976
+ let (_dir, db) = test_db();
977
+ let epic_id = create(&db, ItemType::Epic, "Epic", CreateOptions::default()).unwrap();
978
+ create(
979
+ &db,
980
+ ItemType::Feature,
981
+ "F1",
982
+ CreateOptions { parent_id: Some(epic_id), ..Default::default() },
983
+ ).unwrap();
984
+ create(
985
+ &db,
986
+ ItemType::Feature,
987
+ "F2",
988
+ CreateOptions { parent_id: Some(epic_id), ..Default::default() },
989
+ ).unwrap();
990
+
991
+ let children = get_children(&db, epic_id).unwrap();
992
+ assert_eq!(children.len(), 2);
993
+ }
994
+
995
+ #[test]
996
+ fn find_epic_traverses_chain() {
997
+ let (_dir, db) = test_db();
998
+ let epic_id = create(&db, ItemType::Epic, "Epic", CreateOptions::default()).unwrap();
999
+ let feature_id = create(
1000
+ &db,
1001
+ ItemType::Feature,
1002
+ "Feature",
1003
+ CreateOptions { parent_id: Some(epic_id), ..Default::default() },
1004
+ ).unwrap();
1005
+ let chore_id = create(
1006
+ &db,
1007
+ ItemType::Chore,
1008
+ "Chore",
1009
+ CreateOptions { parent_id: Some(feature_id), ..Default::default() },
1010
+ ).unwrap();
1011
+
1012
+ let epic = find_epic(&db, chore_id).unwrap().unwrap();
1013
+ assert_eq!(epic.id, epic_id);
1014
+ }
1015
+
1016
+ #[test]
1017
+ fn discovery_decisions_crud() {
1018
+ let (_dir, db) = test_db();
1019
+ let epic_id = create(&db, ItemType::Epic, "Epic", CreateOptions::default()).unwrap();
1020
+
1021
+ add_decision(&db, epic_id, "Architecture", "REST API", "Simple and well-understood").unwrap();
1022
+ add_decision(&db, epic_id, "Database", "SQLite", "Embedded, no server needed").unwrap();
1023
+
1024
+ let decisions = get_decisions(&db, epic_id).unwrap();
1025
+ assert_eq!(decisions.len(), 2);
1026
+ assert_eq!(decisions[0].aspect, "Architecture");
1027
+ assert_eq!(decisions[1].aspect, "Database");
1028
+ }
1029
+
1030
+ #[test]
1031
+ fn set_mode_rejects_non_features() {
1032
+ let (_dir, db) = test_db();
1033
+ let id = create(&db, ItemType::Chore, "Chore", CreateOptions::default()).unwrap();
1034
+ assert!(set_mode(&db, id, "speed").is_err());
1035
+ }
1036
+
1037
+ #[test]
1038
+ fn get_tree_filters_completed() {
1039
+ let (_dir, db) = test_db();
1040
+ let id1 = create(&db, ItemType::Chore, "Active", CreateOptions::default()).unwrap();
1041
+ let id2 = create(&db, ItemType::Chore, "Done", CreateOptions::default()).unwrap();
1042
+ update_status(&db, id2, Status::Done).unwrap();
1043
+
1044
+ let active = get_tree(&db, false).unwrap();
1045
+ assert_eq!(active.len(), 1);
1046
+ assert_eq!(active[0].id, id1);
1047
+
1048
+ let all = get_tree(&db, true).unwrap();
1049
+ assert_eq!(all.len(), 2);
1050
+ }
1051
+
1052
+ #[test]
1053
+ fn rejection_tracking() {
1054
+ let (_dir, db) = test_db();
1055
+ let id = create(&db, ItemType::Feature, "Feature", CreateOptions::default()).unwrap();
1056
+
1057
+ increment_rejection(&db, id, Some("Needs more tests")).unwrap();
1058
+ let item = get(&db, id).unwrap();
1059
+ assert_eq!(item.rejection_count, 1);
1060
+ assert_eq!(item.rejection_reason.as_deref(), Some("Needs more tests"));
1061
+ assert!(item.rejected_at.is_some());
1062
+
1063
+ // Child should inherit rejection_round
1064
+ let child_id = create(
1065
+ &db,
1066
+ ItemType::Chore,
1067
+ "Fix",
1068
+ CreateOptions { parent_id: Some(id), ..Default::default() },
1069
+ ).unwrap();
1070
+ let child = get(&db, child_id).unwrap();
1071
+ assert_eq!(child.rejection_round, Some(1));
1072
+ }
1073
+
1074
+ #[test]
1075
+ fn complete_discovery_transition() {
1076
+ let (_dir, db) = test_db();
1077
+ let id = create(&db, ItemType::Feature, "Feature", CreateOptions::default()).unwrap();
1078
+
1079
+ complete_discovery(&db, id, Some("test.feature"), Some("Approach A"), Some("Best fit")).unwrap();
1080
+ let item = get(&db, id).unwrap();
1081
+ assert_eq!(item.phase, Some(Phase::Implementation));
1082
+ assert_eq!(item.mode, Some(Mode::Speed));
1083
+ assert_eq!(item.status, Status::Todo);
1084
+ assert_eq!(item.scenario_file.as_deref(), Some("test.feature"));
1085
+ }
1086
+ }