Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
```
Expand Down
73 changes: 44 additions & 29 deletions go/internal/agenthooks/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,72 +314,87 @@ 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)
if err != nil {
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)
}

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{
Expand Down
45 changes: 45 additions & 0 deletions go/internal/agenthooks/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()

Expand Down
111 changes: 111 additions & 0 deletions go/internal/agenthooks/state_artifacts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: 2026 Blackcat Informatics Inc. <paudley@blackcat.ca>
// 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,
},
}
}
Loading
Loading