1use std::collections::{BTreeSet, HashMap};
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::config::Config;
14
15const BUNDLED_GITHUB_PR_WORKFLOW: &str = include_str!("workflows/github_pr.toml");
16const BUNDLED_DIAGNOSE_FAILURE_WORKFLOW: &str = include_str!("workflows/diagnose_failure.toml");
17const BUNDLED_FIX_FAILING_TESTS_WORKFLOW: &str = include_str!("workflows/fix_failing_tests.toml");
18const BUNDLED_RESEARCH_WORKFLOW: &str = include_str!("workflows/research.toml");
19const BUNDLED_PLANNING_WORKFLOW: &str = include_str!("workflows/planning.toml");
20const BUNDLED_TDD_PEER_REVIEW_WORKFLOW: &str = include_str!("workflows/tdd_peer_review.toml");
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct WorkflowClassifierConfig {
32 #[serde(default = "default_classifier_min_score")]
34 pub min_score: f32,
35 #[serde(default)]
37 pub keywords: Vec<String>,
38 #[serde(default)]
40 pub examples: Vec<String>,
41}
42
43impl Default for WorkflowClassifierConfig {
44 fn default() -> Self {
45 Self {
46 min_score: default_classifier_min_score(),
47 keywords: Vec::new(),
48 examples: Vec::new(),
49 }
50 }
51}
52
53fn default_classifier_min_score() -> f32 {
54 0.24
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct WorkflowStep {
60 pub id: String,
62 pub title: String,
64 #[serde(default)]
66 pub steer: String,
67 #[serde(default)]
75 pub tools: Vec<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct WorkflowConfig {
81 #[serde(default)]
83 pub name: String,
84 #[serde(default = "default_enabled")]
86 pub enabled: bool,
87 #[serde(default)]
89 pub description: String,
90 #[serde(default)]
92 pub classifier: WorkflowClassifierConfig,
93 #[serde(default)]
96 pub trigger_terms: Vec<String>,
97 #[serde(default)]
99 pub steps: Vec<WorkflowStep>,
100 #[serde(default)]
106 pub delegate_hint: Option<String>,
107 #[serde(default)]
113 pub progress_horizon_rounds: Option<usize>,
114}
115
116fn default_enabled() -> bool {
117 true
118}
119
120fn tokens(s: &str) -> BTreeSet<String> {
121 s.split(|c: char| !c.is_alphanumeric())
122 .filter_map(|raw| {
123 let t = raw.trim().to_ascii_lowercase();
124 (t.len() >= 3).then_some(t)
125 })
126 .collect()
127}
128
129fn prototype_similarity(query: &BTreeSet<String>, prototype: &BTreeSet<String>) -> f32 {
130 if query.is_empty() || prototype.is_empty() {
131 return 0.0;
132 }
133 let overlap = query.intersection(prototype).count() as f32;
134 let prototype_recall = overlap / prototype.len() as f32;
135 let union = query.union(prototype).count() as f32;
136 let jaccard = if union == 0.0 { 0.0 } else { overlap / union };
137 jaccard.max(prototype_recall)
138}
139
140impl WorkflowConfig {
141 fn with_name_from_path(mut self, path: &Path) -> Self {
142 if self.name.trim().is_empty() {
143 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
144 self.name = stem.to_string();
145 }
146 }
147 self
148 }
149
150 fn matches(&self, text: &str) -> bool {
151 let lc = text.to_ascii_lowercase();
152 let keyword_match = self
153 .classifier
154 .keywords
155 .iter()
156 .chain(self.trigger_terms.iter())
157 .map(|term| term.trim().to_ascii_lowercase())
158 .filter(|term| !term.is_empty())
159 .any(|term| lc.contains(&term));
160 if keyword_match {
161 return true;
162 }
163 let query = tokens(text);
164 self.classifier
165 .examples
166 .iter()
167 .map(|example| prototype_similarity(&query, &tokens(example)))
168 .fold(0.0_f32, f32::max)
169 >= self.classifier.min_score
170 }
171
172 fn render_plan_update_hint(&self) -> String {
173 let mut out = format!(
174 "Configured workflow '{}' is active",
175 if self.name.is_empty() {
176 "unnamed"
177 } else {
178 &self.name
179 }
180 );
181 if !self.description.trim().is_empty() {
182 out.push_str(": ");
183 out.push_str(self.description.trim());
184 }
185 out.push_str(". Track progress with update_plan so it survives session restarts/stops.");
186 if !self.steps.is_empty() {
187 out.push_str(" Workflow steps:");
188 for step in self.steps.iter().take(12) {
189 out.push_str("\n- ");
190 out.push_str(&step.id);
191 out.push_str(": ");
192 out.push_str(step.title.trim());
193 if !step.steer.trim().is_empty() {
194 out.push_str(" - ");
195 out.push_str(step.steer.trim());
196 }
197 if !step.tools.is_empty() {
198 out.push_str(" (tools: ");
199 out.push_str(&step.tools.join(", "));
200 out.push(')');
201 }
202 }
203 }
204 out.push_str(
205 "\nUse update_plan now to align the active plan to this workflow. Commit each verified coherent implementation step on the feature branch before pushing/opening the PR.",
206 );
207 out
208 }
209}
210
211#[must_use]
223pub fn builtin_workflows() -> Vec<WorkflowConfig> {
224 vec![
225 toml::from_str(BUNDLED_FIX_FAILING_TESTS_WORKFLOW)
226 .expect("bundled fix-failing-tests workflow template is valid"),
227 toml::from_str(BUNDLED_TDD_PEER_REVIEW_WORKFLOW)
228 .expect("bundled TDD-peer-review workflow template is valid"),
229 toml::from_str(BUNDLED_RESEARCH_WORKFLOW)
230 .expect("bundled research workflow template is valid"),
231 toml::from_str(BUNDLED_PLANNING_WORKFLOW)
232 .expect("bundled planning workflow template is valid"),
233 toml::from_str(BUNDLED_GITHUB_PR_WORKFLOW)
234 .expect("bundled GitHub PR workflow template is valid"),
235 toml::from_str(BUNDLED_DIAGNOSE_FAILURE_WORKFLOW)
236 .expect("bundled diagnose-failure workflow template is valid"),
237 ]
238}
239
240#[must_use]
242pub fn load_workflows_from_dir(dir: &Path) -> Vec<WorkflowConfig> {
243 let Ok(entries) = std::fs::read_dir(dir) else {
244 return Vec::new();
245 };
246 let mut paths: Vec<PathBuf> = entries
247 .flatten()
248 .map(|e| e.path())
249 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("toml"))
250 .collect();
251 paths.sort();
252 let mut out = Vec::new();
253 for path in paths {
254 match std::fs::read_to_string(&path).map(|s| toml::from_str::<WorkflowConfig>(&s)) {
255 Ok(Ok(workflow)) => out.push(workflow.with_name_from_path(&path)),
256 Ok(Err(e)) => {
257 tracing::warn!(path = %path.display(), error = %e, "skipping malformed workflow");
258 }
259 Err(_) => {}
260 }
261 }
262 out
263}
264
265#[must_use]
267pub fn merge_workflows(layers: Vec<Vec<WorkflowConfig>>) -> Vec<WorkflowConfig> {
268 let mut order = Vec::new();
269 let mut by_name: HashMap<String, WorkflowConfig> = HashMap::new();
270 for layer in layers {
271 for workflow in layer {
272 if !by_name.contains_key(&workflow.name) {
273 order.push(workflow.name.clone());
274 }
275 by_name.insert(workflow.name.clone(), workflow);
276 }
277 }
278 order
279 .into_iter()
280 .filter_map(|name| by_name.remove(&name))
281 .collect()
282}
283
284#[must_use]
287pub fn workflow_config_dir() -> Option<PathBuf> {
288 Config::user_config_dir().map(|dir| dir.join("workflows"))
289}
290
291#[derive(Debug, Clone)]
293pub struct WorkflowSteerer {
294 workflows: Vec<WorkflowConfig>,
295}
296
297impl WorkflowSteerer {
298 #[must_use]
299 pub fn builtin() -> Self {
300 Self {
301 workflows: builtin_workflows(),
302 }
303 }
304
305 #[must_use]
306 pub fn from_workflows(workflows: Vec<WorkflowConfig>) -> Self {
307 Self { workflows }
308 }
309
310 #[must_use]
311 pub fn load_from_dir(dir: &Path) -> Self {
312 Self::from_workflows(merge_workflows(vec![
313 builtin_workflows(),
314 load_workflows_from_dir(dir),
315 ]))
316 }
317
318 #[must_use]
320 pub fn load_default() -> Self {
321 #[cfg(test)]
322 {
323 Self::builtin()
324 }
325 #[cfg(not(test))]
326 {
327 workflow_config_dir()
328 .map(|dir| Self::load_from_dir(&dir))
329 .unwrap_or_else(Self::builtin)
330 }
331 }
332
333 #[must_use]
335 pub fn plan_update_hint(&self, text: &str) -> Option<String> {
336 self.workflows
337 .iter()
338 .filter(|workflow| workflow.enabled)
339 .find(|workflow| workflow.matches(text))
340 .map(WorkflowConfig::render_plan_update_hint)
341 }
342
343 fn matching(&self, text: &str) -> Option<&WorkflowConfig> {
347 self.workflows
348 .iter()
349 .filter(|workflow| workflow.enabled)
350 .find(|workflow| workflow.matches(text))
351 }
352
353 #[must_use]
358 pub fn delegate_hint(&self, text: &str, crew_available: bool) -> Option<String> {
359 if !crew_available {
360 return None;
361 }
362 self.matching(text)?
363 .delegate_hint
364 .as_deref()
365 .map(str::trim)
366 .filter(|hint| !hint.is_empty())
367 .map(str::to_string)
368 }
369
370 #[must_use]
373 pub fn progress_horizon(&self, text: &str) -> Option<usize> {
374 self.matching(text)?.progress_horizon_rounds
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn builtin_github_pr_workflow_mentions_branch_commit_push_and_pr() {
384 let steer = WorkflowSteerer::builtin();
385 let hint = steer
386 .plan_update_hint("I need to plan the implementation and open a PR")
387 .expect("built-in workflow should match");
388 assert!(hint.contains("github_pr"), "{hint}");
389 assert!(hint.contains("create_branch"), "{hint}");
390 assert!(hint.contains("commit_step"), "{hint}");
391 assert!(hint.contains("push_branch"), "{hint}");
392 assert!(hint.contains("open_pr"), "{hint}");
393 assert!(hint.contains("survives session restarts/stops"), "{hint}");
394 }
395
396 #[test]
397 fn workflow_dropin_overrides_builtin_and_filename_supplies_name() {
398 let dir = tempfile::tempdir().unwrap();
399 std::fs::write(
400 dir.path().join("github_pr.toml"),
401 r#"
402description = "custom PR flow"
403
404[classifier]
405keywords = ["ship it"]
406examples = ["Ship this branch by committing the work and opening a PR."]
407
408[[steps]]
409id = "custom_step"
410title = "Custom branch policy"
411steer = "Always commit before pushing"
412"#,
413 )
414 .unwrap();
415
416 let steer = WorkflowSteerer::load_from_dir(dir.path());
417 let hint = steer
418 .plan_update_hint("ship it")
419 .expect("drop-in workflow should match");
420 assert!(hint.contains("custom PR flow"), "{hint}");
421 assert!(hint.contains("custom_step"), "{hint}");
422 assert!(!hint.contains("read_issue"), "{hint}");
423 }
424
425 #[test]
426 fn step_tools_render_when_present_and_are_absent_when_empty() {
427 let dir = tempfile::tempdir().unwrap();
428 std::fs::write(
429 dir.path().join("synthetic.toml"),
430 r#"
431name = "synthetic"
432
433[classifier]
434keywords = ["do the synthetic thing"]
435
436[[steps]]
437id = "with_tools"
438title = "A step naming tools"
439steer = "do it"
440tools = ["read_file", "crew"]
441
442[[steps]]
443id = "without_tools"
444title = "A step naming no tools"
445steer = "do the other thing"
446"#,
447 )
448 .unwrap();
449
450 let steer = WorkflowSteerer::load_from_dir(dir.path());
451 let hint = steer
452 .plan_update_hint("do the synthetic thing")
453 .expect("synthetic workflow should match");
454 assert!(hint.contains("(tools: read_file, crew)"), "{hint}");
455 let without_tools_line = hint
457 .lines()
458 .find(|l| l.contains("without_tools"))
459 .expect("without_tools step should render");
460 assert!(
461 !without_tools_line.contains("(tools:"),
462 "{without_tools_line}"
463 );
464 }
465
466 #[test]
467 fn unmatched_text_does_not_select_a_default_workflow() {
468 let steer = WorkflowSteerer::builtin();
469 assert!(steer
470 .plan_update_hint("Summarize the local cache eviction behavior")
471 .is_none());
472 }
473
474 #[test]
475 fn workflow_dir_is_under_user_config_dir() {
476 let dir = workflow_config_dir().unwrap_or_else(|| PathBuf::from(".newt/workflows"));
477 assert!(dir.ends_with("workflows"));
478 }
479
480 #[test]
481 fn fix_failing_tests_workflow_matches_and_renders_tool_suggestions() {
482 let steer = WorkflowSteerer::builtin();
483 let hint = steer
484 .plan_update_hint("Keep iterating on the tests until the whole suite is green.")
485 .expect("built-in fix_failing_tests workflow should match");
486 assert!(hint.contains("fix_failing_tests"), "{hint}");
487 assert!(hint.contains("run_suite"), "{hint}");
488 assert!(hint.contains("loop_or_stop"), "{hint}");
489 assert!(hint.contains("(tools: run_command"), "{hint}");
491 assert!(hint.contains("edit_file"), "{hint}");
492 }
493
494 #[test]
495 fn fix_failing_tests_delegate_hint_offers_crew_for_the_mechanical_loop() {
496 let steer = WorkflowSteerer::builtin();
497 let hint = steer
498 .delegate_hint(
499 "Fix all the failing tests one at a time and re-run until none fail.",
500 true,
501 )
502 .expect("fix_failing_tests should match and offer a delegate hint");
503 assert!(hint.contains("crew"), "{hint}");
504 }
505
506 #[test]
507 fn research_workflow_matches_open_ended_investigation_not_a_failure() {
508 let steer = WorkflowSteerer::builtin();
509 let hint = steer
510 .plan_update_hint(
511 "Investigate how these two subsystems communicate before we change either one.",
512 )
513 .expect("built-in research workflow should match");
514 assert!(hint.contains("'research'"), "{hint}");
515 assert!(hint.contains("gather_evidence"), "{hint}");
516 assert!(hint.contains("(tools: code_search"), "{hint}");
517 }
518
519 #[test]
520 fn research_delegate_hint_frames_crew_as_parallel_breadth() {
521 let steer = WorkflowSteerer::builtin();
522 let hint = steer
523 .delegate_hint(
524 "Explore the codebase to find every place this pattern is used.",
525 true,
526 )
527 .expect("research should match and offer a delegate hint");
528 assert!(hint.contains("crew"), "{hint}");
529 assert!(hint.to_ascii_lowercase().contains("parallel"), "{hint}");
530 }
531
532 #[test]
533 fn planning_workflow_matches_design_first_phrasing() {
534 let steer = WorkflowSteerer::builtin();
535 let hint = steer
536 .plan_update_hint("Write a design doc proposing an approach to this problem.")
537 .expect("built-in planning workflow should match");
538 assert!(hint.contains("'planning'"), "{hint}");
539 assert!(hint.contains("adversarial_check"), "{hint}");
540 assert!(hint.contains("(tools: crew)"), "{hint}");
541 }
542
543 #[test]
544 fn planning_does_not_steal_the_github_pr_workflows_own_match() {
545 let steer = WorkflowSteerer::builtin();
549 let hint = steer
550 .plan_update_hint("I need to plan the implementation and open a PR")
551 .expect("built-in workflow should match");
552 assert!(hint.contains("github_pr"), "{hint}");
553 }
554
555 #[test]
556 fn tdd_peer_review_workflow_matches_test_first_phrasing() {
557 let steer = WorkflowSteerer::builtin();
558 let hint = steer
559 .plan_update_hint("Use TDD for this: write a failing test first, then implement.")
560 .expect("built-in tdd_peer_review workflow should match");
561 assert!(hint.contains("tdd_peer_review"), "{hint}");
562 assert!(hint.contains("confirm_red"), "{hint}");
563 assert!(hint.contains("peer_review"), "{hint}");
564 }
565
566 #[test]
567 fn tdd_peer_review_delegate_hint_frames_crew_as_independent_reviewer() {
568 let steer = WorkflowSteerer::builtin();
569 let hint = steer
570 .delegate_hint(
571 "Build this test-first — red, green, refactor — and then peer review it.",
572 true,
573 )
574 .expect("tdd_peer_review should match and offer a delegate hint");
575 assert!(hint.contains("crew"), "{hint}");
576 assert!(hint.to_ascii_lowercase().contains("independent"), "{hint}");
577 }
578
579 #[test]
580 fn diagnose_failure_workflow_matches_failure_investigation_text() {
581 let steer = WorkflowSteerer::builtin();
582 let hint = steer
583 .plan_update_hint("I'm showing broken tests here, can you find and fix them?")
584 .expect("built-in diagnose_failure workflow should match");
585 assert!(hint.contains("diagnose_failure"), "{hint}");
586 assert!(hint.contains("reproduce"), "{hint}");
587 assert!(hint.contains("isolate"), "{hint}");
588 }
589
590 #[test]
591 fn delegate_hint_is_none_without_crew_available_even_on_a_match() {
592 let steer = WorkflowSteerer::builtin();
593 assert!(steer
594 .delegate_hint("the pipeline is red, investigate why and fix it", false)
595 .is_none());
596 }
597
598 #[test]
599 fn delegate_hint_recommends_crew_team_when_matched_and_available() {
600 let steer = WorkflowSteerer::builtin();
601 let hint = steer
602 .delegate_hint("the pipeline is red, investigate why and fix it", true)
603 .expect("diagnose_failure workflow should match and offer a delegate hint");
604 assert!(hint.contains("crew"), "{hint}");
605 assert!(hint.contains("team"), "{hint}");
606 }
607
608 #[test]
609 fn delegate_hint_is_none_for_a_workflow_without_one_configured() {
610 let steer = WorkflowSteerer::builtin();
613 assert!(steer
614 .delegate_hint("I need to plan the implementation and open a PR", true)
615 .is_none());
616 }
617
618 #[test]
619 fn delegate_hint_is_none_for_unmatched_text() {
620 let steer = WorkflowSteerer::builtin();
621 assert!(steer
622 .delegate_hint("Summarize the local cache eviction behavior", true)
623 .is_none());
624 }
625
626 #[test]
627 fn progress_horizon_is_none_for_a_workflow_without_an_override() {
628 let steer = WorkflowSteerer::builtin();
629 assert_eq!(
630 steer.progress_horizon("I need to plan the implementation and open a PR"),
631 None
632 );
633 }
634
635 #[test]
636 fn progress_horizon_returns_the_matching_workflows_override() {
637 let steer = WorkflowSteerer::builtin();
638 assert_eq!(
639 steer.progress_horizon("the pipeline is red, investigate why and fix it"),
640 Some(6)
641 );
642 }
643
644 #[test]
650 fn misplaced_top_level_field_after_classifier_is_a_parse_error_not_silently_dropped() {
651 let toml = r#"
652name = "bad"
653
654[classifier]
655keywords = ["x"]
656delegate_hint = "this belongs to WorkflowConfig, not WorkflowClassifierConfig"
657"#;
658 let err = toml::from_str::<WorkflowConfig>(toml)
659 .expect_err("a misplaced field must fail to parse, not silently vanish");
660 assert!(
661 err.to_string().to_ascii_lowercase().contains("unknown"),
662 "{err}"
663 );
664 }
665
666 #[test]
667 fn progress_horizon_is_none_for_unmatched_text() {
668 let steer = WorkflowSteerer::builtin();
669 assert_eq!(
670 steer.progress_horizon("Summarize the local cache eviction behavior"),
671 None
672 );
673 }
674}