handoff-mcp-server 0.11.0 → 0.13.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/Cargo.lock CHANGED
@@ -144,10 +144,11 @@ dependencies = [
144
144
 
145
145
  [[package]]
146
146
  name = "handoff-mcp"
147
- version = "0.11.0"
147
+ version = "0.13.0"
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"
@@ -454,6 +465,21 @@ dependencies = [
454
465
  "syn",
455
466
  ]
456
467
 
468
+ [[package]]
469
+ name = "tinyvec"
470
+ version = "1.11.0"
471
+ source = "registry+https://github.com/rust-lang/crates.io-index"
472
+ checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
473
+ dependencies = [
474
+ "tinyvec_macros",
475
+ ]
476
+
477
+ [[package]]
478
+ name = "tinyvec_macros"
479
+ version = "0.1.1"
480
+ source = "registry+https://github.com/rust-lang/crates.io-index"
481
+ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
482
+
457
483
  [[package]]
458
484
  name = "toml"
459
485
  version = "0.8.23"
@@ -501,6 +527,21 @@ version = "1.0.24"
501
527
  source = "registry+https://github.com/rust-lang/crates.io-index"
502
528
  checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
503
529
 
530
+ [[package]]
531
+ name = "unicode-normalization"
532
+ version = "0.1.25"
533
+ source = "registry+https://github.com/rust-lang/crates.io-index"
534
+ checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
535
+ dependencies = [
536
+ "tinyvec",
537
+ ]
538
+
539
+ [[package]]
540
+ name = "unicode-segmentation"
541
+ version = "1.13.3"
542
+ source = "registry+https://github.com/rust-lang/crates.io-index"
543
+ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
544
+
504
545
  [[package]]
505
546
  name = "unicode-xid"
506
547
  version = "0.2.6"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.11.0"
3
+ version = "0.13.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
@@ -9,7 +9,7 @@ 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]
@@ -20,6 +20,7 @@ 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
@@ -155,9 +155,21 @@ rename) so a concurrent reader never sees a partially-written file.
155
155
  | `handoff_dashboard` | Overview of all handoff-enabled projects |
156
156
  | `handoff_import_context` | Bulk import tasks and session data from documents |
157
157
  | `handoff_refer` | Send a cross-project referral (bug, improvement, request) |
158
- | `handoff_list_referrals` | List incoming referrals from other projects |
158
+ | `handoff_list_referrals` | List incoming referrals from other projects (summaries only) |
159
+ | `handoff_get_referral` | Fetch one incoming referral in full — details, suggested tasks, done_criteria, context |
159
160
  | `handoff_update_referral` | Update referral status (open → acknowledged → resolved) |
160
161
 
