handoff-mcp-server 0.24.6 → 0.25.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 +29 -3
- package/Cargo.toml +4 -2
- package/README.md +23 -9
- package/package.json +1 -1
- package/skills/handoff-docs/SKILL.md +181 -9
- package/src/context/injection.rs +55 -18
- package/src/context/mod.rs +1 -1
- package/src/mcp/handlers/auto_schedule.rs +1 -13
- package/src/mcp/handlers/capacity.rs +1 -14
- package/src/mcp/handlers/config.rs +13 -0
- package/src/mcp/handlers/docs.rs +832 -76
- package/src/mcp/handlers/docs_query.rs +4 -4
- package/src/mcp/handlers/memory.rs +88 -13
- package/src/mcp/handlers/mod.rs +1 -0
- package/src/mcp/handlers/task_checklist.rs +17 -5
- package/src/mcp/tools.rs +28 -7
- package/src/storage/config.rs +217 -5
- package/src/storage/docs/frontmatter.rs +509 -0
- package/src/storage/docs/mod.rs +346 -108
- package/src/storage/docs/model.rs +186 -15
- package/src/storage/memory/mod.rs +1 -0
- package/src/storage/memory/model.rs +38 -7
|
@@ -333,9 +333,9 @@ pub fn handle_doc_query(arguments: &Value) -> Result<String> {
|
|
|
333
333
|
.map_err(|_| anyhow::anyhow!("doc corpus cache mutex poisoned"))?;
|
|
334
334
|
let corpus = cache.get_or_build_corpus(&doc_texts);
|
|
335
335
|
|
|
336
|
-
let mut query_tokens = lexsim::
|
|
336
|
+
let mut query_tokens = lexsim::tokenize_weighted(&text);
|
|
337
337
|
for p in &file_paths {
|
|
338
|
-
query_tokens.extend(lexsim::
|
|
338
|
+
query_tokens.extend(lexsim::tokenize_weighted(&basename(p)));
|
|
339
339
|
}
|
|
340
340
|
|
|
341
341
|
let scope_paths: Vec<Vec<String>> = candidates
|
|
@@ -344,6 +344,7 @@ pub fn handle_doc_query(arguments: &Value) -> Result<String> {
|
|
|
344
344
|
.collect();
|
|
345
345
|
let rank_config = RankConfig {
|
|
346
346
|
min_score: DOC_QUERY_MIN_SCORE,
|
|
347
|
+
relative_threshold: 0.0,
|
|
347
348
|
scope_path_bonus: SCOPE_PATH_BONUS,
|
|
348
349
|
limit: candidates.len(),
|
|
349
350
|
};
|
|
@@ -957,8 +958,7 @@ pub fn handle_doc_import(arguments: &Value) -> Result<String> {
|
|
|
957
958
|
doc.source.original_path = Some(file.clone());
|
|
958
959
|
doc.has_bom = split_doc.has_bom;
|
|
959
960
|
doc.line_ending = split_doc.line_ending.to_string();
|
|
960
|
-
doc.
|
|
961
|
-
doc.source.frontmatter_trailing_eol = split_doc.frontmatter_trailing_eol;
|
|
961
|
+
doc.split_level = DEFAULT_SPLIT_LEVEL;
|
|
962
962
|
|
|
963
963
|
let body_after_strip: String = split_doc.fragments.iter().map(|f| f.body).collect();
|
|
964
964
|
write_doc_body(&handoff, &slug, &body_after_strip)?;
|
|
@@ -96,6 +96,7 @@ pub fn handle_save(arguments: &Value) -> Result<String> {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
let tags = string_array(arguments, "tags");
|
|
99
|
+
let keywords = string_array(arguments, "keywords");
|
|
99
100
|
let scope_paths = string_array(arguments, "scope_paths");
|
|
100
101
|
let force = arguments
|
|
101
102
|
.get("force")
|
|
@@ -112,6 +113,7 @@ pub fn handle_save(arguments: &Value) -> Result<String> {
|
|
|
112
113
|
text,
|
|
113
114
|
kind,
|
|
114
115
|
tags,
|
|
116
|
+
keywords,
|
|
115
117
|
scope_paths,
|
|
116
118
|
);
|
|
117
119
|
}
|
|
@@ -130,7 +132,18 @@ pub fn handle_save(arguments: &Value) -> Result<String> {
|
|
|
130
132
|
// (3) Near-duplicate: hand both bodies back for AI-driven merge.
|
|
131
133
|
if !force {
|
|
132
134
|
let dup_threshold = settings.memory_dup_threshold;
|
|
133
|
-
|
|
135
|
+
// Build the new memory's index text (body + tags + keywords),
|
|
136
|
+
// matching MemoryEntry::index_text() for symmetric Jaccard comparison.
|
|
137
|
+
let mut new_index = text.clone();
|
|
138
|
+
if !tags.is_empty() {
|
|
139
|
+
new_index.push(' ');
|
|
140
|
+
new_index.push_str(&tags.join(" "));
|
|
141
|
+
}
|
|
142
|
+
if !keywords.is_empty() {
|
|
143
|
+
new_index.push(' ');
|
|
144
|
+
new_index.push_str(&keywords.join(" "));
|
|
145
|
+
}
|
|
146
|
+
let new_set = lexsim::token_set(&new_index);
|
|
134
147
|
let mut similar: Vec<Value> = Vec::new();
|
|
135
148
|
for m in &existing {
|
|
136
149
|
let score = lexsim::jaccard_sets(&new_set, &lexsim::token_set(&m.index_text()));
|
|
@@ -163,13 +176,22 @@ pub fn handle_save(arguments: &Value) -> Result<String> {
|
|
|
163
176
|
|
|
164
177
|
// (4) New memory.
|
|
165
178
|
let id = new_memory_id();
|
|
166
|
-
let entry = MemoryEntry::new(
|
|
179
|
+
let entry = MemoryEntry::new(
|
|
180
|
+
id.clone(),
|
|
181
|
+
text,
|
|
182
|
+
kind,
|
|
183
|
+
tags,
|
|
184
|
+
keywords,
|
|
185
|
+
scope_paths,
|
|
186
|
+
now_rfc3339(),
|
|
187
|
+
);
|
|
167
188
|
write_memory(&handoff, &entry)?;
|
|
168
189
|
Ok(to_json(&json!({ "status": "saved", "id": id })))
|
|
169
190
|
}
|
|
170
191
|
|
|
171
192
|
/// Commit an AI-driven merge: overwrite `merge_into` with the merged text and
|
|
172
193
|
/// delete the absorbed memories, recording them in `superseded_ids`.
|
|
194
|
+
#[allow(clippy::too_many_arguments)]
|
|
173
195
|
fn commit_merge(
|
|
174
196
|
handoff: &std::path::Path,
|
|
175
197
|
merge_into: &str,
|
|
@@ -177,6 +199,7 @@ fn commit_merge(
|
|
|
177
199
|
text: String,
|
|
178
200
|
kind: String,
|
|
179
201
|
tags: Vec<String>,
|
|
202
|
+
keywords: Vec<String>,
|
|
180
203
|
scope_paths: Vec<String>,
|
|
181
204
|
) -> Result<String> {
|
|
182
205
|
let mut target = read_memory_by_id(handoff, merge_into)?
|
|
@@ -188,6 +211,9 @@ fn commit_merge(
|
|
|
188
211
|
if !tags.is_empty() {
|
|
189
212
|
target.tags = tags;
|
|
190
213
|
}
|
|
214
|
+
if !keywords.is_empty() {
|
|
215
|
+
target.keywords = keywords;
|
|
216
|
+
}
|
|
191
217
|
if !scope_paths.is_empty() {
|
|
192
218
|
target.scope_paths = scope_paths;
|
|
193
219
|
}
|
|
@@ -200,8 +226,24 @@ fn commit_merge(
|
|
|
200
226
|
if raw == &target_id {
|
|
201
227
|
continue; // never absorb the target into itself
|
|
202
228
|
}
|
|
203
|
-
// Resolve to a concrete id (supports prefixes), then delete it.
|
|
204
229
|
if let Some(m) = read_memory_by_id(handoff, raw)? {
|
|
230
|
+
// Fold the absorbed memory's metadata into the target before
|
|
231
|
+
// deleting, so keywords/tags/scope_paths are not lost.
|
|
232
|
+
for kw in &m.keywords {
|
|
233
|
+
if !target.keywords.contains(kw) {
|
|
234
|
+
target.keywords.push(kw.clone());
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for t in &m.tags {
|
|
238
|
+
if !target.tags.contains(t) {
|
|
239
|
+
target.tags.push(t.clone());
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for s in &m.scope_paths {
|
|
243
|
+
if !target.scope_paths.contains(s) {
|
|
244
|
+
target.scope_paths.push(s.clone());
|
|
245
|
+
}
|
|
246
|
+
}
|
|
205
247
|
if delete_memory(handoff, &m.id)? {
|
|
206
248
|
absorbed.push(m.id);
|
|
207
249
|
}
|
|
@@ -265,24 +307,28 @@ pub fn handle_query(arguments: &Value) -> Result<String> {
|
|
|
265
307
|
return Ok(to_json(&json!({ "memories": [], "injected_count": 0 })));
|
|
266
308
|
}
|
|
267
309
|
|
|
268
|
-
// Build the BM25 corpus
|
|
269
|
-
|
|
270
|
-
|
|
310
|
+
// Build the weighted BM25 corpus from per-memory weighted tokens.
|
|
311
|
+
// Keywords get doc-side TF boost (weight 2.0) via build_weighted_tokens;
|
|
312
|
+
// stopwords and CL-CnG trigrams from stopword-only context are excluded.
|
|
313
|
+
let doc_tokens: Vec<Vec<lexsim::WeightedToken>> =
|
|
314
|
+
memories.iter().map(|m| m.index_weighted_tokens()).collect();
|
|
315
|
+
let corpus = lexsim::Corpus::build_weighted_tokens(&doc_tokens);
|
|
271
316
|
|
|
272
317
|
// Query = prompt text + tool name + file basenames (so a PreToolUse hook
|
|
273
318
|
// that only passes a file path still matches name-related memories).
|
|
274
|
-
let mut query_tokens = lexsim::
|
|
319
|
+
let mut query_tokens = lexsim::tokenize_weighted(&text);
|
|
275
320
|
if let Some(tn) = tool_name {
|
|
276
|
-
query_tokens.extend(lexsim::
|
|
321
|
+
query_tokens.extend(lexsim::tokenize_weighted(tn));
|
|
277
322
|
}
|
|
278
323
|
for p in &file_paths {
|
|
279
|
-
query_tokens.extend(lexsim::
|
|
324
|
+
query_tokens.extend(lexsim::tokenize_weighted(&basename(p)));
|
|
280
325
|
}
|
|
281
326
|
|
|
282
327
|
// Score + scope-path bonus, then threshold and rank (shared with doc_query).
|
|
283
328
|
let scope_paths: Vec<Vec<String>> = memories.iter().map(|m| m.scope_paths.clone()).collect();
|
|
284
329
|
let rank_config = RankConfig {
|
|
285
330
|
min_score: settings.memory_query_min_score,
|
|
331
|
+
relative_threshold: settings.memory_query_relative_threshold,
|
|
286
332
|
scope_path_bonus: SCOPE_PATH_BONUS,
|
|
287
333
|
// Rank without truncating yet — the session diff below needs the full
|
|
288
334
|
// ranked order so it can backfill past already-injected memories.
|
|
@@ -314,15 +360,27 @@ pub fn handle_query(arguments: &Value) -> Result<String> {
|
|
|
314
360
|
.iter()
|
|
315
361
|
.map(|item| {
|
|
316
362
|
let m = &memories[item.index];
|
|
317
|
-
json!({
|
|
363
|
+
let mut entry = json!({
|
|
318
364
|
"id": m.id,
|
|
319
365
|
"text": m.text,
|
|
320
366
|
"kind": m.kind,
|
|
321
367
|
"score": round2(item.score),
|
|
322
|
-
})
|
|
368
|
+
});
|
|
369
|
+
if !m.keywords.is_empty() {
|
|
370
|
+
entry["keywords"] = json!(m.keywords);
|
|
371
|
+
}
|
|
372
|
+
entry
|
|
323
373
|
})
|
|
324
374
|
.collect();
|
|
325
375
|
|
|
376
|
+
// Warn about returned memories that have no keywords — these get weaker
|
|
377
|
+
// BM25 signal and would benefit from `memory_save` with explicit keywords.
|
|
378
|
+
let missing_kw: Vec<&str> = fresh
|
|
379
|
+
.iter()
|
|
380
|
+
.filter(|item| memories[item.index].keywords.is_empty())
|
|
381
|
+
.map(|item| memories[item.index].id.as_str())
|
|
382
|
+
.collect();
|
|
383
|
+
|
|
326
384
|
// Bookkeeping: record survivors in the sidecar and bump usage stats. Only
|
|
327
385
|
// when we have a session id and marking is enabled (the hook's normal path).
|
|
328
386
|
if mark_injected && !fresh.is_empty() {
|
|
@@ -331,10 +389,21 @@ pub fn handle_query(arguments: &Value) -> Result<String> {
|
|
|
331
389
|
}
|
|
332
390
|
}
|
|
333
391
|
|
|
334
|
-
|
|
392
|
+
let mut result = json!({
|
|
335
393
|
"memories": out,
|
|
336
394
|
"injected_count": out.len(),
|
|
337
|
-
})
|
|
395
|
+
});
|
|
396
|
+
if !missing_kw.is_empty() {
|
|
397
|
+
result["warnings"] = json!([
|
|
398
|
+
format!(
|
|
399
|
+
"{} injected memories have no keywords — add keywords via memory_save(merge_into=<id>) to improve match precision: {}",
|
|
400
|
+
missing_kw.len(),
|
|
401
|
+
missing_kw.join(", ")
|
|
402
|
+
)
|
|
403
|
+
]);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
Ok(to_json(&result))
|
|
338
407
|
}
|
|
339
408
|
|
|
340
409
|
/// Append the freshly-injected memories to the session sidecar and bump each
|
|
@@ -530,6 +599,11 @@ fn merge_exact_duplicates(handoff: &std::path::Path, now: &str) -> Result<usize>
|
|
|
530
599
|
keeper.tags.push(t.clone());
|
|
531
600
|
}
|
|
532
601
|
}
|
|
602
|
+
for kw in &dup.keywords {
|
|
603
|
+
if !keeper.keywords.contains(kw) {
|
|
604
|
+
keeper.keywords.push(kw.clone());
|
|
605
|
+
}
|
|
606
|
+
}
|
|
533
607
|
for s in &dup.scope_paths {
|
|
534
608
|
if !keeper.scope_paths.contains(s) {
|
|
535
609
|
keeper.scope_paths.push(s.clone());
|
|
@@ -804,6 +878,7 @@ mod tests {
|
|
|
804
878
|
"lesson".to_string(),
|
|
805
879
|
vec![],
|
|
806
880
|
vec![],
|
|
881
|
+
vec![],
|
|
807
882
|
created.to_string(),
|
|
808
883
|
);
|
|
809
884
|
m.last_referenced_at = last_ref.map(str::to_string);
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -99,6 +99,7 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
99
99
|
"handoff_timer_stop" => timer::handle_stop(arguments),
|
|
100
100
|
"handoff_timer_get_time" => timer::handle_get_time(arguments),
|
|
101
101
|
"handoff_doc_save" => docs::handle_doc_save(arguments),
|
|
102
|
+
"handoff_doc_update_section" => docs::handle_doc_update_section(arguments),
|
|
102
103
|
"handoff_doc_get" => docs::handle_doc_get(arguments),
|
|
103
104
|
"handoff_doc_list" => docs::handle_doc_list(arguments),
|
|
104
105
|
"handoff_doc_delete" => docs::handle_doc_delete(arguments),
|
|
@@ -314,7 +314,12 @@ fn item_is_stale(doc: &DocMetadata, item: &VerificationItem) -> bool {
|
|
|
314
314
|
let Some(hash_at_verify) = &item.content_hash_at_verify else {
|
|
315
315
|
return false;
|
|
316
316
|
};
|
|
317
|
-
|
|
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) {
|
|
318
323
|
Some(section) => §ion.content_hash != hash_at_verify,
|
|
319
324
|
None => true,
|
|
320
325
|
}
|
|
@@ -470,10 +475,17 @@ fn suggested_actions_json(data: &TaskData, docs: &[DocMetadata]) -> Vec<String>
|
|
|
470
475
|
for item in &v.items {
|
|
471
476
|
let resolved = item.status == "verified" || item.status == "skipped";
|
|
472
477
|
if !resolved || item_is_stale(doc, item) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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);
|
|
477
489
|
}
|
|
478
490
|
}
|
|
479
491
|
}
|
package/src/mcp/tools.rs
CHANGED
|
@@ -1080,6 +1080,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1080
1080
|
"text": { "type": "string", "description": "The memory body (any language). Required, non-empty." },
|
|
1081
1081
|
"kind": { "type": "string", "description": "Memory kind.", "enum": ["lesson", "rule", "convention", "gotcha"], "default": "lesson" },
|
|
1082
1082
|
"tags": { "type": "array", "items": { "type": "string" }, "description": "Optional tags; also indexed for similarity." },
|
|
1083
|
+
"keywords": { "type": "array", "items": { "type": "string" }, "description": "Subject keywords — nouns, technical terms, proper nouns that identify what this memory is about. These are weighted higher than body text in BM25 relevance scoring. Distinct from tags (classification labels)." },
|
|
1083
1084
|
"scope_paths": { "type": "array", "items": { "type": "string" }, "description": "Path prefixes this memory applies to (e.g. 'src/storage/'). Boosts relevance when a query touches a matching file." },
|
|
1084
1085
|
"merge_into": { "type": "string", "description": "Commit an AI merge: overwrite this memory id with `text` and absorb `absorb_ids`." },
|
|
1085
1086
|
"absorb_ids": { "type": "array", "items": { "type": "string" }, "description": "Memory ids to delete and record as superseded when merging." },
|
|
@@ -1259,6 +1260,21 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1259
1260
|
}
|
|
1260
1261
|
}),
|
|
1261
1262
|
},
|
|
1263
|
+
ToolDefinition {
|
|
1264
|
+
name: "handoff_doc_update_section".to_string(),
|
|
1265
|
+
description: "Replace a single section of a document by its seq number, without re-sending the whole document body. new_content is the replacement Markdown (including the section's own heading line); an empty string deletes the section. Sections are computed on-demand from the current body (same as doc_get/doc_save), so seq must match the document's current section numbering. expected_hash is an optional optimistic lock: when given, it must match the section's current content_hash or the call errors with the actual current hash (for retry) and makes no change. Updates the document's updated_at and content_hash, and — if a verification matrix item exists at this fragment_seq — surfaces a warning that it is now stale (its content_hash_at_verify no longer matches). Returns a JSON string {doc_id,seq,heading,content_hash,updated_at,section_count,warnings?:[…]}.".to_string(),
|
|
1266
|
+
input_schema: json!({
|
|
1267
|
+
"type": "object",
|
|
1268
|
+
"properties": {
|
|
1269
|
+
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1270
|
+
"doc_id": { "type": "string", "description": "Document id or slug to update." },
|
|
1271
|
+
"seq": { "type": "integer", "description": "Section sequence number to replace (0 = preamble)." },
|
|
1272
|
+
"new_content": { "type": "string", "description": "Replacement Markdown text for this section, including its own heading line. An empty string deletes the section." },
|
|
1273
|
+
"expected_hash": { "type": "string", "description": "Optimistic lock: the section's expected current content_hash. If it does not match, the update is rejected with the actual current hash." }
|
|
1274
|
+
},
|
|
1275
|
+
"required": ["doc_id", "seq", "new_content"]
|
|
1276
|
+
}),
|
|
1277
|
+
},
|
|
1262
1278
|
ToolDefinition {
|
|
1263
1279
|
name: "handoff_doc_get".to_string(),
|
|
1264
1280
|
description: "Read a document by doc_id or slug. format='full' returns the original Markdown body (read directly from _doc.<slug>.md) plus metadata; 'meta' returns metadata only (no body, cheap for graph traversal); 'section' returns one section's body (byte-sliced from the document body, requires seq). Returns a JSON string.".to_string(),
|
|
@@ -1329,17 +1345,21 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1329
1345
|
},
|
|
1330
1346
|
ToolDefinition {
|
|
1331
1347
|
name: "handoff_doc_verify".to_string(),
|
|
1332
|
-
description: "Operate on a document's verification matrix (wiki/140-verification-matrix.md): generate (create a matrix from the document's current sections, error if one already exists), check (mark fragment_seq verified, recording verified_at/reviewer/notes/content_hash_at_verify), skip (mark fragment_seq skipped), sync (re-sync the matrix with the document's current sections — adds new sections as pending, removes deleted ones, preserves existing item status),
|
|
1348
|
+
description: "Operate on a document's verification matrix (wiki/140-verification-matrix.md): generate (create a matrix from the document's current sections, error if one already exists), check (mark fragment_seq — or its sub_item_index — verified, recording verified_at/reviewer/notes/content_hash_at_verify — fragment_seq may be a single section seq or an array of seqs to verify in one call), check_all (verify every section and sub_item in the matrix in one call, error if no matrix exists yet), skip (mark fragment_seq — or its sub_item_index — skipped), sync (re-sync the matrix with the document's current sections — adds new sections as pending, removes deleted ones, preserves existing item status), set_refs (update impl_refs/test_refs for fragment_seq), add_item (v2, spec §7.2: with fragment_seq given, append a SubItem — description required — to that section's sub_items; with fragment_seq omitted, append a new freeform top-level item not tied to any section — label required), or suggest_refs (t124.6: read-only — scans the document's scope_paths for source/test files whose fn/struct/impl/mod definitions or test functions fuzzy-match each verification item's heading, and returns candidate impl_refs/test_refs per item for the caller to review and apply via set_refs; errors if no matrix exists yet). Overall verification_status is recomputed after every mutation: 'pending' if all items pending, 'verified' if all verified/skipped, else 'in_review'. Returns a JSON string {doc_id,verification_status,checked,skipped,pending,total,stale} for mutating actions, or {doc_id,suggestions:[{fragment_seq,heading,suggested_impl_refs,suggested_test_refs}]} for suggest_refs.".to_string(),
|
|
1333
1349
|
input_schema: json!({
|
|
1334
1350
|
"type": "object",
|
|
1335
1351
|
"properties": {
|
|
1336
1352
|
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1337
1353
|
"doc_id": { "type": "string", "description": "Document id or slug to operate on." },
|
|
1338
|
-
"action": { "type": "string", "description": "Verification matrix action.", "enum": ["generate", "check", "skip", "sync", "set_refs"] },
|
|
1354
|
+
"action": { "type": "string", "description": "Verification matrix action.", "enum": ["generate", "check", "check_all", "skip", "sync", "set_refs", "add_item", "suggest_refs"] },
|
|
1339
1355
|
"skip_seqs": { "type": "array", "items": { "type": "integer" }, "description": "generate only: section seqs to create as 'skipped' instead of 'pending'." },
|
|
1340
|
-
"fragment_seq": { "
|
|
1341
|
-
"
|
|
1342
|
-
"
|
|
1356
|
+
"fragment_seq": { "description": "skip/set_refs: the section seq (VerificationItem.fragment_seq) to operate on. check: a single section seq, or an array of section seqs to verify in one call. add_item: the section seq to attach a new sub_item to; omit to add a freeform top-level item instead.", "oneOf": [ { "type": "integer" }, { "type": "array", "items": { "type": "integer" } } ] },
|
|
1357
|
+
"sub_item_index": { "type": "integer", "description": "check/skip: the 0-based SubItem.index within fragment_seq's sub_items to operate on, instead of the parent item itself." },
|
|
1358
|
+
"description": { "type": "string", "description": "add_item (fragment_seq given): the new sub_item's description. Required in this form." },
|
|
1359
|
+
"label": { "type": "string", "description": "add_item (fragment_seq omitted): the new freeform top-level item's label. Required in this form." },
|
|
1360
|
+
"category": { "type": "string", "description": "add_item: item/sub_item category (free-extensible, e.g. 'requirement', 'visual', 'regression', 'manual'). Defaults to 'requirement' for sub_items, 'visual' for freeform items." },
|
|
1361
|
+
"reviewer": { "type": "string", "description": "check/check_all: who verified it.", "enum": ["ai", "user"] },
|
|
1362
|
+
"notes": { "type": "string", "description": "check/check_all: optional free-text note." },
|
|
1343
1363
|
"impl_refs": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "lines": { "type": "string" }, "label": { "type": "string" } }, "required": ["path"] }, "description": "set_refs: implementation code references to attach to fragment_seq." },
|
|
1344
1364
|
"test_refs": { "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "lines": { "type": "string" }, "label": { "type": "string" } }, "required": ["path"] }, "description": "set_refs: test code references to attach to fragment_seq." }
|
|
1345
1365
|
},
|
|
@@ -1348,13 +1368,14 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1348
1368
|
},
|
|
1349
1369
|
ToolDefinition {
|
|
1350
1370
|
name: "handoff_doc_verify_status".to_string(),
|
|
1351
|
-
description: "Get a document's verification matrix status: overall verification_status, progress counts (checked/skipped/pending/total/stale/percentage), and (when include_items=true) every item with a computed stale flag (its content_hash_at_verify no longer matches the section's current content_hash — spec §3.5). Errors if the document has no verification matrix yet (use handoff_doc_verify(action='generate') first). Returns a JSON string {doc_id,title,verification_status,progress:{…},items?:[…]}.".to_string(),
|
|
1371
|
+
description: "Get a document's verification matrix status: overall verification_status, progress counts (checked/skipped/pending/total/stale/percentage — v2: counts leaf items, i.e. sub_items and freeform items, spec §7.4), and (when include_items=true) every item with a computed stale flag (its content_hash_at_verify no longer matches the section's current content_hash — spec §3.5). Errors if the document has no verification matrix yet (use handoff_doc_verify(action='generate') first). Returns a JSON string {doc_id,title,verification_status,progress:{…},items?:[…]} by default, or (format='checklist') a Markdown checklist rendering (spec §7.3) instead.".to_string(),
|
|
1352
1372
|
input_schema: json!({
|
|
1353
1373
|
"type": "object",
|
|
1354
1374
|
"properties": {
|
|
1355
1375
|
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1356
1376
|
"doc_id": { "type": "string", "description": "Document id or slug to read verification status for." },
|
|
1357
|
-
"include_items": { "type": "boolean", "description": "Include the full per-item list (with stale detection).", "default": false }
|
|
1377
|
+
"include_items": { "type": "boolean", "description": "Include the full per-item list (with stale detection).", "default": false },
|
|
1378
|
+
"format": { "type": "string", "description": "Output format: 'json' (default, structured payload) or 'checklist' (Markdown checklist rendering with headings, sub_item checkboxes, refs, and categories).", "enum": ["json", "checklist"], "default": "json" }
|
|
1358
1379
|
},
|
|
1359
1380
|
"required": ["doc_id"]
|
|
1360
1381
|
}),
|
package/src/storage/config.rs
CHANGED
|
@@ -2,7 +2,71 @@ use std::collections::HashMap;
|
|
|
2
2
|
use std::path::Path;
|
|
3
3
|
|
|
4
4
|
use anyhow::{Context, Result};
|
|
5
|
-
use serde::{
|
|
5
|
+
use serde::de::{self, SeqAccess};
|
|
6
|
+
use serde::{Deserialize, Deserializer, Serialize};
|
|
7
|
+
|
|
8
|
+
/// Convert a weekday name to its number (0=Sun..6=Sat).
|
|
9
|
+
pub fn weekday_to_num(s: &str) -> Option<u32> {
|
|
10
|
+
match s.to_lowercase().as_str() {
|
|
11
|
+
"sun" | "sunday" => Some(0),
|
|
12
|
+
"mon" | "monday" => Some(1),
|
|
13
|
+
"tue" | "tuesday" => Some(2),
|
|
14
|
+
"wed" | "wednesday" => Some(3),
|
|
15
|
+
"thu" | "thursday" => Some(4),
|
|
16
|
+
"fri" | "friday" => Some(5),
|
|
17
|
+
"sat" | "saturday" => Some(6),
|
|
18
|
+
_ => None,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fn deserialize_weekdays<'de, D>(deserializer: D) -> std::result::Result<Vec<u32>, D::Error>
|
|
23
|
+
where
|
|
24
|
+
D: Deserializer<'de>,
|
|
25
|
+
{
|
|
26
|
+
struct WeekdayVisitor;
|
|
27
|
+
|
|
28
|
+
impl<'de> de::Visitor<'de> for WeekdayVisitor {
|
|
29
|
+
type Value = Vec<u32>;
|
|
30
|
+
|
|
31
|
+
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
32
|
+
f.write_str("an array of weekday numbers (0-6) or names (\"sun\"..\"sat\")")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Vec<u32>, A::Error>
|
|
36
|
+
where
|
|
37
|
+
A: SeqAccess<'de>,
|
|
38
|
+
{
|
|
39
|
+
let mut vals = Vec::new();
|
|
40
|
+
while let Some(elem) = seq.next_element::<toml::Value>()? {
|
|
41
|
+
match &elem {
|
|
42
|
+
toml::Value::Integer(n) => {
|
|
43
|
+
let n = *n as u32;
|
|
44
|
+
if n > 6 {
|
|
45
|
+
return Err(de::Error::custom(format!(
|
|
46
|
+
"weekday number {n} out of range 0-6"
|
|
47
|
+
)));
|
|
48
|
+
}
|
|
49
|
+
vals.push(n);
|
|
50
|
+
}
|
|
51
|
+
toml::Value::String(s) => {
|
|
52
|
+
let n = weekday_to_num(s).ok_or_else(|| {
|
|
53
|
+
de::Error::custom(format!("unknown weekday name: \"{s}\""))
|
|
54
|
+
})?;
|
|
55
|
+
vals.push(n);
|
|
56
|
+
}
|
|
57
|
+
_ => {
|
|
58
|
+
return Err(de::Error::custom(
|
|
59
|
+
"closed_weekdays elements must be integers or strings",
|
|
60
|
+
));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
Ok(vals)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
deserializer.deserialize_seq(WeekdayVisitor)
|
|
69
|
+
}
|
|
6
70
|
|
|
7
71
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
8
72
|
pub struct Config {
|
|
@@ -66,9 +130,16 @@ pub struct SettingsConfig {
|
|
|
66
130
|
#[serde(default = "default_memory_dup_threshold")]
|
|
67
131
|
pub memory_dup_threshold: f64,
|
|
68
132
|
/// BM25 relevance floor for `memory_query`; scores below are not returned.
|
|
69
|
-
/// Default 0.
|
|
133
|
+
/// Default 2.0.
|
|
70
134
|
#[serde(default = "default_memory_query_min_score")]
|
|
71
135
|
pub memory_query_min_score: f64,
|
|
136
|
+
/// Relative threshold (0.0–1.0) for `memory_query`: after the absolute
|
|
137
|
+
/// `min_score` floor, a candidate is dropped unless its score is at least
|
|
138
|
+
/// `top_score × relative_threshold`. Prevents low-relevance "tail" matches
|
|
139
|
+
/// from riding a strong top hit. 0.0 disables (keep everything above
|
|
140
|
+
/// `min_score`). Default 0.3.
|
|
141
|
+
#[serde(default = "default_memory_query_relative_threshold")]
|
|
142
|
+
pub memory_query_relative_threshold: f64,
|
|
72
143
|
/// Maximum number of memories `memory_query` returns per call. Default 5.
|
|
73
144
|
#[serde(default = "default_memory_query_limit")]
|
|
74
145
|
pub memory_query_limit: u32,
|
|
@@ -114,7 +185,12 @@ pub struct CalendarConfig {
|
|
|
114
185
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
115
186
|
pub work_hours_per_day: Option<f64>,
|
|
116
187
|
/// Weekday numbers (0=Sun..6=Sat) that are non-working.
|
|
117
|
-
|
|
188
|
+
/// Accepts integers or weekday names ("sun", "sat", etc.) in TOML.
|
|
189
|
+
#[serde(
|
|
190
|
+
default,
|
|
191
|
+
skip_serializing_if = "Vec::is_empty",
|
|
192
|
+
deserialize_with = "deserialize_weekdays"
|
|
193
|
+
)]
|
|
118
194
|
pub closed_weekdays: Vec<u32>,
|
|
119
195
|
/// Specific YYYY-MM-DD dates that are non-working (override weekdays).
|
|
120
196
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
@@ -155,7 +231,11 @@ pub struct AssigneeConfig {
|
|
|
155
231
|
pub color: Option<String>,
|
|
156
232
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
157
233
|
pub work_hours_per_day: Option<f64>,
|
|
158
|
-
#[serde(
|
|
234
|
+
#[serde(
|
|
235
|
+
default,
|
|
236
|
+
skip_serializing_if = "Vec::is_empty",
|
|
237
|
+
deserialize_with = "deserialize_weekdays"
|
|
238
|
+
)]
|
|
159
239
|
pub closed_weekdays: Vec<u32>,
|
|
160
240
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
161
241
|
pub closed_dates: Vec<String>,
|
|
@@ -252,7 +332,11 @@ fn default_memory_dup_threshold() -> f64 {
|
|
|
252
332
|
}
|
|
253
333
|
|
|
254
334
|
fn default_memory_query_min_score() -> f64 {
|
|
255
|
-
0
|
|
335
|
+
2.0
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
fn default_memory_query_relative_threshold() -> f64 {
|
|
339
|
+
0.3
|
|
256
340
|
}
|
|
257
341
|
|
|
258
342
|
fn default_memory_query_limit() -> u32 {
|
|
@@ -299,6 +383,7 @@ impl Default for SettingsConfig {
|
|
|
299
383
|
memory_enabled: default_memory_enabled(),
|
|
300
384
|
memory_dup_threshold: default_memory_dup_threshold(),
|
|
301
385
|
memory_query_min_score: default_memory_query_min_score(),
|
|
386
|
+
memory_query_relative_threshold: default_memory_query_relative_threshold(),
|
|
302
387
|
memory_query_limit: default_memory_query_limit(),
|
|
303
388
|
memory_stale_days: default_memory_stale_days(),
|
|
304
389
|
memory_injected_gc_days: default_memory_injected_gc_days(),
|
|
@@ -364,3 +449,130 @@ pub fn write_config(path: &Path, config: &Config) -> Result<()> {
|
|
|
364
449
|
.with_context(|| format!("Failed to write config: {}", path.display()))?;
|
|
365
450
|
Ok(())
|
|
366
451
|
}
|
|
452
|
+
|
|
453
|
+
#[cfg(test)]
|
|
454
|
+
mod tests {
|
|
455
|
+
use super::*;
|
|
456
|
+
|
|
457
|
+
fn parse_config(toml_str: &str) -> Config {
|
|
458
|
+
toml::from_str(toml_str).unwrap()
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#[test]
|
|
462
|
+
fn closed_weekdays_string_names() {
|
|
463
|
+
let cfg = parse_config(
|
|
464
|
+
r#"
|
|
465
|
+
[project]
|
|
466
|
+
name = "test"
|
|
467
|
+
[calendar]
|
|
468
|
+
closed_weekdays = ["sun", "sat"]
|
|
469
|
+
"#,
|
|
470
|
+
);
|
|
471
|
+
assert_eq!(cfg.calendar.closed_weekdays, vec![0, 6]);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#[test]
|
|
475
|
+
fn closed_weekdays_integer_values() {
|
|
476
|
+
let cfg = parse_config(
|
|
477
|
+
r#"
|
|
478
|
+
[project]
|
|
479
|
+
name = "test"
|
|
480
|
+
[calendar]
|
|
481
|
+
closed_weekdays = [0, 6]
|
|
482
|
+
"#,
|
|
483
|
+
);
|
|
484
|
+
assert_eq!(cfg.calendar.closed_weekdays, vec![0, 6]);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#[test]
|
|
488
|
+
fn closed_weekdays_mixed() {
|
|
489
|
+
let cfg = parse_config(
|
|
490
|
+
r#"
|
|
491
|
+
[project]
|
|
492
|
+
name = "test"
|
|
493
|
+
[calendar]
|
|
494
|
+
closed_weekdays = ["sun", 6]
|
|
495
|
+
"#,
|
|
496
|
+
);
|
|
497
|
+
assert_eq!(cfg.calendar.closed_weekdays, vec![0, 6]);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#[test]
|
|
501
|
+
fn closed_weekdays_empty() {
|
|
502
|
+
let cfg = parse_config(
|
|
503
|
+
r#"
|
|
504
|
+
[project]
|
|
505
|
+
name = "test"
|
|
506
|
+
"#,
|
|
507
|
+
);
|
|
508
|
+
assert!(cfg.calendar.closed_weekdays.is_empty());
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#[test]
|
|
512
|
+
fn closed_weekdays_full_names() {
|
|
513
|
+
let cfg = parse_config(
|
|
514
|
+
r#"
|
|
515
|
+
[project]
|
|
516
|
+
name = "test"
|
|
517
|
+
[calendar]
|
|
518
|
+
closed_weekdays = ["sunday", "saturday"]
|
|
519
|
+
"#,
|
|
520
|
+
);
|
|
521
|
+
assert_eq!(cfg.calendar.closed_weekdays, vec![0, 6]);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#[test]
|
|
525
|
+
fn assignee_closed_weekdays_strings() {
|
|
526
|
+
let cfg = parse_config(
|
|
527
|
+
r#"
|
|
528
|
+
[project]
|
|
529
|
+
name = "test"
|
|
530
|
+
[assignees.alice]
|
|
531
|
+
closed_weekdays = ["mon", "fri"]
|
|
532
|
+
"#,
|
|
533
|
+
);
|
|
534
|
+
let alice = cfg.assignees.get("alice").unwrap();
|
|
535
|
+
assert_eq!(alice.closed_weekdays, vec![1, 5]);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
#[test]
|
|
539
|
+
fn closed_weekdays_invalid_name() {
|
|
540
|
+
let result = toml::from_str::<Config>(
|
|
541
|
+
r#"
|
|
542
|
+
[project]
|
|
543
|
+
name = "test"
|
|
544
|
+
[calendar]
|
|
545
|
+
closed_weekdays = ["funday"]
|
|
546
|
+
"#,
|
|
547
|
+
);
|
|
548
|
+
assert!(result.is_err());
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#[test]
|
|
552
|
+
fn closed_weekdays_out_of_range() {
|
|
553
|
+
let result = toml::from_str::<Config>(
|
|
554
|
+
r#"
|
|
555
|
+
[project]
|
|
556
|
+
name = "test"
|
|
557
|
+
[calendar]
|
|
558
|
+
closed_weekdays = [7]
|
|
559
|
+
"#,
|
|
560
|
+
);
|
|
561
|
+
assert!(result.is_err());
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
#[test]
|
|
565
|
+
fn round_trip_preserves_integer_format() {
|
|
566
|
+
let cfg = parse_config(
|
|
567
|
+
r#"
|
|
568
|
+
[project]
|
|
569
|
+
name = "test"
|
|
570
|
+
[calendar]
|
|
571
|
+
closed_weekdays = ["sun", "sat"]
|
|
572
|
+
"#,
|
|
573
|
+
);
|
|
574
|
+
let serialized = toml::to_string_pretty(&cfg).unwrap();
|
|
575
|
+
let re_parsed = parse_config(&serialized);
|
|
576
|
+
assert_eq!(re_parsed.calendar.closed_weekdays, vec![0, 6]);
|
|
577
|
+
}
|
|
578
|
+
}
|