handoff-mcp-server 0.22.1 → 0.23.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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +4 -4
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +11 -9
- package/skills/handoff-docs/SKILL.md +55 -1
- package/src/mcp/handlers/bulk_update.rs +2 -3
- package/src/mcp/handlers/mod.rs +2 -0
- package/src/mcp/handlers/task_checklist.rs +495 -0
- package/src/mcp/tools.rs +24 -8
- package/src/storage/docs/mod.rs +119 -0
- package/src/storage/tasks.rs +7 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
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
|
|
560
|
-
`
|
|
561
|
-
`blocked` / `skipped`
|
|
562
|
-
|
|
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.
|
|
3
|
+
"version": "0.23.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>",
|
package/skills/handoff/SKILL.md
CHANGED
|
@@ -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
|
-
- **
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
`
|
|
238
|
-
|
|
239
|
-
|
|
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,32 @@ 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
|
+
|
|
20
46
|
## The 9 Doc Tools
|
|
21
47
|
|
|
22
48
|
| Tool | Purpose |
|
|
@@ -231,3 +257,31 @@ only be validated when every file is visible at once.
|
|
|
231
257
|
`spec` (requirements/behavior contracts) · `design` (architecture/design
|
|
232
258
|
docs) · `adr` (architecture decision records) · `guide` (how-to/operational
|
|
233
259
|
docs) · `note` (fallback — anything that doesn't fit the above).
|
|
260
|
+
|
|
261
|
+
## Templates
|
|
262
|
+
|
|
263
|
+
Three starter templates are registered as `doc_type="guide"` documents tagged
|
|
264
|
+
`template` (plus a type-specific tag: `spec`, `design`, or `adr`). Fetch one
|
|
265
|
+
with `handoff_doc_list(tags=["template"])` or `handoff_doc_get(doc_id=...)`
|
|
266
|
+
before writing a new spec/design/ADR from scratch — copy its section
|
|
267
|
+
structure rather than reinventing it:
|
|
268
|
+
|
|
269
|
+
| Template | Tags | Structure |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| Specification Template (`specification-template`) | `template`, `spec` | 課題 / ゴール / 設計 / 実装計画 / 検証チェックリスト / 未決事項 |
|
|
272
|
+
| Design Document Template (`design-doc-template`) | `template`, `design` | 概要 / 制約・前提 / 設計案(採用案・代替案)/ トレードオフ表 / 実装影響範囲 / リスク |
|
|
273
|
+
| ADR Template (`adr-template`) | `template`, `adr` | コンテキスト / 決定 / 理由 / 結果 |
|
|
274
|
+
|
|
275
|
+
Templates are registered with `auto_inject="none"` — they are reference
|
|
276
|
+
material fetched on demand, not injected into every prompt.
|
|
277
|
+
|
|
278
|
+
## Memory vs Documents
|
|
279
|
+
|
|
280
|
+
| Criterion | Use Memory | Use Documents |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| Size | < 1 page, no sections | Multi-section, structured |
|
|
283
|
+
| Lifecycle | Permanent lesson/rule | Versioned with the project |
|
|
284
|
+
| Granularity | Single fact/convention | Sections need independent tracking |
|
|
285
|
+
| Review tracking | Not needed | Verification matrix tracks per-section |
|
|
286
|
+
| Task linkage | Not applicable | Bidirectional task_ids |
|
|
287
|
+
| 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
|
|
135
|
-
//
|
|
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,
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -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) => §ion.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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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" },
|
|
@@ -1429,6 +1429,22 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1429
1429
|
"required": ["analyzed"]
|
|
1430
1430
|
}),
|
|
1431
1431
|
},
|
|
1432
|
+
ToolDefinition {
|
|
1433
|
+
name: "handoff_task_checklist".to_string(),
|
|
1434
|
+
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(),
|
|
1435
|
+
input_schema: json!({
|
|
1436
|
+
"type": "object",
|
|
1437
|
+
"properties": {
|
|
1438
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1439
|
+
"task_id": { "type": "string", "description": "Task ID to build the checklist for (e.g. 't1', 't1.2')." },
|
|
1440
|
+
"action": { "type": "string", "description": "Checklist action.", "enum": ["view", "generate"], "default": "view" },
|
|
1441
|
+
"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'." },
|
|
1442
|
+
"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" },
|
|
1443
|
+
"skip_seqs": { "type": "array", "items": { "type": "integer" }, "description": "generate only: additional section seqs to exclude, beyond the always-skipped seq=0 preamble." }
|
|
1444
|
+
},
|
|
1445
|
+
"required": ["task_id"]
|
|
1446
|
+
}),
|
|
1447
|
+
},
|
|
1432
1448
|
]
|
|
1433
1449
|
}
|
|
1434
1450
|
|
package/src/storage/docs/mod.rs
CHANGED
|
@@ -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
|
}
|
package/src/storage/tasks.rs
CHANGED
|
@@ -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, "
|
|
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
|
);
|