handoff-mcp-server 0.20.0 → 0.22.1
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 +52 -11
- package/Cargo.toml +3 -2
- package/README.md +31 -2
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +24 -0
- package/skills/handoff-docs/SKILL.md +233 -0
- package/skills/handoff-load/SKILL.md +10 -2
- package/skills/handoff-memory/SKILL.md +20 -0
- package/src/context/injection.rs +239 -0
- package/src/context/mod.rs +129 -0
- package/src/lib.rs +1 -0
- package/src/mcp/handlers/docs.rs +1492 -0
- package/src/mcp/handlers/docs_query.rs +1335 -0
- package/src/mcp/handlers/get_task.rs +9 -0
- package/src/mcp/handlers/import_context.rs +1 -0
- package/src/mcp/handlers/memory.rs +33 -53
- package/src/mcp/handlers/mod.rs +15 -0
- package/src/mcp/handlers/update_task.rs +2 -0
- package/src/mcp/tools.rs +195 -0
- package/src/storage/docs/mod.rs +478 -0
- package/src/storage/docs/model.rs +537 -0
- package/src/storage/docs/reassemble.rs +167 -0
- package/src/storage/docs/split.rs +377 -0
- package/src/storage/mod.rs +1 -0
- package/src/storage/tasks.rs +131 -0
|
@@ -0,0 +1,1492 @@
|
|
|
1
|
+
//! MCP handlers for document management (save / get / list) — P1-6a (t96.1),
|
|
2
|
+
//! v5 rearchitecture (2-file slug-based storage,
|
|
3
|
+
//! wiki/130-document-management.md §3.1).
|
|
4
|
+
//!
|
|
5
|
+
//! Builds on the storage layer in `crate::storage::docs` (split into
|
|
6
|
+
//! in-memory sections + slug-named `.json`/`.md` pair I/O) and the
|
|
7
|
+
//! task<->doc bidirectional link sync in
|
|
8
|
+
//! `crate::storage::tasks::sync_doc_task_links`. See
|
|
9
|
+
//! `wiki/130-document-management.md` §5.1-§5.3 for the spec.
|
|
10
|
+
|
|
11
|
+
use std::path::Path;
|
|
12
|
+
|
|
13
|
+
use anyhow::{Context, Result};
|
|
14
|
+
use serde_json::{json, Value};
|
|
15
|
+
|
|
16
|
+
use super::resolve_project_dir;
|
|
17
|
+
use crate::context::injection::{rank_by_bm25_and_scope, RankConfig};
|
|
18
|
+
use crate::storage::docs::reassemble::extract_section;
|
|
19
|
+
use crate::storage::docs::split::{compute_sections, split};
|
|
20
|
+
use crate::storage::docs::{
|
|
21
|
+
delete_doc, delete_doc_body, ensure_docs_dir, find_doc_by_id, read_all_docs, read_doc,
|
|
22
|
+
read_doc_body, validate_slug, write_doc, write_doc_body, CodeRef, DocMetadata, DocRelation,
|
|
23
|
+
Verification, VerificationItem,
|
|
24
|
+
};
|
|
25
|
+
use crate::storage::ensure_handoff_exists;
|
|
26
|
+
use crate::storage::tasks::sync_doc_task_links;
|
|
27
|
+
|
|
28
|
+
/// Bonus added to a document's BM25 score when one of its `scope_paths` is a
|
|
29
|
+
/// prefix of one of the query's `file_paths`. Mirrors `memory.rs`'s
|
|
30
|
+
/// `SCOPE_PATH_BONUS` — kept as a separate constant since the two features
|
|
31
|
+
/// tune independently even though the value happens to match today.
|
|
32
|
+
const SCOPE_PATH_BONUS: f64 = 2.0;
|
|
33
|
+
|
|
34
|
+
/// Default relevance floor for `doc_list(query=...)`. Kept at 0.0 (no floor)
|
|
35
|
+
/// since `doc_list` is an explicit search the caller controls via `query`
|
|
36
|
+
/// presence/absence, unlike `memory_query`'s hook-driven auto-injection which
|
|
37
|
+
/// needs a floor to avoid noise.
|
|
38
|
+
const DOC_QUERY_MIN_SCORE: f64 = 0.0;
|
|
39
|
+
|
|
40
|
+
fn new_doc_id() -> String {
|
|
41
|
+
format!("doc-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S-%6f"))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Resolve a document by either its file-naming `slug` or its stable `id`
|
|
45
|
+
/// (spec instructs `doc_get`/`doc_delete`/etc. to accept either). Tries the
|
|
46
|
+
/// direct slug-keyed file lookup first (cheap, no scan), falling back to a
|
|
47
|
+
/// full `id` scan so callers that only recorded a document's `id` (e.g. from
|
|
48
|
+
/// a `related`/`parent_id` reference) can still resolve it.
|
|
49
|
+
fn resolve_doc(handoff: &Path, slug_or_id: &str) -> Result<Option<DocMetadata>> {
|
|
50
|
+
if let Some(doc) = read_doc(handoff, slug_or_id)? {
|
|
51
|
+
return Ok(Some(doc));
|
|
52
|
+
}
|
|
53
|
+
find_doc_by_id(handoff, slug_or_id)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// `handoff_doc_save` — create or update a document from a full Markdown
|
|
57
|
+
/// body: split into in-memory sections, persist the body + metadata as a
|
|
58
|
+
/// slug-named pair, and sync the task<->doc bidirectional link.
|
|
59
|
+
pub fn handle_doc_save(arguments: &Value) -> Result<String> {
|
|
60
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
61
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
62
|
+
ensure_docs_dir(&handoff)?;
|
|
63
|
+
|
|
64
|
+
let body = arguments
|
|
65
|
+
.get("body")
|
|
66
|
+
.and_then(|v| v.as_str())
|
|
67
|
+
.ok_or_else(|| anyhow::anyhow!("'body' is required"))?;
|
|
68
|
+
|
|
69
|
+
let doc_id = arguments.get("doc_id").and_then(|v| v.as_str());
|
|
70
|
+
let existing = match doc_id {
|
|
71
|
+
Some(id) => Some(
|
|
72
|
+
find_doc_by_id(&handoff, id)?
|
|
73
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {id}"))?,
|
|
74
|
+
),
|
|
75
|
+
None => None,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// slug: required for new documents, taken from the existing document on
|
|
79
|
+
// update (the `slug` argument is ignored on update — renaming a
|
|
80
|
+
// document's file-naming slug is out of scope for `doc_save`).
|
|
81
|
+
let slug = match &existing {
|
|
82
|
+
Some(d) => d.slug.clone(),
|
|
83
|
+
None => {
|
|
84
|
+
let slug = arguments
|
|
85
|
+
.get("slug")
|
|
86
|
+
.and_then(|v| v.as_str())
|
|
87
|
+
.ok_or_else(|| anyhow::anyhow!("'slug' is required for new documents"))?
|
|
88
|
+
.to_string();
|
|
89
|
+
validate_slug(&slug)?;
|
|
90
|
+
if read_doc(&handoff, &slug)?.is_some() {
|
|
91
|
+
anyhow::bail!("slug '{slug}' is already in use by another document");
|
|
92
|
+
}
|
|
93
|
+
slug
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
let title = arguments
|
|
98
|
+
.get("title")
|
|
99
|
+
.and_then(|v| v.as_str())
|
|
100
|
+
.or(existing.as_ref().map(|d| d.title.as_str()))
|
|
101
|
+
.ok_or_else(|| anyhow::anyhow!("'title' is required for new documents"))?
|
|
102
|
+
.to_string();
|
|
103
|
+
|
|
104
|
+
let split_level = arguments
|
|
105
|
+
.get("split_level")
|
|
106
|
+
.and_then(|v| v.as_u64())
|
|
107
|
+
.map(|n| n as u8)
|
|
108
|
+
.unwrap_or(crate::storage::docs::split::DEFAULT_SPLIT_LEVEL);
|
|
109
|
+
|
|
110
|
+
let split_doc = split(body, split_level)?;
|
|
111
|
+
|
|
112
|
+
let now = chrono::Utc::now().to_rfc3339();
|
|
113
|
+
let id = doc_id.map(str::to_string).unwrap_or_else(new_doc_id);
|
|
114
|
+
|
|
115
|
+
let mut doc = match existing {
|
|
116
|
+
Some(mut d) => {
|
|
117
|
+
d.title = title.clone();
|
|
118
|
+
d
|
|
119
|
+
}
|
|
120
|
+
None => {
|
|
121
|
+
let mut d = DocMetadata::new(
|
|
122
|
+
id.clone(),
|
|
123
|
+
slug.clone(),
|
|
124
|
+
title.clone(),
|
|
125
|
+
"note".to_string(),
|
|
126
|
+
now.clone(),
|
|
127
|
+
);
|
|
128
|
+
d.source.origin = "authored".to_string();
|
|
129
|
+
d
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if let Some(doc_type) = arguments.get("doc_type").and_then(|v| v.as_str()) {
|
|
134
|
+
doc.doc_type = doc_type.to_string();
|
|
135
|
+
}
|
|
136
|
+
if let Some(tags) = arguments.get("tags") {
|
|
137
|
+
doc.tags = string_array_value(tags);
|
|
138
|
+
}
|
|
139
|
+
if let Some(scope_paths) = arguments.get("scope_paths") {
|
|
140
|
+
doc.scope_paths = string_array_value(scope_paths);
|
|
141
|
+
}
|
|
142
|
+
let previous_parent_id = doc.parent_id.clone();
|
|
143
|
+
if let Some(parent_id) = arguments.get("parent_id") {
|
|
144
|
+
doc.parent_id = parent_id.as_str().map(str::to_string);
|
|
145
|
+
}
|
|
146
|
+
let mut warnings: Vec<String> = Vec::new();
|
|
147
|
+
if let Some(related) = arguments.get("related").and_then(|v| v.as_array()) {
|
|
148
|
+
let mut malformed_count = 0usize;
|
|
149
|
+
doc.related = related
|
|
150
|
+
.iter()
|
|
151
|
+
.filter_map(|r| {
|
|
152
|
+
let rid = r.get("id").and_then(|v| v.as_str());
|
|
153
|
+
let rel = r.get("rel").and_then(|v| v.as_str());
|
|
154
|
+
match (rid, rel) {
|
|
155
|
+
(Some(rid), Some(rel)) => Some(DocRelation {
|
|
156
|
+
id: rid.to_string(),
|
|
157
|
+
rel: rel.to_string(),
|
|
158
|
+
}),
|
|
159
|
+
_ => {
|
|
160
|
+
malformed_count += 1;
|
|
161
|
+
None
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
.collect();
|
|
166
|
+
if malformed_count > 0 {
|
|
167
|
+
warnings.push(format!(
|
|
168
|
+
"Ignored {malformed_count} malformed 'related' entr{} (each entry requires string 'id' and 'rel')",
|
|
169
|
+
if malformed_count == 1 { "y" } else { "ies" }
|
|
170
|
+
));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if let Some(auto_inject) = arguments.get("auto_inject").and_then(|v| v.as_str()) {
|
|
174
|
+
doc.auto_inject = auto_inject.to_string();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
doc.has_bom = split_doc.has_bom;
|
|
178
|
+
doc.line_ending = split_doc.line_ending.to_string();
|
|
179
|
+
doc.source.frontmatter = split_doc.frontmatter.map(str::to_string);
|
|
180
|
+
doc.source.frontmatter_trailing_eol = split_doc.frontmatter_trailing_eol;
|
|
181
|
+
doc.updated_at = now.clone();
|
|
182
|
+
|
|
183
|
+
// v5: the full body (after BOM/frontmatter stripping) is written verbatim
|
|
184
|
+
// to `_doc.<slug>.md`; sections are an in-memory byte-offset index into
|
|
185
|
+
// it, computed fresh on every save (no stale-fragment cleanup needed —
|
|
186
|
+
// there is nothing left on disk to clean up per section).
|
|
187
|
+
let body_after_strip: String = split_doc.fragments.iter().map(|f| f.body).collect();
|
|
188
|
+
write_doc_body(&handoff, &slug, &body_after_strip)?;
|
|
189
|
+
doc.sections = compute_sections(&split_doc);
|
|
190
|
+
|
|
191
|
+
let content_hash = lexsim::content_hash(&body_after_strip);
|
|
192
|
+
doc.content_hash = content_hash.clone();
|
|
193
|
+
doc.source.canonical_hash = Some(content_hash);
|
|
194
|
+
|
|
195
|
+
let new_task_ids = arguments
|
|
196
|
+
.get("task_ids")
|
|
197
|
+
.map(string_array_value)
|
|
198
|
+
.unwrap_or_else(|| doc.task_ids.clone());
|
|
199
|
+
|
|
200
|
+
if arguments.get("task_ids").is_some() {
|
|
201
|
+
let (link_ids, unlink_ids) = if doc_id.is_some() {
|
|
202
|
+
let previous: Vec<String> = doc.task_ids.clone();
|
|
203
|
+
let link: Vec<String> = new_task_ids
|
|
204
|
+
.iter()
|
|
205
|
+
.filter(|t| !previous.contains(t))
|
|
206
|
+
.cloned()
|
|
207
|
+
.collect();
|
|
208
|
+
let unlink: Vec<String> = previous
|
|
209
|
+
.iter()
|
|
210
|
+
.filter(|t| !new_task_ids.contains(t))
|
|
211
|
+
.cloned()
|
|
212
|
+
.collect();
|
|
213
|
+
(link, unlink)
|
|
214
|
+
} else {
|
|
215
|
+
(new_task_ids.clone(), Vec::new())
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let tasks_dir = handoff.join("tasks");
|
|
219
|
+
let report = sync_doc_task_links(&tasks_dir, &id, &title, &link_ids, &unlink_ids)?;
|
|
220
|
+
if !report.unresolved.is_empty() {
|
|
221
|
+
warnings.push(format!(
|
|
222
|
+
"Could not resolve task id(s) for linking: {}",
|
|
223
|
+
report.unresolved.join(", ")
|
|
224
|
+
));
|
|
225
|
+
}
|
|
226
|
+
doc.task_ids = new_task_ids;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
write_doc(&handoff, &doc)?;
|
|
230
|
+
|
|
231
|
+
// Keep the family tree's `children` list in sync with `parent_id`: if the
|
|
232
|
+
// parent changed (including unset -> set on first save), push this doc's
|
|
233
|
+
// id into the new parent's `children` and drop it from the old parent's,
|
|
234
|
+
// mirroring the same "sync the other side" pattern as
|
|
235
|
+
// sync_doc_task_links. A parent id that doesn't resolve is a non-fatal
|
|
236
|
+
// warning, not a rollback — same policy as unresolved task_ids above.
|
|
237
|
+
// `parent_id` references a document's stable `id`, not its `slug`, so
|
|
238
|
+
// resolution goes through `find_doc_by_id`.
|
|
239
|
+
if doc.parent_id != previous_parent_id {
|
|
240
|
+
if let Some(old_parent_id) = &previous_parent_id {
|
|
241
|
+
if let Some(mut old_parent) = find_doc_by_id(&handoff, old_parent_id)? {
|
|
242
|
+
let before = old_parent.children.len();
|
|
243
|
+
old_parent.children.retain(|c| c != &id);
|
|
244
|
+
if old_parent.children.len() != before {
|
|
245
|
+
write_doc(&handoff, &old_parent)?;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if let Some(new_parent_id) = &doc.parent_id {
|
|
250
|
+
match find_doc_by_id(&handoff, new_parent_id)? {
|
|
251
|
+
Some(mut new_parent) => {
|
|
252
|
+
if !new_parent.children.iter().any(|c| c == &id) {
|
|
253
|
+
new_parent.children.push(id.clone());
|
|
254
|
+
write_doc(&handoff, &new_parent)?;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
None => warnings.push(format!("Parent document not found: {new_parent_id}")),
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
Ok(to_json(&json!({
|
|
263
|
+
"doc_id": id,
|
|
264
|
+
"slug": doc.slug,
|
|
265
|
+
"title": doc.title,
|
|
266
|
+
"doc_type": doc.doc_type,
|
|
267
|
+
"section_count": doc.sections.len(),
|
|
268
|
+
"content_hash": doc.content_hash,
|
|
269
|
+
"warnings": warnings,
|
|
270
|
+
})))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// Reads a document's full original body: `_doc.<slug>.md` (the
|
|
274
|
+
/// post-BOM/frontmatter-stripped body) with the BOM and YAML frontmatter
|
|
275
|
+
/// (if any) restored in front of it, exactly as originally authored.
|
|
276
|
+
/// Returns `Ok(None)` when the `.md` file is missing (metadata exists but
|
|
277
|
+
/// body was deleted out-of-band).
|
|
278
|
+
fn read_full_body(handoff: &Path, doc: &DocMetadata) -> Result<Option<String>> {
|
|
279
|
+
let Some(stripped_body) = read_doc_body(handoff, &doc.slug)? else {
|
|
280
|
+
return Ok(None);
|
|
281
|
+
};
|
|
282
|
+
let mut body = stripped_body;
|
|
283
|
+
if let Some(frontmatter) = &doc.source.frontmatter {
|
|
284
|
+
let eol = if doc.line_ending == "crlf" {
|
|
285
|
+
"\r\n"
|
|
286
|
+
} else {
|
|
287
|
+
"\n"
|
|
288
|
+
};
|
|
289
|
+
let trailing_eol = if doc.source.frontmatter_trailing_eol {
|
|
290
|
+
eol
|
|
291
|
+
} else {
|
|
292
|
+
""
|
|
293
|
+
};
|
|
294
|
+
body = format!("---{eol}{frontmatter}---{trailing_eol}{body}");
|
|
295
|
+
}
|
|
296
|
+
if doc.has_bom {
|
|
297
|
+
body = format!("\u{FEFF}{body}");
|
|
298
|
+
}
|
|
299
|
+
Ok(Some(body))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/// `handoff_doc_get` — read a document (by `doc_id` or `slug`) as `full`
|
|
303
|
+
/// (the original Markdown body + metadata), `meta` (metadata only), or
|
|
304
|
+
/// `section` (one section's body, byte-sliced from `_doc.<slug>.md`).
|
|
305
|
+
pub fn handle_doc_get(arguments: &Value) -> Result<String> {
|
|
306
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
307
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
308
|
+
|
|
309
|
+
let doc_id = arguments
|
|
310
|
+
.get("doc_id")
|
|
311
|
+
.and_then(|v| v.as_str())
|
|
312
|
+
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
313
|
+
|
|
314
|
+
let format = arguments
|
|
315
|
+
.get("format")
|
|
316
|
+
.and_then(|v| v.as_str())
|
|
317
|
+
.unwrap_or("full");
|
|
318
|
+
|
|
319
|
+
match format {
|
|
320
|
+
"meta" => {
|
|
321
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
322
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
323
|
+
Ok(to_json(&doc_metadata_json(&doc)))
|
|
324
|
+
}
|
|
325
|
+
"section" | "fragment" => {
|
|
326
|
+
let seq = arguments
|
|
327
|
+
.get("seq")
|
|
328
|
+
.and_then(|v| v.as_u64())
|
|
329
|
+
.ok_or_else(|| anyhow::anyhow!("'seq' is required when format='section'"))?
|
|
330
|
+
as usize;
|
|
331
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
332
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
333
|
+
let section =
|
|
334
|
+
doc.sections.iter().find(|s| s.seq == seq).ok_or_else(|| {
|
|
335
|
+
anyhow::anyhow!("Section not found: doc_id={doc_id} seq={seq}")
|
|
336
|
+
})?;
|
|
337
|
+
let body = read_doc_body(&handoff, &doc.slug)?.ok_or_else(|| {
|
|
338
|
+
anyhow::anyhow!("Document body file missing for slug '{}'", doc.slug)
|
|
339
|
+
})?;
|
|
340
|
+
let section_body = extract_section(&body, section)?;
|
|
341
|
+
Ok(to_json(&json!({
|
|
342
|
+
"doc_id": doc.id,
|
|
343
|
+
"seq": section.seq,
|
|
344
|
+
"heading": section.heading,
|
|
345
|
+
"level": section.level,
|
|
346
|
+
"content_hash": section.content_hash,
|
|
347
|
+
"body": section_body,
|
|
348
|
+
})))
|
|
349
|
+
}
|
|
350
|
+
_ => {
|
|
351
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
352
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
353
|
+
let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
|
|
354
|
+
let mut out = doc_metadata_json(&doc);
|
|
355
|
+
out["body"] = json!(body);
|
|
356
|
+
Ok(to_json(&out))
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// `handoff_doc_list` — list/search documents with optional `doc_type` /
|
|
362
|
+
/// `tags` (AND) / `task_id` filters, BM25 `query` ranking, and optional
|
|
363
|
+
/// reassembled `body` inclusion.
|
|
364
|
+
pub fn handle_doc_list(arguments: &Value) -> Result<String> {
|
|
365
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
366
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
367
|
+
|
|
368
|
+
let doc_type = arguments.get("doc_type").and_then(|v| v.as_str());
|
|
369
|
+
let tags = arguments.get("tags").map(string_array_value);
|
|
370
|
+
let task_id = arguments.get("task_id").and_then(|v| v.as_str());
|
|
371
|
+
let include_body = arguments
|
|
372
|
+
.get("include_body")
|
|
373
|
+
.and_then(|v| v.as_bool())
|
|
374
|
+
.unwrap_or(false);
|
|
375
|
+
let query = arguments
|
|
376
|
+
.get("query")
|
|
377
|
+
.and_then(|v| v.as_str())
|
|
378
|
+
.map(str::trim)
|
|
379
|
+
.filter(|s| !s.is_empty());
|
|
380
|
+
|
|
381
|
+
let mut docs = read_all_docs(&handoff)?;
|
|
382
|
+
if let Some(dt) = doc_type {
|
|
383
|
+
docs.retain(|d| d.doc_type == dt);
|
|
384
|
+
}
|
|
385
|
+
if let Some(tags) = &tags {
|
|
386
|
+
if !tags.is_empty() {
|
|
387
|
+
docs.retain(|d| tags.iter().all(|t| d.tags.contains(t)));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if let Some(tid) = task_id {
|
|
391
|
+
docs.retain(|d| d.task_ids.iter().any(|t| t == tid));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let ordered_indices: Vec<usize> = if let Some(q) = query {
|
|
395
|
+
rank_docs_by_query(&handoff, &docs, q)?
|
|
396
|
+
} else {
|
|
397
|
+
(0..docs.len()).collect()
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
let mut out_docs = Vec::with_capacity(ordered_indices.len());
|
|
401
|
+
for idx in ordered_indices {
|
|
402
|
+
let d = &docs[idx];
|
|
403
|
+
let mut entry = doc_metadata_json(d);
|
|
404
|
+
if include_body {
|
|
405
|
+
let body = read_full_body(&handoff, d)?.unwrap_or_default();
|
|
406
|
+
entry["body"] = json!(body);
|
|
407
|
+
}
|
|
408
|
+
out_docs.push(entry);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
Ok(to_json(&json!({ "documents": out_docs })))
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/// Ranks `docs` against `query` via BM25 over each document's index text
|
|
415
|
+
/// (title + tags + body), returning original-order indices sorted by
|
|
416
|
+
/// descending relevance. Corpus is built fresh every call (no cache — the
|
|
417
|
+
/// cache is reserved for `doc_query`, t96.3, per the task's own note).
|
|
418
|
+
fn rank_docs_by_query(handoff: &Path, docs: &[DocMetadata], query: &str) -> Result<Vec<usize>> {
|
|
419
|
+
let mut index_texts = Vec::with_capacity(docs.len());
|
|
420
|
+
for d in docs {
|
|
421
|
+
let body = read_doc_body(handoff, &d.slug)?.unwrap_or_default();
|
|
422
|
+
let mut text = d.title.clone();
|
|
423
|
+
text.push(' ');
|
|
424
|
+
text.push_str(&d.tags.join(" "));
|
|
425
|
+
text.push(' ');
|
|
426
|
+
text.push_str(&body);
|
|
427
|
+
index_texts.push(text);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let corpus = lexsim::Corpus::build(&index_texts);
|
|
431
|
+
let query_tokens = lexsim::tokenize(query);
|
|
432
|
+
let scope_paths: Vec<Vec<String>> = docs.iter().map(|d| d.scope_paths.clone()).collect();
|
|
433
|
+
let config = RankConfig {
|
|
434
|
+
min_score: DOC_QUERY_MIN_SCORE,
|
|
435
|
+
scope_path_bonus: SCOPE_PATH_BONUS,
|
|
436
|
+
limit: docs.len(),
|
|
437
|
+
};
|
|
438
|
+
let ranked = rank_by_bm25_and_scope(&corpus, &query_tokens, &scope_paths, &[], &config);
|
|
439
|
+
Ok(ranked.into_iter().map(|item| item.index).collect())
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/// `handoff_doc_delete` — delete a document (by `doc_id` or `slug`) and its
|
|
443
|
+
/// body file, unlink it from any linked tasks, remove it from its parent's
|
|
444
|
+
/// `children`, and orphan (clear `parent_id` on) any of its own children.
|
|
445
|
+
/// See `wiki/130-document-management.md` §5.4.
|
|
446
|
+
pub fn handle_doc_delete(arguments: &Value) -> Result<String> {
|
|
447
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
448
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
449
|
+
|
|
450
|
+
let doc_id = arguments
|
|
451
|
+
.get("doc_id")
|
|
452
|
+
.and_then(|v| v.as_str())
|
|
453
|
+
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
454
|
+
|
|
455
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
456
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
457
|
+
|
|
458
|
+
let mut warnings: Vec<String> = Vec::new();
|
|
459
|
+
|
|
460
|
+
delete_doc_body(&handoff, &doc.slug)?;
|
|
461
|
+
delete_doc(&handoff, &doc.slug)?;
|
|
462
|
+
|
|
463
|
+
if !doc.task_ids.is_empty() {
|
|
464
|
+
let tasks_dir = handoff.join("tasks");
|
|
465
|
+
let report = sync_doc_task_links(&tasks_dir, &doc.id, &doc.title, &[], &doc.task_ids)?;
|
|
466
|
+
if !report.unresolved.is_empty() {
|
|
467
|
+
warnings.push(format!(
|
|
468
|
+
"Could not resolve task id(s) for unlinking: {}",
|
|
469
|
+
report.unresolved.join(", ")
|
|
470
|
+
));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if let Some(parent_id) = &doc.parent_id {
|
|
475
|
+
if let Some(mut parent) = find_doc_by_id(&handoff, parent_id)? {
|
|
476
|
+
let before = parent.children.len();
|
|
477
|
+
parent.children.retain(|c| c != &doc.id);
|
|
478
|
+
if parent.children.len() != before {
|
|
479
|
+
write_doc(&handoff, &parent)?;
|
|
480
|
+
}
|
|
481
|
+
} else {
|
|
482
|
+
warnings.push(format!("Parent document not found: {parent_id}"));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
for child_id in &doc.children {
|
|
487
|
+
if let Some(mut child) = find_doc_by_id(&handoff, child_id)? {
|
|
488
|
+
child.parent_id = None;
|
|
489
|
+
write_doc(&handoff, &child)?;
|
|
490
|
+
} else {
|
|
491
|
+
warnings.push(format!("Child document not found: {child_id}"));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
crate::context::doc_corpus_cache()
|
|
496
|
+
.lock()
|
|
497
|
+
.expect("cache")
|
|
498
|
+
.increment_generation();
|
|
499
|
+
|
|
500
|
+
Ok(to_json(&json!({
|
|
501
|
+
"deleted": true,
|
|
502
|
+
"doc_id": doc.id,
|
|
503
|
+
"section_count": doc.sections.len(),
|
|
504
|
+
"warnings": warnings,
|
|
505
|
+
})))
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/// `handoff_doc_reassemble` — read a document's (by `doc_id` or `slug`)
|
|
509
|
+
/// original Markdown body directly from `_doc.<slug>.md` (v5: the `.md` file
|
|
510
|
+
/// already *is* the original document, restoring BOM/frontmatter is the only
|
|
511
|
+
/// reassembly step left), and detect drift (the body's current content hash
|
|
512
|
+
/// no longer matches the recorded `content_hash` — e.g. edited directly
|
|
513
|
+
/// outside `doc_save`). See `wiki/130-document-management.md` §5.5.
|
|
514
|
+
pub fn handle_doc_reassemble(arguments: &Value) -> Result<String> {
|
|
515
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
516
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
517
|
+
|
|
518
|
+
let doc_id = arguments
|
|
519
|
+
.get("doc_id")
|
|
520
|
+
.and_then(|v| v.as_str())
|
|
521
|
+
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
522
|
+
|
|
523
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
524
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
525
|
+
|
|
526
|
+
let stripped_body = read_doc_body(&handoff, &doc.slug)?
|
|
527
|
+
.ok_or_else(|| anyhow::anyhow!("Document body file missing for slug '{}'", doc.slug))?;
|
|
528
|
+
let drifted = lexsim::content_hash(&stripped_body) != doc.content_hash;
|
|
529
|
+
|
|
530
|
+
let body = read_full_body(&handoff, &doc)?.unwrap_or_default();
|
|
531
|
+
|
|
532
|
+
let output_path = arguments.get("output_path").and_then(|v| v.as_str());
|
|
533
|
+
let mut out = json!({
|
|
534
|
+
"doc_id": doc.id,
|
|
535
|
+
"body": body,
|
|
536
|
+
"drifted": drifted,
|
|
537
|
+
});
|
|
538
|
+
if let Some(path) = output_path {
|
|
539
|
+
std::fs::write(path, &body)
|
|
540
|
+
.with_context(|| format!("Failed to write reassembled document to {path}"))?;
|
|
541
|
+
out["output_path"] = json!(path);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
Ok(to_json(&out))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/// `handoff_doc_tree` — traverse a document's family tree starting from
|
|
548
|
+
/// `doc_id`: its immediate parent (if any) plus `depth` levels of children,
|
|
549
|
+
/// optionally including its `related` (semantic) links. See
|
|
550
|
+
/// `wiki/130-document-management.md` §5.6.
|
|
551
|
+
pub fn handle_doc_tree(arguments: &Value) -> Result<String> {
|
|
552
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
553
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
554
|
+
|
|
555
|
+
let doc_id = arguments
|
|
556
|
+
.get("doc_id")
|
|
557
|
+
.and_then(|v| v.as_str())
|
|
558
|
+
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
559
|
+
|
|
560
|
+
let depth = arguments
|
|
561
|
+
.get("depth")
|
|
562
|
+
.and_then(|v| v.as_u64())
|
|
563
|
+
.unwrap_or(DEFAULT_TREE_DEPTH);
|
|
564
|
+
|
|
565
|
+
let include_related = arguments
|
|
566
|
+
.get("include_related")
|
|
567
|
+
.and_then(|v| v.as_bool())
|
|
568
|
+
.unwrap_or(false);
|
|
569
|
+
|
|
570
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
571
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
572
|
+
|
|
573
|
+
let mut tree = doc_tree_node_json(&handoff, &doc, include_related)?;
|
|
574
|
+
|
|
575
|
+
let parent = match &doc.parent_id {
|
|
576
|
+
Some(parent_id) => find_doc_by_id(&handoff, parent_id)?.map(|p| doc_tree_summary_json(&p)),
|
|
577
|
+
None => None,
|
|
578
|
+
};
|
|
579
|
+
tree["parent"] = parent.unwrap_or(Value::Null);
|
|
580
|
+
|
|
581
|
+
tree["children"] = json!(doc_tree_children(
|
|
582
|
+
&handoff,
|
|
583
|
+
&doc.children,
|
|
584
|
+
depth,
|
|
585
|
+
include_related
|
|
586
|
+
)?);
|
|
587
|
+
|
|
588
|
+
Ok(to_json(&tree))
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/// Default depth for `handoff_doc_tree` when `depth` is omitted (spec §5.6).
|
|
592
|
+
const DEFAULT_TREE_DEPTH: u64 = 2;
|
|
593
|
+
|
|
594
|
+
/// Recursively builds the `children` array for [`handle_doc_tree`], descending
|
|
595
|
+
/// up to `depth` levels. Missing child documents (broken link) are skipped
|
|
596
|
+
/// rather than erroring the whole traversal.
|
|
597
|
+
fn doc_tree_children(
|
|
598
|
+
handoff: &Path,
|
|
599
|
+
child_ids: &[String],
|
|
600
|
+
depth: u64,
|
|
601
|
+
include_related: bool,
|
|
602
|
+
) -> Result<Vec<Value>> {
|
|
603
|
+
if depth == 0 {
|
|
604
|
+
return Ok(Vec::new());
|
|
605
|
+
}
|
|
606
|
+
let mut out = Vec::with_capacity(child_ids.len());
|
|
607
|
+
for child_id in child_ids {
|
|
608
|
+
let Some(child) = find_doc_by_id(handoff, child_id)? else {
|
|
609
|
+
continue;
|
|
610
|
+
};
|
|
611
|
+
let mut node = doc_tree_node_json(handoff, &child, include_related)?;
|
|
612
|
+
node["children"] = json!(doc_tree_children(
|
|
613
|
+
handoff,
|
|
614
|
+
&child.children,
|
|
615
|
+
depth - 1,
|
|
616
|
+
include_related
|
|
617
|
+
)?);
|
|
618
|
+
out.push(node);
|
|
619
|
+
}
|
|
620
|
+
Ok(out)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/// Compact `{id, title, doc_type}` summary used for `parent` and family-tree
|
|
624
|
+
/// list entries (`related`) in `doc_tree`'s output.
|
|
625
|
+
fn doc_tree_summary_json(doc: &DocMetadata) -> Value {
|
|
626
|
+
json!({
|
|
627
|
+
"id": doc.id,
|
|
628
|
+
"title": doc.title,
|
|
629
|
+
"doc_type": doc.doc_type,
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/// One node in the `doc_tree` output: id/title/doc_type plus (optionally)
|
|
634
|
+
/// `related` summaries (each resolved to `{id, rel, title}`). `children` is
|
|
635
|
+
/// populated by the caller afterward.
|
|
636
|
+
fn doc_tree_node_json(handoff: &Path, doc: &DocMetadata, include_related: bool) -> Result<Value> {
|
|
637
|
+
let mut related: Vec<Value> = Vec::new();
|
|
638
|
+
if include_related {
|
|
639
|
+
for r in &doc.related {
|
|
640
|
+
// related entries may point cross-tree/cross-project ids that
|
|
641
|
+
// don't resolve locally; that lookup is deferred to a future
|
|
642
|
+
// resolver (spec §10.3) — for now a related id that can't be
|
|
643
|
+
// read from this project's docs/ is a no-op skip, matching the
|
|
644
|
+
// same lenient policy as read_all_docs.
|
|
645
|
+
let Some(target) = find_doc_by_id(handoff, &r.id)? else {
|
|
646
|
+
continue;
|
|
647
|
+
};
|
|
648
|
+
related.push(json!({ "id": r.id, "rel": r.rel, "title": target.title }));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
Ok(json!({
|
|
652
|
+
"id": doc.id,
|
|
653
|
+
"title": doc.title,
|
|
654
|
+
"doc_type": doc.doc_type,
|
|
655
|
+
"children": [],
|
|
656
|
+
"related": related,
|
|
657
|
+
}))
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/// Recomputes `Verification.status` from its items (wiki/140-verification-matrix.md
|
|
661
|
+
/// §3.3): all pending -> "pending"; all verified/skipped -> "verified";
|
|
662
|
+
/// otherwise -> "in_review".
|
|
663
|
+
fn recompute_verification_status(items: &[VerificationItem]) -> String {
|
|
664
|
+
if items.iter().all(|i| i.status == "pending") {
|
|
665
|
+
"pending".to_string()
|
|
666
|
+
} else if items
|
|
667
|
+
.iter()
|
|
668
|
+
.all(|i| i.status == "verified" || i.status == "skipped")
|
|
669
|
+
{
|
|
670
|
+
"verified".to_string()
|
|
671
|
+
} else {
|
|
672
|
+
"in_review".to_string()
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/// Parses a JSON array of `{path, lines?, label?}` objects into `CodeRef`s.
|
|
677
|
+
/// Entries missing the required `path` are skipped.
|
|
678
|
+
fn code_refs_from_value(v: &Value) -> Vec<CodeRef> {
|
|
679
|
+
v.as_array()
|
|
680
|
+
.map(|arr| {
|
|
681
|
+
arr.iter()
|
|
682
|
+
.filter_map(|r| {
|
|
683
|
+
let path = r.get("path").and_then(|v| v.as_str())?.to_string();
|
|
684
|
+
Some(CodeRef {
|
|
685
|
+
path,
|
|
686
|
+
lines: r.get("lines").and_then(|v| v.as_str()).map(str::to_string),
|
|
687
|
+
label: r.get("label").and_then(|v| v.as_str()).map(str::to_string),
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
.collect()
|
|
691
|
+
})
|
|
692
|
+
.unwrap_or_default()
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/// Summary counts used by both `doc_verify`'s mutation response and
|
|
696
|
+
/// `doc_verify_status`'s `progress` block.
|
|
697
|
+
struct VerificationCounts {
|
|
698
|
+
checked: usize,
|
|
699
|
+
skipped: usize,
|
|
700
|
+
pending: usize,
|
|
701
|
+
total: usize,
|
|
702
|
+
stale: usize,
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
fn count_verification(doc: &DocMetadata, v: &Verification) -> VerificationCounts {
|
|
706
|
+
let checked = v.items.iter().filter(|i| i.status == "verified").count();
|
|
707
|
+
let skipped = v.items.iter().filter(|i| i.status == "skipped").count();
|
|
708
|
+
let pending = v.items.iter().filter(|i| i.status == "pending").count();
|
|
709
|
+
let stale = v.items.iter().filter(|i| item_is_stale(doc, i)).count();
|
|
710
|
+
VerificationCounts {
|
|
711
|
+
checked,
|
|
712
|
+
skipped,
|
|
713
|
+
pending,
|
|
714
|
+
total: v.items.len(),
|
|
715
|
+
stale,
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/// An item is stale when it was verified at a content_hash that no longer
|
|
720
|
+
/// matches its section's current content_hash (spec §3.5) — items never
|
|
721
|
+
/// verified (`content_hash_at_verify: None`) are never stale, and items whose
|
|
722
|
+
/// section has been removed (sync should have dropped them, but be
|
|
723
|
+
/// defensive) are treated as stale so drift is never silently hidden.
|
|
724
|
+
fn item_is_stale(doc: &DocMetadata, item: &VerificationItem) -> bool {
|
|
725
|
+
let Some(hash_at_verify) = &item.content_hash_at_verify else {
|
|
726
|
+
return false;
|
|
727
|
+
};
|
|
728
|
+
match doc.sections.iter().find(|s| s.seq == item.fragment_seq) {
|
|
729
|
+
Some(section) => §ion.content_hash != hash_at_verify,
|
|
730
|
+
None => true,
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/// `handoff_doc_verify` — generate/check/skip/sync/set_refs a document's
|
|
735
|
+
/// verification matrix (wiki/140-verification-matrix.md §4.1).
|
|
736
|
+
pub fn handle_doc_verify(arguments: &Value) -> Result<String> {
|
|
737
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
738
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
739
|
+
|
|
740
|
+
let doc_id = arguments
|
|
741
|
+
.get("doc_id")
|
|
742
|
+
.and_then(|v| v.as_str())
|
|
743
|
+
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
744
|
+
let action = arguments
|
|
745
|
+
.get("action")
|
|
746
|
+
.and_then(|v| v.as_str())
|
|
747
|
+
.ok_or_else(|| anyhow::anyhow!("'action' is required"))?;
|
|
748
|
+
|
|
749
|
+
let mut doc = resolve_doc(&handoff, doc_id)?
|
|
750
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
751
|
+
|
|
752
|
+
let now = chrono::Utc::now().to_rfc3339();
|
|
753
|
+
|
|
754
|
+
match action {
|
|
755
|
+
"generate" => {
|
|
756
|
+
if doc.verification.is_some() {
|
|
757
|
+
anyhow::bail!(
|
|
758
|
+
"Verification matrix already exists for document {doc_id}; use action='sync' to re-sync it instead"
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
let skip_seqs: Vec<usize> = arguments
|
|
762
|
+
.get("skip_seqs")
|
|
763
|
+
.and_then(|v| v.as_array())
|
|
764
|
+
.map(|arr| {
|
|
765
|
+
arr.iter()
|
|
766
|
+
.filter_map(|v| v.as_u64())
|
|
767
|
+
.map(|n| n as usize)
|
|
768
|
+
.collect()
|
|
769
|
+
})
|
|
770
|
+
.unwrap_or_default();
|
|
771
|
+
|
|
772
|
+
let items: Vec<VerificationItem> = doc
|
|
773
|
+
.sections
|
|
774
|
+
.iter()
|
|
775
|
+
.map(|s| VerificationItem {
|
|
776
|
+
fragment_seq: s.seq,
|
|
777
|
+
heading: s.heading.clone(),
|
|
778
|
+
status: if skip_seqs.contains(&s.seq) {
|
|
779
|
+
"skipped".to_string()
|
|
780
|
+
} else {
|
|
781
|
+
"pending".to_string()
|
|
782
|
+
},
|
|
783
|
+
impl_refs: Vec::new(),
|
|
784
|
+
test_refs: Vec::new(),
|
|
785
|
+
reviewer: None,
|
|
786
|
+
verified_at: None,
|
|
787
|
+
notes: String::new(),
|
|
788
|
+
content_hash_at_verify: None,
|
|
789
|
+
})
|
|
790
|
+
.collect();
|
|
791
|
+
|
|
792
|
+
doc.verification = Some(Verification {
|
|
793
|
+
status: recompute_verification_status(&items),
|
|
794
|
+
created_at: now.clone(),
|
|
795
|
+
updated_at: now,
|
|
796
|
+
items,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
"check" => {
|
|
800
|
+
let fragment_seq = required_fragment_seq(arguments)?;
|
|
801
|
+
let reviewer = arguments
|
|
802
|
+
.get("reviewer")
|
|
803
|
+
.and_then(|v| v.as_str())
|
|
804
|
+
.map(str::to_string);
|
|
805
|
+
let notes = arguments
|
|
806
|
+
.get("notes")
|
|
807
|
+
.and_then(|v| v.as_str())
|
|
808
|
+
.map(str::to_string);
|
|
809
|
+
let section_hash = doc
|
|
810
|
+
.sections
|
|
811
|
+
.iter()
|
|
812
|
+
.find(|s| s.seq == fragment_seq)
|
|
813
|
+
.map(|s| s.content_hash.clone());
|
|
814
|
+
|
|
815
|
+
let v = verification_mut(&mut doc, doc_id)?;
|
|
816
|
+
let item = find_item_mut(v, fragment_seq, doc_id)?;
|
|
817
|
+
item.status = "verified".to_string();
|
|
818
|
+
item.verified_at = Some(now.clone());
|
|
819
|
+
if reviewer.is_some() {
|
|
820
|
+
item.reviewer = reviewer;
|
|
821
|
+
}
|
|
822
|
+
if let Some(notes) = notes {
|
|
823
|
+
item.notes = notes;
|
|
824
|
+
}
|
|
825
|
+
item.content_hash_at_verify = section_hash;
|
|
826
|
+
v.updated_at = now.clone();
|
|
827
|
+
v.status = recompute_verification_status(&v.items);
|
|
828
|
+
}
|
|
829
|
+
"skip" => {
|
|
830
|
+
let fragment_seq = required_fragment_seq(arguments)?;
|
|
831
|
+
let v = verification_mut(&mut doc, doc_id)?;
|
|
832
|
+
let item = find_item_mut(v, fragment_seq, doc_id)?;
|
|
833
|
+
item.status = "skipped".to_string();
|
|
834
|
+
v.updated_at = now.clone();
|
|
835
|
+
v.status = recompute_verification_status(&v.items);
|
|
836
|
+
}
|
|
837
|
+
"sync" => {
|
|
838
|
+
let sections = doc.sections.clone();
|
|
839
|
+
let v = verification_mut(&mut doc, doc_id)?;
|
|
840
|
+
let current_seqs: std::collections::HashSet<usize> =
|
|
841
|
+
sections.iter().map(|s| s.seq).collect();
|
|
842
|
+
v.items.retain(|i| current_seqs.contains(&i.fragment_seq));
|
|
843
|
+
let existing_seqs: std::collections::HashSet<usize> =
|
|
844
|
+
v.items.iter().map(|i| i.fragment_seq).collect();
|
|
845
|
+
for s in §ions {
|
|
846
|
+
if !existing_seqs.contains(&s.seq) {
|
|
847
|
+
v.items.push(VerificationItem {
|
|
848
|
+
fragment_seq: s.seq,
|
|
849
|
+
heading: s.heading.clone(),
|
|
850
|
+
status: "pending".to_string(),
|
|
851
|
+
impl_refs: Vec::new(),
|
|
852
|
+
test_refs: Vec::new(),
|
|
853
|
+
reviewer: None,
|
|
854
|
+
verified_at: None,
|
|
855
|
+
notes: String::new(),
|
|
856
|
+
content_hash_at_verify: None,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
v.items.sort_by_key(|i| i.fragment_seq);
|
|
861
|
+
v.updated_at = now.clone();
|
|
862
|
+
v.status = recompute_verification_status(&v.items);
|
|
863
|
+
}
|
|
864
|
+
"set_refs" => {
|
|
865
|
+
let fragment_seq = required_fragment_seq(arguments)?;
|
|
866
|
+
let impl_refs = arguments.get("impl_refs").map(code_refs_from_value);
|
|
867
|
+
let test_refs = arguments.get("test_refs").map(code_refs_from_value);
|
|
868
|
+
|
|
869
|
+
let v = verification_mut(&mut doc, doc_id)?;
|
|
870
|
+
let item = find_item_mut(v, fragment_seq, doc_id)?;
|
|
871
|
+
if let Some(impl_refs) = impl_refs {
|
|
872
|
+
item.impl_refs = impl_refs;
|
|
873
|
+
}
|
|
874
|
+
if let Some(test_refs) = test_refs {
|
|
875
|
+
item.test_refs = test_refs;
|
|
876
|
+
}
|
|
877
|
+
v.updated_at = now.clone();
|
|
878
|
+
v.status = recompute_verification_status(&v.items);
|
|
879
|
+
}
|
|
880
|
+
other => anyhow::bail!(
|
|
881
|
+
"Unknown action '{other}'; expected one of generate, check, skip, sync, set_refs"
|
|
882
|
+
),
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
write_doc(&handoff, &doc)?;
|
|
886
|
+
|
|
887
|
+
let v = doc
|
|
888
|
+
.verification
|
|
889
|
+
.as_ref()
|
|
890
|
+
.expect("verification was just set/mutated above");
|
|
891
|
+
let counts = count_verification(&doc, v);
|
|
892
|
+
|
|
893
|
+
Ok(to_json(&json!({
|
|
894
|
+
"doc_id": doc.id,
|
|
895
|
+
"verification_status": v.status,
|
|
896
|
+
"checked": counts.checked,
|
|
897
|
+
"skipped": counts.skipped,
|
|
898
|
+
"pending": counts.pending,
|
|
899
|
+
"total": counts.total,
|
|
900
|
+
"stale": counts.stale,
|
|
901
|
+
})))
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
fn required_fragment_seq(arguments: &Value) -> Result<usize> {
|
|
905
|
+
arguments
|
|
906
|
+
.get("fragment_seq")
|
|
907
|
+
.and_then(|v| v.as_u64())
|
|
908
|
+
.map(|n| n as usize)
|
|
909
|
+
.ok_or_else(|| anyhow::anyhow!("'fragment_seq' is required for this action"))
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
fn verification_mut<'a>(doc: &'a mut DocMetadata, doc_id: &str) -> Result<&'a mut Verification> {
|
|
913
|
+
doc.verification.as_mut().ok_or_else(|| {
|
|
914
|
+
anyhow::anyhow!(
|
|
915
|
+
"No verification matrix exists for document {doc_id}; use action='generate' first"
|
|
916
|
+
)
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
fn find_item_mut<'a>(
|
|
921
|
+
v: &'a mut Verification,
|
|
922
|
+
fragment_seq: usize,
|
|
923
|
+
doc_id: &str,
|
|
924
|
+
) -> Result<&'a mut VerificationItem> {
|
|
925
|
+
v.items
|
|
926
|
+
.iter_mut()
|
|
927
|
+
.find(|i| i.fragment_seq == fragment_seq)
|
|
928
|
+
.ok_or_else(|| {
|
|
929
|
+
anyhow::anyhow!(
|
|
930
|
+
"No verification item at fragment_seq={fragment_seq} for document {doc_id}"
|
|
931
|
+
)
|
|
932
|
+
})
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/// `handoff_doc_verify_status` — verification matrix summary + optional
|
|
936
|
+
/// per-item detail with stale detection (wiki/140-verification-matrix.md
|
|
937
|
+
/// §4.2).
|
|
938
|
+
pub fn handle_doc_verify_status(arguments: &Value) -> Result<String> {
|
|
939
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
940
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
941
|
+
|
|
942
|
+
let doc_id = arguments
|
|
943
|
+
.get("doc_id")
|
|
944
|
+
.and_then(|v| v.as_str())
|
|
945
|
+
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
946
|
+
let include_items = arguments
|
|
947
|
+
.get("include_items")
|
|
948
|
+
.and_then(|v| v.as_bool())
|
|
949
|
+
.unwrap_or(false);
|
|
950
|
+
|
|
951
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
952
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
953
|
+
|
|
954
|
+
let v = doc.verification.as_ref().ok_or_else(|| {
|
|
955
|
+
anyhow::anyhow!(
|
|
956
|
+
"No verification matrix exists for document {doc_id}; use handoff_doc_verify(action='generate') first"
|
|
957
|
+
)
|
|
958
|
+
})?;
|
|
959
|
+
|
|
960
|
+
let counts = count_verification(&doc, v);
|
|
961
|
+
let percentage = if counts.total == 0 {
|
|
962
|
+
0.0
|
|
963
|
+
} else {
|
|
964
|
+
(counts.checked + counts.skipped) as f64 / counts.total as f64 * 100.0
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
let mut out = json!({
|
|
968
|
+
"doc_id": doc.id,
|
|
969
|
+
"title": doc.title,
|
|
970
|
+
"verification_status": v.status,
|
|
971
|
+
"progress": {
|
|
972
|
+
"checked": counts.checked,
|
|
973
|
+
"skipped": counts.skipped,
|
|
974
|
+
"pending": counts.pending,
|
|
975
|
+
"total": counts.total,
|
|
976
|
+
"stale": counts.stale,
|
|
977
|
+
"percentage": percentage,
|
|
978
|
+
},
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
if include_items {
|
|
982
|
+
let items: Vec<Value> = v
|
|
983
|
+
.items
|
|
984
|
+
.iter()
|
|
985
|
+
.map(|i| {
|
|
986
|
+
json!({
|
|
987
|
+
"fragment_seq": i.fragment_seq,
|
|
988
|
+
"heading": i.heading,
|
|
989
|
+
"status": i.status,
|
|
990
|
+
"stale": item_is_stale(&doc, i),
|
|
991
|
+
"impl_refs": i.impl_refs,
|
|
992
|
+
"test_refs": i.test_refs,
|
|
993
|
+
"reviewer": i.reviewer,
|
|
994
|
+
"verified_at": i.verified_at,
|
|
995
|
+
"notes": i.notes,
|
|
996
|
+
})
|
|
997
|
+
})
|
|
998
|
+
.collect();
|
|
999
|
+
out["items"] = json!(items);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
Ok(to_json(&out))
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/// `handoff_doc_graph` — build a graph of every document in the project:
|
|
1006
|
+
/// `nodes[]` (one per document, with optional verification progress),
|
|
1007
|
+
/// `edges[]` (explicit parent_child/related links, plus implicit
|
|
1008
|
+
/// shared_task/shared_scope links when `include_implicit=true`), and
|
|
1009
|
+
/// `layers` (doc ids grouped by `doc_type`).
|
|
1010
|
+
pub fn handle_doc_graph(arguments: &Value) -> Result<String> {
|
|
1011
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
1012
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
1013
|
+
|
|
1014
|
+
let include_implicit = arguments
|
|
1015
|
+
.get("include_implicit")
|
|
1016
|
+
.and_then(|v| v.as_bool())
|
|
1017
|
+
.unwrap_or(true);
|
|
1018
|
+
let include_verification = arguments
|
|
1019
|
+
.get("include_verification")
|
|
1020
|
+
.and_then(|v| v.as_bool())
|
|
1021
|
+
.unwrap_or(false);
|
|
1022
|
+
|
|
1023
|
+
let docs = read_all_docs(&handoff)?;
|
|
1024
|
+
|
|
1025
|
+
let nodes: Vec<Value> = docs
|
|
1026
|
+
.iter()
|
|
1027
|
+
.map(|d| doc_graph_node_json(d, include_verification))
|
|
1028
|
+
.collect();
|
|
1029
|
+
|
|
1030
|
+
let mut edges = doc_graph_explicit_edges(&docs);
|
|
1031
|
+
if include_implicit {
|
|
1032
|
+
edges.extend(doc_graph_implicit_edges(&docs));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
let mut layers: std::collections::BTreeMap<String, Vec<String>> =
|
|
1036
|
+
std::collections::BTreeMap::new();
|
|
1037
|
+
for d in &docs {
|
|
1038
|
+
layers
|
|
1039
|
+
.entry(d.doc_type.clone())
|
|
1040
|
+
.or_default()
|
|
1041
|
+
.push(d.id.clone());
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
Ok(to_json(&json!({
|
|
1045
|
+
"nodes": nodes,
|
|
1046
|
+
"edges": edges,
|
|
1047
|
+
"layers": layers,
|
|
1048
|
+
})))
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/// Builds one `handoff_doc_graph` node: id/slug/title/doc_type/tags/task_ids
|
|
1052
|
+
/// /section_count/updated_at, plus `verification_progress` when requested
|
|
1053
|
+
/// (and the document has a verification matrix).
|
|
1054
|
+
fn doc_graph_node_json(doc: &DocMetadata, include_verification: bool) -> Value {
|
|
1055
|
+
let mut node = json!({
|
|
1056
|
+
"id": doc.id,
|
|
1057
|
+
"slug": doc.slug,
|
|
1058
|
+
"title": doc.title,
|
|
1059
|
+
"doc_type": doc.doc_type,
|
|
1060
|
+
"tags": doc.tags,
|
|
1061
|
+
"task_ids": doc.task_ids,
|
|
1062
|
+
"section_count": doc.sections.len(),
|
|
1063
|
+
"updated_at": doc.updated_at,
|
|
1064
|
+
});
|
|
1065
|
+
if include_verification {
|
|
1066
|
+
if let Some(v) = &doc.verification {
|
|
1067
|
+
let total = v.items.len();
|
|
1068
|
+
let verified = v.items.iter().filter(|i| i.status == "verified").count();
|
|
1069
|
+
node["verification_progress"] = json!({ "total": total, "verified": verified });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
node
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/// Explicit edges: `parent_id` (`type="parent_child"`, `direction="down"`,
|
|
1076
|
+
/// from=parent to=child) and `related[]` (`type=<rel>`,
|
|
1077
|
+
/// `direction="forward"`, from=this doc to=related target). Related entries
|
|
1078
|
+
/// pointing at an id not present in `docs` are still emitted — the graph
|
|
1079
|
+
/// consumer is expected to render dangling links, not silently drop them.
|
|
1080
|
+
fn doc_graph_explicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
|
|
1081
|
+
let mut edges = Vec::new();
|
|
1082
|
+
for d in docs {
|
|
1083
|
+
if let Some(parent_id) = &d.parent_id {
|
|
1084
|
+
edges.push(json!({
|
|
1085
|
+
"from": parent_id,
|
|
1086
|
+
"to": d.id,
|
|
1087
|
+
"type": "parent_child",
|
|
1088
|
+
"direction": "down",
|
|
1089
|
+
}));
|
|
1090
|
+
}
|
|
1091
|
+
for r in &d.related {
|
|
1092
|
+
edges.push(json!({
|
|
1093
|
+
"from": d.id,
|
|
1094
|
+
"to": r.id,
|
|
1095
|
+
"type": r.rel,
|
|
1096
|
+
"direction": "forward",
|
|
1097
|
+
}));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
edges
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/// Implicit edges: `shared_task` (two documents sharing at least one
|
|
1104
|
+
/// `task_ids` entry — `task_ids` on the edge lists every id shared, not just
|
|
1105
|
+
/// the first) and `shared_scope` (two documents sharing at least one
|
|
1106
|
+
/// `scope_paths` entry). Both are unordered/undirected pairs, emitted once
|
|
1107
|
+
/// per pair (i<j) to avoid duplicating the same relationship in both
|
|
1108
|
+
/// directions.
|
|
1109
|
+
fn doc_graph_implicit_edges(docs: &[DocMetadata]) -> Vec<Value> {
|
|
1110
|
+
let mut edges = Vec::new();
|
|
1111
|
+
for i in 0..docs.len() {
|
|
1112
|
+
for j in (i + 1)..docs.len() {
|
|
1113
|
+
let a = &docs[i];
|
|
1114
|
+
let b = &docs[j];
|
|
1115
|
+
|
|
1116
|
+
let shared_tasks: Vec<String> = a
|
|
1117
|
+
.task_ids
|
|
1118
|
+
.iter()
|
|
1119
|
+
.filter(|t| b.task_ids.contains(t))
|
|
1120
|
+
.cloned()
|
|
1121
|
+
.collect();
|
|
1122
|
+
if !shared_tasks.is_empty() {
|
|
1123
|
+
edges.push(json!({
|
|
1124
|
+
"from": a.id,
|
|
1125
|
+
"to": b.id,
|
|
1126
|
+
"type": "shared_task",
|
|
1127
|
+
"task_ids": shared_tasks,
|
|
1128
|
+
}));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
let shares_scope = a.scope_paths.iter().any(|p| b.scope_paths.contains(p));
|
|
1132
|
+
if shares_scope {
|
|
1133
|
+
edges.push(json!({
|
|
1134
|
+
"from": a.id,
|
|
1135
|
+
"to": b.id,
|
|
1136
|
+
"type": "shared_scope",
|
|
1137
|
+
}));
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
edges
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/// One entry in a `handoff_doc_trace` `chain[]`/`branches[].docs[]`:
|
|
1145
|
+
/// `{id, title, doc_type, rel}`. `rel` describes how this doc relates to the
|
|
1146
|
+
/// previous entry in the chain ("parent", "child", or the `related[].rel`
|
|
1147
|
+
/// value for a related-doc detour); `None` for the trace's starting doc.
|
|
1148
|
+
fn doc_trace_item_json(doc: &DocMetadata, rel: Option<&str>) -> Value {
|
|
1149
|
+
json!({
|
|
1150
|
+
"id": doc.id,
|
|
1151
|
+
"title": doc.title,
|
|
1152
|
+
"doc_type": doc.doc_type,
|
|
1153
|
+
"rel": rel,
|
|
1154
|
+
})
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/// Walks the child->parent chain starting at `doc` (exclusive — `doc` itself
|
|
1158
|
+
/// is not included), ordered from the immediate parent up to the root.
|
|
1159
|
+
/// `visited` prevents infinite loops on a cyclic `parent_id` graph; a doc
|
|
1160
|
+
/// already visited (including `doc` itself) stops the walk rather than
|
|
1161
|
+
/// erroring.
|
|
1162
|
+
fn doc_trace_walk_up(
|
|
1163
|
+
handoff: &Path,
|
|
1164
|
+
doc: &DocMetadata,
|
|
1165
|
+
visited: &mut std::collections::HashSet<String>,
|
|
1166
|
+
) -> Result<Vec<Value>> {
|
|
1167
|
+
let mut out = Vec::new();
|
|
1168
|
+
let mut current = doc.clone();
|
|
1169
|
+
while let Some(parent_id) = current.parent_id.clone() {
|
|
1170
|
+
if visited.contains(&parent_id) {
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1173
|
+
let Some(parent) = find_doc_by_id(handoff, &parent_id)? else {
|
|
1174
|
+
break;
|
|
1175
|
+
};
|
|
1176
|
+
visited.insert(parent.id.clone());
|
|
1177
|
+
out.push(doc_trace_item_json(&parent, Some("parent")));
|
|
1178
|
+
current = parent;
|
|
1179
|
+
}
|
|
1180
|
+
out.reverse();
|
|
1181
|
+
Ok(out)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/// Recursively walks parent->children (DFS) starting at `doc` (exclusive).
|
|
1185
|
+
/// Returns the primary descendant chain (first child at each level) plus any
|
|
1186
|
+
/// `branches` recorded for multi-child forks. `visited` prevents infinite
|
|
1187
|
+
/// loops on a cyclic `children` graph.
|
|
1188
|
+
fn doc_trace_walk_down(
|
|
1189
|
+
handoff: &Path,
|
|
1190
|
+
doc: &DocMetadata,
|
|
1191
|
+
visited: &mut std::collections::HashSet<String>,
|
|
1192
|
+
branches: &mut Vec<Value>,
|
|
1193
|
+
) -> Result<Vec<Value>> {
|
|
1194
|
+
let mut children = Vec::new();
|
|
1195
|
+
for child_id in &doc.children {
|
|
1196
|
+
if visited.contains(child_id) {
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
if let Some(child) = find_doc_by_id(handoff, child_id)? {
|
|
1200
|
+
children.push(child);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if children.is_empty() {
|
|
1205
|
+
return Ok(Vec::new());
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Fork detection: more than one live (non-visited, resolvable) child at
|
|
1209
|
+
// this level. Every child's own sub-chain is recorded under `branches`;
|
|
1210
|
+
// the first child's sub-chain also becomes the primary continuation of
|
|
1211
|
+
// the returned chain, so a single-child level still reads as a plain
|
|
1212
|
+
// linear chain.
|
|
1213
|
+
let is_fork = children.len() > 1;
|
|
1214
|
+
let mut primary_chain = Vec::new();
|
|
1215
|
+
|
|
1216
|
+
for (idx, child) in children.iter().enumerate() {
|
|
1217
|
+
if visited.contains(&child.id) {
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
visited.insert(child.id.clone());
|
|
1221
|
+
let mut sub_chain = vec![doc_trace_item_json(child, Some("child"))];
|
|
1222
|
+
sub_chain.extend(doc_trace_walk_down(handoff, child, visited, branches)?);
|
|
1223
|
+
|
|
1224
|
+
if is_fork {
|
|
1225
|
+
branches.push(json!({
|
|
1226
|
+
"fork_from": doc.id,
|
|
1227
|
+
"docs": sub_chain,
|
|
1228
|
+
}));
|
|
1229
|
+
}
|
|
1230
|
+
if idx == 0 {
|
|
1231
|
+
primary_chain = sub_chain;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
Ok(primary_chain)
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/// Appends `related` (implements/references/etc.) detours for every document
|
|
1239
|
+
/// already present in `chain` (by id), skipping any related id already
|
|
1240
|
+
/// visited. Related docs are appended once, immediately, as a flat list — a
|
|
1241
|
+
/// "detour" from the main chain rather than a further recursive expansion.
|
|
1242
|
+
fn doc_trace_related_detours(
|
|
1243
|
+
handoff: &Path,
|
|
1244
|
+
chain_doc_ids: &[String],
|
|
1245
|
+
visited: &mut std::collections::HashSet<String>,
|
|
1246
|
+
) -> Result<Vec<Value>> {
|
|
1247
|
+
let mut out = Vec::new();
|
|
1248
|
+
for doc_id in chain_doc_ids {
|
|
1249
|
+
let Some(doc) = find_doc_by_id(handoff, doc_id)? else {
|
|
1250
|
+
continue;
|
|
1251
|
+
};
|
|
1252
|
+
for r in &doc.related {
|
|
1253
|
+
if visited.contains(&r.id) {
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
let Some(target) = find_doc_by_id(handoff, &r.id)? else {
|
|
1257
|
+
continue;
|
|
1258
|
+
};
|
|
1259
|
+
visited.insert(target.id.clone());
|
|
1260
|
+
out.push(doc_trace_item_json(&target, Some(&r.rel)));
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
Ok(out)
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/// `handoff_doc_trace` — trace a document's family-tree lineage: `up` (walk
|
|
1267
|
+
/// child->parent), `down` (walk parent->children, DFS), or `both` (merge the
|
|
1268
|
+
/// up chain + the target + the down chain). `related` docs encountered along
|
|
1269
|
+
/// the primary chain are appended as detour entries. Multi-child forks in the
|
|
1270
|
+
/// `down` direction are additionally reported in `branches[]`.
|
|
1271
|
+
pub fn handle_doc_trace(arguments: &Value) -> Result<String> {
|
|
1272
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
1273
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
1274
|
+
|
|
1275
|
+
let doc_id = arguments
|
|
1276
|
+
.get("doc_id")
|
|
1277
|
+
.and_then(|v| v.as_str())
|
|
1278
|
+
.ok_or_else(|| anyhow::anyhow!("'doc_id' is required"))?;
|
|
1279
|
+
let direction = arguments
|
|
1280
|
+
.get("direction")
|
|
1281
|
+
.and_then(|v| v.as_str())
|
|
1282
|
+
.unwrap_or("both");
|
|
1283
|
+
|
|
1284
|
+
let doc = resolve_doc(&handoff, doc_id)?
|
|
1285
|
+
.ok_or_else(|| anyhow::anyhow!("Document not found: {doc_id}"))?;
|
|
1286
|
+
|
|
1287
|
+
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
1288
|
+
visited.insert(doc.id.clone());
|
|
1289
|
+
|
|
1290
|
+
let mut branches: Vec<Value> = Vec::new();
|
|
1291
|
+
let mut chain: Vec<Value> = Vec::new();
|
|
1292
|
+
|
|
1293
|
+
if direction == "up" || direction == "both" {
|
|
1294
|
+
chain.extend(doc_trace_walk_up(&handoff, &doc, &mut visited)?);
|
|
1295
|
+
}
|
|
1296
|
+
chain.push(doc_trace_item_json(&doc, None));
|
|
1297
|
+
if direction == "down" || direction == "both" {
|
|
1298
|
+
chain.extend(doc_trace_walk_down(
|
|
1299
|
+
&handoff,
|
|
1300
|
+
&doc,
|
|
1301
|
+
&mut visited,
|
|
1302
|
+
&mut branches,
|
|
1303
|
+
)?);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
let chain_doc_ids: Vec<String> = chain
|
|
1307
|
+
.iter()
|
|
1308
|
+
.filter_map(|v| v["id"].as_str().map(str::to_string))
|
|
1309
|
+
.collect();
|
|
1310
|
+
chain.extend(doc_trace_related_detours(
|
|
1311
|
+
&handoff,
|
|
1312
|
+
&chain_doc_ids,
|
|
1313
|
+
&mut visited,
|
|
1314
|
+
)?);
|
|
1315
|
+
|
|
1316
|
+
Ok(to_json(&json!({
|
|
1317
|
+
"chain": chain,
|
|
1318
|
+
"branches": branches,
|
|
1319
|
+
})))
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
fn doc_metadata_json(doc: &DocMetadata) -> Value {
|
|
1323
|
+
json!({
|
|
1324
|
+
"id": doc.id,
|
|
1325
|
+
"slug": doc.slug,
|
|
1326
|
+
"title": doc.title,
|
|
1327
|
+
"doc_type": doc.doc_type,
|
|
1328
|
+
"tags": doc.tags,
|
|
1329
|
+
"scope_paths": doc.scope_paths,
|
|
1330
|
+
"parent_id": doc.parent_id,
|
|
1331
|
+
"children": doc.children,
|
|
1332
|
+
"related": doc.related,
|
|
1333
|
+
"auto_inject": doc.auto_inject,
|
|
1334
|
+
"task_ids": doc.task_ids,
|
|
1335
|
+
"has_bom": doc.has_bom,
|
|
1336
|
+
"line_ending": doc.line_ending,
|
|
1337
|
+
"sections": doc.sections,
|
|
1338
|
+
"section_count": doc.sections.len(),
|
|
1339
|
+
"created_at": doc.created_at,
|
|
1340
|
+
"updated_at": doc.updated_at,
|
|
1341
|
+
"content_hash": doc.content_hash,
|
|
1342
|
+
})
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/// Read a `&[String]` from a JSON string-array value (missing/non-array →
|
|
1346
|
+
/// empty).
|
|
1347
|
+
fn string_array_value(v: &Value) -> Vec<String> {
|
|
1348
|
+
v.as_array()
|
|
1349
|
+
.map(|arr| {
|
|
1350
|
+
arr.iter()
|
|
1351
|
+
.filter_map(|v| v.as_str())
|
|
1352
|
+
.map(str::to_string)
|
|
1353
|
+
.collect()
|
|
1354
|
+
})
|
|
1355
|
+
.unwrap_or_default()
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
fn to_json(v: &Value) -> String {
|
|
1359
|
+
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
#[cfg(test)]
|
|
1363
|
+
mod graph_tests {
|
|
1364
|
+
use super::*;
|
|
1365
|
+
|
|
1366
|
+
fn doc(id: &str, slug: &str, doc_type: &str) -> DocMetadata {
|
|
1367
|
+
DocMetadata::new(
|
|
1368
|
+
id.to_string(),
|
|
1369
|
+
slug.to_string(),
|
|
1370
|
+
format!("Title {id}"),
|
|
1371
|
+
doc_type.to_string(),
|
|
1372
|
+
"2026-07-12T00:00:00Z".to_string(),
|
|
1373
|
+
)
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
#[test]
|
|
1377
|
+
fn explicit_edges_include_parent_child_and_related() {
|
|
1378
|
+
let mut parent = doc("doc-1", "parent", "spec");
|
|
1379
|
+
let mut child = doc("doc-2", "child", "design");
|
|
1380
|
+
child.parent_id = Some("doc-1".to_string());
|
|
1381
|
+
parent.children = vec!["doc-2".to_string()];
|
|
1382
|
+
child.related.push(DocRelation {
|
|
1383
|
+
id: "doc-3".to_string(),
|
|
1384
|
+
rel: "implements".to_string(),
|
|
1385
|
+
});
|
|
1386
|
+
let other = doc("doc-3", "other", "note");
|
|
1387
|
+
|
|
1388
|
+
let docs = vec![parent, child, other];
|
|
1389
|
+
let edges = doc_graph_explicit_edges(&docs);
|
|
1390
|
+
|
|
1391
|
+
assert!(edges.iter().any(|e| e["type"] == "parent_child"
|
|
1392
|
+
&& e["from"] == "doc-1"
|
|
1393
|
+
&& e["to"] == "doc-2"
|
|
1394
|
+
&& e["direction"] == "down"));
|
|
1395
|
+
assert!(edges.iter().any(|e| e["type"] == "implements"
|
|
1396
|
+
&& e["from"] == "doc-2"
|
|
1397
|
+
&& e["to"] == "doc-3"
|
|
1398
|
+
&& e["direction"] == "forward"));
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
#[test]
|
|
1402
|
+
fn implicit_edges_detect_shared_task_ids() {
|
|
1403
|
+
let mut a = doc("doc-1", "a", "spec");
|
|
1404
|
+
let mut b = doc("doc-2", "b", "spec");
|
|
1405
|
+
a.task_ids = vec!["t-1".to_string(), "t-2".to_string()];
|
|
1406
|
+
b.task_ids = vec!["t-2".to_string(), "t-3".to_string()];
|
|
1407
|
+
let docs = vec![a, b];
|
|
1408
|
+
|
|
1409
|
+
let edges = doc_graph_implicit_edges(&docs);
|
|
1410
|
+
let shared_task_edge = edges
|
|
1411
|
+
.iter()
|
|
1412
|
+
.find(|e| e["type"] == "shared_task")
|
|
1413
|
+
.expect("shared_task edge must be generated");
|
|
1414
|
+
assert_eq!(shared_task_edge["from"], "doc-1");
|
|
1415
|
+
assert_eq!(shared_task_edge["to"], "doc-2");
|
|
1416
|
+
assert_eq!(shared_task_edge["task_ids"], json!(["t-2"]));
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
#[test]
|
|
1420
|
+
fn implicit_edges_detect_shared_scope_paths() {
|
|
1421
|
+
let mut a = doc("doc-1", "a", "spec");
|
|
1422
|
+
let mut b = doc("doc-2", "b", "spec");
|
|
1423
|
+
a.scope_paths = vec!["src/mcp/".to_string()];
|
|
1424
|
+
b.scope_paths = vec!["src/mcp/".to_string(), "src/storage/".to_string()];
|
|
1425
|
+
let docs = vec![a, b];
|
|
1426
|
+
|
|
1427
|
+
let edges = doc_graph_implicit_edges(&docs);
|
|
1428
|
+
assert!(edges
|
|
1429
|
+
.iter()
|
|
1430
|
+
.any(|e| e["type"] == "shared_scope" && e["from"] == "doc-1" && e["to"] == "doc-2"));
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
#[test]
|
|
1434
|
+
fn implicit_edges_absent_when_nothing_shared() {
|
|
1435
|
+
let a = doc("doc-1", "a", "spec");
|
|
1436
|
+
let b = doc("doc-2", "b", "spec");
|
|
1437
|
+
let docs = vec![a, b];
|
|
1438
|
+
|
|
1439
|
+
let edges = doc_graph_implicit_edges(&docs);
|
|
1440
|
+
assert!(edges.is_empty());
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
#[test]
|
|
1444
|
+
fn graph_node_json_includes_verification_progress_when_requested() {
|
|
1445
|
+
let mut d = doc("doc-1", "a", "spec");
|
|
1446
|
+
d.verification = Some(Verification {
|
|
1447
|
+
status: "in_review".to_string(),
|
|
1448
|
+
created_at: "2026-07-12T00:00:00Z".to_string(),
|
|
1449
|
+
updated_at: "2026-07-12T00:00:00Z".to_string(),
|
|
1450
|
+
items: vec![
|
|
1451
|
+
VerificationItem {
|
|
1452
|
+
fragment_seq: 0,
|
|
1453
|
+
heading: String::new(),
|
|
1454
|
+
status: "verified".to_string(),
|
|
1455
|
+
impl_refs: Vec::new(),
|
|
1456
|
+
test_refs: Vec::new(),
|
|
1457
|
+
reviewer: None,
|
|
1458
|
+
verified_at: None,
|
|
1459
|
+
notes: String::new(),
|
|
1460
|
+
content_hash_at_verify: None,
|
|
1461
|
+
},
|
|
1462
|
+
VerificationItem {
|
|
1463
|
+
fragment_seq: 1,
|
|
1464
|
+
heading: "H".to_string(),
|
|
1465
|
+
status: "pending".to_string(),
|
|
1466
|
+
impl_refs: Vec::new(),
|
|
1467
|
+
test_refs: Vec::new(),
|
|
1468
|
+
reviewer: None,
|
|
1469
|
+
verified_at: None,
|
|
1470
|
+
notes: String::new(),
|
|
1471
|
+
content_hash_at_verify: None,
|
|
1472
|
+
},
|
|
1473
|
+
],
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
let with_verification = doc_graph_node_json(&d, true);
|
|
1477
|
+
assert_eq!(
|
|
1478
|
+
with_verification["verification_progress"],
|
|
1479
|
+
json!({ "total": 2, "verified": 1 })
|
|
1480
|
+
);
|
|
1481
|
+
|
|
1482
|
+
let without_verification = doc_graph_node_json(&d, false);
|
|
1483
|
+
assert!(without_verification.get("verification_progress").is_none());
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
#[test]
|
|
1487
|
+
fn graph_node_json_omits_verification_progress_when_no_matrix() {
|
|
1488
|
+
let d = doc("doc-1", "a", "spec");
|
|
1489
|
+
let node = doc_graph_node_json(&d, true);
|
|
1490
|
+
assert!(node.get("verification_progress").is_none());
|
|
1491
|
+
}
|
|
1492
|
+
}
|