diff --git a/README.md b/README.md index 1391aaa2..e6ad4564 100644 --- a/README.md +++ b/README.md @@ -850,6 +850,8 @@ results from the packages that own those concerns. | Sync Gemini prompt pack | `make sync-gemini-prompts` | | Check Gemini prompt-pack drift | `make check-gemini-prompts` | | Check generated agent skill drift | `make check-agent-skills` | +| Inspect generated-surface install state | `bin/coding-ethos-run policy install-state-doctor --repo .` | +| Plan generated-surface repair writes | `bin/coding-ethos-run policy install-state-repair-plan --repo .` | | Run staged-file hooks | `make pre-commit` | | Run hooks over all files | `make pre-commit-all` | | Run pre-push hooks | `make pre-push` | @@ -922,6 +924,11 @@ Sync generated tool configs: ```bash make sync-tool-configs REPO=/path/to/repo +bin/coding-ethos-run policy sync-tool-configs \ + --repo /path/to/repo \ + --ethos-root . \ + --dry-run \ + --format toon ``` By default the same command writes the managed SARIF CI files and includes @@ -934,6 +941,32 @@ Repos with a deliberate exception can set They are checked by `make check-tool-configs`; there is no separate CI sync path. +Successful generated-surface sync writes a repo-local install/sync state file at +`.coding-ethos/state/install-sync.json`. The state records schema version, +source config hashes, runtime version/commit, target repo root, provider +targets, requested action, coding-ethos-owned artifact paths, expected SHA-256 +hashes, last validation time, and the command that verifies each generated +surface. The state file is ignored runtime state, not a source artifact. + +Dry-run sync reports the exact writes it would perform without mutating files: + +```bash +bin/coding-ethos-run policy sync-tool-configs --repo /path/to/repo --dry-run --format json +bin/coding-ethos-run policy sync-gemini-prompts --repo /path/to/repo --dry-run --format toon +bin/coding-ethos-run policy sync-agent-skills --repo /path/to/repo --dry-run --format toon +bin/coding-ethos-run agent-hooks sync --root /path/to/repo --ethos-root . --dry-run --format toon +``` + +Doctor and repair planning are read-only. Doctor compares recorded source hashes +and artifact hashes against the current checkout and reports missing, stale, or +drifted surfaces. Repair planning lists only `coding-ethos-managed` recorded +outputs, so external or unrecorded files are never proposed for mutation: + +```bash +bin/coding-ethos-run policy install-state-doctor --repo /path/to/repo --format toon +bin/coding-ethos-run policy install-state-repair-plan --repo /path/to/repo --format json +``` + Check generated tool config drift: ```bash @@ -1516,6 +1549,7 @@ Render or verify repo-local agent hook settings: ```bash bin/coding-ethos-run agent-hooks print bin/coding-ethos-run agent-hooks sync +bin/coding-ethos-run agent-hooks sync --root /path/to/repo --ethos-root . --dry-run --format toon bin/coding-ethos-run agent-hooks doctor bin/coding-ethos-run agent-hooks verify ``` diff --git a/go/internal/agenthooks/settings.go b/go/internal/agenthooks/settings.go index d3555a03..94f19605 100644 --- a/go/internal/agenthooks/settings.go +++ b/go/internal/agenthooks/settings.go @@ -314,57 +314,54 @@ func removeStaleCodexHooksFile(path string) error { return nil } -func syncSettingsFile(path string, merge func(map[string]any)) (err error) { - payload, err := existingSettingsPayload(path) +func syncSettingsFile(path string, merge func(map[string]any)) error { + content, err := renderSettingsFileContent(path, merge) if err != nil { return err } - merge(payload) - err = os.MkdirAll(filepath.Dir(path), settingsDirMode) if err != nil { return fmt.Errorf("create settings directory: %w", err) } - file, err := os.OpenFile( - path, - os.O_WRONLY|os.O_CREATE|os.O_TRUNC, - settingsFileMode, - ) + err = os.WriteFile(path, []byte(content), settingsFileMode) if err != nil { - return fmt.Errorf("open settings: %w", err) + return fmt.Errorf("write settings: %w", err) } - defer func() { - closeErr := file.Close() - if err == nil && closeErr != nil { - err = fmt.Errorf("close settings: %w", closeErr) - } - }() + return nil +} + +func renderSettingsFileContent( + path string, + merge func(map[string]any), +) (string, error) { + payload, err := existingSettingsPayload(path) + if err != nil { + return "", err + } + + merge(payload) + + var buffer bytes.Buffer - encoder := json.NewEncoder(file) + encoder := json.NewEncoder(&buffer) encoder.SetEscapeHTML(false) encoder.SetIndent("", " ") err = encoder.Encode(payload) if err != nil { - return fmt.Errorf("encode settings: %w", err) + return "", fmt.Errorf("encode settings: %w", err) } - return nil + return buffer.String(), nil } func syncTextSettingsFile(path string, mutate func(string) string) error { - content := "" - - data, err := os.ReadFile(path) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("read settings: %w", err) - } - - if err == nil { - content = string(data) + content, err := renderTextSettingsFileContent(path, mutate) + if err != nil { + return err } err = os.MkdirAll(filepath.Dir(path), settingsDirMode) @@ -372,7 +369,7 @@ func syncTextSettingsFile(path string, mutate func(string) string) error { return fmt.Errorf("create settings directory: %w", err) } - err = os.WriteFile(path, []byte(mutate(content)), settingsFileMode) + err = os.WriteFile(path, []byte(content), settingsFileMode) if err != nil { return fmt.Errorf("write settings: %w", err) } @@ -380,6 +377,24 @@ func syncTextSettingsFile(path string, mutate func(string) string) error { return nil } +func renderTextSettingsFileContent( + path string, + mutate func(string) string, +) (string, error) { + content := "" + + data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("read settings: %w", err) + } + + if err == nil { + content = string(data) + } + + return mutate(content), nil +} + func ensureCodexHookFeature(content string) string { lines := strings.Split(content, "\n") rewrite := codexConfigRewrite{ diff --git a/go/internal/agenthooks/settings_test.go b/go/internal/agenthooks/settings_test.go index f85b2ed1..d566a3dc 100644 --- a/go/internal/agenthooks/settings_test.go +++ b/go/internal/agenthooks/settings_test.go @@ -17,6 +17,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/agenthooks" "blackcat.ca/coding-ethos/go/internal/hooks" "blackcat.ca/coding-ethos/go/internal/policy" + "blackcat.ca/coding-ethos/go/internal/syncstate" ) const testHookCommand = "/repo/bin/coding-ethos-run agent-hook" @@ -150,6 +151,50 @@ func TestProviderCapabilitiesMatchUpdatedInputBehavior(t *testing.T) { } } +func TestStateArtifactsDescribeManagedHookSurfaces(t *testing.T) { + t.Parallel() + + root := t.TempDir() + artifacts, err := agenthooks.StateArtifacts(root, testHookCommand) + if err != nil { + t.Fatalf("state artifacts: %v", err) + } + + paths := agenthooks.DefaultSettingsPaths(root) + expected := map[string]string{ + filepath.ToSlash(filepath.Join(".claude", "settings.local.json")): "claude-settings", + filepath.ToSlash(".mcp.json"): "claude-mcp", + filepath.ToSlash(filepath.Join(".codex", "config.toml")): "codex-config", + filepath.ToSlash(filepath.Join(".gemini", "settings.json")): "gemini-settings", + } + if len(artifacts) != len(expected) { + t.Fatalf( + "artifact count = %d, want %d: %#v", + len(artifacts), + len(expected), + artifacts, + ) + } + + for _, artifact := range artifacts { + surface, found := expected[artifact.Path] + if !found { + t.Fatalf("unexpected artifact path %q from %#v", artifact.Path, paths) + } + if artifact.Provider != "agent-hooks" || + artifact.Surface != surface || + artifact.Ownership != syncstate.DefaultOwnership || + artifact.VerificationCommand != "bin/coding-ethos-run agent-hooks doctor" || + !strings.HasPrefix(artifact.ExpectedSHA256, "sha256:") { + t.Fatalf("artifact = %#v", artifact) + } + delete(expected, artifact.Path) + } + if len(expected) != 0 { + t.Fatalf("missing artifacts: %#v", expected) + } +} + func TestRuntimeHookSpecsAreProviderNeutral(t *testing.T) { t.Parallel() diff --git a/go/internal/agenthooks/state_artifacts.go b/go/internal/agenthooks/state_artifacts.go new file mode 100644 index 00000000..6f16a6a2 --- /dev/null +++ b/go/internal/agenthooks/state_artifacts.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package agenthooks + +import ( + "fmt" + + "blackcat.ca/coding-ethos/go/internal/syncstate" +) + +func StateArtifacts(root, hookCommand string) ([]syncstate.Artifact, error) { + settings, err := buildAllSettings(hookCommand) + if err != nil { + return nil, err + } + + serverConfig, err := mcpServerConfig(hookCommand) + if err != nil { + return nil, err + } + + paths := DefaultSettingsPaths(root) + + claude, err := renderSettingsFileContent(paths.Claude, func(payload map[string]any) { + payload["hooks"] = settings.Claude.Hooks + }) + if err != nil { + return nil, err + } + + claudeMCP, err := renderSettingsFileContent( + paths.ClaudeMCP, + func(payload map[string]any) { + syncMCPServers(payload, serverConfig.claudeJSON()) + }, + ) + if err != nil { + return nil, err + } + + codex, err := renderTextSettingsFileContent( + paths.CodexConfig, + func(content string) string { + return ensureCodexConfig(content, settings.Codex, serverConfig) + }, + ) + if err != nil { + return nil, err + } + + gemini, err := renderSettingsFileContent(paths.Gemini, func(payload map[string]any) { + payload["hooksConfig"] = settings.Gemini.HooksConfig + payload["hooks"] = settings.Gemini.Hooks + syncMCPServers(payload, serverConfig.geminiJSON()) + }) + if err != nil { + return nil, err + } + + artifacts, err := syncstate.Artifacts( + root, + agentHookStateArtifactInputs(paths, claude, claudeMCP, codex, gemini), + ) + if err != nil { + return nil, fmt.Errorf("build agent hook state artifacts: %w", err) + } + + return artifacts, nil +} + +func agentHookStateArtifactInputs( + paths SettingsPaths, + claude, + claudeMCP, + codex, + gemini string, +) []syncstate.ArtifactInput { + const verifyCommand = "bin/coding-ethos-run agent-hooks doctor" + + return []syncstate.ArtifactInput{ + { + RelativePath: paths.Claude, + Content: claude, + Provider: "agent-hooks", + Surface: "claude-settings", + VerificationCommand: verifyCommand, + }, + { + RelativePath: paths.ClaudeMCP, + Content: claudeMCP, + Provider: "agent-hooks", + Surface: "claude-mcp", + VerificationCommand: verifyCommand, + }, + { + RelativePath: paths.CodexConfig, + Content: codex, + Provider: "agent-hooks", + Surface: "codex-config", + VerificationCommand: verifyCommand, + }, + { + RelativePath: paths.Gemini, + Content: gemini, + Provider: "agent-hooks", + Surface: "gemini-settings", + VerificationCommand: verifyCommand, + }, + } +} diff --git a/go/internal/agenthookscli/main.go b/go/internal/agenthookscli/main.go index 0780a84f..4826b4da 100644 --- a/go/internal/agenthookscli/main.go +++ b/go/internal/agenthookscli/main.go @@ -14,6 +14,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/agenthooks" "blackcat.ca/coding-ethos/go/internal/apperror" "blackcat.ca/coding-ethos/go/internal/feedback" + "blackcat.ca/coding-ethos/go/internal/syncstate" ) const ( @@ -90,23 +91,66 @@ func printSettings(args []string) error { func syncSettings(args []string) error { flags := flag.NewFlagSet("sync", flag.ContinueOnError) root := flags.String("root", ".", "Repository root for agent settings") + ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") hookCommand := flags.String("hook-command", "", "Agent hook command") + dryRun := flags.Bool("dry-run", false, "Report planned writes without mutating files") + format := flags.String( + "format", + feedback.FormatTOON, + "Output format for dry-run reports", + ) err := flags.Parse(args) if err != nil { return fmt.Errorf("parse sync flags: %w", err) } - err = agenthooks.SyncSettings(*root, defaultHookCommand(*hookCommand)) + resolvedHookCommand := defaultHookCommand(*hookCommand) + + artifacts, err := agenthooks.StateArtifacts(*root, resolvedHookCommand) + if err != nil { + return fmt.Errorf("plan agent hook settings: %w", err) + } + + if *dryRun { + return writeSyncStateReport( + syncstate.Plan(*root, "agent-hooks sync", artifacts), + *format, + ) + } + + err = agenthooks.SyncSettings(*root, resolvedHookCommand) if err != nil { return fmt.Errorf("sync agent hook settings: %w", err) } - err = agenthooks.SyncCodexTrustState(*root, defaultHookCommand(*hookCommand), "") + err = agenthooks.SyncCodexTrustState(*root, resolvedHookCommand, "") if err != nil { return fmt.Errorf("sync Codex hook trust: %w", err) } + _, err = syncstate.Upsert(syncstate.UpsertOptions{ + RepoRoot: *root, + EthosRoot: *ethosRoot, + RequestedAction: "agent-hooks sync", + ProviderTargets: []syncstate.ProviderTarget{ + {Provider: "agent-hooks", Root: *root}, + }, + Artifacts: artifacts, + }) + if err != nil { + return fmt.Errorf("write install sync state: %w", err) + } + + return nil +} + +func writeSyncStateReport(report syncstate.Report, format string) error { + err := feedback.Write(os.Stdout, report, format) + if err != nil { + return fmt.Errorf("write install sync state report: %w", err) + } + return nil } @@ -228,10 +272,13 @@ func usage() { } func usageTo(writer io.Writer) { + const text = "Usage: coding-ethos-agent-hooks " + + "[flags]; sync supports --dry-run --format json|toon" + feedback.Emit( writer, feedback.Text{ - Text: "Usage: coding-ethos-agent-hooks [flags]", + Text: text, }, feedback.FormatTOON, ) diff --git a/go/internal/agenthookscli/main_internal_test.go b/go/internal/agenthookscli/main_internal_test.go index 17550032..9a3412be 100644 --- a/go/internal/agenthookscli/main_internal_test.go +++ b/go/internal/agenthookscli/main_internal_test.go @@ -5,10 +5,14 @@ package agenthookscli import ( "bytes" + "io" "os" "path/filepath" "strings" "testing" + + "blackcat.ca/coding-ethos/go/internal/syncstate" + "blackcat.ca/coding-ethos/go/internal/testlock" ) func TestDefaultHookCommandPrefersExplicitValue(t *testing.T) { @@ -96,6 +100,69 @@ func TestPrintSyncDoctorVerifySettingsCommands(t *testing.T) { } } +func TestSyncSettingsDryRunDoesNotWriteSettingsOrState(t *testing.T) { + root := t.TempDir() + t.Setenv("CODEX_HOME", filepath.Join(root, "codex-home")) + + hookCommand := filepath.Join(root, "bin", "coding-ethos-run") + " agent-hook" + + var err error + captureStdout(t, func() { + err = syncSettings([]string{ + "--root", root, + "--hook-command", hookCommand, + "--dry-run", + "--format", "json", + }) + }) + if err != nil { + t.Fatalf("syncSettings dry-run returned error: %v", err) + } + + for _, path := range []string{ + filepath.Join(root, ".claude", "settings.local.json"), + filepath.Join(root, ".mcp.json"), + filepath.Join(root, ".codex", "config.toml"), + filepath.Join(root, ".gemini", "settings.json"), + syncstate.FilePath(root), + } { + if _, statErr := os.Stat(path); statErr == nil { + t.Fatalf("dry-run wrote %s", path) + } + } +} + +func TestSyncSettingsUsesEthosRootForInstallState(t *testing.T) { + root := t.TempDir() + ethosRoot := t.TempDir() + t.Setenv("CODEX_HOME", filepath.Join(root, "codex-home")) + + writeAgentHooksCLITestFile( + t, + filepath.Join(ethosRoot, "pyproject.toml"), + "[project]\nversion = \"7.8.9\"\n", + ) + + hookCommand := filepath.Join(root, "bin", "coding-ethos-run") + " agent-hook" + + err := syncSettings([]string{ + "--root", root, + "--ethos-root", ethosRoot, + "--hook-command", hookCommand, + }) + if err != nil { + t.Fatalf("syncSettings returned error: %v", err) + } + + state, err := syncstate.Read(root) + if err != nil { + t.Fatalf("read install sync state: %v", err) + } + if state.RuntimeVersion != "7.8.9" { + t.Fatalf("runtime version = %q", state.RuntimeVersion) + } +} + func TestRunCLIDispatchesAgentHookCommands(t *testing.T) { root := t.TempDir() t.Setenv("CODEX_HOME", filepath.Join(root, "codex-home")) @@ -121,6 +188,61 @@ func TestRunCLIDispatchesAgentHookCommands(t *testing.T) { } } +func writeAgentHooksCLITestFile(t *testing.T, path, content string) { + t.Helper() + + err := os.MkdirAll(filepath.Dir(path), 0o700) + if err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + + err = os.WriteFile(path, []byte(content), 0o600) + if err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func captureStdout(t *testing.T, run func()) string { + t.Helper() + + release := testlock.ProcessStateScope(t, "coding-ethos-agent-hooks") + defer release() + + original := os.Stdout + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stdout: %v", err) + } + + os.Stdout = writer + + defer func() { + os.Stdout = original + }() + + run() + + closeErr := writer.Close() + if closeErr != nil { + t.Fatalf("close stdout writer: %v", closeErr) + } + + var buffer bytes.Buffer + + _, err = io.Copy(&buffer, reader) + if err != nil { + t.Fatalf("read stdout: %v", err) + } + + closeErr = reader.Close() + if closeErr != nil { + t.Fatalf("close stdout reader: %v", closeErr) + } + + return buffer.String() +} + func TestRunCLIReturnsUsageAndCommandErrors(t *testing.T) { t.Parallel() diff --git a/go/internal/agentskills/render.go b/go/internal/agentskills/render.go index cea752d9..b4342fd1 100644 --- a/go/internal/agentskills/render.go +++ b/go/internal/agentskills/render.go @@ -10,6 +10,8 @@ import ( "path/filepath" "sort" "strings" + + "blackcat.ca/coding-ethos/go/internal/syncstate" ) const ( @@ -72,6 +74,39 @@ func Sync(options Options) ([]string, error) { return written, nil } +func StateArtifacts(options Options) ([]syncstate.Artifact, error) { + rendered, err := Render(options) + if err != nil { + return nil, err + } + + inputs := make([]syncstate.ArtifactInput, 0, len(rendered)) + for relativePath, content := range rendered { + inputs = append(inputs, syncstate.ArtifactInput{ + RelativePath: relativePath, + Content: content, + Provider: "agent-skills", + Surface: agentSkillSurface(relativePath), + VerificationCommand: "make check-agent-skills", + }) + } + + artifacts, err := syncstate.Artifacts(options.RepoRoot, inputs) + if err != nil { + return nil, fmt.Errorf("build agent skill state artifacts: %w", err) + } + + return artifacts, nil +} + +func agentSkillSurface(path string) string { + if path == geminiManifestPath { + return "gemini-extension-manifest" + } + + return "generated-agent-skill" +} + func Check(options Options) ([]string, error) { rendered, err := Render(options) if err != nil { diff --git a/go/internal/codeintelcli/main_internal_test.go b/go/internal/codeintelcli/main_internal_test.go index fbf5d48d..1d761726 100644 --- a/go/internal/codeintelcli/main_internal_test.go +++ b/go/internal/codeintelcli/main_internal_test.go @@ -1051,10 +1051,12 @@ func runCodeIntelCommands( t.Helper() for _, args := range commands { - err := run(ctx, args) - if err != nil { - t.Fatalf("run(%s) returned error: %v", args[0], err) - } + captureStdout(t, func() { + err := run(ctx, args) + if err != nil { + t.Fatalf("run(%s) returned error: %v", args[0], err) + } + }) } } diff --git a/go/internal/geminiprompts/render.go b/go/internal/geminiprompts/render.go index cca2ebf4..1869702d 100644 --- a/go/internal/geminiprompts/render.go +++ b/go/internal/geminiprompts/render.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" + "blackcat.ca/coding-ethos/go/internal/syncstate" "blackcat.ca/coding-ethos/go/internal/toolconfigs" ) @@ -115,6 +116,28 @@ func Sync(options Options) ([]string, error) { return []string{path}, nil } +func StateArtifacts(options Options) ([]syncstate.Artifact, error) { + rendered, err := Render(options) + if err != nil { + return nil, err + } + + artifacts, err := syncstate.Artifacts(options.RepoRoot, []syncstate.ArtifactInput{ + { + RelativePath: PromptPackPath, + Content: rendered, + Provider: "gemini-prompts", + Surface: "prompt-pack", + VerificationCommand: "make check-gemini-prompts", + }, + }) + if err != nil { + return nil, fmt.Errorf("build Gemini prompt state artifacts: %w", err) + } + + return artifacts, nil +} + func Check(options Options) ([]string, error) { rendered, err := Render(options) if err != nil { diff --git a/go/internal/policycli/main.go b/go/internal/policycli/main.go index 79509a77..37a4097e 100644 --- a/go/internal/policycli/main.go +++ b/go/internal/policycli/main.go @@ -20,6 +20,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/feedback" "blackcat.ca/coding-ethos/go/internal/geminiprompts" "blackcat.ca/coding-ethos/go/internal/policy" + "blackcat.ca/coding-ethos/go/internal/syncstate" "blackcat.ca/coding-ethos/go/internal/toolconfigs" ) @@ -46,6 +47,9 @@ var ( errToolConfigRepoRequired = apperror.StaticError( "tool config command requires --repo", ) + errInstallStateRepoRequired = apperror.StaticError( + "install-state command requires --repo", + ) errGeminiPromptRepoRequired = apperror.StaticError( "gemini prompt command requires --repo", ) @@ -94,19 +98,21 @@ type policyCommandHandler func([]string) error func policyCommandHandlers() map[string]policyCommandHandler { return map[string]policyCommandHandler{ - "compile": compile, - "dump-example": dumpExample, - "write-example": writeExample, - "validate": validate, - "validate-metadata": validateMetadata, - "explain": explain, - "config-trace": configTrace, - "sync-tool-configs": syncToolConfigs, - "check-tool-configs": checkToolConfigs, - "sync-gemini-prompts": syncGeminiPrompts, - "check-gemini-prompts": checkGeminiPrompts, - "sync-agent-skills": syncAgentSkills, - "check-agent-skills": checkAgentSkills, + "compile": compile, + "dump-example": dumpExample, + "write-example": writeExample, + "validate": validate, + "validate-metadata": validateMetadata, + "explain": explain, + "config-trace": configTrace, + "sync-tool-configs": syncToolConfigs, + "check-tool-configs": checkToolConfigs, + "sync-gemini-prompts": syncGeminiPrompts, + "check-gemini-prompts": checkGeminiPrompts, + "sync-agent-skills": syncAgentSkills, + "check-agent-skills": checkAgentSkills, + "install-state-doctor": installStateDoctor, + "install-state-repair-plan": installStateRepairPlan, } } @@ -116,6 +122,22 @@ func syncToolConfigs(args []string) error { return err } + artifacts, err := toolconfigs.StateArtifacts( + options.ethosRoot, + options.repo, + options.repoConfig, + ) + if err != nil { + return fmt.Errorf("plan generated tool configs: %w", err) + } + + if options.dryRun { + return writeSyncStateReport( + syncstate.Plan(options.repo, "sync-tool-configs", artifacts), + options.format, + ) + } + written, err := toolconfigs.Sync( options.ethosRoot, options.repo, @@ -125,10 +147,25 @@ func syncToolConfigs(args []string) error { return fmt.Errorf("sync generated tool configs: %w", err) } + err = upsertSyncState(syncStateOptions{ + EthosRoot: options.ethosRoot, + RepoRoot: options.repo, + RepoConfig: options.repoConfig, + RequestedAction: "sync-tool-configs", + Provider: "tool-configs", + Artifacts: artifacts, + SourcePaths: toolConfigSourcePaths(options), + }) + if err != nil { + return err + } + for _, path := range written { writePolicyOutput(path) } + writePolicyOutput(syncstate.FilePath(options.repo)) + return nil } @@ -162,6 +199,8 @@ type toolConfigOptions struct { ethosRoot string repo string repoConfig string + format string + dryRun bool } func parseToolConfigFlags(command string, args []string) (toolConfigOptions, error) { @@ -169,6 +208,12 @@ func parseToolConfigFlags(command string, args []string) (toolConfigOptions, err ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") repo := flags.String("repo", "", "Repository root where configs are generated") repoConfig := flags.String("repo-config", "", "Optional repo override config") + dryRun := flags.Bool("dry-run", false, "Report planned writes without mutating files") + format := flags.String( + "format", + feedback.FormatTOON, + "Output format for dry-run reports", + ) err := flags.Parse(args) if err != nil { @@ -183,6 +228,8 @@ func parseToolConfigFlags(command string, args []string) (toolConfigOptions, err ethosRoot: *ethosRoot, repo: *repo, repoConfig: *repoConfig, + dryRun: *dryRun, + format: *format, }, nil } @@ -192,15 +239,42 @@ func syncGeminiPrompts(args []string) error { return err } - written, err := geminiprompts.Sync(options) + artifacts, err := geminiprompts.StateArtifacts(options.Options) + if err != nil { + return fmt.Errorf("plan Gemini prompt pack: %w", err) + } + + if options.dryRun { + return writeSyncStateReport( + syncstate.Plan(options.Options.RepoRoot, "sync-gemini-prompts", artifacts), + options.format, + ) + } + + written, err := geminiprompts.Sync(options.Options) if err != nil { return fmt.Errorf("sync Gemini prompt pack: %w", err) } + err = upsertSyncState(syncStateOptions{ + EthosRoot: options.Options.EthosRoot, + RepoRoot: options.Options.RepoRoot, + RepoConfig: options.Options.RepoConfig, + RequestedAction: "sync-gemini-prompts", + Provider: "gemini-prompts", + Artifacts: artifacts, + SourcePaths: geminiSourcePaths(options.Options), + }) + if err != nil { + return err + } + for _, path := range written { writePolicyOutput(path) } + writePolicyOutput(syncstate.FilePath(options.Options.RepoRoot)) + return nil } @@ -210,7 +284,7 @@ func checkGeminiPrompts(args []string) error { return err } - mismatched, err := geminiprompts.Check(options) + mismatched, err := geminiprompts.Check(options.Options) if err != nil { return fmt.Errorf("check Gemini prompt pack: %w", err) } @@ -232,15 +306,41 @@ func syncAgentSkills(args []string) error { return err } - written, err := agentskills.Sync(options) + artifacts, err := agentskills.StateArtifacts(options.Options) + if err != nil { + return fmt.Errorf("plan agent skills: %w", err) + } + + if options.dryRun { + return writeSyncStateReport( + syncstate.Plan(options.Options.RepoRoot, "sync-agent-skills", artifacts), + options.format, + ) + } + + written, err := agentskills.Sync(options.Options) if err != nil { return fmt.Errorf("sync agent skills: %w", err) } + err = upsertSyncState(syncStateOptions{ + EthosRoot: options.Options.EthosRoot, + RepoRoot: options.Options.RepoRoot, + RequestedAction: "sync-agent-skills", + Provider: "agent-skills", + Artifacts: artifacts, + SourcePaths: agentSkillSourcePaths(options.Options), + }) + if err != nil { + return err + } + for _, path := range written { writePolicyOutput(path) } + writePolicyOutput(syncstate.FilePath(options.Options.RepoRoot)) + return nil } @@ -250,7 +350,7 @@ func checkAgentSkills(args []string) error { return err } - mismatched, err := agentskills.Check(options) + mismatched, err := agentskills.Check(options.Options) if err != nil { return fmt.Errorf("check agent skills: %w", err) } @@ -266,59 +366,240 @@ func checkAgentSkills(args []string) error { return nil } -func parseAgentSkillFlags(command string, args []string) (agentskills.Options, error) { +type agentSkillCLIOptions struct { + Options agentskills.Options + format string + dryRun bool +} + +func parseAgentSkillFlags(command string, args []string) (agentSkillCLIOptions, error) { flags := flag.NewFlagSet(command, flag.ExitOnError) ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") repo := flags.String("repo", "", "Repository root where skills are generated") primary := flags.String("primary", "", "Path to coding_ethos.yml") repoEthos := flags.String("repo-ethos", "", "Optional repo ethos overlay") + dryRun := flags.Bool("dry-run", false, "Report planned writes without mutating files") + format := flags.String( + "format", + feedback.FormatTOON, + "Output format for dry-run reports", + ) err := flags.Parse(args) if err != nil { - return agentskills.Options{}, fmt.Errorf("parse %s flags: %w", command, err) + return agentSkillCLIOptions{}, fmt.Errorf("parse %s flags: %w", command, err) } if strings.TrimSpace(*repo) == "" { - return agentskills.Options{}, errAgentSkillRepoRequired + return agentSkillCLIOptions{}, errAgentSkillRepoRequired } - return agentskills.Options{ - EthosRoot: *ethosRoot, - RepoRoot: *repo, - Primary: *primary, - RepoEthos: *repoEthos, + return agentSkillCLIOptions{ + Options: agentskills.Options{ + EthosRoot: *ethosRoot, + RepoRoot: *repo, + Primary: *primary, + RepoEthos: *repoEthos, + }, + dryRun: *dryRun, + format: *format, }, nil } +type geminiPromptCLIOptions struct { + Options geminiprompts.Options + format string + dryRun bool +} + func parseGeminiPromptFlags( command string, args []string, -) (geminiprompts.Options, error) { +) (geminiPromptCLIOptions, error) { flags := flag.NewFlagSet(command, flag.ExitOnError) ethosRoot := flags.String("ethos-root", ".", "Path to coding-ethos checkout") repo := flags.String("repo", "", "Repository root where prompt pack is generated") primary := flags.String("primary", "", "Path to coding_ethos.yml") repoEthos := flags.String("repo-ethos", "", "Optional repo ethos overlay") repoConfig := flags.String("repo-config", "", "Optional repo override config") + dryRun := flags.Bool("dry-run", false, "Report planned writes without mutating files") + format := flags.String( + "format", + feedback.FormatTOON, + "Output format for dry-run reports", + ) err := flags.Parse(args) if err != nil { - return geminiprompts.Options{}, fmt.Errorf("parse %s flags: %w", command, err) + return geminiPromptCLIOptions{}, fmt.Errorf("parse %s flags: %w", command, err) } if strings.TrimSpace(*repo) == "" { - return geminiprompts.Options{}, errGeminiPromptRepoRequired + return geminiPromptCLIOptions{}, errGeminiPromptRepoRequired } - return geminiprompts.Options{ - EthosRoot: *ethosRoot, - RepoRoot: *repo, - Primary: *primary, - RepoEthos: *repoEthos, - RepoConfig: *repoConfig, + return geminiPromptCLIOptions{ + Options: geminiprompts.Options{ + EthosRoot: *ethosRoot, + RepoRoot: *repo, + Primary: *primary, + RepoEthos: *repoEthos, + RepoConfig: *repoConfig, + }, + dryRun: *dryRun, + format: *format, }, nil } +func installStateDoctor(args []string) error { + options, err := parseInstallStateFlags("install-state-doctor", args) + if err != nil { + return err + } + + report, err := syncstate.Doctor(options.repo) + if err != nil { + return fmt.Errorf("doctor install state: %w", err) + } + + return writeSyncStateReport(report, options.format) +} + +func installStateRepairPlan(args []string) error { + options, err := parseInstallStateFlags("install-state-repair-plan", args) + if err != nil { + return err + } + + report, err := syncstate.RepairPlan(options.repo) + if err != nil { + return fmt.Errorf("plan install state repair: %w", err) + } + + return writeSyncStateReport(report, options.format) +} + +type installStateOptions struct { + repo string + format string +} + +func parseInstallStateFlags( + command string, + args []string, +) (installStateOptions, error) { + flags := flag.NewFlagSet(command, flag.ExitOnError) + repo := flags.String("repo", "", "Repository root containing sync state") + format := flags.String("format", feedback.FormatTOON, "Output format") + + err := flags.Parse(args) + if err != nil { + return installStateOptions{}, fmt.Errorf("parse %s flags: %w", command, err) + } + + if strings.TrimSpace(*repo) == "" { + return installStateOptions{}, errInstallStateRepoRequired + } + + return installStateOptions{repo: *repo, format: *format}, nil +} + +type syncStateOptions struct { + EthosRoot string + RepoRoot string + RepoConfig string + RequestedAction string + Provider string + Artifacts []syncstate.Artifact + SourcePaths []string +} + +func upsertSyncState(options syncStateOptions) error { + _, err := syncstate.Upsert(syncstate.UpsertOptions{ + RepoRoot: options.RepoRoot, + EthosRoot: options.EthosRoot, + RequestedAction: options.RequestedAction, + SourcePaths: options.SourcePaths, + ProviderTargets: []syncstate.ProviderTarget{ + {Provider: options.Provider, Root: options.RepoRoot}, + }, + Artifacts: options.Artifacts, + }) + if err != nil { + return fmt.Errorf("write install sync state: %w", err) + } + + return nil +} + +func writeSyncStateReport(report syncstate.Report, format string) error { + err := feedback.Write(os.Stdout, report, format) + if err != nil { + return fmt.Errorf("write install sync state report: %w", err) + } + + return nil +} + +func toolConfigSourcePaths(options toolConfigOptions) []string { + return compactCandidatePaths([]string{ + filepath.Join(options.ethosRoot, "config.yaml"), + options.repoConfig, + filepath.Join(options.repo, "repo_config.yaml"), + filepath.Join(options.repo, "repo_config.yml"), + filepath.Join(options.repo, "coding-ethos.repo.yaml"), + filepath.Join(options.repo, "coding-ethos.repo.yml"), + }) +} + +func geminiSourcePaths(options geminiprompts.Options) []string { + return compactCandidatePaths([]string{ + filepath.Join(options.EthosRoot, "config.yaml"), + defaultPath(options.Primary, filepath.Join(options.EthosRoot, "coding_ethos.yml")), + defaultPath(options.RepoEthos, filepath.Join(options.RepoRoot, "repo_ethos.yml")), + options.RepoConfig, + filepath.Join(options.RepoRoot, "repo_config.yaml"), + filepath.Join(options.RepoRoot, "repo_config.yml"), + }) +} + +func agentSkillSourcePaths(options agentskills.Options) []string { + return compactCandidatePaths([]string{ + defaultPath(options.Primary, filepath.Join(options.EthosRoot, "coding_ethos.yml")), + defaultPath(options.RepoEthos, filepath.Join(options.RepoRoot, "repo_ethos.yml")), + }) +} + +func defaultPath(path, fallback string) string { + if strings.TrimSpace(path) != "" { + return path + } + + return fallback +} + +func compactCandidatePaths(paths []string) []string { + compacted := make([]string, 0, len(paths)) + seen := map[string]bool{} + + for _, path := range paths { + cleaned := strings.TrimSpace(path) + if cleaned == "" { + continue + } + + cleaned = filepath.Clean(cleaned) + if seen[cleaned] { + continue + } + + seen[cleaned] = true + compacted = append(compacted, cleaned) + } + + return compacted +} + func validateMetadata(args []string) error { flags := flag.NewFlagSet("validate-metadata", flag.ExitOnError) metadataPath := flags.String("metadata", "", "Path to policy-metadata.json") @@ -1060,19 +1341,22 @@ func usage() { coding-ethos-policy config-trace [--primary coding_ethos.yml] [--config config.yaml] [--repo-config repo_config.yaml] [--json] coding-ethos-policy sync-tool-configs --repo REPO [--ethos-root .] - [--repo-config repo_config.yaml] + [--repo-config repo_config.yaml] [--dry-run] [--format json|toon] coding-ethos-policy check-tool-configs --repo REPO [--ethos-root .] [--repo-config repo_config.yaml] coding-ethos-policy sync-gemini-prompts --repo REPO [--ethos-root .] [--primary coding_ethos.yml] [--repo-ethos repo_ethos.yml] - [--repo-config repo_config.yaml] + [--repo-config repo_config.yaml] [--dry-run] [--format json|toon] coding-ethos-policy check-gemini-prompts --repo REPO [--ethos-root .] [--primary coding_ethos.yml] [--repo-ethos repo_ethos.yml] [--repo-config repo_config.yaml] coding-ethos-policy sync-agent-skills --repo REPO [--ethos-root .] [--primary coding_ethos.yml] [--repo-ethos repo_ethos.yml] + [--dry-run] [--format json|toon] coding-ethos-policy check-agent-skills --repo REPO [--ethos-root .] [--primary coding_ethos.yml] [--repo-ethos repo_ethos.yml] + coding-ethos-policy install-state-doctor --repo REPO [--format json|toon] + coding-ethos-policy install-state-repair-plan --repo REPO [--format json|toon] `) } diff --git a/go/internal/policycli/main_internal_test.go b/go/internal/policycli/main_internal_test.go index 2bca1084..3887c953 100644 --- a/go/internal/policycli/main_internal_test.go +++ b/go/internal/policycli/main_internal_test.go @@ -13,6 +13,7 @@ import ( "testing" "blackcat.ca/coding-ethos/go/internal/policy" + "blackcat.ca/coding-ethos/go/internal/syncstate" "blackcat.ca/coding-ethos/go/internal/testlock" ) @@ -224,6 +225,133 @@ func TestValidateRepoConfigSectionsRejectsUnknownCodeIntelKey(t *testing.T) { } } +func TestSyncToolConfigsDryRunDoesNotWriteFiles(t *testing.T) { + t.Parallel() + + ethosRoot := t.TempDir() + repoRoot := t.TempDir() + writePolicyCLITestFile( + t, + filepath.Join(ethosRoot, "config.yaml"), + "project:\n name: example\n", + ) + + var err error + captureStdout(t, func() { + err = syncToolConfigs([]string{ + "--ethos-root", ethosRoot, + "--repo", repoRoot, + "--dry-run", + "--format", "json", + }) + }) + if err != nil { + t.Fatalf("syncToolConfigs dry-run: %v", err) + } + + if _, err = os.Stat(filepath.Join(repoRoot, "pyrightconfig.json")); err == nil { + t.Fatal("dry-run wrote pyrightconfig.json") + } + + if _, err = os.Stat(syncstate.FilePath(repoRoot)); err == nil { + t.Fatal("dry-run wrote install sync state") + } +} + +func TestSyncToolConfigsWritesInstallSyncState(t *testing.T) { + t.Parallel() + + ethosRoot := t.TempDir() + repoRoot := t.TempDir() + writePolicyCLITestFile( + t, + filepath.Join(ethosRoot, "config.yaml"), + "project:\n name: example\n", + ) + writePolicyCLITestFile( + t, + filepath.Join(ethosRoot, "pyproject.toml"), + "[project]\nversion = \"1.2.3\"\n", + ) + + var err error + captureStdout(t, func() { + err = syncToolConfigs([]string{"--ethos-root", ethosRoot, "--repo", repoRoot}) + }) + if err != nil { + t.Fatalf("syncToolConfigs: %v", err) + } + + state, err := syncstate.Read(repoRoot) + if err != nil { + t.Fatalf("read install sync state: %v", err) + } + + if state.RequestedAction != "sync-tool-configs" || state.RuntimeVersion != "1.2.3" || + len(state.Artifacts) == 0 { + t.Fatalf("state = %#v", state) + } +} + +func TestRunCLIDispatchesInstallStateReports(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + artifactPath := filepath.Join(repoRoot, "generated.txt") + writePolicyCLITestFile(t, artifactPath, "expected\n") + + artifacts, err := syncstate.Artifacts(repoRoot, []syncstate.ArtifactInput{ + { + RelativePath: "generated.txt", + Content: "expected\n", + Provider: "agent-hooks", + Surface: "codex-config", + VerificationCommand: "bin/coding-ethos-run agent-hooks doctor", + }, + }) + if err != nil { + t.Fatalf("artifacts: %v", err) + } + + _, err = syncstate.Upsert(syncstate.UpsertOptions{ + RepoRoot: repoRoot, + RequestedAction: "agent-hooks sync", + ProviderTargets: []syncstate.ProviderTarget{ + {Provider: "agent-hooks", Root: repoRoot}, + }, + Artifacts: artifacts, + }) + if err != nil { + t.Fatalf("upsert install state: %v", err) + } + + doctorOutput := captureStdout(t, func() { + if code := runCLI( + []string{"install-state-doctor", "--repo", repoRoot, "--format", "toon"}, + ); code != 0 { + t.Fatalf("install-state-doctor exit = %d", code) + } + }) + if !strings.Contains(doctorOutput, "tool: install-sync-doctor") || + !strings.Contains(doctorOutput, "status: pass") { + t.Fatalf("doctor output = %q", doctorOutput) + } + + writePolicyCLITestFile(t, artifactPath, "drifted\n") + + repairOutput := captureStdout(t, func() { + if code := runCLI( + []string{"install-state-repair-plan", "--repo", repoRoot, "--format", "json"}, + ); code != 0 { + t.Fatalf("install-state-repair-plan exit = %d", code) + } + }) + if !strings.Contains(repairOutput, `"tool": "install-sync-repair-plan"`) || + !strings.Contains(repairOutput, `"planned_write_count": 1`) { + t.Fatalf("repair output = %q", repairOutput) + } +} + func TestValidateRepoConfigSectionsAllowsProxyOutputCompression(t *testing.T) { t.Parallel() @@ -744,6 +872,20 @@ func TestConfigTraceReportsConfigAndRepoSections(t *testing.T) { } } +func writePolicyCLITestFile(t *testing.T, path, content string) { + t.Helper() + + err := os.MkdirAll(filepath.Dir(path), 0o700) + if err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + + err = os.WriteFile(path, []byte(content), 0o600) + if err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + func captureStdout(t *testing.T, run func()) string { t.Helper() diff --git a/go/internal/syncstate/state.go b/go/internal/syncstate/state.go new file mode 100644 index 00000000..3dc14ed6 --- /dev/null +++ b/go/internal/syncstate/state.go @@ -0,0 +1,778 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package syncstate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "blackcat.ca/coding-ethos/go/internal/feedback" + "blackcat.ca/coding-ethos/go/internal/realgit" +) + +const ( + SchemaVersion = 1 + StatePath = ".coding-ethos/state/install-sync.json" + + DefaultOwnership = "coding-ethos-managed" + + artifactStatusDrifted = "drifted" + artifactStatusMissing = "missing" + artifactStatusPlanned = "planned" + artifactStatusUnchanged = "unchanged" + artifactStatusWouldUpdate = "would_update" + sourceStatusCurrent = "current" + sourceStatusMissing = "missing" + sourceStatusStale = "stale" + stateDirMode = 0o700 + stateFileMode = 0o600 +) + +var ( + errArtifactOutsideRepo = errors.New("artifact path is outside repo root") + errInvalidSyncState = errors.New("invalid sync state") +) + +type State struct { + TargetRepoRoot string `json:"target_repo_root"` + RequestedAction string `json:"requested_action"` + RuntimeVersion string `json:"runtime_version"` + RuntimeCommit string `json:"runtime_commit"` + LastValidationUTC string `json:"last_validation_utc"` + SourceHashes []SourceHash `json:"source_config_hashes"` + ProviderTargets []ProviderTarget `json:"provider_targets"` + Artifacts []Artifact `json:"artifacts"` + SchemaVersion int `json:"schema_version"` +} + +type SourceHash struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +type ProviderTarget struct { + Provider string `json:"provider"` + Root string `json:"root"` +} + +type Artifact struct { + Path string `json:"path"` + Provider string `json:"provider"` + Surface string `json:"surface"` + Ownership string `json:"ownership"` + ExpectedSHA256 string `json:"expected_sha256"` + VerificationCommand string `json:"verification_command"` + LastWrittenUTC string `json:"last_written_utc,omitempty"` +} + +type ArtifactInput struct { + RelativePath string + Content string + Provider string + Surface string + Ownership string + VerificationCommand string +} + +type UpsertOptions struct { + Now time.Time + RepoRoot string + EthosRoot string + RequestedAction string + SourcePaths []string + ProviderTargets []ProviderTarget + Artifacts []Artifact +} + +type Report struct { + Tool string `json:"tool"` + Status string `json:"status"` + StatePath string `json:"state_path"` + TargetRepoRoot string `json:"target_repo_root"` + RequestedAction string `json:"requested_action,omitempty"` + RuntimeVersion string `json:"runtime_version,omitempty"` + RuntimeCommit string `json:"runtime_commit,omitempty"` + LastValidationUTC string `json:"last_validation_utc,omitempty"` + ProviderTargets []ProviderTarget `json:"provider_targets,omitempty"` + Artifacts []ArtifactReport `json:"artifacts"` + Sources []SourceReport `json:"sources,omitempty"` + PlannedWriteCount int `json:"planned_write_count"` +} + +type ArtifactReport struct { + Path string `json:"path"` + Provider string `json:"provider"` + Surface string `json:"surface"` + Ownership string `json:"ownership"` + Status string `json:"status"` + Plan string `json:"plan,omitempty"` + ExpectedSHA256 string `json:"expected_sha256,omitempty"` + ActualSHA256 string `json:"actual_sha256,omitempty"` + VerificationCommand string `json:"verification_command,omitempty"` +} + +type SourceReport struct { + Path string `json:"path"` + Status string `json:"status"` + ExpectedSHA256 string `json:"expected_sha256,omitempty"` + ActualSHA256 string `json:"actual_sha256,omitempty"` +} + +func Artifacts(repoRoot string, inputs []ArtifactInput) ([]Artifact, error) { + artifacts := make([]Artifact, 0, len(inputs)) + + for _, input := range inputs { + relativePath, err := repoRelativePath(repoRoot, input.RelativePath) + if err != nil { + return nil, err + } + + ownership := strings.TrimSpace(input.Ownership) + if ownership == "" { + ownership = DefaultOwnership + } + + artifacts = append(artifacts, Artifact{ + Path: relativePath, + Provider: strings.TrimSpace(input.Provider), + Surface: strings.TrimSpace(input.Surface), + Ownership: ownership, + ExpectedSHA256: hashString(input.Content), + VerificationCommand: strings.TrimSpace(input.VerificationCommand), + }) + } + + sortArtifacts(artifacts) + + return artifacts, nil +} + +func Upsert(options UpsertOptions) (State, error) { + now := options.Now + if now.IsZero() { + now = time.Now().UTC() + } + + path := FilePath(options.RepoRoot) + + state, err := ReadFile(path) + switch { + case err == nil: + case errors.Is(err, os.ErrNotExist), errors.Is(err, errInvalidSyncState): + state = State{SchemaVersion: SchemaVersion} + default: + return State{}, err + } + + state.SchemaVersion = SchemaVersion + state.TargetRepoRoot = filepath.Clean(options.RepoRoot) + state.RequestedAction = strings.TrimSpace(options.RequestedAction) + state.LastValidationUTC = now.UTC().Format(time.RFC3339) + state.RuntimeVersion = runtimeVersion(options.EthosRoot) + state.RuntimeCommit = runtimeCommit(options.EthosRoot) + state.SourceHashes = mergeSources(state.SourceHashes, hashSources(options.SourcePaths)) + state.ProviderTargets = mergeProviderTargets( + state.ProviderTargets, + normalizedProviderTargets(options.RepoRoot, options.ProviderTargets), + ) + state.Artifacts = mergeArtifacts( + state.Artifacts, + timestampArtifacts(options.Artifacts, now), + ) + + err = WriteFile(path, state) + if err != nil { + return State{}, err + } + + return state, nil +} + +func Read(repoRoot string) (State, error) { + return ReadFile(FilePath(repoRoot)) +} + +func ReadFile(path string) (State, error) { + payload, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return State{}, fmt.Errorf("read sync state %s: %w", path, err) + } + + var state State + + err = json.Unmarshal(payload, &state) + if err != nil { + return State{}, fmt.Errorf( + "parse sync state %s: %w: %w", + path, + errInvalidSyncState, + err, + ) + } + + return state, nil +} + +func WriteFile(path string, state State) error { + payload, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("encode sync state: %w", err) + } + + err = os.MkdirAll(filepath.Dir(path), stateDirMode) + if err != nil { + return fmt.Errorf("create sync state dir: %w", err) + } + + err = os.WriteFile(filepath.Clean(path), append(payload, '\n'), stateFileMode) + if err != nil { + return fmt.Errorf("write sync state %s: %w", path, err) + } + + return nil +} + +func FilePath(repoRoot string) string { + return filepath.Join(filepath.Clean(repoRoot), filepath.FromSlash(StatePath)) +} + +func Plan(repoRoot, requestedAction string, artifacts []Artifact) Report { + report := baseReport("install-sync-plan", repoRoot) + report.RequestedAction = requestedAction + report.Artifacts = artifactReports(repoRoot, artifacts, true) + report.PlannedWriteCount = plannedWriteCount(report.Artifacts) + + if report.PlannedWriteCount == 0 { + report.Status = "pass" + } else { + report.Status = artifactStatusPlanned + } + + return report +} + +func Doctor(repoRoot string) (Report, error) { + state, err := Read(repoRoot) + if err != nil { + return Report{}, err + } + + report := reportFromState("install-sync-doctor", repoRoot, state) + report.Artifacts = artifactReports(repoRoot, state.Artifacts, false) + report.Sources = sourceReports(state.SourceHashes) + report.PlannedWriteCount = plannedWriteCount(report.Artifacts) + report.Status = doctorStatus(report) + + return report, nil +} + +func RepairPlan(repoRoot string) (Report, error) { + state, err := Read(repoRoot) + if err != nil { + return Report{}, err + } + + owned := make([]Artifact, 0, len(state.Artifacts)) + for _, artifact := range state.Artifacts { + if artifact.Ownership == DefaultOwnership { + owned = append(owned, artifact) + } + } + + report := reportFromState("install-sync-repair-plan", repoRoot, state) + report.Artifacts = artifactReports(repoRoot, owned, true) + report.Sources = sourceReports(state.SourceHashes) + report.PlannedWriteCount = plannedWriteCount(report.Artifacts) + + if report.PlannedWriteCount == 0 { + report.Status = "pass" + } else { + report.Status = artifactStatusPlanned + } + + return report, nil +} + +func (report Report) MarshalFeedbackJSON() any { + return report +} + +func (report Report) MarshalFeedbackTOON() string { + lines := []string{ + "tool: " + feedback.Cell(report.Tool), + "status: " + feedback.Cell(report.Status), + "state_path: " + feedback.Cell(report.StatePath), + "target_repo_root: " + feedback.Cell(report.TargetRepoRoot), + } + + if report.RequestedAction != "" { + lines = append(lines, "requested_action: "+feedback.Cell(report.RequestedAction)) + } + + if report.RuntimeVersion != "" { + lines = append(lines, "runtime_version: "+feedback.Cell(report.RuntimeVersion)) + } + + if report.RuntimeCommit != "" { + lines = append(lines, "runtime_commit: "+feedback.Cell(report.RuntimeCommit)) + } + + if report.LastValidationUTC != "" { + lines = append(lines, "last_validation_utc: "+feedback.Cell(report.LastValidationUTC)) + } + + lines = append(lines, providerTargetTOONLines(report.ProviderTargets)...) + lines = append(lines, sourceTOONLines(report.Sources)...) + lines = append(lines, artifactTOONLines(report.Artifacts)...) + lines = append( + lines, + fmt.Sprintf("planned_write_count: %d", report.PlannedWriteCount), + ) + + return strings.Join(lines, "\n") +} + +func (report Report) MarshalFeedbackHuman() string { + return report.MarshalFeedbackTOON() +} + +func (report Report) MarshalFeedbackSARIF() feedback.SARIFLog { + return feedback.Text{Text: report.MarshalFeedbackTOON()}.MarshalFeedbackSARIF() +} + +func (report Report) FeedbackLogFields() map[string]any { + return map[string]any{ + "tool": report.Tool, + "status": report.Status, + "state_path": report.StatePath, + "planned_write_count": report.PlannedWriteCount, + } +} + +func baseReport(tool, repoRoot string) Report { + return Report{ + Tool: tool, + StatePath: FilePath(repoRoot), + TargetRepoRoot: filepath.Clean(repoRoot), + } +} + +func reportFromState(tool, repoRoot string, state State) Report { + report := baseReport(tool, repoRoot) + report.RequestedAction = state.RequestedAction + report.RuntimeVersion = state.RuntimeVersion + report.RuntimeCommit = state.RuntimeCommit + report.LastValidationUTC = state.LastValidationUTC + report.ProviderTargets = state.ProviderTargets + + return report +} + +func artifactReports( + repoRoot string, + artifacts []Artifact, + dryRun bool, +) []ArtifactReport { + reports := make([]ArtifactReport, 0, len(artifacts)) + for _, artifact := range artifacts { + report := ArtifactReport{ + Path: artifact.Path, + Provider: artifact.Provider, + Surface: artifact.Surface, + Ownership: artifact.Ownership, + ExpectedSHA256: artifact.ExpectedSHA256, + VerificationCommand: artifact.VerificationCommand, + } + + actual, err := fileSHA256(filepath.Join(repoRoot, filepath.FromSlash(artifact.Path))) + switch { + case err != nil: + report.Status = artifactStatusMissing + report.Plan = "write" + case actual == artifact.ExpectedSHA256: + report.ActualSHA256 = actual + report.Status = artifactStatusUnchanged + report.Plan = "none" + case dryRun: + report.ActualSHA256 = actual + report.Status = artifactStatusWouldUpdate + report.Plan = "write" + default: + report.ActualSHA256 = actual + report.Status = artifactStatusDrifted + report.Plan = "repair" + } + + reports = append(reports, report) + } + + sort.SliceStable(reports, func(left, right int) bool { + return reports[left].Path < reports[right].Path + }) + + return reports +} + +func sourceReports(sources []SourceHash) []SourceReport { + reports := make([]SourceReport, 0, len(sources)) + for _, source := range sources { + report := SourceReport{ + Path: source.Path, + ExpectedSHA256: source.SHA256, + } + + actual, err := fileSHA256(source.Path) + if err != nil { + report.Status = sourceStatusMissing + } else { + report.ActualSHA256 = actual + if actual == source.SHA256 { + report.Status = sourceStatusCurrent + } else { + report.Status = sourceStatusStale + } + } + + reports = append(reports, report) + } + + sort.SliceStable(reports, func(left, right int) bool { + return reports[left].Path < reports[right].Path + }) + + return reports +} + +func doctorStatus(report Report) string { + for _, source := range report.Sources { + if source.Status != sourceStatusCurrent { + return "fail" + } + } + + for _, artifact := range report.Artifacts { + if artifact.Status != artifactStatusUnchanged { + return "fail" + } + } + + return "pass" +} + +func plannedWriteCount(artifacts []ArtifactReport) int { + count := 0 + + for _, artifact := range artifacts { + if artifact.Plan == "write" || artifact.Plan == "repair" { + count++ + } + } + + return count +} + +func providerTargetTOONLines(targets []ProviderTarget) []string { + lines := make([]string, 0, 1+len(targets)) + lines = append( + lines, + fmt.Sprintf("provider_targets[%d]{provider,root}:", len(targets)), + ) + + for _, target := range targets { + lines = append(lines, fmt.Sprintf( + " %s,%s", + feedback.Cell(target.Provider), + feedback.Cell(target.Root), + )) + } + + return lines +} + +func sourceTOONLines(sources []SourceReport) []string { + lines := make([]string, 0, 1+len(sources)) + lines = append(lines, fmt.Sprintf( + "sources[%d]{path,status,expected_sha256,actual_sha256}:", + len(sources), + )) + + for _, source := range sources { + lines = append(lines, fmt.Sprintf( + " %s,%s,%s,%s", + feedback.Cell(source.Path), + feedback.Cell(source.Status), + feedback.Cell(source.ExpectedSHA256), + feedback.Cell(source.ActualSHA256), + )) + } + + return lines +} + +func artifactTOONLines(artifacts []ArtifactReport) []string { + const columns = "path,provider,surface,status,ownership,plan," + + "expected_sha256,actual_sha256,verification_command" + + lines := make([]string, 0, 1+len(artifacts)) + lines = append(lines, fmt.Sprintf("artifacts[%d]{%s}:", len(artifacts), columns)) + + for _, artifact := range artifacts { + lines = append(lines, fmt.Sprintf( + " %s,%s,%s,%s,%s,%s,%s,%s,%s", + feedback.Cell(artifact.Path), + feedback.Cell(artifact.Provider), + feedback.Cell(artifact.Surface), + feedback.Cell(artifact.Status), + feedback.Cell(artifact.Ownership), + feedback.Cell(artifact.Plan), + feedback.Cell(artifact.ExpectedSHA256), + feedback.Cell(artifact.ActualSHA256), + feedback.Cell(artifact.VerificationCommand), + )) + } + + return lines +} + +func mergeSources(existing, incoming []SourceHash) []SourceHash { + merged := map[string]SourceHash{} + + for _, source := range existing { + merged[source.Path] = source + } + + for _, source := range incoming { + merged[source.Path] = source + } + + sources := make([]SourceHash, 0, len(merged)) + for _, source := range merged { + sources = append(sources, source) + } + + sort.SliceStable(sources, func(left, right int) bool { + return sources[left].Path < sources[right].Path + }) + + return sources +} + +func mergeProviderTargets(existing, incoming []ProviderTarget) []ProviderTarget { + merged := map[string]ProviderTarget{} + + for _, target := range existing { + merged[target.Provider+"\x00"+target.Root] = target + } + + for _, target := range incoming { + merged[target.Provider+"\x00"+target.Root] = target + } + + targets := make([]ProviderTarget, 0, len(merged)) + for _, target := range merged { + targets = append(targets, target) + } + + sort.SliceStable(targets, func(left, right int) bool { + if targets[left].Provider != targets[right].Provider { + return targets[left].Provider < targets[right].Provider + } + + return targets[left].Root < targets[right].Root + }) + + return targets +} + +func mergeArtifacts(existing, incoming []Artifact) []Artifact { + merged := map[string]Artifact{} + + for _, artifact := range existing { + merged[artifact.Provider+"\x00"+artifact.Path] = artifact + } + + for _, artifact := range incoming { + merged[artifact.Provider+"\x00"+artifact.Path] = artifact + } + + artifacts := make([]Artifact, 0, len(merged)) + for _, artifact := range merged { + artifacts = append(artifacts, artifact) + } + + sortArtifacts(artifacts) + + return artifacts +} + +func timestampArtifacts(artifacts []Artifact, now time.Time) []Artifact { + timestamped := make([]Artifact, 0, len(artifacts)) + for _, artifact := range artifacts { + artifact.LastWrittenUTC = now.UTC().Format(time.RFC3339) + timestamped = append(timestamped, artifact) + } + + return timestamped +} + +func normalizedProviderTargets( + repoRoot string, + targets []ProviderTarget, +) []ProviderTarget { + normalized := make([]ProviderTarget, 0, len(targets)) + for _, target := range targets { + root := strings.TrimSpace(target.Root) + if root == "" { + root = repoRoot + } + + normalized = append(normalized, ProviderTarget{ + Provider: strings.TrimSpace(target.Provider), + Root: filepath.Clean(root), + }) + } + + return normalized +} + +func sortArtifacts(artifacts []Artifact) { + sort.SliceStable(artifacts, func(left, right int) bool { + if artifacts[left].Provider != artifacts[right].Provider { + return artifacts[left].Provider < artifacts[right].Provider + } + + return artifacts[left].Path < artifacts[right].Path + }) +} + +func hashSources(paths []string) []SourceHash { + sources := make([]SourceHash, 0, len(paths)) + for _, path := range paths { + cleaned := filepath.Clean(path) + if strings.TrimSpace(cleaned) == "" || cleaned == "." { + continue + } + + sum, err := fileSHA256(cleaned) + if err != nil { + continue + } + + sources = append(sources, SourceHash{Path: cleaned, SHA256: sum}) + } + + sort.SliceStable(sources, func(left, right int) bool { + return sources[left].Path < sources[right].Path + }) + + return sources +} + +func fileSHA256(path string) (string, error) { + payload, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return "", fmt.Errorf("read file for SHA-256 %s: %w", path, err) + } + + return hashBytes(payload), nil +} + +func hashString(value string) string { + return hashBytes([]byte(value)) +} + +func hashBytes(payload []byte) string { + sum := sha256.Sum256(payload) + + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func repoRelativePath(repoRoot, path string) (string, error) { + cleaned := filepath.Clean(path) + root := filepath.Clean(repoRoot) + + if !filepath.IsAbs(cleaned) { + cleaned = filepath.Join(root, cleaned) + } + + relative, err := filepath.Rel(root, cleaned) + if err != nil { + return "", fmt.Errorf("resolve relative artifact path %s: %w", path, err) + } + + if pathEscapesRoot(relative) { + return "", fmt.Errorf( + "artifact path %s is outside repo root %s: %w", + path, + repoRoot, + errArtifactOutsideRepo, + ) + } + + return filepath.ToSlash(relative), nil +} + +func pathEscapesRoot(path string) bool { + return path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) +} + +func runtimeVersion(ethosRoot string) string { + payload, err := os.ReadFile(filepath.Join(ethosRoot, "pyproject.toml")) + if err != nil { + return "" + } + + inProject := false + + for line := range strings.SplitSeq(string(payload), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "[project]" { + inProject = true + + continue + } + + if strings.HasPrefix(trimmed, "[") { + inProject = false + + continue + } + + if !inProject { + continue + } + + if !strings.HasPrefix(trimmed, "version = ") { + continue + } + + return strings.Trim(strings.TrimPrefix(trimmed, "version = "), `"`) + } + + return "" +} + +func runtimeCommit(ethosRoot string) string { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + command := realgit.Command(ctx, false, "rev-parse", "HEAD") + command.Dir = filepath.Clean(ethosRoot) + + output, err := command.Output() + if err != nil { + return "" + } + + return strings.TrimSpace(string(output)) +} diff --git a/go/internal/syncstate/state_test.go b/go/internal/syncstate/state_test.go new file mode 100644 index 00000000..7c44eef4 --- /dev/null +++ b/go/internal/syncstate/state_test.go @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. +// SPDX-License-Identifier: AGPL-3.0-only + +package syncstate + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestInstallSyncStatePlansRecordsAndDoctorsArtifacts(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + ethosRoot := t.TempDir() + configPath := filepath.Join(ethosRoot, "config.yaml") + artifactPath := filepath.Join(repoRoot, "generated", "artifact.txt") + + writeTestFile( + t, + filepath.Join(ethosRoot, "pyproject.toml"), + "[project]\nversion = \"9.8.7\"\n", + ) + writeTestFile(t, configPath, "project:\n name: example\n") + + artifacts, err := Artifacts(repoRoot, []ArtifactInput{ + { + RelativePath: "generated/artifact.txt", + Content: "expected\n", + Provider: "tool-configs", + Surface: "generated-tool-config", + VerificationCommand: "make check-tool-configs", + }, + }) + if err != nil { + t.Fatalf("artifacts: %v", err) + } + + plan := Plan(repoRoot, "sync-tool-configs", artifacts) + if plan.Status != artifactStatusPlanned || plan.PlannedWriteCount != 1 || + plan.Artifacts[0].Status != artifactStatusMissing { + t.Fatalf("plan = %#v", plan) + } + + writeTestFile(t, artifactPath, "expected\n") + + state, err := Upsert(UpsertOptions{ + RepoRoot: repoRoot, + EthosRoot: ethosRoot, + RequestedAction: "sync-tool-configs", + SourcePaths: []string{configPath}, + ProviderTargets: []ProviderTarget{{Provider: "tool-configs", Root: repoRoot}}, + Artifacts: artifacts, + Now: time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC), + }) + if err != nil { + t.Fatalf("upsert: %v", err) + } + + if state.SchemaVersion != SchemaVersion || state.RuntimeVersion != "9.8.7" || + len(state.Artifacts) != 1 || len(state.SourceHashes) != 1 { + t.Fatalf("state = %#v", state) + } + if state.Artifacts[0].LastWrittenUTC != "2026-02-03T04:05:06Z" { + t.Fatalf("last written timestamp = %s", state.Artifacts[0].LastWrittenUTC) + } + + doctor, err := Doctor(repoRoot) + if err != nil { + t.Fatalf("doctor: %v", err) + } + if doctor.Status != "pass" || doctor.PlannedWriteCount != 0 { + t.Fatalf("doctor = %#v", doctor) + } + + writeTestFile(t, artifactPath, "drifted\n") + + doctor, err = Doctor(repoRoot) + if err != nil { + t.Fatalf("doctor after drift: %v", err) + } + if doctor.Status != "fail" || doctor.Artifacts[0].Status != artifactStatusDrifted { + t.Fatalf("doctor after drift = %#v", doctor) + } + + repair, err := RepairPlan(repoRoot) + if err != nil { + t.Fatalf("repair plan: %v", err) + } + if repair.Status != artifactStatusPlanned || repair.PlannedWriteCount != 1 || + repair.Artifacts[0].Plan != "write" { + t.Fatalf("repair = %#v", repair) + } + + writeTestFile(t, configPath, "project:\n name: changed\n") + + doctor, err = Doctor(repoRoot) + if err != nil { + t.Fatalf("doctor after source drift: %v", err) + } + if doctor.Sources[0].Status != sourceStatusStale { + t.Fatalf("source reports = %#v", doctor.Sources) + } +} + +func TestRepairPlanOnlyIncludesCodingEthosOwnedArtifacts(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + managedPath := filepath.Join(repoRoot, "managed.txt") + externalPath := filepath.Join(repoRoot, "external.txt") + + artifacts, err := Artifacts(repoRoot, []ArtifactInput{ + {RelativePath: "managed.txt", Content: "expected\n", Provider: "agent-hooks"}, + { + RelativePath: "external.txt", + Content: "expected\n", + Provider: "external", + Ownership: "external", + }, + }) + if err != nil { + t.Fatalf("artifacts: %v", err) + } + + writeTestFile(t, managedPath, "drifted\n") + writeTestFile(t, externalPath, "drifted\n") + + _, err = Upsert(UpsertOptions{ + RepoRoot: repoRoot, + RequestedAction: "agent-hooks sync", + Artifacts: artifacts, + }) + if err != nil { + t.Fatalf("upsert: %v", err) + } + + repair, err := RepairPlan(repoRoot) + if err != nil { + t.Fatalf("repair plan: %v", err) + } + + if len(repair.Artifacts) != 1 || repair.Artifacts[0].Path != "managed.txt" { + t.Fatalf("repair artifacts = %#v", repair.Artifacts) + } +} + +func TestReportRendersAllFeedbackFormats(t *testing.T) { + t.Parallel() + + report := Report{ + Tool: "install-sync-doctor", + Status: "planned", + StatePath: ".coding-ethos/state/install-sync.json", + TargetRepoRoot: "/repo", + RequestedAction: "agent-hooks sync", + RuntimeVersion: "1.2.3", + RuntimeCommit: "abc1234", + LastValidationUTC: "2026-02-03T04:05:06Z", + ProviderTargets: []ProviderTarget{ + {Provider: "agent-hooks", Root: "/repo"}, + }, + Sources: []SourceReport{ + { + Path: "coding_ethos.yml", + Status: sourceStatusCurrent, + ExpectedSHA256: "sha256:source", + ActualSHA256: "sha256:source", + }, + }, + Artifacts: []ArtifactReport{ + { + Path: ".codex/config.toml", + Provider: "agent-hooks", + Surface: "codex-config", + Ownership: DefaultOwnership, + Status: artifactStatusMissing, + Plan: "write", + ExpectedSHA256: "sha256:expected", + VerificationCommand: "bin/coding-ethos-run agent-hooks doctor", + }, + }, + PlannedWriteCount: 1, + } + + if got, ok := report.MarshalFeedbackJSON().(Report); !ok || got.Tool != report.Tool { + t.Fatalf("json feedback = %#v", got) + } + + toon := report.MarshalFeedbackTOON() + for _, want := range []string{ + "tool: install-sync-doctor", + "provider_targets[1]{provider,root}:", + "sources[1]{path,status,expected_sha256,actual_sha256}:", + "artifacts[1]{path,provider,surface,status,ownership,plan,expected_sha256,actual_sha256,verification_command}:", + "planned_write_count: 1", + } { + if !strings.Contains(toon, want) { + t.Fatalf("TOON output missing %q:\n%s", want, toon) + } + } + + if human := report.MarshalFeedbackHuman(); human != toon { + t.Fatalf("human feedback differs from TOON:\n%s", human) + } + + sarif := report.MarshalFeedbackSARIF() + if sarif.Version == "" || len(sarif.Runs) != 1 || + len(sarif.Runs[0].Results) != 1 { + t.Fatalf("SARIF feedback = %#v", sarif) + } + + fields := report.FeedbackLogFields() + if fields["tool"] != report.Tool || + fields["status"] != report.Status || + fields["planned_write_count"] != report.PlannedWriteCount { + t.Fatalf("log fields = %#v", fields) + } +} + +func TestUpsertRebuildsCorruptedRuntimeState(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + writeTestFile(t, FilePath(repoRoot), "{not-json") + + state, err := Upsert(UpsertOptions{ + RepoRoot: repoRoot, + RequestedAction: "sync-tool-configs", + }) + if err != nil { + t.Fatalf("upsert corrupted state: %v", err) + } + + if state.SchemaVersion != SchemaVersion || + state.RequestedAction != "sync-tool-configs" { + t.Fatalf("state = %#v", state) + } +} + +func TestRepoRelativePathRejectsOnlyRealTraversal(t *testing.T) { + t.Parallel() + + repoRoot := filepath.Join(t.TempDir(), "repo") + + _, err := repoRelativePath(repoRoot, "../../etc/passwd") + if !errors.Is(err, errArtifactOutsideRepo) { + t.Fatalf("relative traversal error = %v", err) + } + + valid, err := repoRelativePath(repoRoot, filepath.Join(repoRoot, "..foo", "bar.txt")) + if err != nil { + t.Fatalf("valid path returned error: %v", err) + } + if valid != filepath.ToSlash(filepath.Join("..foo", "bar.txt")) { + t.Fatalf("valid path = %q", valid) + } +} + +func TestRuntimeVersionUsesProjectTable(t *testing.T) { + t.Parallel() + + ethosRoot := t.TempDir() + writeTestFile( + t, + filepath.Join(ethosRoot, "pyproject.toml"), + "[tool.example]\nversion = \"0.0.1\"\n\n[project]\nversion = \"2.3.4\"\n", + ) + + if got := runtimeVersion(ethosRoot); got != "2.3.4" { + t.Fatalf("runtime version = %q", got) + } +} + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + + err := os.MkdirAll(filepath.Dir(path), 0o700) + if err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + + err = os.WriteFile(path, []byte(content), 0o600) + if err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/go/internal/toolconfigs/sync.go b/go/internal/toolconfigs/sync.go index 9ea97c91..ad6e3640 100644 --- a/go/internal/toolconfigs/sync.go +++ b/go/internal/toolconfigs/sync.go @@ -12,6 +12,7 @@ import ( "blackcat.ca/coding-ethos/go/internal/configdata" "blackcat.ca/coding-ethos/go/internal/configprofiles" + "blackcat.ca/coding-ethos/go/internal/syncstate" ) const ( @@ -73,6 +74,48 @@ func Sync(ethosRoot, repoRoot, repoConfig string) ([]string, error) { return written, nil } +func StateArtifacts( + ethosRoot, + repoRoot, + repoConfig string, +) ([]syncstate.Artifact, error) { + rendered, err := renderForRepo(ethosRoot, repoRoot, repoConfig) + if err != nil { + return nil, err + } + + manifest, err := RenderHashManifest(rendered) + if err != nil { + return nil, err + } + + inputs := make([]syncstate.ArtifactInput, 0, len(rendered)+1) + for relativePath, content := range rendered { + inputs = append(inputs, syncstate.ArtifactInput{ + RelativePath: relativePath, + Content: content, + Provider: "tool-configs", + Surface: "generated-tool-config", + VerificationCommand: "make check-tool-configs", + }) + } + + inputs = append(inputs, syncstate.ArtifactInput{ + RelativePath: HashManifestPath, + Content: manifest, + Provider: "tool-configs", + Surface: "hash-manifest", + VerificationCommand: "make check-tool-configs", + }) + + artifacts, err := syncstate.Artifacts(repoRoot, inputs) + if err != nil { + return nil, fmt.Errorf("build tool config state artifacts: %w", err) + } + + return artifacts, nil +} + func Check(ethosRoot, repoRoot, repoConfig string) ([]string, error) { rendered, err := renderForRepo(ethosRoot, repoRoot, repoConfig) if err != nil {