handoff-mcp-server 0.24.5 → 0.25.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 +29 -3
- package/Cargo.toml +4 -2
- package/README.md +23 -9
- package/package.json +1 -1
- package/skills/handoff-docs/SKILL.md +181 -9
- package/src/context/injection.rs +55 -18
- package/src/context/mod.rs +1 -1
- package/src/mcp/handlers/auto_schedule.rs +1 -13
- package/src/mcp/handlers/capacity.rs +1 -14
- package/src/mcp/handlers/config.rs +13 -0
- package/src/mcp/handlers/docs.rs +832 -76
- package/src/mcp/handlers/docs_query.rs +4 -4
- package/src/mcp/handlers/memory.rs +88 -13
- package/src/mcp/handlers/mod.rs +1 -0
- package/src/mcp/handlers/save_context.rs +8 -3
- package/src/mcp/handlers/task_checklist.rs +17 -5
- package/src/mcp/handlers/update_session.rs +6 -4
- package/src/mcp/tools.rs +28 -7
- package/src/storage/config.rs +217 -5
- package/src/storage/docs/frontmatter.rs +509 -0
- package/src/storage/docs/mod.rs +346 -108
- package/src/storage/docs/model.rs +186 -15
- package/src/storage/memory/mod.rs +1 -0
- package/src/storage/memory/model.rs +38 -7
- package/src/storage/sessions.rs +39 -11
package/Cargo.lock
CHANGED
|
@@ -145,7 +145,7 @@ dependencies = [
|
|
|
145
145
|
|
|
146
146
|
[[package]]
|
|
147
147
|
name = "handoff-mcp"
|
|
148
|
-
version = "0.
|
|
148
|
+
version = "0.25.0"
|
|
149
149
|
dependencies = [
|
|
150
150
|
"anyhow",
|
|
151
151
|
"chrono",
|
|
@@ -153,6 +153,7 @@ dependencies = [
|
|
|
153
153
|
"pulldown-cmark",
|
|
154
154
|
"serde",
|
|
155
155
|
"serde_json",
|
|
156
|
+
"serde_yaml",
|
|
156
157
|
"tempfile",
|
|
157
158
|
"thiserror",
|
|
158
159
|
"toml",
|
|
@@ -218,9 +219,9 @@ dependencies = [
|
|
|
218
219
|
|
|
219
220
|
[[package]]
|
|
220
221
|
name = "lexsim"
|
|
221
|
-
version = "0.
|
|
222
|
+
version = "0.7.0"
|
|
222
223
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
223
|
-
checksum = "
|
|
224
|
+
checksum = "c7bf6d7f096edac1f6d1f955d41d2acbe238b5775da9c7f4039fdd975138a5d1"
|
|
224
225
|
dependencies = [
|
|
225
226
|
"unicode-normalization",
|
|
226
227
|
"unicode-segmentation",
|
|
@@ -333,6 +334,12 @@ version = "1.0.23"
|
|
|
333
334
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
334
335
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|
335
336
|
|
|
337
|
+
[[package]]
|
|
338
|
+
name = "ryu"
|
|
339
|
+
version = "1.0.23"
|
|
340
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
341
|
+
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
|
342
|
+
|
|
336
343
|
[[package]]
|
|
337
344
|
name = "serde"
|
|
338
345
|
version = "1.0.228"
|
|
@@ -386,6 +393,19 @@ dependencies = [
|
|
|
386
393
|
"serde",
|
|
387
394
|
]
|
|
388
395
|
|
|
396
|
+
[[package]]
|
|
397
|
+
name = "serde_yaml"
|
|
398
|
+
version = "0.9.34+deprecated"
|
|
399
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
400
|
+
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
|
401
|
+
dependencies = [
|
|
402
|
+
"indexmap",
|
|
403
|
+
"itoa",
|
|
404
|
+
"ryu",
|
|
405
|
+
"serde",
|
|
406
|
+
"unsafe-libyaml",
|
|
407
|
+
]
|
|
408
|
+
|
|
389
409
|
[[package]]
|
|
390
410
|
name = "shlex"
|
|
391
411
|
version = "2.0.1"
|
|
@@ -531,6 +551,12 @@ version = "0.2.2"
|
|
|
531
551
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
532
552
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
|
533
553
|
|
|
554
|
+
[[package]]
|
|
555
|
+
name = "unsafe-libyaml"
|
|
556
|
+
version = "0.2.11"
|
|
557
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
558
|
+
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
|
559
|
+
|
|
534
560
|
[[package]]
|
|
535
561
|
name = "wasm-bindgen"
|
|
536
562
|
version = "0.2.126"
|
package/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "handoff-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.25.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
description = "MCP server that gives AI coding agents persistent memory across sessions"
|
|
6
6
|
license = "MIT"
|
|
@@ -20,8 +20,10 @@ toml_edit = "0.22"
|
|
|
20
20
|
chrono = { version = "0.4", features = ["serde"] }
|
|
21
21
|
anyhow = "1"
|
|
22
22
|
thiserror = "2"
|
|
23
|
-
lexsim = ">=0.
|
|
23
|
+
lexsim = ">=0.7.0, <1.0.0"
|
|
24
24
|
pulldown-cmark = "0.13"
|
|
25
|
+
serde_yaml = "0.9"
|
|
25
26
|
|
|
26
27
|
[dev-dependencies]
|
|
27
28
|
tempfile = "3.27.0"
|
|
29
|
+
|
package/README.md
CHANGED
|
@@ -452,21 +452,29 @@ See [Project Memory](#project-memory-1) below for what it is and how to wire aut
|
|
|
452
452
|
|
|
453
453
|
| Tool | Purpose |
|
|
454
454
|
|------|---------|
|
|
455
|
-
| `handoff_doc_save` | Create or update a document (auto-splits into
|
|
456
|
-
| `handoff_doc_get` | Read a document — full, meta, or single
|
|
455
|
+
| `handoff_doc_save` | Create or update a document (auto-splits into sections) |
|
|
456
|
+
| `handoff_doc_get` | Read a document — full, meta, or single section |
|
|
457
457
|
| `handoff_doc_list` | List/search documents with BM25 and filters |
|
|
458
|
-
| `handoff_doc_delete` | Delete a document
|
|
459
|
-
| `handoff_doc_reassemble` | Reconstruct original Markdown from
|
|
458
|
+
| `handoff_doc_delete` | Delete a document; unlinks from tasks |
|
|
459
|
+
| `handoff_doc_reassemble` | Reconstruct original Markdown from sections, with drift detection |
|
|
460
|
+
| `handoff_doc_update_section` | Replace a single section by seq (optimistic locking) |
|
|
460
461
|
| `handoff_doc_tree` | Walk family tree (ancestors/descendants/related) |
|
|
462
|
+
| `handoff_doc_graph` | Visualize inter-document relationships with optional verification status |
|
|
463
|
+
| `handoff_doc_trace` | Trace a document's lineage or dependency chain |
|
|
461
464
|
| `handoff_doc_query` | Context injection — staged full/outline, hook-driven |
|
|
465
|
+
| `handoff_doc_verify` | Verification matrix: generate, check, check_all, skip, sync, set_refs |
|
|
466
|
+
| `handoff_doc_verify_status` | Verification progress summary with optional per-section details |
|
|
462
467
|
| `handoff_doc_analyze` | Read-only heuristic scan (import step 1) |
|
|
463
468
|
| `handoff_doc_import` | Atomic bulk write after analysis (import step 3) |
|
|
464
469
|
|
|
465
|
-
Documents live in `.handoff/docs/`
|
|
466
|
-
|
|
467
|
-
reconstructs the original with drift detection, and
|
|
468
|
-
staged (outline-first, then full-text) context to
|
|
469
|
-
that powers the hook-driven injection described
|
|
470
|
+
Documents live in `.handoff/docs/` as single `_doc.<slug>.md` files (YAML
|
|
471
|
+
frontmatter + body). Large Markdown is split into sections on save;
|
|
472
|
+
`handoff_doc_reassemble` reconstructs the original with drift detection, and
|
|
473
|
+
`handoff_doc_query` feeds staged (outline-first, then full-text) context to
|
|
474
|
+
the agent — the same mechanism that powers the hook-driven injection described
|
|
475
|
+
below. `handoff_doc_verify` provides a verification matrix for tracking
|
|
476
|
+
per-section review status, implementation/test references, and staleness
|
|
477
|
+
detection after spec changes.
|
|
470
478
|
|
|
471
479
|
### Task Data Model
|
|
472
480
|
|
|
@@ -823,10 +831,15 @@ This project uses handoff-mcp for session continuity.
|
|
|
823
831
|
When the VSCode extension is running, the timer delegates to it automatically.
|
|
824
832
|
When the extension is absent, MCP runs a fallback timer and logs hours on stop.
|
|
825
833
|
Use `handoff_timer_get_time` to check elapsed time without stopping.
|
|
834
|
+
- **Spec registration**: if the task has a spec or design document,
|
|
835
|
+
register it via `handoff_doc_save(task_ids=[...])` so it survives across
|
|
836
|
+
sessions and links bidirectionally to the task. Generate a verification
|
|
837
|
+
matrix with `handoff_doc_verify(action="generate")` for review tracking.
|
|
826
838
|
- **Project memory**: Use `handoff_memory_save` to record durable lessons, rules,
|
|
827
839
|
conventions, and gotchas that every future session should know. Use
|
|
828
840
|
`handoff_memory_query` to retrieve relevant memories. Near-duplicate memories are
|
|
829
841
|
surfaced as conflicts for you to merge or force-save — never merged silently.
|
|
842
|
+
Save **as you learn them** during work, not at session end.
|
|
830
843
|
```
|
|
831
844
|
|
|
832
845
|
## Skills
|
|
@@ -837,6 +850,7 @@ This repository includes skill files that make handoff behavior automatic in Cla
|
|
|
837
850
|
|-------|---------|
|
|
838
851
|
| `handoff` | Core session lifecycle, task management, metrics, scheduling |
|
|
839
852
|
| `handoff-load` | Quick session-start procedure |
|
|
853
|
+
| `handoff-docs` | Document management — save, search, verify, import, family tree |
|
|
840
854
|
| `handoff-memory` | Memory CRUD, conflict handling, cleanup |
|
|
841
855
|
| `handoff-refer` | Cross-project referrals |
|
|
842
856
|
| `handoff-import` | Bulk import from documents |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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>",
|
|
@@ -60,17 +60,21 @@ Check readiness:
|
|
|
60
60
|
- MCP automatically computes a section index at each h2 heading boundary
|
|
61
61
|
- Use `doc_get(format="section", seq=N)` to retrieve individual sections on demand
|
|
62
62
|
|
|
63
|
-
## The
|
|
63
|
+
## The 13 Doc Tools
|
|
64
64
|
|
|
65
65
|
| Tool | Purpose |
|
|
66
66
|
|---|---|
|
|
67
|
-
| `handoff_doc_save` | Create or update a document. Splits `body` into
|
|
68
|
-
| `handoff_doc_get` | Read a document — `full` (reassembled body), `meta` (manifest only), or `
|
|
69
|
-
| `handoff_doc_list` | List/search documents (BM25 over title +
|
|
70
|
-
| `handoff_doc_delete` | Delete a document
|
|
71
|
-
| `handoff_doc_reassemble` | Reconstruct the original Markdown from
|
|
67
|
+
| `handoff_doc_save` | Create or update a document. Splits `body` into sections automatically. |
|
|
68
|
+
| `handoff_doc_get` | Read a document — `full` (reassembled body), `meta` (manifest only), or `section` (one section by seq). |
|
|
69
|
+
| `handoff_doc_list` | List/search documents (BM25 over title + section bodies), filter by `doc_type`, `tags`, `task_id`. |
|
|
70
|
+
| `handoff_doc_delete` | Delete a document; unlinks it from any linked tasks. |
|
|
71
|
+
| `handoff_doc_reassemble` | Reconstruct the original Markdown from sections, with drift detection. |
|
|
72
|
+
| `handoff_doc_update_section` | Replace a single section's content by seq (optimistic locking via `expected_hash`). |
|
|
72
73
|
| `handoff_doc_tree` | Walk the family tree (ancestors/descendants/related) for a document. |
|
|
74
|
+
| `handoff_doc_graph` | Visualize inter-document relationships; optionally includes verification status per node. |
|
|
75
|
+
| `handoff_doc_trace` | Trace a document's lineage or dependency chain. |
|
|
73
76
|
| `handoff_doc_query` | Context injection — hook-driven, staged `full`/`outline` results ranked by relevance. |
|
|
77
|
+
| `handoff_doc_verify` | Verification matrix operations: `generate`, `check`, `check_all`, `skip`, `sync`, `set_refs`, `add_item` (v2 — freeform items / sub_items), `suggest_refs` (scan scope_paths for impl/test ref candidates). |
|
|
74
78
|
| `handoff_doc_analyze` | Read-only heuristic scan of a file or directory — step 1 of the import flow. |
|
|
75
79
|
| `handoff_doc_import` | Atomic bulk write of analyzed + AI-reviewed documents — step 3 of the import flow. |
|
|
76
80
|
|
|
@@ -133,6 +137,19 @@ preserved — `save(body) → reassemble()` is byte-identical. If a fragment was
|
|
|
133
137
|
edited directly after the split, its `content_hash` no longer matches and
|
|
134
138
|
`reassemble` reports the drift instead of silently returning stale content.
|
|
135
139
|
|
|
140
|
+
### `handoff_doc_update_section`
|
|
141
|
+
|
|
142
|
+
| Param | Required | Description |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `doc_id` | yes | Document to update |
|
|
145
|
+
| `seq` | yes | Section sequence number to replace |
|
|
146
|
+
| `new_content` | yes | New Markdown content for the section (empty string deletes it) |
|
|
147
|
+
| `expected_hash` | no | Optimistic lock — if set, the update fails when the section's current `content_hash` differs (returns the current hash so you can retry) |
|
|
148
|
+
|
|
149
|
+
Replaces a single section's content without rewriting the entire document.
|
|
150
|
+
The section's `content_hash` is recomputed after the update, and any
|
|
151
|
+
verification matrix item for this seq is marked stale.
|
|
152
|
+
|
|
136
153
|
### `handoff_doc_tree`
|
|
137
154
|
|
|
138
155
|
| Param | Required | Description |
|
|
@@ -141,6 +158,20 @@ edited directly after the split, its `content_hash` no longer matches and
|
|
|
141
158
|
| `depth` | no | How many parent/child levels to return |
|
|
142
159
|
| `include_related` | no | Whether to also include semantically `related` documents |
|
|
143
160
|
|
|
161
|
+
### `handoff_doc_graph`
|
|
162
|
+
|
|
163
|
+
| Param | Required | Description |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `doc_id` | no | Focus on a specific document and its neighbors |
|
|
166
|
+
| `include_verification` | no | Include `{total, verified}` verification progress per node |
|
|
167
|
+
|
|
168
|
+
### `handoff_doc_trace`
|
|
169
|
+
|
|
170
|
+
| Param | Required | Description |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `doc_id` | yes | Document to trace from |
|
|
173
|
+
| `direction` | no | `"up"` (ancestors) or `"down"` (descendants), default both |
|
|
174
|
+
|
|
144
175
|
### `handoff_doc_query`
|
|
145
176
|
|
|
146
177
|
| Param | Required | Description |
|
|
@@ -177,9 +208,150 @@ each carrying a concrete `suggestion` the AI can approve, edit, or reject.
|
|
|
177
208
|
| `overrides` | no | Per-file corrections (`doc_type`, relationship resolutions, etc.) |
|
|
178
209
|
| `task_ids` | no | Link every imported document to these tasks |
|
|
179
210
|
|
|
180
|
-
Writes all
|
|
181
|
-
|
|
182
|
-
|
|
211
|
+
Writes all documents atomically in one transaction, including any task links
|
|
212
|
+
— matches the "validate whole tree, then write" pattern used by
|
|
213
|
+
`handoff_import_context` for tasks.
|
|
214
|
+
|
|
215
|
+
### `handoff_doc_verify`
|
|
216
|
+
|
|
217
|
+
| Param | Required | Description |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `doc_id` | yes | Document whose verification matrix to operate on |
|
|
220
|
+
| `action` | yes | One of: `generate`, `check`, `check_all`, `skip`, `sync`, `set_refs`, `add_item`, `suggest_refs` |
|
|
221
|
+
| `fragment_seq` | for `check`/`skip`/`set_refs`/`add_item` | Section seq to operate on (integer or array of integers for batch). For `add_item`, omit to add a freeform top-level item instead of a section sub_item. |
|
|
222
|
+
| `sub_item_index` | no | For `check`/`skip`: the 0-based `SubItem.index` within `fragment_seq`'s `sub_items` to operate on, instead of the parent item itself (v2) |
|
|
223
|
+
| `description` | for `add_item` when `fragment_seq` given | The new sub_item's description (v2) |
|
|
224
|
+
| `label` | for `add_item` when `fragment_seq` omitted | The new freeform top-level item's label (v2) |
|
|
225
|
+
| `category` | no | For `add_item`: item/sub_item category — `"requirement"` (default for sub_items), `"visual"`, `"regression"`, `"manual"`, ... free-extensible (v2) |
|
|
226
|
+
| `skip_seqs` | for `generate` | Seqs to mark `skipped` on generation (e.g. `[0]` to skip the preamble) |
|
|
227
|
+
| `reviewer` | no | `"ai"` or `"user"` — who performed the review |
|
|
228
|
+
| `notes` | no | Free-text notes attached to the check |
|
|
229
|
+
| `impl_refs` | for `set_refs` | Array of `{ path, lines?, label? }` — implementation locations |
|
|
230
|
+
| `test_refs` | for `set_refs` | Array of `{ path, lines?, label? }` — test locations |
|
|
231
|
+
|
|
232
|
+
**Actions:**
|
|
233
|
+
|
|
234
|
+
| Action | What it does |
|
|
235
|
+
|---|---|
|
|
236
|
+
| `generate` | Create a new verification matrix from the document's sections. Errors if a matrix already exists (use `sync` to update). |
|
|
237
|
+
| `check` | Mark one or more sections (or, with `sub_item_index`, a single sub_item) as `verified`. Records `verified_at` and `content_hash_at_verify`. |
|
|
238
|
+
| `check_all` | Mark every section — and every sub_item (v2) — in the matrix as `verified` in one call. |
|
|
239
|
+
| `skip` | Mark a section (or, with `sub_item_index`, a single sub_item) as `skipped` (not applicable for review). |
|
|
240
|
+
| `sync` | Re-synchronize the matrix after sections changed (added/removed). Preserves existing item statuses; freeform items (v2) are never dropped. |
|
|
241
|
+
| `set_refs` | Attach `impl_refs` / `test_refs` to a section item. |
|
|
242
|
+
| `add_item` (v2) | With `fragment_seq`: append a `SubItem` (individual requirement) to that section's `sub_items` — `description` required. Without `fragment_seq`: append a freeform top-level item not tied to any section (e.g. a GUI check or regression test) — `label` required. |
|
|
243
|
+
| `suggest_refs` | Read-only. Scans the document's `scope_paths` for source/test files (`.rs`/`.ts`/`.tsx`/`.py`/`.go`/`.js`/`.jsx`) and fuzzy-matches `fn`/`struct`/`impl`/`mod` definitions and test functions (`#[test]`, `fn test_*`, files under `tests/`) against each item's heading, returning up to 20 `impl_refs`/`test_refs` candidates per item for review. Requires an existing matrix (`generate` first). Does not mutate the document — accept candidates by passing them to `set_refs`. |
|
|
244
|
+
|
|
245
|
+
### `handoff_doc_verify_status`
|
|
246
|
+
|
|
247
|
+
| Param | Required | Description |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| `doc_id` | yes | Document to query |
|
|
250
|
+
| `include_items` | no | `true` to include per-section item details (default `false` — summary only) |
|
|
251
|
+
| `format` | no | `"json"` (default) or `"checklist"` (v2 — Markdown checklist rendering, see below) |
|
|
252
|
+
|
|
253
|
+
Returns verification progress: `{ verification_status, progress: { checked, skipped, pending, total, stale, percentage } }`.
|
|
254
|
+
When `include_items: true`, also returns an `items` array with each section's
|
|
255
|
+
status, staleness flag, refs, reviewer, and notes (v2: including `category`,
|
|
256
|
+
`sub_items`, and `label` for freeform items). v2 progress counts are
|
|
257
|
+
leaf-based: an item with `sub_items` contributes its sub_items to
|
|
258
|
+
`checked`/`skipped`/`pending`/`total` instead of itself, and freeform items
|
|
259
|
+
(`fragment_seq: null`) are counted directly.
|
|
260
|
+
|
|
261
|
+
#### `format="checklist"` (v2)
|
|
262
|
+
|
|
263
|
+
`handoff_doc_verify_status(doc_id=..., include_items=true, format="checklist")`
|
|
264
|
+
returns a Markdown checklist instead of JSON — useful for pasting into a PR
|
|
265
|
+
description or presenting readiness to a human reviewer:
|
|
266
|
+
|
|
267
|
+
```markdown
|
|
268
|
+
# Verification: Document Title
|
|
269
|
+
Status: in_review (5/16, 31%)
|
|
270
|
+
|
|
271
|
+
## §2 1. Requirements ✓ verified ⚠ stale
|
|
272
|
+
- impl: src/storage/docs/mod.rs:42-180 (DocStore)
|
|
273
|
+
- test: tests/doc_save.rs (roundtrip test)
|
|
274
|
+
- [ ] Shape must be an octahedron
|
|
275
|
+
- [x] Color matches status (@ai, 2026-07-11)
|
|
276
|
+
|
|
277
|
+
## — Drag-and-drop visual check ○ pending [visual]
|
|
278
|
+
## — No layout regressions in the existing task list ○ pending [regression]
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Verification Workflow
|
|
282
|
+
|
|
283
|
+
### When to use each action
|
|
284
|
+
|
|
285
|
+
| Situation | What to do |
|
|
286
|
+
|---|---|
|
|
287
|
+
| Spec just saved | `generate` (optionally with `skip_seqs: [0]` to skip the preamble) |
|
|
288
|
+
| Implementation complete for a section | `check(fragment_seq=N, reviewer="ai")` + `set_refs` |
|
|
289
|
+
| Section is background/context only | `skip(fragment_seq=N)` |
|
|
290
|
+
| Spec was updated after matrix existed | `sync` to add/remove items, then re-check stale items |
|
|
291
|
+
| GUI/visual check needed | `check(fragment_seq=N, reviewer="user")` — user confirms manually |
|
|
292
|
+
| A section has multiple distinct requirements to track individually | `add_item(fragment_seq=N, description=...)` per requirement, then `check(fragment_seq=N, sub_item_index=I)` on each |
|
|
293
|
+
| A check doesn't map to any single section (GUI/regression/manual sweep) | `add_item(label=..., category="visual"\|"regression"\|"manual")` (freeform, no `fragment_seq`), then `check(fragment_seq=<its seq>)` |
|
|
294
|
+
| Human-readable readiness summary (PR description, review handoff) | `doc_verify_status(include_items=true, format="checklist")` — Markdown checklist |
|
|
295
|
+
| Quick release readiness | `doc_verify_status` — check `verification_status == "verified"` |
|
|
296
|
+
| Don't want to hunt for impl/test locations by hand | `suggest_refs` to get candidates per item, review them, then `set_refs(fragment_seq=N, impl_refs=..., test_refs=...)` with the ones you accept |
|
|
297
|
+
|
|
298
|
+
### `reviewer` guidelines
|
|
299
|
+
|
|
300
|
+
| Reviewer | When |
|
|
301
|
+
|---|---|
|
|
302
|
+
| `"ai"` | AI verified by reading the code, running tests, or comparing spec vs implementation |
|
|
303
|
+
| `"user"` | User verified visually (GUI, layout, drag behavior) or confirmed a judgment call |
|
|
304
|
+
|
|
305
|
+
### Stale detection and response
|
|
306
|
+
|
|
307
|
+
When a section's content changes after being verified, `doc_verify_status`
|
|
308
|
+
flags it as `stale: true`. Response flow:
|
|
309
|
+
|
|
310
|
+
1. Check `doc_verify_status(include_items: true)` — look for `stale` items
|
|
311
|
+
2. Review the changed section: `doc_get(format="section", seq=N)`
|
|
312
|
+
3. If still valid: `check(fragment_seq=N)` to re-verify (updates `content_hash_at_verify`)
|
|
313
|
+
4. If invalid: update implementation, then re-verify
|
|
314
|
+
|
|
315
|
+
### Multi-document release verification
|
|
316
|
+
|
|
317
|
+
For release readiness across multiple specs:
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
1. handoff_doc_list(doc_type="spec", tags=["release-target"])
|
|
321
|
+
2. For each doc: handoff_doc_verify_status(doc_id=...)
|
|
322
|
+
3. All docs must have verification_status == "verified" and stale == 0
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### E2E workflow example
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
# 1. Write and save the spec
|
|
329
|
+
handoff_doc_save(slug="auth-spec", title="Authentication Spec",
|
|
330
|
+
body="# Auth Spec\n\n## Requirements\n...",
|
|
331
|
+
doc_type="spec", task_ids=["t42"])
|
|
332
|
+
|
|
333
|
+
# 2. Generate verification matrix (skip preamble)
|
|
334
|
+
handoff_doc_verify(doc_id="doc-...", action="generate", skip_seqs=[0])
|
|
335
|
+
|
|
336
|
+
# 3. Implement, then mark sections verified
|
|
337
|
+
handoff_doc_verify(doc_id="doc-...", action="check", fragment_seq=1,
|
|
338
|
+
reviewer="ai", notes="Implemented and tested")
|
|
339
|
+
handoff_doc_verify(doc_id="doc-...", action="set_refs", fragment_seq=1,
|
|
340
|
+
impl_refs=[{path: "src/auth.rs", lines: "10-50"}],
|
|
341
|
+
test_refs=[{path: "tests/auth.rs", label: "login flow"}])
|
|
342
|
+
|
|
343
|
+
# 3b. Or let suggest_refs propose candidates instead of hand-picking them —
|
|
344
|
+
# requires scope_paths to be set on the document (doc_save(scope_paths=[...]))
|
|
345
|
+
handoff_doc_verify(doc_id="doc-...", action="suggest_refs")
|
|
346
|
+
# → { suggestions: [{ fragment_seq: 1, heading: "Requirements",
|
|
347
|
+
# suggested_impl_refs: [{path: "src/auth.rs", lines: "12", label: "handle_login"}],
|
|
348
|
+
# suggested_test_refs: [{path: "tests/auth.rs", lines: "5", label: "test_login_flow"}] }] }
|
|
349
|
+
# Review the candidates, then accept the ones you want via set_refs (same as 3).
|
|
350
|
+
|
|
351
|
+
# 4. Check release readiness
|
|
352
|
+
handoff_doc_verify_status(doc_id="doc-...", include_items=true)
|
|
353
|
+
# → verification_status: "verified", stale: 0 → ready to ship
|
|
354
|
+
```
|
|
183
355
|
|
|
184
356
|
## Staged Injection (outline vs full)
|
|
185
357
|
|
package/src/context/injection.rs
CHANGED
|
@@ -20,6 +20,9 @@ pub struct RankItem {
|
|
|
20
20
|
pub struct RankConfig {
|
|
21
21
|
/// Candidates scoring below this are dropped before sorting.
|
|
22
22
|
pub min_score: f64,
|
|
23
|
+
/// Relative threshold (0.0–1.0): after ranking, a candidate is dropped
|
|
24
|
+
/// unless `score >= top_score * relative_threshold`. 0.0 disables.
|
|
25
|
+
pub relative_threshold: f64,
|
|
23
26
|
/// Added to a candidate's BM25 score when [`scope_matches`] is true.
|
|
24
27
|
pub scope_path_bonus: f64,
|
|
25
28
|
/// Max number of items returned (applied after sort, before session diff).
|
|
@@ -37,10 +40,10 @@ pub fn scope_matches(scopes: &[String], files: &[String]) -> bool {
|
|
|
37
40
|
.any(|scope| files.iter().any(|f| f.contains(scope.as_str())))
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
/// Rank every document in `corpus` against `query_tokens` via BM25,
|
|
41
|
-
/// `config.scope_path_bonus` when `scope_paths[i]` matches `file_paths`,
|
|
42
|
-
/// anything below `config.min_score`, sort descending by score, and
|
|
43
|
-
/// to `config.limit`.
|
|
43
|
+
/// Rank every document in `corpus` against `query_tokens` via weighted BM25,
|
|
44
|
+
/// add `config.scope_path_bonus` when `scope_paths[i]` matches `file_paths`,
|
|
45
|
+
/// drop anything below `config.min_score`, sort descending by score, and
|
|
46
|
+
/// truncate to `config.limit`.
|
|
44
47
|
///
|
|
45
48
|
/// `scope_paths` and the corpus must be index-aligned (one entry per
|
|
46
49
|
/// document); `corpus.len()` and `scope_paths.len()` are expected to match —
|
|
@@ -48,12 +51,12 @@ pub fn scope_matches(scopes: &[String], files: &[String]) -> bool {
|
|
|
48
51
|
/// consulted (indices beyond `corpus.len()` are not produced).
|
|
49
52
|
pub fn rank_by_bm25_and_scope(
|
|
50
53
|
corpus: &lexsim::Corpus,
|
|
51
|
-
query_tokens: &[
|
|
54
|
+
query_tokens: &[lexsim::WeightedToken],
|
|
52
55
|
scope_paths: &[Vec<String>],
|
|
53
56
|
file_paths: &[String],
|
|
54
57
|
config: &RankConfig,
|
|
55
58
|
) -> Vec<RankItem> {
|
|
56
|
-
let scores = corpus.
|
|
59
|
+
let scores = corpus.bm25_scores_weighted_tokens(query_tokens);
|
|
57
60
|
|
|
58
61
|
let mut ranked: Vec<RankItem> = scores
|
|
59
62
|
.into_iter()
|
|
@@ -66,7 +69,7 @@ pub fn rank_by_bm25_and_scope(
|
|
|
66
69
|
}
|
|
67
70
|
RankItem { index, score }
|
|
68
71
|
})
|
|
69
|
-
.filter(|item| item.score >= config.min_score)
|
|
72
|
+
.filter(|item| item.score > 0.0 && item.score >= config.min_score)
|
|
70
73
|
.collect();
|
|
71
74
|
|
|
72
75
|
ranked.sort_by(|a, b| {
|
|
@@ -74,6 +77,16 @@ pub fn rank_by_bm25_and_scope(
|
|
|
74
77
|
.partial_cmp(&a.score)
|
|
75
78
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
76
79
|
});
|
|
80
|
+
|
|
81
|
+
// Relative threshold: drop candidates whose score is below a fraction of
|
|
82
|
+
// the top hit. Applied after sorting so `ranked[0]` is the best match.
|
|
83
|
+
if config.relative_threshold > 0.0 {
|
|
84
|
+
if let Some(top) = ranked.first() {
|
|
85
|
+
let floor = top.score * config.relative_threshold;
|
|
86
|
+
ranked.retain(|item| item.score >= floor);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
ranked.truncate(config.limit);
|
|
78
91
|
ranked
|
|
79
92
|
}
|
|
@@ -114,6 +127,7 @@ mod tests {
|
|
|
114
127
|
fn default_config() -> RankConfig {
|
|
115
128
|
RankConfig {
|
|
116
129
|
min_score: 0.0,
|
|
130
|
+
relative_threshold: 0.0,
|
|
117
131
|
scope_path_bonus: 2.0,
|
|
118
132
|
limit: 10,
|
|
119
133
|
}
|
|
@@ -136,27 +150,25 @@ mod tests {
|
|
|
136
150
|
|
|
137
151
|
#[test]
|
|
138
152
|
fn rank_by_bm25_orders_relevant_docs_first() {
|
|
139
|
-
let corpus = lexsim::Corpus::
|
|
140
|
-
let query_tokens = lexsim::
|
|
153
|
+
let corpus = lexsim::Corpus::build_weighted(&docs());
|
|
154
|
+
let query_tokens = lexsim::tokenize_weighted("rust ownership");
|
|
141
155
|
let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
|
|
142
156
|
let ranked =
|
|
143
157
|
rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &default_config());
|
|
144
158
|
|
|
145
159
|
assert!(!ranked.is_empty());
|
|
146
|
-
// Doc 2 mentions "rust" and "ownership" twice — should rank first.
|
|
147
160
|
assert_eq!(ranked[0].index, 2);
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
#[test]
|
|
151
164
|
fn rank_by_bm25_applies_scope_path_bonus() {
|
|
152
|
-
let corpus = lexsim::Corpus::
|
|
153
|
-
|
|
154
|
-
// queried file path, so the bonus should promote it into the results.
|
|
155
|
-
let query_tokens = lexsim::tokenize("javascript");
|
|
165
|
+
let corpus = lexsim::Corpus::build_weighted(&docs());
|
|
166
|
+
let query_tokens = lexsim::tokenize_weighted("javascript");
|
|
156
167
|
let scope_paths: Vec<Vec<String>> = vec![vec![], vec!["src/web/".to_string()], vec![]];
|
|
157
168
|
let file_paths = vec!["/repo/src/web/app.js".to_string()];
|
|
158
169
|
let config = RankConfig {
|
|
159
170
|
min_score: 0.0,
|
|
171
|
+
relative_threshold: 0.0,
|
|
160
172
|
scope_path_bonus: 2.0,
|
|
161
173
|
limit: 10,
|
|
162
174
|
};
|
|
@@ -168,11 +180,12 @@ mod tests {
|
|
|
168
180
|
|
|
169
181
|
#[test]
|
|
170
182
|
fn rank_by_bm25_filters_below_min_score() {
|
|
171
|
-
let corpus = lexsim::Corpus::
|
|
172
|
-
let query_tokens = lexsim::
|
|
183
|
+
let corpus = lexsim::Corpus::build_weighted(&docs());
|
|
184
|
+
let query_tokens = lexsim::tokenize_weighted("completely unrelated gibberish zzz");
|
|
173
185
|
let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
|
|
174
186
|
let config = RankConfig {
|
|
175
187
|
min_score: 0.01,
|
|
188
|
+
relative_threshold: 0.0,
|
|
176
189
|
scope_path_bonus: 2.0,
|
|
177
190
|
limit: 10,
|
|
178
191
|
};
|
|
@@ -182,11 +195,12 @@ mod tests {
|
|
|
182
195
|
|
|
183
196
|
#[test]
|
|
184
197
|
fn rank_by_bm25_respects_limit() {
|
|
185
|
-
let corpus = lexsim::Corpus::
|
|
186
|
-
let query_tokens = lexsim::
|
|
198
|
+
let corpus = lexsim::Corpus::build_weighted(&docs());
|
|
199
|
+
let query_tokens = lexsim::tokenize_weighted("rust javascript");
|
|
187
200
|
let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
|
|
188
201
|
let config = RankConfig {
|
|
189
202
|
min_score: 0.0,
|
|
203
|
+
relative_threshold: 0.0,
|
|
190
204
|
scope_path_bonus: 2.0,
|
|
191
205
|
limit: 1,
|
|
192
206
|
};
|
|
@@ -194,6 +208,29 @@ mod tests {
|
|
|
194
208
|
assert_eq!(ranked.len(), 1);
|
|
195
209
|
}
|
|
196
210
|
|
|
211
|
+
#[test]
|
|
212
|
+
fn rank_by_bm25_applies_relative_threshold() {
|
|
213
|
+
let corpus = lexsim::Corpus::build_weighted(&docs());
|
|
214
|
+
let query_tokens = lexsim::tokenize_weighted("rust ownership");
|
|
215
|
+
let scope_paths: Vec<Vec<String>> = vec![vec![], vec![], vec![]];
|
|
216
|
+
let all =
|
|
217
|
+
rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &default_config());
|
|
218
|
+
assert!(all.len() >= 2, "need at least 2 results to test relative");
|
|
219
|
+
let config_rel = RankConfig {
|
|
220
|
+
min_score: 0.0,
|
|
221
|
+
relative_threshold: 0.95,
|
|
222
|
+
scope_path_bonus: 0.0,
|
|
223
|
+
limit: 10,
|
|
224
|
+
};
|
|
225
|
+
let filtered =
|
|
226
|
+
rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config_rel);
|
|
227
|
+
assert!(
|
|
228
|
+
filtered.len() < all.len(),
|
|
229
|
+
"relative threshold should drop low-scoring tail"
|
|
230
|
+
);
|
|
231
|
+
assert_eq!(filtered[0].index, all[0].index, "top hit must survive");
|
|
232
|
+
}
|
|
233
|
+
|
|
197
234
|
#[test]
|
|
198
235
|
fn filter_already_injected_drops_marked_and_respects_limit() {
|
|
199
236
|
let ranked = vec![
|
package/src/context/mod.rs
CHANGED
|
@@ -54,7 +54,7 @@ impl CorpusCache {
|
|
|
54
54
|
pub fn get_or_build_corpus(&mut self, doc_texts: &[String]) -> &lexsim::Corpus {
|
|
55
55
|
let stale = self.built_generation != Some(self.generation) || self.doc_texts != doc_texts;
|
|
56
56
|
if stale {
|
|
57
|
-
self.corpus = Some(lexsim::Corpus::
|
|
57
|
+
self.corpus = Some(lexsim::Corpus::build_weighted(doc_texts));
|
|
58
58
|
self.doc_texts = doc_texts.to_vec();
|
|
59
59
|
self.built_generation = Some(self.generation);
|
|
60
60
|
}
|
|
@@ -6,6 +6,7 @@ use serde_json::{json, Value};
|
|
|
6
6
|
use toml_edit::DocumentMut;
|
|
7
7
|
|
|
8
8
|
use super::resolve_project_dir;
|
|
9
|
+
use crate::storage::config::weekday_to_num;
|
|
9
10
|
use crate::storage::ensure_handoff_exists;
|
|
10
11
|
use crate::storage::tasks::*;
|
|
11
12
|
|
|
@@ -486,16 +487,3 @@ fn parse_assignee_calendars(config_path: &std::path::Path) -> Result<HashMap<Str
|
|
|
486
487
|
|
|
487
488
|
Ok(result)
|
|
488
489
|
}
|
|
489
|
-
|
|
490
|
-
fn weekday_to_num(s: &str) -> Option<u32> {
|
|
491
|
-
match s.to_lowercase().as_str() {
|
|
492
|
-
"sun" | "sunday" => Some(0),
|
|
493
|
-
"mon" | "monday" => Some(1),
|
|
494
|
-
"tue" | "tuesday" => Some(2),
|
|
495
|
-
"wed" | "wednesday" => Some(3),
|
|
496
|
-
"thu" | "thursday" => Some(4),
|
|
497
|
-
"fri" | "friday" => Some(5),
|
|
498
|
-
"sat" | "saturday" => Some(6),
|
|
499
|
-
_ => None,
|
|
500
|
-
}
|
|
501
|
-
}
|
|
@@ -4,7 +4,7 @@ use serde_json::{json, Value};
|
|
|
4
4
|
use toml_edit::DocumentMut;
|
|
5
5
|
|
|
6
6
|
use super::resolve_project_dir;
|
|
7
|
-
use crate::storage::config::read_config;
|
|
7
|
+
use crate::storage::config::{read_config, weekday_to_num};
|
|
8
8
|
use crate::storage::ensure_handoff_exists;
|
|
9
9
|
use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
|
|
10
10
|
|
|
@@ -245,19 +245,6 @@ fn parse_calendar(config_path: &std::path::Path, assignee: Option<&str>) -> Resu
|
|
|
245
245
|
Ok(cal)
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
fn weekday_to_num(s: &str) -> Option<u32> {
|
|
249
|
-
match s.to_lowercase().as_str() {
|
|
250
|
-
"sun" | "sunday" => Some(0),
|
|
251
|
-
"mon" | "monday" => Some(1),
|
|
252
|
-
"tue" | "tuesday" => Some(2),
|
|
253
|
-
"wed" | "wednesday" => Some(3),
|
|
254
|
-
"thu" | "thursday" => Some(4),
|
|
255
|
-
"fri" | "friday" => Some(5),
|
|
256
|
-
"sat" | "saturday" => Some(6),
|
|
257
|
-
_ => None,
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
248
|
fn allocate_task_hours(
|
|
262
249
|
tree: &[TaskIndex],
|
|
263
250
|
assignee_filter: Option<&str>,
|
|
@@ -50,6 +50,7 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
50
50
|
"settings.memory_enabled",
|
|
51
51
|
"settings.memory_dup_threshold",
|
|
52
52
|
"settings.memory_query_min_score",
|
|
53
|
+
"settings.memory_query_relative_threshold",
|
|
53
54
|
"settings.memory_query_limit",
|
|
54
55
|
"settings.memory_stale_days",
|
|
55
56
|
"settings.memory_injected_gc_days",
|
|
@@ -153,6 +154,18 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
153
154
|
config.settings.memory_query_min_score = n;
|
|
154
155
|
applied.push(format!("settings.memory_query_min_score = {n}"));
|
|
155
156
|
}
|
|
157
|
+
"settings.memory_query_relative_threshold" => {
|
|
158
|
+
let Some(n) = value.as_f64() else {
|
|
159
|
+
anyhow::bail!("settings.memory_query_relative_threshold must be a number");
|
|
160
|
+
};
|
|
161
|
+
if !(0.0..=1.0).contains(&n) {
|
|
162
|
+
anyhow::bail!(
|
|
163
|
+
"settings.memory_query_relative_threshold must be between 0 and 1"
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
config.settings.memory_query_relative_threshold = n;
|
|
167
|
+
applied.push(format!("settings.memory_query_relative_threshold = {n}"));
|
|
168
|
+
}
|
|
156
169
|
"settings.memory_query_limit" => {
|
|
157
170
|
let Some(n) = value.as_u64() else {
|
|
158
171
|
anyhow::bail!("settings.memory_query_limit must be a non-negative integer");
|