diff --git a/.opencode/skills/sce-atomic-commit/tile.json b/.opencode/skills/sce-atomic-commit/tile.json deleted file mode 100644 index 6845ed6..0000000 --- a/.opencode/skills/sce-atomic-commit/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-atomic-commit", - "version": "0.1.0", - "summary": "Write atomic, repo-style git commits from a change summary or diff.", - "private": false, - "skills": { - "sce-atomic-commit": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/.opencode/skills/sce-bootstrap-context/tile.json b/.opencode/skills/sce-bootstrap-context/tile.json deleted file mode 100644 index b86350e..0000000 --- a/.opencode/skills/sce-bootstrap-context/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-bootstrap-context", - "version": "0.1.0", - "summary": "Create the baseline Shared Context Engineering context directory structure.", - "private": false, - "skills": { - "sce-bootstrap-context": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/.opencode/skills/sce-context-sync/tile.json b/.opencode/skills/sce-context-sync/tile.json deleted file mode 100644 index 20bd055..0000000 --- a/.opencode/skills/sce-context-sync/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-context-sync", - "version": "0.1.0", - "summary": "Sync Shared Context Engineering context files with implemented code changes.", - "private": false, - "skills": { - "sce-context-sync": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/.opencode/skills/sce-drift-analyzer/tile.json b/.opencode/skills/sce-drift-analyzer/tile.json deleted file mode 100644 index c6ee492..0000000 --- a/.opencode/skills/sce-drift-analyzer/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-drift-analyzer", - "version": "0.1.0", - "summary": "Analyze drift between project context documentation and actual code.", - "private": false, - "skills": { - "sce-drift-analyzer": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/.opencode/skills/sce-drift-fixer/tile.json b/.opencode/skills/sce-drift-fixer/tile.json deleted file mode 100644 index b80f037..0000000 --- a/.opencode/skills/sce-drift-fixer/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-drift-fixer", - "version": "0.1.0", - "summary": "Repair stale Shared Context Engineering context files to match code truth.", - "private": false, - "skills": { - "sce-drift-fixer": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/.opencode/skills/sce-handover-writer/tile.json b/.opencode/skills/sce-handover-writer/tile.json deleted file mode 100644 index b6f0e9e..0000000 --- a/.opencode/skills/sce-handover-writer/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-handover-writer", - "version": "0.1.0", - "summary": "Create structured handover notes for Shared Context Engineering tasks.", - "private": false, - "skills": { - "sce-handover-writer": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/.opencode/skills/sce-plan-authoring/tile.json b/.opencode/skills/sce-plan-authoring/tile.json deleted file mode 100644 index 8077971..0000000 --- a/.opencode/skills/sce-plan-authoring/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-plan-authoring", - "version": "0.1.0", - "summary": "Author structured Shared Context Engineering implementation plans.", - "private": false, - "skills": { - "sce-plan-authoring": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/.opencode/skills/sce-plan-review/tile.json b/.opencode/skills/sce-plan-review/tile.json deleted file mode 100644 index be91a90..0000000 --- a/.opencode/skills/sce-plan-review/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-plan-review", - "version": "0.1.0", - "summary": "Review Shared Context Engineering plans and identify the next ready task.", - "private": false, - "skills": { - "sce-plan-review": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/.opencode/skills/sce-task-execution/tile.json b/.opencode/skills/sce-task-execution/tile.json deleted file mode 100644 index 036801a..0000000 --- a/.opencode/skills/sce-task-execution/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-task-execution", - "version": "0.1.0", - "summary": "Execute one approved Shared Context Engineering plan task with guardrails.", - "private": false, - "skills": { - "sce-task-execution": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/.opencode/skills/sce-validation/tile.json b/.opencode/skills/sce-validation/tile.json deleted file mode 100644 index bfc8a4a..0000000 --- a/.opencode/skills/sce-validation/tile.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "crocoder-dev/opencode-sce-validation", - "version": "0.1.0", - "summary": "Run final validation for a Shared Context Engineering plan.", - "private": false, - "skills": { - "sce-validation": { - "path": "SKILL.md" - } - } -} \ No newline at end of file diff --git a/cli/src/services/doctor.rs b/cli/src/services/doctor.rs index e3a7388..70ca3ee 100644 --- a/cli/src/services/doctor.rs +++ b/cli/src/services/doctor.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -8,12 +9,13 @@ use serde_json::json; use crate::services::default_paths::resolve_sce_default_locations; use crate::services::output_format::OutputFormat; use crate::services::setup::{ - install_required_git_hooks, iter_required_hook_assets, RequiredHookInstallStatus, - RequiredHooksInstallOutcome, + install_required_git_hooks, iter_embedded_assets_for_setup_target, iter_required_hook_assets, + RequiredHookInstallStatus, RequiredHooksInstallOutcome, SetupTarget, +}; +use crate::services::style::{ + heading, label, status_tag_fail, status_tag_miss, status_tag_pass, status_tag_warn, success, + value, }; -#[cfg(test)] -use crate::services::setup::{iter_embedded_assets_for_setup_target, SetupTarget}; -use crate::services::style::{heading, label, success, value}; pub const NAME: &str = "doctor"; @@ -24,6 +26,7 @@ const OPENCODE_PLUGIN_RELATIVE_PATH: &str = "plugins/sce-bash-policy.ts"; const OPENCODE_PLUGIN_RUNTIME_RELATIVE_PATH: &str = "plugins/bash-policy/runtime.ts"; const OPENCODE_PLUGIN_PRESET_CATALOG_RELATIVE_PATH: &str = "lib/bash-policy-presets.json"; const OPENCODE_PLUGIN_MANIFEST_ENTRY: &str = "./plugins/sce-bash-policy.ts"; +const OPENCODE_REQUIRED_DIRECTORIES: [&str; 3] = ["agent", "command", "skills"]; pub type DoctorFormat = OutputFormat; @@ -109,6 +112,33 @@ enum HookContentState { Unknown, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OpenCodeSection { + Plugin, + Agent, + Command, + Skills, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct OpenCodeIssue { + summary: String, + path: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct OpenCodeSectionHealth { + section: OpenCodeSection, + issues: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct OpenCodeHealth { + root: PathBuf, + root_missing: bool, + sections: Vec, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct FileLocationHealth { label: &'static str, @@ -137,9 +167,78 @@ struct HookDoctorReport { repo_databases: Vec, all_databases: Vec, hooks: Vec, + opencode_health: Option, problems: Vec, } +impl OpenCodeSectionHealth { + fn new(section: OpenCodeSection) -> Self { + Self { + section, + issues: Vec::new(), + } + } +} + +impl OpenCodeHealth { + fn new(root: PathBuf) -> Self { + Self { + root, + root_missing: false, + sections: vec![ + OpenCodeSectionHealth::new(OpenCodeSection::Plugin), + OpenCodeSectionHealth::new(OpenCodeSection::Agent), + OpenCodeSectionHealth::new(OpenCodeSection::Command), + OpenCodeSectionHealth::new(OpenCodeSection::Skills), + ], + } + } + + fn section_mut(&mut self, section: OpenCodeSection) -> &mut OpenCodeSectionHealth { + self.sections + .iter_mut() + .find(|entry| entry.section == section) + .expect("OpenCode section should exist") + } + + fn push_issue( + &mut self, + section: OpenCodeSection, + summary: impl Into, + path: Option, + ) { + self.section_mut(section).issues.push(OpenCodeIssue { + summary: summary.into(), + path, + }); + } + + fn push_issue_all(&mut self, summary: impl Into, path: Option<&PathBuf>) { + let summary = summary.into(); + for section in [ + OpenCodeSection::Plugin, + OpenCodeSection::Agent, + OpenCodeSection::Command, + OpenCodeSection::Skills, + ] { + self.push_issue(section, summary.clone(), path.cloned()); + } + } + + fn mark_root_missing(&mut self) { + self.root_missing = true; + let root = self.root.clone(); + self.push_issue_all("OpenCode root directory is missing.", Some(&root)); + } + + fn section(&self, section: OpenCodeSection) -> &OpenCodeSectionHealth { + self.sections + .iter() + .find(|entry| entry.section == section) + .expect("OpenCode section should exist") + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ProblemCategory { GlobalState, @@ -169,6 +268,14 @@ enum FixResult { Failed, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StatusTag { + Pass, + Fail, + Miss, + Warn, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct DoctorProblem { category: ProblemCategory, @@ -186,6 +293,12 @@ struct DoctorFixResultRecord { detail: String, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct TaggedLine { + tag: StatusTag, + text: String, +} + struct DoctorDependencies<'a> { run_git_command: &'a dyn Fn(&Path, &[&str]) -> Option, check_git_available: &'a dyn Fn() -> bool, @@ -381,11 +494,13 @@ fn build_report_with_dependencies( Vec::new() }; - if git_available && !bare_repository { - if let Some(resolved_root) = detected_repository_root.as_deref() { - inspect_opencode_plugin_health(resolved_root, &mut problems); - } - } + let opencode_health = if git_available && !bare_repository { + detected_repository_root + .as_deref() + .map(|resolved_root| collect_opencode_health(resolved_root, &mut problems)) + } else { + None + }; let repo_databases = Vec::new(); let all_databases = if database_inventory == DoctorDatabaseInventory::All { @@ -416,6 +531,7 @@ fn build_report_with_dependencies( repo_databases, all_databases, hooks, + opencode_health, problems, } } @@ -768,14 +884,41 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V .collect() } -fn inspect_opencode_plugin_health(repository_root: &Path, problems: &mut Vec) { +fn collect_opencode_health( + repository_root: &Path, + problems: &mut Vec, +) -> OpenCodeHealth { let opencode_root = repository_root.join(OPENCODE_ROOT_DIR); + let mut health = OpenCodeHealth::new(opencode_root.clone()); + if !opencode_root.exists() { - return; + problems.push(DoctorProblem { + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "OpenCode root directory '{}' is missing.", + opencode_root.display() + ), + remediation: + "Run 'sce setup --opencode' to install OpenCode assets, then rerun 'sce doctor'." + .to_string(), + next_action: "manual_steps", + }); + health.mark_root_missing(); + return health; } + inspect_opencode_required_directories(&opencode_root, problems, &mut health); + inspect_opencode_embedded_asset_presence(&opencode_root, problems, &mut health); + let manifest_path = opencode_root.join(OPENCODE_MANIFEST_FILE); if let Some(summary) = opencode_plugin_registry_issue(&manifest_path) { + health.push_issue( + OpenCodeSection::Plugin, + summary.clone(), + Some(manifest_path.clone()), + ); problems.push(DoctorProblem { category: ProblemCategory::RepoAssets, severity: ProblemSeverity::Error, @@ -790,7 +933,7 @@ fn inspect_opencode_plugin_health(repository_root: &Path, problems: &mut Vec, + opencode_health: &mut OpenCodeHealth, +) { + for directory in OPENCODE_REQUIRED_DIRECTORIES { + let required_path = opencode_root.join(directory); + match fs::metadata(&required_path) { + Ok(metadata) => { + if !metadata.is_dir() { + if let Some(section) = opencode_section_for_directory(directory) { + opencode_health.push_issue( + section, + format!( + "OpenCode required directory '{}' is not a directory.", + required_path.display() + ), + Some(required_path.clone()), + ); + } + problems.push(DoctorProblem { + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "OpenCode required directory '{}' is not a directory.", + required_path.display() + ), + remediation: format!( + "Reinstall OpenCode assets so '{}' includes the required '{}' directory, then rerun 'sce doctor'.", + opencode_root.display(), + directory + ), + next_action: "manual_steps", + }); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if let Some(section) = opencode_section_for_directory(directory) { + opencode_health.push_issue( + section, + format!( + "OpenCode required directory '{}' is missing.", + required_path.display() + ), + Some(required_path.clone()), + ); + } + problems.push(DoctorProblem { + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "OpenCode required directory '{}' is missing.", + required_path.display() + ), + remediation: format!( + "Reinstall OpenCode assets so '{}' includes the required '{}' directory, then rerun 'sce doctor'.", + opencode_root.display(), + directory + ), + next_action: "manual_steps", + }); + } + Err(error) => { + if let Some(section) = opencode_section_for_directory(directory) { + opencode_health.push_issue( + section, + format!( + "OpenCode required directory '{}' could not be inspected: {error}", + required_path.display() + ), + Some(required_path.clone()), + ); + } + problems.push(DoctorProblem { + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "OpenCode required directory '{}' could not be inspected: {error}", + required_path.display() + ), + remediation: format!( + "Verify that '{}' is readable and rerun 'sce doctor'.", + required_path.display() + ), + next_action: "manual_steps", + }); + } + } + } +} + +fn opencode_section_for_directory(directory: &str) -> Option { + match directory { + "agent" => Some(OpenCodeSection::Agent), + "command" => Some(OpenCodeSection::Command), + "skills" => Some(OpenCodeSection::Skills), + _ => None, + } +} + +fn opencode_section_for_asset_path(relative_path: &str) -> Option { + match relative_path.split('/').next() { + Some("agent") => Some(OpenCodeSection::Agent), + Some("command") => Some(OpenCodeSection::Command), + Some("skills") => Some(OpenCodeSection::Skills), + _ => None, + } +} + +fn inspect_opencode_embedded_asset_presence( + opencode_root: &Path, + problems: &mut Vec, + opencode_health: &mut OpenCodeHealth, +) { + let mut seen_paths = HashSet::new(); + + for asset in iter_embedded_assets_for_setup_target(SetupTarget::OpenCode) { + let Some(section) = opencode_section_for_asset_path(asset.relative_path) else { + continue; + }; + if !seen_paths.insert(asset.relative_path) { + continue; + } + + let asset_path = opencode_root.join(asset.relative_path); + let metadata = fs::metadata(&asset_path).ok(); + let is_file = metadata.as_ref().is_some_and(std::fs::Metadata::is_file); + + if is_file { + continue; + } + + let summary = if metadata.is_some() { + format!( + "OpenCode embedded asset path '{}' is not a file.", + asset_path.display() + ) + } else { + format!( + "OpenCode embedded asset '{}' is missing.", + asset_path.display() + ) + }; + + opencode_health.push_issue(section, summary.clone(), Some(asset_path.clone())); + problems.push(DoctorProblem { + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary, + remediation: format!( + "Reinstall OpenCode assets so '{}' includes the embedded asset '{}', then rerun 'sce doctor'.", + opencode_root.display(), + asset_path.display() + ), + next_action: "manual_steps", + }); + } } fn opencode_plugin_registry_issue(manifest_path: &Path) -> Option { @@ -870,15 +1183,10 @@ fn opencode_plugin_registry_issue(manifest_path: &Path) -> Option { } } -#[cfg(test)] -fn opencode_plugin_asset() -> Option<&'static crate::services::setup::EmbeddedAsset> { - iter_embedded_assets_for_setup_target(SetupTarget::OpenCode) - .find(|asset| asset.relative_path == OPENCODE_PLUGIN_RELATIVE_PATH) -} - fn inspect_opencode_plugin_dependency_health( opencode_root: &Path, problems: &mut Vec, + opencode_health: &mut OpenCodeHealth, ) { inspect_opencode_asset_presence( opencode_root, @@ -886,6 +1194,7 @@ fn inspect_opencode_plugin_dependency_health( "OpenCode bash-policy runtime", "bash-policy runtime", problems, + opencode_health, ); inspect_opencode_asset_presence( opencode_root, @@ -893,6 +1202,7 @@ fn inspect_opencode_plugin_dependency_health( "OpenCode bash-policy preset catalog", "bash-policy preset catalog", problems, + opencode_health, ); } @@ -902,6 +1212,7 @@ fn inspect_opencode_asset_presence( summary_label: &str, remediation_label: &str, problems: &mut Vec, + opencode_health: &mut OpenCodeHealth, ) { let asset_path = opencode_root.join(relative_path); let metadata = fs::metadata(&asset_path).ok(); @@ -922,6 +1233,11 @@ fn inspect_opencode_asset_presence( asset_path.display() ) }; + opencode_health.push_issue( + OpenCodeSection::Plugin, + summary.clone(), + Some(asset_path.clone()), + ); problems.push(DoctorProblem { category: ProblemCategory::RepoAssets, severity: ProblemSeverity::Warning, @@ -1037,181 +1353,354 @@ fn run_git_command(repository_root: &Path, args: &[&str]) -> Option { } #[allow(clippy::too_many_lines)] -fn format_report(report: &HookDoctorReport) -> String { +fn format_report_lines(report: &HookDoctorReport) -> Vec { let mut lines = Vec::new(); - lines.push(format!( - "{}: {}", - label("SCE doctor"), - match report.readiness { - Readiness::Ready => success("ready"), - Readiness::NotReady => value("not ready"), - } - )); - lines.push(format!( - "{}: {}", - label("Mode"), - match report.mode { - DoctorMode::Diagnose => value("diagnose"), - DoctorMode::Fix => value("fix"), - } - )); - lines.push(format!( - "{}: {}", - label("Database inventory"), - match report.database_inventory { - DoctorDatabaseInventory::Repo => value("repo"), - DoctorDatabaseInventory::All => value("all"), - } - )); + lines.push(TaggedLine { + tag: readiness_tag(report.readiness), + text: format!( + "{}: {}", + label("SCE doctor"), + match report.readiness { + Readiness::Ready => success("ready"), + Readiness::NotReady => value("not ready"), + } + ), + }); + lines.push(TaggedLine { + tag: StatusTag::Pass, + text: format!( + "{}: {}", + label("Mode"), + match report.mode { + DoctorMode::Diagnose => value("diagnose"), + DoctorMode::Fix => value("fix"), + } + ), + }); + lines.push(TaggedLine { + tag: StatusTag::Pass, + text: format!( + "{}: {}", + label("Database inventory"), + match report.database_inventory { + DoctorDatabaseInventory::Repo => value("repo"), + DoctorDatabaseInventory::All => value("all"), + } + ), + }); - lines.push(format!( - "{}: {}", - label("Hooks path source"), - value(match report.hook_path_source { - HookPathSource::Default => "default (.git/hooks)", - HookPathSource::LocalConfig => "per-repo core.hooksPath", - HookPathSource::GlobalConfig => "global core.hooksPath", - }) - )); + lines.push(TaggedLine { + tag: StatusTag::Pass, + text: format!( + "{}: {}", + label("Hooks path source"), + value(match report.hook_path_source { + HookPathSource::Default => "default (.git/hooks)", + HookPathSource::LocalConfig => "per-repo core.hooksPath", + HookPathSource::GlobalConfig => "global core.hooksPath", + }) + ), + }); - lines.push(format!( - "{}: {}", - label("State root"), - report.state_root.as_ref().map_or_else( - || value("(not detected)"), - |location| format!( - "{} ({})", - value(location.state), - value(&location.path.display().to_string()) + lines.push(TaggedLine { + tag: report + .state_root + .as_ref() + .map_or(StatusTag::Miss, |location| { + tag_for_location_state(location.state) + }), + text: format!( + "{}: {}", + label("State root"), + report.state_root.as_ref().map_or_else( + || value("(not detected)"), + |location| format!( + "{} ({})", + value(location.state), + value(&location.path.display().to_string()) + ) ) - ) - )); + ), + }); - lines.push(format!( - "{}: {}", - label("Repository root"), - report.repository_root.as_ref().map_or_else( - || value("(not detected)"), - |path| value(&path.display().to_string()) - ) - )); + lines.push(TaggedLine { + tag: report + .repository_root + .as_ref() + .map_or(StatusTag::Miss, |_| StatusTag::Pass), + text: format!( + "{}: {}", + label("Repository root"), + report.repository_root.as_ref().map_or_else( + || value("(not detected)"), + |path| value(&path.display().to_string()) + ) + ), + }); - lines.push(format!( - "{}: {}", - label("Effective hooks directory"), - report.hooks_directory.as_ref().map_or_else( - || value("(not detected)"), - |path| value(&path.display().to_string()) - ) - )); + lines.push(TaggedLine { + tag: report + .hooks_directory + .as_ref() + .map_or(StatusTag::Miss, |_| StatusTag::Pass), + text: format!( + "{}: {}", + label("Effective hooks directory"), + report.hooks_directory.as_ref().map_or_else( + || value("(not detected)"), + |path| value(&path.display().to_string()) + ) + ), + }); - lines.push(format!("\n{}:", heading("Config files"))); + lines.push(TaggedLine { + tag: StatusTag::Pass, + text: format!("{}:", heading("Config files")), + }); for location in &report.config_locations { - lines.push(format!( - " {}: {} ({})", - label(location.label), - value(location.state), - value(&location.path.display().to_string()) - )); - } - - lines.push(format!( - "\n{}: {}", - label("Agent Trace local DB"), - report.agent_trace_local_db.as_ref().map_or_else( - || value("(not detected)"), - |location| format!( - "{} ({})", + lines.push(TaggedLine { + tag: tag_for_location_state(location.state), + text: format!( + " {}: {} ({})", + label(location.label), value(location.state), value(&location.path.display().to_string()) + ), + }); + } + + lines.push(TaggedLine { + tag: report + .agent_trace_local_db + .as_ref() + .map_or(StatusTag::Miss, |location| { + tag_for_location_state(location.state) + }), + text: format!( + "{}: {}", + label("Agent Trace local DB"), + report.agent_trace_local_db.as_ref().map_or_else( + || value("(not detected)"), + |location| format!( + "{} ({})", + value(location.state), + value(&location.path.display().to_string()) + ) ) - ) - )); + ), + }); // Repo-scoped databases (empty by design) - lines.push(format!("\n{}:", heading("Repo-scoped databases"))); + lines.push(TaggedLine { + tag: StatusTag::Pass, + text: format!("{}:", heading("Repo-scoped databases")), + }); if report.repo_databases.is_empty() { - lines.push(value(" none").clone()); + lines.push(TaggedLine { + tag: StatusTag::Miss, + text: value(" none"), + }); } else { for database in &report.repo_databases { - lines.push(format!("- {}", format_database_record(database))); + lines.push(TaggedLine { + tag: tag_for_database_status(database.status), + text: format!("- {}", format_database_record(database)), + }); } } // All SCE databases (when --all-databases) if report.database_inventory == DoctorDatabaseInventory::All { - lines.push(format!("\n{}:", heading("All SCE databases"))); + lines.push(TaggedLine { + tag: StatusTag::Pass, + text: format!("{}:", heading("All SCE databases")), + }); if report.all_databases.is_empty() { - lines.push(value(" none").clone()); + lines.push(TaggedLine { + tag: StatusTag::Miss, + text: value(" none"), + }); } else { for database in &report.all_databases { - lines.push(format!( - " {}: {} ({}) {}", - value(database_family(database.family)), - value(database_scope(database.scope)), - value(database_status(database.status)), - value(&database.canonical_path.display().to_string()) - )); + lines.push(TaggedLine { + tag: tag_for_database_status(database.status), + text: format!( + " {}: {} ({}) {}", + value(database_family(database.family)), + value(database_scope(database.scope)), + value(database_status(database.status)), + value(&database.canonical_path.display().to_string()) + ), + }); } } } // Required hooks - lines.push(format!("\n{}:", heading("Required hooks"))); + lines.push(TaggedLine { + tag: StatusTag::Pass, + text: format!("{}:", heading("Required hooks")), + }); for hook in &report.hooks { - lines.push(format!( - " {}: {} (content={}, executable={}) {}", - value(hook.name), - value(hook_state(hook)), - value(hook_content_state(hook.content_state)), - value(if hook.executable { "yes" } else { "no" }), - value(&hook.path.display().to_string()) - )); + lines.push(TaggedLine { + tag: tag_for_hook(hook), + text: format!( + " {}: {} (content={}, executable={}) {}", + value(hook.name), + value(hook_state(hook)), + value(hook_content_state(hook.content_state)), + value(if hook.executable { "yes" } else { "no" }), + value(&hook.path.display().to_string()) + ), + }); } + lines.extend(format_opencode_sections(report)); + // Problems if report.problems.is_empty() { - lines.push(format!("\n{}: {}", label("Problems"), success("none"))); + lines.push(TaggedLine { + tag: StatusTag::Pass, + text: format!("{}: {}", label("Problems"), success("none")), + }); } else { - lines.push(format!("\n{}:", heading("Problems"))); + lines.push(TaggedLine { + tag: tag_for_problem_heading(&report.problems), + text: format!("{}:", heading("Problems")), + }); for problem in &report.problems { - lines.push(format!( - " [{}|{}|{}] {}", - value(problem_category(problem.category)), - value(problem_severity(problem.severity)), - value(problem_fixability(problem.fixability)), - value(&problem.summary) - )); + lines.push(TaggedLine { + tag: tag_for_problem_severity(problem.severity), + text: format!( + " [{}|{}|{}] {}", + value(problem_category(problem.category)), + value(problem_severity(problem.severity)), + value(problem_fixability(problem.fixability)), + value(&problem.summary) + ), + }); + } + } + + lines +} + +fn format_opencode_sections(report: &HookDoctorReport) -> Vec { + let mut lines = Vec::new(); + let sections = [ + OpenCodeSection::Plugin, + OpenCodeSection::Agent, + OpenCodeSection::Command, + OpenCodeSection::Skills, + ]; + + for section in sections { + if let Some(health) = &report.opencode_health { + let section_health = health.section(section); + let issues = §ion_health.issues; + let tag = if issues.is_empty() { + StatusTag::Pass + } else { + StatusTag::Fail + }; + lines.push(TaggedLine { + tag, + text: format!( + "{}: {}", + label(opencode_section_label(section)), + if issues.is_empty() { + success("ok") + } else { + value("failed") + } + ), + }); + + if !issues.is_empty() { + for issue in issues { + let detail = match &issue.path { + Some(path) => format!( + " - {} ({})", + value(&issue.summary), + value(&path.display().to_string()) + ), + None => format!(" - {}", value(&issue.summary)), + }; + lines.push(TaggedLine { + tag: StatusTag::Fail, + text: detail, + }); + } + } + } else { + lines.push(TaggedLine { + tag: StatusTag::Fail, + text: format!( + "{}: {}", + label(opencode_section_label(section)), + value("not detected") + ), + }); + lines.push(TaggedLine { + tag: StatusTag::Fail, + text: format!( + " - {}", + value("OpenCode health is not available (repository not detected).") + ), + }); } } - lines.join("\n") + lines +} + +fn opencode_section_label(section: OpenCodeSection) -> &'static str { + match section { + OpenCodeSection::Plugin => "OpenCode plugin", + OpenCodeSection::Agent => "OpenCode agent", + OpenCodeSection::Command => "OpenCode command", + OpenCodeSection::Skills => "OpenCode skills", + } +} + +fn opencode_section_slug(section: OpenCodeSection) -> &'static str { + match section { + OpenCodeSection::Plugin => "plugin", + OpenCodeSection::Agent => "agent", + OpenCodeSection::Command => "command", + OpenCodeSection::Skills => "skills", + } } fn format_execution(execution: &DoctorExecution) -> String { let report = &execution.report; - let base_report = format_report(report); - let mut lines = base_report - .lines() - .map(ToOwned::to_owned) - .collect::>(); + let mut lines = format_report_lines(report); if report.mode == DoctorMode::Fix { if execution.fix_results.is_empty() { - lines.push(format!("\n{}: {}", label("Fix results"), value("none"))); + lines.push(TaggedLine { + tag: StatusTag::Pass, + text: format!("{}: {}", label("Fix results"), value("none")), + }); } else { - lines.push(format!("\n{}:", heading("Fix results"))); + lines.push(TaggedLine { + tag: tag_for_fix_results_heading(&execution.fix_results), + text: format!("{}:", heading("Fix results")), + }); for fix_result in &execution.fix_results { - lines.push(format!( - " [{}] {}", - value(fix_result_outcome(fix_result.outcome)), - value(&fix_result.detail) - )); + lines.push(TaggedLine { + tag: tag_for_fix_result(fix_result.outcome), + text: format!( + " [{}] {}", + value(fix_result_outcome(fix_result.outcome)), + value(&fix_result.detail) + ), + }); } } } - lines.join("\n") + format_tagged_lines(lines) } fn render_report(request: DoctorRequest, execution: &DoctorExecution) -> Result { @@ -1223,33 +1712,6 @@ fn render_report(request: DoctorRequest, execution: &DoctorExecution) -> Result< fn render_report_json(execution: &DoctorExecution) -> Result { let report = &execution.report; - let hooks = report - .hooks - .iter() - .map(|hook| { - json!({ - "name": hook.name, - "path": hook.path.display().to_string(), - "exists": hook.exists, - "executable": hook.executable, - "state": hook_state(hook), - "content_state": hook_content_state(hook.content_state), - }) - }) - .collect::>(); - - let config_paths = report - .config_locations - .iter() - .map(|location| { - json!({ - "label": location.label, - "path": location.path.display().to_string(), - "state": location.state, - }) - }) - .collect::>(); - let payload = json!({ "status": "ok", "command": NAME, @@ -1265,11 +1727,7 @@ fn render_report_json(execution: &DoctorExecution) -> Result { Readiness::Ready => "ready", Readiness::NotReady => "not_ready", }, - "state_root": report.state_root.as_ref().map(|location| json!({ - "label": location.label, - "path": location.path.display().to_string(), - "state": location.state, - })), + "state_root": report.state_root.as_ref().map(render_location_json), "hook_path_source": match report.hook_path_source { HookPathSource::Default => "default", HookPathSource::LocalConfig => "local_config", @@ -1283,41 +1741,218 @@ fn render_report_json(execution: &DoctorExecution) -> Result { .hooks_directory .as_ref() .map(|path| path.display().to_string()), - "config_paths": config_paths, - "agent_trace_local_db": report.agent_trace_local_db.as_ref().map(|location| json!({ - "label": location.label, - "path": location.path.display().to_string(), - "state": location.state, - })), + "config_paths": render_config_paths_json(report), + "agent_trace_local_db": report.agent_trace_local_db.as_ref().map(render_location_json), "repo_databases": report.repo_databases.iter().map(render_database_record_json).collect::>(), "all_databases": report.all_databases.iter().map(render_database_record_json).collect::>(), - "hooks": hooks, - "problems": report.problems.iter().map(|problem| json!({ - "category": problem_category(problem.category), - "severity": problem_severity(problem.severity), - "fixability": problem_fixability(problem.fixability), - "summary": problem.summary, - "remediation": { - "next_action": problem.next_action, - "text": problem.remediation, - }, - })).collect::>(), - "fix_results": if report.mode == DoctorMode::Fix { - execution.fix_results.iter() - .map(|result| json!({ - "category": problem_category(result.category), - "outcome": fix_result_outcome(result.outcome), - "detail": result.detail, - })) - .collect::>() - } else { - Vec::new() - }, + "hooks": render_hooks_json(report), + "opencode_health": report + .opencode_health + .as_ref() + .map(render_opencode_health_json), + "problems": render_problems_json(&report.problems), + "fix_results": render_fix_results_json(execution), }); serde_json::to_string_pretty(&payload).context("failed to serialize doctor report to JSON") } +fn render_hooks_json(report: &HookDoctorReport) -> Vec { + report + .hooks + .iter() + .map(|hook| { + json!({ + "name": hook.name, + "path": hook.path.display().to_string(), + "exists": hook.exists, + "executable": hook.executable, + "state": hook_state(hook), + "content_state": hook_content_state(hook.content_state), + }) + }) + .collect() +} + +fn render_config_paths_json(report: &HookDoctorReport) -> Vec { + report + .config_locations + .iter() + .map(render_location_json) + .collect() +} + +fn render_location_json(location: &FileLocationHealth) -> serde_json::Value { + json!({ + "label": location.label, + "path": location.path.display().to_string(), + "state": location.state, + }) +} + +fn render_opencode_health_json(health: &OpenCodeHealth) -> serde_json::Value { + json!({ + "root": health.root.display().to_string(), + "root_missing": health.root_missing, + "sections": health + .sections + .iter() + .map(|section| { + json!({ + "section": opencode_section_slug(section.section), + "status": if section.issues.is_empty() { "ok" } else { "failed" }, + "issues": section.issues.iter().map(render_opencode_issue_json).collect::>(), + }) + }) + .collect::>(), + }) +} + +fn render_opencode_issue_json(issue: &OpenCodeIssue) -> serde_json::Value { + json!({ + "summary": issue.summary, + "path": issue + .path + .as_ref() + .map(|path| path.display().to_string()), + }) +} + +fn render_problems_json(problems: &[DoctorProblem]) -> Vec { + problems + .iter() + .map(|problem| { + json!({ + "category": problem_category(problem.category), + "severity": problem_severity(problem.severity), + "fixability": problem_fixability(problem.fixability), + "summary": problem.summary, + "remediation": { + "next_action": problem.next_action, + "text": problem.remediation, + }, + }) + }) + .collect() +} + +fn render_fix_results_json(execution: &DoctorExecution) -> Vec { + if execution.report.mode != DoctorMode::Fix { + return Vec::new(); + } + + execution + .fix_results + .iter() + .map(|result| { + json!({ + "category": problem_category(result.category), + "outcome": fix_result_outcome(result.outcome), + "detail": result.detail, + }) + }) + .collect() +} + +fn format_tagged_lines(lines: Vec) -> String { + lines + .into_iter() + .map(|line| format!("{} {}", status_tag_prefix(line.tag), line.text)) + .collect::>() + .join("\n") +} + +fn status_tag_prefix(tag: StatusTag) -> String { + let prefix = format!("[{}]", status_tag_label(tag)); + match tag { + StatusTag::Pass => status_tag_pass(&prefix), + StatusTag::Fail => status_tag_fail(&prefix), + StatusTag::Warn => status_tag_warn(&prefix), + StatusTag::Miss => status_tag_miss(&prefix), + } +} + +fn status_tag_label(tag: StatusTag) -> &'static str { + match tag { + StatusTag::Pass => "PASS", + StatusTag::Fail => "FAIL", + StatusTag::Miss => "MISS", + StatusTag::Warn => "WARN", + } +} + +fn readiness_tag(readiness: Readiness) -> StatusTag { + match readiness { + Readiness::Ready => StatusTag::Pass, + Readiness::NotReady => StatusTag::Fail, + } +} + +fn tag_for_location_state(state: &str) -> StatusTag { + match state { + "present" => StatusTag::Pass, + "expected" => StatusTag::Miss, + _ => StatusTag::Warn, + } +} + +fn tag_for_database_status(status: DatabaseStatus) -> StatusTag { + match status { + DatabaseStatus::Present => StatusTag::Pass, + DatabaseStatus::Missing => StatusTag::Miss, + } +} + +fn tag_for_hook(hook: &HookFileHealth) -> StatusTag { + if hook_state(hook) == "ok" { + StatusTag::Pass + } else { + StatusTag::Fail + } +} + +fn tag_for_problem_heading(problems: &[DoctorProblem]) -> StatusTag { + if problems + .iter() + .any(|problem| problem.severity == ProblemSeverity::Error) + { + StatusTag::Fail + } else { + StatusTag::Warn + } +} + +fn tag_for_problem_severity(severity: ProblemSeverity) -> StatusTag { + match severity { + ProblemSeverity::Error => StatusTag::Fail, + ProblemSeverity::Warning => StatusTag::Warn, + } +} + +fn tag_for_fix_results_heading(results: &[DoctorFixResultRecord]) -> StatusTag { + if results + .iter() + .any(|result| result.outcome == FixResult::Failed) + { + StatusTag::Fail + } else if results + .iter() + .any(|result| result.outcome == FixResult::Manual) + { + StatusTag::Warn + } else { + StatusTag::Pass + } +} + +fn tag_for_fix_result(outcome: FixResult) -> StatusTag { + match outcome { + FixResult::Fixed | FixResult::Skipped => StatusTag::Pass, + FixResult::Manual => StatusTag::Warn, + FixResult::Failed => StatusTag::Fail, + } +} + fn hook_state(hook: &HookFileHealth) -> &'static str { if !hook.exists { "missing" @@ -1636,11 +2271,14 @@ mod tests { use super::{ execute_doctor_with_dependencies, render_report, run_filesystem_auto_fixes, - DoctorDatabaseInventory, DoctorDependencies, DoctorFormat, DoctorMode, DoctorProblem, - DoctorRequest, FileLocationHealth, FixResult, HookDoctorReport, HookPathSource, - ProblemCategory, ProblemFixability, ProblemSeverity, Readiness, NAME, + DoctorDatabaseInventory, DoctorDependencies, DoctorExecution, DoctorFormat, DoctorMode, + DoctorProblem, DoctorRequest, FileLocationHealth, FixResult, HookDoctorReport, + HookPathSource, OpenCodeHealth, OpenCodeSection, ProblemCategory, ProblemFixability, + ProblemSeverity, Readiness, NAME, + }; + use crate::services::setup::{ + iter_embedded_assets_for_setup_target, RequiredHooksInstallOutcome, SetupTarget, }; - use crate::services::setup::RequiredHooksInstallOutcome; struct TestDir { path: PathBuf, @@ -1680,6 +2318,37 @@ mod tests { Ok(()) } + fn install_opencode_embedded_assets(opencode_root: &Path) -> Result<()> { + fs::create_dir_all(opencode_root)?; + for asset in iter_embedded_assets_for_setup_target(SetupTarget::OpenCode) { + let destination = opencode_root.join(asset.relative_path); + let parent = destination + .parent() + .expect("embedded asset path should have a parent"); + fs::create_dir_all(parent)?; + fs::write(&destination, asset.bytes)?; + } + Ok(()) + } + + fn find_opencode_embedded_asset_for_section() -> (OpenCodeSection, &'static str) { + let candidates = [ + (OpenCodeSection::Agent, "agent/"), + (OpenCodeSection::Command, "command/"), + (OpenCodeSection::Skills, "skills/"), + ]; + + for (section, prefix) in candidates { + if let Some(asset) = iter_embedded_assets_for_setup_target(SetupTarget::OpenCode) + .find(|asset| asset.relative_path.starts_with(prefix)) + { + return (section, asset.relative_path); + } + } + + panic!("Expected at least one embedded OpenCode asset under agent/command/skills"); + } + fn filesystem_problem(summary: &str) -> DoctorProblem { DoctorProblem { category: ProblemCategory::FilesystemPermissions, @@ -1716,6 +2385,70 @@ mod tests { .is_some_and(|value| value.contains(summary_fragment)) } + fn assert_all_lines_tagged(output: &str) { + for line in output.lines() { + let normalized = strip_ansi_codes(line); + assert!( + normalized.starts_with("[PASS] ") + || normalized.starts_with("[FAIL] ") + || normalized.starts_with("[MISS] ") + || normalized.starts_with("[WARN] "), + "line missing status tag: '{line}'" + ); + } + } + + fn strip_ansi_codes(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{1b}' { + if matches!(chars.peek(), Some('[')) { + chars.next(); + for next in chars.by_ref() { + if next == 'm' { + break; + } + } + } + continue; + } + output.push(ch); + } + output + } + + fn base_report(mode: DoctorMode, readiness: Readiness) -> HookDoctorReport { + HookDoctorReport { + mode, + database_inventory: DoctorDatabaseInventory::Repo, + readiness, + state_root: Some(FileLocationHealth { + label: "State root", + path: PathBuf::from("/tmp/state"), + state: "present", + }), + repository_root: Some(PathBuf::from("/tmp/repo")), + hook_path_source: HookPathSource::Default, + hooks_directory: Some(PathBuf::from("/tmp/repo/.git/hooks")), + config_locations: vec![FileLocationHealth { + label: "Global config", + path: PathBuf::from("/tmp/config.json"), + state: "present", + }], + agent_trace_local_db: Some(FileLocationHealth { + label: "Agent Trace local DB", + path: PathBuf::from("/tmp/state/sce/agent-trace/local.db"), + state: "present", + }), + repo_databases: Vec::new(), + all_databases: Vec::new(), + hooks: Vec::new(), + opencode_health: None, + problems: Vec::new(), + } + } + #[test] fn render_json_includes_stable_fields_without_filesystem() -> Result<()> { let output = render_report( @@ -1746,6 +2479,7 @@ mod tests { assert!(parsed["repo_databases"].is_array()); assert!(parsed["all_databases"].is_array()); assert!(parsed["hooks"].is_array()); + assert!(parsed["opencode_health"].is_null() || parsed["opencode_health"].is_object()); assert!(parsed["problems"].is_array()); assert!(parsed["fix_results"].is_array()); Ok(()) @@ -1776,18 +2510,207 @@ mod tests { } #[test] - fn doctor_reports_local_config_validation_failures() -> Result<()> { - let test_dir = TestDir::new("doctor-local-config")?; + fn render_json_includes_opencode_health_sections() -> Result<()> { + let mut report = base_report(DoctorMode::Diagnose, Readiness::NotReady); + let mut opencode_health = OpenCodeHealth::new(PathBuf::from("/tmp/repo/.opencode")); + opencode_health.push_issue( + OpenCodeSection::Plugin, + "OpenCode plugin file '/tmp/repo/.opencode/plugins/sce-bash-policy.ts' is missing.", + Some(PathBuf::from( + "/tmp/repo/.opencode/plugins/sce-bash-policy.ts", + )), + ); + opencode_health.push_issue( + OpenCodeSection::Agent, + "OpenCode required directory '/tmp/repo/.opencode/agent' is missing.", + Some(PathBuf::from("/tmp/repo/.opencode/agent")), + ); + report.opencode_health = Some(opencode_health); + + let execution = DoctorExecution { + report, + fix_results: Vec::new(), + }; + let output = render_report( + DoctorRequest { + mode: DoctorMode::Diagnose, + database_inventory: DoctorDatabaseInventory::Repo, + format: DoctorFormat::Json, + }, + &execution, + )?; + let parsed: Value = serde_json::from_str(&output)?; + let opencode = parsed["opencode_health"] + .as_object() + .expect("opencode_health object"); + assert_eq!(opencode["root"], "/tmp/repo/.opencode"); + assert_eq!(opencode["root_missing"], false); + let sections = opencode["sections"].as_array().expect("sections array"); + let find_section = |slug: &str| { + sections + .iter() + .find(|section| section["section"] == slug) + .expect("section should exist") + }; + let plugin = find_section("plugin"); + assert_eq!(plugin["status"], "failed"); + assert!(plugin["issues"].as_array().is_some()); + let agent = find_section("agent"); + assert_eq!(agent["status"], "failed"); + let command = find_section("command"); + assert_eq!(command["status"], "ok"); + let skills = find_section("skills"); + assert_eq!(skills["status"], "ok"); + Ok(()) + } + + #[test] + fn doctor_text_output_tags_all_lines_for_ready_report() { + let execution = DoctorExecution { + report: base_report(DoctorMode::Diagnose, Readiness::Ready), + fix_results: Vec::new(), + }; + let output = super::format_execution(&execution); + + assert_all_lines_tagged(&output); + } + + #[test] + fn doctor_text_output_tags_all_lines_for_not_ready_report() { + let mut report = base_report(DoctorMode::Diagnose, Readiness::NotReady); + report.problems.push(DoctorProblem { + category: ProblemCategory::HookRollout, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: "Missing required hook".to_string(), + remediation: "Install hooks".to_string(), + next_action: "manual_steps", + }); + let execution = DoctorExecution { + report, + fix_results: Vec::new(), + }; + let output = super::format_execution(&execution); + + assert_all_lines_tagged(&output); + let normalized = strip_ansi_codes(&output); + assert!(normalized.contains("[FAIL]")); + } + + #[test] + fn doctor_text_output_tags_all_lines_for_fix_results() { + let execution = DoctorExecution { + report: base_report(DoctorMode::Fix, Readiness::Ready), + fix_results: vec![ + super::DoctorFixResultRecord { + category: ProblemCategory::HookRollout, + outcome: FixResult::Fixed, + detail: "Installed hook".to_string(), + }, + super::DoctorFixResultRecord { + category: ProblemCategory::HookRollout, + outcome: FixResult::Failed, + detail: "Hook repair failed".to_string(), + }, + ], + }; + let output = super::format_execution(&execution); + + assert_all_lines_tagged(&output); + let normalized = strip_ansi_codes(&output); + assert!(normalized.contains("[FAIL]")); + } + + #[test] + fn doctor_text_output_includes_warn_and_miss_tags() { + let mut report = base_report(DoctorMode::Diagnose, Readiness::Ready); + report.state_root = None; + if let Some(location) = report.config_locations.first_mut() { + location.state = "expected"; + } + report.problems.push(DoctorProblem { + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Warning, + fixability: ProblemFixability::ManualOnly, + summary: "warning from test".to_string(), + remediation: "manual remediation".to_string(), + next_action: "manual_steps", + }); + + let execution = DoctorExecution { + report, + fix_results: Vec::new(), + }; + let output = super::format_execution(&execution); + + assert_all_lines_tagged(&output); + let normalized = strip_ansi_codes(&output); + assert!(normalized.contains("[WARN]")); + assert!(normalized.contains("[MISS]")); + assert!(output.contains("warning from test")); + } + + #[test] + fn doctor_text_output_includes_opencode_sections_and_details() { + let mut report = base_report(DoctorMode::Diagnose, Readiness::NotReady); + let mut opencode_health = OpenCodeHealth::new(PathBuf::from("/tmp/repo/.opencode")); + opencode_health.push_issue( + OpenCodeSection::Plugin, + "OpenCode plugin file '/tmp/repo/.opencode/plugins/sce-bash-policy.ts' is missing.", + Some(PathBuf::from( + "/tmp/repo/.opencode/plugins/sce-bash-policy.ts", + )), + ); + opencode_health.push_issue( + OpenCodeSection::Agent, + "OpenCode required directory '/tmp/repo/.opencode/agent' is missing.", + Some(PathBuf::from("/tmp/repo/.opencode/agent")), + ); + report.opencode_health = Some(opencode_health); + + let execution = DoctorExecution { + report, + fix_results: Vec::new(), + }; + let output = super::format_execution(&execution); + let normalized = strip_ansi_codes(&output); + + assert!(normalized.contains("OpenCode plugin")); + assert!(normalized.contains("OpenCode agent")); + assert!(normalized.contains("OpenCode command")); + assert!(normalized.contains("OpenCode skills")); + assert!(normalized.contains("OpenCode command: ok")); + assert!(normalized.contains("OpenCode skills: ok")); + assert!(normalized.contains("OpenCode plugin file")); + assert!(normalized.contains("OpenCode required directory")); + assert!(normalized.contains("/tmp/repo/.opencode/plugins/sce-bash-policy.ts")); + } + + #[test] + fn doctor_text_output_places_missing_embedded_asset_in_section() -> Result<()> { + let test_dir = TestDir::new("doctor-opencode-embedded-asset-text")?; let repository_root = test_dir.path().join("repo"); let hooks_dir = repository_root.join(".git").join("hooks"); - let local_config_path = repository_root.join(".sce").join("config.json"); install_canonical_hooks(&hooks_dir)?; + + let opencode_root = repository_root.join(".opencode"); + install_opencode_embedded_assets(&opencode_root)?; + let (section, relative_path) = find_opencode_embedded_asset_for_section(); + let missing_path = opencode_root.join(relative_path); + fs::remove_file(&missing_path)?; + let missing_path_display = missing_path.display().to_string(); + + let agent_trace_db = test_dir + .path() + .join("state-root") + .join("sce") + .join("agent-trace") + .join("local.db"); fs::create_dir_all( - local_config_path + agent_trace_db .parent() - .expect("local config path should have parent"), + .expect("agent trace path should have parent"), )?; - fs::write(&local_config_path, "{}")?; let repo_root = repository_root.clone(); let hooks_dir = hooks_dir.clone(); @@ -1797,20 +2720,18 @@ mod tests { ["rev-parse", "--git-path", "hooks"] => Some(hooks_dir.display().to_string()), _ => None, }; + + let state_root = test_dir.path().join("state-root"); + let resolve_state_root = move || Ok(state_root.clone()); + let resolve_agent_trace_local_db_path = move || Ok(agent_trace_db.clone()); + let dependencies = DoctorDependencies { run_git_command: &run_git_command, check_git_available: &|| true, - resolve_state_root: &|| Ok(test_dir.path().join("state-root")), + resolve_state_root: &resolve_state_root, resolve_global_config_path: &|| Ok(test_dir.path().join("config-root/sce/config.json")), - resolve_agent_trace_local_db_path: &|| { - Ok(test_dir.path().join("state-root/sce/agent-trace/local.db")) - }, - validate_config_file: &|path: &Path| { - if path.ends_with(Path::new(".sce/config.json")) { - anyhow::bail!("schema mismatch") - } - Ok(()) - }, + resolve_agent_trace_local_db_path: &resolve_agent_trace_local_db_path, + validate_config_file: &|_| Ok(()), check_agent_trace_local_db_health: &|_| Ok(()), install_required_git_hooks: &|_| unreachable!("hook install should not run"), create_directory_all: &|_| unreachable!("directory creation should not run"), @@ -1825,8 +2746,115 @@ mod tests { &repository_root, &dependencies, ); - - assert_eq!(execution.report.readiness, Readiness::NotReady); + let output = super::format_execution(&execution); + let normalized = strip_ansi_codes(&output); + + let section_label = match section { + OpenCodeSection::Agent => "OpenCode agent", + OpenCodeSection::Command => "OpenCode command", + OpenCodeSection::Skills => "OpenCode skills", + OpenCodeSection::Plugin => "OpenCode plugin", + }; + let section_header = format!("{section_label}: failed"); + let section_start = normalized + .find(§ion_header) + .expect("section header should exist"); + let issue_rel = normalized[section_start..] + .find(&missing_path_display) + .expect("missing embedded asset should be listed under section"); + let issue_pos = section_start + issue_rel; + + let next_section_label = match section { + OpenCodeSection::Agent => Some("OpenCode command"), + OpenCodeSection::Command => Some("OpenCode skills"), + OpenCodeSection::Skills | OpenCodeSection::Plugin => None, + }; + let boundary = next_section_label + .and_then(|label| { + normalized[section_start..] + .find(label) + .map(|pos| section_start + pos) + }) + .or_else(|| normalized.find("Problems")) + .unwrap_or(normalized.len()); + + assert!(issue_pos > section_start); + assert!(issue_pos < boundary); + Ok(()) + } + + #[test] + fn doctor_text_output_disables_prefix_colors_when_no_color_set() { + let previous = std::env::var("NO_COLOR").ok(); + std::env::set_var("NO_COLOR", "1"); + let execution = DoctorExecution { + report: base_report(DoctorMode::Diagnose, Readiness::Ready), + fix_results: Vec::new(), + }; + let output = super::format_execution(&execution); + + match previous { + Some(value) => std::env::set_var("NO_COLOR", value), + None => std::env::remove_var("NO_COLOR"), + } + + assert_all_lines_tagged(&output); + assert!(output.contains("[PASS] ")); + assert!(!output.contains("\u{1b}[")); + } + + #[test] + fn doctor_reports_local_config_validation_failures() -> Result<()> { + let test_dir = TestDir::new("doctor-local-config")?; + let repository_root = test_dir.path().join("repo"); + let hooks_dir = repository_root.join(".git").join("hooks"); + let local_config_path = repository_root.join(".sce").join("config.json"); + install_canonical_hooks(&hooks_dir)?; + fs::create_dir_all( + local_config_path + .parent() + .expect("local config path should have parent"), + )?; + fs::write(&local_config_path, "{}")?; + + let repo_root = repository_root.clone(); + let hooks_dir = hooks_dir.clone(); + let run_git_command = move |_cwd: &Path, args: &[&str]| match args { + ["rev-parse", "--show-toplevel"] => Some(repo_root.display().to_string()), + ["rev-parse", "--is-bare-repository"] => Some("false".to_string()), + ["rev-parse", "--git-path", "hooks"] => Some(hooks_dir.display().to_string()), + _ => None, + }; + let dependencies = DoctorDependencies { + run_git_command: &run_git_command, + check_git_available: &|| true, + resolve_state_root: &|| Ok(test_dir.path().join("state-root")), + resolve_global_config_path: &|| Ok(test_dir.path().join("config-root/sce/config.json")), + resolve_agent_trace_local_db_path: &|| { + Ok(test_dir.path().join("state-root/sce/agent-trace/local.db")) + }, + validate_config_file: &|path: &Path| { + if path.ends_with(Path::new(".sce/config.json")) { + anyhow::bail!("schema mismatch") + } + Ok(()) + }, + check_agent_trace_local_db_health: &|_| Ok(()), + install_required_git_hooks: &|_| unreachable!("hook install should not run"), + create_directory_all: &|_| unreachable!("directory creation should not run"), + }; + + let execution = execute_doctor_with_dependencies( + DoctorRequest { + mode: DoctorMode::Diagnose, + database_inventory: DoctorDatabaseInventory::Repo, + format: DoctorFormat::Text, + }, + &repository_root, + &dependencies, + ); + + assert_eq!(execution.report.readiness, Readiness::NotReady); assert!(execution.report.problems.iter().any(|problem| { problem.summary.contains("Local config file") && problem.summary.contains("schema mismatch") @@ -1911,19 +2939,12 @@ mod tests { } #[test] - fn doctor_reports_opencode_plugin_missing_file_warning() -> Result<()> { - let test_dir = TestDir::new("doctor-opencode-file-missing")?; + fn doctor_reports_opencode_root_missing() -> Result<()> { + let test_dir = TestDir::new("doctor-opencode-structure-skip")?; let repository_root = test_dir.path().join("repo"); let hooks_dir = repository_root.join(".git").join("hooks"); install_canonical_hooks(&hooks_dir)?; - let opencode_root = repository_root.join(".opencode"); - fs::create_dir_all(&opencode_root)?; - fs::write( - opencode_root.join("opencode.json"), - "{\"plugin\":[\"./plugins/sce-bash-policy.ts\"]}", - )?; - let agent_trace_db = test_dir .path() .join("state-root") @@ -1975,26 +2996,31 @@ mod tests { &repository_root, &dependencies, ); + assert!(execution + .report + .opencode_health + .as_ref() + .is_some_and(|health| health.root_missing)); let output = render_report(json_request, &execution)?; let parsed: Value = serde_json::from_str(&output)?; - assert_eq!(parsed["readiness"], "ready"); + assert_eq!(parsed["readiness"], "not_ready"); let problems = parsed["problems"].as_array().expect("problems array"); assert!(problems.iter().any(|problem| { problem_matches( problem, "repo_assets", - "warning", + "error", "manual_only", - "is missing", + "OpenCode root directory", ) })); Ok(()) } #[test] - fn doctor_reports_opencode_plugin_runtime_missing_warning() -> Result<()> { - let test_dir = TestDir::new("doctor-opencode-runtime-missing")?; + fn doctor_reports_opencode_structure_missing_directories() -> Result<()> { + let test_dir = TestDir::new("doctor-opencode-structure-missing")?; let repository_root = test_dir.path().join("repo"); let hooks_dir = repository_root.join(".git").join("hooks"); install_canonical_hooks(&hooks_dir)?; @@ -2006,23 +3032,183 @@ mod tests { "{\"plugin\":[\"./plugins/sce-bash-policy.ts\"]}", )?; - let canonical_plugin = super::opencode_plugin_asset() - .expect("canonical OpenCode plugin asset should be embedded"); - let plugin_path = opencode_root.join("plugins").join("sce-bash-policy.ts"); + let agent_trace_db = test_dir + .path() + .join("state-root") + .join("sce") + .join("agent-trace") + .join("local.db"); fs::create_dir_all( - plugin_path + agent_trace_db .parent() - .expect("plugin path should have parent"), + .expect("agent trace path should have parent"), )?; - fs::write(&plugin_path, canonical_plugin.bytes)?; - let preset_path = opencode_root.join("lib").join("bash-policy-presets.json"); + let repo_root = repository_root.clone(); + let hooks_dir = hooks_dir.clone(); + let run_git_command = move |_cwd: &Path, args: &[&str]| match args { + ["rev-parse", "--show-toplevel"] => Some(repo_root.display().to_string()), + ["rev-parse", "--is-bare-repository"] => Some("false".to_string()), + ["rev-parse", "--git-path", "hooks"] => Some(hooks_dir.display().to_string()), + _ => None, + }; + + let state_root = test_dir.path().join("state-root"); + let resolve_state_root = move || Ok(state_root.clone()); + let resolve_agent_trace_local_db_path = move || Ok(agent_trace_db.clone()); + + let dependencies = DoctorDependencies { + run_git_command: &run_git_command, + check_git_available: &|| true, + resolve_state_root: &resolve_state_root, + resolve_global_config_path: &|| Ok(test_dir.path().join("config-root/sce/config.json")), + resolve_agent_trace_local_db_path: &resolve_agent_trace_local_db_path, + validate_config_file: &|_| Ok(()), + check_agent_trace_local_db_health: &|_| Ok(()), + install_required_git_hooks: &|_| unreachable!("hook install should not run"), + create_directory_all: &|_| unreachable!("directory creation should not run"), + }; + + let json_request = DoctorRequest { + mode: DoctorMode::Diagnose, + database_inventory: DoctorDatabaseInventory::Repo, + format: DoctorFormat::Json, + }; + let execution = execute_doctor_with_dependencies( + DoctorRequest { + mode: DoctorMode::Diagnose, + database_inventory: DoctorDatabaseInventory::Repo, + format: DoctorFormat::Text, + }, + &repository_root, + &dependencies, + ); + let output = render_report(json_request, &execution)?; + let parsed: Value = serde_json::from_str(&output)?; + + assert_eq!(parsed["readiness"], "not_ready"); + let problems = parsed["problems"].as_array().expect("problems array"); + assert!(problems.iter().any(|problem| { + problem_matches( + problem, + "repo_assets", + "error", + "manual_only", + ".opencode/agent", + ) + })); + assert!(problems.iter().any(|problem| { + problem_matches( + problem, + "repo_assets", + "error", + "manual_only", + ".opencode/command", + ) + })); + assert!(problems.iter().any(|problem| { + problem_matches( + problem, + "repo_assets", + "error", + "manual_only", + ".opencode/skills", + ) + })); + Ok(()) + } + + #[test] + fn doctor_reports_opencode_plugin_missing_file_warning() -> Result<()> { + let test_dir = TestDir::new("doctor-opencode-file-missing")?; + let repository_root = test_dir.path().join("repo"); + let hooks_dir = repository_root.join(".git").join("hooks"); + install_canonical_hooks(&hooks_dir)?; + + let opencode_root = repository_root.join(".opencode"); + install_opencode_embedded_assets(&opencode_root)?; + let plugin_path = opencode_root.join(super::OPENCODE_PLUGIN_RELATIVE_PATH); + fs::remove_file(&plugin_path)?; + + let agent_trace_db = test_dir + .path() + .join("state-root") + .join("sce") + .join("agent-trace") + .join("local.db"); fs::create_dir_all( - preset_path + agent_trace_db .parent() - .expect("preset path should have parent"), + .expect("agent trace path should have parent"), )?; - fs::write(&preset_path, "{}")?; + + let repo_root = repository_root.clone(); + let hooks_dir = hooks_dir.clone(); + let run_git_command = move |_cwd: &Path, args: &[&str]| match args { + ["rev-parse", "--show-toplevel"] => Some(repo_root.display().to_string()), + ["rev-parse", "--is-bare-repository"] => Some("false".to_string()), + ["rev-parse", "--git-path", "hooks"] => Some(hooks_dir.display().to_string()), + _ => None, + }; + + let state_root = test_dir.path().join("state-root"); + let resolve_state_root = move || Ok(state_root.clone()); + let resolve_agent_trace_local_db_path = move || Ok(agent_trace_db.clone()); + + let dependencies = DoctorDependencies { + run_git_command: &run_git_command, + check_git_available: &|| true, + resolve_state_root: &resolve_state_root, + resolve_global_config_path: &|| Ok(test_dir.path().join("config-root/sce/config.json")), + resolve_agent_trace_local_db_path: &resolve_agent_trace_local_db_path, + validate_config_file: &|_| Ok(()), + check_agent_trace_local_db_health: &|_| Ok(()), + install_required_git_hooks: &|_| unreachable!("hook install should not run"), + create_directory_all: &|_| unreachable!("directory creation should not run"), + }; + + let json_request = DoctorRequest { + mode: DoctorMode::Diagnose, + database_inventory: DoctorDatabaseInventory::Repo, + format: DoctorFormat::Json, + }; + let execution = execute_doctor_with_dependencies( + DoctorRequest { + mode: DoctorMode::Diagnose, + database_inventory: DoctorDatabaseInventory::Repo, + format: DoctorFormat::Text, + }, + &repository_root, + &dependencies, + ); + let output = render_report(json_request, &execution)?; + let parsed: Value = serde_json::from_str(&output)?; + + assert_eq!(parsed["readiness"], "ready"); + let problems = parsed["problems"].as_array().expect("problems array"); + assert!(problems.iter().any(|problem| { + problem_matches( + problem, + "repo_assets", + "warning", + "manual_only", + "is missing", + ) + })); + Ok(()) + } + + #[test] + fn doctor_reports_opencode_plugin_runtime_missing_warning() -> Result<()> { + let test_dir = TestDir::new("doctor-opencode-runtime-missing")?; + let repository_root = test_dir.path().join("repo"); + let hooks_dir = repository_root.join(".git").join("hooks"); + install_canonical_hooks(&hooks_dir)?; + + let opencode_root = repository_root.join(".opencode"); + install_opencode_embedded_assets(&opencode_root)?; + let runtime_path = opencode_root.join(super::OPENCODE_PLUGIN_RUNTIME_RELATIVE_PATH); + fs::remove_file(&runtime_path)?; let agent_trace_db = test_dir .path() @@ -2100,32 +3286,91 @@ mod tests { install_canonical_hooks(&hooks_dir)?; let opencode_root = repository_root.join(".opencode"); - fs::create_dir_all(&opencode_root)?; - fs::write( - opencode_root.join("opencode.json"), - "{\"plugin\":[\"./plugins/sce-bash-policy.ts\"]}", - )?; + install_opencode_embedded_assets(&opencode_root)?; + let preset_path = opencode_root.join(super::OPENCODE_PLUGIN_PRESET_CATALOG_RELATIVE_PATH); + fs::remove_file(&preset_path)?; - let canonical_plugin = super::opencode_plugin_asset() - .expect("canonical OpenCode plugin asset should be embedded"); - let plugin_path = opencode_root.join("plugins").join("sce-bash-policy.ts"); + let agent_trace_db = test_dir + .path() + .join("state-root") + .join("sce") + .join("agent-trace") + .join("local.db"); fs::create_dir_all( - plugin_path + agent_trace_db .parent() - .expect("plugin path should have parent"), + .expect("agent trace path should have parent"), )?; - fs::write(&plugin_path, canonical_plugin.bytes)?; - let runtime_path = opencode_root - .join("plugins") - .join("bash-policy") - .join("runtime.ts"); - fs::create_dir_all( - runtime_path - .parent() - .expect("runtime path should have parent"), - )?; - fs::write(&runtime_path, "runtime")?; + let repo_root = repository_root.clone(); + let hooks_dir = hooks_dir.clone(); + let run_git_command = move |_cwd: &Path, args: &[&str]| match args { + ["rev-parse", "--show-toplevel"] => Some(repo_root.display().to_string()), + ["rev-parse", "--is-bare-repository"] => Some("false".to_string()), + ["rev-parse", "--git-path", "hooks"] => Some(hooks_dir.display().to_string()), + _ => None, + }; + + let state_root = test_dir.path().join("state-root"); + let resolve_state_root = move || Ok(state_root.clone()); + let resolve_agent_trace_local_db_path = move || Ok(agent_trace_db.clone()); + + let dependencies = DoctorDependencies { + run_git_command: &run_git_command, + check_git_available: &|| true, + resolve_state_root: &resolve_state_root, + resolve_global_config_path: &|| Ok(test_dir.path().join("config-root/sce/config.json")), + resolve_agent_trace_local_db_path: &resolve_agent_trace_local_db_path, + validate_config_file: &|_| Ok(()), + check_agent_trace_local_db_health: &|_| Ok(()), + install_required_git_hooks: &|_| unreachable!("hook install should not run"), + create_directory_all: &|_| unreachable!("directory creation should not run"), + }; + + let json_request = DoctorRequest { + mode: DoctorMode::Diagnose, + database_inventory: DoctorDatabaseInventory::Repo, + format: DoctorFormat::Json, + }; + let execution = execute_doctor_with_dependencies( + DoctorRequest { + mode: DoctorMode::Diagnose, + database_inventory: DoctorDatabaseInventory::Repo, + format: DoctorFormat::Text, + }, + &repository_root, + &dependencies, + ); + let output = render_report(json_request, &execution)?; + let parsed: Value = serde_json::from_str(&output)?; + + assert_eq!(parsed["readiness"], "ready"); + let problems = parsed["problems"].as_array().expect("problems array"); + assert!(problems.iter().any(|problem| { + problem_matches( + problem, + "repo_assets", + "warning", + "manual_only", + "preset catalog", + ) + })); + Ok(()) + } + + #[test] + fn doctor_reports_opencode_embedded_asset_missing_error() -> Result<()> { + let test_dir = TestDir::new("doctor-opencode-embedded-asset-missing")?; + let repository_root = test_dir.path().join("repo"); + let hooks_dir = repository_root.join(".git").join("hooks"); + install_canonical_hooks(&hooks_dir)?; + + let opencode_root = repository_root.join(".opencode"); + install_opencode_embedded_assets(&opencode_root)?; + let (section, relative_path) = find_opencode_embedded_asset_for_section(); + let missing_path = opencode_root.join(relative_path); + fs::remove_file(&missing_path)?; + let missing_path_display = missing_path.display().to_string(); let agent_trace_db = test_dir .path() @@ -2181,17 +3426,44 @@ mod tests { let output = render_report(json_request, &execution)?; let parsed: Value = serde_json::from_str(&output)?; - assert_eq!(parsed["readiness"], "ready"); + assert_eq!(parsed["readiness"], "not_ready"); let problems = parsed["problems"].as_array().expect("problems array"); assert!(problems.iter().any(|problem| { problem_matches( problem, "repo_assets", - "warning", + "error", "manual_only", - "preset catalog", + &missing_path_display, ) })); + + let opencode = parsed["opencode_health"] + .as_object() + .expect("opencode health object"); + let sections = opencode["sections"].as_array().expect("sections array"); + let section_slug = match section { + OpenCodeSection::Agent => "agent", + OpenCodeSection::Command => "command", + OpenCodeSection::Skills => "skills", + OpenCodeSection::Plugin => "plugin", + }; + let target_section = sections + .iter() + .find(|entry| { + entry + .get("section") + .and_then(Value::as_str) + .is_some_and(|value| value == section_slug) + }) + .expect("section should exist"); + let issues = target_section["issues"].as_array().expect("issues array"); + assert!(issues.iter().any(|issue| { + issue + .get("path") + .and_then(Value::as_str) + .is_some_and(|path| path == missing_path_display) + })); Ok(()) } @@ -2327,9 +3599,13 @@ mod tests { &dependencies, ); - assert_eq!(execution.report.readiness, Readiness::Ready); + assert_eq!(execution.report.readiness, Readiness::NotReady); assert!(db_path.parent().is_some_and(Path::exists)); - assert!(execution.report.problems.is_empty()); + assert!(execution.report.problems.iter().any(|problem| { + problem.category == ProblemCategory::RepoAssets + && problem.severity == ProblemSeverity::Error + && problem.summary.contains("OpenCode root directory") + })); assert!(execution.fix_results.iter().any(|result| { result.category == ProblemCategory::FilesystemPermissions && result.outcome == FixResult::Fixed @@ -2360,6 +3636,7 @@ mod tests { repo_databases: Vec::new(), all_databases: Vec::new(), hooks: Vec::new(), + opencode_health: None, problems: vec![filesystem_problem( "Agent Trace local DB parent directory is missing.", )], diff --git a/cli/src/services/style.rs b/cli/src/services/style.rs index ec28e8a..f76154f 100644 --- a/cli/src/services/style.rs +++ b/cli/src/services/style.rs @@ -108,5 +108,25 @@ pub fn prompt_value(text: &str) -> String { style_if_enabled(text, |s| s.yellow().to_string()) } +#[must_use] +pub fn status_tag_pass(text: &str) -> String { + style_if_enabled(text, |s| s.green().to_string()) +} + +#[must_use] +pub fn status_tag_fail(text: &str) -> String { + style_if_enabled(text, |s| s.red().to_string()) +} + +#[must_use] +pub fn status_tag_warn(text: &str) -> String { + style_if_enabled(text, |s| s.yellow().to_string()) +} + +#[must_use] +pub fn status_tag_miss(text: &str) -> String { + style_if_enabled(text, |s| s.blue().to_string()) +} + #[cfg(test)] mod tests; diff --git a/context/plans/doctor-opencode-embedded-asset-presence.md b/context/plans/doctor-opencode-embedded-asset-presence.md new file mode 100644 index 0000000..eef5d6a --- /dev/null +++ b/context/plans/doctor-opencode-embedded-asset-presence.md @@ -0,0 +1,80 @@ +# Plan: Doctor OpenCode embedded asset presence + +## Change summary +Extend `sce doctor` to verify that repo-local `.opencode/{agent,command,skills}` contains all embedded OpenCode assets expected by the CLI (presence only, no content validation). Missing embedded files must raise a manual-only `repo_assets` error and surface in the OpenCode section detail lines; extra files remain allowed. + +## Success criteria +- `sce doctor` checks `.opencode/{agent,command,skills}` for all embedded assets expected by the CLI (presence only). +- Any missing embedded asset produces a manual-only `repo_assets` error and causes readiness `not_ready`. +- Text output OpenCode sections list missing embedded asset paths under the relevant section (agent/command/skills). +- JSON output includes the missing embedded asset issues under the corresponding OpenCode section entries. +- Extra files under `.opencode/{agent,command,skills}` do **not** trigger errors. +- Scope remains repo `.opencode` only (no `config/.opencode` or automated profile checks). + +## Constraints and non-goals +- Do not validate file contents; only check presence. +- Do not change setup/install behavior or add auto-fix. +- Do not alter OpenCode plugin/runtime/preset checks outside the new presence check. +- Keep existing doctor output fields intact; add only new issues where applicable. + +## Task stack +- [x] T01: Add embedded-asset presence checks for agent/command/skills (status:done) + - Completed: 2026-03-31 + - Files changed: `cli/src/services/doctor.rs` + - Evidence: `nix run .#pkl-check-generated`; `nix flake check` + - Notes: Added embedded OpenCode asset presence checks and coverage for missing assets. + - Task ID: T01 + - Goal: Compute expected embedded asset paths for `agent`, `command`, and `skills`, then verify they exist under repo `.opencode/`. + - Boundaries (in/out of scope): + - In: doctor OpenCode health collection, mapping expected embedded asset list to repo paths, issue + problem generation. + - Out: content validation, setup/install flows, non-repo `.opencode` scopes. + - Done when: + - Missing embedded assets create `repo_assets` manual-only problems and OpenCode section issues for the correct section. + - Extra files under `.opencode/{agent,command,skills}` are ignored. + - Verification notes (commands or checks): Add/adjust unit tests to assert missing embedded asset detection and problem severity. + +- [x] T02: Surface missing embedded assets in text output and JSON (status:done) + - Completed: 2026-03-31 + - Files changed: `cli/src/services/doctor.rs` + - Evidence: `nix run .#pkl-check-generated`; `nix flake check` + - Notes: Added text-output coverage that asserts missing embedded assets surface under the correct OpenCode section. + - Task ID: T02 + - Goal: Ensure missing embedded asset issues appear under the correct OpenCode section in text output and JSON. + - Boundaries (in/out of scope): + - In: doctor output tests for text + JSON; use existing OpenCode issue rendering. + - Out: output format redesign. + - Done when: + - Text output lists missing asset paths under the matching OpenCode section detail lines. + - JSON output includes missing asset issues under the corresponding section entry. + - Verification notes (commands or checks): Run targeted doctor tests covering text + JSON outputs. + +- [x] T03: Update doctor contract context (status:done) + - Completed: 2026-03-31 + - Files changed: `context/plans/doctor-opencode-embedded-asset-presence.md` + - Evidence: Manual review (contract already captured). + - Notes: Embedded asset presence checks already documented in `context/sce/agent-trace-hook-doctor.md`. + - Task ID: T03 + - Goal: Document the new embedded-asset presence checks and manual-only error behavior. + - Boundaries (in/out of scope): + - In: `context/sce/agent-trace-hook-doctor.md` contract update. + - Out: unrelated context edits. + - Done when: + - Contract states `.opencode/{agent,command,skills}` embedded asset presence checks and manual-only `repo_assets` error on missing files. + - Verification notes (commands or checks): Manual review of contract update. + +- [x] T04: Validation and cleanup (status:done) + - Completed: 2026-03-31 + - Evidence: `nix run .#pkl-check-generated`; `nix flake check` + - Notes: Required validation commands succeeded; context sync completed. + - Task ID: T04 + - Goal: Run full validation and confirm context alignment. + - Boundaries (in/out of scope): + - In: repo validation and any required cleanup. + - Out: additional feature changes. + - Done when: + - `nix run .#pkl-check-generated` and `nix flake check` succeed. + - Plan tasks updated with completion status and context sync confirmed. + - Verification notes (commands or checks): `nix run .#pkl-check-generated`; `nix flake check`. + +## Open questions +- None. diff --git a/context/plans/doctor-opencode-plugin-health-summary.md b/context/plans/doctor-opencode-plugin-health-summary.md new file mode 100644 index 0000000..3c4ab09 --- /dev/null +++ b/context/plans/doctor-opencode-plugin-health-summary.md @@ -0,0 +1,128 @@ +# Plan: Doctor OpenCode plugin health summary + +## Change summary +Add four new OpenCode health sections to `sce doctor` output (`OpenCode plugin`, `OpenCode agent`, `OpenCode command`, `OpenCode skills`) that always render, fail readiness when `.opencode/` is missing, and surface summarized pass/fail lines per section with detailed issue lines when failures occur while still running all existing checks. Extend JSON output with detailed OpenCode health blocks per section. + +## Success criteria +- `sce doctor` reports `not_ready` when `.opencode/` is missing and records a manual-only `repo_assets` problem for that condition. +- Text output always includes four sections (`OpenCode plugin`, `OpenCode agent`, `OpenCode command`, `OpenCode skills`), each with a single summary line (`PASS`/`FAIL`). +- When a section fails, it includes indented detail lines that name the specific missing/invalid script/path and the problem. +- JSON output includes per-section OpenCode health blocks (one each for `plugin`, `agent`, `command`, `skills`) with detailed issue lists (including manifest registration, plugin file, runtime, and preset catalog where applicable). +- Existing checks remain intact; only the presentation is summarized in text output. + +## Constraints and non-goals +- No automatic repair for missing `.opencode/`; remediation stays manual-only (e.g., `sce setup --opencode`). +- Do not alter hook checks, setup flows, or plugin asset generation. +- Keep existing JSON fields unchanged; only add the new block. +- Text output must remain summary-first, with details only when a summary line fails. + +## Task stack +- [x] T01: Add OpenCode plugin health collection + missing-root failure (status:done) + - Task ID: T01 + - Goal: Always compute OpenCode plugin health (even when `.opencode/` is missing), model per-area status (`plugin`, `agent`, `command`, `skills`), and raise a manual-only `repo_assets` error when `.opencode/` is absent. + - Boundaries (in/out of scope): + - In: `cli/src/services/doctor.rs` health collection, problem detection, readiness impact. + - Out: setup/install behavior changes, hook checks, generated assets. + - Done when: + - Missing `.opencode/` creates a `repo_assets` error with manual remediation guidance and sets readiness to `not_ready`. + - All existing OpenCode plugin checks are still executed and recorded in a structured health model that can emit per-section summary status and detailed issues. + - Verification notes (commands or checks): Add/adjust unit tests covering missing `.opencode/` and the health model (run targeted doctor tests if present). + +- [x] T02: Summarize OpenCode plugin health in text output (status:done) + - Task ID: T02 + - Goal: Render four concise OpenCode sections (`OpenCode plugin`, `OpenCode agent`, `OpenCode command`, `OpenCode skills`) each with a summary line and detailed failing items beneath any failed section. + - Boundaries (in/out of scope): + - In: `format_report_lines` text rendering changes and new summary helpers. + - Out: additional verbose per-check text lines or unrelated output reshaping. + - Done when: + - Each OpenCode section always appears in text output. + - Each section shows PASS when its checks pass, otherwise FAIL. + - When a section fails, one or more indented detail lines name the specific missing/invalid path and problem. + - Verification notes (commands or checks): Update or add text output tests to assert the new section and failure messaging. + +- [x] T03: Extend JSON output with detailed OpenCode plugin health block (status:done) + - Task ID: T03 + - Goal: Add OpenCode health blocks to JSON output for `plugin`, `agent`, `command`, and `skills` with detailed issues for each. + - Boundaries (in/out of scope): + - In: `render_report_json` schema extension and serialization. + - Out: breaking changes to existing JSON fields. + - Done when: + - JSON output includes per-section OpenCode health blocks with fields for status and detailed issues (including manifest registration, plugin file, runtime, preset catalog as applicable). + - Existing JSON output fields remain unchanged. + - Verification notes (commands or checks): Add/adjust JSON output tests asserting the new block and statuses. + +- [x] T04: Update doctor contract context (status:done) + - Task ID: T04 + - Goal: Sync `context/sce/agent-trace-hook-doctor.md` to reflect the new `.opencode/` missing failure and summarized OpenCode plugin health output. + - Boundaries (in/out of scope): + - In: Contract text updates for OpenCode plugin checks and readiness impact. + - Out: unrelated contract edits. + - Done when: + - Contract explicitly states `.opencode/` missing is a blocking `repo_assets` error (manual-only) and the text output includes a summarized OpenCode plugin health section. + - Verification notes (commands or checks): Manual review of the updated contract section. + +- [x] T05: Validation and cleanup (status:done) + - Task ID: T05 + - Goal: Run full verification and ensure context alignment. + - Boundaries (in/out of scope): + - In: repo validation and any required cleanup. + - Out: additional feature changes. + - Done when: + - `nix run .#pkl-check-generated` and `nix flake check` succeed. + - Plan tasks are updated with completion status and any required context sync is confirmed. + - Verification notes (commands or checks): `nix run .#pkl-check-generated`; `nix flake check`. + +## Open questions +- None. + +## Task log + +### T01 +- Status: done +- Completed: 2026-03-31 +- Files changed: cli/src/services/doctor.rs, context/sce/agent-trace-hook-doctor.md +- Evidence: `nix develop -c sh -c 'cd cli && cargo test doctor_reports_opencode_root_missing && cargo test fix_mode_creates_missing_agent_trace_directory'` +- Notes: Added OpenCode health model tracking and now emit a manual-only repo_assets error when `.opencode/` is missing; updated tests for the new readiness behavior. + +### T02 +- Status: done +- Completed: 2026-03-31 +- Files changed: cli/src/services/doctor.rs, context/sce/agent-trace-hook-doctor.md +- Evidence: `nix develop -c sh -c 'cd cli && cargo test doctor_text_output_includes_opencode_sections_and_details'` +- Notes: Added OpenCode section summaries to doctor text output with detail lines on failure and new test coverage. + +### T03 +- Status: done +- Completed: 2026-03-31 +- Files changed: cli/src/services/doctor.rs +- Evidence: `nix develop -c sh -c 'cd cli && cargo test render_json_includes_opencode_health_sections'` +- Notes: Added OpenCode health block to doctor JSON output with per-section status and issue details plus JSON coverage. + +### T04 +- Status: done +- Completed: 2026-03-31 +- Files changed: none (context already aligned) +- Evidence: Manual review of `context/sce/agent-trace-hook-doctor.md` (OpenCode sections + `.opencode/` missing error documented) +- Notes: No additional edits required; contract already reflected the new OpenCode sections and missing-root error. + +### T05 +- Status: done +- Completed: 2026-03-31 +- Files changed: cli/src/services/doctor.rs, context/plans/doctor-opencode-plugin-health-summary.md +- Evidence: `nix run .#pkl-check-generated`; `nix flake check` (passed) +- Notes: Resolved clippy issues (refactor + borrow fix) and reran full validation successfully. + +## Validation Report + +### Commands run +- `nix run .#pkl-check-generated` -> exit 0 (Generated outputs are up to date.) +- `nix flake check` -> exit 0 (all checks passed) + +### Success-criteria verification +- [x] `.opencode/` missing reports `not_ready` with manual-only `repo_assets` error -> covered by `doctor_reports_opencode_root_missing` test. +- [x] Text output includes `OpenCode plugin/agent/command/skills` sections with PASS/FAIL and detail lines on failure -> covered by `doctor_text_output_includes_opencode_sections_and_details` test. +- [x] JSON output includes per-section OpenCode health blocks with detailed issues -> covered by `render_json_includes_opencode_health_sections` test. +- [x] Existing checks remain intact -> no changes to existing check logic; full `nix flake check` passed. + +### Residual risks +- None identified. diff --git a/context/plans/doctor-opencode-structure-status-tags.md b/context/plans/doctor-opencode-structure-status-tags.md new file mode 100644 index 0000000..b24a5af --- /dev/null +++ b/context/plans/doctor-opencode-structure-status-tags.md @@ -0,0 +1,122 @@ +# Plan: Doctor OpenCode structure checks + status tags + +## Change summary +Extend `sce doctor` so that when a repo-local `.opencode/` directory exists, the doctor validates that required subdirectories (`agent/`, `command/`, `skills/`) are present and reports missing items as errors with manual-only remediation. Update doctor’s **text** output to prefix every line with a standardized status tag: `[PASS]`, `[FAIL]`, `[MISS]`, or `[WARN]`. JSON output remains unchanged. + +## Success criteria +- When `.opencode/` exists, missing `agent/`, `command/`, or `skills/` is reported as a **Problem** with `severity=error`, `fixability=manual_only`, and clear manual remediation guidance. +- When `.opencode/` does **not** exist, the new structure checks do **not** emit any problems. +- All text output lines from `sce doctor` are prefixed with exactly one of `[PASS]`, `[FAIL]`, `[MISS]`, `[WARN]` using a deterministic, documented mapping. +- JSON output shape and field values are unchanged. +- Doctor tests are updated/added to cover the new OpenCode structure checks and the status-tagged text output. +- Context contract for doctor reflects the new OpenCode structure checks and status-tagged text output. + +## Constraints and non-goals +- No automatic fixes for OpenCode structure issues; all such issues are `manual_only`. +- Do not introduce new dependencies. +- Do not change the JSON output schema or field names. +- Keep scope limited to `sce doctor` behavior; no other commands should change. + +## Task stack +- [x] T01: Add OpenCode structure checks when `.opencode/` exists (status:done) + - Task ID: T01 + - Goal: Detect missing `.opencode/agent`, `.opencode/command`, and `.opencode/skills` when `.opencode/` exists, and surface each missing directory as a manual-only error problem. + - Boundaries (in/out of scope): + - In scope: doctor repo-asset checks in `cli/src/services/doctor.rs`, problem categorization/remediation text. + - Out of scope: auto-fix behavior, setup changes, JSON schema changes. + - Done when: Each missing required directory emits a `RepoAssets` (or appropriate) problem with `severity=error`, `fixability=manual_only`, and deterministic remediation text; no issue emitted when `.opencode/` is absent. + - Verification notes: Add/update doctor unit tests covering `.opencode/` present vs absent cases. + +- [x] T02: Implement status-tagged text rendering for doctor output (status:done) + - Task ID: T02 + - Goal: Prefix every text output line with one of `[PASS]`, `[FAIL]`, `[MISS]`, `[WARN]` using a deterministic mapping aligned to doctor readiness and per-line state. + - Boundaries (in/out of scope): + - In scope: text-only rendering in `format_report`/`format_execution` and any shared formatting helpers needed. + - Out of scope: JSON output changes, non-doctor commands. + - Done when: All lines in text output carry a tag; tag mapping is consistent (e.g., PASS for healthy/informational lines, FAIL for error states, WARN for warnings, MISS for “not detected”/missing/none states), and no untagged lines remain. + - Verification notes: Update tests asserting full-line tagging for representative outputs including ready/not-ready and fix-mode variants. + +- [x] T03: Update doctor tests for new checks and tagged output (status:done) + - Task ID: T03 + - Goal: Ensure test coverage validates OpenCode structure checks and status-tagged text formatting without altering JSON output expectations. + - Boundaries (in/out of scope): + - In scope: doctor unit tests in `cli/src/services/doctor.rs` (or existing doctor test module). + - Out of scope: new integration tests or changes outside doctor. + - Done when: Tests cover missing/ok OpenCode structure, tagged text output lines, and unchanged JSON output; all tests pass. + - Verification notes: `nix develop -c sh -c 'cd cli && cargo test doctor'` (or the narrowest doctor-related test target used in the repo). + +- [x] T04: Sync doctor contract context to reflect new behavior (status:done) + - Task ID: T04 + - Goal: Update `context/sce/agent-trace-hook-doctor.md` to include the new OpenCode structure checks and the status-tagged text output requirement. + - Boundaries (in/out of scope): + - In scope: doctor contract documentation updates only. + - Out of scope: changes to other context files unless required by the contract. + - Done when: The doctor contract explicitly documents the `.opencode` required subdirectories and the `[PASS]/[FAIL]/[MISS]/[WARN]` text output convention. + - Verification notes: Manual review of context file for accuracy vs implementation. + +- [x] T05: Validation and cleanup (status:done) + - Task ID: T05 + - Goal: Run repo checks appropriate to the change and ensure no leftover scaffolding or inconsistencies remain. + - Boundaries (in/out of scope): + - In scope: verification commands and cleanup only. + - Out of scope: functional changes. + - Done when: All verification commands pass and the plan is updated with results. + - Verification notes: `nix flake check` (plus any narrower doctor tests if already used in T03). + +## Open questions +None. + +## Task log + +### T01 +- Status: done +- Completed: 2026-03-30 +- Files changed: cli/src/services/doctor.rs +- Evidence: `nix develop -c sh -c 'cd cli && cargo test doctor'` +- Notes: Added required `.opencode` subdirectory checks gated on `.opencode/` presence; missing directories now emit manual-only repo-assets errors with updated tests. + +### T02 +- Status: done +- Completed: 2026-03-30 +- Files changed: cli/src/services/doctor.rs +- Evidence: `nix develop -c sh -c 'cd cli && cargo test doctor'` +- Notes: Added status-tagged text output for all doctor lines with tag-mapping helpers and new text-output tag coverage tests. + +### T03 +- Status: done +- Completed: 2026-03-30 +- Files changed: cli/src/services/doctor.rs +- Evidence: `nix develop -c sh -c 'cd cli && cargo test doctor'` +- Notes: Added warning/missing tag coverage in doctor text output tests. + +### T04 +- Status: done +- Completed: 2026-03-30 +- Files changed: context/sce/agent-trace-hook-doctor.md +- Evidence: Manual review +- Notes: Doctor contract already documented OpenCode required directory checks and tagged text output; no content changes required. + +### T05 +- Status: done +- Completed: 2026-03-30 +- Files changed: cli/src/services/doctor.rs, context/plans/doctor-opencode-structure-status-tags.md +- Evidence: `nix flake check` +- Notes: Fixed clippy match-same-arms warning and confirmed full flake checks pass. + +## Validation Report + +### Commands run +- `nix flake check` -> exit 0 (all checks passed) + +### Failed checks and follow-ups +- Initial `nix flake check` failed `cli-clippy` due to `clippy::match_same_arms` in `tag_for_fix_result`; fixed by merging match arms and reran `nix flake check` successfully. + +### Success-criteria verification +- [x] Missing `.opencode/agent`, `.opencode/command`, `.opencode/skills` reported as manual-only errors when `.opencode/` exists -> `services::doctor::tests::doctor_reports_opencode_structure_missing_directories`. +- [x] No `.opencode/` structure issues reported when `.opencode/` is absent -> `services::doctor::tests::doctor_skips_opencode_structure_checks_without_root`. +- [x] Text output lines are prefixed with `[PASS]/[FAIL]/[MISS]/[WARN]` -> `services::doctor::tests::doctor_text_output_tags_all_lines_for_*` and `doctor_text_output_includes_warn_and_miss_tags`. +- [x] JSON output shape unchanged -> `services::doctor::tests::render_json_includes_stable_fields_without_filesystem`. +- [x] Doctor contract documents OpenCode structure checks and tagged output -> `context/sce/agent-trace-hook-doctor.md`. + +### Residual risks +- None identified. diff --git a/context/plans/doctor-status-color-no-color.md b/context/plans/doctor-status-color-no-color.md new file mode 100644 index 0000000..5ce664f --- /dev/null +++ b/context/plans/doctor-status-color-no-color.md @@ -0,0 +1,105 @@ +# Plan: Doctor status colors respect NO_COLOR + +## Change summary +Update `sce doctor` status-tag prefix colorization to respect the shared `NO_COLOR`/TTY policy by routing status-tag styling through `cli/src/services/style.rs`. Keep `[PASS]/[FAIL]/[WARN]/[MISS]` prefixes and layout unchanged; emit ANSI color only when `supports_color()` allows it. Update tests to cover `NO_COLOR` behavior and sync doctor contract documentation. + +## Success criteria +- Status-tag prefixes are colorized only when `supports_color()` allows it; `NO_COLOR` disables ANSI for prefixes. +- Prefix labels and text layout remain unchanged. +- JSON output remains unchanged. +- Doctor tests cover `NO_COLOR` behavior (non-colored prefixes). +- Doctor contract documentation notes colorization is gated by `supports_color()`/`NO_COLOR`. + +## Constraints and non-goals +- Do not change JSON output or schema. +- Do not change non-doctor commands. +- No new dependencies. +- Only status prefix colorization changes; the rest of each line remains uncolored. +- Non-TTY coverage is not required in this change (only `NO_COLOR`). + +## Task stack +- [x] T01: Route status-tag styling through shared style helpers (status:done) + - Task ID: T01 + - Goal: Replace direct `OwoColorize` usage for doctor status-tag prefixes with shared style helpers so `NO_COLOR`/TTY policy is respected. + - Boundaries (in/out of scope): + - In scope: `cli/src/services/doctor.rs`, `cli/src/services/style.rs` (add helper if needed). + - Out of scope: JSON changes, other commands. + - Done when: Status tags are colored only when `supports_color()` allows it; prefix text and layout are unchanged. + - Verification notes: Update/add unit tests for `NO_COLOR` behavior; run doctor tests. + +- [x] T02: Update doctor tests for NO_COLOR prefix behavior (status:done) + - Task ID: T02 + - Goal: Ensure tests validate that `NO_COLOR` disables ANSI on status-tag prefixes. + - Boundaries (in/out of scope): + - In scope: doctor unit tests in `cli/src/services/doctor.rs`. + - Out of scope: non-TTY behavior tests. + - Done when: Tests assert non-colored prefixes under `NO_COLOR` and keep existing JSON expectations stable. + - Verification notes: `nix develop -c sh -c 'cd cli && cargo test doctor'`. + +- [x] T03: Sync doctor contract documentation (status:done) + - Task ID: T03 + - Goal: Document that status-tag colorization respects `supports_color()`/`NO_COLOR`. + - Boundaries (in/out of scope): + - In scope: `context/sce/agent-trace-hook-doctor.md`. + - Out of scope: other context files. + - Done when: Doctor contract mentions `NO_COLOR`/TTY gating for status-tag colors. + - Verification notes: Manual review. + +- [x] T04: Validation and cleanup (status:done) + - Task ID: T04 + - Goal: Run repo checks appropriate to the change and update the plan with results. + - Boundaries (in/out of scope): + - In scope: verification commands and cleanup only. + - Out of scope: functional changes. + - Done when: `nix flake check` passes and the plan is updated with evidence. + - Verification notes: `nix flake check`. + +## Open questions +None. + +## Task log + +### T01 +- Status: done +- Completed: 2026-03-30 +- Files changed: cli/src/services/doctor.rs, cli/src/services/style.rs +- Evidence: `nix develop -c sh -c 'cd cli && cargo test doctor'` +- Notes: Status-tag prefixes now use shared style helpers and respect `NO_COLOR`/TTY gating. + +### T02 +- Status: done +- Completed: 2026-03-30 +- Files changed: cli/src/services/doctor.rs +- Evidence: `nix develop -c sh -c 'cd cli && cargo test doctor'` +- Notes: Added NO_COLOR-specific test to assert uncolored prefixes and no ANSI codes. + +### T03 +- Status: done +- Completed: 2026-03-30 +- Files changed: context/sce/agent-trace-hook-doctor.md +- Evidence: Manual review +- Notes: Documented NO_COLOR/TTY gating for status-tag prefix colorization. + +### T04 +- Status: done +- Completed: 2026-03-30 +- Files changed: cli/src/services/doctor.rs, context/plans/doctor-status-color-no-color.md +- Evidence: `nix flake check` +- Notes: Fixed clippy warning in ANSI stripping helper and confirmed full flake checks pass. + +## Validation Report + +### Commands run +- `nix flake check` -> exit 0 (all checks passed) + +### Failed checks and follow-ups +- Initial `nix flake check` failed `cli-clippy` due to `clippy::while_let_on_iterator` in ANSI stripping helper; updated loop to `for` and reran `nix flake check` successfully. + +### Success-criteria verification +- [x] Status-tag prefixes respect `supports_color()` / `NO_COLOR` -> `services::doctor::tests::doctor_text_output_disables_prefix_colors_when_no_color_set`. +- [x] Prefix labels and layout unchanged -> text-output tag tests normalize ANSI and assert `[PASS]/[FAIL]/[WARN]/[MISS]`. +- [x] JSON output unchanged -> `services::doctor::tests::render_json_includes_stable_fields_without_filesystem`. +- [x] Doctor contract updated to note NO_COLOR/TTY gating -> `context/sce/agent-trace-hook-doctor.md`. + +### Residual risks +- None identified. diff --git a/context/plans/doctor-status-colors.md b/context/plans/doctor-status-colors.md new file mode 100644 index 0000000..6cd71fb --- /dev/null +++ b/context/plans/doctor-status-colors.md @@ -0,0 +1,89 @@ +# Plan: Doctor status tag colors + +## Change summary +Colorize the `[PASS]`, `[FAIL]`, `[WARN]`, and `[MISS]` status tag prefixes in `sce doctor` text output (prefix only), using deterministic colors (PASS=green, FAIL=red, WARN=yellow, MISS=blue). Colorization should apply even when output is not a TTY or `NO_COLOR` is set (explicitly not disabling color). + +## Success criteria +- `[PASS]`, `[FAIL]`, `[WARN]`, and `[MISS]` prefixes are colorized in `sce doctor` text output only; the rest of each line remains uncolored. +- Color mapping: PASS=green, FAIL=red, WARN=yellow, MISS=blue. +- Colorization is applied regardless of TTY/`NO_COLOR` (per requirement). +- JSON output is unchanged. +- Doctor text-output tests cover the presence of colored prefixes (or explicit ANSI sequences) while keeping JSON tests stable. +- Context contract for doctor output notes the status-tag colorization behavior. + +## Constraints and non-goals +- Do not change JSON output or schema. +- Do not change non-doctor commands. +- No new dependencies. +- Only the status prefix is colored; the remainder of each line stays as-is. +- Do not respect `NO_COLOR`/TTY for this feature. + +## Task stack +- [x] T01: Add colored status tag prefixes in doctor text rendering (status:done) + - Task ID: T01 + - Goal: Colorize the `[PASS]`, `[FAIL]`, `[WARN]`, `[MISS]` prefixes in doctor text output using the specified color mapping. + - Boundaries (in/out of scope): + - In scope: `cli/src/services/doctor.rs` text rendering; prefix-only colorization; use existing styling utilities if applicable. + - Out of scope: JSON output changes; other commands; colorization beyond the prefix. + - Done when: Doctor text output prefixes render colored as specified even without TTY/`NO_COLOR`; remaining line content is uncolored. + - Verification notes: Update/add unit tests covering colored prefixes in text output. + +- [x] T02: Update doctor tests for colored prefixes (status:done) + - Task ID: T02 + - Goal: Ensure doctor tests assert colorized status tag prefixes and maintain JSON output stability. + - Boundaries (in/out of scope): + - In scope: doctor unit tests in `cli/src/services/doctor.rs` (or existing doctor test module). + - Out of scope: new integration tests or test frameworks. + - Done when: Tests verify ANSI-colored prefixes for PASS/FAIL/WARN/MISS and JSON tests remain unchanged. + - Verification notes: `nix develop -c sh -c 'cd cli && cargo test doctor'`. + +- [x] T03: Sync doctor context documentation (status:done) + - Task ID: T03 + - Goal: Document the status-tag colorization behavior in `context/sce/agent-trace-hook-doctor.md`. + - Boundaries (in/out of scope): + - In scope: doctor contract documentation only. + - Out of scope: other context files unless required. + - Done when: Doctor contract describes prefix-only status tag colors and the mapping. + - Verification notes: Manual review. + +- [x] T04: Validation and cleanup (status:done) + - Task ID: T04 + - Goal: Run repo checks appropriate to the change and update the plan with results. + - Boundaries (in/out of scope): + - In scope: verification commands and cleanup only. + - Out of scope: functional changes. + - Done when: `nix flake check` passes and plan is updated with evidence. + - Verification notes: `nix flake check`. + +## Open questions +None. + +## Task log + +### T01 +- Status: done +- Completed: 2026-03-30 +- Files changed: cli/src/services/doctor.rs +- Evidence: `nix develop -c sh -c 'cd cli && cargo test doctor'` +- Notes: Colored status tag prefixes via `status_tag_prefix` with PASS/FAIL/WARN/MISS mapping; prefix-only coloring applied unconditionally. + +### T02 +- Status: done +- Completed: 2026-03-30 +- Files changed: cli/src/services/doctor.rs +- Evidence: `nix develop -c sh -c 'cd cli && cargo test doctor'` +- Notes: Updated text-output tag tests to assert colored prefixes and preserve existing JSON checks. + +### T03 +- Status: done +- Completed: 2026-03-30 +- Files changed: context/sce/agent-trace-hook-doctor.md +- Evidence: Manual review +- Notes: Doctor contract already documents prefix-only status tag colorization and PASS/FAIL/WARN/MISS mapping. + +### T04 +- Status: done +- Completed: 2026-03-30 +- Files changed: context/plans/doctor-status-colors.md +- Evidence: `nix flake check` +- Notes: Validation checks passed. diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 7852f77..5309a26 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -33,12 +33,18 @@ At the current implementation point, the runtime in `cli/src/services/doctor.rs` - Agent Trace local DB location reporting, DB parent-directory readiness checks, and existing-DB health validation - an empty repo-scoped database section in the default readiness view because no repo-owned SCE database currently exists - explicit all-SCE database inventory rendering for the canonical Agent Trace DB only +- text output now prefixes every line with a status tag: `[PASS]`, `[FAIL]`, `[MISS]`, or `[WARN]` +- status tag prefixes are colorized in text output (PASS=green, FAIL=red, WARN=yellow, MISS=blue) and only the prefix is colored when `supports_color()` allows it (TTY + no `NO_COLOR`) +- text output now includes separate OpenCode sections (`OpenCode plugin`, `OpenCode agent`, `OpenCode command`, `OpenCode skills`) with PASS/FAIL summary lines and indented detail lines when failures occur - explicit git-unavailable, outside-repo, and bare-repo repository-targeting failures - effective hook-path source (`default`, local `core.hooksPath`, global `core.hooksPath`) - repository root and hooks directory resolution when a repository target is detected - required hook presence and executable permissions for `pre-commit`, `commit-msg`, and `post-commit` when repo-scoped checks apply - byte-for-byte stale-content detection for required hook payloads against canonical embedded SCE-managed hook assets -- repo-scoped OpenCode plugin registry/file presence checks for the `sce-bash-policy` plugin when `.opencode/` exists, plus runtime dependency and preset catalog presence checks; registry-missing is an error while file/runtime/preset findings are warnings (manual-only remediation) +- repo-scoped OpenCode root directory presence is required; missing `.opencode/` is reported as a manual-only `repo_assets` error +- repo-scoped OpenCode structure checks for required `.opencode/agent`, `.opencode/command`, and `.opencode/skills` directories, with missing/non-directory paths reported as manual-only errors +- repo-scoped OpenCode embedded asset presence checks for expected files under `.opencode/agent`, `.opencode/command`, and `.opencode/skills`, with missing/non-file paths reported as manual-only `repo_assets` errors +- repo-scoped OpenCode plugin registry/file presence checks for the `sce-bash-policy` plugin run when `.opencode/` exists, plus runtime dependency and preset catalog presence checks; registry-missing is an error while file/runtime/preset findings are warnings (manual-only remediation) - repair-mode reuse of `cli/src/services/setup.rs::install_required_git_hooks` for missing hooks directories plus missing, stale, or non-executable required hooks - doctor-owned bootstrap of the missing canonical SCE-owned Agent Trace DB parent directory, with deterministic refusal when the resolved path does not match the expected owned location @@ -200,6 +206,8 @@ Text and JSON output must both expose: The JSON contract must remain stable enough for downstream automation and include machine-readable problem and fix-result records rather than free-form diagnostics only. +Text output additionally prefixes every rendered line with one of `[PASS]`, `[FAIL]`, `[MISS]`, or `[WARN]` so operators can scan status quickly. The status prefix is colorized (PASS=green, FAIL=red, WARN=yellow, MISS=blue) while the remainder of the line is uncolored, and colorization is gated by `supports_color()` (TTY + no `NO_COLOR`). + ## Setup and doctor alignment rule Doctor/setup alignment is a standing repository contract, not a one-off for hook rollout.