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/Cargo.lock +52 -11
- package/Cargo.toml +2 -1
- package/README.md +140 -4
- package/package.json +5 -2
- package/scripts/sync-plugin-skills.sh +23 -1
- package/scripts/sync-workflow-inline.sh +138 -0
- package/skills/handoff/SKILL.md +61 -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/bulk_update.rs +30 -2
- package/src/mcp/handlers/docs.rs +1043 -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 +120 -13
- package/src/mcp/handlers/memory.rs +33 -53
- package/src/mcp/handlers/mod.rs +13 -0
- package/src/mcp/handlers/update_task.rs +22 -4
- package/src/mcp/tools.rs +185 -13
- 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 +197 -5
|
@@ -22,6 +22,14 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
22
22
|
let (data, status) = read_task(&task_dir)?
|
|
23
23
|
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
24
24
|
|
|
25
|
+
// `links` stays the legacy `Vec<String>` for backward compatibility with
|
|
26
|
+
// existing clients (skills / VSCode extension). `task_links` is an
|
|
27
|
+
// additive field carrying the normalized, deduplicated view from the
|
|
28
|
+
// `links()` accessor (wiki/130-document-management.md §9.1), so callers
|
|
29
|
+
// that understand typed links (doc/url/file/task) can read them without
|
|
30
|
+
// re-deriving the merge themselves.
|
|
31
|
+
let normalized_links = data.links();
|
|
32
|
+
|
|
25
33
|
let result = serde_json::json!({
|
|
26
34
|
"id": data.id,
|
|
27
35
|
"title": data.title,
|
|
@@ -33,6 +41,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
33
41
|
"completed_at": data.completed_at,
|
|
34
42
|
"labels": data.labels,
|
|
35
43
|
"links": data.links,
|
|
44
|
+
"task_links": normalized_links,
|
|
36
45
|
"done_criteria": data.done_criteria,
|
|
37
46
|
"schedule": data.schedule,
|
|
38
47
|
"dependencies": data.dependencies,
|
|
@@ -30,16 +30,46 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
30
30
|
.and_then(|v| v.as_str())
|
|
31
31
|
.unwrap_or("other");
|
|
32
32
|
|
|
33
|
+
let require_estimate_hours = read_config(&config_path)
|
|
34
|
+
.map(|c| c.settings.require_estimate_hours)
|
|
35
|
+
.unwrap_or(true);
|
|
36
|
+
|
|
33
37
|
let mut tasks_created: u32 = 0;
|
|
34
38
|
let mut top_level_count: u32 = 0;
|
|
35
39
|
let mut nested_count: u32 = 0;
|
|
36
40
|
|
|
37
41
|
if let Some(tasks) = arguments.get("tasks").and_then(|v| v.as_array()) {
|
|
42
|
+
// Validate the whole payload before creating anything. Import writes many
|
|
43
|
+
// tasks in one call, so a rejection discovered mid-tree would otherwise
|
|
44
|
+
// leave a half-written forest behind and burn task IDs —
|
|
45
|
+
// `next_top_level_id` counts directories, not task files.
|
|
46
|
+
//
|
|
47
|
+
// Nothing is created yet, so `next_top_level_id` keeps returning the same
|
|
48
|
+
// value; project the IDs the writing pass below will assign, purely so the
|
|
49
|
+
// rejection names the task the caller sent.
|
|
50
|
+
let first_id = next_top_level_id(&tasks_dir)?;
|
|
51
|
+
let first_n: u32 = first_id
|
|
52
|
+
.strip_prefix('t')
|
|
53
|
+
.and_then(|n| n.parse().ok())
|
|
54
|
+
.with_context(|| format!("Unexpected task id form: {first_id}"))?;
|
|
55
|
+
let mut pending_deps: Vec<(String, Vec<String>)> = Vec::new();
|
|
56
|
+
for (i, task_val) in tasks.iter().enumerate() {
|
|
57
|
+
let projected_id = format!("t{}", first_n + i as u32);
|
|
58
|
+
validate_task_recursive(
|
|
59
|
+
&projected_id,
|
|
60
|
+
task_val,
|
|
61
|
+
require_estimate_hours,
|
|
62
|
+
&mut pending_deps,
|
|
63
|
+
)?;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check the dependency graph once, with every task in this payload already
|
|
67
|
+
// in it. Task-at-a-time checking would both reject a valid dependency on a
|
|
68
|
+
// sibling still unwritten and miss a cycle confined to the payload.
|
|
69
|
+
validate_dependencies_batch(&tasks_dir, &pending_deps)?;
|
|
70
|
+
|
|
38
71
|
for task_val in tasks {
|
|
39
|
-
let title = task_val
|
|
40
|
-
.get("title")
|
|
41
|
-
.and_then(|v| v.as_str())
|
|
42
|
-
.ok_or_else(|| anyhow::anyhow!("Each task requires a 'title'"))?;
|
|
72
|
+
let title = task_title(task_val)?;
|
|
43
73
|
|
|
44
74
|
let task_id = next_top_level_id(&tasks_dir)?;
|
|
45
75
|
let count = create_task_recursive(&tasks_dir, &task_id, None, title, task_val)?;
|
|
@@ -204,6 +234,84 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
204
234
|
Ok(msg)
|
|
205
235
|
}
|
|
206
236
|
|
|
237
|
+
// The validating pass and the writing pass must read the payload identically, or
|
|
238
|
+
// a task could pass validation and then be written as something else. These three
|
|
239
|
+
// accessors are the single source of truth for the fields the estimate rule keys
|
|
240
|
+
// on; both passes go through them rather than reaching into `task_val` directly.
|
|
241
|
+
|
|
242
|
+
fn task_title(task_val: &Value) -> Result<&str> {
|
|
243
|
+
task_val
|
|
244
|
+
.get("title")
|
|
245
|
+
.and_then(|v| v.as_str())
|
|
246
|
+
.ok_or_else(|| anyhow::anyhow!("Each task requires a 'title'"))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
fn task_status(task_val: &Value) -> &str {
|
|
250
|
+
task_val
|
|
251
|
+
.get("status")
|
|
252
|
+
.and_then(|v| v.as_str())
|
|
253
|
+
.unwrap_or("todo")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fn task_children(task_val: &Value) -> Option<&Vec<Value>> {
|
|
257
|
+
task_val.get("children").and_then(|v| v.as_array())
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Check one task subtree against the rules `create_task_recursive` would
|
|
261
|
+
/// otherwise only discover after writing earlier siblings to disk.
|
|
262
|
+
///
|
|
263
|
+
/// `has_children` is read from the payload, not the filesystem: at this point the
|
|
264
|
+
/// child directories do not exist, so a filesystem probe would call every parent
|
|
265
|
+
/// a leaf and demand an estimate it is exempt from.
|
|
266
|
+
fn validate_task_recursive(
|
|
267
|
+
task_id: &str,
|
|
268
|
+
task_val: &Value,
|
|
269
|
+
require_estimate_hours: bool,
|
|
270
|
+
pending_deps: &mut Vec<(String, Vec<String>)>,
|
|
271
|
+
) -> Result<()> {
|
|
272
|
+
let title = task_title(task_val)?;
|
|
273
|
+
|
|
274
|
+
let status = task_status(task_val);
|
|
275
|
+
if !is_valid_status(status) {
|
|
276
|
+
anyhow::bail!("Invalid status: {status}");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
validate_priority(task_val.get("priority").and_then(|v| v.as_str()))?;
|
|
280
|
+
|
|
281
|
+
let children = task_children(task_val);
|
|
282
|
+
// An empty `children` array creates no child tasks, so such a task is a leaf
|
|
283
|
+
// and owes an estimate — matching what the writing pass will produce.
|
|
284
|
+
let has_children = children.is_some_and(|c| !c.is_empty());
|
|
285
|
+
|
|
286
|
+
// import only ever creates, so `is_create` is true and the resend example
|
|
287
|
+
// carries `title` — the caller has no stored task to preserve it.
|
|
288
|
+
validate_estimate_required(
|
|
289
|
+
require_estimate_hours,
|
|
290
|
+
task_id,
|
|
291
|
+
title,
|
|
292
|
+
status,
|
|
293
|
+
has_children,
|
|
294
|
+
true,
|
|
295
|
+
extract_schedule(task_val).as_ref(),
|
|
296
|
+
)?;
|
|
297
|
+
|
|
298
|
+
// Record this task's edges under the ID the writing pass will give it, so the
|
|
299
|
+
// batch cycle check below sees the graph as it will exist after the import.
|
|
300
|
+
pending_deps.push((
|
|
301
|
+
task_id.to_string(),
|
|
302
|
+
extract_string_array_from(task_val, "dependencies"),
|
|
303
|
+
));
|
|
304
|
+
|
|
305
|
+
if let Some(children) = children {
|
|
306
|
+
for (i, child_val) in children.iter().enumerate() {
|
|
307
|
+
let child_id = format!("{task_id}.{}", i + 1);
|
|
308
|
+
validate_task_recursive(&child_id, child_val, require_estimate_hours, pending_deps)?;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
Ok(())
|
|
313
|
+
}
|
|
314
|
+
|
|
207
315
|
fn create_task_recursive(
|
|
208
316
|
tasks_dir: &std::path::Path,
|
|
209
317
|
task_id: &str,
|
|
@@ -220,11 +328,12 @@ fn create_task_recursive(
|
|
|
220
328
|
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
221
329
|
|
|
222
330
|
let now = Utc::now().to_rfc3339();
|
|
223
|
-
let status = task_val
|
|
224
|
-
.get("status")
|
|
225
|
-
.and_then(|v| v.as_str())
|
|
226
|
-
.unwrap_or("todo");
|
|
331
|
+
let status = task_status(task_val);
|
|
227
332
|
|
|
333
|
+
// `validate_task_recursive` has already passed on this payload, so these two
|
|
334
|
+
// cannot fail here. They stay as defense-in-depth: this function writes to
|
|
335
|
+
// disk, and a future caller that forgets the pre-pass must not slip an
|
|
336
|
+
// invalid status or priority through unchecked.
|
|
228
337
|
if !is_valid_status(status) {
|
|
229
338
|
anyhow::bail!("Invalid status: {status}");
|
|
230
339
|
}
|
|
@@ -251,6 +360,7 @@ fn create_task_recursive(
|
|
|
251
360
|
completed_at,
|
|
252
361
|
labels: extract_string_array_from(task_val, "labels"),
|
|
253
362
|
links: extract_string_array_from(task_val, "links"),
|
|
363
|
+
task_links: Vec::new(),
|
|
254
364
|
done_criteria: extract_done_criteria(task_val),
|
|
255
365
|
schedule: extract_schedule(task_val),
|
|
256
366
|
dependencies: extract_string_array_from(task_val, "dependencies"),
|
|
@@ -269,12 +379,9 @@ fn create_task_recursive(
|
|
|
269
379
|
|
|
270
380
|
let mut count: u32 = 1;
|
|
271
381
|
|
|
272
|
-
if let Some(children) = task_val
|
|
382
|
+
if let Some(children) = task_children(task_val) {
|
|
273
383
|
for (i, child_val) in children.iter().enumerate() {
|
|
274
|
-
let child_title = child_val
|
|
275
|
-
.get("title")
|
|
276
|
-
.and_then(|v| v.as_str())
|
|
277
|
-
.ok_or_else(|| anyhow::anyhow!("Each child task requires a 'title'"))?;
|
|
384
|
+
let child_title = task_title(child_val)?;
|
|
278
385
|
|
|
279
386
|
let child_id = format!("{task_id}.{}", i + 1);
|
|
280
387
|
count += create_task_recursive(
|
|
@@ -11,6 +11,7 @@ use serde_json::{json, Value};
|
|
|
11
11
|
use std::path::Path;
|
|
12
12
|
|
|
13
13
|
use super::resolve_project_dir;
|
|
14
|
+
use crate::context::injection::{filter_already_injected, rank_by_bm25_and_scope, RankConfig};
|
|
14
15
|
use crate::storage::config::{read_config, SettingsConfig};
|
|
15
16
|
use crate::storage::ensure_handoff_exists;
|
|
16
17
|
use crate::storage::memory::{
|
|
@@ -277,49 +278,47 @@ pub fn handle_query(arguments: &Value) -> Result<String> {
|
|
|
277
278
|
for p in &file_paths {
|
|
278
279
|
query_tokens.extend(lexsim::tokenize(&basename(p)));
|
|
279
280
|
}
|
|
280
|
-
let scores = corpus.bm25_scores_tokens(&query_tokens);
|
|
281
281
|
|
|
282
|
-
// Score + scope-path bonus, then threshold and rank.
|
|
283
|
-
let
|
|
284
|
-
|
|
285
|
-
.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
282
|
+
// Score + scope-path bonus, then threshold and rank (shared with doc_query).
|
|
283
|
+
let scope_paths: Vec<Vec<String>> = memories.iter().map(|m| m.scope_paths.clone()).collect();
|
|
284
|
+
let rank_config = RankConfig {
|
|
285
|
+
min_score: settings.memory_query_min_score,
|
|
286
|
+
scope_path_bonus: SCOPE_PATH_BONUS,
|
|
287
|
+
// Rank without truncating yet — the session diff below needs the full
|
|
288
|
+
// ranked order so it can backfill past already-injected memories.
|
|
289
|
+
limit: memories.len(),
|
|
290
|
+
};
|
|
291
|
+
let ranked = rank_by_bm25_and_scope(
|
|
292
|
+
&corpus,
|
|
293
|
+
&query_tokens,
|
|
294
|
+
&scope_paths,
|
|
295
|
+
&file_paths,
|
|
296
|
+
&rank_config,
|
|
297
|
+
);
|
|
296
298
|
|
|
297
299
|
// Per-session diff: drop memories already injected this session at the same
|
|
298
300
|
// hash. The `limit` is applied to the *fresh* set so the caller still gets up
|
|
299
301
|
// to `limit` new memories even when earlier prompts already consumed some.
|
|
300
302
|
let now = now_rfc3339();
|
|
301
303
|
let injected_set = session_id.map(|sid| read_injected_set(&handoff, sid, &now));
|
|
302
|
-
let
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
})
|
|
311
|
-
.take(limit)
|
|
312
|
-
.collect();
|
|
304
|
+
let already_injected = |i: usize| match &injected_set {
|
|
305
|
+
Some(set) => {
|
|
306
|
+
let m = &memories[i];
|
|
307
|
+
set.already_injected(&m.id, &m.content_hash)
|
|
308
|
+
}
|
|
309
|
+
None => false,
|
|
310
|
+
};
|
|
311
|
+
let fresh = filter_already_injected(ranked, already_injected, limit);
|
|
313
312
|
|
|
314
313
|
let out: Vec<Value> = fresh
|
|
315
314
|
.iter()
|
|
316
|
-
.map(|
|
|
317
|
-
let m = &memories[
|
|
315
|
+
.map(|item| {
|
|
316
|
+
let m = &memories[item.index];
|
|
318
317
|
json!({
|
|
319
318
|
"id": m.id,
|
|
320
319
|
"text": m.text,
|
|
321
320
|
"kind": m.kind,
|
|
322
|
-
"score": round2(
|
|
321
|
+
"score": round2(item.score),
|
|
323
322
|
})
|
|
324
323
|
})
|
|
325
324
|
.collect();
|
|
@@ -352,13 +351,13 @@ fn mark_injected_memories(
|
|
|
352
351
|
handoff: &std::path::Path,
|
|
353
352
|
session_id: &str,
|
|
354
353
|
memories: &[MemoryEntry],
|
|
355
|
-
fresh: &[
|
|
354
|
+
fresh: &[crate::context::injection::RankItem],
|
|
356
355
|
now: &str,
|
|
357
356
|
) -> Result<()> {
|
|
358
357
|
let mut set = read_injected_set(handoff, session_id, now);
|
|
359
358
|
set.updated_at = now.to_string();
|
|
360
|
-
for
|
|
361
|
-
let m = &memories[
|
|
359
|
+
for item in fresh {
|
|
360
|
+
let m = &memories[item.index];
|
|
362
361
|
set.mark(&m.id, &m.content_hash);
|
|
363
362
|
}
|
|
364
363
|
// (1) Persist the suppression record first — this is the correctness-critical
|
|
@@ -369,8 +368,8 @@ fn mark_injected_memories(
|
|
|
369
368
|
// concurrent edit's other fields. A failure here is non-fatal: the sidecar
|
|
370
369
|
// already recorded the injection, so the worst case is a slightly stale
|
|
371
370
|
// hit_count — never a re-spam or a double count.
|
|
372
|
-
for
|
|
373
|
-
let m = &memories[
|
|
371
|
+
for item in fresh {
|
|
372
|
+
let m = &memories[item.index];
|
|
374
373
|
if let Ok(Some(mut entry)) = read_memory_by_id(handoff, &m.id) {
|
|
375
374
|
entry.hit_count = entry.hit_count.saturating_add(1);
|
|
376
375
|
entry.last_referenced_at = Some(now.to_string());
|
|
@@ -742,16 +741,6 @@ impl UnionFind {
|
|
|
742
741
|
}
|
|
743
742
|
}
|
|
744
743
|
|
|
745
|
-
/// True if any `scope` prefix matches any `file` path.
|
|
746
|
-
fn scope_matches(scopes: &[String], files: &[String]) -> bool {
|
|
747
|
-
if scopes.is_empty() || files.is_empty() {
|
|
748
|
-
return false;
|
|
749
|
-
}
|
|
750
|
-
scopes
|
|
751
|
-
.iter()
|
|
752
|
-
.any(|scope| files.iter().any(|f| f.contains(scope.as_str())))
|
|
753
|
-
}
|
|
754
|
-
|
|
755
744
|
/// Last path component of `p` (handles both `/` and `\` separators).
|
|
756
745
|
fn basename(p: &str) -> String {
|
|
757
746
|
p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
|
|
@@ -796,15 +785,6 @@ mod tests {
|
|
|
796
785
|
assert_eq!(basename("plain.rs"), "plain.rs");
|
|
797
786
|
}
|
|
798
787
|
|
|
799
|
-
#[test]
|
|
800
|
-
fn scope_matches_prefix() {
|
|
801
|
-
let scopes = vec!["src/storage/".to_string()];
|
|
802
|
-
let files = vec!["/repo/src/storage/mod.rs".to_string()];
|
|
803
|
-
assert!(scope_matches(&scopes, &files));
|
|
804
|
-
let files2 = vec!["/repo/src/mcp/mod.rs".to_string()];
|
|
805
|
-
assert!(!scope_matches(&scopes, &files2));
|
|
806
|
-
}
|
|
807
|
-
|
|
808
788
|
#[test]
|
|
809
789
|
fn string_array_parsing() {
|
|
810
790
|
let v = json!({ "tags": ["a", "b", 3, "c"] });
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -7,6 +7,8 @@ pub mod check_criterion;
|
|
|
7
7
|
pub mod config;
|
|
8
8
|
pub mod config_crud;
|
|
9
9
|
pub mod dashboard;
|
|
10
|
+
pub mod docs;
|
|
11
|
+
pub mod docs_query;
|
|
10
12
|
pub mod fork_session;
|
|
11
13
|
pub mod get_session;
|
|
12
14
|
pub mod get_task;
|
|
@@ -95,6 +97,17 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
95
97
|
"handoff_timer_start" => timer::handle_start(arguments),
|
|
96
98
|
"handoff_timer_stop" => timer::handle_stop(arguments),
|
|
97
99
|
"handoff_timer_get_time" => timer::handle_get_time(arguments),
|
|
100
|
+
"handoff_doc_save" => docs::handle_doc_save(arguments),
|
|
101
|
+
"handoff_doc_get" => docs::handle_doc_get(arguments),
|
|
102
|
+
"handoff_doc_list" => docs::handle_doc_list(arguments),
|
|
103
|
+
"handoff_doc_delete" => docs::handle_doc_delete(arguments),
|
|
104
|
+
"handoff_doc_reassemble" => docs::handle_doc_reassemble(arguments),
|
|
105
|
+
"handoff_doc_tree" => docs::handle_doc_tree(arguments),
|
|
106
|
+
"handoff_doc_verify" => docs::handle_doc_verify(arguments),
|
|
107
|
+
"handoff_doc_verify_status" => docs::handle_doc_verify_status(arguments),
|
|
108
|
+
"handoff_doc_query" => docs_query::handle_doc_query(arguments),
|
|
109
|
+
"handoff_doc_analyze" => docs_query::handle_doc_analyze(arguments),
|
|
110
|
+
"handoff_doc_import" => docs_query::handle_doc_import(arguments),
|
|
98
111
|
_ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
|
|
99
112
|
};
|
|
100
113
|
|
|
@@ -82,8 +82,6 @@ fn handle_create(
|
|
|
82
82
|
let slug = title_to_slug(title);
|
|
83
83
|
let dir_name = format!("{new_id}-{slug}");
|
|
84
84
|
let task_dir = parent_dir.join(&dir_name);
|
|
85
|
-
std::fs::create_dir_all(&task_dir)
|
|
86
|
-
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
87
85
|
|
|
88
86
|
let now = Utc::now().to_rfc3339();
|
|
89
87
|
let status = task_val
|
|
@@ -116,6 +114,7 @@ fn handle_create(
|
|
|
116
114
|
completed_at: None,
|
|
117
115
|
labels: extract_string_array(task_val, "labels"),
|
|
118
116
|
links: extract_string_array(task_val, "links"),
|
|
117
|
+
task_links: Vec::new(),
|
|
119
118
|
done_criteria: extract_done_criteria(task_val),
|
|
120
119
|
schedule: extract_schedule(task_val),
|
|
121
120
|
dependencies,
|
|
@@ -133,11 +132,20 @@ fn handle_create(
|
|
|
133
132
|
// A newly created task is always a leaf (no children yet).
|
|
134
133
|
validate_estimate_required(
|
|
135
134
|
require_estimate_hours,
|
|
135
|
+
&new_id,
|
|
136
|
+
title,
|
|
136
137
|
status,
|
|
137
138
|
false,
|
|
139
|
+
true,
|
|
138
140
|
data.schedule.as_ref(),
|
|
139
141
|
)?;
|
|
140
142
|
|
|
143
|
+
// Create the directory only once every validation has passed. A rejected
|
|
144
|
+
// create must leave nothing behind: an orphan dir would burn the task ID,
|
|
145
|
+
// because `next_top_level_id` counts directories, not task files.
|
|
146
|
+
std::fs::create_dir_all(&task_dir)
|
|
147
|
+
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
148
|
+
|
|
141
149
|
write_task(&task_dir, status, &data)?;
|
|
142
150
|
|
|
143
151
|
Ok(format!("Created task {new_id}: {title} [{status}]"))
|
|
@@ -169,8 +177,6 @@ fn handle_upsert_create(
|
|
|
169
177
|
let slug = title_to_slug(title);
|
|
170
178
|
let dir_name = format!("{task_id}-{slug}");
|
|
171
179
|
let task_dir = parent_dir.join(&dir_name);
|
|
172
|
-
std::fs::create_dir_all(&task_dir)
|
|
173
|
-
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
174
180
|
|
|
175
181
|
let now = Utc::now().to_rfc3339();
|
|
176
182
|
let status = task_val
|
|
@@ -203,6 +209,7 @@ fn handle_upsert_create(
|
|
|
203
209
|
completed_at: None,
|
|
204
210
|
labels: extract_string_array(task_val, "labels"),
|
|
205
211
|
links: extract_string_array(task_val, "links"),
|
|
212
|
+
task_links: Vec::new(),
|
|
206
213
|
done_criteria: extract_done_criteria(task_val),
|
|
207
214
|
schedule: extract_schedule(task_val),
|
|
208
215
|
dependencies,
|
|
@@ -220,11 +227,19 @@ fn handle_upsert_create(
|
|
|
220
227
|
// Upsert-create: a brand-new task is a leaf.
|
|
221
228
|
validate_estimate_required(
|
|
222
229
|
require_estimate_hours,
|
|
230
|
+
task_id,
|
|
231
|
+
title,
|
|
223
232
|
status,
|
|
224
233
|
false,
|
|
234
|
+
true,
|
|
225
235
|
data.schedule.as_ref(),
|
|
226
236
|
)?;
|
|
227
237
|
|
|
238
|
+
// Create the directory only once every validation has passed, so a rejected
|
|
239
|
+
// upsert-create leaves no orphan dir shadowing the requested ID.
|
|
240
|
+
std::fs::create_dir_all(&task_dir)
|
|
241
|
+
.with_context(|| format!("Failed to create task dir: {}", task_dir.display()))?;
|
|
242
|
+
|
|
228
243
|
write_task(&task_dir, status, &data)?;
|
|
229
244
|
|
|
230
245
|
Ok(format!("Created task {task_id}: {title} [{status}]"))
|
|
@@ -336,8 +351,11 @@ fn handle_update(
|
|
|
336
351
|
let has_children = task_has_children(&task_dir)?;
|
|
337
352
|
validate_estimate_required(
|
|
338
353
|
require_estimate_hours,
|
|
354
|
+
task_id,
|
|
355
|
+
&data.title,
|
|
339
356
|
new_status,
|
|
340
357
|
has_children,
|
|
358
|
+
false,
|
|
341
359
|
data.schedule.as_ref(),
|
|
342
360
|
)?;
|
|
343
361
|
|