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.
- package/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
|
@@ -1,197 +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
|
-
use crate::storage::sessions::{read_active_sessions, SessionData};
|
|
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 target_session_id = arguments.get("session_id").and_then(|v| v.as_str());
|
|
14
|
-
|
|
15
|
-
let active = read_active_sessions(&sessions_dir)?;
|
|
16
|
-
if active.is_empty() {
|
|
17
|
-
anyhow::bail!(
|
|
18
|
-
"No active session. Call save_context with session_status='active' to create one first."
|
|
19
|
-
);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
let session = if let Some(tid) = target_session_id {
|
|
23
|
-
active
|
|
24
|
-
.iter()
|
|
25
|
-
.find(|s| {
|
|
26
|
-
s.id.as_deref()
|
|
27
|
-
.is_some_and(|id| id == tid || id.starts_with(tid) || tid.starts_with(id))
|
|
28
|
-
})
|
|
29
|
-
.ok_or_else(|| anyhow::anyhow!("session_id '{tid}' not found among active sessions"))?
|
|
30
|
-
} else if active.len() == 1 {
|
|
31
|
-
&active[0]
|
|
32
|
-
} else {
|
|
33
|
-
active.last().unwrap()
|
|
34
|
-
};
|
|
35
|
-
let sid = session.id.as_deref().unwrap_or("");
|
|
36
|
-
|
|
37
|
-
let checklist_index = arguments
|
|
38
|
-
.get("checklist_index")
|
|
39
|
-
.and_then(|v| v.as_u64())
|
|
40
|
-
.map(|v| v as usize);
|
|
41
|
-
let checklist_checked = arguments.get("checklist_checked").and_then(|v| v.as_bool());
|
|
42
|
-
let add_checklist_item = arguments.get("add_checklist_item").and_then(|v| v.as_str());
|
|
43
|
-
let add_decision = arguments.get("add_decision");
|
|
44
|
-
let add_handoff_note = arguments.get("add_handoff_note");
|
|
45
|
-
let add_context_pointer = arguments.get("add_context_pointer");
|
|
46
|
-
|
|
47
|
-
let mut data = session.clone();
|
|
48
|
-
let mut changes = Vec::new();
|
|
49
|
-
|
|
50
|
-
if let Some(idx) = checklist_index {
|
|
51
|
-
let checked = checklist_checked.unwrap_or(true);
|
|
52
|
-
if idx >= data.checklist.len() {
|
|
53
|
-
anyhow::bail!(
|
|
54
|
-
"checklist_index {idx} out of range (session has {} items)",
|
|
55
|
-
data.checklist.len()
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
if let Some(item) = data.checklist.get_mut(idx) {
|
|
59
|
-
item["checked"] = serde_json::json!(checked);
|
|
60
|
-
let item_text = item
|
|
61
|
-
.get("item")
|
|
62
|
-
.and_then(|v| v.as_str())
|
|
63
|
-
.unwrap_or("(unknown)");
|
|
64
|
-
changes.push(format!(
|
|
65
|
-
"checklist[{idx}] '{}' → {}",
|
|
66
|
-
item_text,
|
|
67
|
-
if checked { "checked" } else { "unchecked" }
|
|
68
|
-
));
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if let Some(item_text) = add_checklist_item {
|
|
73
|
-
let owner = arguments
|
|
74
|
-
.get("checklist_owner")
|
|
75
|
-
.and_then(|v| v.as_str())
|
|
76
|
-
.unwrap_or("ai");
|
|
77
|
-
data.checklist.push(serde_json::json!({
|
|
78
|
-
"item": item_text,
|
|
79
|
-
"checked": false,
|
|
80
|
-
"owner": owner
|
|
81
|
-
}));
|
|
82
|
-
changes.push(format!("added checklist item: '{item_text}'"));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if let Some(decision) = add_decision {
|
|
86
|
-
if decision.is_object() {
|
|
87
|
-
data.decisions.push(decision.clone());
|
|
88
|
-
let desc = decision
|
|
89
|
-
.get("decision")
|
|
90
|
-
.and_then(|v| v.as_str())
|
|
91
|
-
.unwrap_or("(decision)");
|
|
92
|
-
changes.push(format!("added decision: '{desc}'"));
|
|
93
|
-
} else {
|
|
94
|
-
anyhow::bail!("add_decision must be an object with at least a 'decision' field");
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if let Some(note) = add_handoff_note {
|
|
99
|
-
if note.is_object() {
|
|
100
|
-
data.handoff_notes.push(note.clone());
|
|
101
|
-
let text = note
|
|
102
|
-
.get("note")
|
|
103
|
-
.and_then(|v| v.as_str())
|
|
104
|
-
.unwrap_or("(note)");
|
|
105
|
-
changes.push(format!("added handoff_note: '{}'", truncate(text, 60)));
|
|
106
|
-
} else {
|
|
107
|
-
anyhow::bail!("add_handoff_note must be an object with at least a 'note' field");
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if let Some(pointer) = add_context_pointer {
|
|
112
|
-
if pointer.is_object() {
|
|
113
|
-
data.context_pointers.push(pointer.clone());
|
|
114
|
-
let path = pointer
|
|
115
|
-
.get("path")
|
|
116
|
-
.and_then(|v| v.as_str())
|
|
117
|
-
.unwrap_or("(path)");
|
|
118
|
-
changes.push(format!("added context_pointer: '{path}'"));
|
|
119
|
-
} else {
|
|
120
|
-
anyhow::bail!("add_context_pointer must be an object with at least a 'path' field");
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if changes.is_empty() {
|
|
125
|
-
anyhow::bail!(
|
|
126
|
-
"No updates specified. Use checklist_index, add_checklist_item, \
|
|
127
|
-
add_decision, add_handoff_note, or add_context_pointer."
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
write_active_session_data(&sessions_dir, sid, &data)?;
|
|
132
|
-
|
|
133
|
-
let mut msg = format!("Session {} updated:", sid);
|
|
134
|
-
for c in &changes {
|
|
135
|
-
msg.push_str(&format!("\n - {c}"));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
let checked_count = data
|
|
139
|
-
.checklist
|
|
140
|
-
.iter()
|
|
141
|
-
.filter(|item| {
|
|
142
|
-
item.get("checked")
|
|
143
|
-
.and_then(|v| v.as_bool())
|
|
144
|
-
.unwrap_or(false)
|
|
145
|
-
})
|
|
146
|
-
.count();
|
|
147
|
-
msg.push_str(&format!(
|
|
148
|
-
"\nChecklist: {}/{} checked",
|
|
149
|
-
checked_count,
|
|
150
|
-
data.checklist.len()
|
|
151
|
-
));
|
|
152
|
-
|
|
153
|
-
Ok(msg)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
fn truncate(s: &str, max_chars: usize) -> String {
|
|
157
|
-
let mut chars = s.chars();
|
|
158
|
-
let truncated: String = chars.by_ref().take(max_chars).collect();
|
|
159
|
-
if chars.next().is_some() {
|
|
160
|
-
format!("{truncated}...")
|
|
161
|
-
} else {
|
|
162
|
-
truncated
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
fn write_active_session_data(
|
|
167
|
-
sessions_dir: &std::path::Path,
|
|
168
|
-
session_id: &str,
|
|
169
|
-
data: &SessionData,
|
|
170
|
-
) -> Result<()> {
|
|
171
|
-
let suffix = ".active.json";
|
|
172
|
-
|
|
173
|
-
for entry in std::fs::read_dir(sessions_dir)? {
|
|
174
|
-
let entry = entry?;
|
|
175
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
176
|
-
if !name.ends_with(suffix) {
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
let content = std::fs::read_to_string(entry.path())
|
|
181
|
-
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
182
|
-
let existing: SessionData = serde_json::from_str(&content)
|
|
183
|
-
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
184
|
-
|
|
185
|
-
let file_id = existing.id.as_deref().unwrap_or("");
|
|
186
|
-
if file_id != session_id {
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
let updated = serde_json::to_string_pretty(data).context("Failed to serialize session")?;
|
|
191
|
-
crate::storage::atomic_write(entry.path(), updated.as_bytes())
|
|
192
|
-
.with_context(|| format!("Failed to write session: {}", entry.path().display()))?;
|
|
193
|
-
return Ok(());
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
anyhow::bail!("Active session '{session_id}' not found on disk")
|
|
197
|
-
}
|
|
@@ -1,452 +0,0 @@
|
|
|
1
|
-
use std::collections::HashMap;
|
|
2
|
-
|
|
3
|
-
use anyhow::{Context, Result};
|
|
4
|
-
use chrono::Utc;
|
|
5
|
-
use serde_json::Value;
|
|
6
|
-
|
|
7
|
-
use super::resolve_project_dir;
|
|
8
|
-
use crate::storage::config::read_config;
|
|
9
|
-
use crate::storage::ensure_handoff_exists;
|
|
10
|
-
use crate::storage::tasks::*;
|
|
11
|
-
|
|
12
|
-
pub fn handle(arguments: &Value) -> Result<String> {
|
|
13
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
14
|
-
|
|
15
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
16
|
-
let tasks_dir = handoff.join("tasks");
|
|
17
|
-
|
|
18
|
-
let require_estimate_hours = read_config(&handoff.join("config.toml"))
|
|
19
|
-
.map(|c| c.settings.require_estimate_hours)
|
|
20
|
-
.unwrap_or(true);
|
|
21
|
-
|
|
22
|
-
let task_val = arguments
|
|
23
|
-
.get("task")
|
|
24
|
-
.ok_or_else(|| anyhow::anyhow!("'task' parameter is required"))?;
|
|
25
|
-
|
|
26
|
-
let task_id = task_val.get("id").and_then(|v| v.as_str());
|
|
27
|
-
let move_to = arguments.get("move_to").and_then(|v| v.as_str());
|
|
28
|
-
|
|
29
|
-
if let Some(existing_id) = task_id {
|
|
30
|
-
if let Some(new_parent_id) = move_to {
|
|
31
|
-
return handle_move(&tasks_dir, existing_id, new_parent_id);
|
|
32
|
-
}
|
|
33
|
-
let task_exists = find_task_dir_by_id(&tasks_dir, existing_id)?.is_some();
|
|
34
|
-
if task_exists {
|
|
35
|
-
return handle_update(&tasks_dir, existing_id, task_val, require_estimate_hours);
|
|
36
|
-
}
|
|
37
|
-
return handle_upsert_create(
|
|
38
|
-
&tasks_dir,
|
|
39
|
-
existing_id,
|
|
40
|
-
task_val,
|
|
41
|
-
arguments,
|
|
42
|
-
require_estimate_hours,
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
let title = task_val
|
|
47
|
-
.get("title")
|
|
48
|
-
.and_then(|v| v.as_str())
|
|
49
|
-
.ok_or_else(|| anyhow::anyhow!("'task.title' is required for new tasks"))?;
|
|
50
|
-
|
|
51
|
-
handle_create(
|
|
52
|
-
&tasks_dir,
|
|
53
|
-
title,
|
|
54
|
-
task_val,
|
|
55
|
-
arguments,
|
|
56
|
-
require_estimate_hours,
|
|
57
|
-
)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
fn handle_create(
|
|
61
|
-
tasks_dir: &std::path::Path,
|
|
62
|
-
title: &str,
|
|
63
|
-
task_val: &Value,
|
|
64
|
-
arguments: &Value,
|
|
65
|
-
require_estimate_hours: bool,
|
|
66
|
-
) -> Result<String> {
|
|
67
|
-
let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
|
|
68
|
-
|
|
69
|
-
let (new_id, parent_dir) = match parent_id {
|
|
70
|
-
Some(pid) => {
|
|
71
|
-
let parent_dir = find_task_dir_by_id(tasks_dir, pid)?
|
|
72
|
-
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, pid)))?;
|
|
73
|
-
let id = next_child_id(&parent_dir, pid)?;
|
|
74
|
-
(id, parent_dir)
|
|
75
|
-
}
|
|
76
|
-
None => {
|
|
77
|
-
let id = next_top_level_id(tasks_dir)?;
|
|
78
|
-
(id, tasks_dir.to_path_buf())
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
let slug = title_to_slug(title);
|
|
83
|
-
let dir_name = format!("{new_id}-{slug}");
|
|
84
|
-
let task_dir = parent_dir.join(&dir_name);
|
|
85
|
-
|
|
86
|
-
let now = Utc::now().to_rfc3339();
|
|
87
|
-
let status = task_val
|
|
88
|
-
.get("status")
|
|
89
|
-
.and_then(|v| v.as_str())
|
|
90
|
-
.unwrap_or("todo");
|
|
91
|
-
|
|
92
|
-
if !is_valid_status(status) {
|
|
93
|
-
anyhow::bail!("Invalid status: {status}");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
let priority = task_val.get("priority").and_then(|v| v.as_str());
|
|
97
|
-
validate_priority(priority)?;
|
|
98
|
-
|
|
99
|
-
let dependencies = extract_string_array(task_val, "dependencies");
|
|
100
|
-
if !dependencies.is_empty() {
|
|
101
|
-
validate_dependencies(tasks_dir, &new_id, &dependencies)?;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
let data = TaskData {
|
|
105
|
-
id: new_id.clone(),
|
|
106
|
-
title: title.to_string(),
|
|
107
|
-
notes: task_val
|
|
108
|
-
.get("notes")
|
|
109
|
-
.and_then(|v| v.as_str())
|
|
110
|
-
.map(String::from),
|
|
111
|
-
priority: priority.map(String::from),
|
|
112
|
-
created_at: Some(now.clone()),
|
|
113
|
-
updated_at: Some(now),
|
|
114
|
-
completed_at: None,
|
|
115
|
-
labels: extract_string_array(task_val, "labels"),
|
|
116
|
-
links: extract_string_array(task_val, "links"),
|
|
117
|
-
task_links: Vec::new(),
|
|
118
|
-
done_criteria: extract_done_criteria(task_val),
|
|
119
|
-
schedule: extract_schedule(task_val),
|
|
120
|
-
dependencies,
|
|
121
|
-
order: task_val
|
|
122
|
-
.get("order")
|
|
123
|
-
.and_then(|v| v.as_u64())
|
|
124
|
-
.map(|v| v as u32),
|
|
125
|
-
assignee: task_val
|
|
126
|
-
.get("assignee")
|
|
127
|
-
.and_then(|v| v.as_str())
|
|
128
|
-
.map(String::from),
|
|
129
|
-
extra: HashMap::new(),
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
// A newly created task is always a leaf (no children yet).
|
|
133
|
-
validate_estimate_required(
|
|
134
|
-
require_estimate_hours,
|
|
135
|
-
&new_id,
|
|
136
|
-
title,
|
|
137
|
-
status,
|
|
138
|
-
false,
|
|
139
|
-
true,
|
|
140
|
-
data.schedule.as_ref(),
|
|
141
|
-
)?;
|
|
142
|
-
|
|
143
|
-
// Create the directory only once every validation has passed. A rejected
|
|
144
|
-
// create must leave nothing behind: an orphan dir would burn the task ID,
|
|
145
|
-
// because `next_top_level_id` counts directories, not task files.
|
|
146
|
-
std::fs::create_dir_all(&task_dir)
|
|
147
|
-
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
148
|
-
|
|
149
|
-
write_task(&task_dir, status, &data)?;
|
|
150
|
-
|
|
151
|
-
Ok(format!("Created task {new_id}: {title} [{status}]"))
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
fn handle_upsert_create(
|
|
155
|
-
tasks_dir: &std::path::Path,
|
|
156
|
-
task_id: &str,
|
|
157
|
-
task_val: &Value,
|
|
158
|
-
arguments: &Value,
|
|
159
|
-
require_estimate_hours: bool,
|
|
160
|
-
) -> Result<String> {
|
|
161
|
-
let title = task_val
|
|
162
|
-
.get("title")
|
|
163
|
-
.and_then(|v| v.as_str())
|
|
164
|
-
.ok_or_else(|| {
|
|
165
|
-
let hint = suggest_task_id(tasks_dir, task_id);
|
|
166
|
-
anyhow::anyhow!("{hint}\nProvide 'title' to create a new task with this ID.")
|
|
167
|
-
})?;
|
|
168
|
-
|
|
169
|
-
let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
|
|
170
|
-
|
|
171
|
-
let parent_dir = match parent_id {
|
|
172
|
-
Some(pid) => find_task_dir_by_id(tasks_dir, pid)?
|
|
173
|
-
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, pid)))?,
|
|
174
|
-
None => tasks_dir.to_path_buf(),
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
let slug = title_to_slug(title);
|
|
178
|
-
let dir_name = format!("{task_id}-{slug}");
|
|
179
|
-
let task_dir = parent_dir.join(&dir_name);
|
|
180
|
-
|
|
181
|
-
let now = Utc::now().to_rfc3339();
|
|
182
|
-
let status = task_val
|
|
183
|
-
.get("status")
|
|
184
|
-
.and_then(|v| v.as_str())
|
|
185
|
-
.unwrap_or("todo");
|
|
186
|
-
|
|
187
|
-
if !is_valid_status(status) {
|
|
188
|
-
anyhow::bail!("Invalid status: {status}");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
let priority = task_val.get("priority").and_then(|v| v.as_str());
|
|
192
|
-
validate_priority(priority)?;
|
|
193
|
-
|
|
194
|
-
let dependencies = extract_string_array(task_val, "dependencies");
|
|
195
|
-
if !dependencies.is_empty() {
|
|
196
|
-
validate_dependencies(tasks_dir, task_id, &dependencies)?;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
let data = TaskData {
|
|
200
|
-
id: task_id.to_string(),
|
|
201
|
-
title: title.to_string(),
|
|
202
|
-
notes: task_val
|
|
203
|
-
.get("notes")
|
|
204
|
-
.and_then(|v| v.as_str())
|
|
205
|
-
.map(String::from),
|
|
206
|
-
priority: priority.map(String::from),
|
|
207
|
-
created_at: Some(now.clone()),
|
|
208
|
-
updated_at: Some(now),
|
|
209
|
-
completed_at: None,
|
|
210
|
-
labels: extract_string_array(task_val, "labels"),
|
|
211
|
-
links: extract_string_array(task_val, "links"),
|
|
212
|
-
task_links: Vec::new(),
|
|
213
|
-
done_criteria: extract_done_criteria(task_val),
|
|
214
|
-
schedule: extract_schedule(task_val),
|
|
215
|
-
dependencies,
|
|
216
|
-
order: task_val
|
|
217
|
-
.get("order")
|
|
218
|
-
.and_then(|v| v.as_u64())
|
|
219
|
-
.map(|v| v as u32),
|
|
220
|
-
assignee: task_val
|
|
221
|
-
.get("assignee")
|
|
222
|
-
.and_then(|v| v.as_str())
|
|
223
|
-
.map(String::from),
|
|
224
|
-
extra: HashMap::new(),
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
// Upsert-create: a brand-new task is a leaf.
|
|
228
|
-
validate_estimate_required(
|
|
229
|
-
require_estimate_hours,
|
|
230
|
-
task_id,
|
|
231
|
-
title,
|
|
232
|
-
status,
|
|
233
|
-
false,
|
|
234
|
-
true,
|
|
235
|
-
data.schedule.as_ref(),
|
|
236
|
-
)?;
|
|
237
|
-
|
|
238
|
-
// Create the directory only once every validation has passed, so a rejected
|
|
239
|
-
// upsert-create leaves no orphan dir shadowing the requested ID.
|
|
240
|
-
std::fs::create_dir_all(&task_dir)
|
|
241
|
-
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
242
|
-
|
|
243
|
-
write_task(&task_dir, status, &data)?;
|
|
244
|
-
|
|
245
|
-
Ok(format!("Created task {task_id}: {title} [{status}]"))
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
fn handle_update(
|
|
249
|
-
tasks_dir: &std::path::Path,
|
|
250
|
-
task_id: &str,
|
|
251
|
-
task_val: &Value,
|
|
252
|
-
require_estimate_hours: bool,
|
|
253
|
-
) -> Result<String> {
|
|
254
|
-
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
255
|
-
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
|
|
256
|
-
|
|
257
|
-
let (mut data, current_status) = read_task(&task_dir)?
|
|
258
|
-
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
259
|
-
|
|
260
|
-
if let Some(title) = task_val.get("title").and_then(|v| v.as_str()) {
|
|
261
|
-
data.title = title.to_string();
|
|
262
|
-
}
|
|
263
|
-
if let Some(notes) = task_val.get("notes").and_then(|v| v.as_str()) {
|
|
264
|
-
data.notes = Some(notes.to_string());
|
|
265
|
-
} else if let Some(append) = task_val.get("notes_append").and_then(|v| v.as_str()) {
|
|
266
|
-
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%S");
|
|
267
|
-
let block = format!("--- {timestamp}\n{append}");
|
|
268
|
-
match &mut data.notes {
|
|
269
|
-
Some(existing) if !existing.is_empty() => {
|
|
270
|
-
existing.push_str(&format!("\n\n{block}"));
|
|
271
|
-
}
|
|
272
|
-
_ => data.notes = Some(block),
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
if let Some(priority) = task_val.get("priority").and_then(|v| v.as_str()) {
|
|
276
|
-
validate_priority(Some(priority))?;
|
|
277
|
-
data.priority = Some(priority.to_string());
|
|
278
|
-
}
|
|
279
|
-
if task_val.get("labels").is_some() {
|
|
280
|
-
data.labels = extract_string_array(task_val, "labels");
|
|
281
|
-
}
|
|
282
|
-
if task_val.get("links").is_some() {
|
|
283
|
-
data.links = extract_string_array(task_val, "links");
|
|
284
|
-
}
|
|
285
|
-
if task_val.get("done_criteria").is_some() {
|
|
286
|
-
data.done_criteria = extract_done_criteria(task_val);
|
|
287
|
-
}
|
|
288
|
-
if let Some(sched_val) = task_val.get("schedule") {
|
|
289
|
-
// Field-level merge (not full replacement) so that fields not present in
|
|
290
|
-
// the patch — e.g. actual_hours/remaining_hours accrued by the VSCode timer —
|
|
291
|
-
// are preserved. Mirrors bulk_update_tasks. (referral ref-20260623-232823)
|
|
292
|
-
let schedule = data.schedule.get_or_insert_with(Default::default);
|
|
293
|
-
if let Some(sd) = sched_val.get("start_date").and_then(|v| v.as_str()) {
|
|
294
|
-
schedule.start_date = Some(sd.to_string());
|
|
295
|
-
}
|
|
296
|
-
if let Some(dd) = sched_val.get("due_date").and_then(|v| v.as_str()) {
|
|
297
|
-
schedule.due_date = Some(dd.to_string());
|
|
298
|
-
}
|
|
299
|
-
if let Some(eh) = sched_val.get("estimate_hours").and_then(|v| v.as_f64()) {
|
|
300
|
-
schedule.estimate_hours = Some(eh);
|
|
301
|
-
}
|
|
302
|
-
if let Some(ah) = sched_val.get("actual_hours").and_then(|v| v.as_f64()) {
|
|
303
|
-
schedule.actual_hours = Some(ah);
|
|
304
|
-
}
|
|
305
|
-
if let Some(rh) = sched_val.get("remaining_hours").and_then(|v| v.as_f64()) {
|
|
306
|
-
schedule.remaining_hours = Some(rh);
|
|
307
|
-
}
|
|
308
|
-
if let Some(ms) = sched_val.get("milestone").and_then(|v| v.as_str()) {
|
|
309
|
-
schedule.milestone = Some(ms.to_string());
|
|
310
|
-
}
|
|
311
|
-
if let Some(p) = sched_val.get("pinned").and_then(|v| v.as_bool()) {
|
|
312
|
-
schedule.pinned = Some(p);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
if task_val.get("dependencies").is_some() {
|
|
316
|
-
let new_deps = extract_string_array(task_val, "dependencies");
|
|
317
|
-
if !new_deps.is_empty() {
|
|
318
|
-
validate_dependencies(tasks_dir, task_id, &new_deps)?;
|
|
319
|
-
}
|
|
320
|
-
data.dependencies = new_deps;
|
|
321
|
-
}
|
|
322
|
-
if let Some(order) = task_val.get("order").and_then(|v| v.as_u64()) {
|
|
323
|
-
data.order = Some(order as u32);
|
|
324
|
-
}
|
|
325
|
-
if task_val.get("assignee").is_some() {
|
|
326
|
-
data.assignee = task_val
|
|
327
|
-
.get("assignee")
|
|
328
|
-
.and_then(|v| v.as_str())
|
|
329
|
-
.map(String::from);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
let new_status = task_val
|
|
333
|
-
.get("status")
|
|
334
|
-
.and_then(|v| v.as_str())
|
|
335
|
-
.unwrap_or(¤t_status);
|
|
336
|
-
|
|
337
|
-
if !is_valid_status(new_status) {
|
|
338
|
-
anyhow::bail!("Invalid status: {new_status}");
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if new_status == "done" && current_status != "done" {
|
|
342
|
-
validate_done_transition(&task_dir, &data)?;
|
|
343
|
-
data.completed_at = Some(Utc::now().to_rfc3339());
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
if new_status == "skipped" && current_status != "skipped" {
|
|
347
|
-
validate_skipped_transition(&task_dir, &data)?;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Parent tasks (with children) are exempt; only leaf tasks need an estimate.
|
|
351
|
-
let has_children = task_has_children(&task_dir)?;
|
|
352
|
-
validate_estimate_required(
|
|
353
|
-
require_estimate_hours,
|
|
354
|
-
task_id,
|
|
355
|
-
&data.title,
|
|
356
|
-
new_status,
|
|
357
|
-
has_children,
|
|
358
|
-
false,
|
|
359
|
-
data.schedule.as_ref(),
|
|
360
|
-
)?;
|
|
361
|
-
|
|
362
|
-
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
363
|
-
|
|
364
|
-
if let Some((old_path, _)) = find_task_file(&task_dir)? {
|
|
365
|
-
std::fs::remove_file(&old_path)?;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
write_task(&task_dir, new_status, &data)?;
|
|
369
|
-
|
|
370
|
-
Ok(format!(
|
|
371
|
-
"Updated task {task_id}: {} [{new_status}]",
|
|
372
|
-
data.title
|
|
373
|
-
))
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
fn handle_move(tasks_dir: &std::path::Path, task_id: &str, new_parent_id: &str) -> Result<String> {
|
|
377
|
-
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
378
|
-
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
|
|
379
|
-
|
|
380
|
-
let new_parent_dir = find_task_dir_by_id(tasks_dir, new_parent_id)?
|
|
381
|
-
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, new_parent_id)))?;
|
|
382
|
-
|
|
383
|
-
let dir_name = task_dir
|
|
384
|
-
.file_name()
|
|
385
|
-
.ok_or_else(|| anyhow::anyhow!("Invalid task dir"))?;
|
|
386
|
-
|
|
387
|
-
let dest = new_parent_dir.join(dir_name);
|
|
388
|
-
|
|
389
|
-
std::fs::rename(&task_dir, &dest).with_context(|| {
|
|
390
|
-
format!(
|
|
391
|
-
"Failed to move {} -> {}",
|
|
392
|
-
task_dir.display(),
|
|
393
|
-
dest.display()
|
|
394
|
-
)
|
|
395
|
-
})?;
|
|
396
|
-
|
|
397
|
-
Ok(format!("Moved task {task_id} under {new_parent_id}"))
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
fn extract_string_array(val: &Value, key: &str) -> Vec<String> {
|
|
401
|
-
val.get(key)
|
|
402
|
-
.and_then(|v| v.as_array())
|
|
403
|
-
.map(|arr| {
|
|
404
|
-
arr.iter()
|
|
405
|
-
.filter_map(|v| v.as_str().map(String::from))
|
|
406
|
-
.collect()
|
|
407
|
-
})
|
|
408
|
-
.unwrap_or_default()
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
fn extract_done_criteria(val: &Value) -> Vec<DoneCriterion> {
|
|
412
|
-
val.get("done_criteria")
|
|
413
|
-
.and_then(|v| v.as_array())
|
|
414
|
-
.map(|arr| {
|
|
415
|
-
arr.iter()
|
|
416
|
-
.filter_map(|v| {
|
|
417
|
-
let item = v.get("item")?.as_str()?;
|
|
418
|
-
let checked = v.get("checked").and_then(|c| c.as_bool()).unwrap_or(false);
|
|
419
|
-
Some(DoneCriterion {
|
|
420
|
-
item: item.to_string(),
|
|
421
|
-
checked,
|
|
422
|
-
})
|
|
423
|
-
})
|
|
424
|
-
.collect()
|
|
425
|
-
})
|
|
426
|
-
.unwrap_or_default()
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
fn extract_schedule(val: &Value) -> Option<Schedule> {
|
|
430
|
-
let sched = val.get("schedule")?;
|
|
431
|
-
if sched.is_null() {
|
|
432
|
-
return None;
|
|
433
|
-
}
|
|
434
|
-
Some(Schedule {
|
|
435
|
-
start_date: sched
|
|
436
|
-
.get("start_date")
|
|
437
|
-
.and_then(|v| v.as_str())
|
|
438
|
-
.map(String::from),
|
|
439
|
-
due_date: sched
|
|
440
|
-
.get("due_date")
|
|
441
|
-
.and_then(|v| v.as_str())
|
|
442
|
-
.map(String::from),
|
|
443
|
-
estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
|
|
444
|
-
actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
|
|
445
|
-
remaining_hours: sched.get("remaining_hours").and_then(|v| v.as_f64()),
|
|
446
|
-
milestone: sched
|
|
447
|
-
.get("milestone")
|
|
448
|
-
.and_then(|v| v.as_str())
|
|
449
|
-
.map(String::from),
|
|
450
|
-
pinned: sched.get("pinned").and_then(|v| v.as_bool()),
|
|
451
|
-
})
|
|
452
|
-
}
|
package/src/mcp/mod.rs
DELETED
package/src/mcp/protocol.rs
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
use super::router::handle_request;
|
|
2
|
-
use super::types::{JsonRpcRequest, JsonRpcResponse, INVALID_REQUEST, PARSE_ERROR};
|
|
3
|
-
|
|
4
|
-
pub fn process_line(line: &str) -> Option<String> {
|
|
5
|
-
let trimmed = line.trim();
|
|
6
|
-
if trimmed.is_empty() {
|
|
7
|
-
return None;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
let response = match serde_json::from_str::<JsonRpcRequest>(trimmed) {
|
|
11
|
-
Ok(req) => {
|
|
12
|
-
if req.jsonrpc != "2.0" {
|
|
13
|
-
JsonRpcResponse::error(req.id, INVALID_REQUEST, "Invalid JSON-RPC version")
|
|
14
|
-
} else {
|
|
15
|
-
let mut resp = handle_request(&req.method, req.params.as_ref());
|
|
16
|
-
if resp.id.is_none() && req.id.is_some() {
|
|
17
|
-
resp.id = req.id;
|
|
18
|
-
}
|
|
19
|
-
if is_notification(&req.method) && resp.result.is_none() && resp.error.is_none() {
|
|
20
|
-
return None;
|
|
21
|
-
}
|
|
22
|
-
resp
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
Err(e) => JsonRpcResponse::error(None, PARSE_ERROR, format!("Parse error: {e}")),
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
match serde_json::to_string(&response) {
|
|
29
|
-
Ok(s) => Some(s),
|
|
30
|
-
Err(e) => {
|
|
31
|
-
let fallback = format!(
|
|
32
|
-
r#"{{"jsonrpc":"2.0","id":null,"error":{{"code":-32603,"message":"Serialization error: {e}"}}}}"#
|
|
33
|
-
);
|
|
34
|
-
Some(fallback)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
fn is_notification(method: &str) -> bool {
|
|
40
|
-
method.starts_with("notifications/")
|
|
41
|
-
}
|