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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ This project follows Semantic Versioning.

## [Unreleased]

### Changed

- **Breaking:** replaced legacy per-setting `.code-converge/` files with strict project and user `config.yaml` documents. The next release must be a SemVer major release.

## [0.7.0] - 2026-07-30

### Added
Expand Down
53 changes: 25 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,13 +296,13 @@ Agent output is sanitized before rendering: terminal controls are removed, inval

## Configuration

Every option can be supplied in four places: a command-line flag, an environment variable, project configuration, or user configuration.
Every option can be supplied in four places: a command-line flag, an environment variable, project YAML, or user YAML.

Resolution order is highest to lowest priority:

1. Command-line flags
2. Project configuration in `<git-root>/.code-converge/`
3. User configuration in `~/.code-converge/`
2. Project configuration in `<git-root>/.code-converge/config.yaml`
3. User configuration in `~/.code-converge/config.yaml`
4. Environment variables
5. Built-in defaults

Expand All @@ -322,9 +322,11 @@ The `fast` and `best` modes select these operative stage profiles. `fast` is the
| Fix findings | `gpt-5.6-luna`, `medium` | `gpt-5.6-terra`, `high` | Findings involve architecture, security, migrations, concurrency, or several connected modules. |
| Fix CI | `gpt-5.6-luna`, `medium` | `gpt-5.6-terra`, `high` | The cause is not localized by logs, spans multiple components, or persists after a repair. |

`config.yaml` is the only file-based configuration source. It is a strict, flat YAML mapping: unknown, duplicate, nested, malformed, and invalid values are rejected. Legacy per-setting files are ignored without migration or fallback. Prompt values remain file references; relative paths resolve from the directory containing the YAML file.

### Options and defaults

| Option | Flag | Environment variable | Project / user file | Default |
| Option | Flag | Environment variable | YAML key | Default |
| --- | --- | --- | --- | --- |
| Workflow log format | `--log-format` | `CODE_CONVERGE_LOG_FORMAT` | `log-format` | `human` |
| Human liveness heartbeat | `--heartbeat` | `CODE_CONVERGE_HEARTBEAT` | `heartbeat` | `0` (disabled) |
Expand All @@ -337,43 +339,38 @@ The `fast` and `best` modes select these operative stage profiles. `fast` is the
| Review reasoning effort | `--review-reasoning-effort` | `CODE_CONVERGE_REVIEW_REASONING_EFFORT` | `review-reasoning-effort` | selected profile |
| Fix-findings model | `--fix-model` | `CODE_CONVERGE_FIX_MODEL` | `fix-model` | selected profile |
| Fix-findings reasoning effort | `--fix-reasoning-effort` | `CODE_CONVERGE_FIX_REASONING_EFFORT` | `fix-reasoning-effort` | selected profile |
| Fix-findings prompt | `--fix-prompt-file` | `CODE_CONVERGE_FIX_PROMPT_FILE` | `fix-findings.md` | `fix findings` |
| Fix-findings prompt | `--fix-prompt-file` | `CODE_CONVERGE_FIX_PROMPT_FILE` | `fix-prompt-file` | `fix findings` |
| CI-fix model | `--ci-fix-model` | `CODE_CONVERGE_CI_FIX_MODEL` | `ci-fix-model` | selected profile |
| CI-fix reasoning effort | `--ci-fix-reasoning-effort` | `CODE_CONVERGE_CI_FIX_REASONING_EFFORT` | `ci-fix-reasoning-effort` | selected profile |
| CI-fix prompt | `--ci-fix-prompt-file` | `CODE_CONVERGE_CI_FIX_PROMPT_FILE` | `fix-ci.md` | `Исправь CI` |
| CI-fix prompt | `--ci-fix-prompt-file` | `CODE_CONVERGE_CI_FIX_PROMPT_FILE` | `ci-fix-prompt-file` | `Исправь CI` |
| Review base override | `--review-base` | `CODE_CONVERGE_REVIEW_BASE` | `review-base` | discover intended base |
| Diagnostic session-log directory | `--session-log-dir` | `CODE_CONVERGE_SESSION_LOG_DIR` | `session-log-dir` | `~/.code-converge/session-logs` |
| Diagnostic session-log retention | `--session-log-retention` | `CODE_CONVERGE_SESSION_LOG_RETENTION` | `session-log-retention` | `24h` |
| Disable diagnostic logging for this run | `--no-session-log` | — | — | disabled only when flag supplied |

