diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d231f9f..a4f365d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -44,6 +44,9 @@ opentelemetry = { version = "0.28", features = ["trace"] } opentelemetry_sdk = { version = "0.28", features = ["rt-tokio"] } opentelemetry-otlp = { version = "0.28", features = ["grpc-tonic", "http-proto", "trace"] } +[build-dependencies] +sha2 = "0.10" + [lints] workspace = true diff --git a/cli/build.rs b/cli/build.rs index 714da75..b863e3b 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -1,3 +1,4 @@ +use sha2::{Digest, Sha256}; use std::{ env, fmt::Write, @@ -114,12 +115,15 @@ fn generate_embedded_asset_manifest() -> io::Result<()> { for file in &files { println!("cargo:rerun-if-changed={}", file.absolute_path.display()); + let sha256 = compute_sha256(&file.absolute_path)?; + let sha256_literal = format_sha256_literal(&sha256); writeln!( output, - " EmbeddedAsset {{ relative_path: \"{}\", bytes: include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"{}{}\")) }},", + " EmbeddedAsset {{ relative_path: \"{}\", bytes: include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"{}{}\")), sha256: {} }},", escape_for_rust_string(&file.relative_path), target.include_prefix, escape_for_rust_string(&file.relative_path), + sha256_literal, ) .expect("writing to String buffer should never fail"); } @@ -187,6 +191,24 @@ fn escape_for_rust_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } +fn compute_sha256(path: &Path) -> io::Result<[u8; 32]> { + let bytes = fs::read(path)?; + let digest = Sha256::digest(&bytes); + Ok(digest.into()) +} + +fn format_sha256_literal(hash: &[u8; 32]) -> String { + let mut output = String::from("["); + for (index, byte) in hash.iter().enumerate() { + if index > 0 { + output.push_str(", "); + } + write!(&mut output, "0x{byte:02x}").expect("writing to String buffer should never fail"); + } + output.push(']'); + output +} + fn invalid_data(error: &E) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, error.to_string()) } diff --git a/cli/src/services/doctor.rs b/cli/src/services/doctor.rs index 8ece115..541fdae 100644 --- a/cli/src/services/doctor.rs +++ b/cli/src/services/doctor.rs @@ -4,6 +4,7 @@ use std::process::Command; use anyhow::{Context, Result}; use serde_json::json; +use sha2::{Digest, Sha256}; use crate::services::default_paths::{ hook_dir, opencode_asset, resolve_sce_default_locations, InstallTargetPaths, RepoPaths, @@ -11,7 +12,7 @@ use crate::services::default_paths::{ use crate::services::output_format::OutputFormat; use crate::services::setup::{ install_required_git_hooks, iter_embedded_assets_for_setup_target, iter_required_hook_assets, - RequiredHookInstallStatus, RequiredHooksInstallOutcome, SetupTarget, + EmbeddedAsset, RequiredHookInstallStatus, RequiredHooksInstallOutcome, SetupTarget, }; use crate::services::style::{heading, label, supports_color, value, OwoColorize}; @@ -111,7 +112,15 @@ struct IntegrationGroupHealth { struct IntegrationChildHealth { relative_path: String, path: PathBuf, - present: bool, + content_state: IntegrationContentState, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum IntegrationContentState { + Match, + Missing, + Mismatch, + ReadFailed(String), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -158,9 +167,11 @@ enum ProblemKind { HookNotExecutable, HookContentStale, OpenCodeIntegrationFilesMissing, + OpenCodeIntegrationContentMismatch, OpenCodePluginRegistryInvalid, OpenCodeAssetMissingOrInvalid, HookReadFailed, + OpenCodeAssetReadFailed, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -758,12 +769,25 @@ fn inspect_opencode_integration_health( repository_root: &Path, integration_groups: &[IntegrationGroupHealth], problems: &mut Vec, +) { + push_opencode_integration_missing_problems(integration_groups, problems); + push_opencode_integration_mismatch_problems(integration_groups, problems); + push_opencode_integration_read_fail_problems(integration_groups, problems); + inspect_opencode_plugin_registry_health(repository_root, problems); + + let install_targets = InstallTargetPaths::new(repository_root); + inspect_opencode_plugin_dependency_health(&install_targets, problems); +} + +fn push_opencode_integration_missing_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, ) { for group in integration_groups { let missing_children = group .children .iter() - .filter(|child| !child.present) + .filter(|child| matches!(child.content_state, IntegrationContentState::Missing)) .collect::>(); if missing_children.is_empty() { continue; @@ -790,42 +814,111 @@ fn inspect_opencode_integration_health( next_action: "manual_steps", }); } +} - let repo_paths = RepoPaths::new(repository_root); - let install_targets = InstallTargetPaths::new(repository_root); +fn push_opencode_integration_mismatch_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let mismatched_children = group + .children + .iter() + .filter(|child| matches!(child.content_state, IntegrationContentState::Mismatch)) + .collect::>(); + if mismatched_children.is_empty() { + continue; + } - let manifest_path = repo_paths.opencode_manifest_file(); - let manifest_metadata = fs::metadata(&manifest_path).ok(); - let manifest_is_file = manifest_metadata - .as_ref() - .is_some_and(std::fs::Metadata::is_file); - if !manifest_is_file { - let summary = if manifest_metadata.is_some() { - format!( - "OpenCode plugin registry path '{}' is not a file.", - manifest_path.display() - ) - } else { - format!( - "OpenCode plugin registry file '{}' is missing.", - manifest_path.display() - ) - }; + let mismatched_paths = mismatched_children + .iter() + .map(|child| format!("'{}'", child.path.display())) + .collect::>() + .join(", "); problems.push(DoctorProblem { - kind: ProblemKind::OpenCodePluginRegistryInvalid, + kind: ProblemKind::OpenCodeIntegrationContentMismatch, category: ProblemCategory::RepoAssets, severity: ProblemSeverity::Error, fixability: ProblemFixability::ManualOnly, - summary, + summary: format!( + "{} file(s) differ from the canonical embedded content: {}.", + group.label, mismatched_paths + ), remediation: format!( - "Reinstall OpenCode assets to restore the canonical plugin registry at '{}', then rerun 'sce doctor'.", - manifest_path.display() + "Reinstall repo-root OpenCode assets to restore the canonical {} content, then rerun 'sce doctor'.", + group.label.to_ascii_lowercase() ), next_action: "manual_steps", }); } +} - inspect_opencode_plugin_dependency_health(&install_targets, problems); +fn push_opencode_integration_read_fail_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + for child in &group.children { + let IntegrationContentState::ReadFailed(error) = &child.content_state else { + continue; + }; + problems.push(DoctorProblem { + kind: ProblemKind::OpenCodeAssetReadFailed, + category: ProblemCategory::FilesystemPermissions, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "Unable to read OpenCode asset '{}' at '{}': {error}", + child.relative_path, + child.path.display() + ), + remediation: format!( + "Verify that '{}' is readable before rerunning 'sce doctor'.", + child.path.display() + ), + next_action: "manual_steps", + }); + } + } +} + +fn inspect_opencode_plugin_registry_health( + repository_root: &Path, + problems: &mut Vec, +) { + let repo_paths = RepoPaths::new(repository_root); + let manifest_path = repo_paths.opencode_manifest_file(); + let manifest_metadata = fs::metadata(&manifest_path).ok(); + let manifest_is_file = manifest_metadata + .as_ref() + .is_some_and(std::fs::Metadata::is_file); + if manifest_is_file { + return; + } + + let summary = if manifest_metadata.is_some() { + format!( + "OpenCode plugin registry path '{}' is not a file.", + manifest_path.display() + ) + } else { + format!( + "OpenCode plugin registry file '{}' is missing.", + manifest_path.display() + ) + }; + problems.push(DoctorProblem { + kind: ProblemKind::OpenCodePluginRegistryInvalid, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary, + remediation: format!( + "Reinstall OpenCode assets to restore the canonical plugin registry at '{}', then rerun 'sce doctor'.", + manifest_path.display() + ), + next_action: "manual_steps", + }); } fn inspect_opencode_plugin_dependency_health( @@ -888,22 +981,27 @@ fn collect_opencode_integration_groups(repository_root: &Path) -> Vec>(); + let mut plugin_children = Vec::new(); let mut agent_children = Vec::new(); let mut command_children = Vec::new(); let mut skill_children = Vec::new(); - for asset in iter_embedded_assets_for_setup_target(SetupTarget::OpenCode) { - let asset_path = opencode_root.join(asset.relative_path); - let child = IntegrationChildHealth { - relative_path: asset.relative_path.to_string(), - path: asset_path.clone(), - present: path_is_file(&asset_path), - }; + let manifest_child = embedded_assets + .iter() + .find(|asset| asset.relative_path == "opencode.json") + .map_or_else( + || build_integration_child_presence_only("opencode.json", &manifest_path), + |asset| build_integration_child_from_asset(&opencode_root, asset), + ); + plugin_children.push(manifest_child); + + for asset in embedded_assets { + if asset.relative_path == "opencode.json" { + continue; + } + let child = build_integration_child_from_asset(&opencode_root, asset); if child .relative_path @@ -960,6 +1058,56 @@ fn sort_integration_children(children: &mut [IntegrationChildHealth]) { children.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); } +fn build_integration_child_from_asset( + opencode_root: &Path, + asset: &EmbeddedAsset, +) -> IntegrationChildHealth { + let path = opencode_root.join(asset.relative_path); + let content_state = inspect_opencode_asset_state(&path, &asset.sha256); + IntegrationChildHealth { + relative_path: asset.relative_path.to_string(), + path, + content_state, + } +} + +fn build_integration_child_presence_only( + relative_path: &str, + path: &Path, +) -> IntegrationChildHealth { + let content_state = if path_is_file(path) { + IntegrationContentState::Match + } else { + IntegrationContentState::Missing + }; + IntegrationChildHealth { + relative_path: relative_path.to_string(), + path: path.to_path_buf(), + content_state, + } +} + +fn inspect_opencode_asset_state( + path: &Path, + expected_sha256: &[u8; 32], +) -> IntegrationContentState { + if !path_is_file(path) { + return IntegrationContentState::Missing; + } + + match fs::read(path) { + Ok(bytes) => { + let digest: [u8; 32] = Sha256::digest(&bytes).into(); + if &digest == expected_sha256 { + IntegrationContentState::Match + } else { + IntegrationContentState::Mismatch + } + } + Err(error) => IntegrationContentState::ReadFailed(error.to_string()), + } +} + fn path_is_file(path: &Path) -> bool { fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) } @@ -1176,7 +1324,7 @@ fn format_report_with_color_policy(report: &HookDoctorReport, color_enabled: boo color_enabled, integration_child_status(child, report.repository_root.is_some()), &child.relative_path, - child.path.display().to_string(), + integration_child_detail(child), )); } } @@ -1373,7 +1521,12 @@ fn integration_group_status( group: &IntegrationGroupHealth, repository_available: bool, ) -> HumanTextStatus { - if !repository_available || group.children.iter().any(|child| !child.present) { + if !repository_available + || group + .children + .iter() + .any(|child| !matches!(child.content_state, IntegrationContentState::Match)) + { HumanTextStatus::Fail } else { HumanTextStatus::Pass @@ -1384,12 +1537,30 @@ fn integration_child_status( child: &IntegrationChildHealth, repository_available: bool, ) -> HumanTextStatus { - if !repository_available { - HumanTextStatus::Fail - } else if child.present { - HumanTextStatus::Pass + if repository_available { + match child.content_state { + IntegrationContentState::Match => HumanTextStatus::Pass, + IntegrationContentState::Missing => HumanTextStatus::Miss, + IntegrationContentState::Mismatch | IntegrationContentState::ReadFailed(_) => { + HumanTextStatus::Fail + } + } } else { - HumanTextStatus::Miss + HumanTextStatus::Fail + } +} + +fn integration_child_detail(child: &IntegrationChildHealth) -> String { + match &child.content_state { + IntegrationContentState::Mismatch => { + format!("{} - content mismatch", child.path.display()) + } + IntegrationContentState::ReadFailed(_) => { + format!("{} - read failed", child.path.display()) + } + IntegrationContentState::Match | IntegrationContentState::Missing => { + child.path.display().to_string() + } } } diff --git a/cli/src/services/setup.rs b/cli/src/services/setup.rs index 82da398..af22d01 100644 --- a/cli/src/services/setup.rs +++ b/cli/src/services/setup.rs @@ -29,6 +29,7 @@ pub enum SetupTarget { pub struct EmbeddedAsset { pub relative_path: &'static str, pub bytes: &'static [u8], + pub sha256: [u8; 32], } #[cfg_attr(not(test), allow(dead_code))] diff --git a/context/overview.md b/context/overview.md index 3d8eb0b..6f98120 100644 --- a/context/overview.md +++ b/context/overview.md @@ -21,7 +21,7 @@ The CLI now also applies baseline security hardening for reliability-driven auto The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, nested `otel`, and nested `policies.bash`), deterministic default discovery/merge of global+local config files (`${config_root}/sce/config.json` then `.sce/config.json` with local override, where `config_root` comes from the shared default-path seam with XDG/`dirs::config_dir()` config-root resolution), defaults for the resolved observability value set (`log_level=error`, `log_format=text`, `log_file_mode=truncate`, `otel.enabled=false`, `otel.exporter_otlp_endpoint=http://127.0.0.1:4317`, `otel.exporter_otlp_protocol=grpc`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, first-class bash-policy preset/custom parsing with deterministic conflict and duplicate-prefix validation, and a canonical Pkl-authored `sce/config.json` JSON Schema generated to `config/schema/sce-config.schema.json` and embedded by `cli/src/services/config.rs` for both `sce config validate` and doctor-time config checks. Runtime startup config loading now keeps parity with that schema by accepting the canonical `"$schema": "https://sce.crocoder.dev/config.json"` declaration in repo-local and global config files, so startup commands such as `sce version` no longer fail before dispatch on that field. App-runtime observability now consumes that mixed-shape observability config (flat logging keys plus nested `otel`) through the shared resolver, so env values still override config-file values while config files provide deterministic fallback for file logging and OTEL bootstrap; `sce config show` reports resolved observability/auth/policy values with provenance, while `sce config validate` is now a trimmed validation surface that reports only pass/fail plus validation errors or warnings in text and JSON modes. The canonical preset catalog and matching contract live in `config/pkl/data/bash-policy-presets.json` and `context/sce/bash-tool-policy-enforcement-contract.md`. The shared default path service in `cli/src/services/default_paths.rs` is now the canonical owner for production CLI path definitions. It resolves per-user config/state/cache roots, exposes the current persisted-artifact inventory (global config, auth tokens, Agent Trace local DB), and also defines the repo-relative, embedded-asset, install/runtime, hook, and context-path accessors consumed across current CLI production code. Non-test production modules should consume this shared catalog instead of hardcoding owned path literals. No default cache-backed persisted artifact currently exists, so cache-root resolution remains available without speculative cache-path features and no legacy default-path fallback is supported. Generated config now includes repo-local bash-policy enforcement assets for OpenCode only: OpenCode blocks `bash` tool calls before subprocess launch via `config/.opencode/plugins/sce-bash-policy.ts` plus shared runtime logic and preset data in `config/.opencode/lib/` (also emitted for `config/automated/.opencode/**`). Claude bash-policy enforcement has been removed from generated outputs. -The `doctor` command now exposes explicit inspection mode (`sce doctor`) and repair-intent mode (`sce doctor --fix`) at the CLI/help/schema level while keeping diagnosis mode read-only. It now validates both current global operator health and the current repo/hook-integrity slice: state-root resolution, global config path resolution, global and repo-local `sce/config.json` readability/schema validity, Agent Trace local DB path + health, DB parent-directory readiness, git availability, non-repo vs bare-repo targeting failures, effective git hook-path source (default, per-repo `core.hooksPath`, or global `core.hooksPath`), hooks-directory health, required hook presence/executable permissions/content drift against canonical embedded SCE-managed hook assets, and repo-root OpenCode integration presence across the installed `plugins`, `agents`, `commands`, and `skills` inventories. Text mode now renders the approved human-only layout with ordered `Environment` / `Configuration` / `Repository` / `Git Hooks` / `Integrations` sections, `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens, shared-style green pass plus red fail/miss coloring when color output is enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows derived from repo-root installed artifact presence only; JSON output remains unchanged. Repo-scoped database reporting is empty by default because no repo-owned SCE database currently exists, while the Agent Trace local DB is reported in default doctor output. Fix mode reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and can also bootstrap the missing SCE-owned Agent Trace DB parent directory while preserving manual-only guidance for unsupported issues. +The `doctor` command now exposes explicit inspection mode (`sce doctor`) and repair-intent mode (`sce doctor --fix`) at the CLI/help/schema level while keeping diagnosis mode read-only. It now validates both current global operator health and the current repo/hook-integrity slice: state-root resolution, global config path resolution, global and repo-local `sce/config.json` readability/schema validity, Agent Trace local DB path + health, DB parent-directory readiness, git availability, non-repo vs bare-repo targeting failures, effective git hook-path source (default, per-repo `core.hooksPath`, or global `core.hooksPath`), hooks-directory health, required hook presence/executable permissions/content drift against canonical embedded SCE-managed hook assets, and repo-root OpenCode integration presence across the installed `plugins`, `agents`, `commands`, and `skills` inventories with embedded SHA-256 content verification for OpenCode assets. Text mode now renders the approved human-only layout with ordered `Environment` / `Configuration` / `Repository` / `Git Hooks` / `Integrations` sections, `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens, shared-style green pass plus red fail/miss coloring when color output is enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows that reflect missing vs content-mismatch states; JSON output remains unchanged. Repo-scoped database reporting is empty by default because no repo-owned SCE database currently exists, while the Agent Trace local DB is reported in default doctor output. Fix mode reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and can also bootstrap the missing SCE-owned Agent Trace DB parent directory while preserving manual-only guidance for unsupported issues. The `sync` placeholder performs a local Turso smoke check through a lazily initialized shared tokio current-thread runtime with bounded retry/timeout/backoff controls, then reports a deferred cloud-sync plan from a placeholder gateway contract; persistent local DB schema bootstrap now uses the same bounded resilience wrapper. The repository-root flake (`flake.nix`) now applies a Rust overlay-backed stable toolchain pinned to `1.93.1` (with `rustfmt` and `clippy`), reads package/check version from the repo-root `.version` file, builds `packages.sce` through a Crane `buildDepsOnly` + `buildPackage` pipeline with filtered package sources for the Cargo tree plus required embedded config/assets, and runs `cli-tests`, `cli-clippy`, and `cli-fmt` through Crane-backed check derivations (`cargoTest`, `cargoClippy`, `cargoFmt`) that reuse the same filtered source/toolchain setup. The root flake also exposes release install/run outputs directly as `packages.sce` (with `packages.default = packages.sce`) plus `apps.sce` and `apps.default`, so `nix build .#default`, `nix run . -- --help`, `nix run .#sce -- --help`, and `nix profile install github:crocoder-dev/shared-context-engineering` all target the packaged `sce` binary through the same flake-owned entrypoints. diff --git a/context/plans/doctor-opencode-content-hash.md b/context/plans/doctor-opencode-content-hash.md new file mode 100644 index 0000000..d637020 --- /dev/null +++ b/context/plans/doctor-opencode-content-hash.md @@ -0,0 +1,112 @@ +# Plan: Add OpenCode content-hash verification to `sce doctor` + +## Change summary +- Extend embedded-asset build output to include SHA-256 content hashes. +- Update `sce doctor` to verify repo-root OpenCode asset contents against embedded hashes (presence-only remains for missing files). +- Keep existing text/JSON output structure while adding clear mismatch messaging. + +## Success criteria +- `sce doctor` still reports missing required OpenCode files as `[MISS]` with existing missing-file behavior. +- `sce doctor` reports `[FAIL]` when an OpenCode required file exists but content differs from embedded SHA-256. +- Embedded hashes are generated at build time and stored with embedded asset metadata. +- Hash comparisons use file content only (no timestamps/permissions/metadata checks). +- Text output remains aligned with the current doctor layout and status tokens. + +## Constraints and non-goals +- Scope limited to OpenCode repo-root assets for content verification (no Claude content checks yet). +- Keep existing hook content checks as-is (no migration required in this change). +- Do not introduce new status tokens beyond `[PASS]`, `[FAIL]`, `[MISS]`. +- Avoid new abstractions; follow current `doctor` and embedded-asset patterns. +- JSON output shape should remain stable (new problems are acceptable; no new top-level schema required). + +## Task stack +- [x] T01: Embed SHA-256 hashes in build-time asset manifest (status:done) + - Task ID: T01 + - Goal: Generate SHA-256 hashes for embedded assets at build time and store them in the embedded metadata used at runtime. + - Boundaries (in/out of scope): + - In scope: update `cli/build.rs` to compute hashes, update `EmbeddedAsset` struct, add build-dependency for hashing, update generated manifest output. + - Out of scope: doctor runtime behavior changes, hook content logic changes, output formatting changes. + - Done when: + - `EmbeddedAsset` includes a content-hash field (SHA-256) alongside `relative_path` and `bytes`. + - `cli/build.rs` emits hash data for every embedded asset entry. + - Build script has required dependency support (e.g., `sha2` in build-dependencies). + - Verification notes: defer to T0N full validation (`nix flake check`). + - Status: done + - Completed: 2026-04-03 + - Files changed: cli/build.rs, cli/src/services/setup.rs, cli/Cargo.toml + - Evidence: `nix develop -c sh -c 'cd cli && cargo check'` (succeeded in 44.12s) + - Notes: Added SHA-256 field to embedded asset metadata and generated per-file hash literals during build. + +- [x] T02: Compare OpenCode repo-root assets against embedded hashes in `doctor` (status:done) + - Task ID: T02 + - Goal: Detect and report OpenCode asset content mismatches by comparing on-disk SHA-256 hashes to embedded hashes. + - Boundaries (in/out of scope): + - In scope: update integration asset inspection to compute hashes for repo-root OpenCode files, classify `PASS`/`FAIL`/`MISS`, and add mismatch problem records. + - Out of scope: Claude asset checks, new status tokens, changes to hook logic, and new output sections. + - Done when: + - For each expected OpenCode asset, doctor reads the file, computes SHA-256, and compares to embedded hash. + - Missing files remain `[MISS]`; mismatched content is `[FAIL]` with a concise “content mismatch” detail. + - Problems include a new explicit summary/remediation for content mismatches (category `repo_assets`). + - Integration group status becomes `[FAIL]` when any child is mismatched or missing. + - Verification notes: manual spot-check with `sce doctor` in a repo (optional); defer full validation to T0N. + - Status: done + - Completed: 2026-04-03 + - Files changed: cli/src/services/doctor.rs + - Evidence: `nix develop -c cargo check --manifest-path cli/Cargo.toml` + - Notes: OpenCode integration children now compare on-disk SHA-256 to embedded hashes and surface content mismatches as failures. + +- [x] T03: Update doctor contract context to reflect content verification (status:done) + - Task ID: T03 + - Goal: Sync the doctor contract documentation with the new OpenCode content-hash verification behavior. + - Boundaries (in/out of scope): + - In scope: update `context/sce/agent-trace-hook-doctor.md` and any direct references in `context/overview.md` to reflect content verification for OpenCode assets. + - Out of scope: unrelated context edits or historical summaries. + - Done when: + - The doctor contract no longer states OpenCode integrations are presence-only. + - The overview reflects that OpenCode installed assets are verified for content drift. + - Verification notes: ensure wording matches implemented behavior and does not introduce new contracts beyond this change. + - Status: done + - Completed: 2026-04-03 + - Files changed: context/overview.md, context/sce/agent-trace-hook-doctor.md + - Evidence: not run (context-only updates) + - Notes: Updated doctor contract and overview to reflect OpenCode content-hash verification. + +- [x] T04: Validation and cleanup (status:done) + - Task ID: T04 + - Goal: Run required validations and confirm the plan is ready for completion. + - Boundaries (in/out of scope): + - In scope: repository validation and final sanity checks; verify context updates are aligned. + - Out of scope: new feature work or additional refactors. + - Done when: + - `nix run .#pkl-check-generated` completes successfully. + - `nix flake check` completes successfully. + - Doctor output examples can be produced for match/miss/mismatch scenarios. + - Verification notes: run `nix run .#pkl-check-generated` and `nix flake check` from repo root. + - Status: done + - Completed: 2026-04-03 + - Files changed: cli/src/services/doctor.rs + - Evidence: + - `nix run .#pkl-check-generated` (success) + - `nix flake check -L --keep-going` (success) + - Notes: Refactored OpenCode integration health inspection helpers to satisfy clippy `too_many_lines`; full flake check now passes. + +## Open questions +- None. + +## Validation Report + +### Commands run +- `nix run .#pkl-check-generated` -> exit 0 (Generated outputs are up to date.) +- `nix flake check -L --keep-going` -> exit 0 (all checks passed) +- `nix flake check` -> exit 0 (all checks passed) + +### Failed checks and follow-ups +- None. + +### Success-criteria verification +- [x] `nix run .#pkl-check-generated` completes successfully. +- [x] `nix flake check` completes successfully. +- [x] Doctor output examples for match/miss/mismatch provided in task response. + +### Residual risks +- None identified. diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 901c91c..d83e5e1 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -40,8 +40,8 @@ The runtime in `cli/src/services/doctor.rs` exposes the approved doctor command - top-level-only human text hook rows for `pre-commit`, `commit-msg`, and `post-commit`, with nested `content` / `executable` detail removed from text mode - 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-root installed OpenCode integration presence inventory for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills` -- presence-only child-row reporting for those four integration groups, with missing required files rendered as `[MISS]` and any affected parent group rendered as `[FAIL]` +- repo-root installed OpenCode integration inventory for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills` +- integration child-row reporting for those four groups now validates file content against embedded SHA-256; missing files render as `[MISS]`, content mismatches render as `[FAIL]`, and any affected parent group renders as `[FAIL]` - repo-root OpenCode plugin inventory includes the installed manifest file plus plugin/runtime/preset artifacts as required presence-only files; generated `config/.opencode/**` trees are not inspected by doctor - 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 @@ -100,13 +100,14 @@ Human text output for `Integrations` must use exactly these groups: - `OpenCode skills` Integration checks for this contract inspect installed repo-root artifacts only. -They validate file presence only and do not inspect file contents. +They validate file presence and content hashes against embedded OpenCode assets. Generated `config/.opencode/**` trees are out of scope for doctor integration checks in this change stream. For `agents`, `commands`, and `skills`, the installed repo-root trees are required inventory. -If any required file in an integration group is missing: +If any required file in an integration group is missing or mismatched: -- the missing child row renders `[MISS]` +- missing child rows render `[MISS]` +- mismatched child rows render `[FAIL]` and include a content-mismatch detail - the parent integration group renders `[FAIL]` An integration group renders `[PASS]` only when every required installed file in that group is present. @@ -118,7 +119,7 @@ Integration child rows render as `[STATUS] relative/path (absolute/path)` in tex - no JSON output shape or semantic changes - no `sce doctor --fix` behavior changes -- no integration content-drift validation +- no Claude integration content validation - no new integration group names ## Command surface contract