diff --git a/.uf/dewey/learnings/add-dcp-config-20260816T183957-jay-flowers.md b/.uf/dewey/learnings/add-dcp-config-20260816T183957-jay-flowers.md new file mode 100644 index 0000000..dc27157 --- /dev/null +++ b/.uf/dewey/learnings/add-dcp-config-20260816T183957-jay-flowers.md @@ -0,0 +1,10 @@ +--- +tag: add-dcp-config +author: jay-flowers +category: gotcha +created_at: 2026-08-16T18:39:57Z +identity: add-dcp-config-20260816T183957-jay-flowers +tier: draft +--- + +When adding new code to an existing file like internal/doctor/checks.go, always check if the new code follows the convention pack rules even if the existing code in the same file doesn't. In the add-dcp-config change, the checkDCPConfig() function initially used string concatenation for filesystem paths (projectDir + "/.opencode/commands") because the existing checkConfigDir() in the same file used the same pattern. All 5 review council agents flagged this as a SC-003 MUST violation — new code should use filepath.Join regardless of pre-existing patterns. The ScaffoldDCP() function in agentkit.go correctly used filepath.Join from the start, making the inconsistency within the same change more notable. Lesson: don't copy anti-patterns from existing code; follow the convention pack rules. diff --git a/.uf/dewey/learnings/add-dcp-config-20260816T184002-jay-flowers.md b/.uf/dewey/learnings/add-dcp-config-20260816T184002-jay-flowers.md new file mode 100644 index 0000000..c6b9eec --- /dev/null +++ b/.uf/dewey/learnings/add-dcp-config-20260816T184002-jay-flowers.md @@ -0,0 +1,10 @@ +--- +tag: add-dcp-config +author: jay-flowers +category: gotcha +created_at: 2026-08-16T18:40:02Z +identity: add-dcp-config-20260816T184002-jay-flowers +tier: draft +--- + +When fixing unchecked errors in test setup code (os.MkdirAll, os.WriteFile, os.ReadFile calls without error checking), apply the fix consistently across ALL test files touched by the change, not just the files where the new tests were added. In the add-dcp-config change, iteration 1 review found unchecked errors in agentkit_test.go and checks_test.go (new test code), but those fixes were not applied to pre-existing unchecked errors in init_test.go which was also modified by the change. The iteration 2 Testing reviewer caught this inconsistency and flagged it as HIGH severity. The pattern to follow: if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("setup MkdirAll: %v", err) } — wrap every setup call with t.Fatalf error checking. diff --git a/.uf/dewey/learnings/add-dcp-config-20260816T184006-jay-flowers.md b/.uf/dewey/learnings/add-dcp-config-20260816T184006-jay-flowers.md new file mode 100644 index 0000000..4ab65e4 --- /dev/null +++ b/.uf/dewey/learnings/add-dcp-config-20260816T184006-jay-flowers.md @@ -0,0 +1,10 @@ +--- +tag: add-dcp-config +author: jay-flowers +category: pattern +created_at: 2026-08-16T18:40:06Z +identity: add-dcp-config-20260816T184006-jay-flowers +tier: draft +--- + +The doctor package's Run() function signature was extended from Run(store *db.Store, cfg *config.Config) to Run(store *db.Store, cfg *config.Config, projectDir string) to support per-project checks like checkDCPConfig. The key design decision (D9) was to accept an explicit directory parameter rather than calling os.Getwd() inside the check function. This enables test isolation with t.TempDir() — tests pass a temp directory directly instead of needing unsafe os.Chdir() calls. The os.Getwd() call lives in the CLI layer (cmd/replicator/doctor.go) where it's the caller's responsibility. This pattern should be followed for any future per-project doctor checks. diff --git a/cmd/replicator/doctor.go b/cmd/replicator/doctor.go index 674d6fc..ff348c3 100644 --- a/cmd/replicator/doctor.go +++ b/cmd/replicator/doctor.go @@ -17,7 +17,12 @@ func runDoctor(cfg *config.Config) error { } defer store.Close() - results, err := doctor.Run(store, cfg) + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + results, err := doctor.Run(store, cfg, projectDir) if err != nil { return fmt.Errorf("run checks: %w", err) } diff --git a/cmd/replicator/init.go b/cmd/replicator/init.go index 2730eb8..a7169fc 100644 --- a/cmd/replicator/init.go +++ b/cmd/replicator/init.go @@ -33,9 +33,11 @@ global database (replicator setup) or any external services.`, return cmd } -// runInit creates the .uf/replicator/ directory, seeds cells.json, and -// scaffolds the agent kit into .opencode/. Uses styled output: green for -// created, dim for skipped, yellow for overwritten. +// runInit creates the .uf/replicator/ directory, seeds cells.json, +// scaffolds the agent kit into .opencode/, and creates or updates the +// DCP configuration (.opencode/dcp.jsonc) for protect tag support. +// Uses styled output: green for created, dim for skipped, yellow for +// overwritten. func runInit(targetDir string, force bool) error { styles := ui.NewStyles(os.Stdout) replicatorDir := filepath.Join(targetDir, ".uf", "replicator") @@ -76,5 +78,19 @@ func runInit(targetDir string, force bool) error { } } + // Scaffold DCP config for protect tag support. + dcpResult, err := agentkit.ScaffoldDCP(targetDir) + if err != nil { + return fmt.Errorf("scaffold DCP config: %w", err) + } + switch dcpResult.Action { + case "created": + fmt.Println(styles.Pass.Render(fmt.Sprintf("created .opencode/%s", dcpResult.Path))) + case "skipped": + fmt.Println(styles.Dim.Render(fmt.Sprintf("skipped .opencode/%s (exists)", dcpResult.Path))) + case "overwritten": + fmt.Println(styles.Warn.Render(fmt.Sprintf("overwritten .opencode/%s (added protectTags)", dcpResult.Path))) + } + return nil } diff --git a/cmd/replicator/init_test.go b/cmd/replicator/init_test.go index 6b478ec..3601898 100644 --- a/cmd/replicator/init_test.go +++ b/cmd/replicator/init_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" ) @@ -56,6 +57,19 @@ func TestRunInit_FreshDirectory(t *testing.T) { t.Errorf("expected agent kit file %s to exist: %v", rel, err) } } + + // Verify DCP config was created with protectTags. + dcpPath := filepath.Join(dir, ".opencode", "dcp.jsonc") + dcpData, err := os.ReadFile(dcpPath) + if err != nil { + t.Fatalf("dcp.jsonc not created: %v", err) + } + if !strings.Contains(string(dcpData), "protectTags") { + t.Error("dcp.jsonc missing protectTags") + } + if !strings.Contains(string(dcpData), "$schema") { + t.Error("dcp.jsonc missing $schema") + } } func TestRunInit_AgentKitSkipsExisting(t *testing.T) { @@ -63,16 +77,23 @@ func TestRunInit_AgentKitSkipsExisting(t *testing.T) { // Pre-create a file that init would scaffold. forgePath := filepath.Join(dir, ".opencode", "commands", "forge.md") - os.MkdirAll(filepath.Dir(forgePath), 0o755) + if err := os.MkdirAll(filepath.Dir(forgePath), 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } original := []byte("# my custom forge\n") - os.WriteFile(forgePath, original, 0o644) + if err := os.WriteFile(forgePath, original, 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } if err := runInit(dir, false); err != nil { t.Fatalf("runInit: %v", err) } // Verify the pre-existing file was NOT overwritten. - data, _ := os.ReadFile(forgePath) + data, err := os.ReadFile(forgePath) + if err != nil { + t.Fatalf("read forge.md: %v", err) + } if string(data) != string(original) { t.Errorf("forge.md was overwritten: got %q, want %q", string(data), string(original)) } @@ -89,16 +110,23 @@ func TestRunInit_ForceOverwrites(t *testing.T) { // Pre-create a file that init would scaffold. forgePath := filepath.Join(dir, ".opencode", "commands", "forge.md") - os.MkdirAll(filepath.Dir(forgePath), 0o755) + if err := os.MkdirAll(filepath.Dir(forgePath), 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } original := []byte("# my custom forge\n") - os.WriteFile(forgePath, original, 0o644) + if err := os.WriteFile(forgePath, original, 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } if err := runInit(dir, true); err != nil { t.Fatalf("runInit with force: %v", err) } // Verify the pre-existing file WAS overwritten. - data, _ := os.ReadFile(forgePath) + data, err := os.ReadFile(forgePath) + if err != nil { + t.Fatalf("read forge.md: %v", err) + } if string(data) == string(original) { t.Error("forge.md was NOT overwritten despite force=true") } @@ -114,7 +142,9 @@ func TestRunInit_AlreadyInitialized(t *testing.T) { // Write something to cells.json to verify it's not overwritten. cellsPath := filepath.Join(dir, ".uf", "replicator", "cells.json") - os.WriteFile(cellsPath, []byte(`[{"id":"test"}]`), 0o644) + if err := os.WriteFile(cellsPath, []byte(`[{"id":"test"}]`), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } // Second init — should be idempotent. if err := runInit(dir, false); err != nil { @@ -122,7 +152,10 @@ func TestRunInit_AlreadyInitialized(t *testing.T) { } // Verify cells.json was NOT overwritten. - data, _ := os.ReadFile(cellsPath) + data, err := os.ReadFile(cellsPath) + if err != nil { + t.Fatalf("read cells.json: %v", err) + } if string(data) != `[{"id":"test"}]` { t.Errorf("cells.json was overwritten: got %q", string(data)) } @@ -132,12 +165,32 @@ func TestRunInit_AlreadyInitialized(t *testing.T) { if _, err := os.Stat(forgePath); err != nil { t.Errorf("agent kit files should exist after second init: %v", err) } + + // Verify DCP config still exists and content was preserved. + dcpPath := filepath.Join(dir, ".opencode", "dcp.jsonc") + dcpBefore, err := os.ReadFile(dcpPath) + if err != nil { + t.Fatalf("read dcp.jsonc before second init: %v", err) + } + // Re-run init and verify DCP content is unchanged. + if err := runInit(dir, false); err != nil { + t.Fatalf("third runInit: %v", err) + } + dcpAfter, err := os.ReadFile(dcpPath) + if err != nil { + t.Fatalf("read dcp.jsonc after third init: %v", err) + } + if string(dcpAfter) != string(dcpBefore) { + t.Error("dcp.jsonc was modified on re-init") + } } func TestRunInit_CustomPath(t *testing.T) { parent := t.TempDir() target := filepath.Join(parent, "myproject") - os.MkdirAll(target, 0o755) + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } if err := runInit(target, false); err != nil { t.Fatalf("runInit with custom path: %v", err) diff --git a/internal/agentkit/agentkit.go b/internal/agentkit/agentkit.go index 762c4c2..9de44b1 100644 --- a/internal/agentkit/agentkit.go +++ b/internal/agentkit/agentkit.go @@ -11,6 +11,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" ) //go:embed content/* @@ -22,6 +23,68 @@ type ScaffoldResult struct { Action string `json:"action"` // "created", "skipped", "overwritten" } +// dcpConfigContent is the canonical DCP config that enables protectTags. +// This matches the replicator repo's own .opencode/dcp.jsonc. +const dcpConfigContent = `{ + "$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json", + // Enable tag preservation during DCP compression. + // Slash command files in .opencode/commands/ use tags + // to mark execution-critical sections (guardrails, checklists, + // mandatory gates) that must survive context pruning. + "compress": { + "protectTags": true + } +} +` + +// ScaffoldDCP creates or overwrites the DCP configuration file in +// targetDir/.opencode/. It checks for dcp.jsonc first, then dcp.json. +// If neither exists, it creates dcp.jsonc. If one exists with protectTags +// already configured, it is skipped. If one exists without protectTags, +// the file is overwritten with the canonical DCP config content. +func ScaffoldDCP(targetDir string) (ScaffoldResult, error) { + openCodeDir := filepath.Join(targetDir, ".opencode") + jsoncPath := filepath.Join(openCodeDir, "dcp.jsonc") + jsonPath := filepath.Join(openCodeDir, "dcp.json") + + // Check .jsonc first, then .json (D3: prefer .jsonc). + var existingPath, fileName string + if _, err := os.Stat(jsoncPath); err == nil { + existingPath = jsoncPath + fileName = "dcp.jsonc" + } else if _, err := os.Stat(jsonPath); err == nil { + existingPath = jsonPath + fileName = "dcp.json" + } + + if existingPath != "" { + // File exists — check for protectTags (D2: string scan). + data, err := os.ReadFile(existingPath) + if err != nil { + return ScaffoldResult{}, fmt.Errorf("read %s: %w", fileName, err) + } + + if strings.Contains(string(data), "protectTags") { + return ScaffoldResult{Path: fileName, Action: "skipped"}, nil + } + + // File exists but lacks protectTags — replace with canonical content (D10). + if err := os.WriteFile(existingPath, []byte(dcpConfigContent), 0o644); err != nil { + return ScaffoldResult{}, fmt.Errorf("write %s: %w", fileName, err) + } + return ScaffoldResult{Path: fileName, Action: "overwritten"}, nil + } + + // Neither file exists — create .opencode/dcp.jsonc. + if err := os.MkdirAll(openCodeDir, 0o755); err != nil { + return ScaffoldResult{}, fmt.Errorf("create .opencode directory: %w", err) + } + if err := os.WriteFile(jsoncPath, []byte(dcpConfigContent), 0o644); err != nil { + return ScaffoldResult{}, fmt.Errorf("write dcp.jsonc: %w", err) + } + return ScaffoldResult{Path: "dcp.jsonc", Action: "created"}, nil +} + // Scaffold writes the embedded agent kit files to targetDir/.opencode/. // If force is false, existing files are skipped. If force is true, // existing files are overwritten. diff --git a/internal/agentkit/agentkit_test.go b/internal/agentkit/agentkit_test.go index 2f1ecc6..5b845fc 100644 --- a/internal/agentkit/agentkit_test.go +++ b/internal/agentkit/agentkit_test.go @@ -1078,6 +1078,183 @@ func TestSkillFiles_DriftDetection(t *testing.T) { +func TestScaffoldDCP_FreshDirectory(t *testing.T) { + dir := t.TempDir() + result, err := ScaffoldDCP(dir) + if err != nil { + t.Fatalf("ScaffoldDCP: %v", err) + } + + if result.Action != "created" { + t.Errorf("action = %q, want %q", result.Action, "created") + } + if result.Path != "dcp.jsonc" { + t.Errorf("path = %q, want %q", result.Path, "dcp.jsonc") + } + + // Verify the file exists and contains protectTags. + data, err := os.ReadFile(filepath.Join(dir, ".opencode", "dcp.jsonc")) + if err != nil { + t.Fatalf("read dcp.jsonc: %v", err) + } + if !strings.Contains(string(data), "protectTags") { + t.Error("dcp.jsonc missing protectTags") + } + if !strings.Contains(string(data), `"$schema"`) { + t.Error("dcp.jsonc missing $schema") + } +} + +func TestScaffoldDCP_ExistingWithProtectTags(t *testing.T) { + dir := t.TempDir() + dcpDir := filepath.Join(dir, ".opencode") + if err := os.MkdirAll(dcpDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + existing := []byte(`{"compress":{"protectTags":true}}`) + if err := os.WriteFile(filepath.Join(dcpDir, "dcp.jsonc"), existing, 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + + result, err := ScaffoldDCP(dir) + if err != nil { + t.Fatalf("ScaffoldDCP: %v", err) + } + + if result.Action != "skipped" { + t.Errorf("action = %q, want %q", result.Action, "skipped") + } + + // Verify file was NOT overwritten. + data, err := os.ReadFile(filepath.Join(dcpDir, "dcp.jsonc")) + if err != nil { + t.Fatalf("read dcp.jsonc: %v", err) + } + if string(data) != string(existing) { + t.Error("dcp.jsonc was modified despite having protectTags") + } +} + +func TestScaffoldDCP_ExistingWithoutProtectTags(t *testing.T) { + dir := t.TempDir() + dcpDir := filepath.Join(dir, ".opencode") + if err := os.MkdirAll(dcpDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join(dcpDir, "dcp.jsonc"), []byte(`{"compress":{"minTokens":100}}`), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + + result, err := ScaffoldDCP(dir) + if err != nil { + t.Fatalf("ScaffoldDCP: %v", err) + } + + if result.Action != "overwritten" { + t.Errorf("action = %q, want %q", result.Action, "overwritten") + } + + // Verify file was overwritten with canonical content containing protectTags. + data, err := os.ReadFile(filepath.Join(dcpDir, "dcp.jsonc")) + if err != nil { + t.Fatalf("read dcp.jsonc: %v", err) + } + if !strings.Contains(string(data), "protectTags") { + t.Error("dcp.jsonc missing protectTags after update") + } + if !strings.Contains(string(data), "$schema") { + t.Error("dcp.jsonc missing $schema after update") + } +} + +func TestScaffoldDCP_JSONAlias(t *testing.T) { + dir := t.TempDir() + dcpDir := filepath.Join(dir, ".opencode") + if err := os.MkdirAll(dcpDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + // Only .dcp.json exists (no .jsonc). + if err := os.WriteFile(filepath.Join(dcpDir, "dcp.json"), []byte(`{"compress":{"minTokens":100}}`), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + + result, err := ScaffoldDCP(dir) + if err != nil { + t.Fatalf("ScaffoldDCP: %v", err) + } + + // Should operate on the .json file. + if result.Path != "dcp.json" { + t.Errorf("path = %q, want %q", result.Path, "dcp.json") + } + if result.Action != "overwritten" { + t.Errorf("action = %q, want %q", result.Action, "overwritten") + } + + // Verify .json file was overwritten (not a new .jsonc created). + data, err := os.ReadFile(filepath.Join(dcpDir, "dcp.json")) + if err != nil { + t.Fatalf("read dcp.json: %v", err) + } + if !strings.Contains(string(data), "protectTags") { + t.Error("dcp.json missing protectTags after update") + } + if !strings.Contains(string(data), "$schema") { + t.Error("dcp.json missing $schema after update") + } + // Verify .jsonc was NOT created. + if _, err := os.Stat(filepath.Join(dcpDir, "dcp.jsonc")); err == nil { + t.Error("dcp.jsonc should not be created when dcp.json exists") + } +} + +func TestScaffoldDCP_BothFilesExist(t *testing.T) { + dir := t.TempDir() + dcpDir := filepath.Join(dir, ".opencode") + if err := os.MkdirAll(dcpDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + // Both exist — .jsonc should be preferred. + if err := os.WriteFile(filepath.Join(dcpDir, "dcp.jsonc"), []byte(`{"compress":{"protectTags":true}}`), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + if err := os.WriteFile(filepath.Join(dcpDir, "dcp.json"), []byte(`{"compress":{"minTokens":100}}`), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + + result, err := ScaffoldDCP(dir) + if err != nil { + t.Fatalf("ScaffoldDCP: %v", err) + } + + // Should operate on .jsonc (preferred), which has protectTags → skip. + if result.Path != "dcp.jsonc" { + t.Errorf("path = %q, want %q", result.Path, "dcp.jsonc") + } + if result.Action != "skipped" { + t.Errorf("action = %q, want %q", result.Action, "skipped") + } +} + +func TestScaffoldDCP_CreatesOpenCodeDir(t *testing.T) { + dir := t.TempDir() + // .opencode/ does not exist yet. + + _, err := ScaffoldDCP(dir) + if err != nil { + t.Fatalf("ScaffoldDCP: %v", err) + } + + // Verify .opencode/ directory was created. + info, err := os.Stat(filepath.Join(dir, ".opencode")) + if err != nil { + t.Fatalf(".opencode/ not created: %v", err) + } + if !info.IsDir() { + t.Error(".opencode/ is not a directory") + } +} + func TestSkillTemplates_HaveNameField(t *testing.T) { // Walk the embedded content filesystem and verify every SKILL.md // has a "name: " field in its YAML frontmatter. diff --git a/internal/doctor/checks.go b/internal/doctor/checks.go index 0b20a28..d333952 100644 --- a/internal/doctor/checks.go +++ b/internal/doctor/checks.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "time" @@ -27,13 +28,15 @@ type CheckResult struct { // Run executes all health checks and returns the results. // Individual check failures do not stop subsequent checks. -func Run(store *db.Store, cfg *config.Config) ([]CheckResult, error) { +// The projectDir parameter is used for per-project checks (e.g., DCP config). +func Run(store *db.Store, cfg *config.Config, projectDir string) ([]CheckResult, error) { var results []CheckResult results = append(results, checkGit()) results = append(results, checkDatabase(store)) results = append(results, checkDewey(cfg.DeweyURL)) results = append(results, checkConfigDir()) + results = append(results, checkDCPConfig(projectDir)) return results, nil } @@ -128,6 +131,109 @@ func deweyHealthProbe(deweyURL string) error { return err } +// checkDCPConfig verifies DCP configuration when protect-tagged commands exist. +// It scans .opencode/commands/*.md for tags. If none are found, +// the check passes (DCP is not needed). If protect tags are found, it checks +// for .opencode/dcp.jsonc (or dcp.json) with protectTags enabled. +func checkDCPConfig(projectDir string) CheckResult { + start := time.Now() + + cmdDir := filepath.Join(projectDir, ".opencode", "commands") + entries, err := os.ReadDir(cmdDir) + if err != nil { + // No commands directory — no protect tags to worry about. + elapsed := time.Since(start) + return CheckResult{ + Name: "dcp_config", + Status: "pass", + Message: "no protect-tagged commands found", + Duration: elapsed, + } + } + + // Scan command files for tags. + hasProtectTags := false + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + data, readErr := os.ReadFile(filepath.Join(cmdDir, e.Name())) + if readErr != nil { + elapsed := time.Since(start) + return CheckResult{ + Name: "dcp_config", + Status: "warn", + Message: fmt.Sprintf("cannot read command file %s: %v", e.Name(), readErr), + Duration: elapsed, + } + } + if strings.Contains(string(data), "") { + hasProtectTags = true + break + } + } + + if !hasProtectTags { + elapsed := time.Since(start) + return CheckResult{ + Name: "dcp_config", + Status: "pass", + Message: "no protect-tagged commands found", + Duration: elapsed, + } + } + + // Protect tags found — check for DCP config file. + jsoncPath := filepath.Join(projectDir, ".opencode", "dcp.jsonc") + jsonPath := filepath.Join(projectDir, ".opencode", "dcp.json") + + var configPath string + if _, statErr := os.Stat(jsoncPath); statErr == nil { + configPath = jsoncPath + } else if _, statErr := os.Stat(jsonPath); statErr == nil { + configPath = jsonPath + } + + if configPath == "" { + elapsed := time.Since(start) + return CheckResult{ + Name: "dcp_config", + Status: "warn", + Message: "protect-tagged commands found but no DCP config; run replicator init", + Duration: elapsed, + } + } + + data, err := os.ReadFile(configPath) + if err != nil { + elapsed := time.Since(start) + return CheckResult{ + Name: "dcp_config", + Status: "warn", + Message: fmt.Sprintf("cannot read DCP config: %v", err), + Duration: elapsed, + } + } + + if !strings.Contains(string(data), "protectTags") { + elapsed := time.Since(start) + return CheckResult{ + Name: "dcp_config", + Status: "warn", + Message: "DCP config exists but protectTags is not enabled", + Duration: elapsed, + } + } + + elapsed := time.Since(start) + return CheckResult{ + Name: "dcp_config", + Status: "pass", + Message: "protectTags enabled in DCP config", + Duration: elapsed, + } +} + // checkConfigDir verifies the config directory exists. func checkConfigDir() CheckResult { start := time.Now() diff --git a/internal/doctor/checks_test.go b/internal/doctor/checks_test.go index d94467e..9de6875 100644 --- a/internal/doctor/checks_test.go +++ b/internal/doctor/checks_test.go @@ -6,6 +6,8 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -125,13 +127,16 @@ func TestRun_AllChecks(t *testing.T) { DeweyURL: srv.URL, } - results, err := Run(store, cfg) + // Use a temp dir as project dir for the DCP config check. + projectDir := t.TempDir() + + results, err := Run(store, cfg, projectDir) if err != nil { t.Fatalf("Run: %v", err) } - if len(results) != 4 { - t.Fatalf("expected 4 checks, got %d", len(results)) + if len(results) != 5 { + t.Fatalf("expected 5 checks, got %d", len(results)) } // Verify check names. @@ -139,7 +144,7 @@ func TestRun_AllChecks(t *testing.T) { for _, r := range results { names[r.Name] = true } - for _, expected := range []string{"git", "database", "dewey", "config_dir"} { + for _, expected := range []string{"git", "database", "dewey", "config_dir", "dcp_config"} { if !names[expected] { t.Errorf("missing check: %s", expected) } @@ -284,6 +289,168 @@ func TestCheckConfigDir(t *testing.T) { } } +func TestCheckDCPConfig_PassWithProtectTags(t *testing.T) { + dir := t.TempDir() + cmdDir := filepath.Join(dir, ".opencode", "commands") + if err := os.MkdirAll(cmdDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + // Create a command with tag. + if err := os.WriteFile(filepath.Join(cmdDir, "forge.md"), []byte("---\n---\n\n\n# /forge\n"), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + // Create DCP config with protectTags. + if err := os.WriteFile(filepath.Join(dir, ".opencode", "dcp.jsonc"), []byte(`{"compress":{"protectTags":true}}`), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + + result := checkDCPConfig(dir) + if result.Name != "dcp_config" { + t.Errorf("name = %q, want %q", result.Name, "dcp_config") + } + if result.Status != "pass" { + t.Errorf("status = %q, want %q (message: %s)", result.Status, "pass", result.Message) + } + if !strings.Contains(result.Message, "protectTags enabled") { + t.Errorf("message = %q, want it to contain %q", result.Message, "protectTags enabled") + } + if result.Duration <= 0 { + t.Error("duration should be positive") + } +} + +func TestCheckDCPConfig_PassNoCommands(t *testing.T) { + dir := t.TempDir() + // No .opencode/commands/ directory — no protect-tagged commands. + + result := checkDCPConfig(dir) + if result.Status != "pass" { + t.Errorf("status = %q, want %q (message: %s)", result.Status, "pass", result.Message) + } + if !strings.Contains(result.Message, "no protect-tagged commands") { + t.Errorf("message = %q, want it to contain %q", result.Message, "no protect-tagged commands") + } +} + +func TestCheckDCPConfig_PassNoProtectTags(t *testing.T) { + dir := t.TempDir() + cmdDir := filepath.Join(dir, ".opencode", "commands") + if err := os.MkdirAll(cmdDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + // Command file WITHOUT tag. + if err := os.WriteFile(filepath.Join(cmdDir, "forge.md"), []byte("---\n---\n\n# /forge\n"), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + + result := checkDCPConfig(dir) + if result.Status != "pass" { + t.Errorf("status = %q, want %q (message: %s)", result.Status, "pass", result.Message) + } + if !strings.Contains(result.Message, "no protect-tagged commands") { + t.Errorf("message = %q, want it to contain %q", result.Message, "no protect-tagged commands") + } +} + +func TestCheckDCPConfig_WarnNoDCPConfig(t *testing.T) { + dir := t.TempDir() + cmdDir := filepath.Join(dir, ".opencode", "commands") + if err := os.MkdirAll(cmdDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + // Create a command with tag but NO DCP config. + if err := os.WriteFile(filepath.Join(cmdDir, "forge.md"), []byte("---\n---\n\n\n# /forge\n"), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + + result := checkDCPConfig(dir) + if result.Status != "warn" { + t.Errorf("status = %q, want %q (message: %s)", result.Status, "warn", result.Message) + } + if !strings.Contains(result.Message, "replicator init") { + t.Errorf("message = %q, want it to contain %q", result.Message, "replicator init") + } +} + +func TestCheckDCPConfig_WarnMissingProtectTags(t *testing.T) { + dir := t.TempDir() + cmdDir := filepath.Join(dir, ".opencode", "commands") + if err := os.MkdirAll(cmdDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + // Create a command with tag. + if err := os.WriteFile(filepath.Join(cmdDir, "forge.md"), []byte("---\n---\n\n\n# /forge\n"), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + // Create DCP config WITHOUT protectTags. + if err := os.WriteFile(filepath.Join(dir, ".opencode", "dcp.jsonc"), []byte(`{"compress":{"minTokens":100}}`), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + + result := checkDCPConfig(dir) + if result.Status != "warn" { + t.Errorf("status = %q, want %q (message: %s)", result.Status, "warn", result.Message) + } + if !strings.Contains(result.Message, "protectTags") { + t.Errorf("message = %q, want it to contain %q", result.Message, "protectTags") + } +} + +func TestCheckDCPConfig_PassWithJSONAlias(t *testing.T) { + dir := t.TempDir() + cmdDir := filepath.Join(dir, ".opencode", "commands") + if err := os.MkdirAll(cmdDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + // Create a command with tag. + if err := os.WriteFile(filepath.Join(cmdDir, "forge.md"), []byte("---\n---\n\n\n# /forge\n"), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + // Create DCP config as .dcp.json (not .jsonc) with protectTags. + if err := os.WriteFile(filepath.Join(dir, ".opencode", "dcp.json"), []byte(`{"compress":{"protectTags":true}}`), 0o644); err != nil { + t.Fatalf("setup WriteFile: %v", err) + } + + result := checkDCPConfig(dir) + if result.Status != "pass" { + t.Errorf("status = %q, want %q (message: %s)", result.Status, "pass", result.Message) + } + if !strings.Contains(result.Message, "protectTags enabled") { + t.Errorf("message = %q, want it to contain %q", result.Message, "protectTags enabled") + } +} + +func TestCheckDCPConfig_WarnUnreadableCommandFile(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("cannot test permission denial as root") + } + dir := t.TempDir() + cmdDir := filepath.Join(dir, ".opencode", "commands") + if err := os.MkdirAll(cmdDir, 0o755); err != nil { + t.Fatalf("setup MkdirAll: %v", err) + } + // Create DCP config with protectTags so we reach the command scan. + if err := os.WriteFile(filepath.Join(dir, ".opencode", "dcp.jsonc"), []byte(`{"compress":{"protectTags":true}}`), 0o644); err != nil { + t.Fatalf("setup WriteFile dcp: %v", err) + } + // Create an unreadable command file. + cmdFile := filepath.Join(cmdDir, "broken.md") + if err := os.WriteFile(cmdFile, []byte(""), 0o000); err != nil { + t.Fatalf("setup WriteFile cmd: %v", err) + } + + result := checkDCPConfig(dir) + if result.Status != "warn" { + t.Errorf("status = %q, want %q", result.Status, "warn") + } + if !strings.Contains(result.Message, "cannot read command file") { + t.Errorf("message = %q, want it to contain %q", result.Message, "cannot read command file") + } + if !strings.Contains(result.Message, "broken.md") { + t.Errorf("message = %q, want it to contain file name %q", result.Message, "broken.md") + } +} + func TestCheckResult_StatusValues(t *testing.T) { // Verify that all results use valid status values. store := testStore(t) @@ -291,7 +458,8 @@ func TestCheckResult_StatusValues(t *testing.T) { defer srv.Close() cfg := &config.Config{DeweyURL: srv.URL} - results, _ := Run(store, cfg) + projectDir := t.TempDir() + results, _ := Run(store, cfg, projectDir) validStatuses := map[string]bool{"pass": true, "fail": true, "warn": true} for _, r := range results { diff --git a/openspec/changes/add-dcp-config/.openspec.yaml b/openspec/changes/add-dcp-config/.openspec.yaml new file mode 100644 index 0000000..c9defb1 --- /dev/null +++ b/openspec/changes/add-dcp-config/.openspec.yaml @@ -0,0 +1,2 @@ +schema: unbound-force +created: 2026-08-16 diff --git a/openspec/changes/add-dcp-config/design.md b/openspec/changes/add-dcp-config/design.md new file mode 100644 index 0000000..fe8252b --- /dev/null +++ b/openspec/changes/add-dcp-config/design.md @@ -0,0 +1,90 @@ +## Context + +`replicator init` scaffolds 15 agent kit files into `.opencode/` (5 commands, 7 skills, 3 agents). All five command files contain `` tags that mark execution-critical sections (guardrails, checklists, mandatory gates) for DCP preservation during context pruning. However, the DCP configuration file (`.opencode/dcp.jsonc`) that enables `protectTags: true` is not scaffolded, leaving the protect tags inert. + +The replicator repo itself has `.opencode/dcp.jsonc` committed, but user projects initialized via `replicator init` do not receive it. + +## Goals / Non-Goals + +### Goals +- Scaffold `.opencode/dcp.jsonc` with `protectTags: true` during `replicator init` +- Handle idempotent behavior: skip if already configured, merge if file exists but lacks `protectTags` +- Handle `.opencode/dcp.json` (without `c`) as an alias +- Maintain separation from the existing `Scaffold()` file walk +- Add a `dcp_config` health check to `replicator doctor` that warns when DCP config is missing or incomplete + +### Non-Goals +- Full JSONC parsing (comment stripping, AST manipulation) — too complex for the value +- Modifying `opencode.json` — `protectTags` belongs in DCP plugin config, not core config +- Changing the existing 15-file scaffold count — DCP is a separate function +- Supporting custom DCP config beyond `protectTags: true` + +## Decisions + +### D1: Separate `ScaffoldDCP()` function (not embedded in file walk) + +The existing `Scaffold()` function walks an embedded filesystem and writes files with simple create/skip/overwrite logic. DCP config requires merge semantics (detect existing `protectTags`, preserve user customizations). A separate function keeps these concerns cleanly separated. + +This aligns with **Composability First** — `ScaffoldDCP()` is independently callable without requiring `Scaffold()` to run first. + +### D2: String scan for `protectTags` detection + +Use `strings.Contains(content, `"protectTags"`)` to detect whether an existing DCP config already has protectTags configured, rather than parsing JSONC. JSONC parsing would require stripping comments before JSON unmarshaling, adding complexity for a simple boolean check. + +Trade-off: If a user has `protectTags` in a comment but not as an actual setting, we'd incorrectly skip. This is an acceptable false-positive — the user clearly knows about the setting. + +### D3: Prefer `.jsonc` extension, check both + +Check for `.opencode/dcp.jsonc` first, then `.opencode/dcp.json`. If neither exists, create `.opencode/dcp.jsonc`. If `.dcp.json` exists (without `c`), operate on that file to respect the user's choice. + +### D4: Return single `ScaffoldResult` + +`ScaffoldDCP()` returns `(ScaffoldResult, error)` with the same `Path`/`Action` shape used by `Scaffold()`. Actions: "created" (fresh), "skipped" (already has protectTags), "updated" (merged protectTags into existing file). This aligns with **Observable Quality** — consistent, machine-parseable output. + +### D5: DCP config content matches replicator's own `.opencode/dcp.jsonc` + +The scaffolded file will contain: +```jsonc +{ + "$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json", + // Enable tag preservation during DCP compression. + // Slash command files in .opencode/commands/ use tags + // to mark execution-critical sections (guardrails, checklists, + // mandatory gates) that must survive context pruning. + "compress": { + "protectTags": true + } +} +``` + +### D6: Doctor check uses `warn` status (not `fail`) + +DCP is optional — projects function without it (protect tags are simply inert). Like the Dewey check, the DCP config check returns `warn` when config is missing or incomplete, not `fail`. This follows the established pattern: only environment essentials (git, database, config dir) cause failures. + +### D7: Doctor check scans for `` tags in `.opencode/commands/` + +Rather than unconditionally warning about missing DCP config, the check first looks for `.opencode/commands/*.md` files containing ``. If no protect-tagged commands exist, the check returns `pass` with "no protect-tagged commands found" — the DCP config is not needed. This avoids false warnings for projects that don't use protect tags. + +### D8: Doctor check reuses string scan pattern from D2 + +The doctor check uses the same `strings.Contains` approach as `ScaffoldDCP()` for detecting `protectTags` in the DCP config file. Consistency across the codebase. + +### D9: Doctor `checkDCPConfig()` accepts explicit directory parameter + +`checkDCPConfig(projectDir string) CheckResult` takes an explicit directory path rather than using `os.Getwd()`. This enables isolated testing with `t.TempDir()` without requiring `os.Chdir()` (which is not thread-safe). The `Run()` function obtains the working directory via `os.Getwd()` once and passes it to `checkDCPConfig()`. This follows the pattern of `checkDatabase(store)` and `checkDewey(deweyURL)` — checks receive their dependencies as parameters. The `Run()` signature changes to `Run(store *db.Store, cfg *config.Config, projectDir string)`. + +### D10: Update strategy replaces file content entirely + +When `ScaffoldDCP()` encounters an existing config file without `protectTags`, the "updated" action replaces the entire file with the canonical DCP config content (from D5). This is simpler and safer than attempting to merge into an arbitrary JSONC structure, which could produce invalid JSON. The trade-off is that user customizations beyond `protectTags` are lost — but DCP configs are typically simple, and the non-goal of "supporting custom DCP config beyond `protectTags: true`" makes this acceptable. + +### D11: Both files exist — prefer `.jsonc` + +When both `.opencode/dcp.jsonc` and `.opencode/dcp.json` exist simultaneously, `ScaffoldDCP()` and `checkDCPConfig()` operate on `.opencode/dcp.jsonc` (the preferred extension) and ignore `.opencode/dcp.json`. This is consistent with D3's "check `.jsonc` first" rule. + +## Risks / Trade-offs + +- **String-based detection is imprecise**: `strings.Contains` could match `protectTags` in comments or non-standard locations. Accepted — the false-positive rate is negligible and avoids JSONC parsing complexity. +- **Full replacement on update**: When updating an existing config without `protectTags`, the entire file is replaced with the canonical content (D10). User customizations beyond `protectTags` are lost. This is safer than attempting partial JSONC merges that could produce invalid JSON. +- **File extension ambiguity**: Supporting both `.json` and `.jsonc` adds a small amount of logic. Worth it for user flexibility — DCP supports both. +- **Doctor check is per-project**: Unlike the other 4 checks (environment-level), the DCP check inspects the current working directory. This is a slight conceptual shift but justified — `replicator init` is also per-project, and doctor should verify what init scaffolded. + diff --git a/openspec/changes/add-dcp-config/proposal.md b/openspec/changes/add-dcp-config/proposal.md new file mode 100644 index 0000000..7778829 --- /dev/null +++ b/openspec/changes/add-dcp-config/proposal.md @@ -0,0 +1,67 @@ +## Why + +`replicator init` scaffolds five slash-command files (forge, org, inbox, forge-status, handoff) that each contain `` tags on line 5. These tags are designed to prevent DCP (the Diff Context Protocol plugin) from modifying protected sections during code generation. However, `replicator init` does not create the `.opencode/dcp.jsonc` configuration file that enables DCP's `protectTags` feature. Without this file, the `` tags are inert — DCP has no configuration telling it to honor them. + +This was identified as a gap left by the `protect-tags-slash-commands` change, which added `` tags but placed the `protectTags` setting in `opencode.json` (wrong location — causes a config validation error). The correct location is `.opencode/dcp.jsonc`, which is a DCP plugin config file, not part of OpenCode's core configuration. + +The replicator repo itself already has `.opencode/dcp.jsonc` committed (via `b147bc0`), but projects that run `replicator init` do not receive this file. + +Related: unbound-force/unbound-force#502 + +## What Changes + +Add a `ScaffoldDCP()` function to the `agentkit` package that creates `.opencode/dcp.jsonc` with `protectTags: true` during `replicator init`. The function uses idempotent merge logic (separate from the existing `Scaffold()` file walk) so it can handle existing DCP configurations gracefully. + +## Capabilities + +### New Capabilities +- `ScaffoldDCP`: Creates `.opencode/dcp.jsonc` with DCP schema reference and `protectTags: true` enabled. Idempotent — skips if already configured, merges if file exists but lacks `protectTags`. +- `checkDCPConfig` (doctor): Verifies that the current project has a valid `.opencode/dcp.jsonc` (or `.dcp.json`) with `protectTags: true` when command files with `` tags are present. Warns (not fails) when missing, since DCP is optional. + +### Modified Capabilities +- `replicator init`: Now calls `ScaffoldDCP()` after `Scaffold()`, ensuring projects receive a complete agent kit with working protect-tag support. +- `replicator doctor`: Adds a 5th check (`dcp_config`) that verifies per-project DCP configuration health alongside the existing environment checks. + +### Removed Capabilities +- None + +## Impact + +- **`internal/agentkit/agentkit.go`**: New `ScaffoldDCP()` function with idempotent merge logic +- **`internal/agentkit/agentkit_test.go`**: New tests for DCP scaffolding (fresh, skip, merge scenarios) +- **`cmd/replicator/init.go`**: Call `ScaffoldDCP()` after `Scaffold()` in `runInit()` +- **`cmd/replicator/init_test.go`**: Add `dcp.jsonc` to assertions in existing init tests +- **`internal/doctor/checks.go`**: New `checkDCPConfig()` function as 5th health check +- **`internal/doctor/checks_test.go`**: Tests for DCP config check (present, missing, missing protectTags) +- Existing scaffold file count (15) is unchanged — DCP is a separate function +- All projects initialized with `replicator init` will now have working `` tag support out of the box +- `replicator doctor` will warn users when DCP config is missing or incomplete + +## Constitution Alignment + +Assessed against the Replicator constitution (`.specify/memory/constitution.md`), which extends the Unbound Force org constitution v1.1.0. + +### I. Autonomous Collaboration + +**Assessment**: PASS + +`ScaffoldDCP()` is a standalone function with a clear interface (`targetDir string`) returning `(ScaffoldResult, error)`. It operates independently from `Scaffold()` and produces self-describing results. The DCP config file it creates enables artifact-level protection — a form of autonomous collaboration where agents respect protected boundaries without runtime coupling. + +### II. Composability First + +**Assessment**: PASS + +The function is independently callable — it does not require `Scaffold()` to run first. It handles both `.opencode/dcp.json` and `.opencode/dcp.jsonc` file extensions. The DCP config file itself is optional — projects function without it (protect tags are simply inert). No mandatory dependencies are introduced. + +### III. Observable Quality + +**Assessment**: PASS + +`ScaffoldDCP()` returns a `ScaffoldResult` with `Path` and `Action` fields (created/skipped/updated), matching the existing pattern used by `Scaffold()`. The init command renders these results using the same styled output. The DCP config file uses the standard JSON schema reference for validation. + +### IV. Testability + +**Assessment**: PASS + +All scenarios (fresh directory, existing config with protectTags, existing config without protectTags, `.dcp.json` alias) are testable using `t.TempDir()` with no external services. Tests verify file existence, content correctness, and idempotent behavior in isolation. + diff --git a/openspec/changes/add-dcp-config/specs/scaffold-dcp.md b/openspec/changes/add-dcp-config/specs/scaffold-dcp.md new file mode 100644 index 0000000..e3727ea --- /dev/null +++ b/openspec/changes/add-dcp-config/specs/scaffold-dcp.md @@ -0,0 +1,113 @@ +## ADDED Requirements + +### Requirement: ScaffoldDCP function + +The `agentkit` package MUST export a `ScaffoldDCP(targetDir string) (ScaffoldResult, error)` function that creates or updates `.opencode/dcp.jsonc` with DCP protect-tag configuration. + +#### Scenario: Fresh directory with no existing DCP config + +- **GIVEN** a target directory with no `.opencode/dcp.jsonc` or `.opencode/dcp.json` file +- **WHEN** `ScaffoldDCP(targetDir)` is called +- **THEN** `.opencode/dcp.jsonc` MUST be created with `protectTags: true` under a `compress` key, and the result action MUST be "created" + +#### Scenario: Existing DCP config with protectTags already set + +- **GIVEN** a target directory with `.opencode/dcp.jsonc` containing `"protectTags": true` +- **WHEN** `ScaffoldDCP(targetDir)` is called +- **THEN** the file MUST NOT be modified, and the result action MUST be "skipped" + +#### Scenario: Existing DCP config without protectTags + +- **GIVEN** a target directory with `.opencode/dcp.jsonc` that does not contain `"protectTags"` +- **WHEN** `ScaffoldDCP(targetDir)` is called +- **THEN** the file MUST be replaced with the canonical DCP config content (including `protectTags: true`), and the result action MUST be "updated" + +#### Scenario: Both `.dcp.jsonc` and `.dcp.json` exist + +- **GIVEN** a target directory with both `.opencode/dcp.jsonc` and `.opencode/dcp.json` +- **WHEN** `ScaffoldDCP(targetDir)` is called +- **THEN** the function MUST operate on `.opencode/dcp.jsonc` (preferred extension) and ignore `.opencode/dcp.json` + +#### Scenario: `.dcp.json` alias (without `c` extension) + +- **GIVEN** a target directory with `.opencode/dcp.json` (not `.jsonc`) containing DCP configuration +- **WHEN** `ScaffoldDCP(targetDir)` is called +- **THEN** the function MUST operate on `.opencode/dcp.json` (respecting the user's extension choice), not create a new `.opencode/dcp.jsonc` + +### Requirement: DCP config content + +The scaffolded `.opencode/dcp.jsonc` MUST contain: +1. A `$schema` reference to the DCP JSON schema +2. Comments explaining the purpose of `protectTags` +3. A `compress` object with `protectTags: true` + +### Requirement: Init command integration + +The `replicator init` command MUST call `ScaffoldDCP()` after `Scaffold()` and render the DCP result using the same styled output (green for created, dim for skipped, yellow for updated). + +#### Scenario: `replicator init` on a fresh directory + +- **GIVEN** a directory that has not been initialized +- **WHEN** `replicator init` is run +- **THEN** `.opencode/dcp.jsonc` MUST exist alongside the 15 agent kit files, and the init output MUST include the DCP file status + +#### Scenario: `replicator init` on an already-initialized directory + +- **GIVEN** a directory that was previously initialized (`.opencode/dcp.jsonc` already exists with `protectTags: true`) +- **WHEN** `replicator init` is run again +- **THEN** `.opencode/dcp.jsonc` MUST NOT be modified, and the output MUST show "skipped" for the DCP file + +### Requirement: ScaffoldDCP result shape + +`ScaffoldDCP()` MUST return `(ScaffoldResult, error)` where `ScaffoldResult` has `Path` (string) and `Action` (string) fields. The `Action` field MUST be one of: "created", "skipped", "updated". + +### Requirement: `.opencode/` directory creation + +If the `.opencode/` directory does not exist, `ScaffoldDCP()` MUST create it before writing the config file. + +### Requirement: Doctor DCP config check + +The `doctor` package MUST include a `checkDCPConfig(projectDir string) CheckResult` function that verifies per-project DCP configuration health. The function MUST accept an explicit directory parameter for testability (no implicit `os.Getwd()`). The check MUST be registered as the 5th check in `Run()`. + +#### Scenario: DCP config present with protectTags + +- **GIVEN** a working directory with `.opencode/dcp.jsonc` containing `"protectTags": true` and `.opencode/commands/` containing files with `` tags +- **WHEN** `checkDCPConfig()` is called +- **THEN** the result status MUST be "pass" and the message MUST contain "protectTags enabled" + +#### Scenario: No protect-tagged commands exist + +- **GIVEN** a working directory with no `.opencode/commands/` directory or no files containing `` tags +- **WHEN** `checkDCPConfig()` is called +- **THEN** the result status MUST be "pass" and the message MUST contain "no protect-tagged commands" + +#### Scenario: Protect-tagged commands exist but no DCP config + +- **GIVEN** a working directory with `.opencode/commands/` containing files with `` tags but no `.opencode/dcp.jsonc` or `.opencode/dcp.json` +- **WHEN** `checkDCPConfig()` is called +- **THEN** the result status MUST be "warn" and the message MUST contain "replicator init" + +#### Scenario: DCP config exists but missing protectTags + +- **GIVEN** a working directory with `.opencode/dcp.jsonc` that does not contain `"protectTags"` and `.opencode/commands/` containing files with `` tags +- **WHEN** `checkDCPConfig()` is called +- **THEN** the result status MUST be "warn" and the message MUST contain "protectTags" + +### Requirement: Doctor check result shape + +The `checkDCPConfig()` function MUST return a `CheckResult` with `Name` set to `"dcp_config"`. The `Status` field MUST be "pass" or "warn" (never "fail" — DCP is optional). + +### Requirement: Doctor check count update + +`Run()` MUST return 5 results (up from 4) when all checks complete. The `dcp_config` check MUST be the 5th check. + +## MODIFIED Requirements + +### Requirement: `Run()` signature change + +`Run()` MUST accept a `projectDir string` parameter: `Run(store *db.Store, cfg *config.Config, projectDir string) ([]CheckResult, error)`. The `projectDir` is passed to `checkDCPConfig()` for per-project checks. Callers (e.g., `cmd/replicator/doctor.go`) MUST pass `os.Getwd()` or equivalent. + +## REMOVED Requirements + +None. + diff --git a/openspec/changes/add-dcp-config/tasks.md b/openspec/changes/add-dcp-config/tasks.md new file mode 100644 index 0000000..2f3aa9f --- /dev/null +++ b/openspec/changes/add-dcp-config/tasks.md @@ -0,0 +1,35 @@ + + +## 1. Add `ScaffoldDCP()` to agentkit + +- [x] 1.1 Write failing tests for `ScaffoldDCP()` in `internal/agentkit/agentkit_test.go`: fresh directory (creates `dcp.jsonc`), existing config with `protectTags` (skips), existing config without `protectTags` (replaces with canonical content), `.dcp.json` alias (operates on `.json` not `.jsonc`), both files exist (prefers `.jsonc`), `.opencode/` directory creation +- [x] 1.2 Implement `ScaffoldDCP(targetDir string) (ScaffoldResult, error)` in `internal/agentkit/agentkit.go`: check for `.opencode/dcp.jsonc` then `.opencode/dcp.json`, use `strings.Contains` for `protectTags` detection, create/skip/update with appropriate action string, DCP config content matches design spec D5 + +## 2. Integrate into `replicator init` + +- [x] 2.1 [P] Add `dcp.jsonc` assertions to `cmd/replicator/init_test.go`: verify `TestRunInit_FreshDirectory` includes `dcp.jsonc` in spot-check, verify `TestRunInit_AlreadyInitialized` skips `dcp.jsonc` on re-run +- [x] 2.2 [P] Call `ScaffoldDCP()` in `runInit()` in `cmd/replicator/init.go`: call after `Scaffold()`, render result with same styled output (green/dim/yellow) + +## 3. Add `checkDCPConfig()` to doctor + +- [x] 3.1 Write failing tests for `checkDCPConfig()` in `internal/doctor/checks_test.go`: DCP config present with protectTags (pass), no protect-tagged commands (pass), protect-tagged commands but no DCP config (warn), DCP config missing protectTags (warn) +- [x] 3.2 Implement `checkDCPConfig(projectDir string) CheckResult` in `internal/doctor/checks.go`: accept explicit directory parameter, scan `.opencode/commands/*.md` for `` tags, check `.opencode/dcp.jsonc` then `.opencode/dcp.json` for `protectTags`, return warn (not fail) when missing +- [x] 3.3 Update `Run()` signature to `Run(store *db.Store, cfg *config.Config, projectDir string)`, register `checkDCPConfig(projectDir)` as 5th check, update caller in `cmd/replicator/doctor.go` to pass `os.Getwd()`, update `TestRun_AllChecks` to expect 5 results with `dcp_config` name + +## 4. Verification + +- [x] 4.1 Run `make check` and `make check-coverage` to verify all tests pass and coverage ratchets are maintained +- [x] 4.2 Verify constitution alignment: `ScaffoldDCP()` is independently callable (Composability), returns machine-parseable `ScaffoldResult` (Observable Quality), testable with `t.TempDir()` (Testability), `checkDCPConfig()` uses `t.TempDir()` with no external services (Testability) + + +