handoff-mcp-server 0.11.0 → 0.13.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.
@@ -160,6 +160,44 @@ pub fn change_referral_status(
160
160
  Ok(())
161
161
  }
162
162
 
163
+ /// Read a single referral's full data by ID, returning the data and its status.
164
+ /// Searches every status (open / acknowledged / resolved). Matches by exact ID
165
+ /// first, then falls back to a unique prefix match for convenience.
166
+ pub fn read_referral_by_id(
167
+ referrals_dir: &Path,
168
+ referral_id: &str,
169
+ ) -> Result<Option<(ReferralData, String)>> {
170
+ if !referrals_dir.exists() {
171
+ return Ok(None);
172
+ }
173
+
174
+ let mut prefix_matches: Vec<(ReferralData, String)> = Vec::new();
175
+
176
+ for entry in std::fs::read_dir(referrals_dir)? {
177
+ let entry = entry?;
178
+ let name = entry.file_name().to_string_lossy().to_string();
179
+ if let Some(status) = parse_referral_status(&name) {
180
+ let content = std::fs::read_to_string(entry.path())?;
181
+ if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
182
+ if data.id == referral_id {
183
+ return Ok(Some((data, status)));
184
+ }
185
+ if data.id.starts_with(referral_id) {
186
+ prefix_matches.push((data, status));
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ match prefix_matches.len() {
193
+ 0 => Ok(None),
194
+ 1 => Ok(Some(prefix_matches.into_iter().next().unwrap())),
195
+ n => anyhow::bail!(
196
+ "Ambiguous referral id prefix '{referral_id}' matches {n} referrals; use the full id"
197
+ ),
198
+ }
199
+ }
200
+
163
201
  pub fn find_referral_file(
164
202
  referrals_dir: &Path,
165
203
  referral_id: &str,
@@ -590,6 +590,61 @@ pub fn validate_skipped_transition(task_dir: &Path, data: &TaskData) -> Result<(
590
590
  Ok(())
591
591
  }
592
592
 
593
+ /// Returns true if the task directory contains at least one child task
594
+ /// (a non-`_`/`.`-prefixed subdirectory holding a task file).
595
+ pub fn task_has_children(task_dir: &Path) -> Result<bool> {
596
+ if !task_dir.exists() {
597
+ return Ok(false);
598
+ }
599
+ for entry in std::fs::read_dir(task_dir)? {
600
+ let entry = entry?;
601
+ if !entry.file_type()?.is_dir() {
602
+ continue;
603
+ }
604
+ let name = entry.file_name().to_string_lossy().to_string();
605
+ if name.starts_with('_') || name.starts_with('.') {
606
+ continue;
607
+ }
608
+ if find_task_file(&entry.path())?.is_some() {
609
+ return Ok(true);
610
+ }
611
+ }
612
+ Ok(false)
613
+ }
614
+
615
+ /// Whether a task in the given status requires an effort estimate.
616
+ /// Parent tasks (with children) and blocked/skipped tasks are exempt;
617
+ /// this only covers the status dimension.
618
+ pub fn status_requires_estimate(status: &str) -> bool {
619
+ matches!(status, "todo" | "in_progress" | "review" | "done")
620
+ }
621
+
622
+ /// Validate that a leaf task carries an `estimate_hours` when the project
623
+ /// requires it. `has_children` lets the caller skip parent tasks.
624
+ /// Returns an error guiding the caller to add an estimate.
625
+ pub fn validate_estimate_required(
626
+ require_estimate_hours: bool,
627
+ status: &str,
628
+ has_children: bool,
629
+ schedule: Option<&Schedule>,
630
+ ) -> Result<()> {
631
+ if !require_estimate_hours || has_children || !status_requires_estimate(status) {
632
+ return Ok(());
633
+ }
634
+ let has_estimate = schedule
635
+ .and_then(|s| s.estimate_hours)
636
+ .is_some_and(|h| h > 0.0);
637
+ if !has_estimate {
638
+ anyhow::bail!(
639
+ "Task requires an effort estimate: set schedule.estimate_hours (hours, > 0) \
640
+ when creating or updating a leaf task in status '{status}'. \
641
+ Estimate the human-effort hours for this task. \
642
+ To disable this requirement project-wide, set settings.require_estimate_hours = false."
643
+ );
644
+ }
645
+ Ok(())
646
+ }
647
+
593
648
  fn check_children_terminal(task_dir: &Path, parent_id: &str) -> Result<()> {
594
649
  if !task_dir.exists() {
595
650
  return Ok(());