handoff-mcp-server 0.12.0 → 0.13.1

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/Cargo.lock CHANGED
@@ -144,10 +144,11 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.12.0"
147
+ version = "0.13.1"
148
148
  dependencies = [
149
149
  "anyhow",
150
150
  "chrono",
151
+ "lexsim",
151
152
  "serde",
152
153
  "serde_json",
153
154
  "tempfile",
@@ -242,6 +243,16 @@ version = "0.1.0"
242
243
  source = "registry+https://github.com/rust-lang/crates.io-index"
243
244
  checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
244
245
 
246
+ [[package]]
247
+ name = "lexsim"
248
+ version = "0.1.0"
249
+ source = "registry+https://github.com/rust-lang/crates.io-index"
250
+ checksum = "579b9d7ab407540bead4688eaf1675410a198d77c237a30cda55597df98926e1"
251
+ dependencies = [
252
+ "unicode-normalization",
253
+ "unicode-segmentation",
254
+ ]
255
+
245
256
  [[package]]
246
257
  name = "libc"
247
258
  version = "0.2.186"
@@ -382,6 +393,7 @@ version = "1.0.150"
382
393
  source = "registry+https://github.com/rust-lang/crates.io-index"
383
394
  checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
384
395
  dependencies = [
396
+ "indexmap",
385
397
  "itoa",
386
398
  "memchr",
387
399
  "serde",
@@ -454,6 +466,21 @@ dependencies = [
454
466
  "syn",
455
467
  ]
456
468
 
469
+ [[package]]
470
+ name = "tinyvec"
471
+ version = "1.11.0"
472
+ source = "registry+https://github.com/rust-lang/crates.io-index"
473
+ checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
474
+ dependencies = [
475
+ "tinyvec_macros",
476
+ ]
477
+
478
+ [[package]]
479
+ name = "tinyvec_macros"
480
+ version = "0.1.1"
481
+ source = "registry+https://github.com/rust-lang/crates.io-index"
482
+ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
483
+
457
484
  [[package]]
458
485
  name = "toml"
459
486
  version = "0.8.23"
@@ -501,6 +528,21 @@ version = "1.0.24"
501
528
  source = "registry+https://github.com/rust-lang/crates.io-index"
502
529
  checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
503
530
 
531
+ [[package]]
532
+ name = "unicode-normalization"
533
+ version = "0.1.25"
534
+ source = "registry+https://github.com/rust-lang/crates.io-index"
535
+ checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
536
+ dependencies = [
537
+ "tinyvec",
538
+ ]
539
+
540
+ [[package]]
541
+ name = "unicode-segmentation"
542
+ version = "1.13.3"
543
+ source = "registry+https://github.com/rust-lang/crates.io-index"
544
+ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
545
+
504
546
  [[package]]
505
547
  name = "unicode-xid"
506
548
  version = "0.2.6"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.12.0"
3
+ version = "0.13.1"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
@@ -9,17 +9,18 @@ homepage = "https://github.com/alphaelements/handoff-mcp"
9
9
  readme = "README.md"
10
10
  keywords = ["mcp", "ai", "claude", "handoff", "context"]
11
11
  categories = ["command-line-utilities", "development-tools"]
12
- rust-version = "1.70"
12
+ rust-version = "1.85"
13
13
  exclude = ["lefthook.yml", "tmp/", "wiki/", "docs/", ".claude/", ".vscode/"]
14
14
 
15
15
  [dependencies]
16
16
  serde = { version = "1", features = ["derive"] }
17
- serde_json = "1"
17
+ serde_json = { version = "1", features = ["preserve_order"] }
18
18
  toml = "0.8"
19
19
  toml_edit = "0.22"
20
20
  chrono = { version = "0.4", features = ["serde"] }
21
21
  anyhow = "1"
22
22
  thiserror = "2"
23
+ lexsim = "0.1.0"
23
24
 
24
25
  [dev-dependencies]
25
26
  tempfile = "3.27.0"
package/README.md CHANGED
@@ -33,12 +33,23 @@ At session start, the agent calls `handoff_load_context` to pick up where things
33
33
 
34
34
  ## Installation
35
35
 
36
- ### npm (recommended)
36
+ ### cargo (recommended if you have Rust)
37
+
38
+ ```bash
39
+ cargo install handoff-mcp
40
+ ```
41
+
42
+ ### npm
37
43
 
38
44
  ```bash
39
45
  npm install -g handoff-mcp-server
40
46
  ```
41
47
 
48
+ Both install the same binary. `cargo install` fetches the crate from
49
+ [crates.io](https://crates.io/crates/handoff-mcp) and compiles it directly;
50
+ `npm install` downloads the source and runs `cargo build --release` via
51
+ postinstall, so either way you need a Rust toolchain.
52
+
42
53
  ### Build from source
43
54
 
44
55
  ```bash
@@ -73,6 +84,34 @@ The `-s user` flag registers it globally (available in all projects). Verify wit
73
84
  }
74
85
  ```
75
86
 
87
+ ### Enable automatic memory injection (optional)
88
+
89
+ If you want project memories to be automatically injected into your context
90
+ (instead of calling `handoff_memory_query` manually every time), run:
91
+
92
+ ```bash
93
+ handoff-mcp setup
94
+ ```
95
+
96
+ This installs Claude Code hooks into `~/.claude/settings.json` that
97
+ automatically call `handoff_memory_query` on every prompt and file edit, and run
98
+ `handoff_memory_cleanup` at session start. Restart Claude Code after running setup.
99
+
100
+ You can check the current status or remove the hooks:
101
+
102
+ ```bash
103
+ handoff-mcp setup --check # Show hook status
104
+ handoff-mcp setup --uninstall # Remove handoff hooks
105
+ ```
106
+
107
+ The hooks fire on every prompt and file edit, which adds a small overhead per
108
+ interaction. If you want to stop automatic injection, run
109
+ `handoff-mcp setup --uninstall` — the memory tools themselves remain available
110
+ for manual use, only the automatic hooks are removed.
111
+
112
+ See [Automatic injection via hooks](#automatic-injection-via-hooks) for the
113
+ manual configuration alternative.
114
+
76
115
  ## Quick Start
77
116
 
78
117
  1. **Initialize** a project:
@@ -155,9 +194,21 @@ rename) so a concurrent reader never sees a partially-written file.
155
194
  | `handoff_dashboard` | Overview of all handoff-enabled projects |
156
195
  | `handoff_import_context` | Bulk import tasks and session data from documents |
157
196
  | `handoff_refer` | Send a cross-project referral (bug, improvement, request) |
158
- | `handoff_list_referrals` | List incoming referrals from other projects |
197
+ | `handoff_list_referrals` | List incoming referrals from other projects (summaries only) |
198
+ | `handoff_get_referral` | Fetch one incoming referral in full — details, suggested tasks, done_criteria, context |
159
199
  | `handoff_update_referral` | Update referral status (open → acknowledged → resolved) |
160
200
 
201
+ ### Project Memory
202
+
203
+ | Tool | Purpose |
204
+ |------|---------|
205
+ | `handoff_memory_save` | Save a durable project memory (lesson/rule/convention/gotcha); detects exact and near-duplicate memories and hands near-duplicates back for AI-driven merge |
206
+ | `handoff_memory_query` | Return the memories most relevant to the current prompt/file (BM25 + scope-path boost); with a `session_id`, suppresses repeats already injected this session |
207
+ | `handoff_memory_delete` | Delete a memory by id (full id or unique prefix) |
208
+ | `handoff_memory_cleanup` | Housekeeping (for SessionStart): silently merge exact duplicates, return near-duplicate/stale recommendations, gc old injection sidecars |
209
+
210
+ See [Project Memory](#project-memory-1) below for what it is and how to wire automatic injection.
211
+
161
212
  ### Task Data Model
162
213
 
163
214
  Tasks are stored as a directory tree with status encoded in filenames:
@@ -223,9 +274,11 @@ name = "my-project"
223
274
  description = "Project description"
224
275
 
225
276
  [settings]
226
- history_limit = 20 # Max closed sessions to keep
227
- done_task_limit = 10 # Max completed tasks to show
228
- auto_git_summary = true # Capture git state automatically
277
+ history_limit = 20 # Max closed sessions to keep
278
+ done_task_limit = 10 # Max completed tasks to show
279
+ auto_git_summary = true # Capture git state automatically
280
+ require_estimate_hours = true # Require estimate_hours on leaf tasks (default true)
281
+ ai_estimate_multiplier = 0.2 # Multiplier turning human estimates into AI-effort hours
229
282
 
230
283
  [dashboard]
231
284
  scan_dirs = ["~/pro/"] # Directories to scan for dashboard
@@ -263,6 +316,152 @@ mode = "compare" # plan, actual, compare
263
316
 
264
317
  All configuration sections can be updated via `handoff_update_config` with dot-notation keys (e.g., `"calendar.work_hours_per_day": 7`).
265
318
 
319
+ ### Estimates and AI effort
320
+
321
+ handoff-mcp distinguishes the **raw human-effort estimate** you record on a task
322
+ from the **AI-effort hours** used in scheduling and metrics:
323
+
324
+ - **`require_estimate_hours`** (default `true`) — `handoff_update_task` rejects
325
+ creating or updating a *leaf* task (in `todo` / `in_progress` / `review` /
326
+ `done`) without `schedule.estimate_hours > 0`. Parent tasks (with children) and
327
+ `blocked` / `skipped` tasks are exempt, and an estimate already on the task
328
+ satisfies the requirement. Set to `false` to opt out.
329
+ - **`ai_estimate_multiplier`** (default `0.2`) — the factor applied to raw
330
+ estimates to model how long the work takes when an AI agent does it. Always
331
+ record the *raw human-effort* estimate; the multiplier is applied at
332
+ aggregation time by `handoff_get_metrics` (`total_adjusted_estimate_hours` and
333
+ per-milestone `adjusted_estimate_hours`) and `handoff_get_capacity`. Raw values
334
+ are never overwritten.
335
+
336
+ ## Project Memory
337
+
338
+ Sessions answer *"what was I doing last time?"*. **Memory** answers a longer-lived
339
+ question: *"what should every session in this project always know?"* — durable
340
+ lessons, rules, conventions, and gotchas that outlive any one session.
341
+
342
+ > **Note:** The memory tools (`handoff_memory_save`, `handoff_memory_query`, etc.) can always be
343
+ > called directly by the agent. For **automatic** injection — where relevant
344
+ > memories are surfaced on every prompt without the agent asking — you need to
345
+ > configure Claude Code hooks. See [Automatic injection via hooks](#automatic-injection-via-hooks).
346
+
347
+ Memories live in `.handoff/memory/` (one JSON file per memory, plus per-session
348
+ `injected/` sidecars). A built-in multilingual similarity engine (Japanese /
349
+ English, dictionary-free) ranks relevance and detects duplicates, all in-memory
350
+ and sub-millisecond.
351
+
352
+ ### Using it directly
353
+
354
+ The agent can call the memory tools at any time:
355
+
356
+ - `handoff_memory_save` — record a memory. An exact duplicate is reported (not
357
+ rewritten); a near-duplicate comes back as a `conflict` with both bodies so the
358
+ agent can merge them (`merge_into=<id>`, `absorb_ids=[…]`) or save separately
359
+ with `force=true`. **handoff-mcp never merges for you** — it surfaces both
360
+ bodies and lets the agent decide.
361
+ - `handoff_memory_query` — fetch the memories most relevant to some text and/or files.
362
+ - `handoff_memory_delete` / `handoff_memory_cleanup` — prune and de-duplicate the store.
363
+
364
+ ### Automatic injection via hooks
365
+
366
+ MCP is request/response — the server cannot push a memory into the agent's
367
+ context on its own. **Claude Code hooks** close that gap: they fire regardless of
368
+ what the agent intends, call `handoff_memory_query`, and inject the matching memories as
369
+ `additionalContext`. A per-session diff (keyed on the hook `session_id`) ensures
370
+ the same memory is **not injected twice in one session** — and an *edited* memory
371
+ (new content hash) is re-injected.
372
+
373
+ | Event | Calls | Effect |
374
+ |-------|-------|--------|
375
+ | `UserPromptSubmit` | `handoff_memory_query` (prompt text) | Inject memories relevant to the prompt |
376
+ | `PreToolUse` (`Edit\|Write\|MultiEdit`) | `handoff_memory_query` (file path) | Inject memories scoped to the file being edited |
377
+ | `SessionStart` | `handoff_memory_cleanup` | Merge exact duplicates, gc old sidecars |
378
+
379
+ > **Wire hooks in your *user/global* settings, not in the repo.** Hooks are a
380
+ > personal workflow choice; the handoff-mcp repo does not ship a `.claude/`
381
+ > hooks config, and you should not commit one into a shared project. Put the
382
+ > config in `~/.claude/settings.json` (global) or your own
383
+ > `.claude/settings.local.json` (git-ignored).
384
+
385
+ **Native `mcp_tool` hook (preferred).** Recent Claude Code versions can call an
386
+ MCP tool from a hook directly, with no wrapper script. In
387
+ `~/.claude/settings.json`:
388
+
389
+ ```json
390
+ {
391
+ "hooks": {
392
+ "UserPromptSubmit": [
393
+ { "hooks": [ {
394
+ "type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query",
395
+ "input": { "project_dir": "${cwd}",
396
+ "session_id": "${session_id}", "text": "${prompt}" }
397
+ } ] }
398
+ ],
399
+ "PreToolUse": [
400
+ { "matcher": "Edit|Write|MultiEdit", "hooks": [ {
401
+ "type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query",
402
+ "input": { "project_dir": "${cwd}",
403
+ "session_id": "${session_id}", "tool_name": "${tool_name}",
404
+ "text": "${tool_input.file_path}",
405
+ "file_paths": ["${tool_input.file_path}"] }
406
+ } ] }
407
+ ],
408
+ "SessionStart": [
409
+ { "hooks": [ {
410
+ "type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_cleanup",
411
+ "input": { "project_dir": "${cwd}" }
412
+ } ] }
413
+ ]
414
+ }
415
+ }
416
+ ```
417
+
418
+ (`server` must match the name you registered handoff-mcp under — `handoff` in the
419
+ [Setup](#setup) examples.)
420
+
421
+ **Wrapper script fallback.** If your Claude Code version doesn't support the
422
+ `mcp_tool` hook type, use the bundled `command` wrapper
423
+ [`scripts/handoff-memory-hook.py`](scripts/handoff-memory-hook.py). It reads the
424
+ hook JSON on stdin, calls the server over JSON-RPC, and emits
425
+ `additionalContext` — the memory tools return their payload as a JSON *string* so
426
+ both paths parse it identically. Point all three hooks at it:
427
+
428
+ ```json
429
+ {
430
+ "hooks": {
431
+ "UserPromptSubmit": [
432
+ { "hooks": [ { "type": "command",
433
+ "command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
434
+ ],
435
+ "PreToolUse": [
436
+ { "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command",
437
+ "command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
438
+ ],
439
+ "SessionStart": [
440
+ { "hooks": [ { "type": "command",
441
+ "command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
442
+ ]
443
+ }
444
+ }
445
+ ```
446
+
447
+ The script resolves the `handoff-mcp` binary from `PATH` (override with
448
+ `HANDOFF_MCP_BIN`) and **fails safe**: on any error it prints nothing and exits
449
+ 0, so a memory miss is silent and never blocks your prompt.
450
+
451
+ ### Memory settings
452
+
453
+ All under `[settings]` in `.handoff/config.toml`, all with safe defaults
454
+ (existing projects need no change), all settable via `handoff_update_config`:
455
+
456
+ | Key | Default | Meaning |
457
+ |-----|---------|---------|
458
+ | `memory_enabled` | `true` | Master switch. When `false`, all four memory tools return a benign empty result and write nothing |
459
+ | `memory_dup_threshold` | `0.72` | Jaccard similarity at/above which a save is a near-duplicate conflict and cleanup groups a cluster |
460
+ | `memory_query_min_score` | `0.5` | BM25 relevance floor for `handoff_memory_query` results |
461
+ | `memory_query_limit` | `5` | Max memories returned per query |
462
+ | `memory_stale_days` | `60` | Days without a reference before a memory is flagged stale |
463
+ | `memory_injected_gc_days` | `14` | Age at which per-session injection sidecars are garbage-collected |
464
+
266
465
  ## MCP Resources
267
466
 
268
467
  | URI | Description |
@@ -290,6 +489,10 @@ This project uses handoff-mcp for session continuity.
290
489
  - **Decisions**: Record decisions with confidence levels as they are made,
291
490
  not just at session end. Use `confirmed` for verified facts, `estimated`
292
491
  for reasonable assumptions, `unverified` for unknowns.
492
+ - **Project memory**: Use `handoff_memory_save` to record durable lessons, rules,
493
+ conventions, and gotchas that every future session should know. Use
494
+ `handoff_memory_query` to retrieve relevant memories. Near-duplicate memories are
495
+ surfaced as conflicts for you to merge or force-save — never merged silently.
293
496
  ```
294
497
 
295
498
  ## Skill File (Optional)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
@@ -0,0 +1,29 @@
1
+ # shellcheck shell=sh
2
+ # cargo-env.sh — make cargo commands work across heterogeneous dev environments.
3
+ #
4
+ # Some environments (e.g. a Docker image where CARGO_HOME=/usr/local/cargo is
5
+ # owned by root) expose a CARGO_HOME that the current user cannot write to.
6
+ # cargo then fails to download/cache crates or fetch the advisory-db, breaking
7
+ # the git hooks even though nothing is wrong with the code.
8
+ #
9
+ # This script detects an unwritable CARGO_HOME and transparently falls back to
10
+ # the per-user $HOME/.cargo, which every supported environment can write to.
11
+ # When CARGO_HOME is already writable (local macOS, a synced Mac, or simply
12
+ # unset so cargo uses its default ~/.cargo) it leaves the environment untouched.
13
+ #
14
+ # Usage (from a hook or shell):
15
+ # . scripts/cargo-env.sh && cargo <args>
16
+
17
+ # Resolve the directory cargo would use right now.
18
+ _cargo_home="${CARGO_HOME:-$HOME/.cargo}"
19
+
20
+ # Probe writability with a temp file; fall back to $HOME/.cargo if we can't write.
21
+ if ! ( mkdir -p "$_cargo_home" 2>/dev/null && touch "$_cargo_home/.cargo-env-wtest" 2>/dev/null ); then
22
+ export CARGO_HOME="$HOME/.cargo"
23
+ mkdir -p "$CARGO_HOME" 2>/dev/null || true
24
+ else
25
+ rm -f "$_cargo_home/.cargo-env-wtest" 2>/dev/null || true
26
+ export CARGO_HOME="$_cargo_home"
27
+ fi
28
+
29
+ unset _cargo_home
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env python3
2
+ """Claude Code hook wrapper for handoff-mcp project memory.
3
+
4
+ This is the **fallback path** for memory auto-injection. The preferred wiring is
5
+ a native ``mcp_tool`` hook that calls ``handoff_memory_query`` / ``handoff_memory_cleanup``
6
+ directly (see README "Project Memory"); this script exists for Claude Code
7
+ versions that lack the ``mcp_tool`` hook type, by translating a Claude Code hook
8
+ event into a single JSON-RPC ``tools/call`` against the handoff-mcp server and
9
+ emitting the result as ``hookSpecificOutput.additionalContext``.
10
+
11
+ It speaks the server's line-delimited JSON-RPC over stdio: one request object on
12
+ one line in, one response object on one line out. ``handoff_memory_query`` /
13
+ ``handoff_memory_cleanup`` return their payload as a JSON *string* inside
14
+ ``result.content[0].text`` (so both this wrapper and the native hook path parse
15
+ it identically), which this script re-parses.
16
+
17
+ Wiring (in ``~/.claude/settings.json`` — do NOT commit this into a repo):
18
+
19
+ {
20
+ "hooks": {
21
+ "UserPromptSubmit": [
22
+ { "hooks": [ { "type": "command",
23
+ "command": "handoff-mcp-memory-hook" } ] }
24
+ ],
25
+ "PreToolUse": [
26
+ { "matcher": "Edit|Write|MultiEdit",
27
+ "hooks": [ { "type": "command",
28
+ "command": "handoff-mcp-memory-hook" } ] }
29
+ ],
30
+ "SessionStart": [
31
+ { "hooks": [ { "type": "command",
32
+ "command": "handoff-mcp-memory-hook" } ] }
33
+ ]
34
+ }
35
+ }
36
+
37
+ The same script handles all three events; it picks the tool from
38
+ ``hook_event_name`` on stdin.
39
+
40
+ Environment:
41
+ - ``HANDOFF_MCP_BIN`` — path to the ``handoff-mcp`` binary (default: ``handoff-mcp``
42
+ resolved on ``PATH``).
43
+
44
+ Design contract: this script must **never break the session**. On any error
45
+ (binary missing, bad JSON, timeout) it prints nothing and exits 0 — a memory
46
+ miss is silent, never a blocked prompt.
47
+ """
48
+
49
+ # `X | None` annotations are evaluated lazily so this runs on Python 3.7+
50
+ # (a hook host may not have 3.10). The annotations are never evaluated at runtime.
51
+ from __future__ import annotations
52
+
53
+ import json
54
+ import os
55
+ import shutil
56
+ import subprocess
57
+ import sys
58
+
59
+ # Map a Claude Code hook event to the memory tool it drives.
60
+ QUERY_EVENTS = {"UserPromptSubmit", "PreToolUse"}
61
+ CLEANUP_EVENTS = {"SessionStart"}
62
+
63
+ # How long to wait for the server to answer one request, in seconds. The query
64
+ # is in-memory and sub-millisecond; this is just a safety net so a hung binary
65
+ # can never stall the prompt.
66
+ TIMEOUT_SECONDS = 5.0
67
+
68
+
69
+ def _server_bin() -> str | None:
70
+ """Resolve the handoff-mcp binary path, or None if it can't be found."""
71
+ explicit = os.environ.get("HANDOFF_MCP_BIN")
72
+ if explicit:
73
+ return explicit if os.path.exists(explicit) else None
74
+ return shutil.which("handoff-mcp")
75
+
76
+
77
+ def _call(bin_path: str, tool: str, arguments: dict) -> dict | None:
78
+ """Send one ``tools/call`` line and parse the inner JSON payload.
79
+
80
+ Returns the decoded inner object (e.g. ``{"memories": [...]}`), or None on
81
+ any transport / decode failure. The server is stateless per line, so no
82
+ ``initialize`` handshake is needed — a bare ``tools/call`` is answered.
83
+ """
84
+ request = {
85
+ "jsonrpc": "2.0",
86
+ "id": 1,
87
+ "method": "tools/call",
88
+ "params": {"name": tool, "arguments": arguments},
89
+ }
90
+ try:
91
+ proc = subprocess.run(
92
+ [bin_path],
93
+ input=json.dumps(request) + "\n",
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=TIMEOUT_SECONDS,
97
+ )
98
+ except (OSError, subprocess.TimeoutExpired):
99
+ return None
100
+
101
+ # The response is the first non-empty stdout line (stderr carries the
102
+ # startup banner and is ignored).
103
+ line = next((ln for ln in proc.stdout.splitlines() if ln.strip()), None)
104
+ if not line:
105
+ return None
106
+ try:
107
+ envelope = json.loads(line)
108
+ text = envelope["result"]["content"][0]["text"]
109
+ return json.loads(text)
110
+ except (json.JSONDecodeError, KeyError, IndexError, TypeError):
111
+ return None
112
+
113
+
114
+ def _file_paths_from_tool_input(tool_input) -> list[str]:
115
+ """Pull file paths out of a PreToolUse ``tool_input`` (Edit/Write/MultiEdit)."""
116
+ if not isinstance(tool_input, dict):
117
+ return []
118
+ paths = []
119
+ fp = tool_input.get("file_path")
120
+ if isinstance(fp, str) and fp:
121
+ paths.append(fp)
122
+ # MultiEdit and similar may carry an edits array; each can name a file.
123
+ for edit in tool_input.get("edits", []) or []:
124
+ if isinstance(edit, dict):
125
+ efp = edit.get("file_path")
126
+ if isinstance(efp, str) and efp:
127
+ paths.append(efp)
128
+ return paths
129
+
130
+
131
+ def _emit(event: str, context: str) -> None:
132
+ """Print a Claude Code hook output object carrying additionalContext."""
133
+ if not context:
134
+ return
135
+ out = {
136
+ "hookSpecificOutput": {
137
+ "hookEventName": event,
138
+ "additionalContext": context,
139
+ }
140
+ }
141
+ print(json.dumps(out))
142
+
143
+
144
+ def _format_memories(payload: dict) -> str:
145
+ """Turn a ``handoff_memory_query`` payload into an injectable context block."""
146
+ memories = payload.get("memories") or []
147
+ if not memories:
148
+ return ""
149
+ lines = ["Relevant project memories (handoff-mcp):"]
150
+ for m in memories:
151
+ text = (m.get("text") or "").strip()
152
+ if not text:
153
+ continue
154
+ kind = m.get("kind") or "memory"
155
+ lines.append(f"- [{kind}] {text}")
156
+ # Only a header with no bullets means nothing useful to inject.
157
+ return "\n".join(lines) if len(lines) > 1 else ""
158
+
159
+
160
+ def main() -> int:
161
+ raw = sys.stdin.read()
162
+ try:
163
+ hook = json.loads(raw) if raw.strip() else {}
164
+ except json.JSONDecodeError:
165
+ return 0 # malformed hook input — stay silent, never block
166
+
167
+ event = hook.get("hook_event_name") or ""
168
+ bin_path = _server_bin()
169
+ if not bin_path:
170
+ return 0 # server not installed — nothing to inject
171
+
172
+ project_dir = hook.get("cwd") or os.getcwd()
173
+ session_id = hook.get("session_id")
174
+
175
+ if event in QUERY_EVENTS:
176
+ # UserPromptSubmit → match the prompt. PreToolUse → match the file(s).
177
+ prompt = hook.get("prompt") or ""
178
+ tool_name = hook.get("tool_name")
179
+ file_paths = _file_paths_from_tool_input(hook.get("tool_input"))
180
+ # Text fed to BM25: the prompt for UserPromptSubmit, else the file paths
181
+ # (the server also adds basenames + tool_name to the query).
182
+ text = prompt if prompt else " ".join(file_paths)
183
+ arguments = {"project_dir": project_dir, "text": text}
184
+ if session_id:
185
+ arguments["session_id"] = session_id
186
+ if tool_name:
187
+ arguments["tool_name"] = tool_name
188
+ if file_paths:
189
+ arguments["file_paths"] = file_paths
190
+
191
+ payload = _call(bin_path, "handoff_memory_query", arguments)
192
+ if payload:
193
+ _emit(event, _format_memories(payload))
194
+ return 0
195
+
196
+ if event in CLEANUP_EVENTS:
197
+ # Housekeeping only: merge exact duplicates and gc sidecars. We do not
198
+ # inject the cleanup recommendations as context (that is for an explicit
199
+ # AI-driven pass), so there is no additionalContext to emit here.
200
+ _call(bin_path, "handoff_memory_cleanup", {"project_dir": project_dir})
201
+ return 0
202
+
203
+ # Unknown event — nothing to do.
204
+ return 0
205
+
206
+
207
+ if __name__ == "__main__":
208
+ sys.exit(main())
package/src/lib.rs CHANGED
@@ -1,2 +1,3 @@
1
1
  pub mod mcp;
2
+ pub mod setup;
2
3
  pub mod storage;
package/src/main.rs CHANGED
@@ -3,6 +3,35 @@ use std::io::{self, BufRead, Write};
3
3
  use handoff_mcp::mcp::protocol::process_line;
4
4
 
5
5
  fn main() {
6
+ let args: Vec<String> = std::env::args().collect();
7
+
8
+ if args.len() >= 2 {
9
+ match args[1].as_str() {
10
+ "setup" => {
11
+ let check = args.iter().any(|a| a == "--check");
12
+ let uninstall = args.iter().any(|a| a == "--uninstall");
13
+ if let Err(e) = handoff_mcp::setup::run_setup(check, uninstall) {
14
+ eprintln!("Error: {e:#}");
15
+ std::process::exit(1);
16
+ }
17
+ return;
18
+ }
19
+ "--version" | "-V" => {
20
+ println!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
21
+ return;
22
+ }
23
+ "--help" | "-h" => {
24
+ print_help();
25
+ return;
26
+ }
27
+ other => {
28
+ eprintln!("Unknown command: {other}");
29
+ eprintln!("Run `handoff-mcp --help` for usage.");
30
+ std::process::exit(1);
31
+ }
32
+ }
33
+ }
34
+
6
35
  eprintln!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
7
36
 
8
37
  let stdin = io::stdin();
@@ -27,3 +56,21 @@ fn main() {
27
56
  }
28
57
  }
29
58
  }
59
+
60
+ fn print_help() {
61
+ println!(
62
+ "handoff-mcp v{version}
63
+ MCP server for AI session handoff
64
+
65
+ USAGE:
66
+ handoff-mcp Start the MCP server (stdio transport)
67
+ handoff-mcp setup Install memory auto-injection hooks into Claude Code
68
+ handoff-mcp setup --check Check if hooks are installed
69
+ handoff-mcp setup --uninstall Remove handoff hooks from Claude Code
70
+
71
+ OPTIONS:
72
+ -h, --help Print this help message
73
+ -V, --version Print version",
74
+ version = env!("CARGO_PKG_VERSION")
75
+ );
76
+ }