handoff-mcp-server 0.10.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 +2 -1
- package/Cargo.toml +2 -1
- package/README.md +112 -32
- package/package.json +2 -1
- package/skills/handoff/SKILL.md +230 -0
- package/skills/handoff-import/SKILL.md +238 -0
- package/skills/handoff-load/SKILL.md +37 -0
- package/skills/handoff-refer/SKILL.md +71 -0
- package/src/mcp/handlers/assignees.rs +254 -0
- package/src/mcp/handlers/auto_schedule.rs +498 -0
- package/src/mcp/handlers/bulk_update.rs +115 -0
- package/src/mcp/handlers/calendar.rs +196 -0
- package/src/mcp/handlers/capacity.rs +331 -0
- package/src/mcp/handlers/config.rs +229 -67
- package/src/mcp/handlers/config_crud.rs +183 -0
- package/src/mcp/handlers/get_session.rs +48 -0
- package/src/mcp/handlers/get_task.rs +1 -0
- package/src/mcp/handlers/import_context.rs +7 -0
- package/src/mcp/handlers/list_sessions.rs +129 -0
- package/src/mcp/handlers/list_tasks.rs +76 -10
- package/src/mcp/handlers/load_context.rs +16 -0
- package/src/mcp/handlers/log_time.rs +70 -0
- package/src/mcp/handlers/metrics.rs +196 -0
- package/src/mcp/handlers/milestones.rs +102 -0
- package/src/mcp/handlers/mod.rs +30 -0
- package/src/mcp/handlers/referrals.rs +20 -1
- package/src/mcp/handlers/update_session.rs +1 -1
- package/src/mcp/handlers/update_task.rs +99 -6
- package/src/mcp/tools.rs +357 -3
- package/src/storage/config.rs +162 -1
- package/src/storage/mod.rs +42 -0
- package/src/storage/referrals.rs +39 -1
- package/src/storage/sessions.rs +95 -22
- package/src/storage/tasks.rs +122 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
use anyhow::{Context, Result};
|
|
2
2
|
use serde_json::Value;
|
|
3
|
+
use toml_edit::{DocumentMut, Item, Value as TomlValue};
|
|
3
4
|
|
|
4
5
|
use super::resolve_project_dir;
|
|
5
6
|
use crate::storage::config::{read_config, write_config};
|
|
@@ -9,9 +10,19 @@ pub fn handle_get(arguments: &Value) -> Result<String> {
|
|
|
9
10
|
let project_dir = resolve_project_dir(arguments)?;
|
|
10
11
|
|
|
11
12
|
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
12
|
-
let
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
let config_path = handoff.join("config.toml");
|
|
14
|
+
|
|
15
|
+
let raw = std::fs::read_to_string(&config_path)
|
|
16
|
+
.with_context(|| format!("Failed to read config: {}", config_path.display()))?;
|
|
17
|
+
let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config.toml")?;
|
|
18
|
+
|
|
19
|
+
let toml_str = doc.to_string();
|
|
20
|
+
let toml_value: toml::Value =
|
|
21
|
+
toml::from_str(&toml_str).with_context(|| "Failed to deserialize config")?;
|
|
22
|
+
let json_value =
|
|
23
|
+
serde_json::to_value(&toml_value).with_context(|| "Failed to convert to JSON")?;
|
|
24
|
+
|
|
25
|
+
serde_json::to_string_pretty(&json_value).context("Failed to format config")
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
@@ -19,7 +30,6 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
19
30
|
|
|
20
31
|
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
21
32
|
let config_path = handoff.join("config.toml");
|
|
22
|
-
let mut config = read_config(&config_path)?;
|
|
23
33
|
|
|
24
34
|
let updates = arguments
|
|
25
35
|
.get("updates")
|
|
@@ -29,83 +39,153 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
29
39
|
.as_object()
|
|
30
40
|
.ok_or_else(|| anyhow::anyhow!("'updates' must be an object"))?;
|
|
31
41
|
|
|
42
|
+
// Separate typed keys (settings/dashboard/project) from raw TOML keys
|
|
43
|
+
let typed_keys = [
|
|
44
|
+
"settings.history_limit",
|
|
45
|
+
"settings.done_task_limit",
|
|
46
|
+
"settings.auto_git_summary",
|
|
47
|
+
"settings.require_estimate_hours",
|
|
48
|
+
"settings.ai_estimate_multiplier",
|
|
49
|
+
"settings.context_files",
|
|
50
|
+
"dashboard.scan_dirs",
|
|
51
|
+
"dashboard.exclude_patterns",
|
|
52
|
+
"project.name",
|
|
53
|
+
"project.description",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
let mut has_typed = false;
|
|
57
|
+
let mut has_raw = false;
|
|
58
|
+
|
|
59
|
+
for key in updates.keys() {
|
|
60
|
+
if typed_keys.contains(&key.as_str()) {
|
|
61
|
+
has_typed = true;
|
|
62
|
+
} else {
|
|
63
|
+
has_raw = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
let mut applied = Vec::new();
|
|
33
68
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
69
|
+
// Handle typed keys via existing Config struct
|
|
70
|
+
if has_typed {
|
|
71
|
+
let mut config = read_config(&config_path)?;
|
|
72
|
+
for (key, value) in updates {
|
|
73
|
+
match key.as_str() {
|
|
74
|
+
"settings.history_limit" => {
|
|
75
|
+
if let Some(n) = value.as_u64() {
|
|
76
|
+
config.settings.history_limit = n as u32;
|
|
77
|
+
applied.push(format!("settings.history_limit = {n}"));
|
|
78
|
+
}
|
|
40
79
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
80
|
+
"settings.done_task_limit" => {
|
|
81
|
+
if let Some(n) = value.as_u64() {
|
|
82
|
+
config.settings.done_task_limit = n as u32;
|
|
83
|
+
applied.push(format!("settings.done_task_limit = {n}"));
|
|
84
|
+
}
|
|
46
85
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
86
|
+
"settings.auto_git_summary" => {
|
|
87
|
+
if let Some(b) = value.as_bool() {
|
|
88
|
+
config.settings.auto_git_summary = b;
|
|
89
|
+
applied.push(format!("settings.auto_git_summary = {b}"));
|
|
90
|
+
}
|
|
52
91
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.filter_map(|v| v.as_str().map(String::from))
|
|
59
|
-
.collect();
|
|
60
|
-
applied.push(format!(
|
|
61
|
-
"settings.context_files = {:?}",
|
|
62
|
-
config.settings.context_files
|
|
63
|
-
));
|
|
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
|
+
}
|
|
64
97
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
"dashboard.scan_dirs = {:?}",
|
|
74
|
-
config.dashboard.scan_dirs
|
|
75
|
-
));
|
|
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
|
+
}
|
|
76
106
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
"settings.context_files" => {
|
|
108
|
+
if let Some(arr) = value.as_array() {
|
|
109
|
+
config.settings.context_files = arr
|
|
110
|
+
.iter()
|
|
111
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
112
|
+
.collect();
|
|
113
|
+
applied.push(format!(
|
|
114
|
+
"settings.context_files = {:?}",
|
|
115
|
+
config.settings.context_files
|
|
116
|
+
));
|
|
117
|
+
}
|
|
88
118
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
119
|
+
"dashboard.scan_dirs" => {
|
|
120
|
+
if let Some(arr) = value.as_array() {
|
|
121
|
+
config.dashboard.scan_dirs = arr
|
|
122
|
+
.iter()
|
|
123
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
124
|
+
.collect();
|
|
125
|
+
applied.push(format!(
|
|
126
|
+
"dashboard.scan_dirs = {:?}",
|
|
127
|
+
config.dashboard.scan_dirs
|
|
128
|
+
));
|
|
129
|
+
}
|
|
94
130
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
131
|
+
"dashboard.exclude_patterns" => {
|
|
132
|
+
if let Some(arr) = value.as_array() {
|
|
133
|
+
config.dashboard.exclude_patterns = arr
|
|
134
|
+
.iter()
|
|
135
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
136
|
+
.collect();
|
|
137
|
+
applied.push(format!(
|
|
138
|
+
"dashboard.exclude_patterns = {:?}",
|
|
139
|
+
config.dashboard.exclude_patterns
|
|
140
|
+
));
|
|
141
|
+
}
|
|
100
142
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
143
|
+
"project.name" => {
|
|
144
|
+
if let Some(s) = value.as_str() {
|
|
145
|
+
config.project.name = s.to_string();
|
|
146
|
+
applied.push(format!("project.name = {s}"));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
"project.description" => {
|
|
150
|
+
if let Some(s) = value.as_str() {
|
|
151
|
+
config.project.description = Some(s.to_string());
|
|
152
|
+
applied.push(format!("project.description = {s}"));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
_ => {}
|
|
104
156
|
}
|
|
105
157
|
}
|
|
158
|
+
write_config(&config_path, &config)?;
|
|
106
159
|
}
|
|
107
160
|
|
|
108
|
-
|
|
161
|
+
// Handle raw TOML keys (calendar, assignees, effort_budget, gantt_view, etc.)
|
|
162
|
+
if has_raw {
|
|
163
|
+
let raw = std::fs::read_to_string(&config_path)
|
|
164
|
+
.with_context(|| format!("Failed to read config: {}", config_path.display()))?;
|
|
165
|
+
let mut doc: DocumentMut = raw
|
|
166
|
+
.parse()
|
|
167
|
+
.with_context(|| "Failed to parse config.toml for raw update")?;
|
|
168
|
+
|
|
169
|
+
for (key, value) in updates {
|
|
170
|
+
if typed_keys.contains(&key.as_str()) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let parts: Vec<&str> = key.split('.').collect();
|
|
175
|
+
if parts.is_empty() {
|
|
176
|
+
applied.push(format!("{key}: invalid key"));
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
match set_toml_value(&mut doc, &parts, value) {
|
|
181
|
+
Ok(()) => applied.push(format!("{key} = {value}")),
|
|
182
|
+
Err(e) => applied.push(format!("{key}: error ({e})")),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
crate::storage::atomic_write(&config_path, doc.to_string().as_bytes())
|
|
187
|
+
.with_context(|| format!("Failed to write config: {}", config_path.display()))?;
|
|
188
|
+
}
|
|
109
189
|
|
|
110
190
|
if applied.is_empty() {
|
|
111
191
|
Ok("No updates applied".to_string())
|
|
@@ -113,3 +193,85 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
113
193
|
Ok(format!("Updated config:\n{}", applied.join("\n")))
|
|
114
194
|
}
|
|
115
195
|
}
|
|
196
|
+
|
|
197
|
+
fn set_toml_value(doc: &mut DocumentMut, parts: &[&str], json_val: &Value) -> Result<()> {
|
|
198
|
+
let toml_val = json_to_toml_value(json_val)?;
|
|
199
|
+
|
|
200
|
+
match parts.len() {
|
|
201
|
+
1 => {
|
|
202
|
+
doc[parts[0]] = Item::Value(toml_val);
|
|
203
|
+
}
|
|
204
|
+
2 => {
|
|
205
|
+
if !doc.contains_table(parts[0]) {
|
|
206
|
+
doc[parts[0]] = Item::Table(toml_edit::Table::new());
|
|
207
|
+
}
|
|
208
|
+
doc[parts[0]][parts[1]] = Item::Value(toml_val);
|
|
209
|
+
}
|
|
210
|
+
3 => {
|
|
211
|
+
if !doc.contains_table(parts[0]) {
|
|
212
|
+
doc[parts[0]] = Item::Table(toml_edit::Table::new());
|
|
213
|
+
}
|
|
214
|
+
let table = doc[parts[0]]
|
|
215
|
+
.as_table_mut()
|
|
216
|
+
.ok_or_else(|| anyhow::anyhow!("{} is not a table", parts[0]))?;
|
|
217
|
+
if !table.contains_table(parts[1]) {
|
|
218
|
+
table[parts[1]] = Item::Table(toml_edit::Table::new());
|
|
219
|
+
}
|
|
220
|
+
table[parts[1]][parts[2]] = Item::Value(toml_val);
|
|
221
|
+
}
|
|
222
|
+
4 => {
|
|
223
|
+
if !doc.contains_table(parts[0]) {
|
|
224
|
+
doc[parts[0]] = Item::Table(toml_edit::Table::new());
|
|
225
|
+
}
|
|
226
|
+
let t0 = doc[parts[0]]
|
|
227
|
+
.as_table_mut()
|
|
228
|
+
.ok_or_else(|| anyhow::anyhow!("{} is not a table", parts[0]))?;
|
|
229
|
+
if !t0.contains_table(parts[1]) {
|
|
230
|
+
t0[parts[1]] = Item::Table(toml_edit::Table::new());
|
|
231
|
+
}
|
|
232
|
+
let t1 = t0[parts[1]]
|
|
233
|
+
.as_table_mut()
|
|
234
|
+
.ok_or_else(|| anyhow::anyhow!("{}.{} is not a table", parts[0], parts[1]))?;
|
|
235
|
+
if !t1.contains_table(parts[2]) {
|
|
236
|
+
t1[parts[2]] = Item::Table(toml_edit::Table::new());
|
|
237
|
+
}
|
|
238
|
+
t1[parts[2]][parts[3]] = Item::Value(toml_val);
|
|
239
|
+
}
|
|
240
|
+
_ => {
|
|
241
|
+
anyhow::bail!("Key depth > 4 not supported");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
Ok(())
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fn json_to_toml_value(val: &Value) -> Result<TomlValue> {
|
|
249
|
+
match val {
|
|
250
|
+
Value::String(s) => Ok(TomlValue::from(s.as_str())),
|
|
251
|
+
Value::Number(n) => {
|
|
252
|
+
if let Some(i) = n.as_i64() {
|
|
253
|
+
Ok(TomlValue::from(i))
|
|
254
|
+
} else if let Some(f) = n.as_f64() {
|
|
255
|
+
Ok(TomlValue::from(f))
|
|
256
|
+
} else {
|
|
257
|
+
anyhow::bail!("Unsupported number: {n}")
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
Value::Bool(b) => Ok(TomlValue::from(*b)),
|
|
261
|
+
Value::Array(arr) => {
|
|
262
|
+
let mut toml_arr = toml_edit::Array::new();
|
|
263
|
+
for item in arr {
|
|
264
|
+
toml_arr.push(json_to_toml_value(item)?);
|
|
265
|
+
}
|
|
266
|
+
Ok(TomlValue::Array(toml_arr))
|
|
267
|
+
}
|
|
268
|
+
Value::Object(obj) => {
|
|
269
|
+
let mut inline = toml_edit::InlineTable::new();
|
|
270
|
+
for (k, v) in obj {
|
|
271
|
+
inline.insert(k, json_to_toml_value(v)?);
|
|
272
|
+
}
|
|
273
|
+
Ok(TomlValue::InlineTable(inline))
|
|
274
|
+
}
|
|
275
|
+
Value::Null => Ok(TomlValue::from("")),
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
//! Shared helpers for config.toml CRUD handlers (assignees, milestones,
|
|
2
|
+
//! calendar, labels, project start). All mutate the raw TOML document via
|
|
3
|
+
//! `toml_edit` so that comments and unrelated keys are preserved, then write it
|
|
4
|
+
//! back atomically.
|
|
5
|
+
|
|
6
|
+
use std::path::{Path, PathBuf};
|
|
7
|
+
|
|
8
|
+
use anyhow::{Context, Result};
|
|
9
|
+
use serde_json::Value;
|
|
10
|
+
use toml_edit::{Array, DocumentMut, Item, Value as TomlValue};
|
|
11
|
+
|
|
12
|
+
use super::resolve_project_dir;
|
|
13
|
+
use crate::storage::ensure_handoff_exists;
|
|
14
|
+
|
|
15
|
+
/// Resolve the project dir, ensure `.handoff/` exists, and return the
|
|
16
|
+
/// config.toml path.
|
|
17
|
+
pub fn config_path(arguments: &Value) -> Result<PathBuf> {
|
|
18
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
19
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
20
|
+
Ok(handoff.join("config.toml"))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Parse config.toml into a mutable document.
|
|
24
|
+
pub fn load_doc(path: &Path) -> Result<DocumentMut> {
|
|
25
|
+
let raw = std::fs::read_to_string(path)
|
|
26
|
+
.with_context(|| format!("Failed to read config: {}", path.display()))?;
|
|
27
|
+
raw.parse::<DocumentMut>()
|
|
28
|
+
.with_context(|| format!("Failed to parse config: {}", path.display()))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Serialize and atomically write the document back to disk.
|
|
32
|
+
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<()> {
|
|
33
|
+
crate::storage::atomic_write(path, doc.to_string().as_bytes())
|
|
34
|
+
.with_context(|| format!("Failed to write config: {}", path.display()))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Required string argument or a descriptive error.
|
|
38
|
+
pub fn require_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str> {
|
|
39
|
+
arguments
|
|
40
|
+
.get(key)
|
|
41
|
+
.and_then(|v| v.as_str())
|
|
42
|
+
.ok_or_else(|| anyhow::anyhow!("'{key}' is required"))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Set `table[field]` to a string if the JSON arg is present; if it is JSON
|
|
46
|
+
/// null, remove the key (allows clearing a value).
|
|
47
|
+
pub fn set_opt_str(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
|
|
48
|
+
match arg {
|
|
49
|
+
Some(Value::Null) => {
|
|
50
|
+
table.remove(field);
|
|
51
|
+
}
|
|
52
|
+
Some(v) => {
|
|
53
|
+
if let Some(s) = v.as_str() {
|
|
54
|
+
table[field] = Item::Value(TomlValue::from(s));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
None => {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Set `table[field]` to a number if present; null removes it.
|
|
62
|
+
pub fn set_opt_f64(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
|
|
63
|
+
match arg {
|
|
64
|
+
Some(Value::Null) => {
|
|
65
|
+
table.remove(field);
|
|
66
|
+
}
|
|
67
|
+
Some(v) => {
|
|
68
|
+
if let Some(n) = v.as_f64() {
|
|
69
|
+
table[field] = Item::Value(TomlValue::from(n));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
None => {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Set `table[field]` to a bool if present; null removes it.
|
|
77
|
+
pub fn set_opt_bool(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
|
|
78
|
+
match arg {
|
|
79
|
+
Some(Value::Null) => {
|
|
80
|
+
table.remove(field);
|
|
81
|
+
}
|
|
82
|
+
Some(v) => {
|
|
83
|
+
if let Some(b) = v.as_bool() {
|
|
84
|
+
table[field] = Item::Value(TomlValue::from(b));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
None => {}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Set `table[field]` to a TOML array of strings if the JSON arg is an array;
|
|
92
|
+
/// null removes it.
|
|
93
|
+
pub fn set_string_array(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
|
|
94
|
+
match arg {
|
|
95
|
+
Some(Value::Null) => {
|
|
96
|
+
table.remove(field);
|
|
97
|
+
}
|
|
98
|
+
Some(Value::Array(items)) => {
|
|
99
|
+
let mut arr = Array::new();
|
|
100
|
+
for it in items {
|
|
101
|
+
if let Some(s) = it.as_str() {
|
|
102
|
+
arr.push(s);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
table[field] = Item::Value(TomlValue::Array(arr));
|
|
106
|
+
}
|
|
107
|
+
_ => {}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Set `table[field]` to a TOML array preserving integer-or-string items
|
|
112
|
+
/// (used for `closed_weekdays`, which may be numbers or weekday names); null removes it.
|
|
113
|
+
pub fn set_mixed_array(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
|
|
114
|
+
match arg {
|
|
115
|
+
Some(Value::Null) => {
|
|
116
|
+
table.remove(field);
|
|
117
|
+
}
|
|
118
|
+
Some(Value::Array(items)) => {
|
|
119
|
+
let mut arr = Array::new();
|
|
120
|
+
for it in items {
|
|
121
|
+
if let Some(i) = it.as_i64() {
|
|
122
|
+
arr.push(i);
|
|
123
|
+
} else if let Some(s) = it.as_str() {
|
|
124
|
+
arr.push(s);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
table[field] = Item::Value(TomlValue::Array(arr));
|
|
128
|
+
}
|
|
129
|
+
_ => {}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Replace a nested `[parent.<key>.<field>]`-style map of `{name: hours}` with
|
|
134
|
+
/// the provided JSON object (e.g. `day_hours`). null removes the whole sub-table.
|
|
135
|
+
pub fn set_f64_map(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
|
|
136
|
+
match arg {
|
|
137
|
+
Some(Value::Null) => {
|
|
138
|
+
table.remove(field);
|
|
139
|
+
}
|
|
140
|
+
Some(Value::Object(map)) => {
|
|
141
|
+
let mut sub = toml_edit::Table::new();
|
|
142
|
+
sub.set_implicit(false);
|
|
143
|
+
for (k, v) in map {
|
|
144
|
+
if let Some(n) = v.as_f64() {
|
|
145
|
+
sub[k] = Item::Value(TomlValue::from(n));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
table[field] = Item::Table(sub);
|
|
149
|
+
}
|
|
150
|
+
_ => {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/// Get a mutable reference to `doc[section]` as a table, creating it if absent.
|
|
155
|
+
pub fn ensure_table<'a>(
|
|
156
|
+
doc: &'a mut DocumentMut,
|
|
157
|
+
section: &str,
|
|
158
|
+
) -> Result<&'a mut toml_edit::Table> {
|
|
159
|
+
if !doc.contains_table(section) {
|
|
160
|
+
doc[section] = Item::Table(toml_edit::Table::new());
|
|
161
|
+
}
|
|
162
|
+
doc[section]
|
|
163
|
+
.as_table_mut()
|
|
164
|
+
.ok_or_else(|| anyhow::anyhow!("[{section}] exists but is not a table"))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Get a mutable reference to `doc[section][key]` as a table, creating both if
|
|
168
|
+
/// absent. Marks the parent as a dotted/implicit table so it serializes as
|
|
169
|
+
/// `[section.key]`.
|
|
170
|
+
pub fn ensure_subtable<'a>(
|
|
171
|
+
doc: &'a mut DocumentMut,
|
|
172
|
+
section: &str,
|
|
173
|
+
key: &str,
|
|
174
|
+
) -> Result<&'a mut toml_edit::Table> {
|
|
175
|
+
let parent = ensure_table(doc, section)?;
|
|
176
|
+
parent.set_implicit(true);
|
|
177
|
+
if !parent.contains_table(key) {
|
|
178
|
+
parent[key] = Item::Table(toml_edit::Table::new());
|
|
179
|
+
}
|
|
180
|
+
parent[key]
|
|
181
|
+
.as_table_mut()
|
|
182
|
+
.ok_or_else(|| anyhow::anyhow!("[{section}.{key}] exists but is not a table"))
|
|
183
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use serde_json::Value;
|
|
3
|
+
|
|
4
|
+
use super::resolve_project_dir;
|
|
5
|
+
use crate::storage::ensure_handoff_exists;
|
|
6
|
+
|
|
7
|
+
pub fn handle(arguments: &Value) -> Result<String> {
|
|
8
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
9
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
10
|
+
let sessions_dir = handoff.join("sessions");
|
|
11
|
+
|
|
12
|
+
let session_id = arguments
|
|
13
|
+
.get("session_id")
|
|
14
|
+
.and_then(|v| v.as_str())
|
|
15
|
+
.ok_or_else(|| anyhow::anyhow!("'session_id' parameter is required"))?;
|
|
16
|
+
|
|
17
|
+
if !sessions_dir.exists() {
|
|
18
|
+
anyhow::bail!("Sessions directory not found");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for entry in std::fs::read_dir(&sessions_dir)
|
|
22
|
+
.with_context(|| format!("Failed to read sessions dir: {}", sessions_dir.display()))?
|
|
23
|
+
{
|
|
24
|
+
let entry = entry?;
|
|
25
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
26
|
+
if !name.ends_with(".json") {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let content = match std::fs::read_to_string(entry.path()) {
|
|
31
|
+
Ok(c) => c,
|
|
32
|
+
Err(_) => continue,
|
|
33
|
+
};
|
|
34
|
+
let data: Value = match serde_json::from_str(&content) {
|
|
35
|
+
Ok(d) => d,
|
|
36
|
+
Err(_) => continue,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let file_id = data.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
|
40
|
+
if file_id == session_id || name.contains(session_id) {
|
|
41
|
+
return serde_json::to_string_pretty(&data).map_err(Into::into);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
anyhow::bail!(
|
|
46
|
+
"Session not found: {session_id}. Use handoff_list_sessions to see available session IDs."
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -250,6 +250,11 @@ fn create_task_recursive(
|
|
|
250
250
|
.get("order")
|
|
251
251
|
.and_then(|v| v.as_u64())
|
|
252
252
|
.map(|v| v as u32),
|
|
253
|
+
assignee: task_val
|
|
254
|
+
.get("assignee")
|
|
255
|
+
.and_then(|v| v.as_str())
|
|
256
|
+
.map(String::from),
|
|
257
|
+
extra: std::collections::HashMap::new(),
|
|
253
258
|
};
|
|
254
259
|
|
|
255
260
|
write_task(&task_dir, status, &data)?;
|
|
@@ -340,9 +345,11 @@ fn extract_schedule(val: &Value) -> Option<Schedule> {
|
|
|
340
345
|
.map(String::from),
|
|
341
346
|
estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
|
|
342
347
|
actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
|
|
348
|
+
remaining_hours: sched.get("remaining_hours").and_then(|v| v.as_f64()),
|
|
343
349
|
milestone: sched
|
|
344
350
|
.get("milestone")
|
|
345
351
|
.and_then(|v| v.as_str())
|
|
346
352
|
.map(String::from),
|
|
353
|
+
pinned: sched.get("pinned").and_then(|v| v.as_bool()),
|
|
347
354
|
})
|
|
348
355
|
}
|