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,625 @@
1
+ //! Session management.
2
+ //!
3
+ //! Tracks active Claude Code sessions, their associated work items,
4
+ //! and provides session lifecycle operations.
5
+ //!
6
+ //! Three subsystems:
7
+ //! 1. **Claude sessions** (DB) — track which Claude sessions are active, their content
8
+ //! 2. **Session files** (filesystem) — `.claude/session.md` for mode/context resumption
9
+ //! 3. **Current work** — derive active work item from git branch name
10
+
11
+ use std::path::{Path, PathBuf};
12
+
13
+ use rusqlite::params;
14
+
15
+ use crate::db::Database;
16
+ use crate::error::{CoreError, Result, WorkError};
17
+ use crate::git;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Claude Sessions (DB-backed)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /// A Claude Code session record.
24
+ #[derive(Debug, Clone)]
25
+ pub struct ClaudeSession {
26
+ pub id: i64,
27
+ pub work_item_id: Option<i64>,
28
+ pub title: String,
29
+ pub session_title: Option<String>,
30
+ pub status: SessionStatus,
31
+ pub started_at: String,
32
+ pub completed_at: Option<String>,
33
+ pub content: Option<String>,
34
+ }
35
+
36
+ /// Session lifecycle status.
37
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
38
+ pub enum SessionStatus {
39
+ Active,
40
+ Completed,
41
+ Error,
42
+ Orphaned,
43
+ }
44
+
45
+ impl SessionStatus {
46
+ pub fn as_str(&self) -> &'static str {
47
+ match self {
48
+ Self::Active => "active",
49
+ Self::Completed => "completed",
50
+ Self::Error => "error",
51
+ Self::Orphaned => "orphaned",
52
+ }
53
+ }
54
+ }
55
+
56
+ impl std::fmt::Display for SessionStatus {
57
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58
+ f.write_str(self.as_str())
59
+ }
60
+ }
61
+
62
+ impl std::str::FromStr for SessionStatus {
63
+ type Err = CoreError;
64
+ fn from_str(s: &str) -> Result<Self> {
65
+ match s {
66
+ "active" => Ok(Self::Active),
67
+ "completed" => Ok(Self::Completed),
68
+ "error" => Ok(Self::Error),
69
+ "orphaned" => Ok(Self::Orphaned),
70
+ _ => Err(WorkError::InvalidState(format!("invalid session status: {s}")).into()),
71
+ }
72
+ }
73
+ }
74
+
75
+ fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result<ClaudeSession> {
76
+ let status_str: String = row.get("status")?;
77
+ Ok(ClaudeSession {
78
+ id: row.get("id")?,
79
+ work_item_id: row.get("work_item_id")?,
80
+ title: row.get("title")?,
81
+ session_title: row.get("session_title")?,
82
+ status: status_str.parse().unwrap_or(SessionStatus::Active),
83
+ started_at: row.get("started_at")?,
84
+ completed_at: row.get("completed_at")?,
85
+ content: row.get("content").ok(),
86
+ })
87
+ }
88
+
89
+ /// Create a new session (unlinked to a work item).
90
+ pub fn create_session(db: &Database, title: &str, session_title: Option<&str>) -> Result<i64> {
91
+ db.conn().execute(
92
+ "INSERT INTO claude_sessions (title, session_title, status, started_at) \
93
+ VALUES (?, ?, 'active', datetime('now'))",
94
+ params![title, session_title],
95
+ )?;
96
+ Ok(db.conn().last_insert_rowid())
97
+ }
98
+
99
+ /// Get a session by id.
100
+ pub fn get_session(db: &Database, id: i64) -> Result<Option<ClaudeSession>> {
101
+ let mut stmt = db.conn().prepare(
102
+ "SELECT id, work_item_id, title, session_title, status, started_at, completed_at, content \
103
+ FROM claude_sessions WHERE id = ?",
104
+ )?;
105
+ let mut rows = stmt.query_map(params![id], row_to_session)?;
106
+ match rows.next() {
107
+ Some(Ok(s)) => Ok(Some(s)),
108
+ Some(Err(e)) => Err(e.into()),
109
+ None => Ok(None),
110
+ }
111
+ }
112
+
113
+ /// Close a session (set status = completed).
114
+ pub fn close_session(db: &Database, id: i64) -> Result<bool> {
115
+ let changed = db.conn().execute(
116
+ "UPDATE claude_sessions SET status = 'completed', completed_at = datetime('now') WHERE id = ?",
117
+ params![id],
118
+ )?;
119
+ Ok(changed > 0)
120
+ }
121
+
122
+ /// Close all active sessions for a work item.
123
+ pub fn close_sessions_for_work_item(db: &Database, work_item_id: i64) -> Result<bool> {
124
+ let changed = db.conn().execute(
125
+ "UPDATE claude_sessions SET status = 'completed', completed_at = datetime('now') \
126
+ WHERE work_item_id = ? AND status = 'active'",
127
+ params![work_item_id],
128
+ )?;
129
+ Ok(changed > 0)
130
+ }
131
+
132
+ /// Link a session to a work item.
133
+ pub fn link_session_to_work_item(
134
+ db: &Database,
135
+ session_id: i64,
136
+ work_item_id: i64,
137
+ title: &str,
138
+ ) -> Result<bool> {
139
+ let changed = db.conn().execute(
140
+ "UPDATE claude_sessions SET work_item_id = ?, title = ? \
141
+ WHERE id = ? AND work_item_id IS NULL",
142
+ params![work_item_id, title, session_id],
143
+ )?;
144
+ Ok(changed > 0)
145
+ }
146
+
147
+ /// Get active sessions for a work item.
148
+ pub fn get_active_sessions_for_work_item(
149
+ db: &Database,
150
+ work_item_id: i64,
151
+ ) -> Result<Vec<ClaudeSession>> {
152
+ let mut stmt = db.conn().prepare(
153
+ "SELECT id, work_item_id, title, session_title, status, started_at, completed_at, content \
154
+ FROM claude_sessions WHERE work_item_id = ? AND status = 'active'",
155
+ )?;
156
+ let rows = stmt.query_map(params![work_item_id], row_to_session)?;
157
+ rows.map(|r| r.map_err(CoreError::from)).collect()
158
+ }
159
+
160
+ /// Get or create a session for a work item.
161
+ /// Returns `(session, created)` — `created` is true if a new session was made.
162
+ pub fn get_or_create_session(
163
+ db: &Database,
164
+ work_item_id: i64,
165
+ title: &str,
166
+ ) -> Result<(ClaudeSession, bool)> {
167
+ let existing = get_active_sessions_for_work_item(db, work_item_id)?;
168
+ if let Some(session) = existing.into_iter().next() {
169
+ return Ok((session, false));
170
+ }
171
+
172
+ db.conn().execute(
173
+ "INSERT INTO claude_sessions (work_item_id, title, status, started_at) \
174
+ VALUES (?, ?, 'active', datetime('now'))",
175
+ params![work_item_id, title],
176
+ )?;
177
+ let id = db.conn().last_insert_rowid();
178
+ let session = get_session(db, id)?.unwrap();
179
+ Ok((session, true))
180
+ }
181
+
182
+ /// Count active sessions.
183
+ pub fn count_active_sessions(db: &Database) -> Result<i64> {
184
+ let count: i64 = db.conn().query_row(
185
+ "SELECT COUNT(*) FROM claude_sessions WHERE status = 'active'",
186
+ [],
187
+ |row| row.get(0),
188
+ )?;
189
+ Ok(count)
190
+ }
191
+
192
+ /// Get all active sessions ordered by most recent activity.
193
+ pub fn get_all_active_sessions(db: &Database) -> Result<Vec<ClaudeSession>> {
194
+ let mut stmt = db.conn().prepare(
195
+ "SELECT id, work_item_id, title, session_title, status, started_at, completed_at, content \
196
+ FROM claude_sessions WHERE status = 'active' \
197
+ ORDER BY COALESCE(completed_at, started_at) DESC",
198
+ )?;
199
+ let rows = stmt.query_map([], row_to_session)?;
200
+ rows.map(|r| r.map_err(CoreError::from)).collect()
201
+ }
202
+
203
+ /// Update session content.
204
+ pub fn set_session_content(db: &Database, id: i64, content: &str) -> Result<()> {
205
+ db.conn().execute(
206
+ "UPDATE claude_sessions SET content = ? WHERE id = ?",
207
+ params![content, id],
208
+ )?;
209
+ Ok(())
210
+ }
211
+
212
+ /// Clean up stale sessions older than `retention_days`.
213
+ /// Removes orphaned sessions and old completed/error sessions.
214
+ pub fn cleanup_stale_sessions(db: &Database, retention_days: i64) -> Result<usize> {
215
+ let days_str = retention_days.to_string();
216
+ let deleted = db.conn().execute(
217
+ "DELETE FROM claude_sessions \
218
+ WHERE status = 'orphaned' \
219
+ OR (status = 'completed' AND completed_at < datetime('now', '-' || ? || ' days')) \
220
+ OR (status = 'error' AND completed_at < datetime('now', '-' || ? || ' days'))",
221
+ params![days_str, days_str],
222
+ )?;
223
+ Ok(deleted)
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Worktree Sessions (DB-backed)
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /// A worktree session record.
231
+ #[derive(Debug, Clone)]
232
+ pub struct WorktreeSession {
233
+ pub id: i64,
234
+ pub worktree_path: String,
235
+ pub work_item_id: i64,
236
+ pub branch_name: String,
237
+ pub last_activity: String,
238
+ }
239
+
240
+ fn row_to_worktree_session(row: &rusqlite::Row) -> rusqlite::Result<WorktreeSession> {
241
+ Ok(WorktreeSession {
242
+ id: row.get("id")?,
243
+ worktree_path: row.get("worktree_path")?,
244
+ work_item_id: row.get("work_item_id")?,
245
+ branch_name: row.get("branch_name")?,
246
+ last_activity: row.get("last_activity")?,
247
+ })
248
+ }
249
+
250
+ /// Create or update a worktree session.
251
+ pub fn upsert_worktree_session(
252
+ db: &Database,
253
+ worktree_path: &str,
254
+ work_item_id: i64,
255
+ branch_name: &str,
256
+ ) -> Result<()> {
257
+ db.conn().execute(
258
+ "INSERT INTO worktree_sessions (worktree_path, work_item_id, branch_name) \
259
+ VALUES (?, ?, ?) \
260
+ ON CONFLICT(worktree_path) DO UPDATE SET \
261
+ work_item_id = excluded.work_item_id, \
262
+ branch_name = excluded.branch_name, \
263
+ last_activity = CURRENT_TIMESTAMP",
264
+ params![worktree_path, work_item_id, branch_name],
265
+ )?;
266
+ Ok(())
267
+ }
268
+
269
+ /// Get a worktree session by path.
270
+ pub fn get_worktree_session(db: &Database, worktree_path: &str) -> Result<Option<WorktreeSession>> {
271
+ let mut stmt = db.conn().prepare(
272
+ "SELECT * FROM worktree_sessions WHERE worktree_path = ?",
273
+ )?;
274
+ let mut rows = stmt.query_map(params![worktree_path], row_to_worktree_session)?;
275
+ match rows.next() {
276
+ Some(Ok(ws)) => Ok(Some(ws)),
277
+ Some(Err(e)) => Err(e.into()),
278
+ None => Ok(None),
279
+ }
280
+ }
281
+
282
+ /// Remove a worktree session.
283
+ pub fn remove_worktree_session(db: &Database, worktree_path: &str) -> Result<()> {
284
+ db.conn().execute(
285
+ "DELETE FROM worktree_sessions WHERE worktree_path = ?",
286
+ params![worktree_path],
287
+ )?;
288
+ Ok(())
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Session file (filesystem)
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /// Map a mode name to its corresponding skill name.
296
+ pub fn mode_to_skill_name(mode: &str) -> String {
297
+ match mode {
298
+ "speed" => "speed-mode".to_string(),
299
+ "stable" => "stable-mode".to_string(),
300
+ "production" => "production-mode".to_string(),
301
+ other => format!("{other}-mode"),
302
+ }
303
+ }
304
+
305
+ /// Get the path to the session file: `<cwd>/.claude/session.md`.
306
+ pub fn session_file_path(cwd: &Path) -> PathBuf {
307
+ cwd.join(".claude").join("session.md")
308
+ }
309
+
310
+ /// Write a session file for mode/context resumption in a worktree.
311
+ ///
312
+ /// The session file is `.claude/session.md` (gitignored) and tells Claude Code
313
+ /// which skill to resume when reopening the worktree.
314
+ pub fn write_session_file(
315
+ cwd: &Path,
316
+ work_item_id: i64,
317
+ title: &str,
318
+ item_type: &str,
319
+ status: &str,
320
+ mode: &str,
321
+ epic_id: Option<i64>,
322
+ epic_title: Option<&str>,
323
+ ) -> Result<()> {
324
+ let path = session_file_path(cwd);
325
+
326
+ // Ensure .claude directory exists
327
+ if let Some(parent) = path.parent() {
328
+ std::fs::create_dir_all(parent)?;
329
+ }
330
+
331
+ let skill_name = mode_to_skill_name(mode);
332
+
333
+ let mut content = String::new();
334
+ content.push_str("# Current Work Session\n\n");
335
+ content.push_str("## Work Context\n\n");
336
+ content.push_str(&format!(
337
+ "Working on: [#{}] {} ({})\n",
338
+ work_item_id, title, item_type
339
+ ));
340
+
341
+ if let (Some(eid), Some(etitle)) = (epic_id, epic_title) {
342
+ content.push_str(&format!("Epic: [#{}] {}\n", eid, etitle));
343
+ }
344
+
345
+ content.push_str(&format!("Mode: {}\n", mode));
346
+ content.push_str(&format!("Status: {}\n", status));
347
+ content.push_str("\n## IMMEDIATE ACTION REQUIRED\n\n");
348
+ content.push_str(&format!(
349
+ "You are resuming work on a {} in {} mode.\n\n",
350
+ item_type, mode
351
+ ));
352
+ content.push_str(&format!(
353
+ "**Invoke the {} skill now to continue the workflow.**\n",
354
+ skill_name
355
+ ));
356
+
357
+ std::fs::write(&path, content)?;
358
+ Ok(())
359
+ }
360
+
361
+ /// Clear the session file.
362
+ pub fn clear_session_file(cwd: &Path) -> Result<()> {
363
+ let path = session_file_path(cwd);
364
+ if path.exists() {
365
+ std::fs::remove_file(&path)?;
366
+ }
367
+ Ok(())
368
+ }
369
+
370
+ /// Read the session file content.
371
+ pub fn read_session_file(cwd: &Path) -> Result<Option<String>> {
372
+ let path = session_file_path(cwd);
373
+ if path.exists() {
374
+ Ok(Some(std::fs::read_to_string(&path)?))
375
+ } else {
376
+ Ok(None)
377
+ }
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Current work (from git branch)
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /// Extract a work item ID from a branch name.
385
+ ///
386
+ /// Matches patterns like `feature/work-123-title` or `123-title`.
387
+ pub fn extract_work_item_id_from_branch(branch: &str) -> Option<i64> {
388
+ // Try feature/work-{id}-... pattern
389
+ if let Some(rest) = branch.strip_prefix("feature/work-") {
390
+ if let Some(id_str) = rest.split('-').next() {
391
+ if let Ok(id) = id_str.parse::<i64>() {
392
+ return Some(id);
393
+ }
394
+ }
395
+ }
396
+
397
+ // Try bare {id}-... pattern
398
+ if let Some(id_str) = branch.split('-').next() {
399
+ if let Ok(id) = id_str.parse::<i64>() {
400
+ return Some(id);
401
+ }
402
+ }
403
+
404
+ None
405
+ }
406
+
407
+ /// Get the current work item by inspecting the git branch name.
408
+ /// Returns `None` if not in a git repo, on a non-work branch, or item not found.
409
+ pub fn get_current_work_from_branch(db: &Database, cwd: &Path) -> Result<Option<i64>> {
410
+ let branch = match git::current_branch(cwd) {
411
+ Ok(Some(b)) => b,
412
+ _ => return Ok(None),
413
+ };
414
+
415
+ match extract_work_item_id_from_branch(&branch) {
416
+ Some(id) => {
417
+ // Verify the work item exists and is in_progress
418
+ let exists: bool = db
419
+ .conn()
420
+ .query_row(
421
+ "SELECT COUNT(*) > 0 FROM work_items WHERE id = ? AND status = 'in_progress'",
422
+ params![id],
423
+ |row| row.get(0),
424
+ )
425
+ .unwrap_or(false);
426
+
427
+ if exists {
428
+ Ok(Some(id))
429
+ } else {
430
+ Ok(None)
431
+ }
432
+ }
433
+ None => Ok(None),
434
+ }
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Tests
439
+ // ---------------------------------------------------------------------------
440
+
441
+ #[cfg(test)]
442
+ mod tests {
443
+ use super::*;
444
+ use crate::db::Database;
445
+ use tempfile::TempDir;
446
+
447
+ fn setup_db() -> (TempDir, Database) {
448
+ let dir = TempDir::new().unwrap();
449
+ let db_path = dir.path().join("work.db");
450
+ let db = Database::open_path_unchecked(&db_path).unwrap();
451
+ (dir, db)
452
+ }
453
+
454
+ #[test]
455
+ fn create_and_get_session() {
456
+ let (_dir, db) = setup_db();
457
+
458
+ let id = create_session(&db, "Test session", Some("My Title")).unwrap();
459
+ assert!(id > 0);
460
+
461
+ let session = get_session(&db, id).unwrap().unwrap();
462
+ assert_eq!(session.title, "Test session");
463
+ assert_eq!(session.session_title.as_deref(), Some("My Title"));
464
+ assert_eq!(session.status, SessionStatus::Active);
465
+ assert!(session.work_item_id.is_none());
466
+ }
467
+
468
+ #[test]
469
+ fn close_session_works() {
470
+ let (_dir, db) = setup_db();
471
+
472
+ let id = create_session(&db, "Test", None).unwrap();
473
+ assert!(close_session(&db, id).unwrap());
474
+
475
+ let session = get_session(&db, id).unwrap().unwrap();
476
+ assert_eq!(session.status, SessionStatus::Completed);
477
+ assert!(session.completed_at.is_some());
478
+ }
479
+
480
+ #[test]
481
+ fn link_session_to_work_item_works() {
482
+ let (_dir, db) = setup_db();
483
+
484
+ // Create a work item
485
+ db.conn()
486
+ .execute(
487
+ "INSERT INTO work_items (id, type, title, status) VALUES (1, 'chore', 'My chore', 'in_progress')",
488
+ [],
489
+ )
490
+ .unwrap();
491
+
492
+ let id = create_session(&db, "Unlinked", None).unwrap();
493
+ assert!(link_session_to_work_item(&db, id, 1, "Linked title").unwrap());
494
+
495
+ let session = get_session(&db, id).unwrap().unwrap();
496
+ assert_eq!(session.work_item_id, Some(1));
497
+ assert_eq!(session.title, "Linked title");
498
+ }
499
+
500
+ #[test]
501
+ fn get_or_create_session_reuses_existing() {
502
+ let (_dir, db) = setup_db();
503
+
504
+ db.conn()
505
+ .execute(
506
+ "INSERT INTO work_items (id, type, title, status) VALUES (1, 'chore', 'Chore', 'in_progress')",
507
+ [],
508
+ )
509
+ .unwrap();
510
+
511
+ let (s1, created1) = get_or_create_session(&db, 1, "Chore").unwrap();
512
+ assert!(created1);
513
+
514
+ let (s2, created2) = get_or_create_session(&db, 1, "Chore").unwrap();
515
+ assert!(!created2);
516
+ assert_eq!(s1.id, s2.id);
517
+ }
518
+
519
+ #[test]
520
+ fn count_and_get_all_active() {
521
+ let (_dir, db) = setup_db();
522
+
523
+ create_session(&db, "Session 1", None).unwrap();
524
+ create_session(&db, "Session 2", None).unwrap();
525
+ let id3 = create_session(&db, "Session 3", None).unwrap();
526
+ close_session(&db, id3).unwrap();
527
+
528
+ assert_eq!(count_active_sessions(&db).unwrap(), 2);
529
+ assert_eq!(get_all_active_sessions(&db).unwrap().len(), 2);
530
+ }
531
+
532
+ #[test]
533
+ fn session_content_roundtrip() {
534
+ let (_dir, db) = setup_db();
535
+
536
+ let id = create_session(&db, "Test", None).unwrap();
537
+ set_session_content(&db, id, "hello world").unwrap();
538
+
539
+ let session = get_session(&db, id).unwrap().unwrap();
540
+ assert_eq!(session.content.as_deref(), Some("hello world"));
541
+ }
542
+
543
+ #[test]
544
+ fn extract_work_item_id_from_branch_works() {
545
+ assert_eq!(
546
+ extract_work_item_id_from_branch("feature/work-1199-port-crud"),
547
+ Some(1199)
548
+ );
549
+ assert_eq!(
550
+ extract_work_item_id_from_branch("42-some-title"),
551
+ Some(42)
552
+ );
553
+ assert_eq!(extract_work_item_id_from_branch("main"), None);
554
+ assert_eq!(extract_work_item_id_from_branch("develop"), None);
555
+ }
556
+
557
+ #[test]
558
+ fn mode_to_skill_name_works() {
559
+ assert_eq!(mode_to_skill_name("speed"), "speed-mode");
560
+ assert_eq!(mode_to_skill_name("stable"), "stable-mode");
561
+ assert_eq!(mode_to_skill_name("production"), "production-mode");
562
+ assert_eq!(mode_to_skill_name("custom"), "custom-mode");
563
+ }
564
+
565
+ #[test]
566
+ fn session_file_write_and_read() {
567
+ let dir = TempDir::new().unwrap();
568
+
569
+ write_session_file(
570
+ dir.path(),
571
+ 42,
572
+ "My chore",
573
+ "chore",
574
+ "in_progress",
575
+ "speed",
576
+ Some(10),
577
+ Some("Epic title"),
578
+ )
579
+ .unwrap();
580
+
581
+ let content = read_session_file(dir.path()).unwrap().unwrap();
582
+ assert!(content.contains("[#42] My chore"));
583
+ assert!(content.contains("Mode: speed"));
584
+ assert!(content.contains("Epic: [#10] Epic title"));
585
+ assert!(content.contains("speed-mode"));
586
+ }
587
+
588
+ #[test]
589
+ fn session_file_clear() {
590
+ let dir = TempDir::new().unwrap();
591
+
592
+ write_session_file(dir.path(), 1, "Test", "chore", "in_progress", "speed", None, None)
593
+ .unwrap();
594
+ assert!(read_session_file(dir.path()).unwrap().is_some());
595
+
596
+ clear_session_file(dir.path()).unwrap();
597
+ assert!(read_session_file(dir.path()).unwrap().is_none());
598
+ }
599
+
600
+ #[test]
601
+ fn worktree_session_upsert_and_get() {
602
+ let (_dir, db) = setup_db();
603
+
604
+ db.conn()
605
+ .execute(
606
+ "INSERT INTO work_items (id, type, title, status) VALUES (1, 'chore', 'Chore', 'in_progress')",
607
+ [],
608
+ )
609
+ .unwrap();
610
+
611
+ upsert_worktree_session(&db, "/tmp/wt-1", 1, "feature/work-1-chore").unwrap();
612
+
613
+ let ws = get_worktree_session(&db, "/tmp/wt-1").unwrap().unwrap();
614
+ assert_eq!(ws.work_item_id, 1);
615
+ assert_eq!(ws.branch_name, "feature/work-1-chore");
616
+
617
+ // Upsert again should update
618
+ upsert_worktree_session(&db, "/tmp/wt-1", 1, "feature/work-1-updated").unwrap();
619
+ let ws = get_worktree_session(&db, "/tmp/wt-1").unwrap().unwrap();
620
+ assert_eq!(ws.branch_name, "feature/work-1-updated");
621
+
622
+ remove_worktree_session(&db, "/tmp/wt-1").unwrap();
623
+ assert!(get_worktree_session(&db, "/tmp/wt-1").unwrap().is_none());
624
+ }
625
+ }