handoff-mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ use std::path::Path;
2
+ use std::process::Command;
3
+
4
+ use anyhow::Result;
5
+
6
+ #[derive(Debug, Clone)]
7
+ pub struct GitState {
8
+ pub branch: String,
9
+ pub commit: String,
10
+ pub dirty_files: Vec<String>,
11
+ }
12
+
13
+ pub fn capture_git_state(project_dir: &Path) -> Result<GitState> {
14
+ let branch = run_git(project_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
15
+ .unwrap_or_else(|_| "unknown".to_string());
16
+
17
+ let commit = run_git(project_dir, &["rev-parse", "--short", "HEAD"])
18
+ .unwrap_or_else(|_| "unknown".to_string());
19
+
20
+ let dirty_output = run_git(project_dir, &["status", "--porcelain"]).unwrap_or_default();
21
+
22
+ let dirty_files: Vec<String> = dirty_output
23
+ .lines()
24
+ .filter(|l| !l.is_empty())
25
+ .map(|l| l.to_string())
26
+ .collect();
27
+
28
+ Ok(GitState {
29
+ branch,
30
+ commit,
31
+ dirty_files,
32
+ })
33
+ }
34
+
35
+ fn run_git(dir: &Path, args: &[&str]) -> Result<String> {
36
+ let output = Command::new("git").args(args).current_dir(dir).output()?;
37
+
38
+ if !output.status.success() {
39
+ anyhow::bail!(
40
+ "git {} failed: {}",
41
+ args.join(" "),
42
+ String::from_utf8_lossy(&output.stderr)
43
+ );
44
+ }
45
+
46
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
47
+ }
@@ -0,0 +1,41 @@
1
+ pub mod config;
2
+ pub mod git;
3
+ pub mod sessions;
4
+ pub mod tasks;
5
+
6
+ use std::path::{Path, PathBuf};
7
+
8
+ use anyhow::{Context, Result};
9
+
10
+ pub fn handoff_dir(project_dir: &Path) -> PathBuf {
11
+ project_dir.join(".handoff")
12
+ }
13
+
14
+ pub fn ensure_handoff_exists(project_dir: &Path) -> Result<PathBuf> {
15
+ let dir = handoff_dir(project_dir);
16
+ if !dir.exists() {
17
+ anyhow::bail!(
18
+ ".handoff/ directory not found in {}. Run handoff_init first.",
19
+ project_dir.display()
20
+ );
21
+ }
22
+ Ok(dir)
23
+ }
24
+
25
+ pub fn init_handoff(project_dir: &Path, project_name: &str, description: &str) -> Result<()> {
26
+ let dir = handoff_dir(project_dir);
27
+ if dir.exists() {
28
+ anyhow::bail!(
29
+ ".handoff/ already exists in {}. Project is already initialized.",
30
+ project_dir.display()
31
+ );
32
+ }
33
+
34
+ std::fs::create_dir_all(dir.join("sessions")).context("Failed to create .handoff/sessions/")?;
35
+ std::fs::create_dir_all(dir.join("tasks")).context("Failed to create .handoff/tasks/")?;
36
+
37
+ let config = config::Config::new(project_name, description);
38
+ config::write_config(&dir.join("config.toml"), &config)?;
39
+
40
+ Ok(())
41
+ }
@@ -0,0 +1,167 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use anyhow::{Context, Result};
4
+ use serde::{Deserialize, Serialize};
5
+ use serde_json::Value;
6
+
7
+ #[derive(Debug, Clone, Serialize, Deserialize)]
8
+ pub struct SessionData {
9
+ pub version: u32,
10
+ #[serde(skip_serializing_if = "Option::is_none")]
11
+ pub ended_at: Option<String>,
12
+ pub summary: String,
13
+ #[serde(skip_serializing_if = "Option::is_none")]
14
+ pub branch: Option<String>,
15
+ #[serde(skip_serializing_if = "Option::is_none")]
16
+ pub commit: Option<String>,
17
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
18
+ pub dirty_files: Vec<String>,
19
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
20
+ pub decisions: Vec<Value>,
21
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
22
+ pub blockers: Vec<String>,
23
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
24
+ pub checklist: Vec<Value>,
25
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
26
+ pub handoff_notes: Vec<Value>,
27
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
28
+ pub references: Vec<Value>,
29
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
30
+ pub context_pointers: Vec<Value>,
31
+ #[serde(default, skip_serializing_if = "Option::is_none")]
32
+ pub environment: Option<Value>,
33
+ }
34
+
35
+ pub fn generate_session_filename(summary: &str, timestamp: &str) -> String {
36
+ let slug = summary_to_slug(summary);
37
+ format!("{timestamp}-{slug}")
38
+ }
39
+
40
+ fn summary_to_slug(summary: &str) -> String {
41
+ let slug: String = summary
42
+ .to_lowercase()
43
+ .chars()
44
+ .take(40)
45
+ .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
46
+ .collect();
47
+ let slug = slug.trim_matches('-').to_string();
48
+ let mut result = String::new();
49
+ let mut prev_dash = false;
50
+ for c in slug.chars() {
51
+ if c == '-' {
52
+ if !prev_dash {
53
+ result.push(c);
54
+ }
55
+ prev_dash = true;
56
+ } else {
57
+ result.push(c);
58
+ prev_dash = false;
59
+ }
60
+ }
61
+ if result.is_empty() {
62
+ "session".to_string()
63
+ } else {
64
+ result
65
+ }
66
+ }
67
+
68
+ pub fn write_active_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
69
+ let timestamp = data.ended_at.as_deref().unwrap_or("00000000-000000");
70
+ let ts_compact = timestamp
71
+ .replace(['-', ':'], "")
72
+ .replace('T', "-")
73
+ .replace('Z', "");
74
+ let ts_part = if ts_compact.len() >= 15 {
75
+ &ts_compact[..15]
76
+ } else {
77
+ &ts_compact
78
+ };
79
+
80
+ let base = generate_session_filename(&data.summary, ts_part);
81
+ let filename = format!("{base}.active.json");
82
+ let path = sessions_dir.join(&filename);
83
+
84
+ let content = serde_json::to_string_pretty(data).context("Failed to serialize session")?;
85
+ std::fs::write(&path, content)
86
+ .with_context(|| format!("Failed to write session: {}", path.display()))?;
87
+
88
+ Ok(path)
89
+ }
90
+
91
+ pub fn close_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
92
+ let mut closed = Vec::new();
93
+
94
+ if !sessions_dir.exists() {
95
+ return Ok(closed);
96
+ }
97
+
98
+ for entry in std::fs::read_dir(sessions_dir)? {
99
+ let entry = entry?;
100
+ let name = entry.file_name().to_string_lossy().to_string();
101
+ if name.ends_with(".active.json") {
102
+ let new_name = name.replace(".active.json", ".closed.json");
103
+ let new_path = sessions_dir.join(&new_name);
104
+ std::fs::rename(entry.path(), &new_path)
105
+ .with_context(|| format!("Failed to close session: {}", entry.path().display()))?;
106
+ closed.push(new_path);
107
+ }
108
+ }
109
+
110
+ Ok(closed)
111
+ }
112
+
113
+ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
114
+ let mut sessions = Vec::new();
115
+
116
+ if !sessions_dir.exists() {
117
+ return Ok(sessions);
118
+ }
119
+
120
+ let mut entries: Vec<_> = std::fs::read_dir(sessions_dir)?
121
+ .filter_map(|e| e.ok())
122
+ .collect();
123
+ entries.sort_by_key(|e| e.file_name());
124
+
125
+ for entry in entries {
126
+ let name = entry.file_name().to_string_lossy().to_string();
127
+ if name.ends_with(".active.json") {
128
+ let content = std::fs::read_to_string(entry.path())
129
+ .with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
130
+ let data: SessionData = serde_json::from_str(&content)
131
+ .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
132
+ sessions.push(data);
133
+ }
134
+ }
135
+
136
+ Ok(sessions)
137
+ }
138
+
139
+ pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
140
+ if !sessions_dir.exists() {
141
+ return Ok(0);
142
+ }
143
+
144
+ let mut closed_files: Vec<PathBuf> = Vec::new();
145
+
146
+ for entry in std::fs::read_dir(sessions_dir)? {
147
+ let entry = entry?;
148
+ let name = entry.file_name().to_string_lossy().to_string();
149
+ if name.ends_with(".closed.json") {
150
+ closed_files.push(entry.path());
151
+ }
152
+ }
153
+
154
+ closed_files.sort();
155
+
156
+ let mut removed = 0u32;
157
+ while closed_files.len() > limit as usize {
158
+ if let Some(oldest) = closed_files.first() {
159
+ std::fs::remove_file(oldest)
160
+ .with_context(|| format!("Failed to remove old session: {}", oldest.display()))?;
161
+ closed_files.remove(0);
162
+ removed += 1;
163
+ }
164
+ }
165
+
166
+ Ok(removed)
167
+ }
@@ -0,0 +1,365 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use anyhow::{Context, Result};
4
+ use serde::{Deserialize, Serialize};
5
+
6
+ #[derive(Debug, Clone, Serialize, Deserialize)]
7
+ pub struct TaskData {
8
+ pub id: String,
9
+ pub title: String,
10
+ #[serde(default, skip_serializing_if = "Option::is_none")]
11
+ pub notes: Option<String>,
12
+ #[serde(default, skip_serializing_if = "Option::is_none")]
13
+ pub priority: Option<String>,
14
+ #[serde(default, skip_serializing_if = "Option::is_none")]
15
+ pub created_at: Option<String>,
16
+ #[serde(default, skip_serializing_if = "Option::is_none")]
17
+ pub updated_at: Option<String>,
18
+ #[serde(default, skip_serializing_if = "Option::is_none")]
19
+ pub completed_at: Option<String>,
20
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
21
+ pub labels: Vec<String>,
22
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
23
+ pub links: Vec<String>,
24
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
25
+ pub done_criteria: Vec<DoneCriterion>,
26
+ }
27
+
28
+ #[derive(Debug, Clone, Serialize, Deserialize)]
29
+ pub struct DoneCriterion {
30
+ pub item: String,
31
+ #[serde(default)]
32
+ pub checked: bool,
33
+ }
34
+
35
+ #[derive(Debug, Clone, Serialize, Deserialize)]
36
+ pub struct TaskIndex {
37
+ pub id: String,
38
+ pub title: String,
39
+ pub status: String,
40
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
41
+ pub children: Vec<TaskIndex>,
42
+ }
43
+
44
+ #[derive(Debug, Clone, Serialize, Deserialize)]
45
+ pub struct TaskSummary {
46
+ pub total: u32,
47
+ pub by_status: std::collections::HashMap<String, u32>,
48
+ }
49
+
50
+ const VALID_STATUSES: &[&str] = &[
51
+ "todo",
52
+ "in_progress",
53
+ "review",
54
+ "done",
55
+ "blocked",
56
+ "skipped",
57
+ ];
58
+
59
+ pub fn is_valid_status(status: &str) -> bool {
60
+ VALID_STATUSES.contains(&status)
61
+ }
62
+
63
+ pub fn is_terminal_status(status: &str) -> bool {
64
+ status == "done" || status == "skipped"
65
+ }
66
+
67
+ pub fn title_to_slug(title: &str) -> String {
68
+ let slug: String = title
69
+ .to_lowercase()
70
+ .chars()
71
+ .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
72
+ .collect();
73
+ let slug = slug.trim_matches('-').to_string();
74
+ let mut result = String::new();
75
+ let mut prev_dash = false;
76
+ for c in slug.chars() {
77
+ if c == '-' {
78
+ if !prev_dash {
79
+ result.push(c);
80
+ }
81
+ prev_dash = true;
82
+ } else {
83
+ result.push(c);
84
+ prev_dash = false;
85
+ }
86
+ }
87
+ if result.is_empty() {
88
+ "task".to_string()
89
+ } else {
90
+ result
91
+ }
92
+ }
93
+
94
+ pub fn find_task_file(task_dir: &Path) -> Result<Option<(PathBuf, String)>> {
95
+ for entry in std::fs::read_dir(task_dir)
96
+ .with_context(|| format!("Failed to read task dir: {}", task_dir.display()))?
97
+ {
98
+ let entry = entry?;
99
+ let name = entry.file_name().to_string_lossy().to_string();
100
+ if let Some(status) = parse_task_filename(&name) {
101
+ return Ok(Some((entry.path(), status)));
102
+ }
103
+ }
104
+ Ok(None)
105
+ }
106
+
107
+ fn parse_task_filename(name: &str) -> Option<String> {
108
+ let name = name.strip_prefix("_task.")?;
109
+ let status = name.strip_suffix(".json")?;
110
+ if is_valid_status(status) {
111
+ Some(status.to_string())
112
+ } else {
113
+ None
114
+ }
115
+ }
116
+
117
+ pub fn read_task(task_dir: &Path) -> Result<Option<(TaskData, String)>> {
118
+ let (file_path, status) = match find_task_file(task_dir)? {
119
+ Some(v) => v,
120
+ None => return Ok(None),
121
+ };
122
+ let content = std::fs::read_to_string(&file_path)
123
+ .with_context(|| format!("Failed to read task: {}", file_path.display()))?;
124
+ let data: TaskData = serde_json::from_str(&content)
125
+ .with_context(|| format!("Failed to parse task: {}", file_path.display()))?;
126
+ Ok(Some((data, status)))
127
+ }
128
+
129
+ pub fn write_task(task_dir: &Path, status: &str, data: &TaskData) -> Result<()> {
130
+ let file_path = task_dir.join(format!("_task.{status}.json"));
131
+ let content = serde_json::to_string_pretty(data).context("Failed to serialize task")?;
132
+ std::fs::write(&file_path, content)
133
+ .with_context(|| format!("Failed to write task: {}", file_path.display()))?;
134
+ Ok(())
135
+ }
136
+
137
+ pub fn change_status(task_dir: &Path, new_status: &str) -> Result<()> {
138
+ if !is_valid_status(new_status) {
139
+ anyhow::bail!("Invalid status: {new_status}");
140
+ }
141
+
142
+ let (old_path, old_status) = find_task_file(task_dir)?
143
+ .ok_or_else(|| anyhow::anyhow!("No task file found in {}", task_dir.display()))?;
144
+
145
+ if old_status == new_status {
146
+ return Ok(());
147
+ }
148
+
149
+ let new_path = task_dir.join(format!("_task.{new_status}.json"));
150
+ std::fs::rename(&old_path, &new_path).with_context(|| {
151
+ format!(
152
+ "Failed to rename {} -> {}",
153
+ old_path.display(),
154
+ new_path.display()
155
+ )
156
+ })?;
157
+
158
+ Ok(())
159
+ }
160
+
161
+ pub fn next_child_id(parent_dir: &Path, parent_id: &str) -> Result<String> {
162
+ let mut max_n: u32 = 0;
163
+
164
+ if parent_dir.exists() {
165
+ for entry in std::fs::read_dir(parent_dir)? {
166
+ let entry = entry?;
167
+ if !entry.file_type()?.is_dir() {
168
+ continue;
169
+ }
170
+ let name = entry.file_name().to_string_lossy().to_string();
171
+ if let Some(n) = extract_child_number(&name, parent_id) {
172
+ max_n = max_n.max(n);
173
+ }
174
+ }
175
+ }
176
+
177
+ Ok(format!("{parent_id}.{}", max_n + 1))
178
+ }
179
+
180
+ pub fn next_top_level_id(tasks_dir: &Path) -> Result<String> {
181
+ let mut max_n: u32 = 0;
182
+
183
+ if tasks_dir.exists() {
184
+ for entry in std::fs::read_dir(tasks_dir)? {
185
+ let entry = entry?;
186
+ if !entry.file_type()?.is_dir() {
187
+ continue;
188
+ }
189
+ let name = entry.file_name().to_string_lossy().to_string();
190
+ if let Some(n) = extract_top_level_number(&name) {
191
+ max_n = max_n.max(n);
192
+ }
193
+ }
194
+ }
195
+
196
+ Ok(format!("t{}", max_n + 1))
197
+ }
198
+
199
+ fn extract_child_number(dir_name: &str, parent_id: &str) -> Option<u32> {
200
+ let prefix = format!("{parent_id}.");
201
+ let rest = dir_name.strip_prefix(&prefix)?;
202
+ let num_part = rest.split('-').next()?;
203
+ num_part.parse().ok()
204
+ }
205
+
206
+ fn extract_top_level_number(dir_name: &str) -> Option<u32> {
207
+ let rest = dir_name.strip_prefix('t')?;
208
+ let num_part = rest.split('-').next()?;
209
+ if num_part.contains('.') {
210
+ return None;
211
+ }
212
+ num_part.parse().ok()
213
+ }
214
+
215
+ pub fn find_task_dir_by_id(tasks_dir: &Path, task_id: &str) -> Result<Option<PathBuf>> {
216
+ find_task_dir_recursive(tasks_dir, task_id)
217
+ }
218
+
219
+ fn find_task_dir_recursive(dir: &Path, task_id: &str) -> Result<Option<PathBuf>> {
220
+ if !dir.exists() {
221
+ return Ok(None);
222
+ }
223
+ for entry in std::fs::read_dir(dir)? {
224
+ let entry = entry?;
225
+ if !entry.file_type()?.is_dir() {
226
+ continue;
227
+ }
228
+ let name = entry.file_name().to_string_lossy().to_string();
229
+ let entry_id = name.split('-').next().unwrap_or("");
230
+ if entry_id == task_id {
231
+ return Ok(Some(entry.path()));
232
+ }
233
+ if let Some(found) = find_task_dir_recursive(&entry.path(), task_id)? {
234
+ return Ok(Some(found));
235
+ }
236
+ }
237
+ Ok(None)
238
+ }
239
+
240
+ pub fn build_task_index(
241
+ tasks_dir: &Path,
242
+ done_task_limit: u32,
243
+ ) -> Result<(Vec<TaskIndex>, TaskSummary)> {
244
+ let mut tree = Vec::new();
245
+ let mut summary = TaskSummary {
246
+ total: 0,
247
+ by_status: std::collections::HashMap::new(),
248
+ };
249
+ let mut done_count: u32 = 0;
250
+
251
+ build_index_recursive(
252
+ tasks_dir,
253
+ &mut tree,
254
+ &mut summary,
255
+ &mut done_count,
256
+ done_task_limit,
257
+ )?;
258
+
259
+ Ok((tree, summary))
260
+ }
261
+
262
+ fn build_index_recursive(
263
+ dir: &Path,
264
+ tree: &mut Vec<TaskIndex>,
265
+ summary: &mut TaskSummary,
266
+ done_count: &mut u32,
267
+ done_task_limit: u32,
268
+ ) -> Result<()> {
269
+ if !dir.exists() {
270
+ return Ok(());
271
+ }
272
+
273
+ let mut entries: Vec<_> = std::fs::read_dir(dir)?
274
+ .filter_map(|e| e.ok())
275
+ .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
276
+ .collect();
277
+ entries.sort_by_key(|e| e.file_name());
278
+
279
+ for entry in entries {
280
+ let name = entry.file_name().to_string_lossy().to_string();
281
+ if name.starts_with('.') {
282
+ continue;
283
+ }
284
+
285
+ let task_dir = entry.path();
286
+ let (data, status) = match read_task(&task_dir)? {
287
+ Some(v) => v,
288
+ None => continue,
289
+ };
290
+
291
+ summary.total += 1;
292
+ *summary.by_status.entry(status.clone()).or_insert(0) += 1;
293
+
294
+ if is_terminal_status(&status) {
295
+ *done_count += 1;
296
+ if *done_count > done_task_limit {
297
+ continue;
298
+ }
299
+ }
300
+
301
+ let mut children = Vec::new();
302
+ build_index_recursive(
303
+ &task_dir,
304
+ &mut children,
305
+ summary,
306
+ done_count,
307
+ done_task_limit,
308
+ )?;
309
+
310
+ tree.push(TaskIndex {
311
+ id: data.id,
312
+ title: data.title,
313
+ status,
314
+ children,
315
+ });
316
+ }
317
+
318
+ Ok(())
319
+ }
320
+
321
+ pub fn validate_done_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
322
+ for criterion in &data.done_criteria {
323
+ if !criterion.checked {
324
+ anyhow::bail!(
325
+ "Cannot mark task {} as done: done_criteria item '{}' is not checked",
326
+ data.id,
327
+ criterion.item
328
+ );
329
+ }
330
+ }
331
+
332
+ check_children_terminal(task_dir, &data.id)?;
333
+
334
+ Ok(())
335
+ }
336
+
337
+ pub fn validate_skipped_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
338
+ check_children_terminal(task_dir, &data.id)?;
339
+ Ok(())
340
+ }
341
+
342
+ fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
343
+ if !task_dir.exists() {
344
+ return Ok(());
345
+ }
346
+ for entry in std::fs::read_dir(task_dir)? {
347
+ let entry = entry?;
348
+ if !entry.file_type()?.is_dir() {
349
+ continue;
350
+ }
351
+ let name = entry.file_name().to_string_lossy().to_string();
352
+ if name.starts_with('_') || name.starts_with('.') {
353
+ continue;
354
+ }
355
+ if let Some((_, status)) = find_task_file(&entry.path())? {
356
+ if !is_terminal_status(&status) {
357
+ anyhow::bail!(
358
+ "Cannot mark task {parent_id} as done/skipped: child task in directory '{}' has status '{status}'",
359
+ name
360
+ );
361
+ }
362
+ }
363
+ }
364
+ Ok(())
365
+ }