162
+ ### Project Memory
163
+
164
+ | Tool | Purpose |
165
+ |------|---------|
166
+ | `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 |
167
+ | `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 |
168
+ | `memory_delete` | Delete a memory by id (full id or unique prefix) |
169
+ | `memory_cleanup` | Housekeeping (for SessionStart): silently merge exact duplicates, return near-duplicate/stale recommendations, gc old injection sidecars |
170
+
171
+ See [Project Memory](#project-memory-1) below for what it is and how to wire automatic injection.
172
+
161
173
  ### Task Data Model
162
174
 
163
175
  Tasks are stored as a directory tree with status encoded in filenames:
@@ -223,9 +235,11 @@ name = "my-project"
223
235
  description = "Project description"
224
236
 
225
237
  [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
238
+ history_limit = 20 # Max closed sessions to keep
239
+ done_task_limit = 10 # Max completed tasks to show
240
+ auto_git_summary = true # Capture git state automatically
241
+ require_estimate_hours = true # Require estimate_hours on leaf tasks (default true)
242
+ ai_estimate_multiplier = 0.2 # Multiplier turning human estimates into AI-effort hours
229
243
 
230
244
  [dashboard]
231
245
  scan_dirs = ["~/pro/"] # Directories to scan for dashboard
@@ -263,6 +277,147 @@ mode = "compare" # plan, actual, compare
263
277
 
264
278
  All configuration sections can be updated via `handoff_update_config` with dot-notation keys (e.g., `"calendar.work_hours_per_day": 7`).
265
279
 
280
+ ### Estimates and AI effort
281
+
282
+ handoff-mcp distinguishes the **raw human-effort estimate** you record on a task
283
+ from the **AI-effort hours** used in scheduling and metrics:
284
+
285
+ - **`require_estimate_hours`** (default `true`) — `handoff_update_task` rejects
286
+ creating or updating a *leaf* task (in `todo` / `in_progress` / `review` /
287
+ `done`) without `schedule.estimate_hours > 0`. Parent tasks (with children) and
288
+ `blocked` / `skipped` tasks are exempt, and an estimate already on the task
289
+ satisfies the requirement. Set to `false` to opt out.
290
+ - **`ai_estimate_multiplier`** (default `0.2`) — the factor applied to raw
291
+ estimates to model how long the work takes when an AI agent does it. Always
292
+ record the *raw human-effort* estimate; the multiplier is applied at
293
+ aggregation time by `handoff_get_metrics` (`total_adjusted_estimate_hours` and
294
+ per-milestone `adjusted_estimate_hours`) and `handoff_get_capacity`. Raw values
295
+ are never overwritten.
296
+
297
+ ## Project Memory
298
+
299
+ Sessions answer *"what was I doing last time?"*. **Memory** answers a longer-lived
300
+ question: *"what should every session in this project always know?"* — durable
301
+ lessons, rules, conventions, and gotchas that outlive any one session.
302
+
303
+ Memories live in `.handoff/memory/` (one JSON file per memory, plus per-session
304
+ `injected/` sidecars). A built-in multilingual similarity engine (Japanese /
305
+ English, dictionary-free) ranks relevance and detects duplicates, all in-memory
306
+ and sub-millisecond.
307
+
308
+ ### Using it directly
309
+
310
+ The agent can call the memory tools at any time:
311
+
312
+ - `memory_save` — record a memory. An exact duplicate is reported (not
313
+ rewritten); a near-duplicate comes back as a `conflict` with both bodies so the
314
+ agent can merge them (`merge_into=<id>`, `absorb_ids=[…]`) or save separately
315
+ with `force=true`. **handoff-mcp never merges for you** — it surfaces both
316
+ bodies and lets the agent decide.
317
+ - `memory_query` — fetch the memories most relevant to some text and/or files.
318
+ - `memory_delete` / `memory_cleanup` — prune and de-duplicate the store.
319
+
320
+ ### Automatic injection via hooks
321
+
322
+ MCP is request/response — the server cannot push a memory into the agent's
323
+ context on its own. **Claude Code hooks** close that gap: they fire regardless of
324
+ what the agent intends, call `memory_query`, and inject the matching memories as
325
+ `additionalContext`. A per-session diff (keyed on the hook `session_id`) ensures
326
+ the same memory is **not injected twice in one session** — and an *edited* memory
327
+ (new content hash) is re-injected.
328
+
329
+ | Event | Calls | Effect |
330
+ |-------|-------|--------|
331
+ | `UserPromptSubmit` | `memory_query` (prompt text) | Inject memories relevant to the prompt |
332
+ | `PreToolUse` (`Edit\|Write\|MultiEdit`) | `memory_query` (file path) | Inject memories scoped to the file being edited |
333
+ | `SessionStart` | `memory_cleanup` | Merge exact duplicates, gc old sidecars |
334
+
335
+ > **Wire hooks in your *user/global* settings, not in the repo.** Hooks are a
336
+ > personal workflow choice; the handoff-mcp repo does not ship a `.claude/`
337
+ > hooks config, and you should not commit one into a shared project. Put the
338
+ > config in `~/.claude/settings.json` (global) or your own
339
+ > `.claude/settings.local.json` (git-ignored).
340
+
341
+ **Native `mcp_tool` hook (preferred).** Recent Claude Code versions can call an
342
+ MCP tool from a hook directly, with no wrapper script. In
343
+ `~/.claude/settings.json`:
344
+
345
+ ```json
346
+ {
347
+ "hooks": {
348
+ "UserPromptSubmit": [
349
+ { "hooks": [ {
350
+ "type": "mcp_tool", "server": "handoff", "tool": "memory_query",
351
+ "input": { "project_dir": "${cwd}",
352
+ "session_id": "${session_id}", "text": "${prompt}" }
353
+ } ] }
354
+ ],
355
+ "PreToolUse": [
356
+ { "matcher": "Edit|Write|MultiEdit", "hooks": [ {
357
+ "type": "mcp_tool", "server": "handoff", "tool": "memory_query",
358
+ "input": { "project_dir": "${cwd}",
359
+ "session_id": "${session_id}", "tool_name": "${tool_name}",
360
+ "text": "${tool_input.file_path}",
361
+ "file_paths": ["${tool_input.file_path}"] }
362
+ } ] }
363
+ ],
364
+ "SessionStart": [
365
+ { "hooks": [ {
366
+ "type": "mcp_tool", "server": "handoff", "tool": "memory_cleanup",
367
+ "input": { "project_dir": "${cwd}" }
368
+ } ] }
369
+ ]
370
+ }
371
+ }
372
+ ```
373
+
374
+ (`server` must match the name you registered handoff-mcp under — `handoff` in the
375
+ [Setup](#setup) examples.)
376
+
377
+ **Wrapper script fallback.** If your Claude Code version doesn't support the
378
+ `mcp_tool` hook type, use the bundled `command` wrapper
379
+ [`scripts/handoff-memory-hook.py`](scripts/handoff-memory-hook.py). It reads the
380
+ hook JSON on stdin, calls the server over JSON-RPC, and emits
381
+ `additionalContext` — the memory tools return their payload as a JSON *string* so
382
+ both paths parse it identically. Point all three hooks at it:
383
+
384
+ ```json
385
+ {
386
+ "hooks": {
387
+ "UserPromptSubmit": [
388
+ { "hooks": [ { "type": "command",
389
+ "command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
390
+ ],
391
+ "PreToolUse": [
392
+ { "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command",
393
+ "command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
394
+ ],
395
+ "SessionStart": [
396
+ { "hooks": [ { "type": "command",
397
+ "command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
398
+ ]
399
+ }
400
+ }
401
+ ```
402
+
403
+ The script resolves the `handoff-mcp` binary from `PATH` (override with
404
+ `HANDOFF_MCP_BIN`) and **fails safe**: on any error it prints nothing and exits
405
+ 0, so a memory miss is silent and never blocks your prompt.
406
+
407
+ ### Memory settings
408
+
409
+ All under `[settings]` in `.handoff/config.toml`, all with safe defaults
410
+ (existing projects need no change), all settable via `handoff_update_config`:
411
+
412
+ | Key | Default | Meaning |
413
+ |-----|---------|---------|
414
+ | `memory_enabled` | `true` | Master switch. When `false`, all four memory tools return a benign empty result and write nothing |
415
+ | `memory_dup_threshold` | `0.72` | Jaccard similarity at/above which a save is a near-duplicate conflict and cleanup groups a cluster |
416
+ | `memory_query_min_score` | `0.5` | BM25 relevance floor for `memory_query` results |
417
+ | `memory_query_limit` | `5` | Max memories returned per query |
418
+ | `memory_stale_days` | `60` | Days without a reference before a memory is flagged stale |
419
+ | `memory_injected_gc_days` | `14` | Age at which per-session injection sidecars are garbage-collected |
420
+
266
421
  ## MCP Resources
267
422
 
268
423
  | URI | Description |
@@ -290,6 +445,10 @@ This project uses handoff-mcp for session continuity.
290
445
  - **Decisions**: Record decisions with confidence levels as they are made,
291
446
  not just at session end. Use `confirmed` for verified facts, `estimated`
292
447
  for reasonable assumptions, `unverified` for unknowns.
448
+ - **Project memory**: Use `memory_save` to record durable lessons, rules,
449
+ conventions, and gotchas that every future session should know. Use
450
+ `memory_query` to retrieve relevant memories. Near-duplicate memories are
451
+ surfaced as conflicts for you to merge or force-save — never merged silently.
293
452
  ```
294
453
 
295
454
  ## Skill File (Optional)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
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 ``memory_query`` / ``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. ``memory_query`` /
13
+ ``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 ``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, "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, "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())
@@ -54,6 +54,13 @@ description: "Session handoff — load context at start, save at end, track task
54
54
  user that their input is needed before proceeding.
55
55
  - Create new tasks as work is discovered. Always include `done_criteria` with
56
56
  verifiable items so completion can be tracked.
57
+ - **Always set `schedule.estimate_hours`** (raw human-effort hours, > 0) on every
58
+ leaf task. It is required by default — `handoff_update_task` rejects creating or
59
+ updating a leaf task without it (parent tasks and `blocked`/`skipped` tasks are
60
+ exempt). Enter the raw human-effort estimate; the AI-effort multiplier
61
+ (`settings.ai_estimate_multiplier`, default 0.2) is applied automatically at
62
+ aggregation time by `handoff_get_metrics`/`handoff_get_capacity`. To turn the
63
+ requirement off, set `settings.require_estimate_hours = false`.
57
64
 
58
65
  ### Progressive done_criteria Checking
59
66
  - **Check off `done_criteria` immediately as each item is verified** — do not
@@ -91,7 +98,7 @@ description: "Session handoff — load context at start, save at end, track task
91
98
  Use `handoff_log_time` to record hours worked on a task:
92
99
  - Atomically adds to `schedule.actual_hours` and deducts from `schedule.remaining_hours`.
93
100
  - Never overwrite `actual_hours` directly — always use the additive `handoff_log_time` tool.
94
- - Set `schedule.estimate_hours` on task creation so metrics can show estimate vs actual.
101
+ - Set `schedule.estimate_hours` on task creation (required by default) so metrics can show estimate vs actual.
95
102
  - When the handoff-vscode time tracker is enabled, the extension also logs time
96
103
  automatically — the AI does not need to log time manually for extension-tracked tasks.
97
104
  - `handoff_update_task`'s `schedule` field **merges** (partial update): passing
@@ -4,6 +4,7 @@ use serde_json::{json, Value};
4
4
  use toml_edit::DocumentMut;
5
5
 
6
6
  use super::resolve_project_dir;
7
+ use crate::storage::config::read_config;
7
8
  use crate::storage::ensure_handoff_exists;
8
9
  use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
9
10
 
@@ -54,9 +55,12 @@ pub fn handle(arguments: &Value) -> Result<String> {
54
55
  }
55
56
 
56
57
  // Calculate allocated hours from tasks
58
+ let multiplier = read_config(&config_path)
59
+ .map(|c| c.settings.ai_estimate_multiplier)
60
+ .unwrap_or(0.2);
57
61
  if tasks_dir.exists() {
58
62
  let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
59
- allocate_task_hours(&tree, assignee_filter, &start, &end, &mut days);
63
+ allocate_task_hours(&tree, assignee_filter, &start, &end, &mut days, multiplier);
60
64
  }
61
65
 
62
66
  let allocated_hours: f64 = days
@@ -260,6 +264,7 @@ fn allocate_task_hours(
260
264
  start: &NaiveDate,
261
265
  end: &NaiveDate,
262
266
  days: &mut [Value],
267
+ multiplier: f64,
263
268
  ) {
264
269
  for node in tree {
265
270
  if is_terminal_status(&node.status) {
@@ -278,10 +283,13 @@ fn allocate_task_hours(
278
283
  NaiveDate::parse_from_str(sd, "%Y-%m-%d"),
279
284
  NaiveDate::parse_from_str(dd, "%Y-%m-%d"),
280
285
  ) {
281
- let est = sched
282
- .remaining_hours
283
- .or(sched.estimate_hours)
284
- .unwrap_or(0.0);
286
+ // remaining_hours is already actual AI-effort progress, so it
287
+ // is used as-is; the raw estimate is human-effort and gets the
288
+ // AI multiplier applied to derive AI-effort allocation.
289
+ let est = match sched.remaining_hours {
290
+ Some(rem) => rem,
291
+ None => sched.estimate_hours.unwrap_or(0.0) * multiplier,
292
+ };
285
293
  let overlap_start = (*start).max(task_start);
286
294
  let overlap_end = (*end).min(task_end);
287
295
  if overlap_start <= overlap_end {
@@ -311,6 +319,13 @@ fn allocate_task_hours(
311
319
  }
312
320
  }
313
321
 
314
- allocate_task_hours(&node.children, assignee_filter, start, end, days);
322
+ allocate_task_hours(
323
+ &node.children,
324
+ assignee_filter,
325
+ start,
326
+ end,
327
+ days,
328
+ multiplier,
329
+ );
315
330
  }
316
331
  }