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,1249 @@
1
+ //! Command handler implementations.
2
+ //!
3
+ //! Each function corresponds to a CLI command. They receive a shared
4
+ //! [`CommandContext`] (project root + open database handle), call into
5
+ //! `jettypod_core`, and format output for the terminal.
6
+
7
+ use std::path::{Path, PathBuf};
8
+
9
+ use anyhow::{bail, Context, Result};
10
+ use jettypod_core::db::Database;
11
+ use jettypod_core::work::{self, CreateOptions, ItemType, Mode, Status};
12
+ use jettypod_core::{config, git, sessions, skills, worktree};
13
+
14
+ use crate::{PrototypeAction, TestAction};
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Shared context
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /// Shared context for CLI commands — holds the project root and an open database handle.
21
+ pub struct CommandContext {
22
+ pub root: PathBuf,
23
+ pub db: Database,
24
+ }
25
+
26
+ impl CommandContext {
27
+ pub fn new(root: &Path) -> Result<Self> {
28
+ let db = Database::open(root).context("Failed to open project database")?;
29
+ Ok(Self {
30
+ root: root.to_path_buf(),
31
+ db,
32
+ })
33
+ }
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ fn type_icon(t: &ItemType) -> &'static str {
41
+ match t {
42
+ ItemType::Epic => "📦",
43
+ ItemType::Feature => "✨",
44
+ ItemType::Chore => "🔧",
45
+ ItemType::Bug => "🐛",
46
+ }
47
+ }
48
+
49
+ fn status_icon(s: &Status) -> &'static str {
50
+ match s {
51
+ Status::Backlog => "📋",
52
+ Status::Todo => "📝",
53
+ Status::InProgress => "🔄",
54
+ Status::Blocked => "🚫",
55
+ Status::Done => "✅",
56
+ Status::Cancelled => "❌",
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Init
62
+ // ---------------------------------------------------------------------------
63
+
64
+ pub fn init(name: Option<String>) -> Result<()> {
65
+ let cwd = std::env::current_dir()?;
66
+
67
+ let project_name = name.unwrap_or_else(|| {
68
+ cwd.file_name()
69
+ .unwrap_or_default()
70
+ .to_string_lossy()
71
+ .to_string()
72
+ });
73
+
74
+ let jettypod_dir = cwd.join(".jettypod");
75
+ if jettypod_dir.exists() {
76
+ println!("Project already initialized: {project_name}");
77
+ // Still sync skills and regenerate CLAUDE.md.
78
+ } else {
79
+ std::fs::create_dir_all(&jettypod_dir)?;
80
+ println!("Created .jettypod/");
81
+ }
82
+
83
+ // Ensure database exists with schema.
84
+ let _db = Database::open(&cwd)?;
85
+ println!("Database ready: .jettypod/work.db");
86
+
87
+ // Write config if it doesn't exist.
88
+ let config_path = jettypod_dir.join("config.json");
89
+ if !config_path.exists() {
90
+ let cfg = jettypod_core::config::ProjectConfig {
91
+ name: project_name.clone(),
92
+ stage: "empty".into(),
93
+ bundles: vec!["core".into()],
94
+ project_state: "internal".into(),
95
+ project_discovery: Default::default(),
96
+ main_branch: None,
97
+ extra: Default::default(),
98
+ };
99
+ config::write(&cwd, &cfg)?;
100
+ println!("Created config: .jettypod/config.json");
101
+ }
102
+
103
+ // Sync skills.
104
+ let source = skills::default_source_dir(&cwd);
105
+ let dest = skills::default_dest_dir(&cwd);
106
+ match skills::sync_skills(&skills::SyncOptions {
107
+ source_dir: source,
108
+ dest_dir: dest,
109
+ }) {
110
+ Ok(result) => {
111
+ if let Some(warning) = result.warning {
112
+ println!("Skills: {warning}");
113
+ } else {
114
+ println!("Skills: {} files synced", result.files_copied);
115
+ }
116
+ }
117
+ Err(e) => eprintln!("Warning: Skills sync failed: {e}"),
118
+ }
119
+
120
+ println!("\n✅ Initialized JettyPod project: {project_name}");
121
+ Ok(())
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Describe / Generate
126
+ // ---------------------------------------------------------------------------
127
+
128
+ pub fn describe(root: &Path, description: &str) -> Result<()> {
129
+ let mut cfg = config::read(root);
130
+ cfg.extra.insert(
131
+ "description".into(),
132
+ serde_json::Value::String(description.to_string()),
133
+ );
134
+ config::write(root, &cfg)?;
135
+ println!("Updated project description.");
136
+ Ok(())
137
+ }
138
+
139
+ pub fn generate(root: &Path) -> Result<()> {
140
+ // CLAUDE.md generation is handled by the Node.js template engine.
141
+ // For now, just confirm config exists.
142
+ let cfg = config::read(root);
143
+ println!("Project: {}", cfg.name);
144
+ println!("State: {}", cfg.project_state);
145
+ println!("CLAUDE.md regeneration requires the template engine (not yet ported).");
146
+ Ok(())
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Work: Create
151
+ // ---------------------------------------------------------------------------
152
+
153
+ pub fn work_create(
154
+ ctx: &CommandContext,
155
+ from: Option<String>,
156
+ item_type: Option<String>,
157
+ title: Option<String>,
158
+ description: Option<String>,
159
+ parent: Option<i64>,
160
+ mode: Option<String>,
161
+ needs_discovery: bool,
162
+ ) -> Result<()> {
163
+ // Parse from JSON file if --from specified.
164
+ let (typ, ttl, desc, par, md, nd) = if let Some(path) = from {
165
+ let content = std::fs::read_to_string(&path)
166
+ .with_context(|| format!("Failed to read {path}"))?;
167
+ let v: serde_json::Value = serde_json::from_str(&content)
168
+ .with_context(|| format!("Invalid JSON in {path}"))?;
169
+
170
+ let t = v["type"].as_str().unwrap_or("feature").to_string();
171
+ let ti = v["title"]
172
+ .as_str()
173
+ .map(|s| s.to_string())
174
+ .unwrap_or_default();
175
+ let d = v["description"].as_str().map(|s| s.to_string());
176
+ let p = v["parent"].as_i64();
177
+ let m = v["mode"].as_str().map(|s| s.to_string());
178
+ let n = v["needsDiscovery"].as_bool().unwrap_or(false);
179
+ (t, ti, d, p, m, n)
180
+ } else {
181
+ let t = item_type.unwrap_or_else(|| "feature".into());
182
+ let ti = title.unwrap_or_default();
183
+ (t, ti, description, parent, mode, needs_discovery)
184
+ };
185
+
186
+ if ttl.is_empty() {
187
+ bail!("Title is required");
188
+ }
189
+
190
+ let item_type = typ.parse::<ItemType>()?;
191
+ let md_parsed = md.map(|m| m.parse::<Mode>()).transpose()?;
192
+
193
+ let id = work::create(
194
+ &ctx.db,
195
+ item_type,
196
+ &ttl,
197
+ CreateOptions {
198
+ description: desc.unwrap_or_default(),
199
+ parent_id: par,
200
+ mode: md_parsed,
201
+ needs_discovery: nd,
202
+ conversational: false,
203
+ },
204
+ )?;
205
+
206
+ println!(
207
+ "✅ Created {} #{}: {}",
208
+ type_icon(&item_type),
209
+ id,
210
+ ttl
211
+ );
212
+ Ok(())
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Work: Start (create worktree)
217
+ // ---------------------------------------------------------------------------
218
+
219
+ pub fn work_start(ctx: &CommandContext, id: i64) -> Result<()> {
220
+ let item = work::get(&ctx.db, id)?;
221
+
222
+ // Check if worktree already exists.
223
+ if let Some(existing) = worktree::get_for_work_item(&ctx.db, id)? {
224
+ if existing.status == worktree::WorktreeStatus::Active {
225
+ println!(
226
+ "📁 Worktree already exists: {}",
227
+ existing.worktree_path
228
+ );
229
+ println!("Working on: [#{}] {} ({})", item.id, item.title, item.item_type);
230
+ return Ok(());
231
+ }
232
+ }
233
+
234
+ // Create worktree.
235
+ let wt = worktree::create(
236
+ &ctx.db,
237
+ &worktree::CreateWorktreeOpts {
238
+ work_item_id: id,
239
+ title: &item.title,
240
+ repo_root: &ctx.root,
241
+ },
242
+ )?;
243
+
244
+ // Mark as current and in_progress.
245
+ work::set_current(&ctx.db, id)?;
246
+ if item.status == Status::Backlog || item.status == Status::Todo {
247
+ work::update_status(&ctx.db, id, Status::InProgress)?;
248
+ }
249
+
250
+ // Set branch name.
251
+ work::set_branch(&ctx.db, id, &wt.branch_name)?;
252
+
253
+ // Write session file.
254
+ let epic = work::find_epic(&ctx.db, id)?;
255
+ if let Err(e) = sessions::write_session_file(
256
+ &ctx.root,
257
+ id,
258
+ &item.title,
259
+ item.item_type.as_ref(),
260
+ item.status.as_ref(),
261
+ item.mode.as_ref().map(|m| m.as_ref()).unwrap_or(""),
262
+ epic.as_ref().map(|e| e.id),
263
+ epic.as_ref().map(|e| e.title.as_str()),
264
+ ) {
265
+ eprintln!("Warning: Failed to write session file: {e}");
266
+ }
267
+
268
+ println!("✅ Created worktree: {}", wt.worktree_path);
269
+ println!(
270
+ "📁 IMPORTANT: Use absolute paths for file operations:\n {}/lib/file.js (correct)\n lib/file.js (wrong - creates in main repo)",
271
+ wt.worktree_path
272
+ );
273
+ println!(
274
+ "Working on: [#{}] {} ({})",
275
+ item.id, item.title, item.item_type
276
+ );
277
+
278
+ if let Some(parent_id) = item.parent_id {
279
+ if let Ok(parent) = work::get(&ctx.db, parent_id) {
280
+ if parent.scenario_file.is_none() {
281
+ println!(
282
+ "\n⚠️ Warning: Parent feature has no scenario file\n\n\
283
+ Parent feature #{} \"{}\" does not have\n\
284
+ a scenario file set.\n\n\
285
+ Suggestion: Create a BDD scenario file for the feature and update scenario_file.",
286
+ parent.id, parent.title
287
+ );
288
+ }
289
+ }
290
+ }
291
+
292
+ Ok(())
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Work: Stop
297
+ // ---------------------------------------------------------------------------
298
+
299
+ pub fn work_stop(ctx: &CommandContext, status: Option<String>) -> Result<()> {
300
+ // Find current work item.
301
+ let items = work::get_tree(&ctx.db, false)?;
302
+ let current = items.iter().find(|i| i.current);
303
+
304
+ match current {
305
+ Some(item) => {
306
+ if let Some(st) = status {
307
+ let new_status = st.parse::<Status>()?;
308
+ work::update_status(&ctx.db, item.id, new_status)?;
309
+ println!("Updated #{} status to {}", item.id, st);
310
+ }
311
+ // Clear current.
312
+ if let Err(e) = ctx.db.conn().execute(
313
+ "UPDATE work_items SET current = 0 WHERE id = ?",
314
+ rusqlite::params![item.id],
315
+ ) {
316
+ eprintln!("Warning: Failed to clear current work item: {e}");
317
+ }
318
+ // Clear session file.
319
+ if let Err(e) = sessions::clear_session_file(&ctx.root) {
320
+ eprintln!("Warning: Failed to clear session file: {e}");
321
+ }
322
+ println!("Stopped working on #{}: {}", item.id, item.title);
323
+ }
324
+ None => {
325
+ println!("No active work item.");
326
+ }
327
+ }
328
+
329
+ Ok(())
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Work: Status update
334
+ // ---------------------------------------------------------------------------
335
+
336
+ pub fn work_status(ctx: &CommandContext, id: i64, status: &str) -> Result<()> {
337
+ let new_status = status.parse::<Status>()?;
338
+ work::update_status(&ctx.db, id, new_status)?;
339
+
340
+ let item = work::get(&ctx.db, id)?;
341
+ println!(
342
+ "{} #{} \"{}\" → {}",
343
+ status_icon(&new_status),
344
+ id,
345
+ item.title,
346
+ status
347
+ );
348
+
349
+ // Mark complete with ready_for_review if done.
350
+ if new_status == Status::Done {
351
+ println!("✅ {} #{} marked done.", type_icon(&item.item_type), id);
352
+ }
353
+
354
+ Ok(())
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Work: Current
359
+ // ---------------------------------------------------------------------------
360
+
361
+ pub fn work_current(ctx: &CommandContext) -> Result<()> {
362
+ let items = work::get_tree(&ctx.db, false)?;
363
+ let current = items.iter().find(|i| i.current);
364
+
365
+ match current {
366
+ Some(item) => {
367
+ println!("{} #{}: {}", type_icon(&item.item_type), item.id, item.title);
368
+ println!(" Status: {} {}", status_icon(&item.status), item.status);
369
+ if let Some(ref mode) = item.mode {
370
+ println!(" Mode: {mode}");
371
+ }
372
+ if let Some(ref branch) = item.branch_name {
373
+ println!(" Branch: {branch}");
374
+ }
375
+ }
376
+ None => println!("No active work item."),
377
+ }
378
+
379
+ Ok(())
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Work: Show
384
+ // ---------------------------------------------------------------------------
385
+
386
+ pub fn work_show(ctx: &CommandContext, id: i64) -> Result<()> {
387
+ let item = work::get(&ctx.db, id)?;
388
+
389
+ println!(
390
+ "{} #{}: {}",
391
+ type_icon(&item.item_type),
392
+ item.id,
393
+ item.title
394
+ );
395
+ println!(" Type: {}", item.item_type);
396
+ println!(" Status: {} {}", status_icon(&item.status), item.status);
397
+
398
+ if let Some(ref mode) = item.mode {
399
+ println!(" Mode: {mode}");
400
+ }
401
+ if let Some(ref phase) = item.phase {
402
+ println!(" Phase: {phase}");
403
+ }
404
+ if let Some(parent_id) = item.parent_id {
405
+ if let Ok(parent) = work::get(&ctx.db, parent_id) {
406
+ println!(
407
+ " Parent: {} #{} {}",
408
+ type_icon(&parent.item_type),
409
+ parent.id,
410
+ parent.title
411
+ );
412
+ }
413
+ }
414
+ if let Some(ref branch) = item.branch_name {
415
+ println!(" Branch: {branch}");
416
+ }
417
+ if let Some(ref desc) = item.description {
418
+ if !desc.is_empty() {
419
+ println!("\n {desc}");
420
+ }
421
+ }
422
+ if let Some(ref created) = item.created_at {
423
+ println!(" Created: {created}");
424
+ }
425
+ if let Some(ref completed) = item.completed_at {
426
+ println!(" Completed: {completed}");
427
+ }
428
+
429
+ // Show decisions if epic.
430
+ if item.item_type == ItemType::Epic {
431
+ let decisions = work::get_decisions(&ctx.db, id)?;
432
+ if !decisions.is_empty() {
433
+ println!("\n Decisions:");
434
+ for d in &decisions {
435
+ println!(" • {}: {}", d.aspect, d.decision);
436
+ println!(" Rationale: {}", d.rationale);
437
+ }
438
+ }
439
+ }
440
+
441
+ // Show children.
442
+ let children = work::get_children(&ctx.db, id)?;
443
+ if !children.is_empty() {
444
+ println!("\n Children:");
445
+ for child in &children {
446
+ println!(
447
+ " {} #{}: {} [{}]",
448
+ type_icon(&child.item_type),
449
+ child.id,
450
+ child.title,
451
+ child.status
452
+ );
453
+ }
454
+ }
455
+
456
+ Ok(())
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Work: Describe
461
+ // ---------------------------------------------------------------------------
462
+
463
+ pub fn work_describe(ctx: &CommandContext, id: i64, description: &str) -> Result<()> {
464
+ work::set_description(&ctx.db, id, description)?;
465
+ println!("Updated description for #{}.", id);
466
+ Ok(())
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Work: Children
471
+ // ---------------------------------------------------------------------------
472
+
473
+ pub fn work_children(ctx: &CommandContext, id: i64) -> Result<()> {
474
+ let children = work::get_children(&ctx.db, id)?;
475
+
476
+ if children.is_empty() {
477
+ println!("No children for #{id}.");
478
+ } else {
479
+ for child in &children {
480
+ println!(
481
+ "{} #{}: {} [{}]{}",
482
+ type_icon(&child.item_type),
483
+ child.id,
484
+ child.title,
485
+ child.status,
486
+ child.mode.as_ref().map(|m| format!(" ({m})")).unwrap_or_default()
487
+ );
488
+ }
489
+ }
490
+
491
+ Ok(())
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Work: Merge
496
+ // ---------------------------------------------------------------------------
497
+
498
+ pub fn work_merge(ctx: &CommandContext, id: Option<i64>, release_lock: bool) -> Result<()> {
499
+ // Find work item ID.
500
+ let work_id = match id {
501
+ Some(id) => id,
502
+ None => {
503
+ // Try to detect from current branch.
504
+ match sessions::get_current_work_from_branch(&ctx.db, &ctx.root)? {
505
+ Some(id) => id,
506
+ None => bail!("No work item ID provided and not in a worktree branch"),
507
+ }
508
+ }
509
+ };
510
+
511
+ if release_lock {
512
+ if let Err(e) = ctx.db.conn().execute(
513
+ "DELETE FROM merge_locks WHERE work_item_id = ?",
514
+ rusqlite::params![work_id],
515
+ ) {
516
+ eprintln!("Warning: Failed to release merge lock: {e}");
517
+ }
518
+ println!("Released merge lock for #{}.", work_id);
519
+ return Ok(());
520
+ }
521
+
522
+ let item = work::get(&ctx.db, work_id)?;
523
+ let wt = worktree::get_for_work_item(&ctx.db, work_id)?
524
+ .ok_or_else(|| anyhow::anyhow!("No worktree found for #{work_id}"))?;
525
+
526
+ // Guard: block merge if parent feature has no QA steps set.
527
+ if item.item_type == ItemType::Chore || item.item_type == ItemType::Bug {
528
+ if let Some(parent_id) = item.parent_id {
529
+ let parent = work::get(&ctx.db, parent_id)?;
530
+ if parent.item_type == ItemType::Feature {
531
+ let has_qa = parent
532
+ .qa_steps
533
+ .as_ref()
534
+ .map(|s| !s.is_empty() && s != "[]")
535
+ .unwrap_or(false);
536
+ if !has_qa {
537
+ bail!(
538
+ "Cannot merge: QA steps not set on parent feature #{} \"{}\".\n\
539
+ Generate QA steps first:\n\n \
540
+ jettypod work set-qa-steps {} --from=<file>\n",
541
+ parent_id, parent.title, parent_id
542
+ );
543
+ }
544
+ }
545
+ }
546
+ }
547
+
548
+ println!("⏳ Acquiring merge lock...");
549
+ let lock_id = worktree::acquire_merge_lock(&ctx.db, work_id, "cli", 300)?;
550
+ println!("✅ Merge lock acquired");
551
+
552
+ println!("Merging work item #{}: {}", work_id, item.title);
553
+ println!("Branch: {}", wt.branch_name);
554
+
555
+ let merge_result = (|| -> Result<()> {
556
+ // Mark worktree as merging.
557
+ worktree::mark_status(&ctx.db, wt.id, worktree::WorktreeStatus::Merging)?;
558
+
559
+ let default_branch = config::resolve_default_branch(&ctx.root);
560
+
561
+ // Push feature branch.
562
+ println!("Pushing feature branch to remote...");
563
+ if let Err(e) = git::push_with_upstream(&ctx.root, &wt.branch_name) {
564
+ eprintln!("Warning: Failed to push feature branch: {e}");
565
+ }
566
+
567
+ // Checkout main.
568
+ println!("Checking out {default_branch}...");
569
+ git::run(&ctx.root, &["checkout", &default_branch])?;
570
+
571
+ // Pull latest.
572
+ println!("Updating {default_branch} from remote...");
573
+ if let Err(e) = git::pull_origin(&ctx.root, &default_branch) {
574
+ eprintln!("Warning: Failed to pull {}: {e}", default_branch);
575
+ }
576
+
577
+ // Merge.
578
+ println!("Merging {} into {default_branch}...", wt.branch_name);
579
+ git::merge_no_ff(&ctx.root, &wt.branch_name)?;
580
+
581
+ // Push main.
582
+ println!("Pushing {default_branch} to remote...");
583
+ if let Err(e) = git::run(&ctx.root, &["push"]) {
584
+ eprintln!("Warning: Failed to push {}: {e}", default_branch);
585
+ }
586
+
587
+ // Mark work item status.
588
+ println!("✓ Successfully merged work item #{work_id}");
589
+
590
+ // Update status.
591
+ if item.item_type == ItemType::Chore || item.item_type == ItemType::Bug {
592
+ work::update_status(&ctx.db, work_id, Status::Done)?;
593
+ println!(
594
+ "✅ {} #{} ready for review",
595
+ type_icon(&item.item_type),
596
+ work_id
597
+ );
598
+ }
599
+
600
+ Ok(())
601
+ })();
602
+
603
+ // Always release lock.
604
+ if let Err(e) = worktree::release_merge_lock(&ctx.db, lock_id) {
605
+ eprintln!("Warning: Failed to release merge lock: {e}");
606
+ }
607
+ println!("✅ Merge lock released");
608
+
609
+ if let Err(e) = merge_result {
610
+ bail!("Merge failed: {e}");
611
+ }
612
+
613
+ println!(
614
+ "\n⚠️ CLEANUP REQUIRED - Run these commands NOW:\n\n cd {}\n jettypod work cleanup {}\n\n Worktrees accumulate until cleaned up.",
615
+ ctx.root.display(),
616
+ work_id
617
+ );
618
+
619
+ Ok(())
620
+ }
621
+
622
+ // ---------------------------------------------------------------------------
623
+ // Work: Cleanup
624
+ // ---------------------------------------------------------------------------
625
+
626
+ pub fn work_cleanup(ctx: &CommandContext, id: Option<i64>, force: bool, dry_run: bool) -> Result<()> {
627
+ match id {
628
+ Some(work_id) => {
629
+ let wt = worktree::get_for_work_item(&ctx.db, work_id)?
630
+ .ok_or_else(|| anyhow::anyhow!("No worktree found for #{work_id}"))?;
631
+
632
+ let item = work::get(&ctx.db, work_id)?;
633
+ println!("Cleaning up worktree for #{}: {}", work_id, item.title);
634
+
635
+ if dry_run {
636
+ println!(" Would remove: {}", wt.worktree_path);
637
+ println!(" Would delete branch: {}", wt.branch_name);
638
+ return Ok(());
639
+ }
640
+
641
+ if !force
642
+ && wt.status != worktree::WorktreeStatus::Merging
643
+ && wt.status != worktree::WorktreeStatus::Active
644
+ {
645
+ println!("Worktree status is {} — nothing to clean.", wt.status);
646
+ return Ok(());
647
+ }
648
+
649
+ worktree::cleanup(
650
+ &ctx.db,
651
+ wt.id,
652
+ &worktree::CleanupWorktreeOpts {
653
+ repo_root: &ctx.root,
654
+ delete_branch: true,
655
+ },
656
+ )?;
657
+
658
+ println!("✅ Removed worktree directory");
659
+ println!("✅ Deleted branch");
660
+ println!("✅ Worktree cleaned up");
661
+ }
662
+ None => {
663
+ // Batch cleanup: find all non-active worktrees.
664
+ let active = worktree::get_all_active(&ctx.db)?;
665
+ if active.is_empty() {
666
+ println!("No active worktrees to clean up.");
667
+ return Ok(());
668
+ }
669
+ println!("Found {} active worktrees.", active.len());
670
+ for wt in &active {
671
+ let path = std::path::Path::new(&wt.worktree_path);
672
+ if !path.exists() {
673
+ println!(
674
+ " Cleaning orphan: {} (directory missing)",
675
+ wt.worktree_path
676
+ );
677
+ if !dry_run {
678
+ if let Err(e) = worktree::cleanup(
679
+ &ctx.db,
680
+ wt.id,
681
+ &worktree::CleanupWorktreeOpts {
682
+ repo_root: &ctx.root,
683
+ delete_branch: true,
684
+ },
685
+ ) {
686
+ eprintln!("Warning: Failed to cleanup orphan worktree: {e}");
687
+ }
688
+ }
689
+ }
690
+ }
691
+ }
692
+ }
693
+
694
+ Ok(())
695
+ }
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // Work: Set mode / Elevate
699
+ // ---------------------------------------------------------------------------
700
+
701
+ pub fn work_set_mode(ctx: &CommandContext, id: i64, mode: &str) -> Result<()> {
702
+ work::set_mode(&ctx.db, id, mode)?;
703
+ println!("Set mode for #{} to {}", id, mode);
704
+ Ok(())
705
+ }
706
+
707
+ pub fn work_elevate(ctx: &CommandContext, id: i64, mode: &str) -> Result<()> {
708
+ let item = work::get(&ctx.db, id)?;
709
+
710
+ let target = mode.parse::<Mode>()?;
711
+ let valid_progression = match item.mode {
712
+ Some(Mode::Speed) => target == Mode::Stable || target == Mode::Production,
713
+ Some(Mode::Stable) => target == Mode::Production,
714
+ _ => false,
715
+ };
716
+
717
+ if !valid_progression {
718
+ bail!(
719
+ "Cannot elevate #{} from {:?} to {}",
720
+ id,
721
+ item.mode,
722
+ mode
723
+ );
724
+ }
725
+
726
+ work::set_mode(&ctx.db, id, mode)?;
727
+ println!(
728
+ "⬆️ Elevated #{} from {} → {}",
729
+ id,
730
+ item.mode.map(|m| m.to_string()).unwrap_or_default(),
731
+ mode
732
+ );
733
+ Ok(())
734
+ }
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // Work: Tests
738
+ // ---------------------------------------------------------------------------
739
+
740
+ pub fn work_tests(
741
+ ctx: &CommandContext,
742
+ action: Option<TestAction>,
743
+ id: Option<i64>,
744
+ ) -> Result<()> {
745
+ match action {
746
+ Some(TestAction::Start { id: feature_id }) => {
747
+ create_test_worktree(ctx, feature_id)
748
+ }
749
+ Some(TestAction::Merge { id: feature_id }) => {
750
+ println!("Merging test worktree for #{}...", feature_id);
751
+ work_merge(ctx, Some(feature_id), false)
752
+ }
753
+ None => {
754
+ // Shorthand: `jettypod work tests <id>`
755
+ match id {
756
+ Some(feature_id) => create_test_worktree(ctx, feature_id),
757
+ None => bail!("Usage: jettypod work tests <feature-id> OR jettypod work tests start <id>"),
758
+ }
759
+ }
760
+ }
761
+ }
762
+
763
+ fn create_test_worktree(ctx: &CommandContext, feature_id: i64) -> Result<()> {
764
+ let item = work::get(&ctx.db, feature_id)?;
765
+ println!("Creating test worktree for #{}: {}", feature_id, item.title);
766
+ let wt = worktree::create(
767
+ &ctx.db,
768
+ &worktree::CreateWorktreeOpts {
769
+ work_item_id: feature_id,
770
+ title: &format!("{}-tests", item.title),
771
+ repo_root: &ctx.root,
772
+ },
773
+ )?;
774
+ println!("✅ Test worktree created: {}", wt.worktree_path);
775
+ Ok(())
776
+ }
777
+
778
+ // ---------------------------------------------------------------------------
779
+ // Work: Prototype
780
+ // ---------------------------------------------------------------------------
781
+
782
+ pub fn work_prototype(ctx: &CommandContext, action: PrototypeAction) -> Result<()> {
783
+ match action {
784
+ PrototypeAction::Start { id, approach } => {
785
+ let item = work::get(&ctx.db, id)?;
786
+ println!(
787
+ "Creating prototype worktree for #{}: {} (approach: {})",
788
+ id, item.title, approach
789
+ );
790
+ let wt = worktree::create(
791
+ &ctx.db,
792
+ &worktree::CreateWorktreeOpts {
793
+ work_item_id: id,
794
+ title: &format!("{}-prototype-{}", item.title, approach),
795
+ repo_root: &ctx.root,
796
+ },
797
+ )?;
798
+ println!("✅ Prototype worktree created: {}", wt.worktree_path);
799
+ Ok(())
800
+ }
801
+ PrototypeAction::Merge { id } => {
802
+ println!("Merging prototype worktree for #{}...", id);
803
+ work_merge(ctx, Some(id), false)
804
+ }
805
+ }
806
+ }
807
+
808
+ // ---------------------------------------------------------------------------
809
+ // Work: Epic implement (record decision)
810
+ // ---------------------------------------------------------------------------
811
+
812
+ pub fn work_epic_implement(
813
+ ctx: &CommandContext,
814
+ id: i64,
815
+ aspect: &str,
816
+ decision: &str,
817
+ rationale: &str,
818
+ _prototypes: Option<String>,
819
+ ) -> Result<()> {
820
+ let item = work::get(&ctx.db, id)?;
821
+
822
+ if item.item_type != ItemType::Epic {
823
+ bail!("#{} is a {} — epic-implement is only for epics", id, item.item_type);
824
+ }
825
+
826
+ work::add_decision(&ctx.db, id, aspect, decision, rationale)?;
827
+ println!("✅ Architectural decision recorded for epic #{}", id);
828
+ println!(" Aspect: {aspect}");
829
+ println!(" Decision: {decision}");
830
+ println!(" Rationale: {rationale}");
831
+ Ok(())
832
+ }
833
+
834
+ // ---------------------------------------------------------------------------
835
+ // Work: Set QA Steps
836
+ // ---------------------------------------------------------------------------
837
+
838
+ pub fn work_set_qa_steps(
839
+ ctx: &CommandContext,
840
+ id: i64,
841
+ from: Option<String>,
842
+ steps: Option<String>,
843
+ ) -> Result<()> {
844
+ let json = if let Some(path) = from {
845
+ std::fs::read_to_string(&path)
846
+ .map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path, e))?
847
+ } else if let Some(s) = steps {
848
+ s
849
+ } else {
850
+ anyhow::bail!("Provide QA steps via --from=<file> or as an argument");
851
+ };
852
+
853
+ // Validate it's valid JSON array
854
+ let parsed: serde_json::Value = serde_json::from_str(&json)
855
+ .map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e))?;
856
+ if !parsed.is_array() {
857
+ anyhow::bail!("QA steps must be a JSON array");
858
+ }
859
+
860
+ work::set_qa_steps(&ctx.db, id, &json)?;
861
+ let count = parsed.as_array().map(|a| a.len()).unwrap_or(0);
862
+ println!("✅ Set {} QA steps for work item #{}", count, id);
863
+ Ok(())
864
+ }
865
+
866
+ // ---------------------------------------------------------------------------
867
+ // Work: Implement (transition discovery → implementation)
868
+ // ---------------------------------------------------------------------------
869
+
870
+ pub fn work_implement(
871
+ ctx: &CommandContext,
872
+ id: i64,
873
+ _prototypes: Option<String>,
874
+ winner: Option<String>,
875
+ ) -> Result<()> {
876
+ let item = work::get(&ctx.db, id)?;
877
+
878
+ if item.item_type != ItemType::Feature {
879
+ bail!("#{} is a {} — implement is only for features", id, item.item_type);
880
+ }
881
+
882
+ work::complete_discovery(
883
+ &ctx.db,
884
+ id,
885
+ item.scenario_file.as_deref(),
886
+ winner.as_deref(),
887
+ None,
888
+ )?;
889
+
890
+ println!(
891
+ "✅ Feature #{} transitioned to implementation (mode: speed)",
892
+ id
893
+ );
894
+ Ok(())
895
+ }
896
+
897
+ // ---------------------------------------------------------------------------
898
+ // Backlog
899
+ // ---------------------------------------------------------------------------
900
+
901
+ pub fn backlog(ctx: &CommandContext, _expand: Option<&str>, expand_all: bool) -> Result<()> {
902
+ let items = work::get_tree(&ctx.db, false)?;
903
+
904
+ if items.is_empty() {
905
+ println!("Backlog is empty.");
906
+ return Ok(());
907
+ }
908
+
909
+ // Group by: top-level items (no parent) and their children.
910
+ let top_level: Vec<_> = items.iter().filter(|i| i.parent_id.is_none()).collect();
911
+
912
+ // Recently completed (last 5).
913
+ let completed = {
914
+ let all = work::get_tree(&ctx.db, true)?;
915
+ let mut done: Vec<_> = all.iter().filter(|i| i.status == Status::Done).cloned().collect();
916
+ done.sort_by(|a, b| b.completed_at.cmp(&a.completed_at));
917
+ done.truncate(5);
918
+ done
919
+ };
920
+
921
+ if !completed.is_empty() {
922
+ println!("Recently completed:");
923
+ for item in &completed {
924
+ println!(
925
+ " ✅ {} #{}: {}",
926
+ type_icon(&item.item_type),
927
+ item.id,
928
+ item.title
929
+ );
930
+ }
931
+ println!();
932
+ }
933
+
934
+ println!("Backlog:");
935
+ for item in &top_level {
936
+ let mode_str = item
937
+ .mode
938
+ .as_ref()
939
+ .map(|m| format!(" ({m})"))
940
+ .unwrap_or_default();
941
+ println!(
942
+ "{} #{}: {} [{}]{}",
943
+ type_icon(&item.item_type),
944
+ item.id,
945
+ item.title,
946
+ item.status,
947
+ mode_str
948
+ );
949
+
950
+ // Show children.
951
+ let children: Vec<_> = items.iter().filter(|c| c.parent_id == Some(item.id)).collect();
952
+ for child in &children {
953
+ let child_mode = child
954
+ .mode
955
+ .as_ref()
956
+ .map(|m| format!(" ({m})"))
957
+ .unwrap_or_default();
958
+ println!(
959
+ " {} #{}: {} [{}]{}",
960
+ type_icon(&child.item_type),
961
+ child.id,
962
+ child.title,
963
+ child.status,
964
+ child_mode
965
+ );
966
+
967
+ if expand_all {
968
+ // Show grandchildren.
969
+ let grandchildren: Vec<_> =
970
+ items.iter().filter(|g| g.parent_id == Some(child.id)).collect();
971
+ for gc in &grandchildren {
972
+ println!(
973
+ " {} #{}: {} [{}]",
974
+ type_icon(&gc.item_type),
975
+ gc.id,
976
+ gc.title,
977
+ gc.status
978
+ );
979
+ }
980
+ }
981
+ }
982
+ }
983
+
984
+ Ok(())
985
+ }
986
+
987
+ // ---------------------------------------------------------------------------
988
+ // Decisions
989
+ // ---------------------------------------------------------------------------
990
+
991
+ pub fn decisions(
992
+ ctx: &CommandContext,
993
+ all: bool,
994
+ _project: bool,
995
+ _epics: bool,
996
+ epic: Option<i64>,
997
+ view: bool,
998
+ ) -> Result<()> {
999
+ if view {
1000
+ let decisions_path = ctx.root.join("docs").join("DECISIONS.md");
1001
+ if decisions_path.exists() {
1002
+ let content = std::fs::read_to_string(&decisions_path)?;
1003
+ println!("{content}");
1004
+ } else {
1005
+ println!("No DECISIONS.md found.");
1006
+ }
1007
+ return Ok(());
1008
+ }
1009
+
1010
+ if let Some(epic_id) = epic {
1011
+ let decisions = work::get_decisions(&ctx.db, epic_id)?;
1012
+ let item = work::get(&ctx.db, epic_id)?;
1013
+ println!("Decisions for {} #{}: {}", type_icon(&item.item_type), epic_id, item.title);
1014
+ if decisions.is_empty() {
1015
+ println!(" No decisions recorded.");
1016
+ } else {
1017
+ for d in &decisions {
1018
+ println!(" • {}: {}", d.aspect, d.decision);
1019
+ println!(" Rationale: {}", d.rationale);
1020
+ }
1021
+ }
1022
+ return Ok(());
1023
+ }
1024
+
1025
+ if all {
1026
+ // Show all decisions across all epics.
1027
+ let items = work::get_tree(&ctx.db, true)?;
1028
+ let epics: Vec<_> = items.iter().filter(|i| i.item_type == ItemType::Epic).collect();
1029
+ for ep in &epics {
1030
+ let decisions = work::get_decisions(&ctx.db, ep.id)?;
1031
+ if !decisions.is_empty() {
1032
+ println!("📦 #{}: {}", ep.id, ep.title);
1033
+ for d in &decisions {
1034
+ println!(" • {}: {}", d.aspect, d.decision);
1035
+ println!(" Rationale: {}", d.rationale);
1036
+ }
1037
+ println!();
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ Ok(())
1043
+ }
1044
+
1045
+ // ---------------------------------------------------------------------------
1046
+ // Project
1047
+ // ---------------------------------------------------------------------------
1048
+
1049
+ pub fn project_state(ctx: &CommandContext) -> Result<()> {
1050
+ let cfg = config::read(&ctx.root);
1051
+ println!("Project state: {}", cfg.project_state);
1052
+ Ok(())
1053
+ }
1054
+
1055
+ pub fn project_info(ctx: &CommandContext) -> Result<()> {
1056
+ let cfg = config::read(&ctx.root);
1057
+ println!("Project: {}", cfg.name);
1058
+ println!("State: {}", cfg.project_state);
1059
+ if let Some(desc) = cfg.extra.get("description") {
1060
+ if let Some(s) = desc.as_str() {
1061
+ println!("Description: {s}");
1062
+ }
1063
+ }
1064
+ Ok(())
1065
+ }
1066
+
1067
+ pub fn project_external(ctx: &CommandContext) -> Result<()> {
1068
+ let mut cfg = config::read(&ctx.root);
1069
+ cfg.project_state = "external".into();
1070
+ config::write(&ctx.root, &cfg)?;
1071
+ println!("✅ Project state updated to: external");
1072
+ Ok(())
1073
+ }
1074
+
1075
+ pub fn project_discover_start(ctx: &CommandContext) -> Result<()> {
1076
+ let mut cfg = config::read(&ctx.root);
1077
+ cfg.project_discovery.status = "in_progress".into();
1078
+ cfg.project_discovery.started_date = Some(chrono::Utc::now().to_rfc3339());
1079
+ config::write(&ctx.root, &cfg)?;
1080
+ println!("🔍 Project discovery started.");
1081
+ Ok(())
1082
+ }
1083
+
1084
+ pub fn project_discover_complete(
1085
+ ctx: &CommandContext,
1086
+ winner: &str,
1087
+ rationale: &str,
1088
+ _prototypes: Option<String>,
1089
+ ) -> Result<()> {
1090
+ if !std::path::Path::new(winner).exists() {
1091
+ bail!("Winner path does not exist: {winner}");
1092
+ }
1093
+
1094
+ let mut cfg = config::read(&ctx.root);
1095
+ cfg.project_discovery.status = "completed".into();
1096
+ cfg.project_discovery.winner = Some(winner.to_string());
1097
+ cfg.project_discovery.rationale = Some(rationale.to_string());
1098
+ cfg.project_discovery.completed_date = Some(chrono::Utc::now().to_rfc3339());
1099
+ config::write(&ctx.root, &cfg)?;
1100
+ println!("✅ Project discovery completed.");
1101
+ println!(" Winner: {winner}");
1102
+ println!(" Rationale: {rationale}");
1103
+ Ok(())
1104
+ }
1105
+
1106
+ pub fn project_prototype_start(ctx: &CommandContext, approach: &str) -> Result<()> {
1107
+ // Create a special worktree for project prototype.
1108
+ let wt = worktree::create(
1109
+ &ctx.db,
1110
+ &worktree::CreateWorktreeOpts {
1111
+ work_item_id: 0, // Project-level, no work item.
1112
+ title: &format!("project-prototype-{approach}"),
1113
+ repo_root: &ctx.root,
1114
+ },
1115
+ )?;
1116
+ println!("✅ Prototype worktree created: {}", wt.worktree_path);
1117
+ Ok(())
1118
+ }
1119
+
1120
+ pub fn project_prototype_merge(ctx: &CommandContext) -> Result<()> {
1121
+ println!("Merging project prototype...");
1122
+ // Find and merge the project prototype worktree.
1123
+ let active = worktree::get_all_active(&ctx.db)?;
1124
+ let proto = active.iter().find(|w| w.worktree_path.contains("project-prototype"));
1125
+ match proto {
1126
+ Some(wt) => {
1127
+ work_merge(ctx, Some(wt.work_item_id), false)
1128
+ }
1129
+ None => {
1130
+ bail!("No active project prototype worktree found.");
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ // ---------------------------------------------------------------------------
1136
+ // Impact
1137
+ // ---------------------------------------------------------------------------
1138
+
1139
+ pub fn impact(ctx: &CommandContext, file: &str) -> Result<()> {
1140
+ let items = work::get_tree(&ctx.db, false)?;
1141
+
1142
+ // Check which features have scenario files that reference this file.
1143
+ let mut affected = Vec::new();
1144
+ for item in &items {
1145
+ if let Some(ref scenario) = item.scenario_file {
1146
+ let scenario_path = ctx.root.join(scenario);
1147
+ if scenario_path.exists() {
1148
+ if let Ok(content) = std::fs::read_to_string(&scenario_path) {
1149
+ if content.contains(file) {
1150
+ affected.push(item);
1151
+ }
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+
1157
+ if affected.is_empty() {
1158
+ println!("No features/tests reference: {file}");
1159
+ } else {
1160
+ println!("Affected by changes to {file}:");
1161
+ for item in &affected {
1162
+ println!(
1163
+ " {} #{}: {} [{}]",
1164
+ type_icon(&item.item_type),
1165
+ item.id,
1166
+ item.title,
1167
+ item.status
1168
+ );
1169
+ }
1170
+ }
1171
+
1172
+ Ok(())
1173
+ }
1174
+
1175
+ // ---------------------------------------------------------------------------
1176
+ // Workflow
1177
+ // ---------------------------------------------------------------------------
1178
+
1179
+ pub fn workflow_start(ctx: &CommandContext, skill: &str, id: i64) -> Result<()> {
1180
+ // Record skill execution.
1181
+ ctx.db.conn().execute(
1182
+ "INSERT INTO skill_executions (work_item_id, skill_name, status) VALUES (?, ?, 'in_progress')",
1183
+ rusqlite::params![id, skill],
1184
+ )?;
1185
+
1186
+ // Set workflow gate.
1187
+ let gate_name = format!("{}_started", skill.replace('-', "_"));
1188
+ ctx.db.conn().execute(
1189
+ "INSERT OR REPLACE INTO workflow_gates (work_item_id, gate_name, passed_at) \
1190
+ VALUES (?, ?, datetime('now'))",
1191
+ rusqlite::params![id, gate_name],
1192
+ )?;
1193
+
1194
+ println!("Started {} for #{}", skill, id);
1195
+ Ok(())
1196
+ }
1197
+
1198
+ pub fn workflow_complete(ctx: &CommandContext, skill: &str, id: i64) -> Result<()> {
1199
+ // Update skill execution.
1200
+ ctx.db.conn().execute(
1201
+ "UPDATE skill_executions SET status = 'completed', completed_at = datetime('now') \
1202
+ WHERE work_item_id = ? AND skill_name = ? AND status = 'in_progress'",
1203
+ rusqlite::params![id, skill],
1204
+ )?;
1205
+
1206
+ // Set completion gate.
1207
+ let gate_name = format!("{}_complete", skill.replace('-', "_"));
1208
+ ctx.db.conn().execute(
1209
+ "INSERT OR REPLACE INTO workflow_gates (work_item_id, gate_name, passed_at) \
1210
+ VALUES (?, ?, datetime('now'))",
1211
+ rusqlite::params![id, gate_name],
1212
+ )?;
1213
+
1214
+ println!("Completed {} for #{}", skill, id);
1215
+ Ok(())
1216
+ }
1217
+
1218
+ pub fn workflow_checkpoint(ctx: &CommandContext, id: i64, step: i64) -> Result<()> {
1219
+ let branch = git::current_branch(&ctx.root)?.unwrap_or_else(|| "unknown".to_string());
1220
+
1221
+ ctx.db.conn().execute(
1222
+ "INSERT OR REPLACE INTO workflow_checkpoints (work_item_id, current_step, branch_name, skill_name) \
1223
+ VALUES (?, ?, ?, 'chore-mode')",
1224
+ rusqlite::params![id, step, branch],
1225
+ )?;
1226
+
1227
+ println!("Checkpoint: step {} for #{}", step, id);
1228
+ Ok(())
1229
+ }
1230
+
1231
+ // ---------------------------------------------------------------------------
1232
+ // Dashboard
1233
+ // ---------------------------------------------------------------------------
1234
+
1235
+ pub async fn launch_dashboard(ctx: &CommandContext) -> Result<()> {
1236
+ println!("Starting JettyPod dashboard...");
1237
+
1238
+ // Start WebSocket server for live updates.
1239
+ let config = jettypod_core::ws::WsConfig {
1240
+ port: jettypod_core::ws::DEFAULT_PORT,
1241
+ project_root: ctx.root.clone(),
1242
+ };
1243
+ let _ws_handle = jettypod_core::ws::spawn_server(config);
1244
+
1245
+ println!("WebSocket server started on port {}", jettypod_core::ws::DEFAULT_PORT);
1246
+ println!("Dashboard launch requires the Next.js dev server (not yet ported to Rust).");
1247
+
1248
+ Ok(())
1249
+ }