handoff-mcp-server 0.26.0 → 0.27.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,127 +0,0 @@
1
- use serde::{Deserialize, Serialize};
2
-
3
- /// Current memory schema version. Bump when `MemoryEntry` changes shape in a way
4
- /// that needs migration handling on read.
5
- ///
6
- /// v1 → v2: added `keywords` field (defaults to empty on read).
7
- pub const MEMORY_SCHEMA_VERSION: u32 = 2;
8
-
9
- /// Valid `kind` values for a memory. Free-form text is rejected so the field
10
- /// stays a small, queryable enumeration.
11
- pub const VALID_MEMORY_KINDS: &[&str] = &["lesson", "rule", "convention", "gotcha"];
12
-
13
- /// Returns true if `kind` is one of the accepted memory kinds.
14
- pub fn is_valid_memory_kind(kind: &str) -> bool {
15
- VALID_MEMORY_KINDS.contains(&kind)
16
- }
17
-
18
- /// A single persisted memory: a long-lived, cross-session piece of project
19
- /// knowledge ("lesson / rule / convention / gotcha"). One file per memory under
20
- /// `.handoff/memory/`.
21
- ///
22
- /// Token sets are intentionally **not** stored — they are recomputed from `text`
23
- /// on every read so the index can never drift from the body.
24
- #[derive(Debug, Clone, Serialize, Deserialize)]
25
- pub struct MemoryEntry {
26
- /// Schema version (= [`MEMORY_SCHEMA_VERSION`]).
27
- pub version: u32,
28
- /// Stable id: `m-YYYYMMDD-HHMMSS-NNNNNN`.
29
- pub id: String,
30
- /// The memory body (multilingual).
31
- pub text: String,
32
- /// One of [`VALID_MEMORY_KINDS`].
33
- pub kind: String,
34
- /// Free-form tags; also fed into the similarity index.
35
- #[serde(default)]
36
- pub tags: Vec<String>,
37
- /// Subject keywords — nouns, technical terms, proper nouns that identify what
38
- /// this memory is *about*. Used for BM25 matching with boosted weight.
39
- /// Distinct from `tags` (classification labels) and `scope_paths` (file
40
- /// prefixes). Populated by the AI at save time; defaults to empty for v1
41
- /// memories.
42
- #[serde(default)]
43
- pub keywords: Vec<String>,
44
- /// Path prefixes this memory applies to (e.g. `src/storage/`). A query whose
45
- /// file paths start with one of these gets a relevance boost.
46
- #[serde(default)]
47
- pub scope_paths: Vec<String>,
48
- /// FNV-1a hash of the canonical (tokenized) text. Drives exact-duplicate
49
- /// detection and per-session re-injection tracking.
50
- pub content_hash: String,
51
- /// RFC3339 creation timestamp.
52
- pub created_at: String,
53
- /// RFC3339 last-update timestamp.
54
- pub updated_at: String,
55
- /// RFC3339 timestamp of the last time this memory was injected into a
56
- /// session, if ever.
57
- #[serde(default, skip_serializing_if = "Option::is_none")]
58
- pub last_referenced_at: Option<String>,
59
- /// Number of times this memory has been injected.
60
- #[serde(default)]
61
- pub hit_count: u64,
62
- /// Ids of memories merged into this one (audit trail for AI-driven merges).
63
- #[serde(default)]
64
- pub superseded_ids: Vec<String>,
65
- }
66
-
67
- impl MemoryEntry {
68
- /// Build a new entry with timestamps and content hash filled in. `now` is an
69
- /// RFC3339 timestamp supplied by the caller (keeps this module clock-free
70
- /// and testable).
71
- pub fn new(
72
- id: String,
73
- text: String,
74
- kind: String,
75
- tags: Vec<String>,
76
- keywords: Vec<String>,
77
- scope_paths: Vec<String>,
78
- now: String,
79
- ) -> Self {
80
- let content_hash = lexsim::content_hash(&text);
81
- MemoryEntry {
82
- version: MEMORY_SCHEMA_VERSION,
83
- id,
84
- text,
85
- kind,
86
- tags,
87
- keywords,
88
- scope_paths,
89
- content_hash,
90
- created_at: now.clone(),
91
- updated_at: now,
92
- last_referenced_at: None,
93
- hit_count: 0,
94
- superseded_ids: Vec::new(),
95
- }
96
- }
97
-
98
- /// Plain-text index for Jaccard similarity: body + tags + keywords.
99
- pub fn index_text(&self) -> String {
100
- let mut parts = vec![self.text.clone()];
101
- if !self.tags.is_empty() {
102
- parts.push(self.tags.join(" "));
103
- }
104
- if !self.keywords.is_empty() {
105
- parts.push(self.keywords.join(" "));
106
- }
107
- parts.join(" ")
108
- }
109
-
110
- /// Weighted tokens for `Corpus::build_weighted_tokens`. Body + tags are
111
- /// tokenized at default weight; keywords are added with weight 2.0
112
- /// (doc-side TF boost), replacing the old 2x-concatenation hack.
113
- pub fn index_weighted_tokens(&self) -> Vec<lexsim::WeightedToken> {
114
- let mut base = vec![self.text.clone()];
115
- if !self.tags.is_empty() {
116
- base.push(self.tags.join(" "));
117
- }
118
- let mut tokens = lexsim::tokenize_weighted(&base.join(" "));
119
- for kw in &self.keywords {
120
- for mut wt in lexsim::tokenize_weighted(kw) {
121
- wt.weight *= 2.0;
122
- tokens.push(wt);
123
- }
124
- }
125
- tokens
126
- }
127
- }
@@ -1,96 +0,0 @@
1
- pub mod config;
2
- pub mod docs;
3
- pub mod git;
4
- pub mod memory;
5
- pub mod referrals;
6
- pub mod sessions;
7
- pub mod tasks;
8
-
9
- use std::path::{Path, PathBuf};
10
-
11
- use anyhow::{Context, Result};
12
-
13
- /// Write `content` to `path` atomically: write to a sibling temp file, fsync,
14
- /// then rename over the target. A rename within the same directory is atomic on
15
- /// POSIX, so a concurrent reader never observes a partially-written file.
16
- ///
17
- /// Used by every handoff write path (tasks, config, sessions, referrals) so that
18
- /// the VSCode extension — which writes the same files — never reads torn data.
19
- pub fn atomic_write(path: impl AsRef<Path>, content: &[u8]) -> Result<()> {
20
- use std::io::Write;
21
-
22
- let path = path.as_ref();
23
- let dir = path.parent().ok_or_else(|| {
24
- anyhow::anyhow!("Cannot determine parent directory for {}", path.display())
25
- })?;
26
- let file_name = path
27
- .file_name()
28
- .and_then(|n| n.to_str())
29
- .ok_or_else(|| anyhow::anyhow!("Invalid file name for {}", path.display()))?;
30
-
31
- // Unique-per-process temp name in the same directory (so rename is atomic).
32
- let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
33
- let tmp_path = dir.join(tmp_name);
34
-
35
- let mut f = std::fs::File::create(&tmp_path)
36
- .with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?;
37
- f.write_all(content)
38
- .with_context(|| format!("Failed to write temp file {}", tmp_path.display()))?;
39
- f.sync_all()
40
- .with_context(|| format!("Failed to sync temp file {}", tmp_path.display()))?;
41
- drop(f);
42
-
43
- std::fs::rename(&tmp_path, path).with_context(|| {
44
- // Best-effort cleanup so a failed rename doesn't leave a stray temp file.
45
- let _ = std::fs::remove_file(&tmp_path);
46
- format!(
47
- "Failed to rename {} -> {}",
48
- tmp_path.display(),
49
- path.display()
50
- )
51
- })?;
52
- Ok(())
53
- }
54
-
55
- pub fn expand_tilde(path: &str) -> String {
56
- if let Some(rest) = path.strip_prefix("~/") {
57
- if let Ok(home) = std::env::var("HOME") {
58
- return format!("{home}/{rest}");
59
- }
60
- }
61
- path.to_string()
62
- }
63
-
64
- pub fn handoff_dir(project_dir: &Path) -> PathBuf {
65
- project_dir.join(".handoff")
66
- }
67
-
68
- pub fn ensure_handoff_exists(project_dir: &Path) -> Result<PathBuf> {
69
- let dir = handoff_dir(project_dir);
70
- if !dir.exists() {
71
- anyhow::bail!(
72
- ".handoff/ directory not found in {}. Run handoff_init first.",
73
- project_dir.display()
74
- );
75
- }
76
- Ok(dir)
77
- }
78
-
79
- pub fn init_handoff(project_dir: &Path, project_name: &str, description: &str) -> Result<()> {
80
- let dir = handoff_dir(project_dir);
81
- if dir.exists() {
82
- anyhow::bail!(
83
- ".handoff/ already exists in {}. Project is already initialized.",
84
- project_dir.display()
85
- );
86
- }
87
-
88
- std::fs::create_dir_all(dir.join("sessions")).context("Failed to create .handoff/sessions/")?;
89
- std::fs::create_dir_all(dir.join("tasks")).context("Failed to create .handoff/tasks/")?;
90
- std::fs::create_dir_all(dir.join("memory")).context("Failed to create .handoff/memory/")?;
91
-
92
- let config = config::Config::new(project_name, description);
93
- config::write_config(&dir.join("config.toml"), &config)?;
94
-
95
- Ok(())
96
- }
@@ -1,248 +0,0 @@
1
- use std::path::{Path, PathBuf};
2
-
3
- use anyhow::{Context, Result};
4
- use serde::{Deserialize, Serialize};
5
- use serde_json::Value;
6
-
7
- const VALID_REFERRAL_TYPES: &[&str] = &["improvement", "bug", "request", "info"];
8
- const VALID_REFERRAL_STATUSES: &[&str] = &["open", "acknowledged", "resolved"];
9
-
10
- pub fn is_valid_referral_type(t: &str) -> bool {
11
- VALID_REFERRAL_TYPES.contains(&t)
12
- }
13
-
14
- pub fn is_valid_referral_status(s: &str) -> bool {
15
- VALID_REFERRAL_STATUSES.contains(&s)
16
- }
17
-
18
- #[derive(Debug, Clone, Serialize, Deserialize)]
19
- pub struct ReferralData {
20
- pub id: String,
21
- pub source_project: String,
22
- pub source_project_dir: String,
23
- pub created_at: String,
24
- pub referral_type: String,
25
- pub summary: String,
26
- #[serde(default, skip_serializing_if = "Option::is_none")]
27
- pub details: Option<String>,
28
- #[serde(default, skip_serializing_if = "Option::is_none")]
29
- pub priority: Option<String>,
30
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
31
- pub tasks: Vec<Value>,
32
- #[serde(default, skip_serializing_if = "Option::is_none")]
33
- pub context: Option<Value>,
34
- }
35
-
36
- #[derive(Debug, Clone, Serialize, Deserialize)]
37
- pub struct ReferralSummary {
38
- pub id: String,
39
- pub source_project: String,
40
- pub referral_type: String,
41
- pub summary: String,
42
- #[serde(skip_serializing_if = "Option::is_none")]
43
- pub priority: Option<String>,
44
- pub status: String,
45
- pub created_at: String,
46
- }
47
-
48
- pub fn write_referral(referrals_dir: &Path, data: &ReferralData) -> Result<PathBuf> {
49
- std::fs::create_dir_all(referrals_dir).with_context(|| {
50
- format!(
51
- "Failed to create referrals dir: {}",
52
- referrals_dir.display()
53
- )
54
- })?;
55
-
56
- let slug = source_to_slug(&data.source_project);
57
- let id_suffix = data.id.replace("ref-", "");
58
- let filename = format!("{id_suffix}-{slug}.open.json");
59
- let file_path = referrals_dir.join(&filename);
60
-
61
- let content = serde_json::to_string_pretty(data).context("Failed to serialize referral")?;
62
- crate::storage::atomic_write(&file_path, content.as_bytes())
63
- .with_context(|| format!("Failed to write referral: {}", file_path.display()))?;
64
-
65
- Ok(file_path)
66
- }
67
-
68
- pub fn read_referrals(
69
- referrals_dir: &Path,
70
- status_filter: Option<&str>,
71
- ) -> Result<Vec<(ReferralData, String)>> {
72
- let mut results = Vec::new();
73
-
74
- if !referrals_dir.exists() {
75
- return Ok(results);
76
- }
77
-
78
- let mut entries: Vec<_> = std::fs::read_dir(referrals_dir)?
79
- .filter_map(|e| e.ok())
80
- .collect();
81
- entries.sort_by_key(|e| e.file_name());
82
-
83
- for entry in entries {
84
- let name = entry.file_name().to_string_lossy().to_string();
85
- if let Some(status) = parse_referral_status(&name) {
86
- if let Some(filter) = status_filter {
87
- if status != filter {
88
- continue;
89
- }
90
- }
91
- let content = std::fs::read_to_string(entry.path())
92
- .with_context(|| format!("Failed to read referral: {}", entry.path().display()))?;
93
- let data: ReferralData = serde_json::from_str(&content)
94
- .with_context(|| format!("Failed to parse referral: {}", entry.path().display()))?;
95
- results.push((data, status));
96
- }
97
- }
98
-
99
- Ok(results)
100
- }
101
-
102
- pub fn read_referral_summaries(
103
- referrals_dir: &Path,
104
- status_filter: Option<&str>,
105
- ) -> Result<Vec<ReferralSummary>> {
106
- let referrals = read_referrals(referrals_dir, status_filter)?;
107
- Ok(referrals
108
- .into_iter()
109
- .map(|(data, status)| ReferralSummary {
110
- id: data.id,
111
- source_project: data.source_project,
112
- referral_type: data.referral_type,
113
- summary: data.summary,
114
- priority: data.priority,
115
- status,
116
- created_at: data.created_at,
117
- })
118
- .collect())
119
- }
120
-
121
- pub fn change_referral_status(
122
- referrals_dir: &Path,
123
- referral_id: &str,
124
- new_status: &str,
125
- ) -> Result<()> {
126
- if !is_valid_referral_status(new_status) {
127
- anyhow::bail!(
128
- "Invalid referral status: '{new_status}'. Must be one of: {}",
129
- VALID_REFERRAL_STATUSES.join(", ")
130
- );
131
- }
132
-
133
- let (old_path, old_status) = find_referral_file(referrals_dir, referral_id)?
134
- .ok_or_else(|| anyhow::anyhow!("Referral not found: {referral_id}"))?;
135
-
136
- if old_status == new_status {
137
- return Ok(());
138
- }
139
-
140
- let old_name = old_path
141
- .file_name()
142
- .ok_or_else(|| anyhow::anyhow!("Invalid referral path"))?
143
- .to_string_lossy()
144
- .to_string();
145
-
146
- let new_name = old_name.replace(
147
- &format!(".{old_status}.json"),
148
- &format!(".{new_status}.json"),
149
- );
150
- let new_path = referrals_dir.join(&new_name);
151
-
152
- std::fs::rename(&old_path, &new_path).with_context(|| {
153
- format!(
154
- "Failed to rename {} -> {}",
155
- old_path.display(),
156
- new_path.display()
157
- )
158
- })?;
159
-
160
- Ok(())
161
- }
162
-
163
- /// Read a single referral's full data by ID, returning the data and its status.
164
- /// Searches every status (open / acknowledged / resolved). Matches by exact ID
165
- /// first, then falls back to a unique prefix match for convenience.
166
- pub fn read_referral_by_id(
167
- referrals_dir: &Path,
168
- referral_id: &str,
169
- ) -> Result<Option<(ReferralData, String)>> {
170
- if !referrals_dir.exists() {
171
- return Ok(None);
172
- }
173
-
174
- let mut prefix_matches: Vec<(ReferralData, String)> = Vec::new();
175
-
176
- for entry in std::fs::read_dir(referrals_dir)? {
177
- let entry = entry?;
178
- let name = entry.file_name().to_string_lossy().to_string();
179
- if let Some(status) = parse_referral_status(&name) {
180
- let content = std::fs::read_to_string(entry.path())?;
181
- if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
182
- if data.id == referral_id {
183
- return Ok(Some((data, status)));
184
- }
185
- if data.id.starts_with(referral_id) {
186
- prefix_matches.push((data, status));
187
- }
188
- }
189
- }
190
- }
191
-
192
- match prefix_matches.len() {
193
- 0 => Ok(None),
194
- 1 => Ok(Some(prefix_matches.into_iter().next().unwrap())),
195
- n => anyhow::bail!(
196
- "Ambiguous referral id prefix '{referral_id}' matches {n} referrals; use the full id"
197
- ),
198
- }
199
- }
200
-
201
- pub fn find_referral_file(
202
- referrals_dir: &Path,
203
- referral_id: &str,
204
- ) -> Result<Option<(PathBuf, String)>> {
205
- if !referrals_dir.exists() {
206
- return Ok(None);
207
- }
208
-
209
- for entry in std::fs::read_dir(referrals_dir)? {
210
- let entry = entry?;
211
- let name = entry.file_name().to_string_lossy().to_string();
212
- if let Some(status) = parse_referral_status(&name) {
213
- let content = std::fs::read_to_string(entry.path())?;
214
- if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
215
- if data.id == referral_id {
216
- return Ok(Some((entry.path(), status)));
217
- }
218
- }
219
- }
220
- }
221
-
222
- Ok(None)
223
- }
224
-
225
- fn parse_referral_status(filename: &str) -> Option<String> {
226
- let name = filename.strip_suffix(".json")?;
227
- for status in VALID_REFERRAL_STATUSES {
228
- if name.ends_with(&format!(".{status}")) {
229
- return Some(status.to_string());
230
- }
231
- }
232
- None
233
- }
234
-
235
- fn source_to_slug(source: &str) -> String {
236
- let slug: String = source
237
- .chars()
238
- .take(30)
239
- .map(|c| {
240
- if c.is_ascii_alphanumeric() {
241
- c.to_ascii_lowercase()
242
- } else {
243
- '-'
244
- }
245
- })
246
- .collect();
247
- slug.trim_matches('-').to_string()
248
- }