Skip to main content

oxi/skills/
context_builder.rs

1//! Context builder skill for oxi
2//!
3//! Requirements analysis and context generation skill that builds rich
4//! context packages for other skills to consume. The context builder:
5//!
6//! 1. **Ingests requirements** — raw user input, specs, tickets, or conversations
7//! 2. **Analyzes** — identifies key entities, constraints, and success criteria
8//! 3. **Cross-references** — connects requirements to existing codebase artifacts
9//! 4. **Structures** — produces a formal [`RequirementsContext`] document
10//! 5. **Validates** — checks for completeness, consistency, and ambiguity
11//!
12//! The module provides:
13//! - [`ContextBuilderSession`] — state machine for the context-building lifecycle
14//! - [`RequirementsContext`] — the structured output document
15//! - [`Requirement`] / [`Constraint`] / [`Entity`] — structured requirement types
16//! - [`ContextReport`] — validation report with completeness checks
17//! - [`ContextBuilderSkill`] — skill prompt generator
18
19use anyhow::{bail, Context, Result};
20use chrono::Utc;
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::fmt;
24use std::fs;
25use std::path::{Path, PathBuf};
26
27// ── Phase ──────────────────────────────────────────────────────────────
28
29/// The phase a context builder session is in.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum ContextPhase {
33    /// Ingesting raw requirements.
34    Ingest,
35    /// Analyzing and structuring requirements.
36    Analyze,
37    /// Cross-referencing with codebase.
38    CrossReference,
39    /// Structuring the output document.
40    Structure,
41    /// Validating completeness and consistency.
42    Validate,
43    /// Documenting the context.
44    Document,
45    /// Context building complete.
46    Done,
47}
48
49impl Default for ContextPhase {
50    fn default() -> Self {
51        ContextPhase::Ingest
52    }
53}
54
55impl fmt::Display for ContextPhase {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            ContextPhase::Ingest => write!(f, "Ingest"),
59            ContextPhase::Analyze => write!(f, "Analyze"),
60            ContextPhase::CrossReference => write!(f, "Cross-Reference"),
61            ContextPhase::Structure => write!(f, "Structure"),
62            ContextPhase::Validate => write!(f, "Validate"),
63            ContextPhase::Document => write!(f, "Document"),
64            ContextPhase::Done => write!(f, "Done"),
65        }
66    }
67}
68
69// ── Requirement types ──────────────────────────────────────────────────
70
71/// Priority of a requirement.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum Priority {
75    /// Nice to have — can be deferred.
76    Low,
77    /// Should have — important but not blocking.
78    Medium,
79    /// Must have — critical for success.
80    High,
81    /// Non-negotiable — system won't work without it.
82    Critical,
83}
84
85impl fmt::Display for Priority {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Priority::Low => write!(f, "low"),
89            Priority::Medium => write!(f, "medium"),
90            Priority::High => write!(f, "high"),
91            Priority::Critical => write!(f, "critical"),
92        }
93    }
94}
95
96/// A single structured requirement.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Requirement {
99    /// Unique identifier (e.g., "REQ-001").
100    pub id: String,
101    /// Short title.
102    pub title: String,
103    /// Detailed description.
104    pub description: String,
105    /// Priority level.
106    pub priority: Priority,
107    /// Category (e.g., "functional", "non-functional", "security").
108    pub category: RequirementCategory,
109    /// Acceptance criteria (testable conditions).
110    pub acceptance_criteria: Vec<String>,
111    /// Source of this requirement (user input, spec doc, etc.).
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub source: Option<String>,
114    /// Related codebase files.
115    #[serde(default)]
116    pub related_files: Vec<String>,
117    /// Whether this requirement has been validated.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub validation: Option<RequirementValidation>,
120}
121
122/// Category of requirement.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum RequirementCategory {
126    /// Core functionality the system must provide.
127    Functional,
128    /// Performance, reliability, scalability requirements.
129    NonFunctional,
130    /// Security and access control requirements.
131    Security,
132    /// User interface / UX requirements.
133    UserExperience,
134    /// Integration with external systems.
135    Integration,
136    /// Data model and persistence requirements.
137    Data,
138    /// Testing and quality requirements.
139    Testing,
140    /// Deployment and operations requirements.
141    Operations,
142}
143
144impl fmt::Display for RequirementCategory {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        match self {
147            RequirementCategory::Functional => write!(f, "functional"),
148            RequirementCategory::NonFunctional => write!(f, "non-functional"),
149            RequirementCategory::Security => write!(f, "security"),
150            RequirementCategory::UserExperience => write!(f, "ux"),
151            RequirementCategory::Integration => write!(f, "integration"),
152            RequirementCategory::Data => write!(f, "data"),
153            RequirementCategory::Testing => write!(f, "testing"),
154            RequirementCategory::Operations => write!(f, "operations"),
155        }
156    }
157}
158
159/// Validation result for a requirement.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct RequirementValidation {
162    /// Whether the requirement is complete and unambiguous.
163    pub is_valid: bool,
164    /// Issues found during validation.
165    pub issues: Vec<ValidationIssue>,
166}
167
168/// A validation issue.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ValidationIssue {
171    /// What the issue is.
172    pub description: String,
173    /// Severity of the issue.
174    pub severity: ValidationSeverity,
175}
176
177/// Severity of a validation issue.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum ValidationSeverity {
181    /// Ambiguous or unclear wording.
182    Ambiguous,
183    /// Missing critical information.
184    Incomplete,
185    /// Conflicts with another requirement.
186    Conflicting,
187    /// Not testable or measurable.
188    Untestable,
189}
190
191impl fmt::Display for ValidationSeverity {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        match self {
194            ValidationSeverity::Ambiguous => write!(f, "ambiguous"),
195            ValidationSeverity::Incomplete => write!(f, "incomplete"),
196            ValidationSeverity::Conflicting => write!(f, "conflicting"),
197            ValidationSeverity::Untestable => write!(f, "untestable"),
198        }
199    }
200}
201
202// ── Entity and constraint ──────────────────────────────────────────────
203
204/// A key entity identified from requirements (e.g., "User", "Order", "Cache").
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct Entity {
207    /// Entity name.
208    pub name: String,
209    /// Description of the entity.
210    pub description: String,
211    /// Attributes / properties of the entity.
212    pub attributes: Vec<EntityAttribute>,
213    /// Relationships to other entities.
214    pub relationships: Vec<String>,
215    /// Source requirement IDs that mention this entity.
216    pub source_requirements: Vec<String>,
217}
218
219/// An attribute of an entity.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct EntityAttribute {
222    /// Attribute name.
223    pub name: String,
224    /// Data type (informal, e.g., "string", "integer", "timestamp").
225    pub attr_type: String,
226    /// Whether this attribute is required.
227    pub required: bool,
228    /// Optional description.
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub description: Option<String>,
231}
232
233/// A constraint identified from requirements.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Constraint {
236    /// Short description.
237    pub description: String,
238    /// Type of constraint.
239    pub constraint_type: ConstraintType,
240    /// Source requirement ID.
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub source: Option<String>,
243}
244
245/// Type of constraint.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum ConstraintType {
249    /// Technical constraint (language, framework, platform).
250    Technical,
251    /// Performance constraint (latency, throughput, memory).
252    Performance,
253    /// Business rule constraint.
254    Business,
255    /// Compatibility constraint (API version, data format).
256    Compatibility,
257    /// Regulatory or compliance constraint.
258    Regulatory,
259}
260
261impl fmt::Display for ConstraintType {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            ConstraintType::Technical => write!(f, "technical"),
265            ConstraintType::Performance => write!(f, "performance"),
266            ConstraintType::Business => write!(f, "business"),
267            ConstraintType::Compatibility => write!(f, "compatibility"),
268            ConstraintType::Regulatory => write!(f, "regulatory"),
269        }
270    }
271}
272
273// ── Codebase cross-reference ───────────────────────────────────────────
274
275/// A cross-reference between requirements and codebase artifacts.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct CodebaseCrossRef {
278    /// Files relevant to the requirements.
279    pub relevant_files: Vec<FileRelevance>,
280    /// Existing patterns that apply.
281    pub applicable_patterns: Vec<String>,
282    /// Dependencies that need to be added or are already available.
283    pub dependencies: Vec<String>,
284    /// Potential conflicts with existing code.
285    pub conflicts: Vec<String>,
286}
287
288/// A file's relevance to the requirements.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct FileRelevance {
291    /// File path.
292    pub path: String,
293    /// How relevant this file is.
294    pub relevance: RelevanceLevel,
295    /// Why it's relevant.
296    pub reason: String,
297    /// Which requirements it relates to.
298    pub related_requirements: Vec<String>,
299}
300
301/// How relevant a file is.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum RelevanceLevel {
305    /// Directly implements or will be modified.
306    Direct,
307    /// Related context that should be understood.
308    Contextual,
309    /// May be affected indirectly.
310    Indirect,
311}
312
313impl fmt::Display for RelevanceLevel {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        match self {
316            RelevanceLevel::Direct => write!(f, "direct"),
317            RelevanceLevel::Contextual => write!(f, "contextual"),
318            RelevanceLevel::Indirect => write!(f, "indirect"),
319        }
320    }
321}
322
323// ── Requirements context document ──────────────────────────────────────
324
325/// The structured requirements context document.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct RequirementsContext {
328    /// Document title.
329    pub title: String,
330    /// Creation timestamp.
331    pub created_at: String,
332    /// Version.
333    pub version: u32,
334    /// Raw input summary (what was ingested).
335    pub input_summary: String,
336    /// Structured requirements.
337    pub requirements: Vec<Requirement>,
338    /// Key entities identified.
339    pub entities: Vec<Entity>,
340    /// Constraints identified.
341    pub constraints: Vec<Constraint>,
342    /// Success criteria (what "done" looks like).
343    pub success_criteria: Vec<String>,
344    /// Assumptions made during analysis.
345    pub assumptions: Vec<String>,
346    /// Open questions that need resolution.
347    pub open_questions: Vec<String>,
348    /// Codebase cross-references.
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub cross_references: Option<CodebaseCrossRef>,
351    /// Validation report.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub validation_report: Option<ContextReport>,
354}
355
356impl RequirementsContext {
357    /// Render as Markdown.
358    pub fn render_markdown(&self) -> String {
359        let mut md = String::with_capacity(6144);
360
361        md.push_str(&format!("# {}\n\n", self.title));
362        md.push_str(&format!(
363            "> Created: {} | Version: {}\n\n",
364            &self.created_at[..10],
365            self.version,
366        ));
367
368        // Input summary
369        md.push_str("## Input Summary\n\n");
370        md.push_str(&self.input_summary);
371        md.push_str("\n\n");
372
373        // Success criteria
374        if !self.success_criteria.is_empty() {
375            md.push_str("## Success Criteria\n\n");
376            for (i, criterion) in self.success_criteria.iter().enumerate() {
377                md.push_str(&format!("{}. [ ] {}\n", i + 1, criterion));
378            }
379            md.push('\n');
380        }
381
382        // Requirements
383        if !self.requirements.is_empty() {
384            md.push_str("## Requirements\n\n");
385
386            // Summary table
387            md.push_str("| ID | Title | Priority | Category | Valid |\n");
388            md.push_str("|----|-------|----------|----------|-------|\n");
389            for req in &self.requirements {
390                let valid = req
391                    .validation
392                    .as_ref()
393                    .map(|v| if v.is_valid { "✅" } else { "⚠️" })
394                    .unwrap_or("—");
395                md.push_str(&format!(
396                    "| {} | {} | {} | {} | {} |\n",
397                    req.id, req.title, req.priority, req.category, valid
398                ));
399            }
400            md.push('\n');
401
402            // Detailed requirements
403            for req in &self.requirements {
404                md.push_str(&format!("### {} — {}\n\n", req.id, req.title));
405                md.push_str(&req.description);
406                md.push_str("\n\n");
407                md.push_str(&format!(
408                    "**Priority:** {} | **Category:** {}\n\n",
409                    req.priority, req.category
410                ));
411
412                if !req.acceptance_criteria.is_empty() {
413                    md.push_str("**Acceptance Criteria:**\n");
414                    for (i, ac) in req.acceptance_criteria.iter().enumerate() {
415                        md.push_str(&format!("{}. {}\n", i + 1, ac));
416                    }
417                    md.push('\n');
418                }
419
420                if !req.related_files.is_empty() {
421                    md.push_str("**Related Files:**\n");
422                    for file in &req.related_files {
423                        md.push_str(&format!("- `{}`\n", file));
424                    }
425                    md.push('\n');
426                }
427
428                if let Some(ref validation) = req.validation {
429                    if !validation.issues.is_empty() {
430                        md.push_str("**Validation Issues:**\n");
431                        for issue in &validation.issues {
432                            md.push_str(&format!(
433                                "- [{}] {}\n",
434                                issue.severity, issue.description
435                            ));
436                        }
437                        md.push('\n');
438                    }
439                }
440            }
441        }
442
443        // Entities
444        if !self.entities.is_empty() {
445            md.push_str("## Key Entities\n\n");
446            for entity in &self.entities {
447                md.push_str(&format!("### {}\n\n", entity.name));
448                md.push_str(&entity.description);
449                md.push_str("\n\n");
450
451                if !entity.attributes.is_empty() {
452                    md.push_str("| Attribute | Type | Required | Description |\n");
453                    md.push_str("|-----------|------|----------|-------------|\n");
454                    for attr in &entity.attributes {
455                        let desc = attr.description.as_deref().unwrap_or("—");
456                        md.push_str(&format!(
457                            "| {} | {} | {} | {} |\n",
458                            attr.name,
459                            attr.attr_type,
460                            if attr.required { "yes" } else { "no" },
461                            desc,
462                        ));
463                    }
464                    md.push('\n');
465                }
466
467                if !entity.relationships.is_empty() {
468                    md.push_str(&format!(
469                        "**Relationships:** {}\n\n",
470                        entity.relationships.join(", ")
471                    ));
472                }
473            }
474        }
475
476        // Constraints
477        if !self.constraints.is_empty() {
478            md.push_str("## Constraints\n\n");
479            md.push_str("| Constraint | Type | Source |\n");
480            md.push_str("|-----------|------|--------|\n");
481            for constraint in &self.constraints {
482                let source = constraint.source.as_deref().unwrap_or("—");
483                md.push_str(&format!(
484                    "| {} | {} | {} |\n",
485                    constraint.description, constraint.constraint_type, source
486                ));
487            }
488            md.push('\n');
489        }
490
491        // Cross-references
492        if let Some(ref xref) = self.cross_references {
493            md.push_str("## Codebase Cross-References\n\n");
494
495            if !xref.relevant_files.is_empty() {
496                md.push_str("### Relevant Files\n\n");
497                md.push_str("| File | Relevance | Reason | Requirements |\n");
498                md.push_str("|------|-----------|--------|-------------|\n");
499                for file in &xref.relevant_files {
500                    md.push_str(&format!(
501                        "| `{}` | {} | {} | {} |\n",
502                        file.path,
503                        file.relevance,
504                        file.reason,
505                        file.related_requirements.join(", "),
506                    ));
507                }
508                md.push('\n');
509            }
510
511            if !xref.applicable_patterns.is_empty() {
512                md.push_str("**Applicable Patterns:**\n");
513                for pattern in &xref.applicable_patterns {
514                    md.push_str(&format!("- {}\n", pattern));
515                }
516                md.push('\n');
517            }
518
519            if !xref.conflicts.is_empty() {
520                md.push_str("**Potential Conflicts:**\n");
521                for conflict in &xref.conflicts {
522                    md.push_str(&format!("- ⚠️ {}\n", conflict));
523                }
524                md.push('\n');
525            }
526        }
527
528        // Assumptions
529        if !self.assumptions.is_empty() {
530            md.push_str("## Assumptions\n\n");
531            for assumption in &self.assumptions {
532                md.push_str(&format!("- {}\n", assumption));
533            }
534            md.push('\n');
535        }
536
537        // Open questions
538        if !self.open_questions.is_empty() {
539            md.push_str("## Open Questions\n\n");
540            for question in &self.open_questions {
541                md.push_str(&format!("- [ ] {}\n", question));
542            }
543            md.push('\n');
544        }
545
546        // Validation report
547        if let Some(ref report) = self.validation_report {
548            md.push_str("## Validation Report\n\n");
549            md.push_str(&format!(
550                "**Complete:** {} | **Ambiguous:** {} | **Untestable:** {}\n\n",
551                if report.is_complete { "✅" } else { "❌" },
552                report.ambiguous_count,
553                report.untestable_count,
554            ));
555
556            if !report.issues.is_empty() {
557                md.push_str("### Issues\n\n");
558                for issue in &report.issues {
559                    md.push_str(&format!("- {}\n", issue));
560                }
561                md.push('\n');
562            }
563        }
564
565        md
566    }
567
568    /// Write to file.
569    pub fn write_to_file(&self, dir: &Path) -> Result<PathBuf> {
570        fs::create_dir_all(dir)
571            .with_context(|| format!("Failed to create {}", dir.display()))?;
572
573        let slug = slugify(&self.title);
574        let date = &self.created_at[..10];
575        let filename = format!("{}-{}.md", date, slug);
576        let path = dir.join(&filename);
577
578        let content = self.render_markdown();
579        fs::write(&path, &content)
580            .with_context(|| format!("Failed to write context to {}", path.display()))?;
581
582        Ok(path)
583    }
584}
585
586// ── Validation report ──────────────────────────────────────────────────
587
588/// Overall validation report for the requirements context.
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct ContextReport {
591    /// Whether the context is complete enough for implementation.
592    pub is_complete: bool,
593    /// Number of requirements with issues.
594    pub requirements_with_issues: usize,
595    /// Number of ambiguous requirements.
596    pub ambiguous_count: usize,
597    /// Number of untestable requirements.
598    pub untestable_count: usize,
599    /// Number of incomplete requirements.
600    pub incomplete_count: usize,
601    /// Overall issues.
602    pub issues: Vec<String>,
603}
604
605// ── Session ────────────────────────────────────────────────────────────
606
607/// A context builder session.
608#[derive(Debug, Clone, Serialize, Deserialize)]
609pub struct ContextBuilderSession {
610    /// Current phase.
611    pub phase: ContextPhase,
612    /// Title / topic.
613    pub title: String,
614    /// Optional project root.
615    #[serde(skip_serializing_if = "Option::is_none")]
616    pub project_root: Option<PathBuf>,
617    /// Raw input text.
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub raw_input: Option<String>,
620    /// Structured requirements.
621    pub requirements: Vec<Requirement>,
622    /// Key entities.
623    pub entities: Vec<Entity>,
624    /// Constraints.
625    pub constraints: Vec<Constraint>,
626    /// Success criteria.
627    pub success_criteria: Vec<String>,
628    /// Assumptions.
629    pub assumptions: Vec<String>,
630    /// Open questions.
631    pub open_questions: Vec<String>,
632    /// Codebase cross-references.
633    #[serde(skip_serializing_if = "Option::is_none")]
634    pub cross_references: Option<CodebaseCrossRef>,
635    /// Validation report.
636    #[serde(skip_serializing_if = "Option::is_none")]
637    pub validation_report: Option<ContextReport>,
638    /// The finalized context document.
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub document: Option<RequirementsContext>,
641}
642
643impl ContextBuilderSession {
644    /// Create a new session.
645    pub fn new(title: impl Into<String>) -> Self {
646        Self {
647            phase: ContextPhase::Ingest,
648            title: title.into(),
649            project_root: None,
650            raw_input: None,
651            requirements: Vec::new(),
652            entities: Vec::new(),
653            constraints: Vec::new(),
654            success_criteria: Vec::new(),
655            assumptions: Vec::new(),
656            open_questions: Vec::new(),
657            cross_references: None,
658            validation_report: None,
659            document: None,
660        }
661    }
662
663    /// Set the project root.
664    pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
665        self.project_root = Some(root.into());
666        self
667    }
668
669    /// Advance to the next phase.
670    pub fn advance(&mut self) -> Result<()> {
671        let next = match self.phase {
672            ContextPhase::Ingest => ContextPhase::Analyze,
673            ContextPhase::Analyze => ContextPhase::CrossReference,
674            ContextPhase::CrossReference => ContextPhase::Structure,
675            ContextPhase::Structure => ContextPhase::Validate,
676            ContextPhase::Validate => ContextPhase::Document,
677            ContextPhase::Document => ContextPhase::Done,
678            ContextPhase::Done => bail!("Cannot advance past Done"),
679        };
680        self.phase = next;
681        Ok(())
682    }
683
684    /// Set phase directly.
685    pub fn set_phase(&mut self, phase: ContextPhase) {
686        self.phase = phase;
687    }
688
689    /// Set raw input.
690    pub fn set_raw_input(&mut self, input: impl Into<String>) {
691        self.raw_input = Some(input.into());
692    }
693
694    /// Add a requirement.
695    pub fn add_requirement(&mut self, req: Requirement) {
696        self.requirements.push(req);
697    }
698
699    /// Get requirement by ID.
700    pub fn get_requirement(&self, id: &str) -> Option<&Requirement> {
701        self.requirements.iter().find(|r| r.id == id)
702    }
703
704    /// Number of requirements.
705    pub fn requirement_count(&self) -> usize {
706        self.requirements.len()
707    }
708
709    /// Add an entity.
710    pub fn add_entity(&mut self, entity: Entity) {
711        self.entities.push(entity);
712    }
713
714    /// Add a constraint.
715    pub fn add_constraint(&mut self, constraint: Constraint) {
716        self.constraints.push(constraint);
717    }
718
719    /// Add a success criterion.
720    pub fn add_success_criterion(&mut self, criterion: impl Into<String>) {
721        self.success_criteria.push(criterion.into());
722    }
723
724    /// Add an assumption.
725    pub fn add_assumption(&mut self, assumption: impl Into<String>) {
726        self.assumptions.push(assumption.into());
727    }
728
729    /// Add an open question.
730    pub fn add_open_question(&mut self, question: impl Into<String>) {
731        self.open_questions.push(question.into());
732    }
733
734    /// Set cross-references.
735    pub fn set_cross_references(&mut self, xref: CodebaseCrossRef) {
736        self.cross_references = Some(xref);
737    }
738
739    /// Validate the requirements and produce a report.
740    pub fn validate(&mut self) -> ContextReport {
741        let mut issues = Vec::new();
742        let mut requirements_with_issues = 0;
743        let mut ambiguous_count = 0;
744        let mut untestable_count = 0;
745        let mut incomplete_count = 0;
746
747        for req in &mut self.requirements {
748            let mut req_issues: Vec<ValidationIssue> = Vec::new();
749
750            // Check for ambiguity
751            let ambiguous_words = ["somehow", "maybe", "possibly", "might", "could", "should probably"];
752            let desc_lower = req.description.to_lowercase();
753            for word in &ambiguous_words {
754                if desc_lower.contains(word) {
755                    req_issues.push(ValidationIssue {
756                        description: format!("Requirement {} contains ambiguous word: '{}'", req.id, word),
757                        severity: ValidationSeverity::Ambiguous,
758                    });
759                    ambiguous_count += 1;
760                    break;
761                }
762            }
763
764            // Check for testability
765            if req.acceptance_criteria.is_empty() {
766                req_issues.push(ValidationIssue {
767                    description: format!("Requirement {} has no acceptance criteria", req.id),
768                    severity: ValidationSeverity::Untestable,
769                });
770                untestable_count += 1;
771            }
772
773            // Check for completeness
774            if req.description.is_empty() {
775                req_issues.push(ValidationIssue {
776                    description: format!("Requirement {} has no description", req.id),
777                    severity: ValidationSeverity::Incomplete,
778                });
779                incomplete_count += 1;
780            }
781
782            let is_valid = req_issues.is_empty();
783            if !is_valid {
784                requirements_with_issues += 1;
785            }
786
787            req.validation = Some(RequirementValidation {
788                is_valid,
789                issues: req_issues,
790            });
791        }
792
793        // Check for duplicate IDs
794        let mut seen_ids: HashMap<String, usize> = HashMap::new();
795        for req in &self.requirements {
796            *seen_ids.entry(req.id.clone()).or_default() += 1;
797        }
798        for (id, count) in &seen_ids {
799            if *count > 1 {
800                issues.push(format!("Duplicate requirement ID: {} (appears {} times)", id, count));
801            }
802        }
803
804        // Check overall completeness
805        if self.requirements.is_empty() {
806            issues.push("No requirements defined".to_string());
807        }
808        if self.success_criteria.is_empty() {
809            issues.push("No success criteria defined".to_string());
810        }
811
812        let is_complete = requirements_with_issues == 0
813            && !self.requirements.is_empty()
814            && !self.success_criteria.is_empty()
815            && issues.is_empty();
816
817        let report = ContextReport {
818            is_complete,
819            requirements_with_issues,
820            ambiguous_count,
821            untestable_count,
822            incomplete_count,
823            issues,
824        };
825
826        self.validation_report = Some(report.clone());
827        report
828    }
829
830    /// Finalize the context document.
831    pub fn finalize(&mut self) -> Result<()> {
832        let doc = RequirementsContext {
833            title: self.title.clone(),
834            created_at: Utc::now().to_rfc3339(),
835            version: 1,
836            input_summary: self.raw_input.clone().unwrap_or_default(),
837            requirements: self.requirements.clone(),
838            entities: self.entities.clone(),
839            constraints: self.constraints.clone(),
840            success_criteria: self.success_criteria.clone(),
841            assumptions: self.assumptions.clone(),
842            open_questions: self.open_questions.clone(),
843            cross_references: self.cross_references.clone(),
844            validation_report: self.validation_report.clone(),
845        };
846
847        self.document = Some(doc);
848        Ok(())
849    }
850
851    /// Write the context document to disk.
852    pub fn write_document(&self, explicit_path: Option<&Path>) -> Result<PathBuf> {
853        let doc = self.document.as_ref()
854            .context("Document not finalized — call finalize() first")?;
855
856        if let Some(path) = explicit_path {
857            if let Some(parent) = path.parent() {
858                fs::create_dir_all(parent)
859                    .with_context(|| format!("Failed to create {}", parent.display()))?;
860            }
861            let content = doc.render_markdown();
862            fs::write(path, &content)
863                .with_context(|| format!("Failed to write context to {}", path.display()))?;
864            Ok(path.to_path_buf())
865        } else {
866            let root = self.project_root.as_deref()
867                .context("No project root and no explicit path")?;
868            let ctx_dir = root.join("docs").join("context");
869            doc.write_to_file(&ctx_dir)
870        }
871    }
872}
873
874// ── Skill prompt ───────────────────────────────────────────────────────
875
876/// The context builder skill struct.
877pub struct ContextBuilderSkill;
878
879impl ContextBuilderSkill {
880    /// Create a new context builder skill instance.
881    pub fn new() -> Self {
882        Self
883    }
884
885    /// Generate the system-prompt fragment for the context builder skill.
886    pub fn skill_prompt() -> String {
887        r#"# Context Builder Skill
888
889You are running the **context-builder** skill. Your job is to take raw
890requirements (user input, specs, tickets, or conversations) and produce
891a structured requirements context that other skills can consume.
892
893## Workflow
894
895### Phase 1: Ingest
896
8971. Accept raw input — text, spec documents, ticket descriptions, or conversation transcripts.
8982. Identify the high-level goal or feature being described.
8993. Note any explicit constraints or preferences mentioned.
900
901### Phase 2: Analyze
902
9031. Extract individual requirements from the raw input.
9042. For each requirement:
905   - Assign a unique ID (REQ-001, REQ-002, ...)
906   - Write a clear title and description
907   - Classify by category (functional, non-functional, security, etc.)
908   - Assign priority (low, medium, high, critical)
909   - Define acceptance criteria (testable conditions)
9103. Identify key entities (nouns that represent data or concepts).
9114. Identify constraints (technical, performance, business, compatibility).
912
913### Phase 3: Cross-Reference
914
9151. Read the codebase to find files relevant to the requirements.
9162. Identify existing patterns that apply.
9173. Note potential conflicts with existing code.
9184. Map requirements to specific files.
919
920### Phase 4: Structure
921
9221. Define clear success criteria — what does "done" look like?
9232. List assumptions made during analysis.
9243. Capture open questions that need resolution.
9254. Organize everything into a structured document.
926
927### Phase 5: Validate
928
9291. Check every requirement for:
930   - Ambiguity — vague words, unclear scope
931   - Completeness — missing acceptance criteria, no description
932   - Testability — can we verify this is implemented?
933   - Consistency — conflicts between requirements
9342. Flag issues and suggest clarifications.
935
936### Phase 6: Document
937
9381. Write the structured context to `docs/context/YYYY-MM-DD-<slug>.md`.
9392. The document is now ready for consumption by planner, oracle, or other skills.
940
941## Rules
942
943- Every requirement MUST have at least one acceptance criterion.
944- Priority must be justified — don't mark everything as critical.
945- Entities are nouns with attributes — if you can't name attributes, it's not an entity.
946- Constraints must be specific — "must be fast" is not a constraint; "< 50ms p99" is.
947- If information is missing, list it as an open question rather than guessing.
948- The goal is to produce context precise enough for another skill to act on without asking questions.
949"#
950        .to_string()
951    }
952}
953
954impl Default for ContextBuilderSkill {
955    fn default() -> Self {
956        Self::new()
957    }
958}
959
960impl fmt::Debug for ContextBuilderSkill {
961    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
962        f.debug_struct("ContextBuilderSkill").finish()
963    }
964}
965
966// ── Helpers ────────────────────────────────────────────────────────────
967
968fn slugify(s: &str) -> String {
969    s.to_lowercase()
970        .chars()
971        .map(|c| {
972            if c.is_ascii_alphanumeric() {
973                c
974            } else if c == ' ' || c == '_' || c == '-' {
975                '-'
976            } else {
977                '\0'
978            }
979        })
980        .filter(|c| *c != '\0')
981        .collect::<String>()
982        .trim_matches('-')
983        .to_string()
984}
985
986// ── Tests ──────────────────────────────────────────────────────────────
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use std::fs;
992
993    fn sample_requirement(id: &str) -> Requirement {
994        Requirement {
995            id: id.to_string(),
996            title: format!("Requirement {}", id),
997            description: format!("Description for {}", id),
998            priority: Priority::High,
999            category: RequirementCategory::Functional,
1000            acceptance_criteria: vec![format!("{} works correctly", id)],
1001            source: Some("user input".to_string()),
1002            related_files: vec![format!("src/{}.rs", id.to_lowercase())],
1003            validation: None,
1004        }
1005    }
1006
1007    fn sample_entity(name: &str) -> Entity {
1008        Entity {
1009            name: name.to_string(),
1010            description: format!("{} entity", name),
1011            attributes: vec![EntityAttribute {
1012                name: "id".to_string(),
1013                attr_type: "string".to_string(),
1014                required: true,
1015                description: Some("Unique identifier".to_string()),
1016            }],
1017            relationships: vec![],
1018            source_requirements: vec!["REQ-001".to_string()],
1019        }
1020    }
1021
1022    #[test]
1023    fn test_session_new() {
1024        let session = ContextBuilderSession::new("Auth feature");
1025        assert_eq!(session.phase, ContextPhase::Ingest);
1026        assert_eq!(session.title, "Auth feature");
1027        assert!(session.requirements.is_empty());
1028    }
1029
1030    #[test]
1031    fn test_phase_advance() {
1032        let mut session = ContextBuilderSession::new("test");
1033        assert_eq!(session.phase, ContextPhase::Ingest);
1034
1035        session.advance().unwrap(); // Analyze
1036        assert_eq!(session.phase, ContextPhase::Analyze);
1037
1038        session.advance().unwrap(); // CrossReference
1039        assert_eq!(session.phase, ContextPhase::CrossReference);
1040
1041        session.advance().unwrap(); // Structure
1042        assert_eq!(session.phase, ContextPhase::Structure);
1043
1044        session.advance().unwrap(); // Validate
1045        assert_eq!(session.phase, ContextPhase::Validate);
1046
1047        session.advance().unwrap(); // Document
1048        assert_eq!(session.phase, ContextPhase::Document);
1049
1050        session.advance().unwrap(); // Done
1051        assert_eq!(session.phase, ContextPhase::Done);
1052
1053        assert!(session.advance().is_err());
1054    }
1055
1056    #[test]
1057    fn test_set_phase() {
1058        let mut session = ContextBuilderSession::new("test");
1059        session.set_phase(ContextPhase::Validate);
1060        assert_eq!(session.phase, ContextPhase::Validate);
1061    }
1062
1063    #[test]
1064    fn test_phase_display() {
1065        assert_eq!(format!("{}", ContextPhase::Ingest), "Ingest");
1066        assert_eq!(format!("{}", ContextPhase::Analyze), "Analyze");
1067        assert_eq!(format!("{}", ContextPhase::CrossReference), "Cross-Reference");
1068        assert_eq!(format!("{}", ContextPhase::Structure), "Structure");
1069        assert_eq!(format!("{}", ContextPhase::Validate), "Validate");
1070        assert_eq!(format!("{}", ContextPhase::Document), "Document");
1071        assert_eq!(format!("{}", ContextPhase::Done), "Done");
1072    }
1073
1074    #[test]
1075    fn test_priority_display() {
1076        assert_eq!(format!("{}", Priority::Low), "low");
1077        assert_eq!(format!("{}", Priority::Medium), "medium");
1078        assert_eq!(format!("{}", Priority::High), "high");
1079        assert_eq!(format!("{}", Priority::Critical), "critical");
1080    }
1081
1082    #[test]
1083    fn test_priority_ordering() {
1084        assert!(Priority::Critical > Priority::High);
1085        assert!(Priority::High > Priority::Medium);
1086        assert!(Priority::Medium > Priority::Low);
1087    }
1088
1089    #[test]
1090    fn test_requirement_category_display() {
1091        assert_eq!(format!("{}", RequirementCategory::Functional), "functional");
1092        assert_eq!(format!("{}", RequirementCategory::NonFunctional), "non-functional");
1093        assert_eq!(format!("{}", RequirementCategory::Security), "security");
1094    }
1095
1096    #[test]
1097    fn test_constraint_type_display() {
1098        assert_eq!(format!("{}", ConstraintType::Technical), "technical");
1099        assert_eq!(format!("{}", ConstraintType::Performance), "performance");
1100        assert_eq!(format!("{}", ConstraintType::Business), "business");
1101    }
1102
1103    #[test]
1104    fn test_relevance_level_display() {
1105        assert_eq!(format!("{}", RelevanceLevel::Direct), "direct");
1106        assert_eq!(format!("{}", RelevanceLevel::Contextual), "contextual");
1107        assert_eq!(format!("{}", RelevanceLevel::Indirect), "indirect");
1108    }
1109
1110    #[test]
1111    fn test_validation_severity_display() {
1112        assert_eq!(format!("{}", ValidationSeverity::Ambiguous), "ambiguous");
1113        assert_eq!(format!("{}", ValidationSeverity::Incomplete), "incomplete");
1114        assert_eq!(format!("{}", ValidationSeverity::Conflicting), "conflicting");
1115        assert_eq!(format!("{}", ValidationSeverity::Untestable), "untestable");
1116    }
1117
1118    #[test]
1119    fn test_add_requirements() {
1120        let mut session = ContextBuilderSession::new("test");
1121        session.add_requirement(sample_requirement("REQ-001"));
1122        session.add_requirement(sample_requirement("REQ-002"));
1123
1124        assert_eq!(session.requirement_count(), 2);
1125        assert!(session.get_requirement("REQ-001").is_some());
1126        assert!(session.get_requirement("REQ-999").is_none());
1127    }
1128
1129    #[test]
1130    fn test_add_entities_and_constraints() {
1131        let mut session = ContextBuilderSession::new("test");
1132        session.add_entity(sample_entity("User"));
1133        session.add_constraint(Constraint {
1134            description: "Must be offline-first".to_string(),
1135            constraint_type: ConstraintType::Technical,
1136            source: Some("REQ-001".to_string()),
1137        });
1138
1139        assert_eq!(session.entities.len(), 1);
1140        assert_eq!(session.constraints.len(), 1);
1141    }
1142
1143    #[test]
1144    fn test_add_success_criteria_and_questions() {
1145        let mut session = ContextBuilderSession::new("test");
1146        session.add_success_criterion("User can log in");
1147        session.add_assumption("Node.js >= 18");
1148        session.add_open_question("Which OAuth provider?");
1149
1150        assert_eq!(session.success_criteria, vec!["User can log in"]);
1151        assert_eq!(session.assumptions, vec!["Node.js >= 18"]);
1152        assert_eq!(session.open_questions, vec!["Which OAuth provider?"]);
1153    }
1154
1155    #[test]
1156    fn test_validate_clean() {
1157        let mut session = ContextBuilderSession::new("test");
1158        session.add_requirement(sample_requirement("REQ-001"));
1159        session.add_success_criterion("Works");
1160
1161        let report = session.validate();
1162        assert!(report.is_complete);
1163        assert_eq!(report.requirements_with_issues, 0);
1164        assert!(session.requirements[0].validation.as_ref().unwrap().is_valid);
1165    }
1166
1167    #[test]
1168    fn test_validate_ambiguous() {
1169        let mut session = ContextBuilderSession::new("test");
1170        let mut req = sample_requirement("REQ-001");
1171        req.description = "The system should somehow handle errors".to_string();
1172        session.add_requirement(req);
1173        session.add_success_criterion("Works");
1174
1175        let report = session.validate();
1176        assert!(!report.is_complete);
1177        assert_eq!(report.ambiguous_count, 1);
1178        assert!(!session.requirements[0].validation.as_ref().unwrap().is_valid);
1179    }
1180
1181    #[test]
1182    fn test_validate_no_acceptance_criteria() {
1183        let mut session = ContextBuilderSession::new("test");
1184        let mut req = sample_requirement("REQ-001");
1185        req.acceptance_criteria = vec![];
1186        session.add_requirement(req);
1187        session.add_success_criterion("Works");
1188
1189        let report = session.validate();
1190        assert_eq!(report.untestable_count, 1);
1191    }
1192
1193    #[test]
1194    fn test_validate_empty_description() {
1195        let mut session = ContextBuilderSession::new("test");
1196        let mut req = sample_requirement("REQ-001");
1197        req.description = String::new();
1198        session.add_requirement(req);
1199        session.add_success_criterion("Works");
1200
1201        let report = session.validate();
1202        assert_eq!(report.incomplete_count, 1);
1203    }
1204
1205    #[test]
1206    fn test_validate_no_requirements() {
1207        let mut session = ContextBuilderSession::new("test");
1208        let report = session.validate();
1209        assert!(!report.is_complete);
1210        assert!(report.issues.iter().any(|i| i.contains("No requirements")));
1211    }
1212
1213    #[test]
1214    fn test_validate_no_success_criteria() {
1215        let mut session = ContextBuilderSession::new("test");
1216        session.add_requirement(sample_requirement("REQ-001"));
1217        let report = session.validate();
1218        assert!(!report.is_complete);
1219        assert!(report.issues.iter().any(|i| i.contains("No success criteria")));
1220    }
1221
1222    #[test]
1223    fn test_validate_duplicate_ids() {
1224        let mut session = ContextBuilderSession::new("test");
1225        session.add_requirement(sample_requirement("REQ-001"));
1226        session.add_requirement(sample_requirement("REQ-001"));
1227        session.add_success_criterion("Works");
1228
1229        let report = session.validate();
1230        assert!(report.issues.iter().any(|i| i.contains("Duplicate")));
1231    }
1232
1233    #[test]
1234    fn test_finalize_and_write() {
1235        let tmp = tempfile::tempdir().unwrap();
1236        let mut session = ContextBuilderSession::new("Auth Context")
1237            .with_project_root(tmp.path());
1238
1239        session.set_raw_input("Build authentication for the API");
1240        session.add_requirement(sample_requirement("REQ-001"));
1241        session.add_success_criterion("User can authenticate");
1242        session.add_assumption("JWT tokens");
1243        session.validate();
1244
1245        session.finalize().unwrap();
1246        let path = session.write_document(None).unwrap();
1247        assert!(path.exists());
1248        assert!(path.to_string_lossy().contains("docs/context"));
1249
1250        let content = fs::read_to_string(&path).unwrap();
1251        assert!(content.contains("# Auth Context"));
1252        assert!(content.contains("## Input Summary"));
1253        assert!(content.contains("Build authentication"));
1254        assert!(content.contains("## Requirements"));
1255        assert!(content.contains("REQ-001"));
1256    }
1257
1258    #[test]
1259    fn test_write_explicit_path() {
1260        let tmp = tempfile::tempdir().unwrap();
1261        let mut session = ContextBuilderSession::new("test");
1262        session.add_requirement(sample_requirement("REQ-001"));
1263        session.finalize().unwrap();
1264
1265        let explicit = tmp.path().join("context.md");
1266        let path = session.write_document(Some(&explicit)).unwrap();
1267        assert_eq!(path, explicit);
1268        assert!(path.exists());
1269    }
1270
1271    #[test]
1272    fn test_write_not_finalized() {
1273        let session = ContextBuilderSession::new("test");
1274        assert!(session.write_document(None).is_err());
1275    }
1276
1277    #[test]
1278    fn test_render_markdown_full() {
1279        let mut session = ContextBuilderSession::new("Full Test");
1280        session.set_raw_input("Raw input text");
1281        session.add_requirement(Requirement {
1282            id: "REQ-001".to_string(),
1283            title: "User login".to_string(),
1284            description: "Users must be able to log in".to_string(),
1285            priority: Priority::Critical,
1286            category: RequirementCategory::Functional,
1287            acceptance_criteria: vec!["Login form accepts credentials".to_string()],
1288            source: Some("product spec".to_string()),
1289            related_files: vec!["src/auth.rs".to_string()],
1290            validation: Some(RequirementValidation {
1291                is_valid: true,
1292                issues: vec![],
1293            }),
1294        });
1295
1296        session.add_entity(Entity {
1297            name: "User".to_string(),
1298            description: "A system user".to_string(),
1299            attributes: vec![
1300                EntityAttribute {
1301                    name: "email".to_string(),
1302                    attr_type: "string".to_string(),
1303                    required: true,
1304                    description: Some("User email".to_string()),
1305                },
1306                EntityAttribute {
1307                    name: "password_hash".to_string(),
1308                    attr_type: "string".to_string(),
1309                    required: true,
1310                    description: None,
1311                },
1312            ],
1313            relationships: vec!["Session (1:N)".to_string()],
1314            source_requirements: vec!["REQ-001".to_string()],
1315        });
1316
1317        session.add_constraint(Constraint {
1318            description: "Passwords must be hashed with bcrypt".to_string(),
1319            constraint_type: ConstraintType::Regulatory,
1320            source: Some("REQ-001".to_string()),
1321        });
1322
1323        session.add_success_criterion("Users can log in and receive a token");
1324        session.add_assumption("Single-factor auth only");
1325        session.add_open_question("Password reset flow?");
1326
1327        session.set_cross_references(CodebaseCrossRef {
1328            relevant_files: vec![FileRelevance {
1329                path: "src/auth.rs".to_string(),
1330                relevance: RelevanceLevel::Direct,
1331                reason: "Auth module".to_string(),
1332                related_requirements: vec!["REQ-001".to_string()],
1333            }],
1334            applicable_patterns: vec!["Middleware pattern".to_string()],
1335            dependencies: vec!["bcrypt".to_string()],
1336            conflicts: vec![],
1337        });
1338
1339        session.validate();
1340        session.finalize().unwrap();
1341
1342        let md = session.document.as_ref().unwrap().render_markdown();
1343        assert!(md.contains("# Full Test"));
1344        assert!(md.contains("## Input Summary"));
1345        assert!(md.contains("## Success Criteria"));
1346        assert!(md.contains("## Requirements"));
1347        assert!(md.contains("REQ-001"));
1348        assert!(md.contains("User login"));
1349        assert!(md.contains("critical"));
1350        assert!(md.contains("✅"));
1351        assert!(md.contains("## Key Entities"));
1352        assert!(md.contains("### User"));
1353        assert!(md.contains("email"));
1354        assert!(md.contains("password_hash"));
1355        assert!(md.contains("Session (1:N)"));
1356        assert!(md.contains("## Constraints"));
1357        assert!(md.contains("bcrypt"));
1358        assert!(md.contains("regulatory"));
1359        assert!(md.contains("## Codebase Cross-References"));
1360        assert!(md.contains("`src/auth.rs`"));
1361        assert!(md.contains("Middleware pattern"));
1362        assert!(md.contains("## Assumptions"));
1363        assert!(md.contains("Single-factor auth"));
1364        assert!(md.contains("## Open Questions"));
1365        assert!(md.contains("Password reset flow?"));
1366        assert!(md.contains("## Validation Report"));
1367    }
1368
1369    #[test]
1370    fn test_session_serialization_roundtrip() {
1371        let mut session = ContextBuilderSession::new("Test");
1372        session.add_requirement(sample_requirement("REQ-001"));
1373        session.add_success_criterion("Works");
1374        session.set_phase(ContextPhase::Analyze);
1375
1376        let json = serde_json::to_string(&session).unwrap();
1377        let parsed: ContextBuilderSession = serde_json::from_str(&json).unwrap();
1378        assert_eq!(parsed.title, "Test");
1379        assert_eq!(parsed.phase, ContextPhase::Analyze);
1380        assert_eq!(parsed.requirement_count(), 1);
1381    }
1382
1383    #[test]
1384    fn test_requirements_context_serialization_roundtrip() {
1385        let ctx = RequirementsContext {
1386            title: "Test".to_string(),
1387            created_at: "2025-01-01T00:00:00Z".to_string(),
1388            version: 1,
1389            input_summary: "Raw".to_string(),
1390            requirements: vec![sample_requirement("REQ-001")],
1391            entities: vec![sample_entity("User")],
1392            constraints: vec![Constraint {
1393                description: "Test".to_string(),
1394                constraint_type: ConstraintType::Technical,
1395                source: None,
1396            }],
1397            success_criteria: vec!["Done".to_string()],
1398            assumptions: vec![],
1399            open_questions: vec![],
1400            cross_references: None,
1401            validation_report: None,
1402        };
1403
1404        let json = serde_json::to_string_pretty(&ctx).unwrap();
1405        let parsed: RequirementsContext = serde_json::from_str(&json).unwrap();
1406        assert_eq!(parsed.requirements.len(), 1);
1407        assert_eq!(parsed.entities.len(), 1);
1408        assert_eq!(parsed.constraints.len(), 1);
1409    }
1410
1411    #[test]
1412    fn test_skill_prompt_not_empty() {
1413        let prompt = ContextBuilderSkill::skill_prompt();
1414        assert!(prompt.contains("Context Builder Skill"));
1415        assert!(prompt.contains("Phase 1: Ingest"));
1416        assert!(prompt.contains("Phase 6: Document"));
1417    }
1418}