handoff-mcp-server 0.11.0 → 0.12.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 +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +8 -1
- package/src/mcp/handlers/capacity.rs +21 -6
- package/src/mcp/handlers/config.rs +17 -0
- package/src/mcp/handlers/metrics.rs +11 -0
- package/src/mcp/handlers/mod.rs +1 -0
- package/src/mcp/handlers/referrals.rs +20 -1
- package/src/mcp/handlers/update_task.rs +53 -4
- package/src/mcp/tools.rs +18 -0
- package/src/storage/config.rs +17 -0
- package/src/storage/referrals.rs +38 -0
- package/src/storage/tasks.rs +55 -0
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "MCP server that gives AI coding agents persistent memory across sessions",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
|
package/skills/handoff/SKILL.md
CHANGED
|
@@ -54,6 +54,13 @@ description: "Session handoff — load context at start, save at end, track task
|
|
|
54
54
|
user that their input is needed before proceeding.
|
|
55
55
|
- Create new tasks as work is discovered. Always include `done_criteria` with
|
|
56
56
|
verifiable items so completion can be tracked.
|
|
57
|
+
- **Always set `schedule.estimate_hours`** (raw human-effort hours, > 0) on every
|
|
58
|
+
leaf task. It is required by default — `handoff_update_task` rejects creating or
|
|
59
|
+
updating a leaf task without it (parent tasks and `blocked`/`skipped` tasks are
|
|
60
|
+
exempt). Enter the raw human-effort estimate; the AI-effort multiplier
|
|
61
|
+
(`settings.ai_estimate_multiplier`, default 0.2) is applied automatically at
|
|
62
|
+
aggregation time by `handoff_get_metrics`/`handoff_get_capacity`. To turn the
|
|
63
|
+
requirement off, set `settings.require_estimate_hours = false`.
|
|
57
64
|
|
|
58
65
|
### Progressive done_criteria Checking
|
|
59
66
|
- **Check off `done_criteria` immediately as each item is verified** — do not
|
|
@@ -91,7 +98,7 @@ description: "Session handoff — load context at start, save at end, track task
|
|
|
91
98
|
Use `handoff_log_time` to record hours worked on a task:
|
|
92
99
|
- Atomically adds to `schedule.actual_hours` and deducts from `schedule.remaining_hours`.
|
|
93
100
|
- Never overwrite `actual_hours` directly — always use the additive `handoff_log_time` tool.
|
|
94
|
-
- Set `schedule.estimate_hours` on task creation so metrics can show estimate vs actual.
|
|
101
|
+
- Set `schedule.estimate_hours` on task creation (required by default) so metrics can show estimate vs actual.
|
|
95
102
|
- When the handoff-vscode time tracker is enabled, the extension also logs time
|
|
96
103
|
automatically — the AI does not need to log time manually for extension-tracked tasks.
|
|
97
104
|
- `handoff_update_task`'s `schedule` field **merges** (partial update): passing
|
|
@@ -4,6 +4,7 @@ use serde_json::{json, Value};
|
|
|
4
4
|
use toml_edit::DocumentMut;
|
|
5
5
|
|
|
6
6
|
use super::resolve_project_dir;
|
|
7
|
+
use crate::storage::config::read_config;
|
|
7
8
|
use crate::storage::ensure_handoff_exists;
|
|
8
9
|
use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
|
|
9
10
|
|
|
@@ -54,9 +55,12 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
// Calculate allocated hours from tasks
|
|
58
|
+
let multiplier = read_config(&config_path)
|
|
59
|
+
.map(|c| c.settings.ai_estimate_multiplier)
|
|
60
|
+
.unwrap_or(0.2);
|
|
57
61
|
if tasks_dir.exists() {
|
|
58
62
|
let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
|
|
59
|
-
allocate_task_hours(&tree, assignee_filter, &start, &end, &mut days);
|
|
63
|
+
allocate_task_hours(&tree, assignee_filter, &start, &end, &mut days, multiplier);
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
let allocated_hours: f64 = days
|
|
@@ -260,6 +264,7 @@ fn allocate_task_hours(
|
|
|
260
264
|
start: &NaiveDate,
|
|
261
265
|
end: &NaiveDate,
|
|
262
266
|
days: &mut [Value],
|
|
267
|
+
multiplier: f64,
|
|
263
268
|
) {
|
|
264
269
|
for node in tree {
|
|
265
270
|
if is_terminal_status(&node.status) {
|
|
@@ -278,10 +283,13 @@ fn allocate_task_hours(
|
|
|
278
283
|
NaiveDate::parse_from_str(sd, "%Y-%m-%d"),
|
|
279
284
|
NaiveDate::parse_from_str(dd, "%Y-%m-%d"),
|
|
280
285
|
) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
286
|
+
// remaining_hours is already actual AI-effort progress, so it
|
|
287
|
+
// is used as-is; the raw estimate is human-effort and gets the
|
|
288
|
+
// AI multiplier applied to derive AI-effort allocation.
|
|
289
|
+
let est = match sched.remaining_hours {
|
|
290
|
+
Some(rem) => rem,
|
|
291
|
+
None => sched.estimate_hours.unwrap_or(0.0) * multiplier,
|
|
292
|
+
};
|
|
285
293
|
let overlap_start = (*start).max(task_start);
|
|
286
294
|
let overlap_end = (*end).min(task_end);
|
|
287
295
|
if overlap_start <= overlap_end {
|
|
@@ -311,6 +319,13 @@ fn allocate_task_hours(
|
|
|
311
319
|
}
|
|
312
320
|
}
|
|
313
321
|
|
|
314
|
-
allocate_task_hours(
|
|
322
|
+
allocate_task_hours(
|
|
323
|
+
&node.children,
|
|
324
|
+
assignee_filter,
|
|
325
|
+
start,
|
|
326
|
+
end,
|
|
327
|
+
days,
|
|
328
|
+
multiplier,
|
|
329
|
+
);
|
|
315
330
|
}
|
|
316
331
|
}
|
|
@@ -44,6 +44,8 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
44
44
|
"settings.history_limit",
|
|
45
45
|
"settings.done_task_limit",
|
|
46
46
|
"settings.auto_git_summary",
|
|
47
|
+
"settings.require_estimate_hours",
|
|
48
|
+
"settings.ai_estimate_multiplier",
|
|
47
49
|
"settings.context_files",
|
|
48
50
|
"dashboard.scan_dirs",
|
|
49
51
|
"dashboard.exclude_patterns",
|
|
@@ -87,6 +89,21 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
87
89
|
applied.push(format!("settings.auto_git_summary = {b}"));
|
|
88
90
|
}
|
|
89
91
|
}
|
|
92
|
+
"settings.require_estimate_hours" => {
|
|
93
|
+
if let Some(b) = value.as_bool() {
|
|
94
|
+
config.settings.require_estimate_hours = b;
|
|
95
|
+
applied.push(format!("settings.require_estimate_hours = {b}"));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
"settings.ai_estimate_multiplier" => {
|
|
99
|
+
if let Some(n) = value.as_f64() {
|
|
100
|
+
if n < 0.0 {
|
|
101
|
+
anyhow::bail!("settings.ai_estimate_multiplier must be >= 0");
|
|
102
|
+
}
|
|
103
|
+
config.settings.ai_estimate_multiplier = n;
|
|
104
|
+
applied.push(format!("settings.ai_estimate_multiplier = {n}"));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
90
107
|
"settings.context_files" => {
|
|
91
108
|
if let Some(arr) = value.as_array() {
|
|
92
109
|
config.settings.context_files = arr
|
|
@@ -5,6 +5,7 @@ use chrono::Utc;
|
|
|
5
5
|
use serde_json::{json, Value};
|
|
6
6
|
|
|
7
7
|
use super::resolve_project_dir;
|
|
8
|
+
use crate::storage::config::read_config;
|
|
8
9
|
use crate::storage::ensure_handoff_exists;
|
|
9
10
|
use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
|
|
10
11
|
|
|
@@ -50,6 +51,13 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
50
51
|
|
|
51
52
|
let budget = read_budget(&handoff);
|
|
52
53
|
|
|
54
|
+
// AI-effort multiplier: the raw estimate is the human-effort estimate;
|
|
55
|
+
// multiplying by ai_estimate_multiplier yields the expected AI-effort hours.
|
|
56
|
+
// Raw estimates are preserved; only the adjusted view is derived here.
|
|
57
|
+
let multiplier = read_config(&handoff.join("config.toml"))
|
|
58
|
+
.map(|c| c.settings.ai_estimate_multiplier)
|
|
59
|
+
.unwrap_or(0.2);
|
|
60
|
+
|
|
53
61
|
let milestone_list: Vec<Value> = milestones
|
|
54
62
|
.into_iter()
|
|
55
63
|
.map(|(name, (done, ms_total, est, act))| {
|
|
@@ -58,6 +66,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
58
66
|
"done": done,
|
|
59
67
|
"total": ms_total,
|
|
60
68
|
"estimate_hours": est,
|
|
69
|
+
"adjusted_estimate_hours": est * multiplier,
|
|
61
70
|
"actual_hours": act,
|
|
62
71
|
})
|
|
63
72
|
})
|
|
@@ -68,6 +77,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
68
77
|
"by_status": by_status,
|
|
69
78
|
"completion_percent": (completion_percent * 10.0).round() / 10.0,
|
|
70
79
|
"total_estimate_hours": estimate_sum,
|
|
80
|
+
"ai_estimate_multiplier": multiplier,
|
|
81
|
+
"total_adjusted_estimate_hours": estimate_sum * multiplier,
|
|
71
82
|
"total_actual_hours": actual_sum,
|
|
72
83
|
"total_remaining_hours": remaining_sum,
|
|
73
84
|
"overdue_count": overdue_tasks.len(),
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -53,6 +53,7 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
53
53
|
"handoff_import_context" => import_context::handle(arguments),
|
|
54
54
|
"handoff_refer" => refer::handle(arguments),
|
|
55
55
|
"handoff_list_referrals" => referrals::handle_list(arguments),
|
|
56
|
+
"handoff_get_referral" => referrals::handle_get(arguments),
|
|
56
57
|
"handoff_update_referral" => referrals::handle_update(arguments),
|
|
57
58
|
"handoff_update_session" => update_session::handle(arguments),
|
|
58
59
|
"handoff_log_time" => log_time::handle(arguments),
|
|
@@ -4,7 +4,7 @@ use serde_json::Value;
|
|
|
4
4
|
use super::resolve_project_dir;
|
|
5
5
|
use crate::storage::ensure_handoff_exists;
|
|
6
6
|
use crate::storage::referrals::{
|
|
7
|
-
change_referral_status, is_valid_referral_status, read_referral_summaries,
|
|
7
|
+
change_referral_status, is_valid_referral_status, read_referral_by_id, read_referral_summaries,
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
pub fn handle_list(arguments: &Value) -> Result<String> {
|
|
@@ -32,6 +32,25 @@ pub fn handle_list(arguments: &Value) -> Result<String> {
|
|
|
32
32
|
serde_json::to_string_pretty(&result).context("Failed to serialize referrals")
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
pub fn handle_get(arguments: &Value) -> Result<String> {
|
|
36
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
37
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
38
|
+
let referrals_dir = handoff.join("referrals");
|
|
39
|
+
|
|
40
|
+
let referral_id = arguments
|
|
41
|
+
.get("referral_id")
|
|
42
|
+
.and_then(|v| v.as_str())
|
|
43
|
+
.ok_or_else(|| anyhow::anyhow!("'referral_id' is required"))?;
|
|
44
|
+
|
|
45
|
+
let (data, status) = read_referral_by_id(&referrals_dir, referral_id)?
|
|
46
|
+
.ok_or_else(|| anyhow::anyhow!("Referral not found: {referral_id}"))?;
|
|
47
|
+
|
|
48
|
+
let mut result = serde_json::to_value(&data).context("Failed to serialize referral")?;
|
|
49
|
+
result["status"] = Value::String(status);
|
|
50
|
+
|
|
51
|
+
serde_json::to_string_pretty(&result).context("Failed to serialize referral")
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
36
55
|
let project_dir = resolve_project_dir(arguments)?;
|
|
37
56
|
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
@@ -5,6 +5,7 @@ use chrono::Utc;
|
|
|
5
5
|
use serde_json::Value;
|
|
6
6
|
|
|
7
7
|
use super::resolve_project_dir;
|
|
8
|
+
use crate::storage::config::read_config;
|
|
8
9
|
use crate::storage::ensure_handoff_exists;
|
|
9
10
|
use crate::storage::tasks::*;
|
|
10
11
|
|
|
@@ -14,6 +15,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
14
15
|
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
15
16
|
let tasks_dir = handoff.join("tasks");
|
|
16
17
|
|
|
18
|
+
let require_estimate_hours = read_config(&handoff.join("config.toml"))
|
|
19
|
+
.map(|c| c.settings.require_estimate_hours)
|
|
20
|
+
.unwrap_or(true);
|
|
21
|
+
|
|
17
22
|
let task_val = arguments
|
|
18
23
|
.get("task")
|
|
19
24
|
.ok_or_else(|| anyhow::anyhow!("'task' parameter is required"))?;
|
|
@@ -27,9 +32,15 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
27
32
|
}
|
|
28
33
|
let task_exists = find_task_dir_by_id(&tasks_dir, existing_id)?.is_some();
|
|
29
34
|
if task_exists {
|
|
30
|
-
return handle_update(&tasks_dir, existing_id, task_val);
|
|
35
|
+
return handle_update(&tasks_dir, existing_id, task_val, require_estimate_hours);
|
|
31
36
|
}
|
|
32
|
-
return handle_upsert_create(
|
|
37
|
+
return handle_upsert_create(
|
|
38
|
+
&tasks_dir,
|
|
39
|
+
existing_id,
|
|
40
|
+
task_val,
|
|
41
|
+
arguments,
|
|
42
|
+
require_estimate_hours,
|
|
43
|
+
);
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
let title = task_val
|
|
@@ -37,7 +48,13 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
37
48
|
.and_then(|v| v.as_str())
|
|
38
49
|
.ok_or_else(|| anyhow::anyhow!("'task.title' is required for new tasks"))?;
|
|
39
50
|
|
|
40
|
-
handle_create(
|
|
51
|
+
handle_create(
|
|
52
|
+
&tasks_dir,
|
|
53
|
+
title,
|
|
54
|
+
task_val,
|
|
55
|
+
arguments,
|
|
56
|
+
require_estimate_hours,
|
|
57
|
+
)
|
|
41
58
|
}
|
|
42
59
|
|
|
43
60
|
fn handle_create(
|
|
@@ -45,6 +62,7 @@ fn handle_create(
|
|
|
45
62
|
title: &str,
|
|
46
63
|
task_val: &Value,
|
|
47
64
|
arguments: &Value,
|
|
65
|
+
require_estimate_hours: bool,
|
|
48
66
|
) -> Result<String> {
|
|
49
67
|
let parent_id = arguments.get("parent_id").and_then(|v| v.as_str());
|
|
50
68
|
|
|
@@ -112,6 +130,14 @@ fn handle_create(
|
|
|
112
130
|
extra: HashMap::new(),
|
|
113
131
|
};
|
|
114
132
|
|
|
133
|
+
// A newly created task is always a leaf (no children yet).
|
|
134
|
+
validate_estimate_required(
|
|
135
|
+
require_estimate_hours,
|
|
136
|
+
status,
|
|
137
|
+
false,
|
|
138
|
+
data.schedule.as_ref(),
|
|
139
|
+
)?;
|
|
140
|
+
|
|
115
141
|
write_task(&task_dir, status, &data)?;
|
|
116
142
|
|
|
117
143
|
Ok(format!("Created task {new_id}: {title} [{status}]"))
|
|
@@ -122,6 +148,7 @@ fn handle_upsert_create(
|
|
|
122
148
|
task_id: &str,
|
|
123
149
|
task_val: &Value,
|
|
124
150
|
arguments: &Value,
|
|
151
|
+
require_estimate_hours: bool,
|
|
125
152
|
) -> Result<String> {
|
|
126
153
|
let title = task_val
|
|
127
154
|
.get("title")
|
|
@@ -195,12 +222,25 @@ fn handle_upsert_create(
|
|
|
195
222
|
extra: HashMap::new(),
|
|
196
223
|
};
|
|
197
224
|
|
|
225
|
+
// Upsert-create: a brand-new task is a leaf.
|
|
226
|
+
validate_estimate_required(
|
|
227
|
+
require_estimate_hours,
|
|
228
|
+
status,
|
|
229
|
+
false,
|
|
230
|
+
data.schedule.as_ref(),
|
|
231
|
+
)?;
|
|
232
|
+
|
|
198
233
|
write_task(&task_dir, status, &data)?;
|
|
199
234
|
|
|
200
235
|
Ok(format!("Created task {task_id}: {title} [{status}]"))
|
|
201
236
|
}
|
|
202
237
|
|
|
203
|
-
fn handle_update(
|
|
238
|
+
fn handle_update(
|
|
239
|
+
tasks_dir: &std::path::Path,
|
|
240
|
+
task_id: &str,
|
|
241
|
+
task_val: &Value,
|
|
242
|
+
require_estimate_hours: bool,
|
|
243
|
+
) -> Result<String> {
|
|
204
244
|
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
205
245
|
.ok_or_else(|| anyhow::anyhow!("Task not found: {task_id}"))?;
|
|
206
246
|
|
|
@@ -288,6 +328,15 @@ fn handle_update(tasks_dir: &std::path::Path, task_id: &str, task_val: &Value) -
|
|
|
288
328
|
validate_skipped_transition(&task_dir, &data)?;
|
|
289
329
|
}
|
|
290
330
|
|
|
331
|
+
// Parent tasks (with children) are exempt; only leaf tasks need an estimate.
|
|
332
|
+
let has_children = task_has_children(&task_dir)?;
|
|
333
|
+
validate_estimate_required(
|
|
334
|
+
require_estimate_hours,
|
|
335
|
+
new_status,
|
|
336
|
+
has_children,
|
|
337
|
+
data.schedule.as_ref(),
|
|
338
|
+
)?;
|
|
339
|
+
|
|
291
340
|
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
292
341
|
|
|
293
342
|
if let Some((old_path, _)) = find_task_file(&task_dir)? {
|
package/src/mcp/tools.rs
CHANGED
|
@@ -644,6 +644,24 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
644
644
|
}
|
|
645
645
|
}),
|
|
646
646
|
},
|
|
647
|
+
ToolDefinition {
|
|
648
|
+
name: "handoff_get_referral".to_string(),
|
|
649
|
+
description: "Get the full details of a single incoming referral by ID (summary, details, tasks with done_criteria, priority, context, status). Use this instead of reading .handoff/referrals/*.json directly.".to_string(),
|
|
650
|
+
input_schema: json!({
|
|
651
|
+
"type": "object",
|
|
652
|
+
"properties": {
|
|
653
|
+
"project_dir": {
|
|
654
|
+
"type": "string",
|
|
655
|
+
"description": "Project directory path. Defaults to current working directory."
|
|
656
|
+
},
|
|
657
|
+
"referral_id": {
|
|
658
|
+
"type": "string",
|
|
659
|
+
"description": "ID of the referral to retrieve (full id or a unique prefix)."
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
"required": ["referral_id"]
|
|
663
|
+
}),
|
|
664
|
+
},
|
|
647
665
|
ToolDefinition {
|
|
648
666
|
name: "handoff_update_referral".to_string(),
|
|
649
667
|
description: "Update the status of an incoming referral (open -> acknowledged -> resolved).".to_string(),
|
package/src/storage/config.rs
CHANGED
|
@@ -49,6 +49,13 @@ pub struct SettingsConfig {
|
|
|
49
49
|
pub done_task_limit: u32,
|
|
50
50
|
#[serde(default = "default_auto_git_summary")]
|
|
51
51
|
pub auto_git_summary: bool,
|
|
52
|
+
/// Require `estimate_hours` when creating/updating leaf tasks. Default true.
|
|
53
|
+
#[serde(default = "default_require_estimate_hours")]
|
|
54
|
+
pub require_estimate_hours: bool,
|
|
55
|
+
/// Multiplier applied to AI-entered `estimate_hours` to derive the
|
|
56
|
+
/// adjusted (AI-effort) estimate at aggregation time. Default 0.2.
|
|
57
|
+
#[serde(default = "default_ai_estimate_multiplier")]
|
|
58
|
+
pub ai_estimate_multiplier: f64,
|
|
52
59
|
#[serde(default)]
|
|
53
60
|
pub context_files: Vec<String>,
|
|
54
61
|
#[serde(default)]
|
|
@@ -190,6 +197,14 @@ fn default_auto_git_summary() -> bool {
|
|
|
190
197
|
true
|
|
191
198
|
}
|
|
192
199
|
|
|
200
|
+
fn default_require_estimate_hours() -> bool {
|
|
201
|
+
true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fn default_ai_estimate_multiplier() -> f64 {
|
|
205
|
+
0.2
|
|
206
|
+
}
|
|
207
|
+
|
|
193
208
|
fn default_scan_dirs() -> Vec<String> {
|
|
194
209
|
vec!["~/pro/".to_string()]
|
|
195
210
|
}
|
|
@@ -200,6 +215,8 @@ impl Default for SettingsConfig {
|
|
|
200
215
|
history_limit: default_history_limit(),
|
|
201
216
|
done_task_limit: default_done_task_limit(),
|
|
202
217
|
auto_git_summary: default_auto_git_summary(),
|
|
218
|
+
require_estimate_hours: default_require_estimate_hours(),
|
|
219
|
+
ai_estimate_multiplier: default_ai_estimate_multiplier(),
|
|
203
220
|
context_files: Vec::new(),
|
|
204
221
|
custom_fields: HashMap::new(),
|
|
205
222
|
}
|
package/src/storage/referrals.rs
CHANGED
|
@@ -160,6 +160,44 @@ pub fn change_referral_status(
|
|
|
160
160
|
Ok(())
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/// Read a single referral's full data by ID, returning the data and its status.
|
|
164
|
+
/// Searches every status (open / acknowledged / resolved). Matches by exact ID
|
|
165
|
+
/// first, then falls back to a unique prefix match for convenience.
|
|
166
|
+
pub fn read_referral_by_id(
|
|
167
|
+
referrals_dir: &Path,
|
|
168
|
+
referral_id: &str,
|
|
169
|
+
) -> Result<Option<(ReferralData, String)>> {
|
|
170
|
+
if !referrals_dir.exists() {
|
|
171
|
+
return Ok(None);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let mut prefix_matches: Vec<(ReferralData, String)> = Vec::new();
|
|
175
|
+
|
|
176
|
+
for entry in std::fs::read_dir(referrals_dir)? {
|
|
177
|
+
let entry = entry?;
|
|
178
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
179
|
+
if let Some(status) = parse_referral_status(&name) {
|
|
180
|
+
let content = std::fs::read_to_string(entry.path())?;
|
|
181
|
+
if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
|
|
182
|
+
if data.id == referral_id {
|
|
183
|
+
return Ok(Some((data, status)));
|
|
184
|
+
}
|
|
185
|
+
if data.id.starts_with(referral_id) {
|
|
186
|
+
prefix_matches.push((data, status));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
match prefix_matches.len() {
|
|
193
|
+
0 => Ok(None),
|
|
194
|
+
1 => Ok(Some(prefix_matches.into_iter().next().unwrap())),
|
|
195
|
+
n => anyhow::bail!(
|
|
196
|
+
"Ambiguous referral id prefix '{referral_id}' matches {n} referrals; use the full id"
|
|
197
|
+
),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
163
201
|
pub fn find_referral_file(
|
|
164
202
|
referrals_dir: &Path,
|
|
165
203
|
referral_id: &str,
|
package/src/storage/tasks.rs
CHANGED
|
@@ -590,6 +590,61 @@ pub fn validate_skipped_transition(task_dir: &Path, data: &TaskData) -> Result<(
|
|
|
590
590
|
Ok(())
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
+
/// Returns true if the task directory contains at least one child task
|
|
594
|
+
/// (a non-`_`/`.`-prefixed subdirectory holding a task file).
|
|
595
|
+
pub fn task_has_children(task_dir: &Path) -> Result<bool> {
|
|
596
|
+
if !task_dir.exists() {
|
|
597
|
+
return Ok(false);
|
|
598
|
+
}
|
|
599
|
+
for entry in std::fs::read_dir(task_dir)? {
|
|
600
|
+
let entry = entry?;
|
|
601
|
+
if !entry.file_type()?.is_dir() {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
605
|
+
if name.starts_with('_') || name.starts_with('.') {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if find_task_file(&entry.path())?.is_some() {
|
|
609
|
+
return Ok(true);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
Ok(false)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/// Whether a task in the given status requires an effort estimate.
|
|
616
|
+
/// Parent tasks (with children) and blocked/skipped tasks are exempt;
|
|
617
|
+
/// this only covers the status dimension.
|
|
618
|
+
pub fn status_requires_estimate(status: &str) -> bool {
|
|
619
|
+
matches!(status, "todo" | "in_progress" | "review" | "done")
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/// Validate that a leaf task carries an `estimate_hours` when the project
|
|
623
|
+
/// requires it. `has_children` lets the caller skip parent tasks.
|
|
624
|
+
/// Returns an error guiding the caller to add an estimate.
|
|
625
|
+
pub fn validate_estimate_required(
|
|
626
|
+
require_estimate_hours: bool,
|
|
627
|
+
status: &str,
|
|
628
|
+
has_children: bool,
|
|
629
|
+
schedule: Option<&Schedule>,
|
|
630
|
+
) -> Result<()> {
|
|
631
|
+
if !require_estimate_hours || has_children || !status_requires_estimate(status) {
|
|
632
|
+
return Ok(());
|
|
633
|
+
}
|
|
634
|
+
let has_estimate = schedule
|
|
635
|
+
.and_then(|s| s.estimate_hours)
|
|
636
|
+
.is_some_and(|h| h > 0.0);
|
|
637
|
+
if !has_estimate {
|
|
638
|
+
anyhow::bail!(
|
|
639
|
+
"Task requires an effort estimate: set schedule.estimate_hours (hours, > 0) \
|
|
640
|
+
when creating or updating a leaf task in status '{status}'. \
|
|
641
|
+
Estimate the human-effort hours for this task. \
|
|
642
|
+
To disable this requirement project-wide, set settings.require_estimate_hours = false."
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
Ok(())
|
|
646
|
+
}
|
|
647
|
+
|
|
593
648
|
fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
|
|
594
649
|
if !task_dir.exists() {
|
|
595
650
|
return Ok(());
|