handoff-mcp-server 0.22.0 → 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 +2 -2
- 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/docs.rs +449 -0
- package/src/mcp/handlers/mod.rs +4 -0
- package/src/mcp/handlers/task_checklist.rs +495 -0
- package/src/mcp/tools.rs +49 -8
- package/src/storage/docs/mod.rs +119 -0
- package/src/storage/tasks.rs +7 -4
|
@@ -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" },
|
|
@@ -1358,6 +1358,31 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1358
1358
|
"required": ["doc_id"]
|
|
1359
1359
|
}),
|
|
1360
1360
|
},
|
|
1361
|
+
ToolDefinition {
|
|
1362
|
+
name: "handoff_doc_graph".to_string(),
|
|
1363
|
+
description: "Build a graph of every document in the project: nodes (one per document, with id/slug/title/doc_type/tags/task_ids/section_count/updated_at, plus verification_progress {total,verified} when include_verification=true and a matrix exists), edges (explicit parent_id -> type='parent_child'/direction='down', explicit related[] -> type=<rel>/direction='forward', and — when include_implicit=true — implicit shared_task edges for documents sharing task_ids and shared_scope edges for documents sharing scope_paths), and layers (doc ids grouped by doc_type). Intended for graph-visualization UIs. Returns a JSON string {nodes:[…],edges:[…],layers:{…}}.".to_string(),
|
|
1364
|
+
input_schema: json!({
|
|
1365
|
+
"type": "object",
|
|
1366
|
+
"properties": {
|
|
1367
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1368
|
+
"include_implicit": { "type": "boolean", "description": "Also emit shared_task/shared_scope implicit edges.", "default": true },
|
|
1369
|
+
"include_verification": { "type": "boolean", "description": "Attach verification_progress {total,verified} to each node that has a verification matrix.", "default": false }
|
|
1370
|
+
}
|
|
1371
|
+
}),
|
|
1372
|
+
},
|
|
1373
|
+
ToolDefinition {
|
|
1374
|
+
name: "handoff_doc_trace".to_string(),
|
|
1375
|
+
description: "Trace a document's family-tree lineage from doc_id (id or slug): direction='up' walks the child->parent chain to the root; 'down' walks parent->children (DFS); 'both' (default) merges the up chain, the target doc, and the down chain into one ordered chain (root to leaf). related (implements/references/etc.) documents encountered along the chain are appended as detour entries. Multi-child forks encountered in the down direction are additionally reported in branches[] (one entry per fork, {fork_from,docs:[…]}). Cycle-safe: a visited set skips any document already seen in the traversal. Returns a JSON string {chain:[{id,title,doc_type,rel}…],branches:[{fork_from,docs:[…]}…]}.".to_string(),
|
|
1376
|
+
input_schema: json!({
|
|
1377
|
+
"type": "object",
|
|
1378
|
+
"properties": {
|
|
1379
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1380
|
+
"doc_id": { "type": "string", "description": "Document id or slug to trace from." },
|
|
1381
|
+
"direction": { "type": "string", "description": "Traversal direction.", "enum": ["up", "down", "both"], "default": "both" }
|
|
1382
|
+
},
|
|
1383
|
+
"required": ["doc_id"]
|
|
1384
|
+
}),
|
|
1385
|
+
},
|
|
1361
1386
|
ToolDefinition {
|
|
1362
1387
|
name: "handoff_doc_query".to_string(),
|
|
1363
1388
|
description: "Inject document sections relevant to the current prompt/file/task (hook-driven context injection, mirrors memory_query at section granularity). Ranks by BM25 relevance + scope_paths match + task_id affinity, then stages each result as 'full' (whole section body, when its token estimate is within the inline threshold) or 'outline' (heading + sibling table of contents only, for larger sections — fetch the body via doc_get(format='section')). With session_id, already-injected sections (same content_hash) are skipped this session; mark_injected (default true) records survivors. suppress_doc_ids excludes given documents from this call's results; combined with suppress_until_changed=true (requires session_id), the suppression is recorded in the session's injected sidecar and persists across future calls until that document's content_hash changes. Returns a JSON string {documents:[…],injected_count}.".to_string(),
|
|
@@ -1404,6 +1429,22 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1404
1429
|
"required": ["analyzed"]
|
|
1405
1430
|
}),
|
|
1406
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
|
+
},
|
|
1407
1448
|
]
|
|
1408
1449
|
}
|
|
1409
1450
|
|