handoff-mcp-server 0.22.1 → 0.24.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
@@ -145,7 +145,7 @@ dependencies = [
145
145
 
146
146
  [[package]]
147
147
  name = "handoff-mcp"
148
- version = "0.22.1"
148
+ version = "0.24.0"
149
149
  dependencies = [
150
150
  "anyhow",
151
151
  "chrono",
@@ -655,6 +655,6 @@ dependencies = [
655
655
 
656
656
  [[package]]
657
657
  name = "zmij"
658
- version = "1.0.21"
658
+ version = "1.0.22"
659
659
  source = "registry+https://github.com/rust-lang/crates.io-index"
660
- checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
660
+ checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.22.1"
3
+ version = "0.24.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -556,10 +556,10 @@ handoff-mcp distinguishes the **raw human-effort estimate** you record on a task
556
556
  from the **AI-effort hours** used in scheduling and metrics:
557
557
 
558
558
  - **`require_estimate_hours`** (default `true`) — `handoff_update_task` rejects
559
- creating or updating a *leaf* task (in `todo` / `in_progress` / `review` /
560
- `done`) without `schedule.estimate_hours > 0`. Parent tasks (with children) and
561
- `blocked` / `skipped` tasks are exempt, and an estimate already on the task
562
- satisfies the requirement. Set to `false` to opt out.
559
+ creating or updating a *leaf* task in `in_progress` / `review` / `done` without
560
+ `schedule.estimate_hours > 0`. Parent tasks (with children) and tasks in `todo` /
561
+ `blocked` / `skipped` are exempt, and an estimate already on the task satisfies
562
+ the requirement. Set to `false` to opt out.
563
563
  - **`ai_estimate_multiplier`** (default `0.2`) — the factor applied to raw
564
564
  estimates to model how long the work takes when an AI agent does it. Always
565
565
  record the *raw human-effort* estimate; the multiplier is applied at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.22.1",
3
+ "version": "0.24.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>",
@@ -54,10 +54,11 @@ description: "Session handoff — load context at start, save at end, track task
54
54
  user that their input is needed before proceeding.
55
55
  - Create new tasks as work is discovered. Always include `done_criteria` with
56
56
  verifiable items so completion can be tracked.
57
- - **Always set `schedule.estimate_hours`** (raw human-effort hours, > 0) on every
58
- leaf task. It is required by default — `handoff_update_task` rejects creating or
59
- updating a leaf task without it (parent tasks and `blocked`/`skipped` tasks are
60
- exempt). Enter the raw human-effort estimate; the AI-effort multiplier
57
+ - **Set `schedule.estimate_hours`** (raw human-effort hours, > 0) on leaf tasks
58
+ when moving them to `in_progress`/`review`/`done`. It is required by default —
59
+ `handoff_update_task` rejects leaf tasks in those statuses without it. Tasks in
60
+ `todo`/`blocked`/`skipped` and parent tasks (with children) are exempt. Enter the
61
+ raw human-effort estimate; the AI-effort multiplier
61
62
  (`settings.ai_estimate_multiplier`, default 0.2) is applied automatically at
62
63
  aggregation time by `handoff_get_metrics`/`handoff_get_capacity`. To turn the
63
64
  requirement off, set `settings.require_estimate_hours = false`.
@@ -70,7 +71,7 @@ missing `estimate_hours` is refused, costing a round trip.
70
71
  - [ ] `title` — present (required for any new task)
71
72
  - [ ] `done_criteria` — verifiable items, not restatements of the title
72
73
  - [ ] `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
+ the task is a parent (has children) or its status is `todo`/`blocked`/`skipped`
74
75
  - [ ] `priority` — `low` / `medium` / `high`
75
76
  - [ ] `labels` — at least one, so the task is findable by filter
76
77
  - [ ] `assignee` — matches a key in `config.toml [assignees.<key>]`
@@ -234,10 +235,10 @@ Use `handoff_bulk_update_tasks` for:
234
235
  - Batch assignee changes (e.g., reassigning a team member's tasks).
235
236
  - Each task update is independent — failures on one task don't roll back others.
236
237
  - 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.
238
+ `in_progress`/`review`/`done` without `schedule.estimate_hours` is rejected and
239
+ reported in `errors[]`. Supply the estimate in the same update when moving a
240
+ task out of `todo`/`blocked`/`skipped`. Parent tasks and the statuses
241
+ `todo`/`blocked`/`skipped` are exempt.
241
242
 
242
243
  ### Document Management
243
244
 
@@ -256,6 +257,7 @@ are too large for a single memory entry, use the doc tools instead — see the
256
257
  | `handoff_doc_query` | Context injection (hook-driven) — staged `full`/`outline` results by fragment size |
257
258
  | `handoff_doc_analyze` | Read-only heuristic scan of a file/directory — step 1 of importing existing docs |
258
259
  | `handoff_doc_import` | Atomic bulk write of analyzed + AI-reviewed documents — step 3 of importing existing docs |
260
+ | `handoff_task_checklist` | Combined readiness view for a task — aggregates `done_criteria` with the verification matrices of its linked documents (`action="view"`, read-only); `action="generate"` builds `done_criteria` from a linked spec's sections |
259
261
 
260
262
  `handoff_doc_save(task_ids: [...])` creates a **bidirectional** doc↔task
261
263
  link: the document gains a `task_ids` entry and each linked task gains a
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: handoff-docs
3
- description: "Document management — save, read, search, import, and traverse structured project documents (specs, designs, ADRs, guides, notes). Triggers on 'ドキュメント保存', '仕様書を管理', '設計書をインポート', 'save this doc', 'import specs', 'document management', or when the user asks to persist/organize/search multi-section markdown that is too structured for a single memory entry."
3
+ description: "Document management — save, read, search, import, and traverse structured project documents (specs, designs, ADRs, guides, notes). Triggers on 'ドキュメント保存', '仕様書を管理', '設計書をインポート', 'save this doc', 'import specs', 'document management', 'タスク開始', 'spec registration', '仕様登録', 'verification check', or when the user asks to persist/organize/search multi-section markdown that is too structured for a single memory entry, or when the AI writes a spec/design document during development."
4
4
  ---
5
5
 
6
6
  # Handoff Docs Skill
@@ -17,6 +17,49 @@ If the knowledge is a short, standalone lesson/rule/convention/gotcha (< 1 page,
17
17
  no internal sections), use `handoff-memory` instead — see "Memory vs Documents"
18
18
  in `skills/handoff-memory/SKILL.md` for the boundary.
19
19
 
20
+ ## Development Flow Integration
21
+
22
+ Document management is NOT just for explicit user requests. It activates
23
+ automatically during the standard development cycle:
24
+
25
+ ### When writing a spec or design
26
+ After writing/updating a specification or finishing a `/design-review` session
27
+ (wiki/ or tmp/), immediately:
28
+ 1. Start from a template (see "Templates" below) instead of a blank document.
29
+ 2. `handoff_doc_save(title=..., body=..., doc_type="spec", task_ids=[...])`
30
+ 3. `handoff_doc_verify(doc_id=..., action="generate")` to create the verification matrix
31
+
32
+ ### When starting a task
33
+ Before implementation, fetch related specs:
34
+ - `handoff_doc_query(task_id="<task-id>")` — surfaces linked documents automatically
35
+ - Review the verification matrix: `handoff_doc_verify_status(doc_id=...)`
36
+
37
+ ### When implementation is complete
38
+ Mark verified sections:
39
+ - `handoff_doc_verify(doc_id=..., action="check", fragment_seq=N)` for each completed section
40
+ - `handoff_doc_verify(doc_id=..., action="set_refs", fragment_seq=N, impl_refs=[...])` to record implementation locations
41
+
42
+ ### When reviewing
43
+ Check readiness:
44
+ - `handoff_task_checklist(task_id=..., action="view")` — combined readiness view
45
+
46
+ ## Document Creation Rules
47
+
48
+ 1. **One document = one complete document**
49
+ - Always start with a `# Title` (h1 heading)
50
+ - Group related content of the same category (ADR, spec, design, etc.) into a single document
51
+ - Example: ADR-001 through ADR-005 belong inside `# Architecture Decision Records`
52
+ as `## ADR-001: Redis Session` through `## ADR-005: ...` sections
53
+
54
+ 2. **Use `append_body` to add sections**
55
+ - When adding a section to an existing document, use `append_body` instead of rewriting the whole body
56
+ - Example: `doc_save(doc_id="doc-...", append_body="## ADR-006: ...\n\n...")`
57
+
58
+ 3. **MCP handles splitting internally**
59
+ - There is no need for the AI to split content into separate documents manually
60
+ - MCP automatically computes a section index at each h2 heading boundary
61
+ - Use `doc_get(format="section", seq=N)` to retrieve individual sections on demand
62
+
20
63
  ## The 9 Doc Tools
21
64
 
22
65
  | Tool | Purpose |
@@ -231,3 +274,31 @@ only be validated when every file is visible at once.
231
274
  `spec` (requirements/behavior contracts) · `design` (architecture/design
232
275
  docs) · `adr` (architecture decision records) · `guide` (how-to/operational
233
276
  docs) · `note` (fallback — anything that doesn't fit the above).
277
+
278
+ ## Templates
279
+
280
+ Three starter templates are registered as `doc_type="guide"` documents tagged
281
+ `template` (plus a type-specific tag: `spec`, `design`, or `adr`). Fetch one
282
+ with `handoff_doc_list(tags=["template"])` or `handoff_doc_get(doc_id=...)`
283
+ before writing a new spec/design/ADR from scratch — copy its section
284
+ structure rather than reinventing it:
285
+
286
+ | Template | Tags | Structure |
287
+ |---|---|---|
288
+ | Specification Template (`specification-template`) | `template`, `spec` | 課題 / ゴール / 設計 / 実装計画 / 検証チェックリスト / 未決事項 |
289
+ | Design Document Template (`design-doc-template`) | `template`, `design` | 概要 / 制約・前提 / 設計案(採用案・代替案)/ トレードオフ表 / 実装影響範囲 / リスク |
290
+ | ADR Template (`adr-template`) | `template`, `adr` | コンテキスト / 決定 / 理由 / 結果 |
291
+
292
+ Templates are registered with `auto_inject="none"` — they are reference
293
+ material fetched on demand, not injected into every prompt.
294
+
295
+ ## Memory vs Documents
296
+
297
+ | Criterion | Use Memory | Use Documents |
298
+ |---|---|---|
299
+ | Size | < 1 page, no sections | Multi-section, structured |
300
+ | Lifecycle | Permanent lesson/rule | Versioned with the project |
301
+ | Granularity | Single fact/convention | Sections need independent tracking |
302
+ | Review tracking | Not needed | Verification matrix tracks per-section |
303
+ | Task linkage | Not applicable | Bidirectional task_ids |
304
+ | Example | "Always use SSH for git push" | "Authentication spec with 5 sections" |
@@ -131,9 +131,8 @@ fn apply_single_update(
131
131
 
132
132
  // Guard the same invariant handoff_update_task enforces, or a bulk patch
133
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.
134
+ // status and schedule already merged — not on the patch. Parent tasks (with
135
+ // children) and tasks in todo/blocked/skipped are exempt.
137
136
  let has_children = task_has_children(&task_dir)?;
138
137
  validate_estimate_required(
139
138
  require_estimate_hours,
@@ -61,12 +61,21 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
61
61
  let handoff = ensure_handoff_exists(&project_dir)?;
62
62
  ensure_docs_dir(&handoff)?;
63
63
 
64
- let body = arguments
65
- .get("body")
66
- .and_then(|v| v.as_str())
67
- .ok_or_else(|| anyhow::anyhow!("'body' is required"))?;
64
+ let body_arg = arguments.get("body").and_then(|v| v.as_str());
65
+ let append_body_arg = arguments.get("append_body").and_then(|v| v.as_str());
66
+ if body_arg.is_some() && append_body_arg.is_some() {
67
+ anyhow::bail!("'body' and 'append_body' are mutually exclusive");
68
+ }
69
+ if body_arg.is_none() && append_body_arg.is_none() {
70
+ anyhow::bail!("either 'body' or 'append_body' is required");
71
+ }
68
72
 
69
73
  let doc_id = arguments.get("doc_id").and_then(|v| v.as_str());
74
+ if append_body_arg.is_some() && doc_id.is_none() {
75
+ anyhow::bail!(
76
+ "'append_body' requires 'doc_id' (appending to a new document is not meaningful)"
77
+ );
78
+ }
70
79
  let existing = match doc_id {
71
80
  Some(id) => Some(
72
81
  find_doc_by_id(&handoff, id)?
@@ -75,6 +84,31 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
75
84
  None => None,
76
85
  };
77
86
 
87
+ // `append_body`: join the appended text onto the existing document's
88
+ // stripped body (read_doc_body — NOT read_full_body, whose
89
+ // BOM/frontmatter restoration would otherwise get re-detected and
90
+ // double-persisted by `split()` below). No separator is inserted when
91
+ // the existing body is empty/missing (spec §3.1 edge case).
92
+ let joined_body: String;
93
+ let body: &str = if let Some(append_body) = append_body_arg {
94
+ let existing_doc = existing
95
+ .as_ref()
96
+ .expect("append_body requires doc_id, checked above, so existing is Some");
97
+ let existing_body = read_doc_body(&handoff, &existing_doc.slug)?.unwrap_or_default();
98
+ let separator = arguments
99
+ .get("separator")
100
+ .and_then(|v| v.as_str())
101
+ .unwrap_or("\n\n");
102
+ joined_body = if existing_body.is_empty() {
103
+ append_body.to_string()
104
+ } else {
105
+ format!("{existing_body}{separator}{append_body}")
106
+ };
107
+ &joined_body
108
+ } else {
109
+ body_arg.expect("body_arg is Some in this branch, checked above")
110
+ };
111
+
78
112
  // slug: required for new documents, taken from the existing document on
79
113
  // update (the `slug` argument is ignored on update — renaming a
80
114
  // document's file-naming slug is out of scope for `doc_save`).
@@ -185,6 +219,12 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
185
219
  // it, computed fresh on every save (no stale-fragment cleanup needed —
186
220
  // there is nothing left on disk to clean up per section).
187
221
  let body_after_strip: String = split_doc.fragments.iter().map(|f| f.body).collect();
222
+ if !body_after_strip.starts_with("# ") {
223
+ warnings.push(
224
+ "body does not start with a level-1 heading — consider adding one for readability"
225
+ .to_string(),
226
+ );
227
+ }
188
228
  write_doc_body(&handoff, &slug, &body_after_strip)?;
189
229
  doc.sections = compute_sections(&split_doc);
190
230
 
@@ -25,6 +25,7 @@ pub mod milestones;
25
25
  pub mod refer;
26
26
  pub mod referrals;
27
27
  pub mod save_context;
28
+ pub mod task_checklist;
28
29
  pub mod timer;
29
30
  pub mod update_session;
30
31
  pub mod update_task;
@@ -110,6 +111,7 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
110
111
  "handoff_doc_query" => docs_query::handle_doc_query(arguments),
111
112
  "handoff_doc_analyze" => docs_query::handle_doc_analyze(arguments),
112
113
  "handoff_doc_import" => docs_query::handle_doc_import(arguments),
114
+ "handoff_task_checklist" => task_checklist::handle(arguments),
113
115
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
114
116
  };
115
117
 
@@ -0,0 +1,495 @@
1
+ //! `handoff_task_checklist` — pure-view aggregation of a task's
2
+ //! `done_criteria` and its linked documents' verification matrices
3
+ //! (doc-20260712-191142-602891 §3.1/§3.2, "タスク×ドキュメント連携チェックシート
4
+ //! — 改訂仕様 (v2)"). Phase 1 implements `action="view"`; Phase 2 adds
5
+ //! `action="generate"` (doc-20260712-191142-602891 §3.2), which turns a
6
+ //! linked spec/design document's level-2 section headings into
7
+ //! `done_criteria` items (hardcoded defaults, no config template — spec §3.2
8
+ //! "ハードコードデフォルト (config 不要)").
9
+ //!
10
+ //! `view` writes nothing back to disk: it is a computed view over existing
11
+ //! `TaskData.task_links` (`link_type == "doc"`) and each linked document's
12
+ //! `DocMetadata.verification` matrix. `generate` also reads only
13
+ //! `DocMetadata.sections` (never writes to the document); it writes to the
14
+ //! task's `done_criteria` only in `append`/`replace` mode (never in the
15
+ //! default `preview` mode).
16
+
17
+ use anyhow::Result;
18
+ use serde_json::{json, Value};
19
+
20
+ use super::resolve_project_dir;
21
+ use crate::storage::docs::{
22
+ batch_resolve_docs, find_doc_by_id, read_doc, DocMetadata, SectionIndex, VerificationItem,
23
+ };
24
+ use crate::storage::ensure_handoff_exists;
25
+ use crate::storage::tasks::{
26
+ find_task_dir_by_id, read_modify_write_task, read_task, suggest_task_id, DoneCriterion,
27
+ TaskData,
28
+ };
29
+
30
+ /// `handoff_task_checklist` entry point: dispatches on `action`
31
+ /// (`"view"` default, or `"generate"`).
32
+ pub fn handle(arguments: &Value) -> Result<String> {
33
+ let project_dir = resolve_project_dir(arguments)?;
34
+ let handoff = ensure_handoff_exists(&project_dir)?;
35
+ let tasks_dir = handoff.join("tasks");
36
+
37
+ let task_id = arguments
38
+ .get("task_id")
39
+ .and_then(|v| v.as_str())
40
+ .ok_or_else(|| anyhow::anyhow!("'task_id' is required"))?;
41
+ let action = arguments
42
+ .get("action")
43
+ .and_then(|v| v.as_str())
44
+ .unwrap_or("view");
45
+
46
+ match action {
47
+ "view" => handle_view(arguments, &handoff, &tasks_dir, task_id),
48
+ "generate" => handle_generate(arguments, &handoff, &tasks_dir, task_id),
49
+ other => anyhow::bail!("Unknown action '{other}'; expected 'view' or 'generate'."),
50
+ }
51
+ }
52
+
53
+ fn handle_view(
54
+ _arguments: &Value,
55
+ handoff: &std::path::Path,
56
+ tasks_dir: &std::path::Path,
57
+ task_id: &str,
58
+ ) -> Result<String> {
59
+ let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
60
+ .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
61
+ let (data, _status) = read_task(&task_dir)?
62
+ .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
63
+
64
+ let doc_links: Vec<_> = data
65
+ .links()
66
+ .into_iter()
67
+ .filter(|l| l.link_type == "doc")
68
+ .collect();
69
+
70
+ if doc_links.is_empty() {
71
+ return Ok(to_json(&json!({
72
+ "task_id": data.id,
73
+ "title": data.title,
74
+ "no_linked_docs": true,
75
+ })));
76
+ }
77
+
78
+ let docs = batch_resolve_docs(handoff, &doc_links)?;
79
+
80
+ let done_criteria = done_criteria_json(&data);
81
+ let documents: Vec<Value> = docs.iter().map(doc_coverage_json).collect();
82
+ let overall = overall_progress_json(&docs);
83
+ let combined_readiness = combined_readiness_json(&data, &docs);
84
+ let suggested_actions = suggested_actions_json(&data, &docs);
85
+
86
+ Ok(to_json(&json!({
87
+ "task_id": data.id,
88
+ "title": data.title,
89
+ "no_linked_docs": false,
90
+ "done_criteria": done_criteria,
91
+ "verification_coverage": {
92
+ "documents": documents,
93
+ "overall": overall,
94
+ },
95
+ "combined_readiness": combined_readiness,
96
+ "suggested_actions": suggested_actions,
97
+ })))
98
+ }
99
+
100
+ /// Resolve a document by either its file-naming `slug` or its stable `id`
101
+ /// (mirrors the private `resolve_doc` helper in `docs.rs`, duplicated here
102
+ /// rather than made `pub` there to avoid growing that module's public
103
+ /// surface for a single two-line lookup used by only one other handler).
104
+ fn resolve_doc_by_slug_or_id(
105
+ handoff: &std::path::Path,
106
+ slug_or_id: &str,
107
+ ) -> Result<Option<DocMetadata>> {
108
+ if let Some(doc) = read_doc(handoff, slug_or_id)? {
109
+ return Ok(Some(doc));
110
+ }
111
+ find_doc_by_id(handoff, slug_or_id)
112
+ }
113
+
114
+ /// Only `level == 2` sections are eligible for generation (spec §3.2
115
+ /// "ハードコードデフォルトルール: level 2 の見出しのみ対象"). `skip_seqs` is the
116
+ /// fully-resolved exclusion set built by the caller (`handle_generate`'s
117
+ /// `skipped_seqs`), which always includes seq=0 (the preamble) by default
118
+ /// plus any caller-supplied `skip_seqs` param values.
119
+ fn eligible_sections<'a>(
120
+ sections: &'a [SectionIndex],
121
+ skip_seqs: &[usize],
122
+ ) -> Vec<&'a SectionIndex> {
123
+ sections
124
+ .iter()
125
+ .filter(|s| s.level == 2 && !skip_seqs.contains(&s.seq))
126
+ .collect()
127
+ }
128
+
129
+ /// Fixed checklist items appended alongside the generated per-section
130
+ /// criteria, keyed by `doc_type` (spec §3.2 "ハードコードデフォルト").
131
+ fn fixed_items_for_doc_type(doc_type: &str) -> Vec<&'static str> {
132
+ match doc_type {
133
+ "spec" => vec![
134
+ "仕様書の全セクションがカバーされていることを確認",
135
+ "仕様変更があれば doc_save で更新済み",
136
+ ],
137
+ "design" => vec!["設計と実装の乖離がないことを確認"],
138
+ _ => vec![],
139
+ }
140
+ }
141
+
142
+ fn handle_generate(
143
+ arguments: &Value,
144
+ handoff: &std::path::Path,
145
+ tasks_dir: &std::path::Path,
146
+ task_id: &str,
147
+ ) -> Result<String> {
148
+ let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
149
+ .ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
150
+ let (data, _status) = read_task(&task_dir)?
151
+ .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
152
+
153
+ let doc = match arguments.get("doc_id").and_then(|v| v.as_str()) {
154
+ Some(doc_id) => resolve_doc_by_slug_or_id(handoff, doc_id)?
155
+ .ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?,
156
+ None => {
157
+ let doc_links: Vec<_> = data
158
+ .links()
159
+ .into_iter()
160
+ .filter(|l| l.link_type == "doc")
161
+ .collect();
162
+ let docs = batch_resolve_docs(handoff, &doc_links)?;
163
+ docs.into_iter()
164
+ .find(|d| d.doc_type == "spec" || d.doc_type == "design")
165
+ .ok_or_else(|| {
166
+ anyhow::anyhow!(
167
+ "No 'doc_id' given and task {task_id} has no linked document with doc_type 'spec' or 'design'; pass 'doc_id' explicitly."
168
+ )
169
+ })?
170
+ }
171
+ };
172
+
173
+ let mode = arguments
174
+ .get("mode")
175
+ .and_then(|v| v.as_str())
176
+ .unwrap_or("preview");
177
+ if !["preview", "append", "replace"].contains(&mode) {
178
+ anyhow::bail!("Unknown mode '{mode}'; expected 'preview', 'append', or 'replace'.");
179
+ }
180
+
181
+ let caller_skip_seqs: Vec<usize> = arguments
182
+ .get("skip_seqs")
183
+ .and_then(|v| v.as_array())
184
+ .map(|arr| {
185
+ arr.iter()
186
+ .filter_map(|v| v.as_u64())
187
+ .map(|n| n as usize)
188
+ .collect()
189
+ })
190
+ .unwrap_or_default();
191
+ let mut skipped_seqs: Vec<usize> = std::iter::once(0).chain(caller_skip_seqs).collect();
192
+ skipped_seqs.sort_unstable();
193
+ skipped_seqs.dedup();
194
+
195
+ let generated_criteria: Vec<Value> = eligible_sections(&doc.sections, &skipped_seqs)
196
+ .into_iter()
197
+ .map(|s| {
198
+ json!({
199
+ "item": format!("[{}§{}] {}", doc.doc_type, s.seq, s.heading),
200
+ "fragment_seq": s.seq,
201
+ })
202
+ })
203
+ .collect();
204
+ let fixed_items = fixed_items_for_doc_type(&doc.doc_type);
205
+
206
+ let applied = match mode {
207
+ "preview" => false,
208
+ "append" => {
209
+ apply_generated_criteria(&task_dir, &generated_criteria, false)?;
210
+ true
211
+ }
212
+ "replace" => {
213
+ apply_generated_criteria(&task_dir, &generated_criteria, true)?;
214
+ true
215
+ }
216
+ _ => unreachable!("mode already validated above"),
217
+ };
218
+
219
+ Ok(to_json(&json!({
220
+ "task_id": data.id,
221
+ "generated_criteria": generated_criteria,
222
+ "applied": applied,
223
+ "skipped_seqs": skipped_seqs,
224
+ "fixed_items": fixed_items,
225
+ })))
226
+ }
227
+
228
+ /// Writes `generated_criteria` into the task's `done_criteria`: appends when
229
+ /// `replace` is `false`, overwrites entirely when `true`. Uses
230
+ /// `read_modify_write_task` for optimistic-concurrency safety (mirrors
231
+ /// `log_time.rs`), since this is a write path shared with other tools that
232
+ /// mutate the same task file (e.g. `handoff_check_criterion`).
233
+ fn apply_generated_criteria(
234
+ task_dir: &std::path::Path,
235
+ generated_criteria: &[Value],
236
+ replace: bool,
237
+ ) -> Result<()> {
238
+ let new_items: Vec<DoneCriterion> = generated_criteria
239
+ .iter()
240
+ .map(|c| DoneCriterion {
241
+ item: c["item"].as_str().unwrap_or_default().to_string(),
242
+ checked: false,
243
+ })
244
+ .collect();
245
+
246
+ read_modify_write_task(task_dir, |data, status| {
247
+ if replace {
248
+ data.done_criteria = new_items.clone();
249
+ } else {
250
+ data.done_criteria.extend(new_items.clone());
251
+ }
252
+ data.updated_at = Some(chrono::Utc::now().to_rfc3339());
253
+ Ok(status.to_string())
254
+ })
255
+ }
256
+
257
+ fn done_criteria_json(data: &TaskData) -> Value {
258
+ let items: Vec<Value> = data
259
+ .done_criteria
260
+ .iter()
261
+ .enumerate()
262
+ .map(|(index, c)| {
263
+ json!({
264
+ "index": index,
265
+ "item": c.item,
266
+ "checked": c.checked,
267
+ })
268
+ })
269
+ .collect();
270
+ let checked = data.done_criteria.iter().filter(|c| c.checked).count();
271
+ let total = data.done_criteria.len();
272
+ let percentage = if total == 0 {
273
+ 0.0
274
+ } else {
275
+ checked as f64 / total as f64 * 100.0
276
+ };
277
+ json!({
278
+ "items": items,
279
+ "progress": { "checked": checked, "total": total, "percentage": percentage },
280
+ })
281
+ }
282
+
283
+ /// Priority order (highest first): stale > skipped > verified > implemented
284
+ /// > in_progress > untouched (doc-20260712-191142-602891 §3.1 table).
285
+ fn visual_state(doc: &DocMetadata, item: &VerificationItem) -> &'static str {
286
+ if item_is_stale(doc, item) {
287
+ return "stale";
288
+ }
289
+ match item.status.as_str() {
290
+ "skipped" => "skipped",
291
+ "verified" => "verified",
292
+ "pending" => {
293
+ let has_impl = !item.impl_refs.is_empty();
294
+ let has_test = !item.test_refs.is_empty();
295
+ if has_impl && has_test {
296
+ "implemented"
297
+ } else if has_impl {
298
+ "in_progress"
299
+ } else {
300
+ "untouched"
301
+ }
302
+ }
303
+ _ => "untouched",
304
+ }
305
+ }
306
+
307
+ /// An item is stale when it was verified at a content_hash that no longer
308
+ /// matches its section's current content_hash. Mirrors
309
+ /// `crate::mcp::handlers::docs::item_is_stale` (not reused directly since
310
+ /// that helper is private to `docs.rs`; duplicated here rather than exposed
311
+ /// publicly to avoid growing that already-1492-line file's public surface
312
+ /// for a single one-line predicate).
313
+ fn item_is_stale(doc: &DocMetadata, item: &VerificationItem) -> bool {
314
+ let Some(hash_at_verify) = &item.content_hash_at_verify else {
315
+ return false;
316
+ };
317
+ match doc.sections.iter().find(|s| s.seq == item.fragment_seq) {
318
+ Some(section) => &section.content_hash != hash_at_verify,
319
+ None => true,
320
+ }
321
+ }
322
+
323
+ fn doc_coverage_json(doc: &DocMetadata) -> Value {
324
+ let empty_items: Vec<VerificationItem> = Vec::new();
325
+ let items = doc
326
+ .verification
327
+ .as_ref()
328
+ .map(|v| &v.items)
329
+ .unwrap_or(&empty_items);
330
+
331
+ let items_json: Vec<Value> = items
332
+ .iter()
333
+ .map(|i| {
334
+ json!({
335
+ "fragment_seq": i.fragment_seq,
336
+ "heading": i.heading,
337
+ "status": i.status,
338
+ "stale": item_is_stale(doc, i),
339
+ "visual_state": visual_state(doc, i),
340
+ "impl_refs": i.impl_refs,
341
+ "test_refs": i.test_refs,
342
+ })
343
+ })
344
+ .collect();
345
+
346
+ let verified = items.iter().filter(|i| i.status == "verified").count();
347
+ let pending = items.iter().filter(|i| i.status == "pending").count();
348
+ let skipped = items.iter().filter(|i| i.status == "skipped").count();
349
+ let stale = items.iter().filter(|i| item_is_stale(doc, i)).count();
350
+ let total = items.len();
351
+ let percentage = if total == 0 {
352
+ 0.0
353
+ } else {
354
+ (verified + skipped) as f64 / total as f64 * 100.0
355
+ };
356
+
357
+ json!({
358
+ "doc_id": doc.id,
359
+ "slug": doc.slug,
360
+ "title": doc.title,
361
+ "doc_type": doc.doc_type,
362
+ "items": items_json,
363
+ "progress": {
364
+ "verified": verified,
365
+ "pending": pending,
366
+ "skipped": skipped,
367
+ "stale": stale,
368
+ "total": total,
369
+ "percentage": percentage,
370
+ },
371
+ })
372
+ }
373
+
374
+ fn overall_progress_json(docs: &[DocMetadata]) -> Value {
375
+ let mut verified = 0;
376
+ let mut pending = 0;
377
+ let mut stale = 0;
378
+ let mut total = 0;
379
+ for doc in docs {
380
+ if let Some(v) = &doc.verification {
381
+ verified += v.items.iter().filter(|i| i.status == "verified").count();
382
+ pending += v.items.iter().filter(|i| i.status == "pending").count();
383
+ stale += v.items.iter().filter(|i| item_is_stale(doc, i)).count();
384
+ total += v.items.len();
385
+ }
386
+ }
387
+ let percentage = if total == 0 {
388
+ 0.0
389
+ } else {
390
+ verified as f64 / total as f64 * 100.0
391
+ };
392
+ json!({ "verified": verified, "pending": pending, "stale": stale, "total": total, "percentage": percentage })
393
+ }
394
+
395
+ /// Typed blockers (doc-20260712-191142-602891 §3.1 M7: "blockers は typed
396
+ /// objects"): one entry per unchecked done_criteria item
397
+ /// (`{type:"criteria", index, item}`), one per non-verified/non-skipped
398
+ /// verification item (`{type:"verification", doc_id, doc_slug, fragment_seq,
399
+ /// heading}`), and one per linked doc that has no verification matrix at all
400
+ /// (`{type:"verification_missing", doc_id, doc_slug}` — mirrors
401
+ /// `handle_doc_verify_status`'s hard error on the same condition, so a doc
402
+ /// that never had `action="generate"` run cannot silently count as ready).
403
+ fn combined_readiness_json(data: &TaskData, docs: &[DocMetadata]) -> Value {
404
+ let mut blockers = Vec::new();
405
+
406
+ for (index, c) in data.done_criteria.iter().enumerate() {
407
+ if !c.checked {
408
+ blockers.push(json!({ "type": "criteria", "index": index, "item": c.item }));
409
+ }
410
+ }
411
+
412
+ for doc in docs {
413
+ let Some(v) = &doc.verification else {
414
+ blockers.push(json!({
415
+ "type": "verification_missing",
416
+ "doc_id": doc.id,
417
+ "doc_slug": doc.slug,
418
+ }));
419
+ continue;
420
+ };
421
+ for item in &v.items {
422
+ let resolved = item.status == "verified" || item.status == "skipped";
423
+ if !resolved || item_is_stale(doc, item) {
424
+ blockers.push(json!({
425
+ "type": "verification",
426
+ "doc_id": doc.id,
427
+ "doc_slug": doc.slug,
428
+ "fragment_seq": item.fragment_seq,
429
+ "heading": item.heading,
430
+ }));
431
+ }
432
+ }
433
+ }
434
+
435
+ let done_criteria_met =
436
+ !data.done_criteria.is_empty() && data.done_criteria.iter().all(|c| c.checked);
437
+ let verification_complete = docs.iter().all(|d| match &d.verification {
438
+ None => false,
439
+ Some(v) => v
440
+ .items
441
+ .iter()
442
+ .all(|i| (i.status == "verified" || i.status == "skipped") && !item_is_stale(d, i)),
443
+ });
444
+ let ready = done_criteria_met && verification_complete;
445
+
446
+ json!({
447
+ "done_criteria_met": done_criteria_met,
448
+ "verification_complete": verification_complete,
449
+ "ready": ready,
450
+ "blockers": blockers,
451
+ })
452
+ }
453
+
454
+ /// Advisory next-action hints (doc-20260712-191142-602891 §3.1 / §2.3: no
455
+ /// auto-sync, `suggested_actions` presents next steps for the caller to run
456
+ /// itself). One suggestion per unresolved verification item, one per doc
457
+ /// with no verification matrix yet, and one per unchecked done_criteria
458
+ /// item, each naming the concrete tool call to make.
459
+ fn suggested_actions_json(data: &TaskData, docs: &[DocMetadata]) -> Vec<String> {
460
+ let mut actions = Vec::new();
461
+
462
+ for doc in docs {
463
+ let Some(v) = &doc.verification else {
464
+ actions.push(format!(
465
+ "handoff_doc_verify(doc_id=\"{}\", action=\"generate\") — \"{}\" の検証マトリクスがまだ存在しない",
466
+ doc.id, doc.title
467
+ ));
468
+ continue;
469
+ };
470
+ for item in &v.items {
471
+ let resolved = item.status == "verified" || item.status == "skipped";
472
+ if !resolved || item_is_stale(doc, item) {
473
+ actions.push(format!(
474
+ "handoff_doc_verify(doc_id=\"{}\", action=\"check\", fragment_seq={}) — \"{}\" のレビュー完了時",
475
+ doc.id, item.fragment_seq, item.heading
476
+ ));
477
+ }
478
+ }
479
+ }
480
+
481
+ for (index, c) in data.done_criteria.iter().enumerate() {
482
+ if !c.checked {
483
+ actions.push(format!(
484
+ "handoff_check_criterion(task_id=\"{}\", criterion_index={}) — \"{}\" 完了時",
485
+ data.id, index, c.item
486
+ ));
487
+ }
488
+ }
489
+
490
+ actions
491
+ }
492
+
493
+ fn to_json(v: &Value) -> String {
494
+ serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
495
+ }
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. 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(),
278
+ description: "Add, update, or move a task. Manages the tasks/ directory structure. Include task.schedule.estimate_hours (raw human-effort hours, > 0) when moving a leaf task to in_progress/review/done; it is rejected without one unless the task is a parent, todo, blocked, or skipped.".to_string(),
279
279
  input_schema: json!({
280
280
  "type": "object",
281
281
  "properties": {
@@ -285,7 +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
+ "description": "The task to add or update. schedule.estimate_hours is REQUIRED when a leaf task is in status in_progress/review/done. Omit it for parent tasks (any task with children) or status todo/blocked/skipped.",
289
289
  "properties": {
290
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)." },
291
291
  "title": { "type": "string", "description": "Required for new tasks. Optional when updating (id present)." },
@@ -320,11 +320,11 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
320
320
  },
321
321
  "schedule": {
322
322
  "type": "object",
323
- "description": "Schedule and effort tracking. Supply this with estimate_hours whenever creating a leaf task.",
323
+ "description": "Schedule and effort tracking. Supply estimate_hours when a leaf task enters in_progress/review/done.",
324
324
  "properties": {
325
325
  "start_date": { "type": "string", "description": "YYYY-MM-DD" },
326
326
  "due_date": { "type": "string", "description": "YYYY-MM-DD" },
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." },
327
+ "estimate_hours": { "type": "number", "description": "REQUIRED for leaf tasks in status in_progress/review/done; the call is rejected without it. Omit for parent tasks (any task with children) or status todo/blocked/skipped. Raw human-effort hours, > 0 — do not pre-multiply by settings.ai_estimate_multiplier, which is applied at aggregation time." },
328
328
  "actual_hours": { "type": "number", "description": "Hours actually spent. Prefer handoff_log_time, which adds to this and decrements remaining_hours atomically." },
329
329
  "remaining_hours": { "type": "number", "description": "Hours remaining. Auto-decremented by handoff_log_time." },
330
330
  "milestone": { "type": "string" },
@@ -864,7 +864,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
864
864
  },
865
865
  ToolDefinition {
866
866
  name: "handoff_bulk_update_tasks".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
+ 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 in status 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(),
868
868
  input_schema: json!({
869
869
  "type": "object",
870
870
  "properties": {
@@ -874,12 +874,12 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
874
874
  },
875
875
  "updates": {
876
876
  "type": "array",
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
+ "description": "Array of task updates to apply. Each is validated on its own: if an update would leave a leaf task in status 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 or todo.",
878
878
  "items": {
879
879
  "type": "object",
880
880
  "properties": {
881
881
  "task_id": { "type": "string", "description": "Task ID to update." },
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
+ "status": { "type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"], "description": "Moving a leaf task into 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 todo/blocked/skipped are exempt." },
883
883
  "priority": { "type": "string", "enum": ["low", "medium", "high"] },
884
884
  "assignee": { "type": "string" },
885
885
  "notes": { "type": "string", "description": "Replace task notes." },
@@ -890,7 +890,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
890
890
  "properties": {
891
891
  "start_date": { "type": "string", "description": "YYYY-MM-DD" },
892
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." },
893
+ "estimate_hours": { "type": "number", "description": "REQUIRED for a leaf task in status in_progress/review/done; the update is rejected without it. Omit for parent tasks (any task with children) or status todo/blocked/skipped. Raw human-effort hours, > 0 — do not pre-multiply by settings.ai_estimate_multiplier, which is applied at aggregation time." },
894
894
  "actual_hours": { "type": "number", "description": "Hours actually spent. Prefer handoff_log_time, which adds to this and decrements remaining_hours atomically." },
895
895
  "remaining_hours": { "type": "number", "description": "Hours remaining. Auto-decremented by handoff_log_time." },
896
896
  "milestone": { "type": "string" },
@@ -1237,15 +1237,17 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1237
1237
  // ---- Document management tools (P1-6a, v5 rearchitecture: wiki/130-document-management.md §3.1) ----
1238
1238
  ToolDefinition {
1239
1239
  name: "handoff_doc_save".to_string(),
1240
- description: "Create or update a document from a full Markdown body. The body is stored verbatim at _doc.<slug>.md and split in-memory into a `sections` byte-offset index (no per-section files), syncing the bidirectional task<->doc link when task_ids is given. Omit doc_id to create a new document (slug is then required and must be unique); pass an existing doc_id to update it (slug is taken from the existing document — it cannot be renamed via doc_save). Returns a JSON string {doc_id,slug,title,doc_type,section_count,content_hash,warnings:[…]} — warnings lists any task_ids that could not be resolved.".to_string(),
1240
+ description: "Save a complete document. The body MUST be a whole, human-readable Markdown document starting with a level-1 heading (e.g. `# Authentication Spec`) — group related content into ONE document (for example, all ADRs belong in a single `# Architecture Decision Records` document with each ADR as an `## ADR-001: ...` section, NOT as separate documents). MCP handles section-level indexing internally, so do not pre-split content yourself. To append a new section to an existing document (e.g. adding ADR-003) without rewriting the whole body, pass `append_body` with just the new section instead of `body` — `body` and `append_body` are mutually exclusive, and `append_body` requires an existing `doc_id` (there is nothing to append to when creating a new document). The body (or, for append_body, the resulting combined body) is stored verbatim at _doc.<slug>.md and split in-memory into a `sections` byte-offset index (no per-section files), syncing the bidirectional task<->doc link when task_ids is given. Omit doc_id to create a new document (slug is then required and must be unique); pass an existing doc_id to update it (slug is taken from the existing document — it cannot be renamed via doc_save). Returns a JSON string {doc_id,slug,title,doc_type,section_count,content_hash,warnings:[…]} — warnings lists any task_ids that could not be resolved, and includes a soft notice when the saved body does not start with a level-1 heading (the save is never rejected for this).".to_string(),
1241
1241
  input_schema: json!({
1242
1242
  "type": "object",
1243
1243
  "properties": {
1244
1244
  "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1245
- "doc_id": { "type": "string", "description": "Existing document id to update. Omit to create a new document." },
1245
+ "doc_id": { "type": "string", "description": "Existing document id to update. Omit to create a new document. Required when append_body is given." },
1246
1246
  "slug": { "type": "string", "description": "Human-readable file-naming slug ([a-z0-9-], max 60 chars), used to name _doc.<slug>.json/.md. Required when creating; ignored on update (the existing document's slug is kept)." },
1247
- "title": { "type": "string", "description": "Document title. Required when creating; optional on update (defaults to the existing title)." },
1248
- "body": { "type": "string", "description": "Full Markdown body. Required." },
1247
+ "title": { "type": "string", "description": "Document title. Required when creating; optional on update or append (defaults to the existing title)." },
1248
+ "body": { "type": "string", "description": "Full Markdown document, starting with a level-1 heading. Mutually exclusive with append_body; one of the two is required." },
1249
+ "append_body": { "type": "string", "description": "New section(s) to append to an existing document's body (e.g. `## ADR-003: ...`). Joined onto the existing body with `separator` before the usual split/save. Requires doc_id. Mutually exclusive with body; one of the two is required. Use the same line-ending style as the existing document." },
1250
+ "separator": { "type": "string", "description": "append_body only: text inserted between the existing body and append_body.", "default": "\n\n" },
1249
1251
  "doc_type": { "type": "string", "description": "Document type.", "enum": ["spec", "design", "adr", "guide", "note"], "default": "note" },
1250
1252
  "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags for filtering/search." },
1251
1253
  "scope_paths": { "type": "array", "items": { "type": "string" }, "description": "Path prefixes this document applies to; boosts relevance in doc_list(query=...) when a file path matches." },
@@ -1254,8 +1256,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1254
1256
  "related": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "rel": { "type": "string", "enum": ["supersedes", "references", "implements", "extends", "conflicts"] } }, "required": ["id", "rel"] }, "description": "Sibling/relative relationships to other documents." },
1255
1257
  "split_level": { "type": "integer", "description": "ATX heading level at/above which the body is split into sections.", "default": 2 },
1256
1258
  "auto_inject": { "type": "string", "description": "Auto-injection control.", "enum": ["auto", "full", "outline", "none"], "default": "auto" }
1257
- },
1258
- "required": ["body"]
1259
+ }
1259
1260
  }),
1260
1261
  },
1261
1262
  ToolDefinition {
@@ -1417,7 +1418,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1417
1418
  },
1418
1419
  ToolDefinition {
1419
1420
  name: "handoff_doc_import".to_string(),
1420
- description: "Bulk-write an analyzed payload (from handoff_doc_analyze, with the AI's overrides applied) as new documents. Each analyzed.auto_resolved entry must carry its file's full Markdown 'body' (doc_import writes from the payload, it does not re-read the filesystem). Each document's slug is taken from its override's 'slug' if given, else its suggested_slug, disambiguated with a numeric suffix on collision. Persists every file as a document, applies proposed_tree parent/children relationships, links task_ids to every imported document (bidirectionally), and invalidates the doc corpus cache. Returns a JSON string {imported_count,documents:[{doc_id,slug,title,section_count}],warnings:[…]}.".to_string(),
1421
+ description: "Bulk-import pre-existing Markdown files. Each file becomes ONE document in .handoff/docs/ — the file's content is stored as-is, including its h1 heading. Do NOT split a single source file into multiple documents; MCP indexes sections internally. Writes an analyzed payload (from handoff_doc_analyze, with the AI's overrides applied) as new documents. Each analyzed.auto_resolved entry must carry its file's full Markdown 'body' (doc_import writes from the payload, it does not re-read the filesystem). Each document's slug is taken from its override's 'slug' if given, else its suggested_slug, disambiguated with a numeric suffix on collision. Persists every file as a document, applies proposed_tree parent/children relationships, links task_ids to every imported document (bidirectionally), and invalidates the doc corpus cache. Returns a JSON string {imported_count,documents:[{doc_id,slug,title,section_count}],warnings:[…]}.".to_string(),
1421
1422
  input_schema: json!({
1422
1423
  "type": "object",
1423
1424
  "properties": {
@@ -1429,6 +1430,22 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1429
1430
  "required": ["analyzed"]
1430
1431
  }),
1431
1432
  },
1433
+ ToolDefinition {
1434
+ name: "handoff_task_checklist".to_string(),
1435
+ description: "action=\"view\" (default): pure-view aggregation of a task's done_criteria and its linked documents' verification matrices. No new data is written — reads task_links (link_type=\"doc\") and each linked document's verification matrix, computed fresh on every call. Returns {task_id,title,no_linked_docs:true} as a fast-path response when the task has no linked documents. Otherwise returns {task_id,title,no_linked_docs:false,done_criteria:{items:[…],progress:{…}},verification_coverage:{documents:[{doc_id,slug,title,doc_type,items:[{fragment_seq,heading,status,stale,visual_state,impl_refs,test_refs}],progress:{…}}],overall:{…}},combined_readiness:{done_criteria_met,verification_complete,ready,blockers:[{type:\"criteria\"|\"verification\",…}]},suggested_actions:[…]}. Each item's visual_state is computed in priority order stale > skipped > verified > implemented (pending+impl_refs+test_refs) > in_progress (pending+impl_refs only) > untouched. action=\"generate\": turns a linked spec/design document's level-2 section headings into done_criteria items using hardcoded defaults (no config template) — format '[{doc_type}§{seq}] {heading}', seq=0 (preamble) plus any skip_seqs excluded. doc_id defaults to the first linked document with doc_type 'spec' or 'design' when omitted. mode=\"preview\" (default) returns the generated items without writing; \"append\" adds them to the task's existing done_criteria; \"replace\" overwrites done_criteria entirely — both writes go through the same optimistic-concurrency path as handoff_check_criterion. Returns {task_id,generated_criteria:[{item,fragment_seq}],applied,skipped_seqs,fixed_items}, where fixed_items is a doc_type-specific list of non-section checklist items (spec: 2 items; design: 1 item; other doc_types: []).".to_string(),
1436
+ input_schema: json!({
1437
+ "type": "object",
1438
+ "properties": {
1439
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1440
+ "task_id": { "type": "string", "description": "Task ID to build the checklist for (e.g. 't1', 't1.2')." },
1441
+ "action": { "type": "string", "description": "Checklist action.", "enum": ["view", "generate"], "default": "view" },
1442
+ "doc_id": { "type": "string", "description": "generate only: document id or slug to generate from. Defaults to the first task-linked document with doc_type 'spec' or 'design'." },
1443
+ "mode": { "type": "string", "description": "generate only: 'preview' returns items without writing, 'append' adds to existing done_criteria, 'replace' overwrites done_criteria entirely.", "enum": ["preview", "append", "replace"], "default": "preview" },
1444
+ "skip_seqs": { "type": "array", "items": { "type": "integer" }, "description": "generate only: additional section seqs to exclude, beyond the always-skipped seq=0 preamble." }
1445
+ },
1446
+ "required": ["task_id"]
1447
+ }),
1448
+ },
1432
1449
  ]
1433
1450
  }
1434
1451
 
@@ -207,6 +207,34 @@ pub fn find_doc_by_id(handoff_dir: &Path, doc_id: &str) -> Result<Option<DocMeta
207
207
  Ok(docs.into_iter().find(|d| d.id == doc_id))
208
208
  }
209
209
 
210
+ /// Resolve every `link_type == "doc"` entry in `task_links` to its
211
+ /// [`DocMetadata`], in one pass over `docs/` (via [`read_all_docs`]) rather
212
+ /// than one [`find_doc_by_id`] scan per link. `task_links[].target` holds the
213
+ /// document's stable `id` (see `crate::storage::tasks::sync_doc_task_links`),
214
+ /// so lookup is by `id`, not `slug`. Links whose target doesn't resolve to an
215
+ /// existing document (stale/dangling link) are silently skipped — callers
216
+ /// that need to detect that should compare the input link count against the
217
+ /// output length themselves.
218
+ pub fn batch_resolve_docs(
219
+ handoff_dir: &Path,
220
+ task_links: &[crate::storage::tasks::TaskLink],
221
+ ) -> Result<Vec<DocMetadata>> {
222
+ let doc_ids: Vec<&str> = task_links
223
+ .iter()
224
+ .filter(|l| l.link_type == "doc")
225
+ .map(|l| l.target.as_str())
226
+ .collect();
227
+ if doc_ids.is_empty() {
228
+ return Ok(Vec::new());
229
+ }
230
+
231
+ let all_docs = read_all_docs(handoff_dir)?;
232
+ Ok(doc_ids
233
+ .iter()
234
+ .filter_map(|id| all_docs.iter().find(|d| &d.id == id).cloned())
235
+ .collect())
236
+ }
237
+
210
238
  /// Delete a document's metadata file by exact slug. Returns `Ok(false)` when
211
239
  /// the file does not exist. Does not touch the document's body file —
212
240
  /// callers that want a full delete should also call [`delete_doc_body`].
@@ -475,4 +503,95 @@ mod tests {
475
503
  write_doc_body(&h, "my-slug", "body").unwrap();
476
504
  assert!(docs_dir(&h).exists());
477
505
  }
506
+
507
+ #[test]
508
+ fn batch_resolve_docs_resolves_doc_links_by_id() {
509
+ use crate::storage::tasks::TaskLink;
510
+
511
+ let tmp = TempDir::new().unwrap();
512
+ let h = handoff(&tmp);
513
+ write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
514
+ write_doc(&h, &sample_doc("doc-2", "doc-two")).unwrap();
515
+
516
+ let links = vec![
517
+ TaskLink {
518
+ target: "doc-1".to_string(),
519
+ link_type: "doc".to_string(),
520
+ label: None,
521
+ },
522
+ TaskLink {
523
+ target: "doc-2".to_string(),
524
+ link_type: "doc".to_string(),
525
+ label: None,
526
+ },
527
+ ];
528
+
529
+ let resolved = batch_resolve_docs(&h, &links).unwrap();
530
+ assert_eq!(resolved.len(), 2);
531
+ let ids: Vec<&str> = resolved.iter().map(|d| d.id.as_str()).collect();
532
+ assert!(ids.contains(&"doc-1"));
533
+ assert!(ids.contains(&"doc-2"));
534
+ }
535
+
536
+ #[test]
537
+ fn batch_resolve_docs_ignores_non_doc_link_types() {
538
+ use crate::storage::tasks::TaskLink;
539
+
540
+ let tmp = TempDir::new().unwrap();
541
+ let h = handoff(&tmp);
542
+ write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
543
+
544
+ let links = vec![
545
+ TaskLink {
546
+ target: "doc-1".to_string(),
547
+ link_type: "doc".to_string(),
548
+ label: None,
549
+ },
550
+ TaskLink {
551
+ target: "https://example.com".to_string(),
552
+ link_type: "url".to_string(),
553
+ label: None,
554
+ },
555
+ ];
556
+
557
+ let resolved = batch_resolve_docs(&h, &links).unwrap();
558
+ assert_eq!(resolved.len(), 1);
559
+ assert_eq!(resolved[0].id, "doc-1");
560
+ }
561
+
562
+ #[test]
563
+ fn batch_resolve_docs_skips_dangling_links() {
564
+ use crate::storage::tasks::TaskLink;
565
+
566
+ let tmp = TempDir::new().unwrap();
567
+ let h = handoff(&tmp);
568
+ write_doc(&h, &sample_doc("doc-1", "doc-one")).unwrap();
569
+
570
+ let links = vec![
571
+ TaskLink {
572
+ target: "doc-1".to_string(),
573
+ link_type: "doc".to_string(),
574
+ label: None,
575
+ },
576
+ TaskLink {
577
+ target: "doc-missing".to_string(),
578
+ link_type: "doc".to_string(),
579
+ label: None,
580
+ },
581
+ ];
582
+
583
+ let resolved = batch_resolve_docs(&h, &links).unwrap();
584
+ assert_eq!(resolved.len(), 1);
585
+ assert_eq!(resolved[0].id, "doc-1");
586
+ }
587
+
588
+ #[test]
589
+ fn batch_resolve_docs_empty_links_is_empty_without_reading_docs_dir() {
590
+ let tmp = TempDir::new().unwrap();
591
+ let h = tmp.path().join(".handoff");
592
+ std::fs::create_dir_all(&h).unwrap();
593
+ // docs/ dir does not exist at all — must not error.
594
+ assert!(!docs_dir(&h).exists());
595
+ assert!(batch_resolve_docs(&h, &[]).unwrap().is_empty());
596
+ }
478
597
  }
@@ -786,10 +786,13 @@ pub fn task_has_children(task_dir: &Path) -> Result<bool> {
786
786
  }
787
787
 
788
788
  /// Whether a task in the given status requires an effort estimate.
789
- /// Parent tasks (with children) and blocked/skipped tasks are exempt;
790
- /// this only covers the status dimension.
789
+ /// Parent tasks (with children) and blocked/skipped/todo tasks are exempt;
790
+ /// this only covers the status dimension. `todo` is exempt so that parent
791
+ /// tasks can be created before their children exist (the children turn the
792
+ /// parent from a leaf into a parent, removing the estimate requirement).
793
+ /// The estimate is enforced when the task moves to `in_progress` or later.
791
794
  pub fn status_requires_estimate(status: &str) -> bool {
792
- matches!(status, "todo" | "in_progress" | "review" | "done")
795
+ matches!(status, "in_progress" | "review" | "done")
793
796
  }
794
797
 
795
798
  /// Validate that a leaf task carries an `estimate_hours` when the project
@@ -839,7 +842,7 @@ pub fn validate_estimate_required(
839
842
  Resend with, for example:\n \
840
843
  {example}\n\
841
844
  Exempt from this rule: parent tasks (any task with children), and \
842
- tasks in status 'blocked' or 'skipped'.\n\
845
+ tasks in status 'todo', 'blocked', or 'skipped'.\n\
843
846
  To disable this requirement project-wide, set \
844
847
  settings.require_estimate_hours = false."
845
848
  );