handoff-mcp-server 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/Cargo.lock +0 -686
  6. package/Cargo.toml +0 -30
  7. package/scripts/cargo-env.sh +0 -29
  8. package/scripts/handoff-memory-hook.py +0 -208
  9. package/scripts/install-local.sh +0 -109
  10. package/scripts/postinstall.js +0 -50
  11. package/scripts/sync-plugin-skills.sh +0 -35
  12. package/scripts/sync-plugin-version.sh +0 -85
  13. package/scripts/sync-workflow-inline.sh +0 -138
  14. package/src/cli.rs +0 -551
  15. package/src/context/injection.rs +0 -276
  16. package/src/context/mod.rs +0 -129
  17. package/src/lib.rs +0 -5
  18. package/src/main.rs +0 -157
  19. package/src/mcp/handlers/assignees.rs +0 -254
  20. package/src/mcp/handlers/auto_schedule.rs +0 -489
  21. package/src/mcp/handlers/bulk_update.rs +0 -155
  22. package/src/mcp/handlers/calendar.rs +0 -196
  23. package/src/mcp/handlers/capacity.rs +0 -318
  24. package/src/mcp/handlers/check_criterion.rs +0 -70
  25. package/src/mcp/handlers/config.rs +0 -402
  26. package/src/mcp/handlers/config_crud.rs +0 -183
  27. package/src/mcp/handlers/dashboard.rs +0 -214
  28. package/src/mcp/handlers/docs.rs +0 -2288
  29. package/src/mcp/handlers/docs_query.rs +0 -1335
  30. package/src/mcp/handlers/fork_session.rs +0 -91
  31. package/src/mcp/handlers/get_session.rs +0 -48
  32. package/src/mcp/handlers/get_task.rs +0 -53
  33. package/src/mcp/handlers/import_context.rs +0 -470
  34. package/src/mcp/handlers/init.rs +0 -28
  35. package/src/mcp/handlers/list_sessions.rs +0 -187
  36. package/src/mcp/handlers/list_tasks.rs +0 -308
  37. package/src/mcp/handlers/load_context.rs +0 -361
  38. package/src/mcp/handlers/log_time.rs +0 -67
  39. package/src/mcp/handlers/memory.rs +0 -961
  40. package/src/mcp/handlers/merge_sessions.rs +0 -103
  41. package/src/mcp/handlers/metrics.rs +0 -196
  42. package/src/mcp/handlers/milestones.rs +0 -102
  43. package/src/mcp/handlers/mod.rs +0 -140
  44. package/src/mcp/handlers/refer.rs +0 -307
  45. package/src/mcp/handlers/referrals.rs +0 -74
  46. package/src/mcp/handlers/save_context.rs +0 -354
  47. package/src/mcp/handlers/task_checklist.rs +0 -507
  48. package/src/mcp/handlers/timer.rs +0 -529
  49. package/src/mcp/handlers/update_session.rs +0 -197
  50. package/src/mcp/handlers/update_task.rs +0 -452
  51. package/src/mcp/mod.rs +0 -6
  52. package/src/mcp/protocol.rs +0 -41
  53. package/src/mcp/resources.rs +0 -57
  54. package/src/mcp/router.rs +0 -154
  55. package/src/mcp/tools.rs +0 -1522
  56. package/src/mcp/types.rs +0 -108
  57. package/src/setup.rs +0 -1212
  58. package/src/storage/config.rs +0 -578
  59. package/src/storage/docs/frontmatter.rs +0 -509
  60. package/src/storage/docs/mod.rs +0 -835
  61. package/src/storage/docs/model.rs +0 -708
  62. package/src/storage/docs/reassemble.rs +0 -167
  63. package/src/storage/docs/split.rs +0 -377
  64. package/src/storage/git.rs +0 -47
  65. package/src/storage/memory/injected.rs +0 -340
  66. package/src/storage/memory/mod.rs +0 -236
  67. package/src/storage/memory/model.rs +0 -127
  68. package/src/storage/mod.rs +0 -96
  69. package/src/storage/referrals.rs +0 -248
  70. package/src/storage/sessions.rs +0 -859
  71. package/src/storage/tasks.rs +0 -957
  72. package/templates/claude-md-section.md +0 -12
