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 +3 -3
- package/Cargo.toml +2 -2
- package/README.md +109 -2
- package/package.json +5 -2
- package/scripts/sync-plugin-skills.sh +23 -1
- package/scripts/sync-plugin-version.sh +85 -0
- package/scripts/sync-workflow-inline.sh +138 -0
- package/skills/handoff/SKILL.md +37 -0
- package/src/mcp/handlers/bulk_update.rs +30 -2
- package/src/mcp/handlers/import_context.rs +119 -13
- package/src/mcp/handlers/update_task.rs +20 -4
- package/src/mcp/tools.rs +15 -13
- package/src/storage/tasks.rs +66 -5
package/Cargo.lock
CHANGED
|
@@ -136,7 +136,7 @@ dependencies = [
|
|
|
136
136
|
|
|
137
137
|
[[package]]
|
|
138
138
|
name = "handoff-mcp"
|
|
139
|
-
version = "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.
|
|
211
|
+
version = "0.4.0"
|
|
212
212
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
213
|
-
checksum = "
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
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
|
package/skills/handoff/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
},
|
package/src/storage/tasks.rs
CHANGED
|
@@ -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
|
-
///
|
|
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:
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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(())
|