handoff-mcp-server 0.26.0 → 0.28.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/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
//! Project-level calendar, label, and project-start tools.
|
|
2
|
-
//! - handoff_update_calendar: patch the `[calendar]` section.
|
|
3
|
-
//! - handoff_update_labels: set the top-level `labels` array.
|
|
4
|
-
//! - handoff_start_project: set `started_at` and (optionally) shift all task
|
|
5
|
-
//! dates so the earliest start aligns to the project start date.
|
|
6
|
-
|
|
7
|
-
use std::path::Path;
|
|
8
|
-
|
|
9
|
-
use anyhow::Result;
|
|
10
|
-
use chrono::{Duration, NaiveDate, Utc};
|
|
11
|
-
use serde_json::Value;
|
|
12
|
-
|
|
13
|
-
use super::config_crud::{
|
|
14
|
-
config_path, ensure_table, load_doc, save_doc, set_f64_map, set_mixed_array, set_opt_f64,
|
|
15
|
-
set_opt_str, set_string_array,
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/// handoff_update_calendar — patch the `[calendar]` section. Only provided
|
|
19
|
-
/// fields are changed; passing JSON null clears a field.
|
|
20
|
-
pub fn handle_update_calendar(arguments: &Value) -> Result<String> {
|
|
21
|
-
let path = config_path(arguments)?;
|
|
22
|
-
let mut doc = load_doc(&path)?;
|
|
23
|
-
|
|
24
|
-
let cal = ensure_table(&mut doc, "calendar")?;
|
|
25
|
-
cal.set_implicit(false);
|
|
26
|
-
set_opt_f64(
|
|
27
|
-
cal,
|
|
28
|
-
"work_hours_per_day",
|
|
29
|
-
arguments.get("work_hours_per_day"),
|
|
30
|
-
);
|
|
31
|
-
set_mixed_array(cal, "closed_weekdays", arguments.get("closed_weekdays"));
|
|
32
|
-
set_string_array(cal, "closed_dates", arguments.get("closed_dates"));
|
|
33
|
-
set_string_array(cal, "open_dates", arguments.get("open_dates"));
|
|
34
|
-
set_f64_map(cal, "day_hours", arguments.get("day_hours"));
|
|
35
|
-
set_opt_str(cal, "schedule_mode", arguments.get("schedule_mode"));
|
|
36
|
-
set_opt_f64(
|
|
37
|
-
cal,
|
|
38
|
-
"overwork_limit_percent",
|
|
39
|
-
arguments.get("overwork_limit_percent"),
|
|
40
|
-
);
|
|
41
|
-
set_opt_f64(cal, "max_utilization", arguments.get("max_utilization"));
|
|
42
|
-
|
|
43
|
-
// schedule_mode is also accepted at the top level for parity with VSCode.
|
|
44
|
-
if let Some(mode) = arguments.get("schedule_mode").and_then(|v| v.as_str()) {
|
|
45
|
-
doc["schedule_mode"] = toml_edit::Item::Value(toml_edit::Value::from(mode));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
save_doc(&path, &doc)?;
|
|
49
|
-
Ok("Updated calendar configuration".to_string())
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/// handoff_update_labels — replace the top-level `labels` array.
|
|
53
|
-
pub fn handle_update_labels(arguments: &Value) -> Result<String> {
|
|
54
|
-
let path = config_path(arguments)?;
|
|
55
|
-
let labels = arguments
|
|
56
|
-
.get("labels")
|
|
57
|
-
.and_then(|v| v.as_array())
|
|
58
|
-
.ok_or_else(|| anyhow::anyhow!("'labels' (array of strings) is required"))?;
|
|
59
|
-
|
|
60
|
-
let mut doc = load_doc(&path)?;
|
|
61
|
-
let mut arr = toml_edit::Array::new();
|
|
62
|
-
for it in labels {
|
|
63
|
-
if let Some(s) = it.as_str() {
|
|
64
|
-
arr.push(s);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
let count = arr.len();
|
|
68
|
-
doc["labels"] = toml_edit::Item::Value(toml_edit::Value::Array(arr));
|
|
69
|
-
|
|
70
|
-
save_doc(&path, &doc)?;
|
|
71
|
-
Ok(format!("Set {count} project label(s)"))
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/// handoff_start_project — record the project start timestamp and, when
|
|
75
|
-
/// `shift_dates` is true, move every task's start/due dates so the earliest
|
|
76
|
-
/// start lands on `start_date`. Mirrors the VSCode startProject command.
|
|
77
|
-
pub fn handle_start_project(arguments: &Value) -> Result<String> {
|
|
78
|
-
let path = config_path(arguments)?;
|
|
79
|
-
|
|
80
|
-
// start_date defaults to today (UTC).
|
|
81
|
-
let start_date_str = match arguments.get("start_date").and_then(|v| v.as_str()) {
|
|
82
|
-
Some(s) => s.to_string(),
|
|
83
|
-
None => Utc::now().format("%Y-%m-%d").to_string(),
|
|
84
|
-
};
|
|
85
|
-
let start_date = NaiveDate::parse_from_str(&start_date_str, "%Y-%m-%d").map_err(|_| {
|
|
86
|
-
anyhow::anyhow!("Invalid start_date '{start_date_str}' (expected YYYY-MM-DD)")
|
|
87
|
-
})?;
|
|
88
|
-
|
|
89
|
-
let mut doc = load_doc(&path)?;
|
|
90
|
-
doc["started_at"] = toml_edit::Item::Value(toml_edit::Value::from(start_date_str.as_str()));
|
|
91
|
-
save_doc(&path, &doc)?;
|
|
92
|
-
|
|
93
|
-
let shift = arguments
|
|
94
|
-
.get("shift_dates")
|
|
95
|
-
.and_then(|v| v.as_bool())
|
|
96
|
-
.unwrap_or(false);
|
|
97
|
-
|
|
98
|
-
if !shift {
|
|
99
|
-
return Ok(format!("Set project start to {start_date_str}"));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
let tasks_dir = path
|
|
103
|
-
.parent()
|
|
104
|
-
.map(|p| p.join("tasks"))
|
|
105
|
-
.ok_or_else(|| anyhow::anyhow!("Cannot locate tasks dir"))?;
|
|
106
|
-
let shifted = shift_all_dates(&tasks_dir, start_date)?;
|
|
107
|
-
|
|
108
|
-
Ok(format!(
|
|
109
|
-
"Set project start to {start_date_str} and shifted dates on {shifted} task(s)"
|
|
110
|
-
))
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/// Find the earliest task start date and shift all task start/due dates so that
|
|
114
|
-
/// earliest start aligns to `target`. Returns the number of tasks changed.
|
|
115
|
-
fn shift_all_dates(tasks_dir: &Path, target: NaiveDate) -> Result<usize> {
|
|
116
|
-
use crate::storage::tasks::{read_task, write_task};
|
|
117
|
-
|
|
118
|
-
let dirs = collect_task_dirs(tasks_dir)?;
|
|
119
|
-
|
|
120
|
-
// Pass 1: find earliest start date.
|
|
121
|
-
let mut earliest: Option<NaiveDate> = None;
|
|
122
|
-
for dir in &dirs {
|
|
123
|
-
if let Some((data, _)) = read_task(dir)? {
|
|
124
|
-
if let Some(sd) = data.schedule.as_ref().and_then(|s| s.start_date.as_deref()) {
|
|
125
|
-
if let Ok(d) = NaiveDate::parse_from_str(sd, "%Y-%m-%d") {
|
|
126
|
-
earliest = Some(earliest.map_or(d, |e| e.min(d)));
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
let earliest = match earliest {
|
|
132
|
-
Some(d) => d,
|
|
133
|
-
None => return Ok(0), // no dated tasks → nothing to shift
|
|
134
|
-
};
|
|
135
|
-
let delta = (target - earliest).num_days();
|
|
136
|
-
if delta == 0 {
|
|
137
|
-
return Ok(0);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Pass 2: apply the shift.
|
|
141
|
-
let mut count = 0;
|
|
142
|
-
for dir in &dirs {
|
|
143
|
-
if let Some((mut data, status)) = read_task(dir)? {
|
|
144
|
-
let mut changed = false;
|
|
145
|
-
if let Some(sched) = data.schedule.as_mut() {
|
|
146
|
-
if let Some(sd) = sched.start_date.as_deref() {
|
|
147
|
-
if let Ok(d) = NaiveDate::parse_from_str(sd, "%Y-%m-%d") {
|
|
148
|
-
sched.start_date = Some(shift(d, delta));
|
|
149
|
-
changed = true;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if let Some(dd) = sched.due_date.as_deref() {
|
|
153
|
-
if let Ok(d) = NaiveDate::parse_from_str(dd, "%Y-%m-%d") {
|
|
154
|
-
sched.due_date = Some(shift(d, delta));
|
|
155
|
-
changed = true;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if changed {
|
|
160
|
-
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
161
|
-
write_task(dir, &status, &data)?;
|
|
162
|
-
count += 1;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
Ok(count)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
fn shift(date: NaiveDate, delta_days: i64) -> String {
|
|
170
|
-
(date + Duration::days(delta_days))
|
|
171
|
-
.format("%Y-%m-%d")
|
|
172
|
-
.to_string()
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
fn collect_task_dirs(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
|
|
176
|
-
let mut out = Vec::new();
|
|
177
|
-
collect_into(dir, &mut out)?;
|
|
178
|
-
Ok(out)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
fn collect_into(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
|
|
182
|
-
if !dir.exists() {
|
|
183
|
-
return Ok(());
|
|
184
|
-
}
|
|
185
|
-
for entry in std::fs::read_dir(dir)? {
|
|
186
|
-
let entry = entry?;
|
|
187
|
-
let path = entry.path();
|
|
188
|
-
if path.is_dir() {
|
|
189
|
-
if crate::storage::tasks::find_task_file(&path)?.is_some() {
|
|
190
|
-
out.push(path.clone());
|
|
191
|
-
}
|
|
192
|
-
collect_into(&path, out)?;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
Ok(())
|
|
196
|
-
}
|
|
@@ -1,318 +0,0 @@
|
|
|
1
|
-
use anyhow::{Context, Result};
|
|
2
|
-
use chrono::{Datelike, NaiveDate};
|
|
3
|
-
use serde_json::{json, Value};
|
|
4
|
-
use toml_edit::DocumentMut;
|
|
5
|
-
|
|
6
|
-
use super::resolve_project_dir;
|
|
7
|
-
use crate::storage::config::{read_config, weekday_to_num};
|
|
8
|
-
use crate::storage::ensure_handoff_exists;
|
|
9
|
-
use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
|
|
10
|
-
|
|
11
|
-
pub fn handle(arguments: &Value) -> Result<String> {
|
|
12
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
13
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
14
|
-
let config_path = handoff.join("config.toml");
|
|
15
|
-
let tasks_dir = handoff.join("tasks");
|
|
16
|
-
|
|
17
|
-
let start_date_str = arguments
|
|
18
|
-
.get("start_date")
|
|
19
|
-
.and_then(|v| v.as_str())
|
|
20
|
-
.ok_or_else(|| anyhow::anyhow!("'start_date' parameter is required (YYYY-MM-DD)"))?;
|
|
21
|
-
let end_date_str = arguments
|
|
22
|
-
.get("end_date")
|
|
23
|
-
.and_then(|v| v.as_str())
|
|
24
|
-
.ok_or_else(|| anyhow::anyhow!("'end_date' parameter is required (YYYY-MM-DD)"))?;
|
|
25
|
-
let assignee_filter = arguments.get("assignee").and_then(|v| v.as_str());
|
|
26
|
-
|
|
27
|
-
let start = NaiveDate::parse_from_str(start_date_str, "%Y-%m-%d")
|
|
28
|
-
.with_context(|| format!("Invalid start_date: {start_date_str}"))?;
|
|
29
|
-
let end = NaiveDate::parse_from_str(end_date_str, "%Y-%m-%d")
|
|
30
|
-
.with_context(|| format!("Invalid end_date: {end_date_str}"))?;
|
|
31
|
-
|
|
32
|
-
if end < start {
|
|
33
|
-
anyhow::bail!("end_date must be >= start_date");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
let calendar = parse_calendar(&config_path, assignee_filter)?;
|
|
37
|
-
|
|
38
|
-
let mut days: Vec<Value> = Vec::new();
|
|
39
|
-
let mut total_hours: f64 = 0.0;
|
|
40
|
-
let mut work_days: u32 = 0;
|
|
41
|
-
|
|
42
|
-
let mut date = start;
|
|
43
|
-
while date <= end {
|
|
44
|
-
let hours = calendar.hours_for_date(&date);
|
|
45
|
-
if hours > 0.0 {
|
|
46
|
-
work_days += 1;
|
|
47
|
-
total_hours += hours;
|
|
48
|
-
}
|
|
49
|
-
days.push(json!({
|
|
50
|
-
"date": date.format("%Y-%m-%d").to_string(),
|
|
51
|
-
"capacity_hours": hours,
|
|
52
|
-
"allocated_hours": 0.0,
|
|
53
|
-
}));
|
|
54
|
-
date += chrono::Duration::days(1);
|
|
55
|
-
}
|
|
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);
|
|
61
|
-
if tasks_dir.exists() {
|
|
62
|
-
let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
|
|
63
|
-
allocate_task_hours(&tree, assignee_filter, &start, &end, &mut days, multiplier);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
let allocated_hours: f64 = days
|
|
67
|
-
.iter()
|
|
68
|
-
.filter_map(|d| d.get("allocated_hours").and_then(|v| v.as_f64()))
|
|
69
|
-
.sum();
|
|
70
|
-
|
|
71
|
-
let result = json!({
|
|
72
|
-
"work_days": work_days,
|
|
73
|
-
"total_hours": total_hours,
|
|
74
|
-
"allocated_hours": allocated_hours,
|
|
75
|
-
"available_hours": (total_hours - allocated_hours).max(0.0),
|
|
76
|
-
"days": days,
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
struct CalendarConfig {
|
|
83
|
-
work_hours_per_day: f64,
|
|
84
|
-
closed_weekdays: Vec<u32>,
|
|
85
|
-
closed_dates: Vec<String>,
|
|
86
|
-
open_dates: Vec<String>,
|
|
87
|
-
day_hours: Vec<(String, f64)>,
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
impl CalendarConfig {
|
|
91
|
-
fn hours_for_date(&self, date: &NaiveDate) -> f64 {
|
|
92
|
-
let date_str = date.format("%Y-%m-%d").to_string();
|
|
93
|
-
|
|
94
|
-
// Check per-date hours first
|
|
95
|
-
for (d, h) in &self.day_hours {
|
|
96
|
-
if d == &date_str {
|
|
97
|
-
return *h;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Check if explicitly open (overrides closed weekday)
|
|
102
|
-
let is_open_override = self.open_dates.iter().any(|d| d == &date_str);
|
|
103
|
-
|
|
104
|
-
// Check closed dates
|
|
105
|
-
if self.closed_dates.iter().any(|d| d == &date_str) {
|
|
106
|
-
return 0.0;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Check closed weekdays (chrono: Mon=1 .. Sun=7, we use 0=Sun..6=Sat)
|
|
110
|
-
let weekday_num = match date.weekday() {
|
|
111
|
-
chrono::Weekday::Sun => 0,
|
|
112
|
-
chrono::Weekday::Mon => 1,
|
|
113
|
-
chrono::Weekday::Tue => 2,
|
|
114
|
-
chrono::Weekday::Wed => 3,
|
|
115
|
-
chrono::Weekday::Thu => 4,
|
|
116
|
-
chrono::Weekday::Fri => 5,
|
|
117
|
-
chrono::Weekday::Sat => 6,
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
if !is_open_override && self.closed_weekdays.contains(&weekday_num) {
|
|
121
|
-
return 0.0;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Check per-weekday hours
|
|
125
|
-
let weekday_str = match weekday_num {
|
|
126
|
-
0 => "sun",
|
|
127
|
-
1 => "mon",
|
|
128
|
-
2 => "tue",
|
|
129
|
-
3 => "wed",
|
|
130
|
-
4 => "thu",
|
|
131
|
-
5 => "fri",
|
|
132
|
-
6 => "sat",
|
|
133
|
-
_ => "",
|
|
134
|
-
};
|
|
135
|
-
for (d, h) in &self.day_hours {
|
|
136
|
-
if d == weekday_str {
|
|
137
|
-
return *h;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
self.work_hours_per_day
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
fn parse_calendar(config_path: &std::path::Path, assignee: Option<&str>) -> Result<CalendarConfig> {
|
|
146
|
-
let mut cal = CalendarConfig {
|
|
147
|
-
work_hours_per_day: 8.0,
|
|
148
|
-
closed_weekdays: Vec::new(),
|
|
149
|
-
closed_dates: Vec::new(),
|
|
150
|
-
open_dates: Vec::new(),
|
|
151
|
-
day_hours: Vec::new(),
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
if !config_path.exists() {
|
|
155
|
-
return Ok(cal);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
let raw = std::fs::read_to_string(config_path).with_context(|| "Failed to read config.toml")?;
|
|
159
|
-
let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config.toml")?;
|
|
160
|
-
|
|
161
|
-
// Read project-level calendar
|
|
162
|
-
if let Some(calendar) = doc.get("calendar").and_then(|v| v.as_table()) {
|
|
163
|
-
if let Some(h) = calendar
|
|
164
|
-
.get("work_hours_per_day")
|
|
165
|
-
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
|
|
166
|
-
{
|
|
167
|
-
cal.work_hours_per_day = h;
|
|
168
|
-
}
|
|
169
|
-
if let Some(arr) = calendar.get("closed_weekdays").and_then(|v| v.as_array()) {
|
|
170
|
-
cal.closed_weekdays = arr
|
|
171
|
-
.iter()
|
|
172
|
-
.filter_map(|v| {
|
|
173
|
-
v.as_integer()
|
|
174
|
-
.map(|i| i as u32)
|
|
175
|
-
.or_else(|| v.as_str().and_then(weekday_to_num))
|
|
176
|
-
})
|
|
177
|
-
.collect();
|
|
178
|
-
}
|
|
179
|
-
if let Some(arr) = calendar.get("closed_dates").and_then(|v| v.as_array()) {
|
|
180
|
-
cal.closed_dates = arr
|
|
181
|
-
.iter()
|
|
182
|
-
.filter_map(|v| v.as_str().map(String::from))
|
|
183
|
-
.collect();
|
|
184
|
-
}
|
|
185
|
-
if let Some(arr) = calendar.get("open_dates").and_then(|v| v.as_array()) {
|
|
186
|
-
cal.open_dates = arr
|
|
187
|
-
.iter()
|
|
188
|
-
.filter_map(|v| v.as_str().map(String::from))
|
|
189
|
-
.collect();
|
|
190
|
-
}
|
|
191
|
-
if let Some(dh) = calendar.get("day_hours").and_then(|v| v.as_table()) {
|
|
192
|
-
for (k, v) in dh.iter() {
|
|
193
|
-
if let Some(h) = v.as_float().or_else(|| v.as_integer().map(|i| i as f64)) {
|
|
194
|
-
cal.day_hours.push((k.to_string(), h));
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Override with assignee-specific calendar
|
|
201
|
-
if let Some(assignee_key) = assignee {
|
|
202
|
-
if let Some(assignees) = doc.get("assignees").and_then(|v| v.as_table()) {
|
|
203
|
-
if let Some(a) = assignees.get(assignee_key).and_then(|v| v.as_table()) {
|
|
204
|
-
if let Some(h) = a
|
|
205
|
-
.get("work_hours_per_day")
|
|
206
|
-
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
|
|
207
|
-
{
|
|
208
|
-
cal.work_hours_per_day = h;
|
|
209
|
-
}
|
|
210
|
-
if let Some(arr) = a.get("closed_weekdays").and_then(|v| v.as_array()) {
|
|
211
|
-
cal.closed_weekdays = arr
|
|
212
|
-
.iter()
|
|
213
|
-
.filter_map(|v| {
|
|
214
|
-
v.as_integer()
|
|
215
|
-
.map(|i| i as u32)
|
|
216
|
-
.or_else(|| v.as_str().and_then(weekday_to_num))
|
|
217
|
-
})
|
|
218
|
-
.collect();
|
|
219
|
-
}
|
|
220
|
-
if let Some(arr) = a.get("closed_dates").and_then(|v| v.as_array()) {
|
|
221
|
-
for item in arr.iter() {
|
|
222
|
-
if let Some(s) = item.as_str() {
|
|
223
|
-
cal.closed_dates.push(s.to_string());
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
if let Some(arr) = a.get("open_dates").and_then(|v| v.as_array()) {
|
|
228
|
-
for item in arr.iter() {
|
|
229
|
-
if let Some(s) = item.as_str() {
|
|
230
|
-
cal.open_dates.push(s.to_string());
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if let Some(dh) = a.get("day_hours").and_then(|v| v.as_table()) {
|
|
235
|
-
for (k, v) in dh.iter() {
|
|
236
|
-
if let Some(h) = v.as_float().or_else(|| v.as_integer().map(|i| i as f64)) {
|
|
237
|
-
cal.day_hours.push((k.to_string(), h));
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
Ok(cal)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
fn allocate_task_hours(
|
|
249
|
-
tree: &[TaskIndex],
|
|
250
|
-
assignee_filter: Option<&str>,
|
|
251
|
-
start: &NaiveDate,
|
|
252
|
-
end: &NaiveDate,
|
|
253
|
-
days: &mut [Value],
|
|
254
|
-
multiplier: f64,
|
|
255
|
-
) {
|
|
256
|
-
for node in tree {
|
|
257
|
-
if is_terminal_status(&node.status) {
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
let matches_assignee = match assignee_filter {
|
|
262
|
-
Some(f) => node.assignee.as_deref() == Some(f),
|
|
263
|
-
None => true,
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
if matches_assignee {
|
|
267
|
-
if let Some(ref sched) = node.schedule {
|
|
268
|
-
if let (Some(ref sd), Some(ref dd)) = (&sched.start_date, &sched.due_date) {
|
|
269
|
-
if let (Ok(task_start), Ok(task_end)) = (
|
|
270
|
-
NaiveDate::parse_from_str(sd, "%Y-%m-%d"),
|
|
271
|
-
NaiveDate::parse_from_str(dd, "%Y-%m-%d"),
|
|
272
|
-
) {
|
|
273
|
-
// remaining_hours is already actual AI-effort progress, so it
|
|
274
|
-
// is used as-is; the raw estimate is human-effort and gets the
|
|
275
|
-
// AI multiplier applied to derive AI-effort allocation.
|
|
276
|
-
let est = match sched.remaining_hours {
|
|
277
|
-
Some(rem) => rem,
|
|
278
|
-
None => sched.estimate_hours.unwrap_or(0.0) * multiplier,
|
|
279
|
-
};
|
|
280
|
-
let overlap_start = (*start).max(task_start);
|
|
281
|
-
let overlap_end = (*end).min(task_end);
|
|
282
|
-
if overlap_start <= overlap_end {
|
|
283
|
-
let task_days = (task_end - task_start).num_days().max(1) as f64;
|
|
284
|
-
let hours_per_day = est / task_days;
|
|
285
|
-
|
|
286
|
-
let mut date = overlap_start;
|
|
287
|
-
while date <= overlap_end {
|
|
288
|
-
let idx = (date - *start).num_days() as usize;
|
|
289
|
-
if idx < days.len() {
|
|
290
|
-
if let Some(obj) = days[idx].as_object_mut() {
|
|
291
|
-
let cur = obj
|
|
292
|
-
.get("allocated_hours")
|
|
293
|
-
.and_then(|v| v.as_f64())
|
|
294
|
-
.unwrap_or(0.0);
|
|
295
|
-
obj.insert(
|
|
296
|
-
"allocated_hours".to_string(),
|
|
297
|
-
json!(cur + hours_per_day),
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
date += chrono::Duration::days(1);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
allocate_task_hours(
|
|
310
|
-
&node.children,
|
|
311
|
-
assignee_filter,
|
|
312
|
-
start,
|
|
313
|
-
end,
|
|
314
|
-
days,
|
|
315
|
-
multiplier,
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
use anyhow::Result;
|
|
2
|
-
use chrono::Utc;
|
|
3
|
-
use serde_json::Value;
|
|
4
|
-
|
|
5
|
-
use super::resolve_project_dir;
|
|
6
|
-
use crate::storage::ensure_handoff_exists;
|
|
7
|
-
use crate::storage::tasks::{
|
|
8
|
-
find_task_dir_by_id, find_task_file, read_task, suggest_task_id, write_task,
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
pub fn handle(arguments: &Value) -> Result<String> {
|
|
12
|
-
let project_dir = resolve_project_dir(arguments)?;
|
|
13
|
-
|
|
14
|
-
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
15
|
-
let tasks_dir = handoff.join("tasks");
|
|
16
|
-
|
|
17
|
-
let task_id = arguments
|
|
18
|
-
.get("task_id")
|
|
19
|
-
.and_then(|v| v.as_str())
|
|
20
|
-
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
21
|
-
|
|
22
|
-
let criterion_index = arguments
|
|
23
|
-
.get("criterion_index")
|
|
24
|
-
.and_then(|v| v.as_u64())
|
|
25
|
-
.ok_or_else(|| anyhow::anyhow!("'criterion_index' parameter is required"))?
|
|
26
|
-
as usize;
|
|
27
|
-
|
|
28
|
-
let checked = arguments
|
|
29
|
-
.get("checked")
|
|
30
|
-
.and_then(|v| v.as_bool())
|
|
31
|
-
.ok_or_else(|| anyhow::anyhow!("'checked' parameter is required (boolean)"))?;
|
|
32
|
-
|
|
33
|
-
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
34
|
-
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
|
|
35
|
-
|
|
36
|
-
let (mut data, status) = read_task(&task_dir)?
|
|
37
|
-
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
38
|
-
|
|
39
|
-
if criterion_index >= data.done_criteria.len() {
|
|
40
|
-
anyhow::bail!(
|
|
41
|
-
"criterion_index {criterion_index} is out of range (task has {} criteria)",
|
|
42
|
-
data.done_criteria.len()
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
data.done_criteria[criterion_index].checked = checked;
|
|
47
|
-
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
48
|
-
|
|
49
|
-
if let Some((old_path, _)) = find_task_file(&task_dir)? {
|
|
50
|
-
std::fs::remove_file(&old_path)?;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
write_task(&task_dir, &status, &data)?;
|
|
54
|
-
|
|
55
|
-
let checked_count = data.done_criteria.iter().filter(|c| c.checked).count();
|
|
56
|
-
let total = data.done_criteria.len();
|
|
57
|
-
|
|
58
|
-
let result = serde_json::json!({
|
|
59
|
-
"task_id": data.id,
|
|
60
|
-
"criterion_index": criterion_index,
|
|
61
|
-
"item": data.done_criteria[criterion_index].item,
|
|
62
|
-
"checked": checked,
|
|
63
|
-
"done_criteria_summary": {
|
|
64
|
-
"total": total,
|
|
65
|
-
"checked": checked_count,
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
serde_json::to_string_pretty(&result).map_err(Into::into)
|
|
70
|
-
}
|