From de248cbf99a35d6bfd1d6f98b47fc341a3d05c14 Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Thu, 2 Jul 2026 17:23:57 +0200 Subject: [PATCH 1/2] doctor: Add Claude integration health checks Add repo-root Claude asset inspection to sce doctor using embedded setup asset hashes, including grouped plugin, agent, command, and skill health. Surface missing, mismatched, and unreadable Claude assets through doctor problem kinds and human text integration rows. Co-authored-by: SCE --- cli/src/services/default_paths.rs | 8 +- cli/src/services/doctor/inspect.rs | 197 +++++++++++++++++++++- cli/src/services/doctor/mod.rs | 14 ++ cli/src/services/doctor/render.rs | 21 ++- cli/src/services/doctor/types.rs | 7 + cli/src/services/lifecycle.rs | 5 +- context/cli/cli-command-surface.md | 4 +- context/context-map.md | 2 +- context/glossary.md | 2 +- context/sce/agent-trace-hook-doctor.md | 6 +- context/sce/doctor-human-text-contract.md | 18 +- 11 files changed, 257 insertions(+), 27 deletions(-) diff --git a/cli/src/services/default_paths.rs b/cli/src/services/default_paths.rs index 1a1d0e59..932a41dc 100644 --- a/cli/src/services/default_paths.rs +++ b/cli/src/services/default_paths.rs @@ -270,7 +270,6 @@ pub fn local_db_path() -> anyhow::Result { /// The path is `/sce/auth.db`, where `state_root` comes /// from the shared default-path catalog (`XDG_STATE_HOME` or platform /// equivalent). -#[allow(dead_code)] pub fn auth_db_path() -> anyhow::Result { Ok(resolve_sce_default_locations()? .roots() @@ -284,7 +283,6 @@ pub fn auth_db_path() -> anyhow::Result { /// The path is `/sce/agent-trace.db`, where `state_root` comes /// from the shared default-path catalog (`XDG_STATE_HOME` or platform /// equivalent). -#[allow(dead_code)] pub fn agent_trace_db_path() -> anyhow::Result { Ok(resolve_sce_default_locations()? .roots() @@ -298,7 +296,6 @@ pub fn agent_trace_db_path() -> anyhow::Result { /// The path is `/sce/agent-trace-{checkout_id}.db`, where /// `state_root` comes from the shared default-path catalog (`XDG_STATE_HOME` or /// platform equivalent). -#[allow(dead_code)] pub fn agent_trace_db_path_for_checkout(checkout_id: &str) -> anyhow::Result { let checkout_id = checkout_id.trim(); if checkout_id.is_empty() { @@ -378,11 +375,13 @@ pub(crate) mod opencode_asset { pub const AGENTS_DIR: &str = "agents"; } -#[allow(dead_code)] pub(crate) mod claude_asset { pub const CLAUDE_DIR: &str = "claude"; + pub const SETTINGS_FILE: &str = "settings.json"; + pub const HOOKS_DIR: &str = "hooks"; pub const SKILLS_DIR: &str = "skills"; pub const AGENTS_DIR: &str = "agents"; + pub const COMMANDS_DIR: &str = "commands"; } #[allow(dead_code)] @@ -410,7 +409,6 @@ pub(crate) mod schema { pub const SCE_CONFIG_SCHEMA: &str = "sce-config.schema.json"; } -#[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct RepoPaths { root: PathBuf, diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index 6eeaa043..f384a315 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -6,7 +6,7 @@ use sha2::{Digest, Sha256}; use crate::services::agent_trace_db::lifecycle::diagnose_agent_trace_db_health; use crate::services::checkout; use crate::services::default_paths::{ - agent_trace_db_path_for_checkout, opencode_asset, InstallTargetPaths, RepoPaths, + agent_trace_db_path_for_checkout, claude_asset, opencode_asset, InstallTargetPaths, RepoPaths, }; use crate::services::setup::{ iter_embedded_assets_for_setup_target, iter_required_hook_assets, EmbeddedAsset, SetupTarget, @@ -16,7 +16,8 @@ use super::types::{ CheckoutIdentityHealth, DoctorProblem, FileLocationHealth, GlobalStateHealth, HookContentState, HookDoctorReport, HookFileHealth, HookPathSource, IntegrationChildHealth, IntegrationContentState, IntegrationGroupHealth, ProblemCategory, ProblemFixability, - ProblemKind, ProblemSeverity, Readiness, OPENCODE_AGENTS_LABEL, OPENCODE_COMMANDS_LABEL, + ProblemKind, ProblemSeverity, Readiness, CLAUDE_AGENTS_LABEL, CLAUDE_COMMANDS_LABEL, + CLAUDE_PLUGINS_LABEL, CLAUDE_SKILLS_LABEL, OPENCODE_AGENTS_LABEL, OPENCODE_COMMANDS_LABEL, OPENCODE_PLUGINS_LABEL, OPENCODE_SKILLS_LABEL, }; use super::{is_executable, DoctorDependencies, DoctorMode, REQUIRED_HOOKS}; @@ -509,8 +510,14 @@ fn inspect_repository_integrations( return Vec::new(); }; - let integration_groups = collect_opencode_integration_groups(resolved_root); - inspect_opencode_integration_health(resolved_root, &integration_groups, problems); + let opencode_groups = collect_opencode_integration_groups(resolved_root); + inspect_opencode_integration_health(resolved_root, &opencode_groups, problems); + + let claude_groups = collect_claude_integration_groups(resolved_root); + inspect_claude_integration_health(&claude_groups, problems); + + let mut integration_groups = opencode_groups; + integration_groups.extend(claude_groups); integration_groups } @@ -731,6 +738,15 @@ fn inspect_opencode_integration_health( inspect_opencode_plugin_dependency_health(&install_targets, problems); } +fn inspect_claude_integration_health( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + push_claude_integration_missing_problems(integration_groups, problems); + push_claude_integration_mismatch_problems(integration_groups, problems); + push_claude_integration_read_fail_problems(integration_groups, problems); +} + fn push_opencode_integration_missing_problems( integration_groups: &[IntegrationGroupHealth], problems: &mut Vec, @@ -834,6 +850,109 @@ fn push_opencode_integration_read_fail_problems( } } +fn push_claude_integration_missing_problems( + integration_groups: &[IntegrationGroupHealth], + problems: &mut Vec, +) { + for group in integration_groups { + let missing_children = group + .children + .iter() + .filter(|child| matches!(&child.content_state, IntegrationContentState::Missing)) + .collect::>(); + if missing_children.is_empty() { + continue; + } + + let missing_paths = missing_children + .iter() + .map(|child| format!("'{}'", child.path.display())) + .collect::>() + .join(", "); + problems.push(DoctorProblem { + kind: ProblemKind::ClaudeIntegrationFilesMissing, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "{} required file(s) are missing: {}.", + group.label, missing_paths + ), + remediation: format!( + "Reinstall repo-root Claude assets to restore the missing {} file(s), then rerun 'sce doctor'.", + group.label.to_ascii_lowercase() + ), + next_action: "manual_steps", + }); + } +} + +fn push_claude_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 mismatched_paths = mismatched_children + .iter() + .map(|child| format!("'{}'", child.path.display())) + .collect::>() + .join(", "); + problems.push(DoctorProblem { + kind: ProblemKind::ClaudeIntegrationContentMismatch, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "{} file(s) differ from the canonical embedded content: {}.", + group.label, mismatched_paths + ), + remediation: format!( + "Reinstall repo-root Claude assets to restore the canonical {} content, then rerun 'sce doctor'.", + group.label.to_ascii_lowercase() + ), + next_action: "manual_steps", + }); + } +} + +fn push_claude_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::ClaudeAssetReadFailed, + category: ProblemCategory::FilesystemPermissions, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: format!( + "Unable to read Claude 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, @@ -1000,16 +1119,78 @@ fn collect_opencode_integration_groups(repository_root: &Path) -> Vec Vec { + let repo_paths = RepoPaths::new(repository_root); + let claude_root = repo_paths.claude_dir(); + let embedded_assets = + iter_embedded_assets_for_setup_target(SetupTarget::Claude).collect::>(); + 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 embedded_assets { + let child = build_integration_child_from_asset(&claude_root, asset); + + if child.relative_path == claude_asset::SETTINGS_FILE + || child + .relative_path + .starts_with(&format!("{}/", claude_asset::HOOKS_DIR)) + { + plugin_children.push(child); + } else if child + .relative_path + .starts_with(&format!("{}/", claude_asset::AGENTS_DIR)) + { + agent_children.push(child); + } else if child + .relative_path + .starts_with(&format!("{}/", claude_asset::COMMANDS_DIR)) + { + command_children.push(child); + } else if child + .relative_path + .starts_with(&format!("{}/", claude_asset::SKILLS_DIR)) + { + skill_children.push(child); + } + } + + sort_integration_children(&mut plugin_children); + sort_integration_children(&mut agent_children); + sort_integration_children(&mut command_children); + sort_integration_children(&mut skill_children); + + vec![ + IntegrationGroupHealth { + label: CLAUDE_PLUGINS_LABEL, + children: plugin_children, + }, + IntegrationGroupHealth { + label: CLAUDE_AGENTS_LABEL, + children: agent_children, + }, + IntegrationGroupHealth { + label: CLAUDE_COMMANDS_LABEL, + children: command_children, + }, + IntegrationGroupHealth { + label: CLAUDE_SKILLS_LABEL, + children: skill_children, + }, + ] +} + 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, + integration_root: &Path, asset: &EmbeddedAsset, ) -> IntegrationChildHealth { - let path = opencode_root.join(asset.relative_path); - let content_state = inspect_opencode_asset_state(&path, &asset.sha256); + let path = integration_root.join(asset.relative_path); + let content_state = inspect_integration_asset_state(&path, &asset.sha256); IntegrationChildHealth { relative_path: asset.relative_path.to_string(), path, @@ -1033,7 +1214,7 @@ fn build_integration_child_presence_only( } } -fn inspect_opencode_asset_state( +fn inspect_integration_asset_state( path: &Path, expected_sha256: &[u8; 32], ) -> IntegrationContentState { diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index 4ad657c6..847b4a42 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -299,6 +299,12 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind { HealthProblemKind::OpenCodeIntegrationContentMismatch => { ProblemKind::OpenCodeIntegrationContentMismatch } + HealthProblemKind::ClaudeIntegrationFilesMissing => { + ProblemKind::ClaudeIntegrationFilesMissing + } + HealthProblemKind::ClaudeIntegrationContentMismatch => { + ProblemKind::ClaudeIntegrationContentMismatch + } HealthProblemKind::OpenCodePluginRegistryInvalid => { ProblemKind::OpenCodePluginRegistryInvalid } @@ -307,6 +313,7 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind { } HealthProblemKind::HookReadFailed => ProblemKind::HookReadFailed, HealthProblemKind::OpenCodeAssetReadFailed => ProblemKind::OpenCodeAssetReadFailed, + HealthProblemKind::ClaudeAssetReadFailed => ProblemKind::ClaudeAssetReadFailed, HealthProblemKind::AgentTraceDbConnectionFailed => { ProblemKind::AgentTraceDbConnectionFailed } @@ -341,6 +348,12 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind { ProblemKind::OpenCodeIntegrationContentMismatch => { HealthProblemKind::OpenCodeIntegrationContentMismatch } + ProblemKind::ClaudeIntegrationFilesMissing => { + HealthProblemKind::ClaudeIntegrationFilesMissing + } + ProblemKind::ClaudeIntegrationContentMismatch => { + HealthProblemKind::ClaudeIntegrationContentMismatch + } ProblemKind::OpenCodePluginRegistryInvalid => { HealthProblemKind::OpenCodePluginRegistryInvalid } @@ -349,6 +362,7 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind { } ProblemKind::HookReadFailed => HealthProblemKind::HookReadFailed, ProblemKind::OpenCodeAssetReadFailed => HealthProblemKind::OpenCodeAssetReadFailed, + ProblemKind::ClaudeAssetReadFailed => HealthProblemKind::ClaudeAssetReadFailed, ProblemKind::AgentTraceDbConnectionFailed => { HealthProblemKind::AgentTraceDbConnectionFailed } diff --git a/cli/src/services/doctor/render.rs b/cli/src/services/doctor/render.rs index a81c5cd3..c0e68691 100644 --- a/cli/src/services/doctor/render.rs +++ b/cli/src/services/doctor/render.rs @@ -7,8 +7,9 @@ use super::types::{ fix_result_outcome, problem_category, problem_fixability, problem_severity, FileLocationHealth, HookContentState, HookDoctorReport, HookFileHealth, HookPathSource, HumanTextStatus, IntegrationChildHealth, IntegrationContentState, IntegrationGroupHealth, ProblemKind, - ProblemSeverity, Readiness, OPENCODE_AGENTS_LABEL, OPENCODE_COMMANDS_LABEL, - OPENCODE_PLUGINS_LABEL, OPENCODE_SKILLS_LABEL, + ProblemSeverity, Readiness, CLAUDE_AGENTS_LABEL, CLAUDE_COMMANDS_LABEL, CLAUDE_PLUGINS_LABEL, + CLAUDE_SKILLS_LABEL, OPENCODE_AGENTS_LABEL, OPENCODE_COMMANDS_LABEL, OPENCODE_PLUGINS_LABEL, + OPENCODE_SKILLS_LABEL, }; use super::{DoctorExecution, DoctorFormat, DoctorMode, DoctorRequest, NAME, REQUIRED_HOOKS}; @@ -362,6 +363,22 @@ fn integration_groups_for_text(report: &HookDoctorReport) -> Vec, } -#[allow(dead_code)] pub trait ServiceLifecycle: Send + Sync { + #[allow(dead_code)] fn id(&self) -> LifecycleProviderId; fn diagnose(&self, _ctx: &C) -> Vec { diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 03aab9c1..f3b83479 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -63,7 +63,7 @@ Deferred or gated command surfaces currently avoid claiming unimplemented behavi `setup` now also exposes compile-time embedded config assets for OpenCode/Claude targets, sourced from the generated `config/.opencode/**` and `config/.claude/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. `setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that stages embedded files and uses a unified remove-and-replace policy for `.opencode/`/`.claude/` (removing existing targets before swapping staged content, with deterministic recovery guidance on swap failure) while treating bash-policy enforcement files as first-class SCE-managed assets. `setup` now executes end-to-end and prints deterministic completion details including selected target(s) and per-target install count. -`doctor` now executes end-to-end with explicit diagnosis and repair-intent surfaces: `sce doctor` stays read-only and `sce doctor --fix` selects repair-intent mode. Agent Trace DB checkout discovery has moved out of `doctor` into the `trace` group (`sce trace db list`, `sce trace status`, `sce trace status --all`); see [trace-command.md](trace-command.md). The current `doctor` runtime aggregates `ServiceLifecycle::diagnose` and `ServiceLifecycle::fix` calls across all registered service providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) plus integration checks, covering state-root resolution, global and repo-local `sce/config.json` readability/schema validation, local DB and checkout/global Agent Trace DB path/health, DB-parent readiness barriers, the repo hook rollout slice when a repository target is detected, and repo-root installed OpenCode integration presence for `plugins`, `agents`, `commands`, and `skills`. Fix mode delegates to each provider's `fix` implementation, which reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and missing hooks directories, and it can bootstrap missing canonical database parent directories when the resolved paths match canonical owned locations. +`doctor` now executes end-to-end with explicit diagnosis and repair-intent surfaces: `sce doctor` stays read-only and `sce doctor --fix` selects repair-intent mode. Agent Trace DB checkout discovery has moved out of `doctor` into the `trace` group (`sce trace db list`, `sce trace status`, `sce trace status --all`); see [trace-command.md](trace-command.md). The current `doctor` runtime aggregates `ServiceLifecycle::diagnose` and `ServiceLifecycle::fix` calls across all registered service providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) plus integration checks, covering state-root resolution, global and repo-local `sce/config.json` readability/schema validation, local DB and checkout/global Agent Trace DB path/health, DB-parent readiness barriers, the repo hook rollout slice when a repository target is detected, and repo-root installed OpenCode and Claude integration presence/content health for their embedded setup assets. Fix mode delegates to each provider's `fix` implementation, which reuses the canonical setup hook install flow to repair missing/stale/non-executable required hooks and missing hooks directories, and it can bootstrap missing canonical database parent directories when the resolved paths match canonical owned locations. A user-invocable `sync` command is not wired in the current CLI surface; local DB and Agent Trace DB bootstrap currently happen through `setup`, and DB health/repair currently happens through `doctor`. Command wiring for `sce sync` is deferred to `0.4.0`. ## Command loop and error model @@ -88,7 +88,7 @@ A user-invocable `sync` command is not wired in the current CLI surface; local D - `cli/src/services/setup/mod.rs` defines setup parsing/selection contracts plus runtime install orchestration (`run_setup_for_mode`) over the embedded asset install engine; `cli/src/services/setup/command.rs` owns the setup runtime command handler. Setup now aggregates `ServiceLifecycle::setup` calls across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`) in order, using a `ContextWithRepoRoot`-scoped context with resolved repository root. - `cli/src/services/setup/mod.rs` now keeps its larger internal responsibilities behind focused inline support modules: `install` owns repository canonicalization, staging/swap install flows, required-hook installation, and repo/writeability guards, while `prompt` owns interactive target selection and styled prompt labels. - `cli/src/services/config/mod.rs` defines config parser/runtime contracts (`show`, `validate`, `--help`), strict config-file key/type validation, deterministic text/JSON rendering, repo-configured bash-policy preset/custom validation and reporting under `policies.bash`, and shared auth-key metadata that declares env key, config-file key, and optional baked-default eligibility for supported auth runtime values starting with `workos_client_id` (`WORKOS_CLIENT_ID` vs `workos_client_id`); auth-key provenance/preference metadata stays on `show`, while `validate` stays trimmed to validation status plus issues/warnings. `cli/src/services/config/lifecycle.rs` implements `ServiceLifecycle` for config health checks and setup (global/local config validation and repo-local config bootstrap). -- `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorAction`, `DoctorMode`, `run_doctor`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout-database discovery, stable text/JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, repo-root installed OpenCode integration presence inventory for `plugins`, `agents`, `commands`, and `skills` derived from the embedded OpenCode setup asset catalog, shared-style bracketed human status token rendering (`[PASS]`, `[FAIL]`, `[MISS]`) with simplified `label (path)` text rows, and repair-mode delegation to service-owned fix implementations. +- `cli/src/services/doctor/mod.rs` defines the implemented doctor request/report contract (`DoctorRequest`, `DoctorAction`, `DoctorMode`, `run_doctor`) while focused submodules under `cli/src/services/doctor/` handle runtime command dispatch (`command.rs`), diagnosis (`inspect.rs`), rendering (`render.rs`), fix execution (`fixes.rs`), and doctor-owned domain types (`types.rs`). Together they preserve explicit fix-mode parsing, checkout-database discovery, stable text/JSON problem and database-record rendering, deterministic fix-result reporting, and aggregation of `ServiceLifecycle::diagnose`/`ServiceLifecycle::fix` across registered providers (`config`, `local_db`, `auth_db`, `agent_trace_db`, `hooks`). The doctor module coordinates state-root/config/database reporting and validation, path-source detection plus required-hook presence/executable/content checks when a repository target is detected, repo-root installed OpenCode and Claude integration inventory derived from embedded setup asset catalogs, shared-style bracketed human status token rendering (`[PASS]`, `[FAIL]`, `[MISS]`) with simplified `label (path)` text rows, and repair-mode delegation to service-owned fix implementations. Claude grouping is path-based: `settings.json`/`hooks/**` as `ClaudeCode plugins` (including `.claude/hooks/run-sce-or-show-install-guidance.sh`), plus `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills`. - `cli/src/services/version/mod.rs` defines the version parser/output contract (`parse_version_request`, `render_version`) with deterministic text/JSON output modes; `cli/src/services/version/command.rs` owns the version runtime command handler. - `cli/src/services/completion/mod.rs` defines the completion output contract (`render_completion`) using clap_complete to generate deterministic shell scripts for Bash, Zsh, and Fish; `cli/src/services/completion/command.rs` owns the completion runtime command handler. - `cli/src/services/hooks/mod.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`; `cli/src/services/hooks/command.rs` owns the hook runtime command handler. Current runtime behavior is commit-msg-only attribution behind the enabled-by-default attribution gate with explicit opt-out controls; `pre-commit` and `post-rewrite` are deterministic no-ops; `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains an active intersection + Agent Trace DB persistence path; `diff-trace` performs STDIN JSON intake, required-field validation, and best-effort AgentTraceDb insertion with tool-prefixed stored `session_id` values plus direct nullable `model_id` / `tool_version` attribution. Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. `session-model` is no longer a supported hooks route. `cli/src/services/hooks/lifecycle.rs` implements `ServiceLifecycle` for hook health checks, fix, and setup (hook rollout integrity and required-hook installation). diff --git a/context/context-map.md b/context/context-map.md index 49d9f1fd..b17db384 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -34,7 +34,7 @@ Feature/domain context: - `context/sce/agent-trace-commit-msg-coauthor-policy.md` (current commit-msg canonical co-author trailer policy with enabled-by-default attribution hooks, explicit opt-out controls, `SCE_DISABLED` kill switch, caller-provided `ai_contribution_present` transformer seam wired from staged-diff AI-overlap preflight, idempotent dedupe, the `agent_trace::patches_have_overlap` pure overlap seam, the `StagedDiffAiOverlapResult` three-valued evidence gate, and `sce.hooks.commit_msg.ai_overlap_error` error logging) - `context/sce/agent-trace-post-commit-dual-write.md` (historical post-commit no-op/dual-write reference; current post-commit behavior is documented in `agent-trace-hooks-command-routing.md`) - `context/sce/agent-trace-hook-doctor.md` (approved operator-environment contract for broadening `sce doctor` into the canonical health-and-repair entrypoint, including stable problem taxonomy, `--fix` semantics, checkout-aware Agent Trace DB reporting, setup-to-doctor alignment rules, and the approved downstream human text-mode layout/status/integration contract) -- `context/sce/doctor-human-text-contract.md` (implemented `sce doctor` human text layout contract: section order, `[PASS]`/`[FAIL]`/`[MISS]` status vocabulary, simplified hook rows, and OpenCode integration group rendering rules) +- `context/sce/doctor-human-text-contract.md` (implemented `sce doctor` human text layout contract: section order, `[PASS]`/`[FAIL]`/`[MISS]` status vocabulary, simplified hook rows, and OpenCode plus Claude integration group rendering rules) - `context/sce/setup-githooks-install-contract.md` (T01 canonical `sce setup --hooks` install contract for target-path resolution, idempotent outcomes, remove-and-replace behavior, and doctor-readiness alignment) - `context/sce/setup-no-backup-policy-seam.md` (implemented unified remove-and-replace install policy for both config-install and required-hook install flows, with no backup creation and deterministic recovery guidance on swap failure) - `context/sce/setup-githooks-hook-asset-packaging.md` (T02 compile-time `sce setup --hooks` required-hook template packaging contract, including current post-commit missing-`sce` install guidance, origin remote lookup, remote-URL forwarding/fallback behavior, and setup-service accessor surface) diff --git a/context/glossary.md b/context/glossary.md index 116fd772..f9caa13c 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -141,7 +141,7 @@ - `agent trace commit-msg co-author policy`: Current contract in `cli/src/services/hooks/mod.rs` (`apply_commit_msg_coauthor_policy`) that applies exactly one canonical trailer (`Co-authored-by: SCE `) only when attribution hooks are enabled, SCE is not disabled, and the staged-diff AI-overlap preflight confirms AI/editor evidence (`StagedDiffAiOverlapResult::Overlap`); `NoOverlap` and `Error` both suppress the trailer, with `Error` logged via `sce.hooks.commit_msg.ai_overlap_error`; duplicate canonical trailers are deduped idempotently. - `local DB migration contract`: `cli/src/services/local_db/mod.rs` delegates migration execution to `TursoDb` through the `DbSpec::migrations()` contract. The current `LocalDbSpec` migration list is empty, so `LocalDb::new()` opens/creates the canonical local DB without creating local tables. - `hook no-op baseline`: Current `cli/src/services/hooks/mod.rs` runtime posture where `pre-commit` and `post-rewrite` return deterministic no-op status text, `commit-msg` is a gated mutating path, `post-commit` persists intersections and built Agent Trace payloads without post-commit file artifacts, `diff-trace` validates STDIN payloads, uses only direct payload `model_id` and `tool_version` (session-model fallback removed), applies stored `session_id` prefixes (`oc_`/`cc_`), and inserts DB-only AgentTraceDb rows, and `conversation-trace` is the active message/part intake path. `session-model` is no longer a supported hook route. -- `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, `sce doctor dbs` lists registered checkouts, help/output expose deterministic doctor mode/action, JSON includes stable problem taxonomy/fixability fields plus checkout/database records and fix-result records, the runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and checkout/global Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode integration presence for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills`. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity + Agent Trace checkout DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and presence-only integration parent/child rows where missing required files surface as `[MISS]` children and `[FAIL]` parent groups. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. +- `sce doctor` operator-health contract: `cli/src/services/doctor/mod.rs` is the stable doctor entrypoint, with focused `doctor/{inspect,render,fixes,types}.rs` submodules implementing the current approved operator-health surface in `context/sce/agent-trace-hook-doctor.md`: `sce doctor --fix` selects repair intent, checkout DB discovery lives under `sce trace`, and output exposes deterministic doctor mode, readiness, stable problem taxonomy/fixability fields, checkout/database records, and fix-result records. The runtime validates state-root resolution, global and repo-local `sce/config.json` readability/schema health, local DB and checkout/global Agent Trace DB path/health, DB-parent readiness barriers, git availability, non-repo vs bare-repo targeting failures, effective hook-path source resolution, required hook presence/executable/content drift against canonical embedded hook assets, and repo-root installed OpenCode plus Claude integration content health. Human text mode uses the approved sectioned layout (`Environment`, `Configuration` with checkout identity + Agent Trace checkout DB rows when available, `Repository`, `Git Hooks`, `Integrations`), `SCE doctor diagnose` / `SCE doctor fix` headers, bracketed `[PASS]`/`[FAIL]`/`[MISS]` status tokens with shared-style green/red colorization when enabled, simplified `label (path)` row formatting, top-level-only hook rows, and integration parent/child rows where missing files surface as `[MISS]`, mismatches/read failures as `[FAIL]`, and affected parent groups as `[FAIL]`. Current integration groups are `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, `OpenCode skills`, `ClaudeCode plugins`, `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills`; Claude `settings.json` plus `hooks/**` belong to `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`. Fix mode reuses canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories and can bootstrap canonical missing SCE-owned DB parent directories. - `cli warnings-denied lint policy`: `cli/Cargo.toml` sets `warnings = "deny"`, so plain `cargo clippy --manifest-path cli/Cargo.toml` already fails on warnings without needing an extra `-- -D warnings` tail. - `agent trace local DB schema migration contract`: Retired `apply_core_schema_migrations` behavior removed from the current runtime during `agent-trace-removal-and-hook-noop-reset` T01; the local DB baseline is now file open/create only. - `agent trace removed local-hook paths`: Current-state shorthand for the removed local-hook runtime behaviors that are no longer active: staged-checkpoint persistence, post-commit dual-write, post-rewrite remap ingestion, rewrite trace transformation, and retry replay. diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index b85467e1..1fdf5e9a 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -41,9 +41,9 @@ The runtime in `cli/src/services/doctor/mod.rs` exposes the approved doctor comm - 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 (delegated to `HooksLifecycle::diagnose`) - byte-for-byte stale-content detection for required hook payloads against canonical embedded SCE-managed hook assets (delegated to `HooksLifecycle::diagnose`) -- 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/preset artifacts as required presence-only files; generated `config/.opencode/**` trees are not inspected by doctor +- repo-root installed OpenCode integration inventory for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills`, plus Claude integration inventory for `ClaudeCode plugins`, `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills` +- integration child-row reporting validates installed files against embedded SHA-256 content; missing files render as `[MISS]`, content mismatches render as `[FAIL]`, and any affected parent group renders as `[FAIL]` +- OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `agents/**`, `commands/**`, and `skills/**`); generated `config/.opencode/**` and `config/.claude/**` trees are not inspected by doctor - repair-mode delegation to `ServiceLifecycle::fix` implementations: `HooksLifecycle::fix` reuses `install_required_git_hooks` for missing hooks directories plus missing, stale, or non-executable required hooks; `LocalDbLifecycle::fix`, `AuthDbLifecycle::fix`, and `AgentTraceDbLifecycle::fix` handle bootstrap of missing canonical SCE-owned DB parent directories ## Approved human text-mode contract diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index b6cdcfae..a835a201 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -50,10 +50,21 @@ Human text output for `Integrations` must use exactly these groups: - `OpenCode agents` - `OpenCode commands` - `OpenCode skills` +- `ClaudeCode plugins` +- `ClaudeCode agents` +- `ClaudeCode commands` +- `ClaudeCode skills` Integration checks for this contract inspect installed repo-root artifacts only. -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. +They validate file presence and content hashes against embedded OpenCode and Claude setup assets. +Generated `config/.opencode/**` and `config/.claude/**` trees are out of scope for doctor integration checks in this change stream. + +Claude installed assets are grouped by repo-root `.claude/` relative path: + +- `settings.json` and `hooks/**` -> `ClaudeCode plugins` (including `hooks/run-sce-or-show-install-guidance.sh`) +- `agents/**` -> `ClaudeCode agents` +- `commands/**` -> `ClaudeCode commands` +- `skills/**` -> `ClaudeCode skills` For `agents`, `commands`, and `skills`, the installed repo-root trees are required inventory. If any required file in an integration group is missing or mismatched: @@ -71,7 +82,6 @@ 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 Claude integration content validation -- no new integration group names +- no Claude plugin registry or preset-catalog checks See also: [doctor operator contract](agent-trace-hook-doctor.md), [CLI command surface](../cli/cli-command-surface.md). From b5c3d7d19f7cd20aa0c923773547d6c97bed83aa Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Thu, 2 Jul 2026 20:01:18 +0200 Subject: [PATCH 2/2] config: Add integrations.target key for setup persistence and doctor detection Introduce the `integrations` top-level config key with a `target` array that records which integration targets (opencode, claude) a repo has been set up for. - Add `IntegrationsConfig` and `IntegrationTargetId` types, config parser, and resolver merge support for `integrations.target` - Implement `persist_integration_targets()` in setup service: called after successful target install to record the target(s) in `.sce/config.json` (idempotent, deduped, preserves unrelated keys) - Add target-scoped integration detection in doctor: reads `integrations.target` from config, falls back to directory detection when absent, and renders no-integrations guidance when no targets exist - Register `NoIntegrationsInstalled` problem kind in lifecycle types - Update Pkl schema and generated JSON schema for `integrations.target` - Update `.sce/config.json` to record existing `["claude", "opencode"]` targets - Sync context docs to reflect the new config key, persistence behavior, and doctor resolution rules Co-authored-by: SCE --- .opencode/plugins/sce-agent-trace.ts | 8 +- .opencode/plugins/sce-bash-policy.ts | 4 +- .sce/config.json | 13 +- cli/src/services/config/resolver.rs | 4 + cli/src/services/config/schema.rs | 57 ++++- cli/src/services/config/types.rs | 23 ++ cli/src/services/doctor/inspect.rs | 204 ++++++++---------- cli/src/services/doctor/mod.rs | 2 + cli/src/services/doctor/render.rs | 29 ++- cli/src/services/doctor/types.rs | 2 + cli/src/services/lifecycle.rs | 1 + cli/src/services/setup/mod.rs | 96 +++++++++ config/.opencode/plugins/sce-agent-trace.ts | 8 +- config/.opencode/plugins/sce-bash-policy.ts | 4 +- .../.opencode/plugins/sce-agent-trace.ts | 8 +- .../.opencode/plugins/sce-bash-policy.ts | 4 +- config/pkl/base/sce-config-schema.pkl | 14 ++ config/schema/sce-config.schema.json | 17 ++ context/cli/config-precedence-contract.md | 7 +- context/context-map.md | 4 +- context/sce/agent-trace-hook-doctor.md | 3 +- context/sce/doctor-human-text-contract.md | 9 +- context/sce/setup-githooks-cli-ux.md | 14 ++ .../sce/setup-repo-local-config-bootstrap.md | 13 +- 24 files changed, 389 insertions(+), 159 deletions(-) diff --git a/.opencode/plugins/sce-agent-trace.ts b/.opencode/plugins/sce-agent-trace.ts index 4a74bd3a..980ceb06 100644 --- a/.opencode/plugins/sce-agent-trace.ts +++ b/.opencode/plugins/sce-agent-trace.ts @@ -337,9 +337,7 @@ async function runDiffTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); @@ -364,9 +362,7 @@ async function runConversationTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); diff --git a/.opencode/plugins/sce-bash-policy.ts b/.opencode/plugins/sce-bash-policy.ts index 788929f3..0b2b8d9c 100644 --- a/.opencode/plugins/sce-bash-policy.ts +++ b/.opencode/plugins/sce-bash-policy.ts @@ -32,9 +32,7 @@ function evaluateBashCommandPolicy(command: string): JsonPolicyResult | null { if (result.error) { if ((result.error as NodeJS.ErrnoException).code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } return null; } diff --git a/.sce/config.json b/.sce/config.json index 4298810e..dcf5092c 100644 --- a/.sce/config.json +++ b/.sce/config.json @@ -1,15 +1,19 @@ { "$schema": "https://sce.crocoder.dev/config.json", - "log_level": "error", + "integrations": { + "target": [ + "claude", + "opencode" + ] + }, "log_file": "context/tmp/sce.log", "log_file_mode": "append", + "log_level": "error", "policies": { "attribution_hooks": { "enabled": true }, "bash": { - "presets": [ - ], "custom": [ { "id": "use-nix-flake-check-over-cargo-test", @@ -42,7 +46,8 @@ }, "message": "This repository prefers `nix flake check` over direct `cargo check`. Run `nix flake check` instead." } - ] + ], + "presets": [] } } } diff --git a/cli/src/services/config/resolver.rs b/cli/src/services/config/resolver.rs index 3538ce77..b370ca64 100644 --- a/cli/src/services/config/resolver.rs +++ b/cli/src/services/config/resolver.rs @@ -276,6 +276,7 @@ where bash_policy_presets: None, bash_policy_custom: None, database_retry: None, + integrations: None, }; let mut validation_errors = Vec::new(); for loaded_path in &loaded_config_paths { @@ -318,6 +319,9 @@ where if let Some(database_retry) = layer.database_retry { file_config.database_retry = Some(database_retry); } + if let Some(integrations) = layer.integrations { + file_config.integrations = Some(integrations); + } } let mut resolved_log_level = ResolvedValue { diff --git a/cli/src/services/config/schema.rs b/cli/src/services/config/schema.rs index 7ff867a0..c5944b8d 100644 --- a/cli/src/services/config/schema.rs +++ b/cli/src/services/config/schema.rs @@ -17,7 +17,10 @@ use serde::Deserialize; use serde_json::Value; use super::policy::{parse_bash_policy_presets, parse_custom_bash_policies, CustomBashPolicyEntry}; -use super::types::{ConfigPathSource, DatabaseRetryConfig, LogFileMode, LogFormat, LogLevel}; +use super::types::{ + ConfigPathSource, DatabaseRetryConfig, IntegrationTargetId, IntegrationsConfig, LogFileMode, + LogFormat, LogLevel, +}; use crate::services::resilience::RetryPolicy; pub(crate) const SCE_CONFIG_SCHEMA_JSON: &str = @@ -34,10 +37,11 @@ pub(crate) const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ "timeout_ms", super::resolver::WORKOS_CLIENT_ID_KEY.config_key, "policies", + "integrations", ]; pub(crate) const TOP_LEVEL_CONFIG_KEYS_DESCRIPTION: &str = - "$schema, log_level, log_format, log_file, log_file_mode, timeout_ms, workos_client_id, policies"; + "$schema, log_level, log_format, log_file, log_file_mode, timeout_ms, workos_client_id, policies, integrations"; static CONFIG_SCHEMA_VALIDATOR: OnceLock = OnceLock::new(); @@ -68,6 +72,12 @@ pub(crate) struct ParsedFileConfigDocument { pub(crate) timeout_ms: Option, pub(crate) workos_client_id: Option, pub(crate) policies: Option, + pub(crate) integrations: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct ParsedIntegrationsConfigDocument { + pub(crate) target: Option>, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -141,6 +151,7 @@ pub(crate) struct FileConfig { pub(crate) bash_policy_presets: Option>>, pub(crate) bash_policy_custom: Option>>, pub(crate) database_retry: Option>, + pub(crate) integrations: Option>, } pub(crate) type ParsedBashPolicyConfig = ( @@ -285,6 +296,7 @@ pub(crate) fn parse_file_config( .map(|value| FileConfigValue { value, source }); let (attribution_hooks_enabled, bash_policy_presets, bash_policy_custom, database_retry) = map_policies_config(typed.policies.as_ref(), object, path, source)?; + let integrations = map_integrations_config(typed.integrations.as_ref(), object, path, source)?; Ok(FileConfig { log_level, @@ -297,6 +309,7 @@ pub(crate) fn parse_file_config( bash_policy_presets, bash_policy_custom, database_retry, + integrations, }) } @@ -569,3 +582,43 @@ pub(crate) fn map_database_retry_config( source, })) } + +fn map_integrations_config( + typed: Option<&ParsedIntegrationsConfigDocument>, + object: &serde_json::Map, + path: &Path, + source: ConfigPathSource, +) -> Result>> { + let Some(integrations_value) = object.get("integrations") else { + return Ok(None); + }; + + let integrations_object = integrations_value.as_object().with_context(|| { + format!( + "Config key 'integrations' in '{}' must be an object.", + path.display() + ) + })?; + + validate_object_keys( + integrations_object, + path, + Some("integrations"), + &["target"], + "target", + )?; + + let Some(raw_targets) = typed.and_then(|config| config.target.as_ref()) else { + return Ok(None); + }; + + let targets: Vec = raw_targets + .iter() + .map(|raw| IntegrationTargetId::parse(raw, &format!("config file '{}'", path.display()))) + .collect::>>()?; + + Ok(Some(FileConfigValue { + value: IntegrationsConfig { target: targets }, + source, + })) +} diff --git a/cli/src/services/config/types.rs b/cli/src/services/config/types.rs index 35732105..7a95c1b0 100644 --- a/cli/src/services/config/types.rs +++ b/cli/src/services/config/types.rs @@ -264,3 +264,26 @@ pub(crate) struct PerDbRetryConfig { pub(crate) connection_open: Option, pub(crate) query: Option, } + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum IntegrationTargetId { + Opencode, + Claude, +} + +impl IntegrationTargetId { + pub(crate) fn parse(raw: &str, source: &str) -> anyhow::Result { + match raw { + "opencode" => Ok(Self::Opencode), + "claude" => Ok(Self::Claude), + _ => anyhow::bail!( + "Invalid integration target '{raw}' from {source}. Valid values: opencode, claude." + ), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct IntegrationsConfig { + pub(crate) target: Vec, +} diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index f384a315..a974122f 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -5,6 +5,8 @@ use sha2::{Digest, Sha256}; use crate::services::agent_trace_db::lifecycle::diagnose_agent_trace_db_health; use crate::services::checkout; +use crate::services::config::schema::parse_file_config; +use crate::services::config::{ConfigPathSource, IntegrationTargetId}; use crate::services::default_paths::{ agent_trace_db_path_for_checkout, claude_asset, opencode_asset, InstallTargetPaths, RepoPaths, }; @@ -22,111 +24,6 @@ use super::types::{ }; use super::{is_executable, DoctorDependencies, DoctorMode, REQUIRED_HOOKS}; -#[allow(dead_code)] -pub(super) fn build_report_with_dependencies( - mode: DoctorMode, - repository_root: &Path, - dependencies: &DoctorDependencies<'_>, -) -> HookDoctorReport { - let mut problems = Vec::new(); - let global_state = collect_global_state_health(repository_root, &mut problems, dependencies); - let checkout_identity = collect_checkout_identity_health(repository_root); - let agent_trace_db = collect_agent_trace_db_health(repository_root, &mut problems); - let git_available = (dependencies.check_git_available)(); - - let detected_repository_root = if git_available { - (dependencies.run_git_command)(repository_root, &["rev-parse", "--show-toplevel"]) - .map(PathBuf::from) - } else { - None - }; - - let bare_repository = if git_available { - (dependencies.run_git_command)(repository_root, &["rev-parse", "--is-bare-repository"]) - .is_some_and(|value| value == "true") - } else { - false - }; - - let local_hooks_path = if git_available { - (dependencies.run_git_command)( - repository_root, - &["config", "--local", "--get", "core.hooksPath"], - ) - } else { - None - }; - let global_hooks_path = if git_available { - (dependencies.run_git_command)( - repository_root, - &["config", "--global", "--get", "core.hooksPath"], - ) - } else { - None - }; - - let hook_path_source = if local_hooks_path.is_some() { - HookPathSource::LocalConfig - } else if global_hooks_path.is_some() { - HookPathSource::GlobalConfig - } else { - HookPathSource::Default - }; - - let hooks_directory = detected_repository_root.as_ref().and_then(|resolved_root| { - (dependencies.run_git_command)(resolved_root, &["rev-parse", "--git-path", "hooks"]).map( - |value| { - let path = PathBuf::from(value); - if path.is_absolute() { - path - } else { - resolved_root.join(path) - } - }, - ) - }); - - let hooks = inspect_repository_hooks( - repository_root, - git_available, - bare_repository, - detected_repository_root.as_deref(), - hooks_directory.as_deref(), - &mut problems, - ); - - let integration_groups = inspect_repository_integrations( - git_available, - bare_repository, - detected_repository_root.as_deref(), - &mut problems, - ); - - let readiness = if problems - .iter() - .any(|problem| problem.severity == ProblemSeverity::Error) - { - Readiness::NotReady - } else { - Readiness::Ready - }; - - HookDoctorReport { - mode, - readiness, - state_root: global_state.state_root, - checkout_identity, - agent_trace_db, - repository_root: detected_repository_root, - hook_path_source, - hooks_directory, - config_locations: global_state.config_locations, - hooks, - integration_groups, - problems, - } -} - pub(super) fn build_report_with_lifecycle_problems( mode: DoctorMode, repository_root: &Path, @@ -225,6 +122,11 @@ fn build_report_without_service_owned_problem_checks( Vec::new() }; + let integration_targets_absent = should_show_no_integrations_message( + git_available, + bare_repository, + detected_repository_root.as_deref(), + ); let integration_groups = inspect_repository_integrations( git_available, bare_repository, @@ -244,6 +146,7 @@ fn build_report_without_service_owned_problem_checks( config_locations: global_state.config_locations, hooks, integration_groups, + integration_targets_absent, problems, } } @@ -496,6 +399,57 @@ fn inspect_repository_hooks( Vec::new() } +/// Returns `true` when the doctor was able to check for integration targets +/// and found none (neither configured in `.sce/config.json` nor detected +/// via repo-root `.opencode/` / `.claude/` directories). +fn should_show_no_integrations_message( + git_available: bool, + bare_repository: bool, + detected_repository_root: Option<&Path>, +) -> bool { + if !git_available || bare_repository { + return false; + } + let Some(root) = detected_repository_root else { + return false; + }; + resolve_doctor_integration_targets(root).is_empty() +} + +fn resolve_doctor_integration_targets(repository_root: &Path) -> Vec { + let repo_paths = RepoPaths::new(repository_root); + let config_path = repo_paths.sce_config_file(); + + // Try reading config first + if config_path.exists() { + if let Ok(raw) = std::fs::read_to_string(&config_path) { + if let Ok(config) = + parse_file_config(&raw, &config_path, ConfigPathSource::DefaultDiscoveredLocal) + { + if let Some(integrations) = config.integrations { + // integrations key present with a target property + if integrations.value.target.is_empty() { + // Empty target array — user has not recorded any integration targets + return Vec::new(); + } + // Non-empty configured targets + return integrations.value.target; + } + } + } + } + + // Fallback: no integrations config — detect installed directories + let mut detected = Vec::new(); + if repo_paths.opencode_dir().exists() { + detected.push(IntegrationTargetId::Opencode); + } + if repo_paths.claude_dir().exists() { + detected.push(IntegrationTargetId::Claude); + } + detected +} + fn inspect_repository_integrations( git_available: bool, bare_repository: bool, @@ -510,14 +464,40 @@ fn inspect_repository_integrations( return Vec::new(); }; - let opencode_groups = collect_opencode_integration_groups(resolved_root); - inspect_opencode_integration_health(resolved_root, &opencode_groups, problems); - - let claude_groups = collect_claude_integration_groups(resolved_root); - inspect_claude_integration_health(&claude_groups, problems); + let targets = resolve_doctor_integration_targets(resolved_root); + if targets.is_empty() { + problems.push(DoctorProblem { + kind: ProblemKind::NoIntegrationsInstalled, + category: ProblemCategory::RepoAssets, + severity: ProblemSeverity::Error, + fixability: ProblemFixability::ManualOnly, + summary: String::from( + "No integrations are installed. Run 'sce setup' to install OpenCode and/or Claude integration assets.", + ), + remediation: String::from( + "Run 'sce setup --opencode', 'sce setup --claude', or 'sce setup --both' to install integration assets.", + ), + next_action: "manual_steps", + }); + return Vec::new(); + } + let mut integration_groups = Vec::new(); + + for target in &targets { + match target { + IntegrationTargetId::Opencode => { + let opencode_groups = collect_opencode_integration_groups(resolved_root); + inspect_opencode_integration_health(resolved_root, &opencode_groups, problems); + integration_groups.extend(opencode_groups); + } + IntegrationTargetId::Claude => { + let claude_groups = collect_claude_integration_groups(resolved_root); + inspect_claude_integration_health(&claude_groups, problems); + integration_groups.extend(claude_groups); + } + } + } - let mut integration_groups = opencode_groups; - integration_groups.extend(claude_groups); integration_groups } diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index 847b4a42..a6e20dce 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -293,6 +293,7 @@ fn doctor_problem_kind(kind: HealthProblemKind) -> ProblemKind { HealthProblemKind::RequiredHookMissing => ProblemKind::RequiredHookMissing, HealthProblemKind::HookNotExecutable => ProblemKind::HookNotExecutable, HealthProblemKind::HookContentStale => ProblemKind::HookContentStale, + HealthProblemKind::NoIntegrationsInstalled => ProblemKind::NoIntegrationsInstalled, HealthProblemKind::OpenCodeIntegrationFilesMissing => { ProblemKind::OpenCodeIntegrationFilesMissing } @@ -342,6 +343,7 @@ fn health_problem_kind(kind: ProblemKind) -> HealthProblemKind { ProblemKind::RequiredHookMissing => HealthProblemKind::RequiredHookMissing, ProblemKind::HookNotExecutable => HealthProblemKind::HookNotExecutable, ProblemKind::HookContentStale => HealthProblemKind::HookContentStale, + ProblemKind::NoIntegrationsInstalled => HealthProblemKind::NoIntegrationsInstalled, ProblemKind::OpenCodeIntegrationFilesMissing => { HealthProblemKind::OpenCodeIntegrationFilesMissing } diff --git a/cli/src/services/doctor/render.rs b/cli/src/services/doctor/render.rs index c0e68691..053ab172 100644 --- a/cli/src/services/doctor/render.rs +++ b/cli/src/services/doctor/render.rs @@ -13,6 +13,10 @@ use super::types::{ }; use super::{DoctorExecution, DoctorFormat, DoctorMode, DoctorRequest, NAME, REQUIRED_HOOKS}; +/// Guidance message rendered in the Integrations section when no integration +/// targets are configured, detected, or both. +const NO_INTEGRATIONS_MESSAGE: &str = "No integrations installed; run 'sce setup'"; + pub(super) fn render_report(request: DoctorRequest, execution: &DoctorExecution) -> Result { match request.format { DoctorFormat::Text => Ok(format_execution(execution)), @@ -108,20 +112,29 @@ fn format_report_with_color_policy(report: &HookDoctorReport, color_enabled: boo push_git_hooks_section(report, color_enabled, &mut lines); lines.push(format!("\n{}:", heading("Integrations"))); - for group in integration_groups_for_text(report) { + if report.integration_targets_absent { lines.push(format_human_text_row( color_enabled, - integration_group_status(&group, report.repository_root.is_some()), - group.label, + HumanTextStatus::Fail, + NO_INTEGRATIONS_MESSAGE, "", )); - for child in &group.children { - lines.push(format_human_text_child_row( + } else { + for group in integration_groups_for_text(report) { + lines.push(format_human_text_row( color_enabled, - integration_child_status(child, report.repository_root.is_some()), - &child.relative_path, - integration_child_detail(child), + integration_group_status(&group, report.repository_root.is_some()), + group.label, + "", )); + for child in &group.children { + lines.push(format_human_text_child_row( + color_enabled, + integration_child_status(child, report.repository_root.is_some()), + &child.relative_path, + integration_child_detail(child), + )); + } } } diff --git a/cli/src/services/doctor/types.rs b/cli/src/services/doctor/types.rs index e6f073b1..ef8eed78 100644 --- a/cli/src/services/doctor/types.rs +++ b/cli/src/services/doctor/types.rs @@ -65,6 +65,7 @@ pub(super) struct HookDoctorReport { pub(super) config_locations: Vec, pub(super) hooks: Vec, pub(super) integration_groups: Vec, + pub(super) integration_targets_absent: bool, pub(super) problems: Vec, } @@ -132,6 +133,7 @@ pub(crate) enum ProblemKind { RequiredHookMissing, HookNotExecutable, HookContentStale, + NoIntegrationsInstalled, OpenCodeIntegrationFilesMissing, OpenCodeIntegrationContentMismatch, ClaudeIntegrationFilesMissing, diff --git a/cli/src/services/lifecycle.rs b/cli/src/services/lifecycle.rs index 7c051349..dc2c93d4 100644 --- a/cli/src/services/lifecycle.rs +++ b/cli/src/services/lifecycle.rs @@ -49,6 +49,7 @@ pub enum HealthProblemKind { RequiredHookMissing, HookNotExecutable, HookContentStale, + NoIntegrationsInstalled, OpenCodeIntegrationFilesMissing, OpenCodeIntegrationContentMismatch, ClaudeIntegrationFilesMissing, diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index eb0164a4..721227a1 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Context, Result}; +use serde_json::json; use std::{ fs, path::{Path, PathBuf}, @@ -187,6 +188,14 @@ pub fn run_setup_for_mode(repository_root: &Path, mode: SetupMode) -> Result &'static [SetupTarget } } +/// Convert a concrete [`SetupTarget`] (not `Both`) to its canonical +/// `integrations.target` string representation. +fn integration_target_id_str(target: SetupTarget) -> &'static str { + match target { + SetupTarget::OpenCode => "opencode", + SetupTarget::Claude => "claude", + SetupTarget::Both => { + unreachable!("integration_target_id_str must not be called with SetupTarget::Both") + } + } +} + +/// Persist a successfully installed setup target into the repo-local config file. +/// +/// Reads the existing `.sce/config.json`, merges the new concrete target(s) into +/// `integrations.target` (deduped, preserving existing unrelated fields), +/// and writes the file back. Creates the file with the bootstrap payload +/// if it does not already exist. +pub fn persist_integration_targets(repository_root: &Path, target: SetupTarget) -> Result<()> { + let repo_paths = RepoPaths::new(repository_root); + let config_file = repo_paths.sce_config_file(); + + // Read existing config or start with bootstrap payload. + let raw = if config_file.exists() { + fs::read_to_string(&config_file) + .with_context(|| format!("Failed to read config file '{}'", config_file.display()))? + } else { + bootstrap_repo_local_config(repository_root)?; + fs::read_to_string(&config_file) + .with_context(|| format!("Failed to read config file '{}'", config_file.display()))? + }; + + let mut config: serde_json::Value = serde_json::from_str(&raw).with_context(|| { + format!( + "Config file '{}' must contain valid JSON.", + config_file.display() + ) + })?; + + let config_obj = config.as_object_mut().with_context(|| { + format!( + "Config file '{}' must contain a top-level JSON object.", + config_file.display() + ) + })?; + + // Collect existing integration target values, if any. + let mut existing_targets: Vec = config_obj + .get("integrations") + .and_then(|i| i.get("target")) + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + // Add new concrete targets (expanding Both), deduping as we go. + let new_targets = concrete_targets_for(target); + for concrete in new_targets { + let id_str = integration_target_id_str(*concrete); + let id_owned = id_str.to_string(); + if !existing_targets.contains(&id_owned) { + existing_targets.push(id_owned); + } + } + + // Write the merged integrations block back. + config_obj.insert( + "integrations".to_string(), + json!({ "target": existing_targets }), + ); + + let updated = serde_json::to_string_pretty(&config).with_context(|| { + format!( + "Failed to serialize updated config for '{}'", + config_file.display() + ) + })? + "\n"; + + fs::write(&config_file, updated) + .with_context(|| format!("Failed to write config file '{}'", config_file.display()))?; + + Ok(()) +} + mod install { use anyhow::{bail, Context, Result}; use std::{ diff --git a/config/.opencode/plugins/sce-agent-trace.ts b/config/.opencode/plugins/sce-agent-trace.ts index 4a74bd3a..980ceb06 100644 --- a/config/.opencode/plugins/sce-agent-trace.ts +++ b/config/.opencode/plugins/sce-agent-trace.ts @@ -337,9 +337,7 @@ async function runDiffTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); @@ -364,9 +362,7 @@ async function runConversationTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); diff --git a/config/.opencode/plugins/sce-bash-policy.ts b/config/.opencode/plugins/sce-bash-policy.ts index 788929f3..0b2b8d9c 100644 --- a/config/.opencode/plugins/sce-bash-policy.ts +++ b/config/.opencode/plugins/sce-bash-policy.ts @@ -32,9 +32,7 @@ function evaluateBashCommandPolicy(command: string): JsonPolicyResult | null { if (result.error) { if ((result.error as NodeJS.ErrnoException).code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } return null; } diff --git a/config/automated/.opencode/plugins/sce-agent-trace.ts b/config/automated/.opencode/plugins/sce-agent-trace.ts index 4a74bd3a..980ceb06 100644 --- a/config/automated/.opencode/plugins/sce-agent-trace.ts +++ b/config/automated/.opencode/plugins/sce-agent-trace.ts @@ -337,9 +337,7 @@ async function runDiffTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); @@ -364,9 +362,7 @@ async function runConversationTraceHook( child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } resolve(); }); diff --git a/config/automated/.opencode/plugins/sce-bash-policy.ts b/config/automated/.opencode/plugins/sce-bash-policy.ts index 788929f3..0b2b8d9c 100644 --- a/config/automated/.opencode/plugins/sce-bash-policy.ts +++ b/config/automated/.opencode/plugins/sce-bash-policy.ts @@ -32,9 +32,7 @@ function evaluateBashCommandPolicy(command: string): JsonPolicyResult | null { if (result.error) { if ((result.error as NodeJS.ErrnoException).code === "ENOENT") { - console.warn( - `sce CLI not found. Install it from ${SCE_INSTALL_URL}`, - ); + console.warn(`sce CLI not found. Install it from ${SCE_INSTALL_URL}`); } return null; } diff --git a/config/pkl/base/sce-config-schema.pkl b/config/pkl/base/sce-config-schema.pkl index 36622300..beb20335 100644 --- a/config/pkl/base/sce-config-schema.pkl +++ b/config/pkl/base/sce-config-schema.pkl @@ -163,6 +163,20 @@ local sceConfigSchema = new JsonSchema { } } } + ["integrations"] = new JsonSchema { + type = "object" + additionalProperties = false + properties { + ["target"] = new JsonSchema { + type = "array" + uniqueItems = true + items = new JsonSchema { + type = "string" + enum = new { "opencode"; "claude" } + } + } + } + } } dependentRequired { ["log_file_mode"] = new { "log_file" } diff --git a/config/schema/sce-config.schema.json b/config/schema/sce-config.schema.json index 43d7605b..23966f09 100644 --- a/config/schema/sce-config.schema.json +++ b/config/schema/sce-config.schema.json @@ -338,6 +338,23 @@ } }, "additionalProperties": false + }, + "integrations": { + "type": "object", + "properties": { + "target": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "opencode", + "claude" + ] + }, + "uniqueItems": true + } + }, + "additionalProperties": false } }, "additionalProperties": false, diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index 7a76ad22..a2689941 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -61,7 +61,7 @@ When a default-discovered global or repo-local config file exists but fails JSON - Startup/runtime config resolution now degrades gracefully only for default-discovered files: invalid discovered files are skipped and reported via collected `validation_errors`, while explicit `--config` / `SCE_CONFIG_FILE` targets still fail immediately on the same parse or validation errors. - Config file content must be valid JSON with a top-level object. -- Allowed keys: `$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, `policies`. +- Allowed keys: `$schema`, `log_level`, `log_format`, `log_file`, `log_file_mode`, `timeout_ms`, `workos_client_id`, `policies`, `integrations`. - Unknown keys fail validation. - `log_level` must be one of `error|warn|info|debug`. - `log_format` must be `text` or `json` when present. @@ -71,6 +71,11 @@ When a default-discovered global or repo-local config file exists but fails JSON - `timeout_ms` must be an unsigned integer. - `workos_client_id` must be a string when present. +- `integrations` must be an object when present and currently allows only `target`. +- `integrations.target` must be an array of unique canonical target IDs when present. +- Supported target ID values: `opencode`, `claude`. +- Unknown target IDs fail schema validation. + - `policies` must be an object when present and currently allows `attribution_hooks`, `database_retry`, and `bash`. - `policies.attribution_hooks` must be an object when present and currently allows `enabled`; the generated schema documents default `true`, and explicit `enabled: false` remains a valid opt-out alongside the runtime `SCE_ATTRIBUTION_HOOKS_DISABLED` environment opt-out. - `policies.bash` must be an object when present and currently allows only `presets` and `custom`. diff --git a/context/context-map.md b/context/context-map.md index b17db384..f1941732 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -34,13 +34,13 @@ Feature/domain context: - `context/sce/agent-trace-commit-msg-coauthor-policy.md` (current commit-msg canonical co-author trailer policy with enabled-by-default attribution hooks, explicit opt-out controls, `SCE_DISABLED` kill switch, caller-provided `ai_contribution_present` transformer seam wired from staged-diff AI-overlap preflight, idempotent dedupe, the `agent_trace::patches_have_overlap` pure overlap seam, the `StagedDiffAiOverlapResult` three-valued evidence gate, and `sce.hooks.commit_msg.ai_overlap_error` error logging) - `context/sce/agent-trace-post-commit-dual-write.md` (historical post-commit no-op/dual-write reference; current post-commit behavior is documented in `agent-trace-hooks-command-routing.md`) - `context/sce/agent-trace-hook-doctor.md` (approved operator-environment contract for broadening `sce doctor` into the canonical health-and-repair entrypoint, including stable problem taxonomy, `--fix` semantics, checkout-aware Agent Trace DB reporting, setup-to-doctor alignment rules, and the approved downstream human text-mode layout/status/integration contract) -- `context/sce/doctor-human-text-contract.md` (implemented `sce doctor` human text layout contract: section order, `[PASS]`/`[FAIL]`/`[MISS]` status vocabulary, simplified hook rows, and OpenCode plus Claude integration group rendering rules) +- `context/sce/doctor-human-text-contract.md` (implemented `sce doctor` human text layout contract: section order, `[PASS]`/`[FAIL]`/`[MISS]` status vocabulary, simplified hook rows, target-scoped integration checks with configured/detected/empty target resolution, no-installed-integrations guidance, and OpenCode plus Claude integration group rendering rules) - `context/sce/setup-githooks-install-contract.md` (T01 canonical `sce setup --hooks` install contract for target-path resolution, idempotent outcomes, remove-and-replace behavior, and doctor-readiness alignment) - `context/sce/setup-no-backup-policy-seam.md` (implemented unified remove-and-replace install policy for both config-install and required-hook install flows, with no backup creation and deterministic recovery guidance on swap failure) - `context/sce/setup-githooks-hook-asset-packaging.md` (T02 compile-time `sce setup --hooks` required-hook template packaging contract, including current post-commit missing-`sce` install guidance, origin remote lookup, remote-URL forwarding/fallback behavior, and setup-service accessor surface) - `context/sce/setup-githooks-install-flow.md` (T03 setup-service required-hook install orchestration with git-truth hooks-path resolution, per-hook installed/updated/skipped outcomes, and remove-and-replace behavior with recovery guidance) - `context/sce/setup-githooks-cli-ux.md` (T04 composable `sce setup` target+`--hooks` / `--repo` command-surface contract, option compatibility validation, and deterministic setup/hook output semantics) -- `context/sce/setup-repo-local-config-bootstrap.md` (setup local bootstrap behavior: repo-local `.sce/config.json` create-if-missing via config lifecycle plus lifecycle-owned local DB initialization before hooks/config asset dispatch) +- `context/sce/setup-repo-local-config-bootstrap.md` (setup local bootstrap behavior: repo-local `.sce/config.json` create-if-missing via config lifecycle, additive `integrations.target` persistence after successful target installs, plus lifecycle-owned local DB initialization before hooks/config asset dispatch) - `context/sce/cli-security-hardening-contract.md` (T06 CLI redaction contract, setup `--repo` canonicalization/validation, and setup write-permission probe behavior) - `context/sce/agent-trace-post-rewrite-local-remap-ingestion.md` (current post-rewrite no-op baseline plus historical remap-ingestion reference) - `context/sce/agent-trace-rewrite-trace-transformation.md` (current post-rewrite no-op baseline plus historical rewrite-transformation reference) diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 1fdf5e9a..4f1cc544 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -41,7 +41,8 @@ The runtime in `cli/src/services/doctor/mod.rs` exposes the approved doctor comm - 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 (delegated to `HooksLifecycle::diagnose`) - byte-for-byte stale-content detection for required hook payloads against canonical embedded SCE-managed hook assets (delegated to `HooksLifecycle::diagnose`) -- repo-root installed OpenCode integration inventory for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills`, plus Claude integration inventory for `ClaudeCode plugins`, `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills` +- integration target resolution that reads `integrations.target` from repo-local `.sce/config.json` when present, or falls back to detecting repo-root `.opencode/` and `.claude/` directories when config has no `integrations` or `integrations.target`; only the resolved targets are inspected +- repo-root installed OpenCode integration inventory for `OpenCode plugins`, `OpenCode agents`, `OpenCode commands`, and `OpenCode skills`, plus Claude integration inventory for `ClaudeCode plugins`, `ClaudeCode agents`, `ClaudeCode commands`, and `ClaudeCode skills`, scoped to the resolved targets - integration child-row reporting validates installed files against embedded SHA-256 content; missing files render as `[MISS]`, content mismatches render as `[FAIL]`, and any affected parent group renders as `[FAIL]` - OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `agents/**`, `commands/**`, and `skills/**`); generated `config/.opencode/**` and `config/.claude/**` trees are not inspected by doctor - repair-mode delegation to `ServiceLifecycle::fix` implementations: `HooksLifecycle::fix` reuses `install_required_git_hooks` for missing hooks directories plus missing, stale, or non-executable required hooks; `LocalDbLifecycle::fix`, `AuthDbLifecycle::fix`, and `AgentTraceDbLifecycle::fix` handle bootstrap of missing canonical SCE-owned DB parent directories diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index a835a201..bafe7792 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -44,7 +44,14 @@ This simplification is text-mode only and does not change JSON output requiremen ## Integrations text contract -Human text output for `Integrations` must use exactly these groups: +Integration checks are target-scoped. The doctor resolves which integration targets to inspect using the following priority: + +1. **Configured targets**: If `.sce/config.json` has `integrations.target` with a non-empty array, only the listed targets (`opencode`, `claude`) are inspected. +2. **Empty target array**: If `integrations.target` exists but is an empty array `[]`, the user has not recorded any integration targets. The doctor returns no targets and renders a guidance message instead of group rows. +3. **Directory detection fallback**: When config has no `integrations` property or `integrations.target` property is absent, the doctor falls back to detecting installed repo-root directories — `.opencode/` is detected as OpenCode, `.claude/` is detected as Claude. +4. **No targets**: When directory detection identifies no installed directories either, the `Integrations` section renders `[FAIL] No integrations installed; run 'sce setup'` and a blocking `NoIntegrationsInstalled` problem is recorded, so the Summary counts it as a blocking problem. + +Human text output renders group rows only for the resolved targets: - `OpenCode plugins` - `OpenCode agents` diff --git a/context/sce/setup-githooks-cli-ux.md b/context/sce/setup-githooks-cli-ux.md index 34e81775..d4f2fc8d 100644 --- a/context/sce/setup-githooks-cli-ux.md +++ b/context/sce/setup-githooks-cli-ux.md @@ -36,6 +36,20 @@ Target-install mode contract: - legacy one-purpose invocations remain valid (`sce setup --hooks` for hooks-only, and `sce setup --opencode|--claude|--both` for config-only) - interactive setup without a TTY returns actionable guidance to rerun with `--non-interactive` plus a target flag +## Integration target persistence + +Non-interactive `--opencode`, `--claude`, and `--both` target installs persist the selected target(s) into `.sce/config.json` under `integrations.target` after successful config asset installation: + +- `--opencode` records `["opencode"]`. +- `--claude` adds `"claude"` to an existing array (e.g. `["opencode"]` → `["opencode", "claude"]`). +- `--both` records both `["opencode", "claude"]` atomically. +- Repeated runs are idempotent — existing targets are deduplicated; previously unrelated config keys (`$schema`, `log_level`, etc.) are preserved. +- If the config file does not exist, it is bootstrapped first, then the targets are written. +- `--hooks` only setup (`sce setup --hooks`) does not modify `integrations.target`. +- Interactive `sce setup` (no target flag) persists the interactively selected target(s) the same way as non-interactive flag equivalents. + +Hooks-only setup and failed installs do not update `integrations.target`. + ## Output contract Successful hook setup emits deterministic human/automation-friendly output including: diff --git a/context/sce/setup-repo-local-config-bootstrap.md b/context/sce/setup-repo-local-config-bootstrap.md index 4abcb5be..30f2849b 100644 --- a/context/sce/setup-repo-local-config-bootstrap.md +++ b/context/sce/setup-repo-local-config-bootstrap.md @@ -13,9 +13,20 @@ Task `setup-repo-gate-and-local-config-bootstrap` T02 and `turso-local-db-sync` - The setup flow also bootstraps the canonical local DB through `LocalDbLifecycle::setup` and the Agent Trace DB through `AgentTraceDbLifecycle::setup`; both use the shared `TursoDb` adapter. - The bootstrap runs after the git-repo gate (`ensure_git_repository`) and before config/hooks dispatch, so it applies to all setup modes: config-only, hooks-only, combined, and interactive. +## Post-install integration target persistence + +After config asset installation succeeds for a non-interactive target (`--opencode`, `--claude`, or `--both`), setup persists the selected target(s) into `.sce/config.json` under `integrations.target`: + +- `--opencode` records `["opencode"]`. +- `--claude` adds `"claude"` to an existing array (e.g. `["opencode"]` → `["opencode", "claude"]`). +- `--both` records both `["opencode", "claude"]` atomically. +- Repeated runs are idempotent — existing targets are deduplicated; previously unrelated config keys (`$schema`, `log_level`, etc.) are preserved. +- If the config file does not exist, it is bootstrapped first, then the targets are written. +- `--hooks` only setup does not modify `integrations.target`. + ## Implementation -- `cli/src/services/setup/mod.rs` exports `bootstrap_repo_local_config(repository_root: &Path) -> Result<()>`. +- `cli/src/services/setup/mod.rs` exports `bootstrap_repo_local_config(repository_root: &Path) -> Result<()>` and `persist_integration_targets(repository_root: &Path, target: SetupTarget) -> Result<()>`. - `cli/src/services/local_db/lifecycle.rs` implements `LocalDbLifecycle::setup()` for local DB initialization. - `cli/src/services/agent_trace_db/lifecycle.rs` implements `AgentTraceDbLifecycle::setup()` for Agent Trace DB initialization. - The function uses `RepoPaths::sce_config_file()` and `RepoPaths::sce_dir()` from `default_paths` for path resolution.