@@ -1,91 +0,0 @@
1
- use anyhow::Result;
2
- use serde_json::{json, Value};
3
-
4
- use super::resolve_project_dir;
5
- use crate::storage::ensure_handoff_exists;
6
- use crate::storage::sessions::{fork_session, read_session_by_id};
7
-
8
- pub fn handle(arguments: &Value) -> Result<String> {
9
- let project_dir = resolve_project_dir(arguments)?;
10
- let handoff = ensure_handoff_exists(&project_dir)?;
11
- let sessions_dir = handoff.join("sessions");
12
-
13
- let source_session_id = arguments
14
- .get("source_session_id")
15
- .and_then(|v| v.as_str())
16
- .ok_or_else(|| anyhow::anyhow!("'source_session_id' parameter is required"))?;
17
-
18
- let summary = arguments
19
- .get("summary")
20
- .and_then(|v| v.as_str())
21
- .ok_or_else(|| anyhow::anyhow!("'summary' parameter is required"))?;
22
-
23
- let label = arguments.get("label").and_then(|v| v.as_str());
24
- let timeline = arguments.get("timeline").and_then(|v| v.as_str());
25
-
26
- let related_task_ids: Vec<String> = arguments
27
- .get("related_task_ids")
28
- .and_then(|v| v.as_array())
29
- .map(|arr| {
30
- arr.iter()
31
- .filter_map(|v| v.as_str().map(String::from))
32
- .collect()
33
- })
34
- .unwrap_or_default();
35
-
36
- let default_inherit = vec![
37
- "decisions",
38
- "context_pointers",
39
- "references",
40
- "handoff_notes",
41
- "environment",
42
- ];
43
- let inherit: Vec<&str> = arguments
44
- .get("inherit")
45
- .and_then(|v| v.as_array())
46
- .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
47
- .unwrap_or(default_inherit);
48
-
49
- let source = read_session_by_id(&sessions_dir, source_session_id)?.ok_or_else(|| {
50
- anyhow::anyhow!(
51
- "Source session not found: {source_session_id}. \
52
- Use handoff_list_sessions to see available session IDs."
53
- )
54
- })?;
55
-
56
- let forked = fork_session(
57
- &sessions_dir,
58
- &source,
59
- summary,
60
- label,
61
- timeline,
62
- related_task_ids,
63
- &inherit,
64
- )?;
65
-
66
- let forked_id = forked.id.as_deref().unwrap_or("unknown");
67
-
68
- let result = json!({
69
- "session_id": forked_id,
70
- "parent_session_id": source_session_id,
71
- "status": "active",
72
- "inherited": inherit,
73
- });
74
-
75
- let mut output = format!(
76
- "Forked session: {}\nSession ID: {}\nParent: {}\nStatus: active\nInherited: {}",
77
- summary,
78
- forked_id,
79
- source_session_id,
80
- inherit.join(", "),
81
- );
82
- if let Some(tl) = forked.timeline.as_deref() {
83
- output.push_str(&format!("\nTimeline: {tl}"));
84
- }
85
- if let Some(lbl) = forked.label.as_deref() {
86
- output.push_str(&format!("\nLabel: {lbl}"));
87
- }
88
- output.push_str(&format!("\n\n{}", serde_json::to_string_pretty(&result)?));
89
-
90
- Ok(output)
91
- }
@@ -1,48 +0,0 @@
1
- use anyhow::{Context, Result};
2
- use serde_json::Value;
3
-
4
- use super::resolve_project_dir;
5
- use crate::storage::ensure_handoff_exists;
6
-
7
- pub fn handle(arguments: &Value) -> Result<String> {
8
- let project_dir = resolve_project_dir(arguments)?;
9
- let handoff = ensure_handoff_exists(&project_dir)?;
10
- let sessions_dir = handoff.join("sessions");
11
-
12
- let session_id = arguments
13
- .get("session_id")
14
- .and_then(|v| v.as_str())
15
- .ok_or_else(|| anyhow::anyhow!("'session_id' parameter is required"))?;
16
-
17
- if !sessions_dir.exists() {
18
- anyhow::bail!("Sessions directory not found");
19
- }
20
-
21
- for entry in std::fs::read_dir(&sessions_dir)
22
- .with_context(|| format!("Failed to read sessions dir: {}", sessions_dir.display()))?
23
- {
24
- let entry = entry?;
25
- let name = entry.file_name().to_string_lossy().to_string();
26
- if !name.ends_with(".json") {
27
- continue;
28
- }
29
-
30
- let content = match std::fs::read_to_string(entry.path()) {
31
- Ok(c) => c,
32
- Err(_) => continue,
33
- };
34
- let data: Value = match serde_json::from_str(&content) {
35
- Ok(d) => d,
36
- Err(_) => continue,
37
- };
38
-
39
- let file_id = data.get("id").and_then(|v| v.as_str()).unwrap_or("");
40
- if file_id == session_id || name.contains(session_id) {
41
- return serde_json::to_string_pretty(&data).map_err(Into::into);
42
- }
43
- }
44
-
45
- anyhow::bail!(
46
- "Session not found: {session_id}. Use handoff_list_sessions to see available session IDs."
47
- )
48
- }
@@ -1,53 +0,0 @@
1
- use anyhow::Result;
2
- use serde_json::Value;
3
-
4
- use super::resolve_project_dir;
5
- use crate::storage::ensure_handoff_exists;
6
- use crate::storage::tasks::{find_task_dir_by_id, read_task, suggest_task_id};
7
-
8
- pub fn handle(arguments: &Value) -> Result<String> {
9
- let project_dir = resolve_project_dir(arguments)?;
10
-
11
- let handoff = ensure_handoff_exists(&project_dir)?;
12
- let tasks_dir = handoff.join("tasks");
13
-
14
- let task_id = arguments
15
- .get("task_id")
16
- .and_then(|v| v.as_str())
17
- .ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
18
-
19
- let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
20
- .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
21
-
22
- let (data, status) = read_task(&task_dir)?
23
- .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
24
-
25
- // `links` stays the legacy `Vec<String>` for backward compatibility with
26
- // existing clients (skills / VSCode extension). `task_links` is an
27
- // additive field carrying the normalized, deduplicated view from the
28
- // `links()` accessor (wiki/130-document-management.md §9.1), so callers
29
- // that understand typed links (doc/url/file/task) can read them without
30
- // re-deriving the merge themselves.
31
- let normalized_links = data.links();
32
-
33
- let result = serde_json::json!({
34
- "id": data.id,
35
- "title": data.title,
36
- "status": status,
37
- "notes": data.notes,
38
- "priority": data.priority,
39
- "created_at": data.created_at,
40
- "updated_at": data.updated_at,
41
- "completed_at": data.completed_at,
42
- "labels": data.labels,
43
- "links": data.links,
44
- "task_links": normalized_links,
45
- "done_criteria": data.done_criteria,
46
- "schedule": data.schedule,
47
- "dependencies": data.dependencies,
48
- "order": data.order,
49
- "assignee": data.assignee,
50
- });
51
-
52
- serde_json::to_string_pretty(&result).map_err(Into::into)
53
- }
@@ -1,470 +0,0 @@
1
- use anyhow::{Context, Result};
2
- use chrono::Utc;
3
- use serde_json::Value;
4
-
5
- use super::resolve_project_dir;
6
- use crate::storage::config::read_config;
7
- use crate::storage::ensure_handoff_exists;
8
- use crate::storage::git::capture_git_state;
9
- use crate::storage::sessions::{
10
- close_active_sessions, enforce_history_limit, write_open_session, SessionData,
11
- };
12
- use crate::storage::tasks::*;
13
-
14
- pub fn handle(arguments: &Value) -> Result<String> {
15
- let project_dir = resolve_project_dir(arguments)?;
16
- let handoff = ensure_handoff_exists(&project_dir)?;
17
- let tasks_dir = handoff.join("tasks");
18
- let sessions_dir = handoff.join("sessions");
19
- let config_path = handoff.join("config.toml");
20
-
21
- let source = arguments
22
- .get("source")
23
- .ok_or_else(|| anyhow::anyhow!("'source' parameter is required"))?;
24
- let source_description = source
25
- .get("description")
26
- .and_then(|v| v.as_str())
27
- .ok_or_else(|| anyhow::anyhow!("'source.description' is required"))?;
28
- let source_format = source
29
- .get("format")
30
- .and_then(|v| v.as_str())
31
- .unwrap_or("other");
32
-
33
- let require_estimate_hours = read_config(&config_path)
34
- .map(|c| c.settings.require_estimate_hours)
35
- .unwrap_or(true);
36
-
37
- let mut tasks_created: u32 = 0;
38
- let mut top_level_count: u32 = 0;
39
- let mut nested_count: u32 = 0;
40
-
41
- if let Some(tasks) = arguments.get("tasks").and_then(|v| v.as_array()) {
42
- // Validate the whole payload before creating anything. Import writes many
43
- // tasks in one call, so a rejection discovered mid-tree would otherwise
44
- // leave a half-written forest behind and burn task IDs —
45
- // `next_top_level_id` counts directories, not task files.
46
- //
47
- // Nothing is created yet, so `next_top_level_id` keeps returning the same
48
- // value; project the IDs the writing pass below will assign, purely so the
49
- // rejection names the task the caller sent.
50
- let first_id = next_top_level_id(&tasks_dir)?;
51
- let first_n: u32 = first_id
52
- .strip_prefix('t')
53
- .and_then(|n| n.parse().ok())
54
- .with_context(|| format!("Unexpected task id form: {first_id}"))?;
55
- let mut pending_deps: Vec<(String, Vec<String>)> = Vec::new();
56
- for (i, task_val) in tasks.iter().enumerate() {
57
- let projected_id = format!("t{}", first_n + i as u32);
58
- validate_task_recursive(
59
- &projected_id,
60
- task_val,
61
- require_estimate_hours,
62
- &mut pending_deps,
63
- )?;
64
- }
65
-
66
- // Check the dependency graph once, with every task in this payload already
67
- // in it. Task-at-a-time checking would both reject a valid dependency on a
68
- // sibling still unwritten and miss a cycle confined to the payload.
69
- validate_dependencies_batch(&tasks_dir, &pending_deps)?;
70
-
71
- for task_val in tasks {
72
- let title = task_title(task_val)?;
73
-
74
- let task_id = next_top_level_id(&tasks_dir)?;
75
- let count = create_task_recursive(&tasks_dir, &task_id, None, title, task_val)?;
76
- tasks_created += count;
77
- top_level_count += 1;
78
- nested_count += count - 1;
79
- }
80
- }
81
-
82
- let skip_session_close = arguments
83
- .get("skip_session_close")
84
- .and_then(|v| v.as_bool())
85
- .unwrap_or(false);
86
-
87
- let mut session_saved = false;
88
- if let Some(session) = arguments.get("session") {
89
- let summary = session
90
- .get("summary")
91
- .and_then(|v| v.as_str())
92
- .ok_or_else(|| {
93
- anyhow::anyhow!("'session.summary' is required when session is provided")
94
- })?;
95
-
96
- if !skip_session_close {
97
- close_active_sessions(&sessions_dir)?;
98
- }
99
- let git_state = capture_git_state(&project_dir)?;
100
- let now = Utc::now().to_rfc3339();
101
-
102
- let mut handoff_notes = extract_array(session, "handoff_notes");
103
-
104
- if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
105
- if !raw_notes.is_empty() {
106
- handoff_notes.push(serde_json::json!({
107
- "note": raw_notes,
108
- "category": "context"
109
- }));
110
- }
111
- }
112
-
113
- let mut environment = session
114
- .get("environment")
115
- .cloned()
116
- .unwrap_or(serde_json::json!({}));
117
- if let Some(env_obj) = environment.as_object_mut() {
118
- env_obj.insert(
119
- "import_source".to_string(),
120
- serde_json::json!({
121
- "description": source_description,
122
- "format": source_format
123
- }),
124
- );
125
- }
126
-
127
- let data = SessionData {
128
- version: 2,
129
- id: None,
130
- ended_at: Some(now),
131
- summary: summary.to_string(),
132
- branch: Some(git_state.branch),
133
- commit: Some(git_state.commit),
134
- dirty_files: git_state.dirty_files,
135
- decisions: extract_array(session, "decisions"),
136
- blockers: extract_string_array(session, "blockers"),
137
- checklist: extract_array(session, "checklist"),
138
- handoff_notes,
139
- references: extract_array(session, "references"),
140
- context_pointers: extract_array(session, "context_pointers"),
141
- environment: Some(environment),
142
- timeline: None,
143
- label: None,
144
- parent_session_id: None,
145
- related_task_ids: Vec::new(),
146
- };
147
-
148
- write_open_session(&sessions_dir, &data)?;
149
-
150
- let history_limit = if config_path.exists() {
151
- read_config(&config_path)
152
- .map(|c| c.settings.history_limit)
153
- .unwrap_or(20)
154
- } else {
155
- 20
156
- };
157
- enforce_history_limit(&sessions_dir, history_limit)?;
158
-
159
- session_saved = true;
160
- } else if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
161
- if !raw_notes.is_empty() {
162
- if !skip_session_close {
163
- close_active_sessions(&sessions_dir)?;
164
- }
165
- let git_state = capture_git_state(&project_dir)?;
166
- let now = Utc::now().to_rfc3339();
167
-
168
- let data = SessionData {
169
- version: 2,
170
- id: None,
171
- ended_at: Some(now),
172
- summary: format!("[import] {source_description}"),
173
- branch: Some(git_state.branch),
174
- commit: Some(git_state.commit),
175
- dirty_files: git_state.dirty_files,
176
- decisions: Vec::new(),
177
- blockers: Vec::new(),
178
- checklist: Vec::new(),
179
- handoff_notes: vec![serde_json::json!({
180
- "note": raw_notes,
181
- "category": "context"
182
- })],
183
- references: Vec::new(),
184
- context_pointers: Vec::new(),
185
- environment: Some(serde_json::json!({
186
- "import_source": {
187
- "description": source_description,
188
- "format": source_format
189
- }
190
- })),
191
- timeline: None,
192
- label: None,
193
- parent_session_id: None,
194
- related_task_ids: Vec::new(),
195
- };
196
-
197
- write_open_session(&sessions_dir, &data)?;
198
-
199
- let history_limit = if config_path.exists() {
200
- read_config(&config_path)
201
- .map(|c| c.settings.history_limit)
202
- .unwrap_or(20)
203
- } else {
204
- 20
205
- };
206
- enforce_history_limit(&sessions_dir, history_limit)?;
207
-
208
- session_saved = true;
209
- }
210
- }
211
-
212
- let mut msg = format!("Import complete:\n Source: {source_description}");
213
-
214
- if tasks_created > 0 {
215
- msg.push_str(&format!(
216
- "\n Tasks created: {tasks_created} ({top_level_count} top-level, {nested_count} nested)"
217
- ));
218
- } else {
219
- msg.push_str("\n Tasks created: 0");
220
- }
221
-
222
- if session_saved {
223
- msg.push_str("\n Session saved: yes");
224
- }
225
-
226
- if arguments
227
- .get("raw_notes")
228
- .and_then(|v| v.as_str())
229
- .is_some_and(|s| !s.is_empty())
230
- {
231
- msg.push_str("\n Raw notes: saved as handoff_note (context)");
232
- }
233
-
234
- Ok(msg)
235
- }
236
-
237
- // The validating pass and the writing pass must read the payload identically, or
238
- // a task could pass validation and then be written as something else. These three
239
- // accessors are the single source of truth for the fields the estimate rule keys
240
- // on; both passes go through them rather than reaching into `task_val` directly.
241
-
242
- fn task_title(task_val: &Value) -> Result<&str> {
243
- task_val
244
- .get("title")
245
- .and_then(|v| v.as_str())
246
- .ok_or_else(|| anyhow::anyhow!("Each task requires a 'title'"))
247
- }
248
-
249
- fn task_status(task_val: &Value) -> &str {
250
- task_val
251
- .get("status")
252
- .and_then(|v| v.as_str())
253
- .unwrap_or("todo")
254
- }
255
-
256
- fn task_children(task_val: &Value) -> Option<&Vec<Value>> {
257
- task_val.get("children").and_then(|v| v.as_array())
258
- }
259
-
260
- /// Check one task subtree against the rules `create_task_recursive` would
261
- /// otherwise only discover after writing earlier siblings to disk.
262
- ///
263
- /// `has_children` is read from the payload, not the filesystem: at this point the
264
- /// child directories do not exist, so a filesystem probe would call every parent
265
- /// a leaf and demand an estimate it is exempt from.
266
- fn validate_task_recursive(
267
- task_id: &str,
268
- task_val: &Value,
269
- require_estimate_hours: bool,
270
- pending_deps: &mut Vec<(String, Vec<String>)>,
271
- ) -> Result<()> {
272
- let title = task_title(task_val)?;
273
-
274
- let status = task_status(task_val);
275
- if !is_valid_status(status) {
276
- anyhow::bail!("Invalid status: {status}");
277
- }
278
-
279
- validate_priority(task_val.get("priority").and_then(|v| v.as_str()))?;
280
-
281
- let children = task_children(task_val);
282
- // An empty `children` array creates no child tasks, so such a task is a leaf
283
- // and owes an estimate — matching what the writing pass will produce.
284
- let has_children = children.is_some_and(|c| !c.is_empty());
285
-
286
- // import only ever creates, so `is_create` is true and the resend example
287
- // carries `title` — the caller has no stored task to preserve it.
288
- validate_estimate_required(
289
- require_estimate_hours,
290
- task_id,
291
- title,
292
- status,
293
- has_children,
294
- true,
295
- extract_schedule(task_val).as_ref(),
296
- )?;
297
-
298
- // Record this task's edges under the ID the writing pass will give it, so the
299
- // batch cycle check below sees the graph as it will exist after the import.
300
- pending_deps.push((
301
- task_id.to_string(),
302
- extract_string_array_from(task_val, "dependencies"),
303
- ));
304
-
305
- if let Some(children) = children {
306
- for (i, child_val) in children.iter().enumerate() {
307
- let child_id = format!("{task_id}.{}", i + 1);
308
- validate_task_recursive(&child_id, child_val, require_estimate_hours, pending_deps)?;
309
- }
310
- }
311
-
312
- Ok(())
313
- }
314
-
315
- fn create_task_recursive(
316
- tasks_dir: &std::path::Path,
317
- task_id: &str,
318
- parent_dir: Option<&std::path::Path>,
319
- title: &str,
320
- task_val: &Value,
321
- ) -> Result<u32> {
322
- let slug = title_to_slug(title);
323
- let dir_name = format!("{task_id}-{slug}");
324
- let base_dir = parent_dir.unwrap_or(tasks_dir);
325
- let task_dir = base_dir.join(&dir_name);
326
-
327
- std::fs::create_dir_all(&task_dir)
328
- .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
329
-
330
- let now = Utc::now().to_rfc3339();
331
- let status = task_status(task_val);
332
-
333
- // `validate_task_recursive` has already passed on this payload, so these two
334
- // cannot fail here. They stay as defense-in-depth: this function writes to
335
- // disk, and a future caller that forgets the pre-pass must not slip an
336
- // invalid status or priority through unchecked.
337
- if !is_valid_status(status) {
338
- anyhow::bail!("Invalid status: {status}");
339
- }
340
-
341
- let priority = task_val.get("priority").and_then(|v| v.as_str());
342
- validate_priority(priority)?;
343
-
344
- let completed_at = if is_terminal_status(status) {
345
- Some(now.clone())
346
- } else {
347
- None
348
- };
349
-
350
- let data = TaskData {
351
- id: task_id.to_string(),
352
- title: title.to_string(),
353
- notes: task_val
354
- .get("notes")
355
- .and_then(|v| v.as_str())
356
- .map(String::from),
357
- priority: priority.map(String::from),
358
- created_at: Some(now.clone()),
359
- updated_at: Some(now),
360
- completed_at,
361
- labels: extract_string_array_from(task_val, "labels"),
362
- links: extract_string_array_from(task_val, "links"),
363
- task_links: Vec::new(),
364
- done_criteria: extract_done_criteria(task_val),
365
- schedule: extract_schedule(task_val),
366
- dependencies: extract_string_array_from(task_val, "dependencies"),
367
- order: task_val
368
- .get("order")
369
- .and_then(|v| v.as_u64())
370
- .map(|v| v as u32),
371
- assignee: task_val
372
- .get("assignee")
373
- .and_then(|v| v.as_str())
374
- .map(String::from),
375
- extra: std::collections::HashMap::new(),
376
- };
377
-
378
- write_task(&task_dir, status, &data)?;
379
-
380
- let mut count: u32 = 1;
381
-
382
- if let Some(children) = task_children(task_val) {
383
- for (i, child_val) in children.iter().enumerate() {
384
- let child_title = task_title(child_val)?;
385
-
386
- let child_id = format!("{task_id}.{}", i + 1);
387
- count += create_task_recursive(
388
- &task_dir,
389
- &child_id,
390
- Some(&task_dir),
391
- child_title,
392
- child_val,
393
- )?;
394
- }
395
- }
396
-
397
- Ok(count)
398
- }
399
-
400
- fn extract_array(val: &Value, key: &str) -> Vec<Value> {
401
- val.get(key)
402
- .and_then(|v| v.as_array())
403
- .cloned()
404
- .unwrap_or_default()
405
- }
406
-
407
- fn extract_string_array(val: &Value, key: &str) -> Vec<String> {
408
- val.get(key)
409
- .and_then(|v| v.as_array())
410
- .map(|arr| {
411
- arr.iter()
412
- .filter_map(|v| v.as_str().map(String::from))
413
- .collect()
414
- })
415
- .unwrap_or_default()
416
- }
417
-
418
- fn extract_string_array_from(val: &Value, key: &str) -> Vec<String> {
419
- val.get(key)
420
- .and_then(|v| v.as_array())
421
- .map(|arr| {
422
- arr.iter()
423
- .filter_map(|v| v.as_str().map(String::from))
424
- .collect()
425
- })
426
- .unwrap_or_default()
427
- }
428
-
429
- fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
430
- val.get("done_criteria")
431
- .and_then(|v| v.as_array())
432
- .map(|arr| {
433
- arr.iter()
434
- .filter_map(|v| {
435
- let item = v.get("item")?.as_str()?;
436
- let checked = v.get("checked").and_then(|c| c.as_bool()).unwrap_or(false);
437
- Some(DoneCriterion {
438
- item: item.to_string(),
439
- checked,
440
- })
441
- })
442
- .collect()
443
- })
444
- .unwrap_or_default()
445
- }
446
-
447
- fn extract_schedule(val: &Value) -> Option<Schedule> {
448
- let sched = val.get("schedule")?;
449
- if sched.is_null() {
450
- return None;
451
- }
452
- Some(Schedule {
453
- start_date: sched
454
- .get("start_date")
455
- .and_then(|v| v.as_str())
456
- .map(String::from),
457
- due_date: sched
458
- .get("due_date")
459
- .and_then(|v| v.as_str())
460
- .map(String::from),
461
- estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
462
- actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
463
- remaining_hours: sched.get("remaining_hours").and_then(|v| v.as_f64()),
464
- milestone: sched
465
- .get("milestone")
466
- .and_then(|v| v.as_str())
467
- .map(String::from),
468
- pinned: sched.get("pinned").and_then(|v| v.as_bool()),
469
- })
470
- }
@@ -1,28 +0,0 @@
1
- use anyhow::Result;
2
- use serde_json::Value;
3
-
4
- use super::resolve_project_dir;
5
- use crate::storage;
6
-
7
- pub fn handle(arguments: &Value) -> Result<String> {
8
- let project_dir = resolve_project_dir(arguments)?;
9
-
10
- let project_name = arguments
11
- .get("project_name")
12
- .and_then(|v| v.as_str())
13
- .ok_or_else(|| anyhow::anyhow!("project_name is required"))?;
14
-
15
- let description = arguments
16
- .get("description")
17
- .and_then(|v| v.as_str())
18
- .unwrap_or("");
19
-
20
- storage::init_handoff(&project_dir, project_name, description)?;
21
-
22
- Ok(format!(
23
- "Initialized handoff tracking for '{}' at {}/.handoff/\n\
24
- Created: config.toml, sessions/, tasks/, memory/",
25
- project_name,
26
- project_dir.display()
27
- ))
28
- }