handoff-mcp-server 0.10.0 → 0.11.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 +223 -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 +316 -0
- package/src/mcp/handlers/config.rs +212 -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 +185 -0
- package/src/mcp/handlers/milestones.rs +102 -0
- package/src/mcp/handlers/mod.rs +29 -0
- package/src/mcp/handlers/update_session.rs +1 -1
- package/src/mcp/handlers/update_task.rs +46 -2
- package/src/mcp/tools.rs +339 -3
- package/src/storage/config.rs +145 -1
- package/src/storage/mod.rs +42 -0
- package/src/storage/referrals.rs +1 -1
- package/src/storage/sessions.rs +95 -22
- package/src/storage/tasks.rs +67 -2
package/src/storage/mod.rs
CHANGED
|
@@ -8,6 +8,48 @@ use std::path::{Path, PathBuf};
|
|
|
8
8
|
|
|
9
9
|
use anyhow::{Context, Result};
|
|
10
10
|
|
|
11
|
+
/// Write `content` to `path` atomically: write to a sibling temp file, fsync,
|
|
12
|
+
/// then rename over the target. A rename within the same directory is atomic on
|
|
13
|
+
/// POSIX, so a concurrent reader never observes a partially-written file.
|
|
14
|
+
///
|
|
15
|
+
/// Used by every handoff write path (tasks, config, sessions, referrals) so that
|
|
16
|
+
/// the VSCode extension — which writes the same files — never reads torn data.
|
|
17
|
+
pub fn atomic_write(path: impl AsRef<Path>, content: &[u8]) -> Result<()> {
|
|
18
|
+
use std::io::Write;
|
|
19
|
+
|
|
20
|
+
let path = path.as_ref();
|
|
21
|
+
let dir = path.parent().ok_or_else(|| {
|
|
22
|
+
anyhow::anyhow!("Cannot determine parent directory for {}", path.display())
|
|
23
|
+
})?;
|
|
24
|
+
let file_name = path
|
|
25
|
+
.file_name()
|
|
26
|
+
.and_then(|n| n.to_str())
|
|
27
|
+
.ok_or_else(|| anyhow::anyhow!("Invalid file name for {}", path.display()))?;
|
|
28
|
+
|
|
29
|
+
// Unique-per-process temp name in the same directory (so rename is atomic).
|
|
30
|
+
let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
|
|
31
|
+
let tmp_path = dir.join(tmp_name);
|
|
32
|
+
|
|
33
|
+
let mut f = std::fs::File::create(&tmp_path)
|
|
34
|
+
.with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?;
|
|
35
|
+
f.write_all(content)
|
|
36
|
+
.with_context(|| format!("Failed to write temp file {}", tmp_path.display()))?;
|
|
37
|
+
f.sync_all()
|
|
38
|
+
.with_context(|| format!("Failed to sync temp file {}", tmp_path.display()))?;
|
|
39
|
+
drop(f);
|
|
40
|
+
|
|
41
|
+
std::fs::rename(&tmp_path, path).with_context(|| {
|
|
42
|
+
// Best-effort cleanup so a failed rename doesn't leave a stray temp file.
|
|
43
|
+
let _ = std::fs::remove_file(&tmp_path);
|
|
44
|
+
format!(
|
|
45
|
+
"Failed to rename {} -> {}",
|
|
46
|
+
tmp_path.display(),
|
|
47
|
+
path.display()
|
|
48
|
+
)
|
|
49
|
+
})?;
|
|
50
|
+
Ok(())
|
|
51
|
+
}
|
|
52
|
+
|
|
11
53
|
pub fn expand_tilde(path: &str) -> String {
|
|
12
54
|
if let Some(rest) = path.strip_prefix("~/") {
|
|
13
55
|
if let Ok(home) = std::env::var("HOME") {
|
package/src/storage/referrals.rs
CHANGED
|
@@ -59,7 +59,7 @@ pub fn write_referral(referrals_dir: &Path, data: &ReferralData) -> Result<PathB
|
|
|
59
59
|
let file_path = referrals_dir.join(&filename);
|
|
60
60
|
|
|
61
61
|
let content = serde_json::to_string_pretty(data).context("Failed to serialize referral")?;
|
|
62
|
-
|
|
62
|
+
crate::storage::atomic_write(&file_path, content.as_bytes())
|
|
63
63
|
.with_context(|| format!("Failed to write referral: {}", file_path.display()))?;
|
|
64
64
|
|
|
65
65
|
Ok(file_path)
|
package/src/storage/sessions.rs
CHANGED
|
@@ -109,6 +109,10 @@ fn synthesize_id_from_filename(filename: &str) -> String {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
fn ids_match(candidate: &str, query: &str) -> bool {
|
|
113
|
+
candidate == query || candidate.starts_with(query) || query.starts_with(candidate)
|
|
114
|
+
}
|
|
115
|
+
|
|
112
116
|
pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
|
|
113
117
|
let mut data = data.clone();
|
|
114
118
|
if data.id.is_none() {
|
|
@@ -121,7 +125,7 @@ pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<Pat
|
|
|
121
125
|
let path = sessions_dir.join(&filename);
|
|
122
126
|
|
|
123
127
|
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
124
|
-
|
|
128
|
+
crate::storage::atomic_write(&path, content.as_bytes())
|
|
125
129
|
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
126
130
|
|
|
127
131
|
Ok(path)
|
|
@@ -165,6 +169,40 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
|
165
169
|
read_sessions_by_status(sessions_dir, "active")
|
|
166
170
|
}
|
|
167
171
|
|
|
172
|
+
/// Append `decision` (a decision object: {decision, reason?, confidence?}) to the
|
|
173
|
+
/// `decisions` list of every active session file. Returns the number of sessions
|
|
174
|
+
/// updated. Used by tools like auto_schedule to record applied changes so the
|
|
175
|
+
/// audit trail lives with the session, not just the tool response.
|
|
176
|
+
pub fn append_decision_to_active_sessions(
|
|
177
|
+
sessions_dir: &Path,
|
|
178
|
+
decision: serde_json::Value,
|
|
179
|
+
) -> Result<usize> {
|
|
180
|
+
let suffix = ".active.json";
|
|
181
|
+
if !sessions_dir.exists() {
|
|
182
|
+
return Ok(0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let mut count = 0;
|
|
186
|
+
for entry in std::fs::read_dir(sessions_dir)? {
|
|
187
|
+
let entry = entry?;
|
|
188
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
189
|
+
if !name.ends_with(suffix) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let path = entry.path();
|
|
193
|
+
let content = std::fs::read_to_string(&path)
|
|
194
|
+
.with_context(|| format!("Failed to read session: {}", path.display()))?;
|
|
195
|
+
let mut data: SessionData = serde_json::from_str(&content)
|
|
196
|
+
.with_context(|| format!("Failed to parse session: {}", path.display()))?;
|
|
197
|
+
data.decisions.push(decision.clone());
|
|
198
|
+
let updated = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
199
|
+
crate::storage::atomic_write(&path, updated.as_bytes())
|
|
200
|
+
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
201
|
+
count += 1;
|
|
202
|
+
}
|
|
203
|
+
Ok(count)
|
|
204
|
+
}
|
|
205
|
+
|
|
168
206
|
fn transition_sessions(sessions_dir: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
|
|
169
207
|
let mut transitioned = Vec::new();
|
|
170
208
|
let from_suffix = format!(".{from}.json");
|
|
@@ -206,6 +244,8 @@ fn transition_session_by_id(
|
|
|
206
244
|
return Ok(None);
|
|
207
245
|
}
|
|
208
246
|
|
|
247
|
+
let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
|
|
248
|
+
|
|
209
249
|
for entry in std::fs::read_dir(sessions_dir)? {
|
|
210
250
|
let entry = entry?;
|
|
211
251
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
@@ -219,25 +259,39 @@ fn transition_session_by_id(
|
|
|
219
259
|
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
220
260
|
|
|
221
261
|
let file_id = data.id.as_deref().unwrap_or("").to_string();
|
|
222
|
-
let
|
|
262
|
+
let resolved_id = if file_id.is_empty() {
|
|
223
263
|
synthesize_id_from_filename(&name)
|
|
224
264
|
} else {
|
|
225
265
|
file_id
|
|
226
266
|
};
|
|
227
267
|
|
|
228
|
-
if
|
|
229
|
-
|
|
230
|
-
let new_path = sessions_dir.join(&new_name);
|
|
231
|
-
std::fs::rename(entry.path(), &new_path).with_context(|| {
|
|
232
|
-
format!(
|
|
233
|
-
"Failed to transition session {from}->{to}: {}",
|
|
234
|
-
entry.path().display()
|
|
235
|
-
)
|
|
236
|
-
})?;
|
|
237
|
-
return Ok(Some(new_path));
|
|
268
|
+
if ids_match(&resolved_id, session_id) {
|
|
269
|
+
matches.push((entry.path(), name, resolved_id));
|
|
238
270
|
}
|
|
239
271
|
}
|
|
240
272
|
|
|
273
|
+
if matches.len() > 1 {
|
|
274
|
+
let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
|
|
275
|
+
anyhow::bail!(
|
|
276
|
+
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
277
|
+
session_id,
|
|
278
|
+
matches.len(),
|
|
279
|
+
candidates.join(", ")
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if let Some((path, name, _)) = matches.into_iter().next() {
|
|
284
|
+
let new_name = name.replace(&from_suffix, &to_suffix);
|
|
285
|
+
let new_path = sessions_dir.join(&new_name);
|
|
286
|
+
std::fs::rename(&path, &new_path).with_context(|| {
|
|
287
|
+
format!(
|
|
288
|
+
"Failed to transition session {from}->{to}: {}",
|
|
289
|
+
path.display()
|
|
290
|
+
)
|
|
291
|
+
})?;
|
|
292
|
+
return Ok(Some(new_path));
|
|
293
|
+
}
|
|
294
|
+
|
|
241
295
|
Ok(None)
|
|
242
296
|
}
|
|
243
297
|
|
|
@@ -329,6 +383,8 @@ fn find_and_update_active_session(
|
|
|
329
383
|
return Ok(None);
|
|
330
384
|
}
|
|
331
385
|
|
|
386
|
+
let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
|
|
387
|
+
|
|
332
388
|
for entry in std::fs::read_dir(sessions_dir)? {
|
|
333
389
|
let entry = entry?;
|
|
334
390
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
@@ -338,41 +394,58 @@ fn find_and_update_active_session(
|
|
|
338
394
|
|
|
339
395
|
let content = std::fs::read_to_string(entry.path())
|
|
340
396
|
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
341
|
-
let
|
|
397
|
+
let data: SessionData = serde_json::from_str(&content)
|
|
342
398
|
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
343
399
|
|
|
344
400
|
let file_id = data.id.as_deref().unwrap_or("").to_string();
|
|
345
|
-
let
|
|
401
|
+
let resolved_id = if file_id.is_empty() {
|
|
346
402
|
synthesize_id_from_filename(&name)
|
|
347
403
|
} else {
|
|
348
404
|
file_id
|
|
349
405
|
};
|
|
350
406
|
|
|
351
|
-
if
|
|
352
|
-
|
|
407
|
+
if ids_match(&resolved_id, session_id) {
|
|
408
|
+
matches.push((entry.path(), name, resolved_id));
|
|
353
409
|
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if matches.len() > 1 {
|
|
413
|
+
let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
|
|
414
|
+
anyhow::bail!(
|
|
415
|
+
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
416
|
+
session_id,
|
|
417
|
+
matches.len(),
|
|
418
|
+
candidates.join(", ")
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if let Some((path, name, _)) = matches.into_iter().next() {
|
|
423
|
+
let content = std::fs::read_to_string(&path)
|
|
424
|
+
.with_context(|| format!("Failed to read session: {}", path.display()))?;
|
|
425
|
+
let mut data: SessionData = serde_json::from_str(&content)
|
|
426
|
+
.with_context(|| format!("Failed to parse session: {}", path.display()))?;
|
|
354
427
|
|
|
355
428
|
apply_session_updates(&mut data, updates);
|
|
356
429
|
|
|
357
430
|
let updated_content =
|
|
358
431
|
serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
359
|
-
|
|
360
|
-
.with_context(|| format!("Failed to write session: {}",
|
|
432
|
+
crate::storage::atomic_write(&path, updated_content.as_bytes())
|
|
433
|
+
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
361
434
|
|
|
362
435
|
if let Some(target_status) = transition_to {
|
|
363
436
|
let target_suffix = format!(".{target_status}.json");
|
|
364
437
|
let new_name = name.replace(suffix, &target_suffix);
|
|
365
438
|
let new_path = sessions_dir.join(&new_name);
|
|
366
|
-
std::fs::rename(
|
|
439
|
+
std::fs::rename(&path, &new_path).with_context(|| {
|
|
367
440
|
format!(
|
|
368
441
|
"Failed to transition session active->{target_status}: {}",
|
|
369
|
-
|
|
442
|
+
path.display()
|
|
370
443
|
)
|
|
371
444
|
})?;
|
|
372
445
|
return Ok(Some(new_path));
|
|
373
446
|
}
|
|
374
447
|
|
|
375
|
-
return Ok(Some(
|
|
448
|
+
return Ok(Some(path));
|
|
376
449
|
}
|
|
377
450
|
|
|
378
451
|
Ok(None)
|
|
@@ -440,7 +513,7 @@ pub fn write_session_with_status(
|
|
|
440
513
|
let path = sessions_dir.join(&filename);
|
|
441
514
|
|
|
442
515
|
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
443
|
-
|
|
516
|
+
crate::storage::atomic_write(&path, content.as_bytes())
|
|
444
517
|
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
445
518
|
|
|
446
519
|
Ok(path)
|
package/src/storage/tasks.rs
CHANGED
|
@@ -4,6 +4,11 @@ use std::path::{Path, PathBuf};
|
|
|
4
4
|
use anyhow::{Context, Result};
|
|
5
5
|
use chrono::Utc;
|
|
6
6
|
use serde::{Deserialize, Serialize};
|
|
7
|
+
use serde_json::Value;
|
|
8
|
+
|
|
9
|
+
fn is_empty_map(m: &HashMap<String, Value>) -> bool {
|
|
10
|
+
m.is_empty()
|
|
11
|
+
}
|
|
7
12
|
|
|
8
13
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
9
14
|
pub struct TaskData {
|
|
@@ -31,9 +36,13 @@ pub struct TaskData {
|
|
|
31
36
|
pub dependencies: Vec<String>,
|
|
32
37
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
33
38
|
pub order: Option<u32>,
|
|
39
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
40
|
+
pub assignee: Option<String>,
|
|
41
|
+
#[serde(flatten, default, skip_serializing_if = "is_empty_map")]
|
|
42
|
+
pub extra: HashMap<String, Value>,
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
45
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
37
46
|
pub struct Schedule {
|
|
38
47
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
39
48
|
pub start_date: Option<String>,
|
|
@@ -44,7 +53,11 @@ pub struct Schedule {
|
|
|
44
53
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
45
54
|
pub actual_hours: Option<f64>,
|
|
46
55
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
56
|
+
pub remaining_hours: Option<f64>,
|
|
57
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
47
58
|
pub milestone: Option<String>,
|
|
59
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
60
|
+
pub pinned: Option<bool>,
|
|
48
61
|
}
|
|
49
62
|
|
|
50
63
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -65,6 +78,8 @@ pub struct TaskIndex {
|
|
|
65
78
|
pub dependencies: Vec<String>,
|
|
66
79
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
67
80
|
pub order: Option<u32>,
|
|
81
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
82
|
+
pub assignee: Option<String>,
|
|
68
83
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
69
84
|
pub children: Vec<TaskIndex>,
|
|
70
85
|
}
|
|
@@ -190,11 +205,60 @@ pub fn read_task(task_dir: &Path) -> Result<Option<(TaskData, String)>> {
|
|
|
190
205
|
pub fn write_task(task_dir: &Path, status: &str, data: &TaskData) -> Result<()> {
|
|
191
206
|
let file_path = task_dir.join(format!("_task.{status}.json"));
|
|
192
207
|
let content = serde_json::to_string_pretty(data).context("Failed to serialize task")?;
|
|
193
|
-
|
|
208
|
+
crate::storage::atomic_write(&file_path, content.as_bytes())
|
|
194
209
|
.with_context(|| format!("Failed to write task: {}", file_path.display()))?;
|
|
195
210
|
Ok(())
|
|
196
211
|
}
|
|
197
212
|
|
|
213
|
+
/// Read-modify-write a task with optimistic concurrency control.
|
|
214
|
+
///
|
|
215
|
+
/// Reads the current task, runs `mutate` on a copy, then re-reads just before
|
|
216
|
+
/// writing: if the file's `updated_at` changed since the snapshot (another
|
|
217
|
+
/// writer — e.g. the VSCode extension — won the race), the whole cycle retries
|
|
218
|
+
/// up to `MAX_RETRIES` times. This matches the VSCode side's `updated_at`
|
|
219
|
+
/// protocol (wiki/95-concurrency-safety.md) and prevents lost updates that
|
|
220
|
+
/// atomic_write alone cannot (atomic_write stops *torn* reads, not *lost*
|
|
221
|
+
/// updates).
|
|
222
|
+
///
|
|
223
|
+
/// `mutate` receives the current `TaskData` and the resolved status, and returns
|
|
224
|
+
/// the new status the task should have after the change (usually unchanged).
|
|
225
|
+
pub fn read_modify_write_task<F>(task_dir: &Path, mut mutate: F) -> Result<()>
|
|
226
|
+
where
|
|
227
|
+
F: FnMut(&mut TaskData, &str) -> Result<String>,
|
|
228
|
+
{
|
|
229
|
+
const MAX_RETRIES: usize = 5;
|
|
230
|
+
|
|
231
|
+
for attempt in 0..=MAX_RETRIES {
|
|
232
|
+
let (mut data, status) = read_task(task_dir)?
|
|
233
|
+
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
234
|
+
let snapshot_updated_at = data.updated_at.clone();
|
|
235
|
+
|
|
236
|
+
let new_status = mutate(&mut data, &status)?;
|
|
237
|
+
|
|
238
|
+
// Re-read to detect a concurrent writer before committing.
|
|
239
|
+
let current_updated_at = read_task(task_dir)?.and_then(|(d, _)| d.updated_at);
|
|
240
|
+
if current_updated_at != snapshot_updated_at {
|
|
241
|
+
// Someone else wrote between our read and write. Retry from scratch.
|
|
242
|
+
if attempt == MAX_RETRIES {
|
|
243
|
+
anyhow::bail!(
|
|
244
|
+
"Concurrent modification of task in {} after {} retries; aborting to avoid \
|
|
245
|
+
overwriting another writer's changes.",
|
|
246
|
+
task_dir.display(),
|
|
247
|
+
MAX_RETRIES
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if new_status != status {
|
|
254
|
+
change_status(task_dir, &new_status)?;
|
|
255
|
+
}
|
|
256
|
+
write_task(task_dir, &new_status, &data)?;
|
|
257
|
+
return Ok(());
|
|
258
|
+
}
|
|
259
|
+
unreachable!("loop returns or bails within MAX_RETRIES iterations")
|
|
260
|
+
}
|
|
261
|
+
|
|
198
262
|
pub fn change_status(task_dir: &Path, new_status: &str) -> Result<()> {
|
|
199
263
|
if !is_valid_status(new_status) {
|
|
200
264
|
anyhow::bail!("Invalid status: {new_status}");
|
|
@@ -423,6 +487,7 @@ fn build_index_recursive(
|
|
|
423
487
|
schedule: data.schedule,
|
|
424
488
|
dependencies: data.dependencies,
|
|
425
489
|
order: data.order,
|
|
490
|
+
assignee: data.assignee,
|
|
426
491
|
children,
|
|
427
492
|
});
|
|
428
493
|
}
|