`--finalize-model`, `--finalize-reasoning-effort`, and `--finalize-prompt-file`, their `CODE_CONVERGE_FINALIZE_*` environment variables, and `finalize-*` / `finalize.md` configuration files were removed in this release. Remove them during migration: obsolete environment or configuration-file settings cause an actionable configuration error rather than being ignored, because Codex no longer performs publication or CI polling.
`--finalize-model`, `--finalize-reasoning-effort`, and `--finalize-prompt-file`, their `CODE_CONVERGE_FINALIZE_*` environment variables, and their YAML keys were removed in this release because Codex no longer performs publication or CI polling. Obsolete environment settings and YAML keys cause actionable configuration errors.

For example, a team can commit these files:
For example, a team can commit one project configuration document and keep prompt content alongside it:

```text
.code-converge/
├── log-format
├── heartbeat
├── color
├── mode
├── review-model
├── review-reasoning-effort
├── review-base
├── fix-model
├── fix-reasoning-effort
├── ci-fix-model
├── ci-fix-reasoning-effort
├── max-cycles
├── max-ci-recoveries

├── ci-timeout
├── session-log-dir
├── session-log-retention
├── fix-findings.md
└── fix-ci.md
├── config.yaml
└── prompts/
├── fix-findings.md
└── fix-ci.md
```

```yaml
# <git-root>/.code-converge/config.yaml
mode: best
max-cycles: 3
ci-timeout: 45m
review-base: main
fix-prompt-file: prompts/fix-findings.md
ci-fix-prompt-file: prompts/fix-ci.md
```

The same layout in `~/.code-converge/` sets user-level defaults. Environment variables are particularly useful in CI or temporary shell sessions:
The same schema in `~/.code-converge/config.yaml` sets user-level defaults. Environment variables are particularly useful in CI or temporary shell sessions:

