handoff-mcp-server 0.26.0 → 0.28.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/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
|
@@ -1,507 +0,0 @@
|
|
|
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
|
-
let Some(fragment_seq) = item.fragment_seq else {
|
|
318
|
-
// Freeform items (v2, fragment_seq=None) are never stale: they are
|
|
319
|
-
// not tied to any section's content_hash.
|
|
320
|
-
return false;
|
|
321
|
-
};
|
|
322
|
-
match doc.sections.iter().find(|s| s.seq == fragment_seq) {
|
|
323
|
-
Some(section) => §ion.content_hash != hash_at_verify,
|
|
324
|
-
None => true,
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
fn doc_coverage_json(doc: &DocMetadata) -> Value {
|
|
329
|
-
let empty_items: Vec<VerificationItem> = Vec::new();
|
|
330
|
-
let items = doc
|
|
331
|
-
.verification
|
|
332
|
-
.as_ref()
|
|
333
|
-
.map(|v| &v.items)
|
|
334
|
-
.unwrap_or(&empty_items);
|
|
335
|
-
|
|
336
|
-
let items_json: Vec<Value> = items
|
|
337
|
-
.iter()
|
|
338
|
-
.map(|i| {
|
|
339
|
-
json!({
|
|
340
|
-
"fragment_seq": i.fragment_seq,
|
|
341
|
-
"heading": i.heading,
|
|
342
|
-
"status": i.status,
|
|
343
|
-
"stale": item_is_stale(doc, i),
|
|
344
|
-
"visual_state": visual_state(doc, i),
|
|
345
|
-
"impl_refs": i.impl_refs,
|
|
346
|
-
"test_refs": i.test_refs,
|
|
347
|
-
})
|
|
348
|
-
})
|
|
349
|
-
.collect();
|
|
350
|
-
|
|
351
|
-
let verified = items.iter().filter(|i| i.status == "verified").count();
|
|
352
|
-
let pending = items.iter().filter(|i| i.status == "pending").count();
|
|
353
|
-
let skipped = items.iter().filter(|i| i.status == "skipped").count();
|
|
354
|
-
let stale = items.iter().filter(|i| item_is_stale(doc, i)).count();
|
|
355
|
-
let total = items.len();
|
|
356
|
-
let percentage = if total == 0 {
|
|
357
|
-
0.0
|
|
358
|
-
} else {
|
|
359
|
-
(verified + skipped) as f64 / total as f64 * 100.0
|
|
360
|
-
};
|
|
361
|
-
|
|
362
|
-
json!({
|
|
363
|
-
"doc_id": doc.id,
|
|
364
|
-
"slug": doc.slug,
|
|
365
|
-
"title": doc.title,
|
|
366
|
-
"doc_type": doc.doc_type,
|
|
367
|
-
"items": items_json,
|
|
368
|
-
"progress": {
|
|
369
|
-
"verified": verified,
|
|
370
|
-
"pending": pending,
|
|
371
|
-
"skipped": skipped,
|
|
372
|
-
"stale": stale,
|
|
373
|
-
"total": total,
|
|
374
|
-
"percentage": percentage,
|
|
375
|
-
},
|
|
376
|
-
})
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
fn overall_progress_json(docs: &[DocMetadata]) -> Value {
|
|
380
|
-
let mut verified = 0;
|
|
381
|
-
let mut pending = 0;
|
|
382
|
-
let mut stale = 0;
|
|
383
|
-
let mut total = 0;
|
|
384
|
-
for doc in docs {
|
|
385
|
-
if let Some(v) = &doc.verification {
|
|
386
|
-
verified += v.items.iter().filter(|i| i.status == "verified").count();
|
|
387
|
-
pending += v.items.iter().filter(|i| i.status == "pending").count();
|
|
388
|
-
stale += v.items.iter().filter(|i| item_is_stale(doc, i)).count();
|
|
389
|
-
total += v.items.len();
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
let percentage = if total == 0 {
|
|
393
|
-
0.0
|
|
394
|
-
} else {
|
|
395
|
-
verified as f64 / total as f64 * 100.0
|
|
396
|
-
};
|
|
397
|
-
json!({ "verified": verified, "pending": pending, "stale": stale, "total": total, "percentage": percentage })
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/// Typed blockers (doc-20260712-191142-602891 §3.1 M7: "blockers は typed
|
|
401
|
-
/// objects"): one entry per unchecked done_criteria item
|
|
402
|
-
/// (`{type:"criteria", index, item}`), one per non-verified/non-skipped
|
|
403
|
-
/// verification item (`{type:"verification", doc_id, doc_slug, fragment_seq,
|
|
404
|
-
/// heading}`), and one per linked doc that has no verification matrix at all
|
|
405
|
-
/// (`{type:"verification_missing", doc_id, doc_slug}` — mirrors
|
|
406
|
-
/// `handle_doc_verify_status`'s hard error on the same condition, so a doc
|
|
407
|
-
/// that never had `action="generate"` run cannot silently count as ready).
|
|
408
|
-
fn combined_readiness_json(data: &TaskData, docs: &[DocMetadata]) -> Value {
|
|
409
|
-
let mut blockers = Vec::new();
|
|
410
|
-
|
|
411
|
-
for (index, c) in data.done_criteria.iter().enumerate() {
|
|
412
|
-
if !c.checked {
|
|
413
|
-
blockers.push(json!({ "type": "criteria", "index": index, "item": c.item }));
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
for doc in docs {
|
|
418
|
-
let Some(v) = &doc.verification else {
|
|
419
|
-
blockers.push(json!({
|
|
420
|
-
"type": "verification_missing",
|
|
421
|
-
"doc_id": doc.id,
|
|
422
|
-
"doc_slug": doc.slug,
|
|
423
|
-
}));
|
|
424
|
-
continue;
|
|
425
|
-
};
|
|
426
|
-
for item in &v.items {
|
|
427
|
-
let resolved = item.status == "verified" || item.status == "skipped";
|
|
428
|
-
if !resolved || item_is_stale(doc, item) {
|
|
429
|
-
blockers.push(json!({
|
|
430
|
-
"type": "verification",
|
|
431
|
-
"doc_id": doc.id,
|
|
432
|
-
"doc_slug": doc.slug,
|
|
433
|
-
"fragment_seq": item.fragment_seq,
|
|
434
|
-
"heading": item.heading,
|
|
435
|
-
}));
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
let done_criteria_met =
|
|
441
|
-
!data.done_criteria.is_empty() && data.done_criteria.iter().all(|c| c.checked);
|
|
442
|
-
let verification_complete = docs.iter().all(|d| match &d.verification {
|
|
443
|
-
None => false,
|
|
444
|
-
Some(v) => v
|
|
445
|
-
.items
|
|
446
|
-
.iter()
|
|
447
|
-
.all(|i| (i.status == "verified" || i.status == "skipped") && !item_is_stale(d, i)),
|
|
448
|
-
});
|
|
449
|
-
let ready = done_criteria_met && verification_complete;
|
|
450
|
-
|
|
451
|
-
json!({
|
|
452
|
-
"done_criteria_met": done_criteria_met,
|
|
453
|
-
"verification_complete": verification_complete,
|
|
454
|
-
"ready": ready,
|
|
455
|
-
"blockers": blockers,
|
|
456
|
-
})
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/// Advisory next-action hints (doc-20260712-191142-602891 §3.1 / §2.3: no
|
|
460
|
-
/// auto-sync, `suggested_actions` presents next steps for the caller to run
|
|
461
|
-
/// itself). One suggestion per unresolved verification item, one per doc
|
|
462
|
-
/// with no verification matrix yet, and one per unchecked done_criteria
|
|
463
|
-
/// item, each naming the concrete tool call to make.
|
|
464
|
-
fn suggested_actions_json(data: &TaskData, docs: &[DocMetadata]) -> Vec<String> {
|
|
465
|
-
let mut actions = Vec::new();
|
|
466
|
-
|
|
467
|
-
for doc in docs {
|
|
468
|
-
let Some(v) = &doc.verification else {
|
|
469
|
-
actions.push(format!(
|
|
470
|
-
"handoff_doc_verify(doc_id=\"{}\", action=\"generate\") — \"{}\" の検証マトリクスがまだ存在しない",
|
|
471
|
-
doc.id, doc.title
|
|
472
|
-
));
|
|
473
|
-
continue;
|
|
474
|
-
};
|
|
475
|
-
for item in &v.items {
|
|
476
|
-
let resolved = item.status == "verified" || item.status == "skipped";
|
|
477
|
-
if !resolved || item_is_stale(doc, item) {
|
|
478
|
-
let action = match item.fragment_seq {
|
|
479
|
-
Some(seq) => format!(
|
|
480
|
-
"handoff_doc_verify(doc_id=\"{}\", action=\"check\", fragment_seq={}) — \"{}\" のレビュー完了時",
|
|
481
|
-
doc.id, seq, item.heading
|
|
482
|
-
),
|
|
483
|
-
None => format!(
|
|
484
|
-
"handoff_doc_verify(doc_id=\"{}\", action=\"check\", fragment_seq=null, label=\"{}\") — \"{}\" のレビュー完了時 (フリーフォーム項目)",
|
|
485
|
-
doc.id, item.label.as_deref().unwrap_or(&item.heading), item.heading
|
|
486
|
-
),
|
|
487
|
-
};
|
|
488
|
-
actions.push(action);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
for (index, c) in data.done_criteria.iter().enumerate() {
|
|
494
|
-
if !c.checked {
|
|
495
|
-
actions.push(format!(
|
|
496
|
-
"handoff_check_criterion(task_id=\"{}\", criterion_index={}) — \"{}\" 完了時",
|
|
497
|
-
data.id, index, c.item
|
|
498
|
-
));
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
actions
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
fn to_json(v: &Value) -> String {
|
|
506
|
-
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
|
|
507
|
-
}
|