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
package/src/storage/tasks.rs
DELETED
|
@@ -1,957 +0,0 @@
|
|
|
1
|
-
use std::cmp::Reverse;
|
|
2
|
-
use std::collections::{HashMap, HashSet};
|
|
3
|
-
use std::path::{Path, PathBuf};
|
|
4
|
-
|
|
5
|
-
use anyhow::{Context, Result};
|
|
6
|
-
use chrono::Utc;
|
|
7
|
-
use serde::{Deserialize, Serialize};
|
|
8
|
-
use serde_json::Value;
|
|
9
|
-
|
|
10
|
-
fn is_empty_map(m: &HashMap<String, Value>) -> bool {
|
|
11
|
-
m.is_empty()
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
15
|
-
pub struct TaskData {
|
|
16
|
-
pub id: String,
|
|
17
|
-
pub title: String,
|
|
18
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
19
|
-
pub notes: Option<String>,
|
|
20
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
21
|
-
pub priority: Option<String>,
|
|
22
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
23
|
-
pub created_at: Option<String>,
|
|
24
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
25
|
-
pub updated_at: Option<String>,
|
|
26
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
27
|
-
pub completed_at: Option<String>,
|
|
28
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
29
|
-
pub labels: Vec<String>,
|
|
30
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
31
|
-
pub links: Vec<String>,
|
|
32
|
-
/// Typed document/task/URL links (wiki/130-document-management.md §9.1).
|
|
33
|
-
/// Additive alongside the legacy `links: Vec<String>` field, which is kept
|
|
34
|
-
/// as-is for backward compatibility. Use the `links()` accessor to read a
|
|
35
|
-
/// normalized, deduplicated view of both fields combined.
|
|
36
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
37
|
-
pub task_links: Vec<TaskLink>,
|
|
38
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
39
|
-
pub done_criteria: Vec<DoneCriterion>,
|
|
40
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
41
|
-
pub schedule: Option<Schedule>,
|
|
42
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
43
|
-
pub dependencies: Vec<String>,
|
|
44
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
45
|
-
pub order: Option<u32>,
|
|
46
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
47
|
-
pub assignee: Option<String>,
|
|
48
|
-
#[serde(flatten, default, skip_serializing_if = "is_empty_map")]
|
|
49
|
-
pub extra: HashMap<String, Value>,
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
53
|
-
pub struct Schedule {
|
|
54
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
55
|
-
pub start_date: Option<String>,
|
|
56
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
57
|
-
pub due_date: Option<String>,
|
|
58
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
59
|
-
pub estimate_hours: Option<f64>,
|
|
60
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
61
|
-
pub actual_hours: Option<f64>,
|
|
62
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
63
|
-
pub remaining_hours: Option<f64>,
|
|
64
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
65
|
-
pub milestone: Option<String>,
|
|
66
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
67
|
-
pub pinned: Option<bool>,
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
71
|
-
pub struct DoneCriterion {
|
|
72
|
-
pub item: String,
|
|
73
|
-
#[serde(default)]
|
|
74
|
-
pub checked: bool,
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/// A typed link from a task to a document, URL, file, or another task
|
|
78
|
-
/// (wiki/130-document-management.md §9.1). `link_type` distinguishes the
|
|
79
|
-
/// target kind: `"doc"` (document management fragment), `"url"`, `"file"`,
|
|
80
|
-
/// or `"task"`.
|
|
81
|
-
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
82
|
-
pub struct TaskLink {
|
|
83
|
-
pub target: String,
|
|
84
|
-
pub link_type: String,
|
|
85
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
86
|
-
pub label: Option<String>,
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
impl TaskData {
|
|
90
|
-
/// Normalized, deduplicated view of every link on this task: the legacy
|
|
91
|
-
/// `links: Vec<String>` field (each entry becomes `TaskLink { link_type:
|
|
92
|
-
/// "file", label: None }`) merged with `task_links`. Dedupes by
|
|
93
|
-
/// `(target, link_type)`, keeping the first occurrence — `task_links`
|
|
94
|
-
/// entries are checked first so a richer (labeled) `task_links` entry
|
|
95
|
-
/// wins over an equivalent bare legacy `links` entry.
|
|
96
|
-
pub fn links(&self) -> Vec<TaskLink> {
|
|
97
|
-
let mut seen: HashSet<(String, String)> = HashSet::new();
|
|
98
|
-
let mut result = Vec::with_capacity(self.task_links.len() + self.links.len());
|
|
99
|
-
|
|
100
|
-
for link in &self.task_links {
|
|
101
|
-
let key = (link.target.clone(), link.link_type.clone());
|
|
102
|
-
if seen.insert(key) {
|
|
103
|
-
result.push(link.clone());
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
for target in &self.links {
|
|
107
|
-
let key = (target.clone(), "file".to_string());
|
|
108
|
-
if seen.insert(key) {
|
|
109
|
-
result.push(TaskLink {
|
|
110
|
-
target: target.clone(),
|
|
111
|
-
link_type: "file".to_string(),
|
|
112
|
-
label: None,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
result
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
122
|
-
pub struct TaskIndex {
|
|
123
|
-
pub id: String,
|
|
124
|
-
pub title: String,
|
|
125
|
-
pub status: String,
|
|
126
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
127
|
-
pub schedule: Option<Schedule>,
|
|
128
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
129
|
-
pub dependencies: Vec<String>,
|
|
130
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
131
|
-
pub order: Option<u32>,
|
|
132
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
133
|
-
pub assignee: Option<String>,
|
|
134
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
135
|
-
pub children: Vec<TaskIndex>,
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
139
|
-
pub struct TaskSummary {
|
|
140
|
-
pub total: u32,
|
|
141
|
-
pub by_status: std::collections::HashMap<String, u32>,
|
|
142
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
143
|
-
pub total_estimate_hours: Option<f64>,
|
|
144
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
145
|
-
pub total_actual_hours: Option<f64>,
|
|
146
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
147
|
-
pub completion_rate: Option<f64>,
|
|
148
|
-
#[serde(default)]
|
|
149
|
-
pub overdue_count: u32,
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const VALID_STATUSES: &[&str] = &[
|
|
153
|
-
"todo",
|
|
154
|
-
"in_progress",
|
|
155
|
-
"review",
|
|
156
|
-
"done",
|
|
157
|
-
"blocked",
|
|
158
|
-
"skipped",
|
|
159
|
-
];
|
|
160
|
-
|
|
161
|
-
const VALID_PRIORITIES: &[&str] = &["low", "medium", "high"];
|
|
162
|
-
|
|
163
|
-
pub fn is_valid_status(status: &str) -> bool {
|
|
164
|
-
VALID_STATUSES.contains(&status)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
pub fn is_valid_priority(priority: &str) -> bool {
|
|
168
|
-
VALID_PRIORITIES.contains(&priority)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
pub fn validate_priority(priority: Option<&str>) -> Result<()> {
|
|
172
|
-
if let Some(p) = priority {
|
|
173
|
-
if !is_valid_priority(p) {
|
|
174
|
-
anyhow::bail!(
|
|
175
|
-
"Invalid priority: '{p}'. Must be one of: {}",
|
|
176
|
-
VALID_PRIORITIES.join(", ")
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
Ok(())
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
pub fn is_terminal_status(status: &str) -> bool {
|
|
184
|
-
status == "done" || status == "skipped"
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
pub fn title_to_slug(title: &str) -> String {
|
|
188
|
-
let slug: String = title
|
|
189
|
-
.chars()
|
|
190
|
-
.map(|c| {
|
|
191
|
-
if c.is_ascii_alphanumeric() {
|
|
192
|
-
c.to_ascii_lowercase()
|
|
193
|
-
} else if !c.is_ascii() {
|
|
194
|
-
c
|
|
195
|
-
} else {
|
|
196
|
-
'-'
|
|
197
|
-
}
|
|
198
|
-
})
|
|
199
|
-
.collect();
|
|
200
|
-
let slug = slug.trim_matches('-').to_string();
|
|
201
|
-
let mut result = String::new();
|
|
202
|
-
let mut prev_dash = false;
|
|
203
|
-
for c in slug.chars() {
|
|
204
|
-
if c == '-' {
|
|
205
|
-
if !prev_dash {
|
|
206
|
-
result.push(c);
|
|
207
|
-
}
|
|
208
|
-
prev_dash = true;
|
|
209
|
-
} else {
|
|
210
|
-
result.push(c);
|
|
211
|
-
prev_dash = false;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if result.is_empty() {
|
|
215
|
-
"task".to_string()
|
|
216
|
-
} else {
|
|
217
|
-
result
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
pub fn find_task_file(task_dir: &Path) -> Result<Option<(PathBuf, String)>> {
|
|
222
|
-
for entry in std::fs::read_dir(task_dir)
|
|
223
|
-
.with_context(|| format!("Failed to read task dir: {}", task_dir.display()))?
|
|
224
|
-
{
|
|
225
|
-
let entry = entry?;
|
|
226
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
227
|
-
if let Some(status) = parse_task_filename(&name) {
|
|
228
|
-
return Ok(Some((entry.path(), status)));
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
Ok(None)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
fn parse_task_filename(name: &str) -> Option<String> {
|
|
235
|
-
let name = name.strip_prefix("_task.")?;
|
|
236
|
-
let status = name.strip_suffix(".json")?;
|
|
237
|
-
if is_valid_status(status) {
|
|
238
|
-
Some(status.to_string())
|
|
239
|
-
} else {
|
|
240
|
-
None
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
pub fn read_task(task_dir: &Path) -> Result<Option<(TaskData, String)>> {
|
|
245
|
-
let (file_path, status) = match find_task_file(task_dir)? {
|
|
246
|
-
Some(v) => v,
|
|
247
|
-
None => return Ok(None),
|
|
248
|
-
};
|
|
249
|
-
let content = std::fs::read_to_string(&file_path)
|
|
250
|
-
.with_context(|| format!("Failed to read task: {}", file_path.display()))?;
|
|
251
|
-
let data: TaskData = serde_json::from_str(&content)
|
|
252
|
-
.with_context(|| format!("Failed to parse task: {}", file_path.display()))?;
|
|
253
|
-
Ok(Some((data, status)))
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
pub fn write_task(task_dir: &Path, status: &str, data: &TaskData) -> Result<()> {
|
|
257
|
-
let file_path = task_dir.join(format!("_task.{status}.json"));
|
|
258
|
-
let content = serde_json::to_string_pretty(data).context("Failed to serialize task")?;
|
|
259
|
-
crate::storage::atomic_write(&file_path, content.as_bytes())
|
|
260
|
-
.with_context(|| format!("Failed to write task: {}", file_path.display()))?;
|
|
261
|
-
Ok(())
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/// Read-modify-write a task with optimistic concurrency control.
|
|
265
|
-
///
|
|
266
|
-
/// Reads the current task, runs `mutate` on a copy, then re-reads just before
|
|
267
|
-
/// writing: if the file's `updated_at` changed since the snapshot (another
|
|
268
|
-
/// writer — e.g. the VSCode extension — won the race), the whole cycle retries
|
|
269
|
-
/// up to `MAX_RETRIES` times. This matches the VSCode side's `updated_at`
|
|
270
|
-
/// protocol (wiki/95-concurrency-safety.md) and prevents lost updates that
|
|
271
|
-
/// atomic_write alone cannot (atomic_write stops *torn* reads, not *lost*
|
|
272
|
-
/// updates).
|
|
273
|
-
///
|
|
274
|
-
/// `mutate` receives the current `TaskData` and the resolved status, and returns
|
|
275
|
-
/// the new status the task should have after the change (usually unchanged).
|
|
276
|
-
pub fn read_modify_write_task<F>(task_dir: &Path, mut mutate: F) -> Result<()>
|
|
277
|
-
where
|
|
278
|
-
F: FnMut(&mut TaskData, &str) -> Result<String>,
|
|
279
|
-
{
|
|
280
|
-
const MAX_RETRIES: usize = 5;
|
|
281
|
-
|
|
282
|
-
for attempt in 0..=MAX_RETRIES {
|
|
283
|
-
let (mut data, status) = read_task(task_dir)?
|
|
284
|
-
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
285
|
-
let snapshot_updated_at = data.updated_at.clone();
|
|
286
|
-
|
|
287
|
-
let new_status = mutate(&mut data, &status)?;
|
|
288
|
-
|
|
289
|
-
// Re-read to detect a concurrent writer before committing.
|
|
290
|
-
let current_updated_at = read_task(task_dir)?.and_then(|(d, _)| d.updated_at);
|
|
291
|
-
if current_updated_at != snapshot_updated_at {
|
|
292
|
-
// Someone else wrote between our read and write. Retry from scratch.
|
|
293
|
-
if attempt == MAX_RETRIES {
|
|
294
|
-
anyhow::bail!(
|
|
295
|
-
"Concurrent modification of task in {} after {} retries; aborting to avoid \
|
|
296
|
-
overwriting another writer's changes.",
|
|
297
|
-
task_dir.display(),
|
|
298
|
-
MAX_RETRIES
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
continue;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if new_status != status {
|
|
305
|
-
change_status(task_dir, &new_status)?;
|
|
306
|
-
}
|
|
307
|
-
write_task(task_dir, &new_status, &data)?;
|
|
308
|
-
return Ok(());
|
|
309
|
-
}
|
|
310
|
-
unreachable!("loop returns or bails within MAX_RETRIES iterations")
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
pub fn change_status(task_dir: &Path, new_status: &str) -> Result<()> {
|
|
314
|
-
if !is_valid_status(new_status) {
|
|
315
|
-
anyhow::bail!("Invalid status: {new_status}");
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
let (old_path, old_status) = find_task_file(task_dir)?
|
|
319
|
-
.ok_or_else(|| anyhow::anyhow!("No task file found in {}", task_dir.display()))?;
|
|
320
|
-
|
|
321
|
-
if old_status == new_status {
|
|
322
|
-
return Ok(());
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
let new_path = task_dir.join(format!("_task.{new_status}.json"));
|
|
326
|
-
std::fs::rename(&old_path, &new_path).with_context(|| {
|
|
327
|
-
format!(
|
|
328
|
-
"Failed to rename {} -> {}",
|
|
329
|
-
old_path.display(),
|
|
330
|
-
new_path.display()
|
|
331
|
-
)
|
|
332
|
-
})?;
|
|
333
|
-
|
|
334
|
-
Ok(())
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
pub fn next_child_id(parent_dir: &Path, parent_id: &str) -> Result<String> {
|
|
338
|
-
let mut max_n: u32 = 0;
|
|
339
|
-
|
|
340
|
-
if parent_dir.exists() {
|
|
341
|
-
for entry in std::fs::read_dir(parent_dir)? {
|
|
342
|
-
let entry = entry?;
|
|
343
|
-
if !entry.file_type()?.is_dir() {
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
347
|
-
if let Some(n) = extract_child_number(&name, parent_id) {
|
|
348
|
-
max_n = max_n.max(n);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
Ok(format!("{parent_id}.{}", max_n + 1))
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
pub fn next_top_level_id(tasks_dir: &Path) -> Result<String> {
|
|
357
|
-
let mut max_n: u32 = 0;
|
|
358
|
-
|
|
359
|
-
if tasks_dir.exists() {
|
|
360
|
-
for entry in std::fs::read_dir(tasks_dir)? {
|
|
361
|
-
let entry = entry?;
|
|
362
|
-
if !entry.file_type()?.is_dir() {
|
|
363
|
-
continue;
|
|
364
|
-
}
|
|
365
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
366
|
-
if let Some(n) = extract_top_level_number(&name) {
|
|
367
|
-
max_n = max_n.max(n);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
Ok(format!("t{}", max_n + 1))
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
fn extract_child_number(dir_name: &str, parent_id: &str) -> Option<u32> {
|
|
376
|
-
let prefix = format!("{parent_id}.");
|
|
377
|
-
let rest = dir_name.strip_prefix(&prefix)?;
|
|
378
|
-
let num_part = rest.split('-').next()?;
|
|
379
|
-
num_part.parse().ok()
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
fn extract_top_level_number(dir_name: &str) -> Option<u32> {
|
|
383
|
-
let rest = dir_name.strip_prefix('t')?;
|
|
384
|
-
let num_part = rest.split('-').next()?;
|
|
385
|
-
if num_part.contains('.') {
|
|
386
|
-
return None;
|
|
387
|
-
}
|
|
388
|
-
num_part.parse().ok()
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
pub fn find_task_dir_by_id(tasks_dir: &Path, task_id: &str) -> Result<Option<PathBuf>> {
|
|
392
|
-
find_task_dir_recursive(tasks_dir, task_id)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
fn dir_name_could_match(dir_name: &str, task_id: &str) -> bool {
|
|
396
|
-
dir_name == task_id
|
|
397
|
-
|| (dir_name.starts_with(task_id) && dir_name.as_bytes().get(task_id.len()) == Some(&b'-'))
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
fn find_task_dir_recursive(dir: &Path, task_id: &str) -> Result<Option<PathBuf>> {
|
|
401
|
-
if !dir.exists() {
|
|
402
|
-
return Ok(None);
|
|
403
|
-
}
|
|
404
|
-
let mut candidates = Vec::new();
|
|
405
|
-
let mut other_subdirs = Vec::new();
|
|
406
|
-
for entry in std::fs::read_dir(dir)? {
|
|
407
|
-
let entry = entry?;
|
|
408
|
-
if !entry.file_type()?.is_dir() {
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
412
|
-
if dir_name_could_match(&name, task_id) {
|
|
413
|
-
candidates.push(entry.path());
|
|
414
|
-
} else {
|
|
415
|
-
other_subdirs.push(entry.path());
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
// Verify candidates by reading the JSON id field.
|
|
419
|
-
for candidate in &candidates {
|
|
420
|
-
if let Some((data, _)) = read_task(candidate)? {
|
|
421
|
-
if data.id == task_id {
|
|
422
|
-
return Ok(Some(candidate.clone()));
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
// Recurse into all subdirectories (candidates that didn't match + others).
|
|
427
|
-
for subdir in candidates.into_iter().chain(other_subdirs) {
|
|
428
|
-
if let Some(found) = find_task_dir_recursive(&subdir, task_id)? {
|
|
429
|
-
return Ok(Some(found));
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
Ok(None)
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
fn collect_all_ids_recursive(dir: &Path, ids: &mut Vec<String>) -> Result<()> {
|
|
436
|
-
if !dir.exists() {
|
|
437
|
-
return Ok(());
|
|
438
|
-
}
|
|
439
|
-
for entry in std::fs::read_dir(dir)? {
|
|
440
|
-
let entry = entry?;
|
|
441
|
-
if !entry.file_type()?.is_dir() {
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
if let Some((data, _)) = read_task(&entry.path())? {
|
|
445
|
-
ids.push(data.id);
|
|
446
|
-
}
|
|
447
|
-
collect_all_ids_recursive(&entry.path(), ids)?;
|
|
448
|
-
}
|
|
449
|
-
Ok(())
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
pub fn suggest_task_id(tasks_dir: &Path, requested_id: &str) -> String {
|
|
453
|
-
let mut all_ids = Vec::new();
|
|
454
|
-
let _ = collect_all_ids_recursive(tasks_dir, &mut all_ids);
|
|
455
|
-
if all_ids.is_empty() {
|
|
456
|
-
return format!("Task not found: '{requested_id}'. No tasks exist yet.");
|
|
457
|
-
}
|
|
458
|
-
let mut scored: Vec<(&str, usize)> = all_ids
|
|
459
|
-
.iter()
|
|
460
|
-
.filter_map(|id| {
|
|
461
|
-
let score = fuzzy_score(requested_id, id);
|
|
462
|
-
if score > 0 {
|
|
463
|
-
Some((id.as_str(), score))
|
|
464
|
-
} else {
|
|
465
|
-
None
|
|
466
|
-
}
|
|
467
|
-
})
|
|
468
|
-
.collect();
|
|
469
|
-
scored.sort_by_key(|&(_, s)| Reverse(s));
|
|
470
|
-
scored.truncate(5);
|
|
471
|
-
|
|
472
|
-
if scored.is_empty() {
|
|
473
|
-
return format!(
|
|
474
|
-
"Task not found: '{requested_id}'. Use handoff_list_tasks to see available task IDs."
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
let suggestions: Vec<String> = scored.iter().map(|(id, _)| format!(" - {id}")).collect();
|
|
478
|
-
format!(
|
|
479
|
-
"Task not found: '{requested_id}'. Did you mean one of these?\n{}\n\
|
|
480
|
-
Use handoff_list_tasks to see all task IDs.",
|
|
481
|
-
suggestions.join("\n")
|
|
482
|
-
)
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
fn fuzzy_score(query: &str, candidate: &str) -> usize {
|
|
486
|
-
let q = query.to_lowercase();
|
|
487
|
-
let c = candidate.to_lowercase();
|
|
488
|
-
if c == q {
|
|
489
|
-
return 100;
|
|
490
|
-
}
|
|
491
|
-
if c.starts_with(&q) || q.starts_with(&c) {
|
|
492
|
-
return 80;
|
|
493
|
-
}
|
|
494
|
-
if c.contains(&q) || q.contains(&c) {
|
|
495
|
-
return 60;
|
|
496
|
-
}
|
|
497
|
-
let q_parts: Vec<&str> = q.split(['-', '.', '_']).collect();
|
|
498
|
-
let c_parts: Vec<&str> = c.split(['-', '.', '_']).collect();
|
|
499
|
-
let matching = q_parts.iter().filter(|p| c_parts.contains(p)).count();
|
|
500
|
-
if matching > 0 {
|
|
501
|
-
return 20 * matching;
|
|
502
|
-
}
|
|
503
|
-
0
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
pub fn build_task_index(
|
|
507
|
-
tasks_dir: &Path,
|
|
508
|
-
done_task_limit: u32,
|
|
509
|
-
) -> Result<(Vec<TaskIndex>, TaskSummary)> {
|
|
510
|
-
let mut tree = Vec::new();
|
|
511
|
-
let mut summary = TaskSummary {
|
|
512
|
-
total: 0,
|
|
513
|
-
by_status: HashMap::new(),
|
|
514
|
-
total_estimate_hours: None,
|
|
515
|
-
total_actual_hours: None,
|
|
516
|
-
completion_rate: None,
|
|
517
|
-
overdue_count: 0,
|
|
518
|
-
};
|
|
519
|
-
let mut done_count: u32 = 0;
|
|
520
|
-
let mut estimate_sum: f64 = 0.0;
|
|
521
|
-
let mut actual_sum: f64 = 0.0;
|
|
522
|
-
let mut has_hours = false;
|
|
523
|
-
let today = Utc::now().format("%Y-%m-%d").to_string();
|
|
524
|
-
|
|
525
|
-
build_index_recursive(
|
|
526
|
-
tasks_dir,
|
|
527
|
-
&mut tree,
|
|
528
|
-
&mut summary,
|
|
529
|
-
&mut done_count,
|
|
530
|
-
done_task_limit,
|
|
531
|
-
&mut estimate_sum,
|
|
532
|
-
&mut actual_sum,
|
|
533
|
-
&mut has_hours,
|
|
534
|
-
&today,
|
|
535
|
-
)?;
|
|
536
|
-
|
|
537
|
-
if has_hours {
|
|
538
|
-
summary.total_estimate_hours = Some(estimate_sum);
|
|
539
|
-
summary.total_actual_hours = Some(actual_sum);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if summary.total > 0 {
|
|
543
|
-
let done = *summary.by_status.get("done").unwrap_or(&0) as f64;
|
|
544
|
-
let skipped = *summary.by_status.get("skipped").unwrap_or(&0) as f64;
|
|
545
|
-
summary.completion_rate = Some((done + skipped) / summary.total as f64);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
Ok((tree, summary))
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
#[allow(clippy::too_many_arguments)]
|
|
552
|
-
fn build_index_recursive(
|
|
553
|
-
dir: &Path,
|
|
554
|
-
tree: &mut Vec<TaskIndex>,
|
|
555
|
-
summary: &mut TaskSummary,
|
|
556
|
-
done_count: &mut u32,
|
|
557
|
-
done_task_limit: u32,
|
|
558
|
-
estimate_sum: &mut f64,
|
|
559
|
-
actual_sum: &mut f64,
|
|
560
|
-
has_hours: &mut bool,
|
|
561
|
-
today: &str,
|
|
562
|
-
) -> Result<()> {
|
|
563
|
-
if !dir.exists() {
|
|
564
|
-
return Ok(());
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
let mut entries: Vec<_> = std::fs::read_dir(dir)?
|
|
568
|
-
.filter_map(|e| e.ok())
|
|
569
|
-
.filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
|
|
570
|
-
.collect();
|
|
571
|
-
entries.sort_by_key(|e| e.file_name());
|
|
572
|
-
|
|
573
|
-
for entry in entries {
|
|
574
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
575
|
-
if name.starts_with('.') {
|
|
576
|
-
continue;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
let task_dir = entry.path();
|
|
580
|
-
let (data, status) = match read_task(&task_dir)? {
|
|
581
|
-
Some(v) => v,
|
|
582
|
-
None => continue,
|
|
583
|
-
};
|
|
584
|
-
|
|
585
|
-
summary.total += 1;
|
|
586
|
-
*summary.by_status.entry(status.clone()).or_insert(0) += 1;
|
|
587
|
-
|
|
588
|
-
if let Some(ref sched) = data.schedule {
|
|
589
|
-
if let Some(est) = sched.estimate_hours {
|
|
590
|
-
*estimate_sum += est;
|
|
591
|
-
*has_hours = true;
|
|
592
|
-
}
|
|
593
|
-
if let Some(act) = sched.actual_hours {
|
|
594
|
-
*actual_sum += act;
|
|
595
|
-
*has_hours = true;
|
|
596
|
-
}
|
|
597
|
-
if let Some(ref due) = sched.due_date {
|
|
598
|
-
if !is_terminal_status(&status) && due.as_str() < today {
|
|
599
|
-
summary.overdue_count += 1;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
if is_terminal_status(&status) {
|
|
605
|
-
*done_count += 1;
|
|
606
|
-
if *done_count > done_task_limit {
|
|
607
|
-
continue;
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
let mut children = Vec::new();
|
|
612
|
-
build_index_recursive(
|
|
613
|
-
&task_dir,
|
|
614
|
-
&mut children,
|
|
615
|
-
summary,
|
|
616
|
-
done_count,
|
|
617
|
-
done_task_limit,
|
|
618
|
-
estimate_sum,
|
|
619
|
-
actual_sum,
|
|
620
|
-
has_hours,
|
|
621
|
-
today,
|
|
622
|
-
)?;
|
|
623
|
-
|
|
624
|
-
tree.push(TaskIndex {
|
|
625
|
-
id: data.id,
|
|
626
|
-
title: data.title,
|
|
627
|
-
status,
|
|
628
|
-
schedule: data.schedule,
|
|
629
|
-
dependencies: data.dependencies,
|
|
630
|
-
order: data.order,
|
|
631
|
-
assignee: data.assignee,
|
|
632
|
-
children,
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
Ok(())
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
pub fn validate_dependencies(tasks_dir: &Path, task_id: &str, new_deps: &[String]) -> Result<()> {
|
|
640
|
-
let dep_graph = build_dependency_graph(tasks_dir)?;
|
|
641
|
-
|
|
642
|
-
let mut graph = dep_graph;
|
|
643
|
-
graph.insert(task_id.to_string(), new_deps.to_vec());
|
|
644
|
-
|
|
645
|
-
let mut visited = HashSet::new();
|
|
646
|
-
let mut stack = HashSet::new();
|
|
647
|
-
|
|
648
|
-
if has_cycle(&graph, task_id, &mut visited, &mut stack) {
|
|
649
|
-
anyhow::bail!(
|
|
650
|
-
"Circular dependency detected: setting dependencies {:?} on task {task_id} would create a cycle",
|
|
651
|
-
new_deps
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
Ok(())
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/// Validate dependencies for a whole batch of tasks that do not exist yet.
|
|
659
|
-
///
|
|
660
|
-
/// `validate_dependencies` adds one node to the on-disk graph, which is all an
|
|
661
|
-
/// update needs. An import creates a tree in one call, so its tasks may depend on
|
|
662
|
-
/// each other: checking them one at a time would reject a legitimate dependency
|
|
663
|
-
/// on a sibling (not yet written) and miss a cycle that lives entirely inside the
|
|
664
|
-
/// batch (neither end written). Merge every pending node in first, then search.
|
|
665
|
-
///
|
|
666
|
-
/// A dependency naming a task that exists nowhere is left alone — it contributes
|
|
667
|
-
/// no edge, and `validate_dependencies` tolerates it too.
|
|
668
|
-
pub fn validate_dependencies_batch(
|
|
669
|
-
tasks_dir: &Path,
|
|
670
|
-
pending: &[(String, Vec<String>)],
|
|
671
|
-
) -> Result<()> {
|
|
672
|
-
let mut graph = build_dependency_graph(tasks_dir)?;
|
|
673
|
-
for (id, deps) in pending {
|
|
674
|
-
graph.insert(id.clone(), deps.clone());
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
for (id, deps) in pending {
|
|
678
|
-
let mut visited = HashSet::new();
|
|
679
|
-
let mut stack = HashSet::new();
|
|
680
|
-
if has_cycle(&graph, id, &mut visited, &mut stack) {
|
|
681
|
-
anyhow::bail!(
|
|
682
|
-
"Circular dependency detected: setting dependencies {deps:?} on task {id} would create a cycle"
|
|
683
|
-
);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
Ok(())
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
fn build_dependency_graph(tasks_dir: &Path) -> Result<HashMap<String, Vec<String>>> {
|
|
691
|
-
let mut graph = HashMap::new();
|
|
692
|
-
build_dep_graph_recursive(tasks_dir, &mut graph)?;
|
|
693
|
-
Ok(graph)
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
fn build_dep_graph_recursive(dir: &Path, graph: &mut HashMap<String, Vec<String>>) -> Result<()> {
|
|
697
|
-
if !dir.exists() {
|
|
698
|
-
return Ok(());
|
|
699
|
-
}
|
|
700
|
-
for entry in std::fs::read_dir(dir)? {
|
|
701
|
-
let entry = entry?;
|
|
702
|
-
if !entry.file_type()?.is_dir() {
|
|
703
|
-
continue;
|
|
704
|
-
}
|
|
705
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
706
|
-
if name.starts_with('.') || name.starts_with('_') {
|
|
707
|
-
continue;
|
|
708
|
-
}
|
|
709
|
-
let task_dir = entry.path();
|
|
710
|
-
if let Some((data, _)) = read_task(&task_dir)? {
|
|
711
|
-
graph.insert(data.id.clone(), data.dependencies.clone());
|
|
712
|
-
build_dep_graph_recursive(&task_dir, graph)?;
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
Ok(())
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
fn has_cycle(
|
|
719
|
-
graph: &HashMap<String, Vec<String>>,
|
|
720
|
-
node: &str,
|
|
721
|
-
visited: &mut HashSet<String>,
|
|
722
|
-
stack: &mut HashSet<String>,
|
|
723
|
-
) -> bool {
|
|
724
|
-
if stack.contains(node) {
|
|
725
|
-
return true;
|
|
726
|
-
}
|
|
727
|
-
if visited.contains(node) {
|
|
728
|
-
return false;
|
|
729
|
-
}
|
|
730
|
-
visited.insert(node.to_string());
|
|
731
|
-
stack.insert(node.to_string());
|
|
732
|
-
|
|
733
|
-
if let Some(deps) = graph.get(node) {
|
|
734
|
-
for dep in deps {
|
|
735
|
-
if has_cycle(graph, dep, visited, stack) {
|
|
736
|
-
return true;
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
stack.remove(node);
|
|
742
|
-
false
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
pub fn validate_done_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
|
|
746
|
-
for criterion in &data.done_criteria {
|
|
747
|
-
if !criterion.checked {
|
|
748
|
-
anyhow::bail!(
|
|
749
|
-
"Cannot mark task {} as done: done_criteria item '{}' is not checked",
|
|
750
|
-
data.id,
|
|
751
|
-
criterion.item
|
|
752
|
-
);
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
check_children_terminal(task_dir, &data.id)?;
|
|
757
|
-
|
|
758
|
-
Ok(())
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
pub fn validate_skipped_transition(task_dir: &Path, data: &TaskData) -> Result<()> {
|
|
762
|
-
check_children_terminal(task_dir, &data.id)?;
|
|
763
|
-
Ok(())
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/// Returns true if the task directory contains at least one child task
|
|
767
|
-
/// (a non-`_`/`.`-prefixed subdirectory holding a task file).
|
|
768
|
-
pub fn task_has_children(task_dir: &Path) -> Result<bool> {
|
|
769
|
-
if !task_dir.exists() {
|
|
770
|
-
return Ok(false);
|
|
771
|
-
}
|
|
772
|
-
for entry in std::fs::read_dir(task_dir)? {
|
|
773
|
-
let entry = entry?;
|
|
774
|
-
if !entry.file_type()?.is_dir() {
|
|
775
|
-
continue;
|
|
776
|
-
}
|
|
777
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
778
|
-
if name.starts_with('_') || name.starts_with('.') {
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
781
|
-
if find_task_file(&entry.path())?.is_some() {
|
|
782
|
-
return Ok(true);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
Ok(false)
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
/// Whether a task in the given status requires an effort estimate.
|
|
789
|
-
/// Parent tasks (with children) and blocked/skipped/todo tasks are exempt;
|
|
790
|
-
/// this only covers the status dimension. `todo` is exempt so that parent
|
|
791
|
-
/// tasks can be created before their children exist (the children turn the
|
|
792
|
-
/// parent from a leaf into a parent, removing the estimate requirement).
|
|
793
|
-
/// The estimate is enforced when the task moves to `in_progress` or later.
|
|
794
|
-
pub fn status_requires_estimate(status: &str) -> bool {
|
|
795
|
-
matches!(status, "in_progress" | "review" | "done")
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
/// Validate that a leaf task carries an `estimate_hours` when the project
|
|
799
|
-
/// requires it. `has_children` lets the caller skip parent tasks.
|
|
800
|
-
///
|
|
801
|
-
/// The rejection names the offending task and shows a ready-to-send payload,
|
|
802
|
-
/// so a caller that forgot the estimate can fix it in one retry instead of
|
|
803
|
-
/// discovering the required shape by trial and error. `is_create` tailors that
|
|
804
|
-
/// payload: creating a task also needs `title`, whereas an update must not
|
|
805
|
-
/// imply the stored title should be overwritten.
|
|
806
|
-
pub fn validate_estimate_required(
|
|
807
|
-
require_estimate_hours: bool,
|
|
808
|
-
id: &str,
|
|
809
|
-
title: &str,
|
|
810
|
-
status: &str,
|
|
811
|
-
has_children: bool,
|
|
812
|
-
is_create: bool,
|
|
813
|
-
schedule: Option<&Schedule>,
|
|
814
|
-
) -> Result<()> {
|
|
815
|
-
if !require_estimate_hours || has_children || !status_requires_estimate(status) {
|
|
816
|
-
return Ok(());
|
|
817
|
-
}
|
|
818
|
-
let has_estimate = schedule
|
|
819
|
-
.and_then(|s| s.estimate_hours)
|
|
820
|
-
.is_some_and(|h| h > 0.0);
|
|
821
|
-
if !has_estimate {
|
|
822
|
-
// Mirror the rejected call, so the example can be resent as-is. Build it
|
|
823
|
-
// with serde_json rather than string formatting: a title carrying a quote,
|
|
824
|
-
// a backslash, or a control character must still serialize to valid JSON.
|
|
825
|
-
// A create also needs `title`; an update must not imply overwriting it.
|
|
826
|
-
let mut example = serde_json::Map::new();
|
|
827
|
-
example.insert("id".into(), Value::String(id.to_string()));
|
|
828
|
-
if is_create {
|
|
829
|
-
example.insert("title".into(), Value::String(title.to_string()));
|
|
830
|
-
}
|
|
831
|
-
example.insert(
|
|
832
|
-
"schedule".into(),
|
|
833
|
-
serde_json::json!({ "estimate_hours": 2.0 }),
|
|
834
|
-
);
|
|
835
|
-
let example = Value::Object(example);
|
|
836
|
-
anyhow::bail!(
|
|
837
|
-
"Task '{id}' ({title}) requires an effort estimate: \
|
|
838
|
-
schedule.estimate_hours (hours, > 0) is mandatory for a leaf task \
|
|
839
|
-
in status '{status}'.\n\
|
|
840
|
-
Estimate the raw human-effort hours — do not pre-multiply by the \
|
|
841
|
-
AI-effort multiplier; that is applied at aggregation time.\n\
|
|
842
|
-
Resend with, for example:\n \
|
|
843
|
-
{example}\n\
|
|
844
|
-
Exempt from this rule: parent tasks (any task with children), and \
|
|
845
|
-
tasks in status 'todo', 'blocked', or 'skipped'.\n\
|
|
846
|
-
To disable this requirement project-wide, set \
|
|
847
|
-
settings.require_estimate_hours = false."
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
Ok(())
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
|
|
854
|
-
if !task_dir.exists() {
|
|
855
|
-
return Ok(());
|
|
856
|
-
}
|
|
857
|
-
for entry in std::fs::read_dir(task_dir)? {
|
|
858
|
-
let entry = entry?;
|
|
859
|
-
if !entry.file_type()?.is_dir() {
|
|
860
|
-
continue;
|
|
861
|
-
}
|
|
862
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
863
|
-
if name.starts_with('_') || name.starts_with('.') {
|
|
864
|
-
continue;
|
|
865
|
-
}
|
|
866
|
-
if let Some((_, status)) = find_task_file(&entry.path())? {
|
|
867
|
-
if !is_terminal_status(&status) {
|
|
868
|
-
anyhow::bail!(
|
|
869
|
-
"Cannot mark task {parent_id} as done/skipped: child task in directory '{}' has status '{status}'",
|
|
870
|
-
name
|
|
871
|
-
);
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
Ok(())
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
/// Result of [`sync_doc_task_links`]: reports which of the requested task ids
|
|
879
|
-
/// could not be resolved to an existing task, so the caller (e.g. `doc_save`)
|
|
880
|
-
/// can surface a warning to the user instead of the sync silently doing
|
|
881
|
-
/// nothing for a mistyped id.
|
|
882
|
-
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
883
|
-
pub struct SyncReport {
|
|
884
|
-
/// Task ids passed in `link_task_ids` or `unlink_task_ids` that did not
|
|
885
|
-
/// resolve to an existing task directory. Order-preserved, may contain
|
|
886
|
-
/// ids from either list combined.
|
|
887
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
888
|
-
pub unresolved: Vec<String>,
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
/// Synchronize the task side of a task<->document bidirectional link
|
|
892
|
-
/// (wiki/130-document-management.md §9.2). Intended to be called by the
|
|
893
|
-
/// `handoff_doc_save` handler (t96) after it has written its own doc-side
|
|
894
|
-
/// `task_ids` field, so the two stay in agreement.
|
|
895
|
-
///
|
|
896
|
-
/// For every task id in `link_task_ids`, push-if-absent a
|
|
897
|
-
/// `TaskLink { target: doc_id, link_type: "doc", label: Some(doc_title) }`
|
|
898
|
-
/// into that task's `task_links` (deduped by `(target, link_type)`).
|
|
899
|
-
/// For every task id in `unlink_task_ids`, remove any `task_links` entry
|
|
900
|
-
/// with `target == doc_id && link_type == "doc"`.
|
|
901
|
-
///
|
|
902
|
-
/// A task id that does not resolve to an existing task directory is not
|
|
903
|
-
/// treated as a hard error: the caller (doc_save) may be syncing a batch of
|
|
904
|
-
/// task_ids where one was mistyped, and a document write should not be
|
|
905
|
-
/// rolled back over an unrelated task lookup failure. Instead it is
|
|
906
|
-
/// collected into the returned [`SyncReport::unresolved`] list so the caller
|
|
907
|
-
/// can report it back to the user (partial success + report, not a silent
|
|
908
|
-
/// skip).
|
|
909
|
-
pub fn sync_doc_task_links(
|
|
910
|
-
tasks_dir: &Path,
|
|
911
|
-
doc_id: &str,
|
|
912
|
-
doc_title: &str,
|
|
913
|
-
link_task_ids: &[String],
|
|
914
|
-
unlink_task_ids: &[String],
|
|
915
|
-
) -> Result<SyncReport> {
|
|
916
|
-
let mut unresolved = Vec::new();
|
|
917
|
-
|
|
918
|
-
for task_id in link_task_ids {
|
|
919
|
-
let Some(task_dir) = find_task_dir_by_id(tasks_dir, task_id)? else {
|
|
920
|
-
unresolved.push(task_id.clone());
|
|
921
|
-
continue;
|
|
922
|
-
};
|
|
923
|
-
read_modify_write_task(&task_dir, |data, status| {
|
|
924
|
-
let already_linked = data
|
|
925
|
-
.task_links
|
|
926
|
-
.iter()
|
|
927
|
-
.any(|l| l.target == doc_id && l.link_type == "doc");
|
|
928
|
-
if !already_linked {
|
|
929
|
-
data.task_links.push(TaskLink {
|
|
930
|
-
target: doc_id.to_string(),
|
|
931
|
-
link_type: "doc".to_string(),
|
|
932
|
-
label: Some(doc_title.to_string()),
|
|
933
|
-
});
|
|
934
|
-
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
935
|
-
}
|
|
936
|
-
Ok(status.to_string())
|
|
937
|
-
})?;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
for task_id in unlink_task_ids {
|
|
941
|
-
let Some(task_dir) = find_task_dir_by_id(tasks_dir, task_id)? else {
|
|
942
|
-
unresolved.push(task_id.clone());
|
|
943
|
-
continue;
|
|
944
|
-
};
|
|
945
|
-
read_modify_write_task(&task_dir, |data, status| {
|
|
946
|
-
let before = data.task_links.len();
|
|
947
|
-
data.task_links
|
|
948
|
-
.retain(|l| !(l.target == doc_id && l.link_type == "doc"));
|
|
949
|
-
if data.task_links.len() != before {
|
|
950
|
-
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
951
|
-
}
|
|
952
|
-
Ok(status.to_string())
|
|
953
|
-
})?;
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
Ok(SyncReport { unresolved })
|
|
957
|
-
}
|