```sh
CODE_CONVERGE_MAX_CYCLES=3 \
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ require (
)

require golang.org/x/sys v0.21.0

require gopkg.in/yaml.v3 v3.0.1
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
131 changes: 108 additions & 23 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package config

import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"

"gopkg.in/yaml.v3"
)

const (
Expand Down Expand Up @@ -91,6 +94,29 @@ type spec struct {
promptFile bool
}

type fileConfig map[string]string

type yamlFileConfig struct {
LogFormat *string `yaml:"log-format"`
Heartbeat *string `yaml:"heartbeat"`
Color *string `yaml:"color"`
Mode *string `yaml:"mode"`
MaxCycles *int `yaml:"max-cycles"`
MaxCIRecoveries *int `yaml:"max-ci-recoveries"`
CITimeout *string `yaml:"ci-timeout"`
ReviewModel *string `yaml:"review-model"`
ReviewEffort *string `yaml:"review-reasoning-effort"`
FixModel *string `yaml:"fix-model"`
FixEffort *string `yaml:"fix-reasoning-effort"`
FixPromptPath *string `yaml:"fix-prompt-file"`
CIFixModel *string `yaml:"ci-fix-model"`
CIFixEffort *string `yaml:"ci-fix-reasoning-effort"`
CIFixPromptPath *string `yaml:"ci-fix-prompt-file"`
ReviewBase *string `yaml:"review-base"`
SessionLogDir *string `yaml:"session-log-dir"`
SessionLogRetention *string `yaml:"session-log-retention"`
}

type stageProfile struct {
reviewModel, reviewEffort string
fixModel, fixEffort string
Expand Down Expand Up @@ -183,10 +209,10 @@ func Load(cwd, home string, overrides Overrides) (Config, error) {
{name: "review-reasoning-effort", file: "review-reasoning-effort", env: "CODE_CONVERGE_REVIEW_REASONING_EFFORT", def: profile.reviewEffort, builtIn: fast.reviewEffort, defSource: profileSource, override: overrides.ReviewEffort},
{name: "fix-model", file: "fix-model", env: "CODE_CONVERGE_FIX_MODEL", def: profile.fixModel, builtIn: fast.fixModel, defSource: profileSource, override: overrides.FixModel},
{name: "fix-reasoning-effort", file: "fix-reasoning-effort", env: "CODE_CONVERGE_FIX_REASONING_EFFORT", def: profile.fixEffort, builtIn: fast.fixEffort, defSource: profileSource, override: overrides.FixEffort},
{name: "fix-prompt", file: "fix-findings.md", env: "CODE_CONVERGE_FIX_PROMPT_FILE", def: "fix findings", builtIn: "fix findings", defSource: SourceDefault, override: overrides.FixPromptPath, promptFile: true},
{name: "fix-prompt", file: "fix-prompt-file", env: "CODE_CONVERGE_FIX_PROMPT_FILE", def: "fix findings", builtIn: "fix findings", defSource: SourceDefault, override: overrides.FixPromptPath, promptFile: true},
{name: "ci-fix-model", file: "ci-fix-model", env: "CODE_CONVERGE_CI_FIX_MODEL", def: profile.ciFixModel, builtIn: fast.ciFixModel, defSource: profileSource, override: overrides.CIFixModel},
{name: "ci-fix-reasoning-effort", file: "ci-fix-reasoning-effort", env: "CODE_CONVERGE_CI_FIX_REASONING_EFFORT", def: profile.ciFixEffort, builtIn: fast.ciFixEffort, defSource: profileSource, override: overrides.CIFixEffort},
{name: "ci-fix-prompt", file: "fix-ci.md", env: "CODE_CONVERGE_CI_FIX_PROMPT_FILE", def: "Исправь CI", builtIn: "Исправь CI", defSource: SourceDefault, override: overrides.CIFixPromptPath, promptFile: true},
{name: "ci-fix-prompt", file: "ci-fix-prompt-file", env: "CODE_CONVERGE_CI_FIX_PROMPT_FILE", def: "Исправь CI", builtIn: "Исправь CI", defSource: SourceDefault, override: overrides.CIFixPromptPath, promptFile: true},
{name: "review-base", file: "review-base", env: "CODE_CONVERGE_REVIEW_BASE", def: "", builtIn: "", defSource: SourceDefault, override: overrides.ReviewBase},
{name: "session-log-dir", file: "session-log-dir", env: "CODE_CONVERGE_SESSION_LOG_DIR", def: filepath.Join(home, ".code-converge", "session-logs"), builtIn: filepath.Join(home, ".code-converge", "session-logs"), defSource: SourceDefault, override: overrides.SessionLogDir},
{name: "session-log-retention", file: "session-log-retention", env: "CODE_CONVERGE_SESSION_LOG_RETENTION", def: "24h", builtIn: "24h", defSource: SourceDefault, override: overrides.SessionLogRetention},
Expand Down Expand Up @@ -266,19 +292,6 @@ func rejectObsoleteFinalizeSettings(userDir, projectDir string) error {
return fmt.Errorf("%s was removed; remove this obsolete Finalize-stage setting", name)
}
}
for _, directory := range []struct {
path string
source string
}{{userDir, "user"}, {projectDir, "project"}} {
for _, name := range []string{"finalize-model", "finalize-reasoning-effort", "finalize.md"} {
path := filepath.Join(directory.path, name)
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("%s Finalize-stage setting %q was removed; delete it", directory.source, path)
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect obsolete Finalize-stage setting %q: %w", path, err)
}
}
}
return nil
}

Expand Down Expand Up @@ -359,18 +372,22 @@ func resolve(item spec, cwd, userDir, projectDir string) (string, Setting, error
source = SourceEnv
}
for _, candidate := range []struct{ dir, source string }{{userDir, SourceUser}, {projectDir, SourceProject}} {
path := filepath.Join(candidate.dir, item.file)
content, err := os.ReadFile(path)
config, err := readFileConfig(candidate.dir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return "", Setting{}, fmt.Errorf("read %s: %w", path, err)
return "", Setting{}, err
}
content, ok := config[item.file]
if !ok {
continue
}
if item.promptFile {
value, display = string(content), path
prompt, path, err := readConfiguredPrompt(candidate.dir, content)
if err != nil {
return "", Setting{}, fmt.Errorf("%s from %s config: %w", item.name, candidate.source, err)
}
value, display = prompt, path
} else {
value, display = strings.TrimSpace(string(content)), strings.TrimSpace(string(content))
value, display = strings.TrimSpace(content), strings.TrimSpace(content)
}
source = candidate.source
}
Expand All @@ -389,6 +406,74 @@ func resolve(item spec, cwd, userDir, projectDir string) (string, Setting, error
return value, Setting{Name: item.name, Value: value, Source: source, Default: item.builtIn, DisplayValue: display, DisplayDefault: displayDefault(item)}, nil
}

func readFileConfig(dir string) (fileConfig, error) {
path := filepath.Join(dir, "config.yaml")
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return fileConfig{}, nil
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
var decoded yamlFileConfig
decoder := yaml.NewDecoder(strings.NewReader(string(content)))
decoder.KnownFields(true)
if err := decoder.Decode(&decoded); err == io.EOF {
return fileConfig{}, nil
} else if err != nil {
return nil, fmt.Errorf("invalid YAML configuration %s: %w", path, err)
}
var extra yaml.Node
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return nil, fmt.Errorf("invalid YAML configuration %s: only one YAML document is permitted", path)
}
return nil, fmt.Errorf("invalid YAML configuration %s: %w", path, err)
}
values := fileConfig{}
setString := func(key string, value *string) {
if value != nil {
values[key] = *value
}
}
setInt := func(key string, value *int) {
if value != nil {
values[key] = strconv.Itoa(*value)
}
}
setString("log-format", decoded.LogFormat)
setString("heartbeat", decoded.Heartbeat)
setString("color", decoded.Color)
setString("mode", decoded.Mode)
setInt("max-cycles", decoded.MaxCycles)
setInt("max-ci-recoveries", decoded.MaxCIRecoveries)
setString("ci-timeout", decoded.CITimeout)
setString("review-model", decoded.ReviewModel)
setString("review-reasoning-effort", decoded.ReviewEffort)
setString("fix-model", decoded.FixModel)
setString("fix-reasoning-effort", decoded.FixEffort)
setString("fix-prompt-file", decoded.FixPromptPath)
setString("ci-fix-model", decoded.CIFixModel)
setString("ci-fix-reasoning-effort", decoded.CIFixEffort)
setString("ci-fix-prompt-file", decoded.CIFixPromptPath)
setString("review-base", decoded.ReviewBase)
setString("session-log-dir", decoded.SessionLogDir)
setString("session-log-retention", decoded.SessionLogRetention)
return values, nil
}

func readConfiguredPrompt(configDir, value string) (string, string, error) {
path := value
if !filepath.IsAbs(path) {
path = filepath.Join(configDir, path)
}
content, err := os.ReadFile(path)
if err != nil {
return "", "", fmt.Errorf("read prompt file %s: %w", path, err)
}
return string(content), path, nil
}

func displayDefault(item spec) string {
return displayValue(item.builtIn, item.promptFile)
}
Expand Down
Loading