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,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
|
-
}
|
package/src/mcp/handlers/init.rs
DELETED
|
@@ -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
|
-
}
|