Skip to main content

newt_core/
workflows.rs

1//! Configurable workflow steers.
2//!
3//! Workflow drop-ins live under `~/.newt/workflows/` (or
4//! `$NEWT_CONFIG_DIR/workflows/`). They are not runners: they are steerable
5//! process definitions the agentic loop can quote when a model drifts into a
6//! handoff summary instead of maintaining its plan and continuing.
7
8use 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/// Front matter that lets the workflow classifier find this workflow.
23///
24/// `deny_unknown_fields`: a misplaced top-level field (e.g. `delegate_hint`
25/// written AFTER `[classifier]` opens) would otherwise be silently parsed as
26/// an unknown key of *this* table and dropped — no error, no warning, just a
27/// field that quietly never takes effect. Denying unknown fields turns that
28/// into a loud parse failure instead.
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct WorkflowClassifierConfig {
32    /// Minimum prototype similarity required for an example match.
33    #[serde(default = "default_classifier_min_score")]
34    pub min_score: f32,
35    /// Substrings that directly select this workflow.
36    #[serde(default)]
37    pub keywords: Vec<String>,
38    /// Prototype task phrases for lightweight semantic matching.
39    #[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/// One workflow step the model should track in `update_plan`.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct WorkflowStep {
60    /// Stable step key, e.g. `read_issue`.
61    pub id: String,
62    /// Short model-facing step title.
63    pub title: String,
64    /// Concrete steering text for this step.
65    #[serde(default)]
66    pub steer: String,
67    /// Suggested tool names for this step (e.g. `["code_search", "read_file"]`,
68    /// or `["crew"]`/`["team"]` when the step itself is naturally a
69    /// delegation point). Advisory only — never a hard requirement, and
70    /// never validated against what's actually available this session (a
71    /// tool named here but not offered is simply not usable; the model
72    /// already knows its real tool list). Empty means no specific
73    /// suggestion for this step.
74    #[serde(default)]
75    pub tools: Vec<String>,
76}
77
78/// A configured workflow steer.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct WorkflowConfig {
81    /// Workflow name. For drop-ins this may be omitted; the filename stem wins.
82    #[serde(default)]
83    pub name: String,
84    /// Whether this workflow is eligible for steering.
85    #[serde(default = "default_enabled")]
86    pub enabled: bool,
87    /// Short description shown in nudges.
88    #[serde(default)]
89    pub description: String,
90    /// Classifier front matter for selecting this workflow.
91    #[serde(default)]
92    pub classifier: WorkflowClassifierConfig,
93    /// Compatibility alias for early workflow configs. Prefer
94    /// `[classifier].keywords` in new drop-ins.
95    #[serde(default)]
96    pub trigger_terms: Vec<String>,
97    /// Ordered workflow steps.
98    #[serde(default)]
99    pub steps: Vec<WorkflowStep>,
100    /// A short model-facing hint recommending `crew`/`team` sub-agent
101    /// delegation instead of continuing to spend this session's own round
102    /// budget, offered only when this workflow matches AND sub-agent dispatch
103    /// is actually available this session. `None` (the default) means this
104    /// workflow never suggests delegation.
105    #[serde(default)]
106    pub delegate_hint: Option<String>,
107    /// Override for how many rounds without a plan/edit checkpoint still
108    /// count as "recent progress" for round-cap grace, when this workflow
109    /// matches. `None` uses the shared default. Diagnostic workflows
110    /// legitimately need more read-only rounds between checkpoints than
111    /// routine edits do.
112    #[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/// Built-in workflows, most-specific first (a earlier entry wins ties in
212/// [`WorkflowSteerer::matching`]'s first-match search):
213/// - `fix_failing_tests` — the specific, mechanical run→fix→reverify LOOP.
214/// - `tdd_peer_review` — test-first implementation with an independent review.
215/// - `research` — an open-ended investigative question, not tied to a failure.
216/// - `planning` — produce/critique a plan or design before implementing.
217/// - `github_pr` — issue/request -> branch -> commits -> PR.
218/// - `diagnose_failure` — the broadest: investigate a failing
219///   test/pipeline/build/PR conflict, then land a fix.
220///
221/// All but `github_pr` carry a `delegate_hint` pointing at `crew`.
222#[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/// Load workflow drop-ins from a directory. Missing/malformed files are skipped.
241#[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/// Merge workflow layers by name. Later layers replace earlier layers.
266#[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/// The workflow drop-in directory: `$NEWT_CONFIG_DIR/workflows` or
285/// `~/.newt/workflows`.
286#[must_use]
287pub fn workflow_config_dir() -> Option<PathBuf> {
288    Config::user_config_dir().map(|dir| dir.join("workflows"))
289}
290
291/// Configurable workflow steerer used by the agentic loop.
292#[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    /// Load user workflows from `~/.newt/workflows/`.
319    #[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    /// Render a workflow hint for a plan-update stall.
334    #[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    /// The FIRST enabled matching workflow, if any (shared by
344    /// [`Self::delegate_hint`] and [`Self::progress_horizon`] so both read the
345    /// same match rather than each re-running the classifier).
346    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    /// A short hint recommending `crew`/`team` delegation, when the matching
354    /// workflow configures one AND sub-agent dispatch is available this
355    /// session (`crew_available`). Never returned when `crew_available` is
356    /// false, regardless of workflow match — there is nothing to delegate to.
357    #[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    /// The matching workflow's round-cap grace "recent progress" horizon
371    /// override, if any.
372    #[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        // The tool-less step's line must not carry a stray/empty "(tools: )".
456        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        // Per-step tool suggestions render into the hint.
490        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        // Regression: "plan the implementation" is common enough vocabulary
546        // that `planning`'s classifier (listed before `github_pr`) initially
547        // stole this exact github_pr test phrase via example similarity.
548        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        // github_pr has no `delegate_hint` configured — a match must not
611        // fabricate a delegation suggestion it didn't ask for.
612        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    /// Regression: a `delegate_hint`/`progress_horizon_rounds` field written
645    /// AFTER `[classifier]` opens used to be silently swallowed as an unknown
646    /// key of `WorkflowClassifierConfig` — no error, and the field just never
647    /// took effect (the exact bug this bundled workflow file hit while being
648    /// authored). `deny_unknown_fields` turns that into a loud parse error.
649    #[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}