handoff-mcp-server 0.19.0 → 0.20.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
@@ -136,7 +136,7 @@ dependencies = [
136
136
 
137
137
  [[package]]
138
138
  name = "handoff-mcp"
139
- version = "0.19.0"
139
+ version = "0.20.0"
140
140
  dependencies = [
141
141
  "anyhow",
142
142
  "chrono",
@@ -208,9 +208,9 @@ dependencies = [
208
208
 
209
209
  [[package]]
210
210
  name = "lexsim"
211
- version = "0.1.0"
211
+ version = "0.4.0"
212
212
  source = "registry+https://github.com/rust-lang/crates.io-index"
213
- checksum = "579b9d7ab407540bead4688eaf1675410a198d77c237a30cda55597df98926e1"
213
+ checksum = "14b08f26a4b07325e83c0211adb36d0223fae644534dacd4eae72153c1ccd873"
214
214
  dependencies = [
215
215
  "unicode-normalization",
216
216
  "unicode-segmentation",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.19.0"
3
+ version = "0.20.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
@@ -20,7 +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
+ lexsim = ">=0.1.0, <1.0.0"
24
24
 
25
25
  [dev-dependencies]
26
26
  tempfile = "3.27.0"
package/README.md CHANGED
@@ -115,6 +115,75 @@ version. Note that `install-local.sh` **only rebuilds the binary and refreshes
115
115
  the plugin cache** — it does not register the marketplace or enable the plugin,
116
116
  so steps 2 and 3 are a one-time bootstrap.
117
117
 
118
+ ### Updating
119
+
120
+ If you installed handoff-mcp as a plugin, it updates along **two independent
121
+ paths**, and you need both. The plugin does not bundle the binary:
122
+ `plugin.json` registers the MCP server as `command: "handoff-mcp"`, which
123
+ Claude Code resolves on your `PATH`. So the marketplace ships the skills and
124
+ the plugin manifest, while npm or cargo ships the executable that actually
125
+ implements the MCP tools.
126
+
127
+ (Installed without the plugin, via `cargo install` or `npm install -g` alone?
128
+ Then only step 1 and the restart apply.)
129
+
130
+ Updating only the plugin leaves you on the old MCP tools. Updating only the
131
+ binary leaves you on the old skills.
132
+
133
+ ```bash
134
+ # 1. Binary — this is what implements the MCP tools
135
+ npm install -g handoff-mcp-server@latest
136
+ # or: cargo install handoff-mcp --force
137
+
138
+ # 2. Marketplace catalog — fetch the new version list
139
+ /plugin marketplace update handoff-mcp-marketplace
140
+
141
+ # 3. Plugin — skills and manifest
142
+ /plugin update handoff-mcp@handoff-mcp-marketplace
143
+
144
+ # 4. Restart Claude Code
145
+ ```
146
+
147
+ Update the optional plugins the same way if you installed them:
148
+
149
+ ```bash
150
+ /plugin update handoff-task-loop@handoff-mcp-marketplace
151
+ /plugin update handoff-mcp-hooks@handoff-mcp-marketplace
152
+ ```
153
+
154
+ Steps 1 and 3 update different things, and neither substitutes for the other.
155
+ `/plugin update` never touches the binary — it swaps the cached plugin directory,
156
+ which contains no executable at all — so no amount of restarting will give you
157
+ new MCP tools if you skipped step 1. Conversely, step 1 rewrites the file on disk
158
+ but the MCP server Claude Code already spawned keeps running the old image, so
159
+ you get the new tools only after step 4.
160
+
161
+ Step 4 is therefore not optional, and `/reload-plugins` is not a substitute: it
162
+ refreshes skills, not the MCP server process.
163
+
164
+ Do not expect step 1 to take effect on its own. An installer that overwrites a
165
+ *running* binary in place fails on Linux with `Text file busy`; installers that
166
+ replace the file instead (unlink, then create — what `install-local.sh` does)
167
+ succeed, and leave the already-running server executing the now-deleted old
168
+ image until it restarts. Either way, the new tools appear only after step 4.
169
+
170
+ Verify the update landed:
171
+
172
+ ```bash
173
+ which handoff-mcp # the binary Claude Code will actually run
174
+ handoff-mcp --version
175
+ claude plugin list # plugin version, per marketplace
176
+ ```
177
+
178
+ `claude plugin list` reports the version you actually have installed. Note that
179
+ `claude plugin details` reads the marketplace source instead, so it shows the
180
+ version on offer whether or not you have updated to it — don't use it to
181
+ confirm an update.
182
+
183
+ **Local development checkout**: `./scripts/install-local.sh` does both halves at
184
+ once (rebuilds the binary into `~/.local/bin` and refreshes the plugin cache).
185
+ Restart Claude Code afterwards.
186
+
118
187
  **Troubleshooting**
119
188
 
120
189
  - **Plugin or skills don't show up** — run `/reload-plugins`, or restart Claude
@@ -123,6 +192,43 @@ so steps 2 and 3 are a one-time bootstrap.
123
192
  `/plugin marketplace update handoff-mcp-marketplace`, then reinstall.
124
193
  - **MCP server won't start** — open `/plugin` → **Errors** tab, and confirm the
125
194
  binary is on your `PATH` (`which handoff-mcp`).
195
+ - **You updated, but the MCP tools still behave like the old version** — you
196
+ almost certainly updated the plugin without updating the binary, or you
197
+ updated the binary but did not restart Claude Code. Work through the four
198
+ steps above in order.
199
+ - **An older `handoff-mcp` earlier on your `PATH` shadows the new one.** This is
200
+ the most common cause of "I updated and nothing changed", because
201
+ `npm install -g` and `cargo install` write to *different* directories. List
202
+ every copy and see which one wins:
203
+
204
+ ```bash
205
+ type -a handoff-mcp # every match, in resolution order
206
+ for d in ${PATH//:/ }; do
207
+ [ -x "$d/handoff-mcp" ] && echo "$d -> $("$d/handoff-mcp" --version)"
208
+ done
209
+ ```
210
+
211
+ The first line is the one Claude Code runs. Remove the stale copies (e.g.
212
+ `cargo uninstall handoff-mcp`, or delete the old file), or put the directory
213
+ holding the current binary earlier on your `PATH`.
214
+
215
+ - **`--version` says the right number but the behavior is old.** A version
216
+ string only changes when a release bumps it, so two builds of the *same*
217
+ version — a stale `cargo install` and a fresh one — report identically.
218
+ Compare the file itself rather than the version:
219
+
220
+ ```bash
221
+ ls -l "$(which handoff-mcp)" # check the mtime
222
+ ```
223
+
224
+ When in doubt, reinstall the binary and restart Claude Code.
225
+
226
+ - **Old versions pile up in the plugin cache.** Claude Code keeps each installed
227
+ version in its own directory under
228
+ `~/.claude/plugins/cache/handoff-mcp-marketplace/<plugin>/<version>/`. This is
229
+ harmless — the active version is recorded in
230
+ `~/.claude/plugins/installed_plugins.json` — but you can reclaim the space by
231
+ deleting the directories for versions you no longer use.
126
232
 
127
233
  ### cargo
128
234
 
@@ -569,8 +675,9 @@ If you already ran `handoff-mcp setup` before this change, migrate with
569
675
  - **Manual edit**: open `~/.claude/settings.json` and delete the
570
676
  `hooks.SessionStart` entry whose `tool` is `handoff_memory_cleanup` (remove
571
677
  the whole `SessionStart` key if that was its only entry).
572
- - **Plugin users**: `/plugin update` to pick up the new `hooks.json`, then
573
- restart Claude Code.
678
+ - **Plugin users**: `/plugin update handoff-mcp-hooks@handoff-mcp-marketplace`
679
+ to pick up the new `hooks.json`, then restart Claude Code. This one ships
680
+ inside the plugin, so no binary update is needed.
574
681
 
575
682
  Restart Claude Code after any of the above for the change to take effect.
576
683
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.19.0",
3
+ "version": "0.20.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>",
@@ -21,7 +21,10 @@
21
21
  "handoff-mcp": "./bin/handoff-mcp.js"
22
22
  },
23
23
  "scripts": {
24
- "postinstall": "node scripts/postinstall.js"
24
+ "postinstall": "node scripts/postinstall.js",
25
+ "test:js": "node --test \"plugin-task-loop/workflows/lib/*.test.js\"",
26
+ "sync:workflow": "./scripts/sync-workflow-inline.sh",
27
+ "check:workflow": "./scripts/sync-workflow-inline.sh --check"
25
28
  },
26
29
  "files": [
27
30
  "bin/handoff-mcp.js",
@@ -1,11 +1,33 @@
1
1
  #!/usr/bin/env bash
2
2
  # Sync skills/ -> plugin/skills/ for plugin distribution.
3
- # Run before release to ensure the plugin has the latest skills.
3
+ #
4
+ # plugin/skills/ is generated, but it is COMMITTED: the marketplace serves the
5
+ # plugin straight from the repository, so a skill that is not committed there
6
+ # never reaches an installed plugin. Run this after editing skills/, and commit
7
+ # the result.
8
+ #
9
+ # sync-plugin-skills.sh sync skills/ -> plugin/skills/
10
+ # sync-plugin-skills.sh --check exit non-zero if they differ (no writes)
4
11
  set -euo pipefail
5
12
 
6
13
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
14
  ROOT="$(dirname "$SCRIPT_DIR")"
8
15
 
16
+ if [ "${1:-}" = "--check" ]; then
17
+ if [ ! -d "$ROOT/plugin/skills" ]; then
18
+ echo "ERROR: plugin/skills/ is missing. Run './scripts/sync-plugin-skills.sh'." >&2
19
+ exit 1
20
+ fi
21
+ if ! diff -rq "$ROOT/skills/" "$ROOT/plugin/skills/" >/dev/null 2>&1; then
22
+ echo "ERROR: plugin/skills/ is out of sync with skills/:" >&2
23
+ diff -rq "$ROOT/skills/" "$ROOT/plugin/skills/" >&2 || true
24
+ echo "Run './scripts/sync-plugin-skills.sh' and commit the result." >&2
25
+ exit 1
26
+ fi
27
+ echo "OK: plugin/skills/ is in sync with skills/."
28
+ exit 0
29
+ fi
30
+
9
31
  rm -rf "$ROOT/plugin/skills"
10
32
  cp -a "$ROOT/skills" "$ROOT/plugin/skills"
11
33
 
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # Sync plugin/marketplace manifest versions to package.json's version.
3
+ #
4
+ # package.json is the source of truth (kept in sync with Cargo.toml manually
5
+ # per CLAUDE.md "Version sync" rule). This script propagates that version to:
6
+ # - plugin/.claude-plugin/plugin.json
7
+ # - plugin-hooks/.claude-plugin/plugin.json
8
+ # - plugin-task-loop/.claude-plugin/plugin.json
9
+ # - .claude-plugin/marketplace.json (all three "version" entries)
10
+ #
11
+ # Uses regex line replacement (not json.dump) so untouched formatting in
12
+ # these hand-maintained manifests is preserved byte-for-byte.
13
+ #
14
+ # Usage:
15
+ # ./scripts/sync-plugin-version.sh # fix mismatches in place
16
+ # ./scripts/sync-plugin-version.sh --check # exit 1 if anything is out of sync, fix nothing
17
+ set -euo pipefail
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20
+ ROOT="$(dirname "$SCRIPT_DIR")"
21
+ cd "$ROOT"
22
+
23
+ MODE="fix"
24
+ if [ "${1:-}" = "--check" ]; then
25
+ MODE="check"
26
+ fi
27
+
28
+ python3 - "$MODE" <<'PYEOF'
29
+ import json
30
+ import re
31
+ import sys
32
+
33
+ mode = sys.argv[1]
34
+
35
+ with open("package.json") as f:
36
+ root_version = json.load(f)["version"]
37
+
38
+ version_line_re = re.compile(r'^(\s*"version"\s*:\s*)"[^"]*"(,?\s*(?:\r?\n)?)$')
39
+
40
+ def sync_file(path, mismatches):
41
+ with open(path) as f:
42
+ lines = f.readlines()
43
+ changed = False
44
+ out = []
45
+ for line in lines:
46
+ m = version_line_re.match(line)
47
+ if m:
48
+ current = re.search(r'"version"\s*:\s*"([^"]*)"', line).group(1)
49
+ if current != root_version:
50
+ mismatches.append((path, current))
51
+ changed = True
52
+ line = f'{m.group(1)}"{root_version}"{m.group(2)}'
53
+ out.append(line)
54
+ if changed and mode == "fix":
55
+ with open(path, "w") as f:
56
+ f.writelines(out)
57
+ return changed
58
+
59
+ mismatches = []
60
+ plugin_files = [
61
+ "plugin/.claude-plugin/plugin.json",
62
+ "plugin-hooks/.claude-plugin/plugin.json",
63
+ "plugin-task-loop/.claude-plugin/plugin.json",
64
+ ".claude-plugin/marketplace.json",
65
+ ]
66
+
67
+ for path in plugin_files:
68
+ sync_file(path, mismatches)
69
+
70
+ if mode == "check":
71
+ if mismatches:
72
+ print(f"Version mismatch: package.json is {root_version}, but found:")
73
+ for path, version in mismatches:
74
+ print(f" {path}: {version}")
75
+ print("Run ./scripts/sync-plugin-version.sh to fix.")
76
+ sys.exit(1)
77
+ print(f"All plugin/marketplace manifests match package.json ({root_version}).")
78
+ else:
79
+ if mismatches:
80
+ print(f"Synced {len(mismatches)} version occurrence(s) to {root_version}:")
81
+ for path, old_version in mismatches:
82
+ print(f" {path}: {old_version} -> {root_version}")
83
+ else:
84
+ print(f"Already in sync at version {root_version}. No changes made.")
85
+ PYEOF
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # sync-workflow-inline.sh — keep workflow scripts self-contained.
4
+ #
5
+ # The Workflow runtime cannot `import` (probed: "import() is not available in
6
+ # workflow scripts"; `require` is undefined), and workflow scripts have a
7
+ # top-level `return` so Node cannot import *them* either. Neither side can reach
8
+ # the other, yet we still want the shared logic unit-tested.
9
+ #
10
+ # Resolution: the pure logic lives in testable ES modules (the source of truth).
11
+ # Each module's INLINE region is mirrored verbatim into the workflow script
12
+ # between GENERATED markers. This script performs — or verifies — that mirror.
13
+ #
14
+ # ./scripts/sync-workflow-inline.sh # rewrite the generated blocks
15
+ # ./scripts/sync-workflow-inline.sh --check # fail if out of sync (CI/hook)
16
+ #
17
+ # Mirrors the contract of scripts/sync-plugin-version.sh.
18
+
19
+ set -euo pipefail
20
+
21
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
22
+ cd "$REPO_ROOT"
23
+
24
+ TARGET="plugin-task-loop/workflows/session-execute.js"
25
+
26
+ # Modules mirrored into TARGET, in the order they must appear.
27
+ # Each entry is a bare module name; source is lib/<name>.js.
28
+ #
29
+ # Order is load-bearing where one module's inline region calls another's:
30
+ # `context-injection` calls resolveProfile()/profileStages() from `profile`, so
31
+ # `profile` must be mirrored ahead of it (const bindings are in the temporal dead
32
+ # zone until their block is evaluated).
33
+ # `task-graph` is self-contained (pure union-find over the assignment arrays), so
34
+ # its position is free; it sits last to keep the dependency-ordered prefix intact.
35
+ MODULES=(verdict-logic profile context-injection task-graph)
36
+
37
+ CHECK_MODE=0
38
+ if [ "${1:-}" = "--check" ]; then
39
+ CHECK_MODE=1
40
+ elif [ $# -gt 0 ]; then
41
+ echo "usage: $0 [--check]" >&2
42
+ exit 2
43
+ fi
44
+
45
+ [ -f "$TARGET" ] || { echo "ERROR: missing $TARGET" >&2; exit 1; }
46
+
47
+ count_marker() { grep -cFx "$1" "$2" || true; }
48
+
49
+ WORK="$(mktemp -d)"
50
+ trap 'rm -rf "$WORK"' EXIT
51
+
52
+ # Start from the current target; each module rewrites its own block in turn.
53
+ cp "$TARGET" "$WORK/rendered"
54
+
55
+ for module in "${MODULES[@]}"; do
56
+ SOURCE="plugin-task-loop/workflows/lib/${module}.js"
57
+ [ -f "$SOURCE" ] || { echo "ERROR: missing $SOURCE" >&2; exit 1; }
58
+
59
+ SRC_BEGIN="// --- BEGIN INLINE: ${module} ---"
60
+ SRC_END="// --- END INLINE: ${module} ---"
61
+ DST_BEGIN="// --- BEGIN GENERATED: ${module} (source: lib/${module}.js) ---"
62
+ DST_END="// --- END GENERATED: ${module} ---"
63
+
64
+ for marker in "$SRC_BEGIN" "$SRC_END"; do
65
+ n=$(count_marker "$marker" "$SOURCE")
66
+ [ "$n" -eq 1 ] || { echo "ERROR: '$marker' appears $n time(s) in $SOURCE (want 1)" >&2; exit 1; }
67
+ done
68
+ for marker in "$DST_BEGIN" "$DST_END"; do
69
+ n=$(count_marker "$marker" "$WORK/rendered")
70
+ [ "$n" -eq 1 ] || { echo "ERROR: '$marker' appears $n time(s) in $TARGET (want 1)" >&2; exit 1; }
71
+ done
72
+
73
+ EXTRACTED="$WORK/${module}.body"
74
+ awk -v b="$SRC_BEGIN" -v e="$SRC_END" '
75
+ $0 == b { inblk = 1; next }
76
+ $0 == e { inblk = 0; next }
77
+ inblk { print }
78
+ ' "$SOURCE" > "$EXTRACTED"
79
+
80
+ if [ ! -s "$EXTRACTED" ]; then
81
+ echo "ERROR: extracted inline region from $SOURCE is empty" >&2
82
+ exit 1
83
+ fi
84
+
85
+ # The mirrored region must be self-contained.
86
+ if grep -Eq '^[[:space:]]*(import|export)[[:space:]]' "$EXTRACTED"; then
87
+ echo "ERROR: the inline region of $SOURCE contains import/export." >&2
88
+ echo " Workflow scripts cannot import. Keep those outside the markers." >&2
89
+ exit 1
90
+ fi
91
+
92
+ # A destination marker inside the copied body would corrupt the target — the
93
+ # awk pass would end the generated block early and duplicate the remainder,
94
+ # after which every --check fails with a confusing "appears 2 time(s)".
95
+ for marker in "$DST_BEGIN" "$DST_END"; do
96
+ if grep -qFx "$marker" "$EXTRACTED"; then
97
+ echo "ERROR: the inline region of $SOURCE contains the destination marker:" >&2
98
+ echo " $marker" >&2
99
+ echo " Remove it — it would corrupt $TARGET on the next sync." >&2
100
+ exit 1
101
+ fi
102
+ done
103
+
104
+ awk -v b="$DST_BEGIN" -v e="$DST_END" -v body="$EXTRACTED" -v src="$SOURCE" '
105
+ $0 == b {
106
+ print
107
+ print "// AUTO-GENERATED — DO NOT EDIT BY HAND."
108
+ print "// Source: " src
109
+ print "// Regenerate: ./scripts/sync-workflow-inline.sh"
110
+ while ((getline line < body) > 0) print line
111
+ close(body)
112
+ skip = 1
113
+ next
114
+ }
115
+ $0 == e { skip = 0; print; next }
116
+ !skip { print }
117
+ ' "$WORK/rendered" > "$WORK/next"
118
+ mv "$WORK/next" "$WORK/rendered"
119
+ done
120
+
121
+ if [ "$CHECK_MODE" -eq 1 ]; then
122
+ if ! diff -u "$TARGET" "$WORK/rendered" > /dev/null 2>&1; then
123
+ echo "ERROR: $TARGET is out of sync with its source modules" >&2
124
+ echo " Run ./scripts/sync-workflow-inline.sh to regenerate." >&2
125
+ echo >&2
126
+ diff -u "$TARGET" "$WORK/rendered" >&2 || true
127
+ exit 1
128
+ fi
129
+ echo "OK: $TARGET is in sync with ${MODULES[*]}"
130
+ exit 0
131
+ fi
132
+
133
+ if diff -q "$TARGET" "$WORK/rendered" > /dev/null 2>&1; then
134
+ echo "OK: $TARGET already in sync (no change)"
135
+ else
136
+ cat "$WORK/rendered" > "$TARGET"
137
+ echo "Synced: ${MODULES[*]} -> $TARGET"
138
+ fi
@@ -62,6 +62,38 @@ description: "Session handoff — load context at start, save at end, track task
62
62
  aggregation time by `handoff_get_metrics`/`handoff_get_capacity`. To turn the
63
63
  requirement off, set `settings.require_estimate_hours = false`.
64
64
 
65
+ ### Checklist before every `handoff_update_task` that creates a task
66
+
67
+ Run through this as you write the call, not after it is rejected. A leaf task
68
+ missing `estimate_hours` is refused, costing a round trip.
69
+
70
+ - [ ] `title` — present (required for any new task)
71
+ - [ ] `done_criteria` — verifiable items, not restatements of the title
72
+ - [ ] `schedule.estimate_hours` — **> 0, raw human-effort hours.** Skip only if
73
+ the task is a parent (has children) or its status is `blocked`/`skipped`
74
+ - [ ] `priority` — `low` / `medium` / `high`
75
+ - [ ] `labels` — at least one, so the task is findable by filter
76
+ - [ ] `assignee` — matches a key in `config.toml [assignees.<key>]`
77
+
78
+ A minimal accepted payload:
79
+
80
+ ```json
81
+ {
82
+ "task": {
83
+ "title": "Add retry to the upload path",
84
+ "status": "todo",
85
+ "priority": "high",
86
+ "labels": ["upload", "reliability"],
87
+ "assignee": "ai",
88
+ "schedule": { "estimate_hours": 2.0 },
89
+ "done_criteria": [{ "item": "Upload retries 3x on 5xx, then surfaces the error" }]
90
+ }
91
+ }
92
+ ```
93
+
94
+ When updating an existing task, you do **not** resend `estimate_hours` — the
95
+ stored value satisfies the requirement. Send only the fields you are changing.
96
+
65
97
  ### Appending to Task Notes
66
98
  - Use `notes_append` (not `notes`) in `handoff_update_task` or
67
99
  `handoff_bulk_update_tasks` to add text to existing task notes without
@@ -201,6 +233,11 @@ Use `handoff_bulk_update_tasks` for:
201
233
  - Batch status changes (e.g., closing all review tasks).
202
234
  - Batch assignee changes (e.g., reassigning a team member's tasks).
203
235
  - Each task update is independent — failures on one task don't roll back others.
236
+ - The `estimate_hours` rule applies here too: an update leaving a leaf task in
237
+ `todo`/`in_progress`/`review`/`done` without `schedule.estimate_hours` is
238
+ rejected and reported in `errors[]`. Supply the estimate in the same update
239
+ when moving a task out of `blocked`/`skipped`. Parent tasks and the statuses
240
+ `blocked`/`skipped` are exempt.
204
241
 
205
242
  ### Configuration Management
206
243
 
@@ -3,6 +3,7 @@ use chrono::Utc;
3
3
  use serde_json::{json, Value};
4
4
 
5
5
  use super::resolve_project_dir;
6
+ use crate::storage::config::read_config;
6
7
  use crate::storage::ensure_handoff_exists;
7
8
  use crate::storage::tasks::*;
8
9
 
@@ -11,6 +12,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
11
12
  let handoff = ensure_handoff_exists(&project_dir)?;
12
13
  let tasks_dir = handoff.join("tasks");
13
14
 
15
+ let require_estimate_hours = read_config(&handoff.join("config.toml"))
16
+ .map(|c| c.settings.require_estimate_hours)
17
+ .unwrap_or(true);
18
+
14
19
  let updates = arguments
15
20
  .get("updates")
16
21
  .and_then(|v| v.as_array())
@@ -28,7 +33,9 @@ pub fn handle(arguments: &Value) -> Result<String> {
28
33
  }
29
34
  };
30
35
 
31
- if let Err(e) = apply_single_update(&tasks_dir, task_id, update) {
36
+ // A rejected update is reported per task and the rest still land: that is
37
+ // this tool's established `applied` + `errors[]` contract.
38
+ if let Err(e) = apply_single_update(&tasks_dir, task_id, update, require_estimate_hours) {
32
39
  errors.push(json!({"task_id": task_id, "error": e.to_string()}));
33
40
  } else {
34
41
  applied += 1;
@@ -43,7 +50,12 @@ pub fn handle(arguments: &Value) -> Result<String> {
43
50
  serde_json::to_string_pretty(&result).map_err(Into::into)
44
51
  }
45
52
 
46
- fn apply_single_update(tasks_dir: &std::path::Path, task_id: &str, update: &Value) -> Result<()> {
53
+ fn apply_single_update(
54
+ tasks_dir: &std::path::Path,
55
+ task_id: &str,
56
+ update: &Value,
57
+ require_estimate_hours: bool,
58
+ ) -> Result<()> {
47
59
  let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
48
60
  .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
49
61
 
@@ -117,6 +129,22 @@ fn apply_single_update(tasks_dir: &std::path::Path, task_id: &str, update: &Valu
117
129
  validate_skipped_transition(&task_dir, &data)?;
118
130
  }
119
131
 
132
+ // Guard the same invariant handoff_update_task enforces, or a bulk patch
133
+ // becomes a way around it. The check is on the task as it would be written —
134
+ // status and schedule already merged — not on the patch: a date-only patch
135
+ // that leaves an estimateless leaf in `todo` is exactly the state
136
+ // update_task refuses to write. Parent tasks (with children) are exempt.
137
+ let has_children = task_has_children(&task_dir)?;
138
+ validate_estimate_required(
139
+ require_estimate_hours,
140
+ task_id,
141
+ &data.title,
142
+ new_status,
143
+ has_children,
144
+ false,
145
+ data.schedule.as_ref(),
146
+ )?;
147
+
120
148
  data.updated_at = Some(Utc::now().to_rfc3339());
121
149
 
122
150
  if let Some((old_path, _)) = find_task_file(&task_dir)? {
@@ -30,16 +30,46 @@ pub fn handle(arguments: &Value) -> Result<String> {
30
30
  .and_then(|v| v.as_str())
31
31
  .unwrap_or("other");
32
32
 
33
+ let require_estimate_hours = read_config(&config_path)
34
+ .map(|c| c.settings.require_estimate_hours)
35
+ .unwrap_or(true);
36
+
33
37
  let mut tasks_created: u32 = 0;
34
38
  let mut top_level_count: u32 = 0;
35
39
  let mut nested_count: u32 = 0;
36
40
 
37
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
+
38
71
  for task_val in tasks {
39
- let title = task_val
40
- .get("title")
41
- .and_then(|v| v.as_str())
42
- .ok_or_else(|| anyhow::anyhow!("Each task requires a 'title'"))?;
72
+ let title = task_title(task_val)?;
43
73
 
44
74
  let task_id = next_top_level_id(&tasks_dir)?;
45
75
  let count = create_task_recursive(&tasks_dir, &task_id, None, title, task_val)?;
@@ -204,6 +234,84 @@ pub fn handle(arguments: &Value) -> Result<String> {
204
234
  Ok(msg)
205
235
  }
206
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
+
207
315
  fn create_task_recursive(
208
316
  tasks_dir: &std::path::Path,
209
317
  task_id: &str,
@@ -220,11 +328,12 @@ fn create_task_recursive(
220
328
  .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
221
329
 
222
330
  let now = Utc::now().to_rfc3339();
223
- let status = task_val
224
- .get("status")
225
- .and_then(|v| v.as_str())
226
- .unwrap_or("todo");
331
+ let status = task_status(task_val);
227
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.
228
337
  if !is_valid_status(status) {
229
338
  anyhow::bail!("Invalid status: {status}");
230
339
  }
@@ -269,12 +378,9 @@ fn create_task_recursive(
269
378
 
270
379
  let mut count: u32 = 1;
271
380
 
272
- if let Some(children) = task_val.get("children").and_then(|v| v.as_array()) {
381
+ if let Some(children) = task_children(task_val) {
273
382
  for (i, child_val) in children.iter().enumerate() {
274
- let child_title = child_val
275
- .get("title")
276
- .and_then(|v| v.as_str())
277
- .ok_or_else(|| anyhow::anyhow!("Each child task requires a 'title'"))?;
383
+ let child_title = task_title(child_val)?;
278
384
 
279
385
  let child_id = format!("{task_id}.{}", i + 1);
280
386
  count += create_task_recursive(
@@ -82,8 +82,6 @@ fn handle_create(
82
82
  let slug = title_to_slug(title);
83
83
  let dir_name = format!("{new_id}-{slug}");
84
84
  let task_dir = parent_dir.join(&dir_name);
85
- std::fs::create_dir_all(&task_dir)
86
- .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
87
85
 
88
86
  let now = Utc::now().to_rfc3339();
89
87
  let status = task_val
@@ -133,11 +131,20 @@ fn handle_create(
133
131
  // A newly created task is always a leaf (no children yet).
134
132
  validate_estimate_required(
135
133
  require_estimate_hours,
134
+ &new_id,
135
+ title,
136
136
  status,
137
137
  false,
138
+ true,
138
139
  data.schedule.as_ref(),
139
140
  )?;
140
141
 
142
+ // Create the directory only once every validation has passed. A rejected
143
+ // create must leave nothing behind: an orphan dir would burn the task ID,
144
+ // because `next_top_level_id` counts directories, not task files.
145
+ std::fs::create_dir_all(&task_dir)
146
+ .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
147
+
141
148
  write_task(&task_dir, status, &data)?;
142
149
 
143
150
  Ok(format!("Created task {new_id}: {title} [{status}]"))
@@ -169,8 +176,6 @@ fn handle_upsert_create(
169
176
  let slug = title_to_slug(title);
170
177
  let dir_name = format!("{task_id}-{slug}");
171
178
  let task_dir = parent_dir.join(&dir_name);
172
- std::fs::create_dir_all(&task_dir)
173
- .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
174
179
 
175
180
  let now = Utc::now().to_rfc3339();
176
181
  let status = task_val
@@ -220,11 +225,19 @@ fn handle_upsert_create(
220
225
  // Upsert-create: a brand-new task is a leaf.
221
226
  validate_estimate_required(
222
227
  require_estimate_hours,
228
+ task_id,
229
+ title,
223
230
  status,
224
231
  false,
232
+ true,
225
233
  data.schedule.as_ref(),
226
234
  )?;
227
235
 
236
+ // Create the directory only once every validation has passed, so a rejected
237
+ // upsert-create leaves no orphan dir shadowing the requested ID.
238
+ std::fs::create_dir_all(&task_dir)
239
+ .with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
240
+
228
241
  write_task(&task_dir, status, &data)?;
229
242
 
230
243
  Ok(format!("Created task {task_id}: {title} [{status}]"))
@@ -336,8 +349,11 @@ fn handle_update(
336
349
  let has_children = task_has_children(&task_dir)?;
337
350
  validate_estimate_required(
338
351
  require_estimate_hours,
352
+ task_id,
353
+ &data.title,
339
354
  new_status,
340
355
  has_children,
356
+ false,
341
357
  data.schedule.as_ref(),
342
358
  )?;
343
359
 
package/src/mcp/tools.rs CHANGED
@@ -275,7 +275,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
275
275
  },
276
276
  ToolDefinition {
277
277
  name: "handoff_update_task".to_string(),
278
- description: "Add, update, or move a task. Manages the tasks/ directory structure.".to_string(),
278
+ description: "Add, update, or move a task. Manages the tasks/ directory structure. When creating a leaf task, always include task.schedule.estimate_hours (raw human-effort hours, > 0); it is rejected without one unless the task is a parent or is blocked/skipped.".to_string(),
279
279
  input_schema: json!({
280
280
  "type": "object",
281
281
  "properties": {
@@ -285,6 +285,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
285
285
  },
286
286
  "task": {
287
287
  "type": "object",
288
+ "description": "The task to add or update. When creating a leaf task (status todo/in_progress/review/done), schedule.estimate_hours is REQUIRED and the call is rejected without it. Omit it only for parent tasks (any task with children) or status blocked/skipped.",
288
289
  "properties": {
289
290
  "id": { "type": "string", "description": "Task ID. Omit for auto-generated ID. If provided and task exists, updates it. If provided and task does not exist, creates a new task with that ID (upsert)." },
290
291
  "title": { "type": "string", "description": "Required for new tasks. Optional when updating (id present)." },
@@ -319,12 +320,12 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
319
320
  },
320
321
  "schedule": {
321
322
  "type": "object",
322
- "description": "Schedule and effort tracking.",
323
+ "description": "Schedule and effort tracking. Supply this with estimate_hours whenever creating a leaf task.",
323
324
  "properties": {
324
325
  "start_date": { "type": "string", "description": "YYYY-MM-DD" },
325
326
  "due_date": { "type": "string", "description": "YYYY-MM-DD" },
326
- "estimate_hours": { "type": "number" },
327
- "actual_hours": { "type": "number" },
327
+ "estimate_hours": { "type": "number", "description": "REQUIRED for leaf tasks (status todo/in_progress/review/done); the call is rejected without it. Omit only for parent tasks (any task with children) or status blocked/skipped. Raw human-effort hours, > 0 — do not pre-multiply by settings.ai_estimate_multiplier, which is applied at aggregation time." },
328
+ "actual_hours": { "type": "number", "description": "Hours actually spent. Prefer handoff_log_time, which adds to this and decrements remaining_hours atomically." },
328
329
  "remaining_hours": { "type": "number", "description": "Hours remaining. Auto-decremented by handoff_log_time." },
329
330
  "milestone": { "type": "string" },
330
331
  "pinned": { "type": "boolean", "description": "If true, dates are locked and auto-scheduler skips this task." }
@@ -863,7 +864,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
863
864
  },
864
865
  ToolDefinition {
865
866
  name: "handoff_bulk_update_tasks".to_string(),
866
- description: "Update multiple tasks in one call. Useful for applying auto-schedule results or bulk status/assignee changes.".to_string(),
867
+ description: "Update multiple tasks in one call. Useful for applying auto-schedule results or bulk status/assignee changes. Enforces the same estimate rule as handoff_update_task: a leaf task left in status todo/in_progress/review/done must carry schedule.estimate_hours (> 0). Offending updates are rejected individually and reported in errors[]; the rest still apply.".to_string(),
867
868
  input_schema: json!({
868
869
  "type": "object",
869
870
  "properties": {
@@ -873,26 +874,27 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
873
874
  },
874
875
  "updates": {
875
876
  "type": "array",
876
- "description": "Array of task updates to apply.",
877
+ "description": "Array of task updates to apply. Each is validated on its own: if an update would leave a leaf task in status todo/in_progress/review/done without schedule.estimate_hours, that update is rejected and listed in errors[] while the others still apply. Supply estimate_hours in the same update to move an estimateless task out of blocked/skipped.",
877
878
  "items": {
878
879
  "type": "object",
879
880
  "properties": {
880
881
  "task_id": { "type": "string", "description": "Task ID to update." },
881
- "status": { "type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"] },
882
+ "status": { "type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"], "description": "Moving a leaf task into todo/in_progress/review/done requires schedule.estimate_hours to be present or supplied in the same update. Parent tasks (any task with children) and the statuses blocked/skipped are exempt." },
882
883
  "priority": { "type": "string", "enum": ["low", "medium", "high"] },
883
884
  "assignee": { "type": "string" },
884
885
  "notes": { "type": "string", "description": "Replace task notes." },
885
886
  "notes_append": { "type": "string", "description": "Append text to existing notes with a timestamp heading. If both notes and notes_append are provided, notes (replace) takes precedence." },
886
887
  "schedule": {
887
888
  "type": "object",
889
+ "description": "Schedule fields to merge. Omitted fields are preserved, not cleared.",
888
890
  "properties": {
889
- "start_date": { "type": "string" },
890
- "due_date": { "type": "string" },
891
- "estimate_hours": { "type": "number" },
892
- "actual_hours": { "type": "number" },
893
- "remaining_hours": { "type": "number" },
891
+ "start_date": { "type": "string", "description": "YYYY-MM-DD" },
892
+ "due_date": { "type": "string", "description": "YYYY-MM-DD" },
893
+ "estimate_hours": { "type": "number", "description": "REQUIRED for a leaf task left in status todo/in_progress/review/done; the update is rejected without it. Omit only for parent tasks (any task with children) or status blocked/skipped. Raw human-effort hours, > 0 — do not pre-multiply by settings.ai_estimate_multiplier, which is applied at aggregation time." },
894
+ "actual_hours": { "type": "number", "description": "Hours actually spent. Prefer handoff_log_time, which adds to this and decrements remaining_hours atomically." },
895
+ "remaining_hours": { "type": "number", "description": "Hours remaining. Auto-decremented by handoff_log_time." },
894
896
  "milestone": { "type": "string" },
895
- "pinned": { "type": "boolean" }
897
+ "pinned": { "type": "boolean", "description": "If true, dates are locked and auto-scheduler skips this task." }
896
898
  }
897
899
  }
898
900
  },
@@ -605,6 +605,38 @@ pub fn validate_dependencies(tasks_dir: &Path, task_id: &str, new_deps: &[String
605
605
  Ok(())
606
606
  }
607
607
 
608
+ /// Validate dependencies for a whole batch of tasks that do not exist yet.
609
+ ///
610
+ /// `validate_dependencies` adds one node to the on-disk graph, which is all an
611
+ /// update needs. An import creates a tree in one call, so its tasks may depend on
612
+ /// each other: checking them one at a time would reject a legitimate dependency
613
+ /// on a sibling (not yet written) and miss a cycle that lives entirely inside the
614
+ /// batch (neither end written). Merge every pending node in first, then search.
615
+ ///
616
+ /// A dependency naming a task that exists nowhere is left alone — it contributes
617
+ /// no edge, and `validate_dependencies` tolerates it too.
618
+ pub fn validate_dependencies_batch(
619
+ tasks_dir: &Path,
620
+ pending: &[(String, Vec<String>)],
621
+ ) -> Result<()> {
622
+ let mut graph = build_dependency_graph(tasks_dir)?;
623
+ for (id, deps) in pending {
624
+ graph.insert(id.clone(), deps.clone());
625
+ }
626
+
627
+ for (id, deps) in pending {
628
+ let mut visited = HashSet::new();
629
+ let mut stack = HashSet::new();
630
+ if has_cycle(&graph, id, &mut visited, &mut stack) {
631
+ anyhow::bail!(
632
+ "Circular dependency detected: setting dependencies {deps:?} on task {id} would create a cycle"
633
+ );
634
+ }
635
+ }
636
+
637
+ Ok(())
638
+ }
639
+
608
640
  fn build_dependency_graph(tasks_dir: &Path) -> Result<HashMap<String, Vec<String>>> {
609
641
  let mut graph = HashMap::new();
610
642
  build_dep_graph_recursive(tasks_dir, &mut graph)?;
@@ -712,11 +744,19 @@ pub fn status_requires_estimate(status: &str) -> bool {
712
744
 
713
745
  /// Validate that a leaf task carries an `estimate_hours` when the project
714
746
  /// requires it. `has_children` lets the caller skip parent tasks.
715
- /// Returns an error guiding the caller to add an estimate.
747
+ ///
748
+ /// The rejection names the offending task and shows a ready-to-send payload,
749
+ /// so a caller that forgot the estimate can fix it in one retry instead of
750
+ /// discovering the required shape by trial and error. `is_create` tailors that
751
+ /// payload: creating a task also needs `title`, whereas an update must not
752
+ /// imply the stored title should be overwritten.
716
753
  pub fn validate_estimate_required(
717
754
  require_estimate_hours: bool,
755
+ id: &str,
756
+ title: &str,
718
757
  status: &str,
719
758
  has_children: bool,
759
+ is_create: bool,
720
760
  schedule: Option<&Schedule>,
721
761
  ) -> Result<()> {
722
762
  if !require_estimate_hours || has_children || !status_requires_estimate(status) {
@@ -726,11 +766,32 @@ pub fn validate_estimate_required(
726
766
  .and_then(|s| s.estimate_hours)
727
767
  .is_some_and(|h| h > 0.0);
728
768
  if !has_estimate {
769
+ // Mirror the rejected call, so the example can be resent as-is. Build it
770
+ // with serde_json rather than string formatting: a title carrying a quote,
771
+ // a backslash, or a control character must still serialize to valid JSON.
772
+ // A create also needs `title`; an update must not imply overwriting it.
773
+ let mut example = serde_json::Map::new();
774
+ example.insert("id".into(), Value::String(id.to_string()));
775
+ if is_create {
776
+ example.insert("title".into(), Value::String(title.to_string()));
777
+ }
778
+ example.insert(
779
+ "schedule".into(),
780
+ serde_json::json!({ "estimate_hours": 2.0 }),
781
+ );
782
+ let example = Value::Object(example);
729
783
  anyhow::bail!(
730
- "Task requires an effort estimate: set schedule.estimate_hours (hours, > 0) \
731
- when creating or updating a leaf task in status '{status}'. \
732
- Estimate the human-effort hours for this task. \
733
- To disable this requirement project-wide, set settings.require_estimate_hours = false."
784
+ "Task '{id}' ({title}) requires an effort estimate: \
785
+ schedule.estimate_hours (hours, > 0) is mandatory for a leaf task \
786
+ in status '{status}'.\n\
787
+ Estimate the raw human-effort hours — do not pre-multiply by the \
788
+ AI-effort multiplier; that is applied at aggregation time.\n\
789
+ Resend with, for example:\n \
790
+ {example}\n\
791
+ Exempt from this rule: parent tasks (any task with children), and \
792
+ tasks in status 'blocked' or 'skipped'.\n\
793
+ To disable this requirement project-wide, set \
794
+ settings.require_estimate_hours = false."
734
795
  );
735
796
  }
736
797
  Ok(())