1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum ContextPhase {
33 Ingest,
35 Analyze,
37 CrossReference,
39 Structure,
41 Validate,
43 Document,
45 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum Priority {
75 Low,
77 Medium,
79 High,
81 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#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Requirement {
99 pub id: String,
101 pub title: String,
103 pub description: String,
105 pub priority: Priority,
107 pub category: RequirementCategory,
109 pub acceptance_criteria: Vec<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub source: Option<String>,
114 #[serde(default)]
116 pub related_files: Vec<String>,
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub validation: Option<RequirementValidation>,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum RequirementCategory {
126 Functional,
128 NonFunctional,
130 Security,
132 UserExperience,
134 Integration,
136 Data,
138 Testing,
140 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#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct RequirementValidation {
162 pub is_valid: bool,
164 pub issues: Vec<ValidationIssue>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct ValidationIssue {
171 pub description: String,
173 pub severity: ValidationSeverity,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum ValidationSeverity {
181 Ambiguous,
183 Incomplete,
185 Conflicting,
187 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#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct Entity {
207 pub name: String,
209 pub description: String,
211 pub attributes: Vec<EntityAttribute>,
213 pub relationships: Vec<String>,
215 pub source_requirements: Vec<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct EntityAttribute {
222 pub name: String,
224 pub attr_type: String,
226 pub required: bool,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub description: Option<String>,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Constraint {
236 pub description: String,
238 pub constraint_type: ConstraintType,
240 #[serde(skip_serializing_if = "Option::is_none")]
242 pub source: Option<String>,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "snake_case")]
248pub enum ConstraintType {
249 Technical,
251 Performance,
253 Business,
255 Compatibility,
257 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#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct CodebaseCrossRef {
278 pub relevant_files: Vec<FileRelevance>,
280 pub applicable_patterns: Vec<String>,
282 pub dependencies: Vec<String>,
284 pub conflicts: Vec<String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct FileRelevance {
291 pub path: String,
293 pub relevance: RelevanceLevel,
295 pub reason: String,
297 pub related_requirements: Vec<String>,
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "snake_case")]
304pub enum RelevanceLevel {
305 Direct,
307 Contextual,
309 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#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct RequirementsContext {
328 pub title: String,
330 pub created_at: String,
332 pub version: u32,
334 pub input_summary: String,
336 pub requirements: Vec<Requirement>,
338 pub entities: Vec<Entity>,
340 pub constraints: Vec<Constraint>,
342 pub success_criteria: Vec<String>,
344 pub assumptions: Vec<String>,
346 pub open_questions: Vec<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub cross_references: Option<CodebaseCrossRef>,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub validation_report: Option<ContextReport>,
354}
355
356impl RequirementsContext {
357 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 md.push_str("## Input Summary\n\n");
370 md.push_str(&self.input_summary);
371 md.push_str("\n\n");
372
373 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 if !self.requirements.is_empty() {
384 md.push_str("## Requirements\n\n");
385
386 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct ContextReport {
591 pub is_complete: bool,
593 pub requirements_with_issues: usize,
595 pub ambiguous_count: usize,
597 pub untestable_count: usize,
599 pub incomplete_count: usize,
601 pub issues: Vec<String>,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
609pub struct ContextBuilderSession {
610 pub phase: ContextPhase,
612 pub title: String,
614 #[serde(skip_serializing_if = "Option::is_none")]
616 pub project_root: Option<PathBuf>,
617 #[serde(skip_serializing_if = "Option::is_none")]
619 pub raw_input: Option<String>,
620 pub requirements: Vec<Requirement>,
622 pub entities: Vec<Entity>,
624 pub constraints: Vec<Constraint>,
626 pub success_criteria: Vec<String>,
628 pub assumptions: Vec<String>,
630 pub open_questions: Vec<String>,
632 #[serde(skip_serializing_if = "Option::is_none")]
634 pub cross_references: Option<CodebaseCrossRef>,
635 #[serde(skip_serializing_if = "Option::is_none")]
637 pub validation_report: Option<ContextReport>,
638 #[serde(skip_serializing_if = "Option::is_none")]
640 pub document: Option<RequirementsContext>,
641}
642
643impl ContextBuilderSession {
644 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 pub fn with_project_root(mut self, root: impl Into<PathBuf>) -> Self {
665 self.project_root = Some(root.into());
666 self
667 }
668
669 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 pub fn set_phase(&mut self, phase: ContextPhase) {
686 self.phase = phase;
687 }
688
689 pub fn set_raw_input(&mut self, input: impl Into<String>) {
691 self.raw_input = Some(input.into());
692 }
693
694 pub fn add_requirement(&mut self, req: Requirement) {
696 self.requirements.push(req);
697 }
698
699 pub fn get_requirement(&self, id: &str) -> Option<&Requirement> {
701 self.requirements.iter().find(|r| r.id == id)
702 }
703
704 pub fn requirement_count(&self) -> usize {
706 self.requirements.len()
707 }
708
709 pub fn add_entity(&mut self, entity: Entity) {
711 self.entities.push(entity);
712 }
713
714 pub fn add_constraint(&mut self, constraint: Constraint) {
716 self.constraints.push(constraint);
717 }
718
719 pub fn add_success_criterion(&mut self, criterion: impl Into<String>) {
721 self.success_criteria.push(criterion.into());
722 }
723
724 pub fn add_assumption(&mut self, assumption: impl Into<String>) {
726 self.assumptions.push(assumption.into());
727 }
728
729 pub fn add_open_question(&mut self, question: impl Into<String>) {
731 self.open_questions.push(question.into());
732 }
733
734 pub fn set_cross_references(&mut self, xref: CodebaseCrossRef) {
736 self.cross_references = Some(xref);
737 }
738
739 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 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 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 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 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 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 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 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
874pub struct ContextBuilderSkill;
878
879impl ContextBuilderSkill {
880 pub fn new() -> Self {
882 Self
883 }
884
885 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
966fn 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#[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(); assert_eq!(session.phase, ContextPhase::Analyze);
1037
1038 session.advance().unwrap(); assert_eq!(session.phase, ContextPhase::CrossReference);
1040
1041 session.advance().unwrap(); assert_eq!(session.phase, ContextPhase::Structure);
1043
1044 session.advance().unwrap(); assert_eq!(session.phase, ContextPhase::Validate);
1046
1047 session.advance().unwrap(); assert_eq!(session.phase, ContextPhase::Document);
1049
1050 session.advance().unwrap(); 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}