handoff-mcp-server 0.19.1 → 0.22.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/src/mcp/tools.rs CHANGED
@@ -275,7 +275,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
275
275
  },
276
276
  ToolDefinition {
277
277
  name: "handoff_update_task".to_string(),
278
- description: "Add, update, or move a task. Manages the tasks/ directory structure.".to_string(),
278
+ description: "Add, update, or move a task. Manages the tasks/ directory structure. When creating a leaf task, always include task.schedule.estimate_hours (raw human-effort hours, > 0); it is rejected without one unless the task is a parent or is blocked/skipped.".to_string(),
279
279
  input_schema: json!({
280
280
  "type": "object",
281
281
  "properties": {
@@ -285,6 +285,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
285
285
  },
286
286
  "task": {
287
287
  "type": "object",
288
+ "description": "The task to add or update. When creating a leaf task (status todo/in_progress/review/done), schedule.estimate_hours is REQUIRED and the call is rejected without it. Omit it only for parent tasks (any task with children) or status blocked/skipped.",
288
289
  "properties": {
289
290
  "id": { "type": "string", "description": "Task ID. Omit for auto-generated ID. If provided and task exists, updates it. If provided and task does not exist, creates a new task with that ID (upsert)." },
290
291
  "title": { "type": "string", "description": "Required for new tasks. Optional when updating (id present)." },
@@ -319,12 +320,12 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
319
320
  },
320
321
  "schedule": {
321
322
  "type": "object",
322
- "description": "Schedule and effort tracking.",
323
+ "description": "Schedule and effort tracking. Supply this with estimate_hours whenever creating a leaf task.",
323
324
  "properties": {
324
325
  "start_date": { "type": "string", "description": "YYYY-MM-DD" },
325
326
  "due_date": { "type": "string", "description": "YYYY-MM-DD" },
326
- "estimate_hours": { "type": "number" },
327
- "actual_hours": { "type": "number" },
327
+ "estimate_hours": { "type": "number", "description": "REQUIRED for leaf tasks (status todo/in_progress/review/done); the call is rejected without it. Omit only for parent tasks (any task with children) or status blocked/skipped. Raw human-effort hours, > 0 — do not pre-multiply by settings.ai_estimate_multiplier, which is applied at aggregation time." },
328
+ "actual_hours": { "type": "number", "description": "Hours actually spent. Prefer handoff_log_time, which adds to this and decrements remaining_hours atomically." },
328
329
  "remaining_hours": { "type": "number", "description": "Hours remaining. Auto-decremented by handoff_log_time." },
329
330
  "milestone": { "type": "string" },
330
331
  "pinned": { "type": "boolean", "description": "If true, dates are locked and auto-scheduler skips this task." }
@@ -863,7 +864,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
863
864
  },
864
865
  ToolDefinition {
865
866
  name: "handoff_bulk_update_tasks".to_string(),
866
- description: "Update multiple tasks in one call. Useful for applying auto-schedule results or bulk status/assignee changes.".to_string(),
867
+ description: "Update multiple tasks in one call. Useful for applying auto-schedule results or bulk status/assignee changes. Enforces the same estimate rule as handoff_update_task: a leaf task left in status todo/in_progress/review/done must carry schedule.estimate_hours (> 0). Offending updates are rejected individually and reported in errors[]; the rest still apply.".to_string(),
867
868
  input_schema: json!({
868
869
  "type": "object",
869
870
  "properties": {
@@ -873,26 +874,27 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
873
874
  },
874
875
  "updates": {
875
876
  "type": "array",
876
- "description": "Array of task updates to apply.",
877
+ "description": "Array of task updates to apply. Each is validated on its own: if an update would leave a leaf task in status todo/in_progress/review/done without schedule.estimate_hours, that update is rejected and listed in errors[] while the others still apply. Supply estimate_hours in the same update to move an estimateless task out of blocked/skipped.",
877
878
  "items": {
878
879
  "type": "object",
879
880
  "properties": {
880
881
  "task_id": { "type": "string", "description": "Task ID to update." },
881
- "status": { "type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"] },
882
+ "status": { "type": "string", "enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"], "description": "Moving a leaf task into todo/in_progress/review/done requires schedule.estimate_hours to be present or supplied in the same update. Parent tasks (any task with children) and the statuses blocked/skipped are exempt." },
882
883
  "priority": { "type": "string", "enum": ["low", "medium", "high"] },
883
884
  "assignee": { "type": "string" },
884
885
  "notes": { "type": "string", "description": "Replace task notes." },
885
886
  "notes_append": { "type": "string", "description": "Append text to existing notes with a timestamp heading. If both notes and notes_append are provided, notes (replace) takes precedence." },
886
887
  "schedule": {
887
888
  "type": "object",
889
+ "description": "Schedule fields to merge. Omitted fields are preserved, not cleared.",
888
890
  "properties": {
889
- "start_date": { "type": "string" },
890
- "due_date": { "type": "string" },
891
- "estimate_hours": { "type": "number" },
892
- "actual_hours": { "type": "number" },
893
- "remaining_hours": { "type": "number" },
891
+ "start_date": { "type": "string", "description": "YYYY-MM-DD" },
892
+ "due_date": { "type": "string", "description": "YYYY-MM-DD" },
893
+ "estimate_hours": { "type": "number", "description": "REQUIRED for a leaf task left in status todo/in_progress/review/done; the update is rejected without it. Omit only for parent tasks (any task with children) or status blocked/skipped. Raw human-effort hours, > 0 — do not pre-multiply by settings.ai_estimate_multiplier, which is applied at aggregation time." },
894
+ "actual_hours": { "type": "number", "description": "Hours actually spent. Prefer handoff_log_time, which adds to this and decrements remaining_hours atomically." },
895
+ "remaining_hours": { "type": "number", "description": "Hours remaining. Auto-decremented by handoff_log_time." },
894
896
  "milestone": { "type": "string" },
895
- "pinned": { "type": "boolean" }
897
+ "pinned": { "type": "boolean", "description": "If true, dates are locked and auto-scheduler skips this task." }
896
898
  }
897
899
  }
898
900
  },
@@ -1232,6 +1234,176 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1232
1234
  "required": ["task_id"]
1233
1235
  }),
1234
1236
  },
1237
+ // ---- Document management tools (P1-6a, v5 rearchitecture: wiki/130-document-management.md §3.1) ----
1238
+ ToolDefinition {
1239
+ name: "handoff_doc_save".to_string(),
1240
+ description: "Create or update a document from a full Markdown body. The body is stored verbatim at _doc.<slug>.md and split in-memory into a `sections` byte-offset index (no per-section files), syncing the bidirectional task<->doc link when task_ids is given. Omit doc_id to create a new document (slug is then required and must be unique); pass an existing doc_id to update it (slug is taken from the existing document — it cannot be renamed via doc_save). Returns a JSON string {doc_id,slug,title,doc_type,section_count,content_hash,warnings:[…]} — warnings lists any task_ids that could not be resolved.".to_string(),
1241
+ input_schema: json!({
1242
+ "type": "object",
1243
+ "properties": {
1244
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1245
+ "doc_id": { "type": "string", "description": "Existing document id to update. Omit to create a new document." },
1246
+ "slug": { "type": "string", "description": "Human-readable file-naming slug ([a-z0-9-], max 60 chars), used to name _doc.<slug>.json/.md. Required when creating; ignored on update (the existing document's slug is kept)." },
1247
+ "title": { "type": "string", "description": "Document title. Required when creating; optional on update (defaults to the existing title)." },
1248
+ "body": { "type": "string", "description": "Full Markdown body. Required." },
1249
+ "doc_type": { "type": "string", "description": "Document type.", "enum": ["spec", "design", "adr", "guide", "note"], "default": "note" },
1250
+ "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags for filtering/search." },
1251
+ "scope_paths": { "type": "array", "items": { "type": "string" }, "description": "Path prefixes this document applies to; boosts relevance in doc_list(query=...) when a file path matches." },
1252
+ "parent_id": { "type": "string", "description": "Parent document id (family tree)." },
1253
+ "task_ids": { "type": "array", "items": { "type": "string" }, "description": "Task ids to link bidirectionally. On update, ids removed from this list are unlinked; ids added are linked." },
1254
+ "related": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "rel": { "type": "string", "enum": ["supersedes", "references", "implements", "extends", "conflicts"] } }, "required": ["id", "rel"] }, "description": "Sibling/relative relationships to other documents." },
1255
+ "split_level": { "type": "integer", "description": "ATX heading level at/above which the body is split into sections.", "default": 2 },
1256
+ "auto_inject": { "type": "string", "description": "Auto-injection control.", "enum": ["auto", "full", "outline", "none"], "default": "auto" }
1257
+ },
1258
+ "required": ["body"]
1259
+ }),
1260
+ },
1261
+ ToolDefinition {
1262
+ name: "handoff_doc_get".to_string(),
1263
+ 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(),
1264
+ input_schema: json!({
1265
+ "type": "object",
1266
+ "properties": {
1267
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1268
+ "doc_id": { "type": "string", "description": "Document id or slug to read." },
1269
+ "format": { "type": "string", "description": "Read mode.", "enum": ["full", "meta", "section"], "default": "full" },
1270
+ "seq": { "type": "integer", "description": "Section sequence number. Required when format='section'." }
1271
+ },
1272
+ "required": ["doc_id"]
1273
+ }),
1274
+ },
1275
+ ToolDefinition {
1276
+ name: "handoff_doc_list".to_string(),
1277
+ description: "List/search documents. Filters (doc_type, tags [AND — every tag must be present], task_id) are applied first; an optional query BM25-ranks the survivors by title + tags + body text. include_body includes each matching document's full body, read from _doc.<slug>.md (default false — metadata only). Returns a JSON string {documents:[…]}.".to_string(),
1278
+ input_schema: json!({
1279
+ "type": "object",
1280
+ "properties": {
1281
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1282
+ "doc_type": { "type": "string", "description": "Filter by document type." },
1283
+ "tags": { "type": "array", "items": { "type": "string" }, "description": "Filter: document must have every listed tag (AND)." },
1284
+ "task_id": { "type": "string", "description": "Filter: only documents linked to this task." },
1285
+ "include_body": { "type": "boolean", "description": "Include each document's full body.", "default": false },
1286
+ "query": { "type": "string", "description": "BM25 text search over title + tags + body." }
1287
+ }
1288
+ }),
1289
+ },
1290
+ ToolDefinition {
1291
+ name: "handoff_doc_delete".to_string(),
1292
+ description: "Delete a document (by doc_id or slug) and its body file. Unlinks the document from any linked tasks' task_links, removes it from its parent's children list, and clears parent_id on any of its own children (orphaning them — delete does not cascade to descendants). Returns a JSON string {deleted,doc_id,section_count,warnings:[…]}.".to_string(),
1293
+ input_schema: json!({
1294
+ "type": "object",
1295
+ "properties": {
1296
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1297
+ "doc_id": { "type": "string", "description": "Document id or slug to delete." }
1298
+ },
1299
+ "required": ["doc_id"]
1300
+ }),
1301
+ },
1302
+ ToolDefinition {
1303
+ name: "handoff_doc_reassemble".to_string(),
1304
+ description: "Read a document's (by doc_id or slug) original Markdown body directly from _doc.<slug>.md, restoring BOM/frontmatter, and detect drift (the body's current content hash no longer matches its recorded content_hash — e.g. edited directly outside doc_save). Optionally writes the body to output_path. Returns a JSON string {doc_id,body,drifted,output_path?}.".to_string(),
1305
+ input_schema: json!({
1306
+ "type": "object",
1307
+ "properties": {
1308
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1309
+ "doc_id": { "type": "string", "description": "Document id or slug to reassemble." },
1310
+ "output_path": { "type": "string", "description": "Optional filesystem path to write the reassembled body to." }
1311
+ },
1312
+ "required": ["doc_id"]
1313
+ }),
1314
+ },
1315
+ ToolDefinition {
1316
+ name: "handoff_doc_tree".to_string(),
1317
+ description: "Traverse a document's family tree (parent/children) starting from doc_id (id or slug), up to depth levels of descendants, plus the immediate parent (if any). include_related additionally attaches the document's related (semantic) links. Returns a JSON string tree {id,title,doc_type,parent,children:[…],related:[…]}.".to_string(),
1318
+ input_schema: json!({
1319
+ "type": "object",
1320
+ "properties": {
1321
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1322
+ "doc_id": { "type": "string", "description": "Root document id or slug to traverse from." },
1323
+ "depth": { "type": "integer", "description": "How many levels of children to descend.", "default": 2 },
1324
+ "include_related": { "type": "boolean", "description": "Also include the root document's `related` entries.", "default": false }
1325
+ },
1326
+ "required": ["doc_id"]
1327
+ }),
1328
+ },
1329
+ ToolDefinition {
1330
+ name: "handoff_doc_verify".to_string(),
1331
+ 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), or set_refs (update impl_refs/test_refs for fragment_seq). 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}.".to_string(),
1332
+ input_schema: json!({
1333
+ "type": "object",
1334
+ "properties": {
1335
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1336
+ "doc_id": { "type": "string", "description": "Document id or slug to operate on." },
1337
+ "action": { "type": "string", "description": "Verification matrix action.", "enum": ["generate", "check", "skip", "sync", "set_refs"] },
1338
+ "skip_seqs": { "type": "array", "items": { "type": "integer" }, "description": "generate only: section seqs to create as 'skipped' instead of 'pending'." },
1339
+ "fragment_seq": { "type": "integer", "description": "check/skip/set_refs: the section seq (VerificationItem.fragment_seq) to operate on." },
1340
+ "reviewer": { "type": "string", "description": "check: who verified it.", "enum": ["ai", "user"] },
1341
+ "notes": { "type": "string", "description": "check: optional free-text note." },
1342
+ "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." },
1343
+ "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." }
1344
+ },
1345
+ "required": ["doc_id", "action"]
1346
+ }),
1347
+ },
1348
+ ToolDefinition {
1349
+ name: "handoff_doc_verify_status".to_string(),
1350
+ 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(),
1351
+ input_schema: json!({
1352
+ "type": "object",
1353
+ "properties": {
1354
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1355
+ "doc_id": { "type": "string", "description": "Document id or slug to read verification status for." },
1356
+ "include_items": { "type": "boolean", "description": "Include the full per-item list (with stale detection).", "default": false }
1357
+ },
1358
+ "required": ["doc_id"]
1359
+ }),
1360
+ },
1361
+ ToolDefinition {
1362
+ name: "handoff_doc_query".to_string(),
1363
+ description: "Inject document sections relevant to the current prompt/file/task (hook-driven context injection, mirrors memory_query at section granularity). Ranks by BM25 relevance + scope_paths match + task_id affinity, then stages each result as 'full' (whole section body, when its token estimate is within the inline threshold) or 'outline' (heading + sibling table of contents only, for larger sections — fetch the body via doc_get(format='section')). With session_id, already-injected sections (same content_hash) are skipped this session; mark_injected (default true) records survivors. suppress_doc_ids excludes given documents from this call's results; combined with suppress_until_changed=true (requires session_id), the suppression is recorded in the session's injected sidecar and persists across future calls until that document's content_hash changes. Returns a JSON string {documents:[…],injected_count}.".to_string(),
1364
+ input_schema: json!({
1365
+ "type": "object",
1366
+ "properties": {
1367
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1368
+ "text": { "type": "string", "description": "Prompt/query text to rank sections against." },
1369
+ "file_paths": { "type": "array", "items": { "type": "string" }, "description": "File paths in play; boosts documents whose scope_paths match." },
1370
+ "task_id": { "type": "string", "description": "Boost sections belonging to documents linked to this task (highest-weight ranking signal)." },
1371
+ "session_id": { "type": "string", "description": "Session id for per-session diff injection (skips sections already injected at their current content_hash)." },
1372
+ "limit": { "type": "integer", "description": "Max number of sections to return.", "default": 5 },
1373
+ "mark_injected": { "type": "boolean", "description": "Record returned sections in the session's injected sidecar.", "default": true },
1374
+ "suppress_doc_ids": { "type": "array", "items": { "type": "string" }, "description": "Document ids to exclude entirely from this call's results." },
1375
+ "suppress_until_changed": { "type": "boolean", "description": "With suppress_doc_ids and session_id: persist the suppression in the session's injected sidecar so those documents stay excluded from future doc_query calls until their content_hash changes.", "default": false }
1376
+ }
1377
+ }),
1378
+ },
1379
+ ToolDefinition {
1380
+ name: "handoff_doc_analyze".to_string(),
1381
+ description: "Read-only scan of a Markdown file or directory (never writes). Auto-detects doc_type (keyword scan), tags (frontmatter + heading tokens), scope_paths (code/inline file paths), and a suggested_slug (derived from title) per file; extracts and classifies Markdown links (internal/external/broken); proposes a parent/children tree from directory structure (skip with flatten=true). Returns a JSON conditioning report {files_scanned,auto_resolved:[…],needs_review:[…],proposed_tree:{…}} for AI review before handoff_doc_import.".to_string(),
1382
+ input_schema: json!({
1383
+ "type": "object",
1384
+ "properties": {
1385
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1386
+ "path": { "type": "string", "description": "File or directory path (relative to project_dir) to scan." },
1387
+ "recursive": { "type": "boolean", "description": "Recurse into subdirectories when path is a directory.", "default": true },
1388
+ "flatten": { "type": "boolean", "description": "Skip parent/children tree inference; every file is a standalone document.", "default": false }
1389
+ },
1390
+ "required": ["path"]
1391
+ }),
1392
+ },
1393
+ ToolDefinition {
1394
+ name: "handoff_doc_import".to_string(),
1395
+ description: "Bulk-write an analyzed payload (from handoff_doc_analyze, with the AI's overrides applied) as new documents. Each analyzed.auto_resolved entry must carry its file's full Markdown 'body' (doc_import writes from the payload, it does not re-read the filesystem). Each document's slug is taken from its override's 'slug' if given, else its suggested_slug, disambiguated with a numeric suffix on collision. Persists every file as a document, applies proposed_tree parent/children relationships, links task_ids to every imported document (bidirectionally), and invalidates the doc corpus cache. Returns a JSON string {imported_count,documents:[{doc_id,slug,title,section_count}],warnings:[…]}.".to_string(),
1396
+ input_schema: json!({
1397
+ "type": "object",
1398
+ "properties": {
1399
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1400
+ "analyzed": { "type": "object", "description": "The handoff_doc_analyze report, with each auto_resolved entry additionally carrying its file's 'body'." },
1401
+ "overrides": { "type": "array", "items": { "type": "object", "properties": { "file": { "type": "string" }, "slug": { "type": "string" }, "title": { "type": "string" }, "doc_type": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "scope_paths": { "type": "array", "items": { "type": "string" } } }, "required": ["file"] }, "description": "Per-file AI overrides applied on top of analyzed.auto_resolved before writing." },
1402
+ "task_ids": { "type": "array", "items": { "type": "string" }, "description": "Link every imported document to these tasks (bidirectionally)." }
1403
+ },
1404
+ "required": ["analyzed"]
1405
+ }),
1406
+ },
1235
1407
  ]
1236
1408
  }
1237
1409