handoff-mcp-server 0.19.1 → 0.22.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
@@ -37,9 +37,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
37
37
 
38
38
  [[package]]
39
39
  name = "cc"
40
- version = "1.2.65"
40
+ version = "1.2.67"
41
41
  source = "registry+https://github.com/rust-lang/crates.io-index"
42
- checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
42
+ checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
43
43
  dependencies = [
44
44
  "find-msvc-tools",
45
45
  "shlex",
@@ -123,6 +123,15 @@ dependencies = [
123
123
  "slab",
124
124
  ]
125
125
 
126
+ [[package]]
127
+ name = "getopts"
128
+ version = "0.2.24"
129
+ source = "registry+https://github.com/rust-lang/crates.io-index"
130
+ checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
131
+ dependencies = [
132
+ "unicode-width",
133
+ ]
134
+
126
135
  [[package]]
127
136
  name = "getrandom"
128
137
  version = "0.4.3"
@@ -136,11 +145,12 @@ dependencies = [
136
145
 
137
146
  [[package]]
138
147
  name = "handoff-mcp"
139
- version = "0.19.1"
148
+ version = "0.22.0"
140
149
  dependencies = [
141
150
  "anyhow",
142
151
  "chrono",
143
152
  "lexsim",
153
+ "pulldown-cmark",
144
154
  "serde",
145
155
  "serde_json",
146
156
  "tempfile",
@@ -208,9 +218,9 @@ dependencies = [
208
218
 
209
219
  [[package]]
210
220
  name = "lexsim"
211
- version = "0.4.0"
221
+ version = "0.6.0"
212
222
  source = "registry+https://github.com/rust-lang/crates.io-index"
213
- checksum = "14b08f26a4b07325e83c0211adb36d0223fae644534dacd4eae72153c1ccd873"
223
+ checksum = "b64f48de8c9d203c0ed3ac8c68e9ba1072206d5072551c08889a4bbae9d84677"
214
224
  dependencies = [
215
225
  "unicode-normalization",
216
226
  "unicode-segmentation",
@@ -236,9 +246,9 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
236
246
 
237
247
  [[package]]
238
248
  name = "memchr"
239
- version = "2.8.2"
249
+ version = "2.8.3"
240
250
  source = "registry+https://github.com/rust-lang/crates.io-index"
241
- checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
251
+ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
242
252
 
243
253
  [[package]]
244
254
  name = "num-traits"
@@ -270,6 +280,25 @@ dependencies = [
270
280
  "unicode-ident",
271
281
  ]
272
282
 
283
+ [[package]]
284
+ name = "pulldown-cmark"
285
+ version = "0.13.4"
286
+ source = "registry+https://github.com/rust-lang/crates.io-index"
287
+ checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
288
+ dependencies = [
289
+ "bitflags",
290
+ "getopts",
291
+ "memchr",
292
+ "pulldown-cmark-escape",
293
+ "unicase",
294
+ ]
295
+
296
+ [[package]]
297
+ name = "pulldown-cmark-escape"
298
+ version = "0.11.0"
299
+ source = "registry+https://github.com/rust-lang/crates.io-index"
300
+ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
301
+
273
302
  [[package]]
274
303
  name = "quote"
275
304
  version = "1.0.46"
@@ -300,9 +329,9 @@ dependencies = [
300
329
 
301
330
  [[package]]
302
331
  name = "rustversion"
303
- version = "1.0.22"
332
+ version = "1.0.23"
304
333
  source = "registry+https://github.com/rust-lang/crates.io-index"
305
- checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
334
+ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
306
335
 
307
336
  [[package]]
308
337
  name = "serde"
@@ -415,9 +444,9 @@ dependencies = [
415
444
 
416
445
  [[package]]
417
446
  name = "tinyvec"
418
- version = "1.11.0"
447
+ version = "1.12.0"
419
448
  source = "registry+https://github.com/rust-lang/crates.io-index"
420
- checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
449
+ checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
421
450
  dependencies = [
422
451
  "tinyvec_macros",
423
452
  ]
@@ -469,6 +498,12 @@ version = "0.1.2"
469
498
  source = "registry+https://github.com/rust-lang/crates.io-index"
470
499
  checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
471
500
 
501
+ [[package]]
502
+ name = "unicase"
503
+ version = "2.9.0"
504
+ source = "registry+https://github.com/rust-lang/crates.io-index"
505
+ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
506
+
472
507
  [[package]]
473
508
  name = "unicode-ident"
474
509
  version = "1.0.24"
@@ -490,6 +525,12 @@ version = "1.13.3"
490
525
  source = "registry+https://github.com/rust-lang/crates.io-index"
491
526
  checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
492
527
 
528
+ [[package]]
529
+ name = "unicode-width"
530
+ version = "0.2.2"
531
+ source = "registry+https://github.com/rust-lang/crates.io-index"
532
+ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
533
+
493
534
  [[package]]
494
535
  name = "wasm-bindgen"
495
536
  version = "0.2.126"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.19.1"
3
+ version = "0.22.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
@@ -21,6 +21,7 @@ chrono = { version = "0.4", features = ["serde"] }
21
21
  anyhow = "1"
22
22
  thiserror = "2"
23
23
  lexsim = ">=0.1.0, <1.0.0"
24
+ pulldown-cmark = "0.13"
24
25
 
25
26
  [dev-dependencies]
26
27
  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
 
@@ -313,6 +419,26 @@ rename) so a concurrent reader never sees a partially-written file.
313
419
  For usage best practices (granularity, scope_paths, conflict handling, cleanup), see `skills/handoff-memory/SKILL.md`.
314
420
  See [Project Memory](#project-memory-1) below for what it is and how to wire automatic injection.
315
421
 
422
+ ### Document Management
423
+
424
+ | Tool | Purpose |
425
+ |------|---------|
426
+ | `handoff_doc_save` | Create or update a document (auto-splits into fragments) |
427
+ | `handoff_doc_get` | Read a document — full, meta, or single fragment |
428
+ | `handoff_doc_list` | List/search documents with BM25 and filters |
429
+ | `handoff_doc_delete` | Delete a document and its fragments |
430
+ | `handoff_doc_reassemble` | Reconstruct original Markdown from fragments |
431
+ | `handoff_doc_tree` | Walk family tree (ancestors/descendants/related) |
432
+ | `handoff_doc_query` | Context injection — staged full/outline, hook-driven |
433
+ | `handoff_doc_analyze` | Read-only heuristic scan (import step 1) |
434
+ | `handoff_doc_import` | Atomic bulk write after analysis (import step 3) |
435
+
436
+ Documents live in `.handoff/docs/` (`_doc.*.json` metadata + `_frag.*.{json,md}`
437
+ fragments). Large Markdown is split into fragments on save; `handoff_doc_reassemble`
438
+ reconstructs the original with drift detection, and `handoff_doc_query` feeds
439
+ staged (outline-first, then full-text) context to the agent — the same mechanism
440
+ that powers the hook-driven injection described below.
441
+
316
442
  ### Task Data Model
317
443
 
318
444
  Tasks are stored as a directory tree with status encoded in filenames:
@@ -478,14 +604,23 @@ what the agent intends, call `handoff_memory_query`, and inject the matching mem
478
604
  the same memory is **not injected twice in one session** — and an *edited* memory
479
605
  (new content hash) is re-injected.
480
606
 
607
+ The same hooks also call `handoff_doc_query`, so relevant documents (saved via
608
+ `handoff_doc_save`) are staged into context alongside memories — outline first,
609
+ then full text as relevance/budget allows.
610
+
481
611
  | Event | Calls | Effect |
482
612
  |-------|-------|--------|
483
- | `UserPromptSubmit` | `handoff_memory_query` (prompt text) | Inject memories relevant to the prompt |
484
- | `PreToolUse` (`Edit\|Write\|MultiEdit`) | `handoff_memory_query` (file path) | Inject memories scoped to the file being edited |
613
+ | `UserPromptSubmit` | `handoff_memory_query` (prompt text), `handoff_doc_query` (prompt text) | Inject memories and documents relevant to the prompt |
614
+ | `PreToolUse` (`Edit\|Write\|MultiEdit`) | `handoff_memory_query` (file path), `handoff_doc_query` (file path) | Inject memories and documents scoped to the file being edited |
485
615
 
486
616
  `handoff_memory_cleanup` (merge exact duplicates, gc old sidecars) is not wired
487
617
  to a hook — call it manually or from a CLI/cron job when you want housekeeping.
488
618
 
619
+ > The bundled `plugin-hooks/hooks/hooks.json` (used by the "Handoff MCP — Memory
620
+ > & Document Hooks" plugin) already wires both `handoff_memory_query` and
621
+ > `handoff_doc_query` on `UserPromptSubmit` and `PreToolUse`; the JSON examples
622
+ > below show the equivalent hand-written config for non-plugin setups.
623
+
489
624
  > **Wire hooks in your *user/global* settings, not in the repo.** Hooks are a
490
625
  > personal workflow choice; the handoff-mcp repo does not ship a `.claude/`
491
626
  > hooks config, and you should not commit one into a shared project. Put the
@@ -569,8 +704,9 @@ If you already ran `handoff-mcp setup` before this change, migrate with
569
704
  - **Manual edit**: open `~/.claude/settings.json` and delete the
570
705
  `hooks.SessionStart` entry whose `tool` is `handoff_memory_cleanup` (remove
571
706
  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.
707
+ - **Plugin users**: `/plugin update handoff-mcp-hooks@handoff-mcp-marketplace`
708
+ to pick up the new `hooks.json`, then restart Claude Code. This one ships
709
+ inside the plugin, so no binary update is needed.
574
710
 
575
711
  Restart Claude Code after any of the above for the change to take effect.
576
712
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.19.1",
3
+ "version": "0.22.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,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,35 @@ 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.
241
+
242
+ ### Document Management
243
+
244
+ For structured, multi-section documents (specs, designs, ADRs, guides) that
245
+ are too large for a single memory entry, use the doc tools instead — see the
246
+ `handoff-docs` skill for full parameter details and workflows:
247
+
248
+ | Tool | Purpose |
249
+ |---|---|
250
+ | `handoff_doc_save` | Create/update a document — auto-splits `body` into fragments at `##` (configurable via `split_level`) |
251
+ | `handoff_doc_get` | Read a document — `full` (reassembled), `meta` (manifest only), or `fragment` (one section) |
252
+ | `handoff_doc_list` | List/search documents (BM25), filter by `doc_type`/`tags`/`task_id` |
253
+ | `handoff_doc_delete` | Delete a document and its fragments (also unlinks it from tasks) |
254
+ | `handoff_doc_reassemble` | Reconstruct the original Markdown from fragments, with drift detection |
255
+ | `handoff_doc_tree` | Traverse the family tree — parent/child (structural) + related (semantic: `supersedes`/`references`/`implements`/`extends`/`conflicts`) |
256
+ | `handoff_doc_query` | Context injection (hook-driven) — staged `full`/`outline` results by fragment size |
257
+ | `handoff_doc_analyze` | Read-only heuristic scan of a file/directory — step 1 of importing existing docs |
258
+ | `handoff_doc_import` | Atomic bulk write of analyzed + AI-reviewed documents — step 3 of importing existing docs |
259
+
260
+ `handoff_doc_save(task_ids: [...])` creates a **bidirectional** doc↔task
261
+ link: the document gains a `task_ids` entry and each linked task gains a
262
+ `TaskLink` in its `task_links`. Look it up from either side with
263
+ `handoff_doc_list(task_id: ...)` (docs linked to a task) or
264
+ `handoff_get_task(id: ...)` (inspect `task_links` on the task record).
204
265
 
205
266
  ### Configuration Management
206
267