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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ Key points:

`--review-base <ref>`, `CODE_CONVERGE_REVIEW_BASE` and `.code-converge/review-base` explicitly select the base using the normal configuration precedence. A branch already merged into the selected base has no committed delta but still reviews worktree changes; a fully clean run follows the existing clean/no-change path. It uses the model and reasoning effort resolved from the selected mode and any explicit stage overrides.

### Document review prompts

Ordinary code review remains the default. Select at most one explicit review mode: `--review-prompt-file <path>` reads a regular Markdown file from an absolute or current-directory-relative path; `--review-prompt <name>` reads only `.code-converge/<name>.md` (names contain letters, digits, `_` or `-`); and `--document-review` reviews only changed `.md` files in the same private snapshot, excluding `memory-bank/prompts/**`. The document mode uses `.code-converge/default.md` when it exists and otherwise uses its built-in prompt. Missing, unreadable, non-Markdown, invalid or conflicting selections exit `2` without fallback. If no eligible documents changed, no Codex review is started. Document mode is review-only: after a clean scoped review it exits successfully without publication or CI, so unrelated worktree changes cannot be shipped by the whole-worktree publication path.

Run `code-converge init-document-review-prompt` to write the built-in document prompt to `.code-converge/default.md`; an existing file is preserved unless `--force` is supplied. In document mode, `--document-fix-prompt-file <path>` selects a Markdown fix instruction. It requires `--document-review` and conflicts with `--fix-prompt-file`; without it, the built-in document-fix instruction is used.

The review adapter supplies a strict JSON Schema and, after a zero Codex exit, reads only the file named by `--output-last-message`. The response must contain exactly `findings`, `overall_correctness`, `overall_explanation`, and `overall_confidence_score`; every finding must contain `title`, `body`, `confidence_score`, numeric `priority`, and `code_location` in the documented nested shape. An empty `findings` array is the only clean result. Plain text, terminal stdout/stderr, duplicate or unknown fields, invalid priorities, missing/empty/malformed files, and non-zero command exits cannot be classified as clean and produce operational exit `2`. Codex compatibility is capability-based: the configured CLI must support `exec`, `--output-schema`, and `--output-last-message`; unsupported invocations fail closed without falling back to terminal parsing.

For metrics, schema priorities are normalized as follows: `0` (`P0`) → `critical`, `1` (`P1`) → `high`, `2` (`P2`) → `medium`, and `3` (`P3`) → `low`. Any other priority makes the response invalid. The public `unknown` counter remains present for event-schema compatibility and is zero for accepted structured responses. `findings_total` must equal the sum of all five counters.
Expand Down Expand Up @@ -216,10 +222,10 @@ The required event catalog is:
| --- | --- |
| `run_started` | No fields beyond `ts` and `event`. |
| `stage_started` | `stage`, `model`, `reasoning_effort`; also `review_phase` and `cycle` for `review` and `fix-findings`, and `review_phase` for `fix-ci`. |
| `review_completed` | `stage=review`, `model`, `reasoning_effort`, `review_phase`, `cycle`, `status=clean\|findings\|failed`, and `duration_ms`. A classified result (`clean` or `findings`) also requires all findings counters plus `review_scope=branch_and_worktree`, `review_base` (resolved commit SHA), `review_merge_base` and `review_base_source=explicit\|open_pr\|branch_merge_base\|remote_default`; on command or classification failure these fields and counters are omitted. This is the review stage's sole completion record. |
| `review_completed` | `stage=review`, `model`, `reasoning_effort`, `review_phase`, `cycle`, `status=clean\|findings\|scope_empty\|failed`, and `duration_ms`. A classified result (`clean`, `findings`, or `scope_empty`) also requires `review_scope=branch_and_worktree`, `review_base` (resolved commit SHA), `review_merge_base` and `review_base_source=explicit\|open_pr\|branch_merge_base\|remote_default`; `clean` and `findings` include all findings counters. `scope_empty` is emitted when document review has no eligible Markdown changes and terminates successfully without publication. On command or classification failure these fields and counters are omitted. This is the review stage's sole completion record. |
| `stage_completed` | `stage=fix-findings\|publish\|ci\|fix-ci`, `status`, and `duration_ms`; Codex-backed stages also include model and reasoning effort. `ci` status is `success`, `skipped`, `failed`, or `timeout`; a CI timeout additionally has `timeout_ms`, the configured deadline. |
| `step_completed` | `stage=publish`, `step=commit\|push\|change_request`, and `status=success\|skipped\|failed\|unknown`; CI emits its own `stage=ci` step with `success\|skipped\|failed\|timeout`. |
| `run_completed` | `status=success\|findings_remaining\|operational_failure\|ci_timeout\|ci_failure\|cancelled`, `exit_code`, and `total_duration_ms`. `cancelled` always has `exit_code=130`; `ci_timeout` has `exit_code=2`. For `findings_remaining`, also `checkpoint_status=committed_local\|no_changes\|not_attempted`; `committed_local` additionally requires percent-encoded `checkpoint_branch` and `checkpoint_commit`, while `not_attempted` requires `checkpoint_reason=fix_budget_exhausted\|pre_existing_changes`. |
| `run_completed` | `status=success\|scope_empty\|findings_remaining\|operational_failure\|ci_timeout\|ci_failure\|cancelled`, `exit_code`, and `total_duration_ms`. `scope_empty` has `exit_code=0` and means document review found no eligible Markdown changes; publication and CI are not reached. `cancelled` always has `exit_code=130`; `ci_timeout` has `exit_code=2`. For `findings_remaining`, also `checkpoint_status=committed_local\|no_changes\|not_attempted`; `committed_local` additionally requires percent-encoded `checkpoint_branch` and `checkpoint_commit`, while `not_attempted` requires `checkpoint_reason=fix_budget_exhausted\|pre_existing_changes`. |

For example:

Expand Down
56 changes: 56 additions & 0 deletions docs/document-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Configurable document review prompts

`code-converge` reviews code by default. Since v1.1.0 the review stage also supports
explicit, deterministic document review and custom review prompts.

## What is it for

Teams that keep specifications, plans, and knowledge bases (including the Memory Bank)
in Markdown next to the code can now run the same automated review-and-fix loop on
those documents: consistency checks, contradiction detection, unresolved material
questions — reviewed by Codex with the same strict findings schema as code review.

## Review prompt selection

At most one selector may be given; conflicting selections exit `2` with no fallback:

| Selector | Source |
| --- | --- |
| *(none)* | Built-in code-review prompt (unchanged default) |
| `--review-prompt-file <path>` | Any readable Markdown file (absolute or relative to the current directory) |
| `--review-prompt <name>` | Only `.code-converge/<name>.md`; names may contain letters, digits, `_`, `-` |
| `--document-review` | `.code-converge/default.md` if it exists, otherwise the built-in document prompt |

Named prompts live in `.code-converge/`, so they are versioned and reviewed together
with the project. Missing, unreadable, non-Markdown, or invalid selections fail
predictably with exit code `2`.

## Document review mode

`--document-review` reviews only changed `.md` files in the merge-base-to-worktree
snapshot, excluding `memory-bank/prompts/**`. If no eligible documents changed, the
run completes cleanly without invoking Codex. Document mode is review-only: after a
clean scoped review it exits successfully without publishing or waiting for CI.

Bootstrap a project template with:

```sh
code-converge init-document-review-prompt # writes .code-converge/default.md
code-converge init-document-review-prompt --force # overwrite an existing file
```

Findings in document mode are fixed with the built-in document-fix instruction, or
with `--document-fix-prompt-file <path>` (requires `--document-review`, conflicts
with `--fix-prompt-file`).

## Examples

```sh
code-converge --document-review
code-converge --review-prompt security-audit
code-converge --review-prompt-file ./prompts/api-review.md
code-converge --document-review --document-fix-prompt-file ./prompts/doc-fix.md
```

See the [root README](../README.md#document-review-prompts) for the full CLI and
configuration contract.
89 changes: 87 additions & 2 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -42,6 +43,14 @@ var globalFlagSpecs = []globalFlagSpec{
{"fix-model", "Stage overrides", "Fix-findings model.", func(f *flag.FlagSet, o *config.Overrides) { bind(f, "fix-model", &o.FixModel) }},
{"fix-reasoning-effort", "Stage overrides", "Fix-findings reasoning effort.", func(f *flag.FlagSet, o *config.Overrides) { bind(f, "fix-reasoning-effort", &o.FixEffort) }},
{"fix-prompt-file", "Stage overrides", "Fix-findings prompt file.", func(f *flag.FlagSet, o *config.Overrides) { bind(f, "fix-prompt-file", &o.FixPromptPath) }},
{"review-prompt-file", "Stage overrides", "Explicit Markdown review prompt file.", func(f *flag.FlagSet, o *config.Overrides) { bind(f, "review-prompt-file", &o.ReviewPromptPath) }},
{"review-prompt", "Stage overrides", "Project-local review prompt name.", func(f *flag.FlagSet, o *config.Overrides) { bind(f, "review-prompt", &o.ReviewPromptName) }},
{"document-review", "Stage overrides", "Review changed Markdown documentation.", func(f *flag.FlagSet, o *config.Overrides) {
f.BoolVar(&o.DocumentReview, "document-review", false, "review changed Markdown documentation")
}},
{"document-fix-prompt-file", "Stage overrides", "Markdown fix prompt for document review.", func(f *flag.FlagSet, o *config.Overrides) {
bind(f, "document-fix-prompt-file", &o.DocumentFixPromptPath)
}},
{"ci-fix-model", "Stage overrides", "CI-fix model.", func(f *flag.FlagSet, o *config.Overrides) { bind(f, "ci-fix-model", &o.CIFixModel) }},
{"ci-fix-reasoning-effort", "Stage overrides", "CI-fix reasoning effort.", func(f *flag.FlagSet, o *config.Overrides) { bind(f, "ci-fix-reasoning-effort", &o.CIFixEffort) }},
{"ci-fix-prompt-file", "Stage overrides", "CI-fix prompt file.", func(f *flag.FlagSet, o *config.Overrides) { bind(f, "ci-fix-prompt-file", &o.CIFixPromptPath) }},
Expand Down Expand Up @@ -106,6 +115,68 @@ func (a App) Run(ctx context.Context, args []string) int {
}
return updater.Run(ctx, assumeYes)
}
if len(args) > 0 && args[0] == "init-document-review-prompt" {
force, err := initDocumentReviewArgs(args[1:])
if err != nil {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %v\n", err)
return workflow.ExitOperational
}
cwd := a.Cwd
if cwd == "" {
cwd, err = os.Getwd()
if err != nil {
fmt.Fprintf(stderr, "code-converge: current directory: %v\n", err)
return workflow.ExitOperational
}
}
root, err := config.FindGitRoot(cwd)
if err != nil {
fmt.Fprintf(stderr, "code-converge: %v\n", err)
return workflow.ExitOperational
}
path := filepath.Join(root, ".code-converge", "default.md")
if info, err := os.Lstat(path); err == nil {
if !info.Mode().IsRegular() {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %s is not a regular file\n", path)
return workflow.ExitOperational
}
if !force {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %s already exists; use --force to overwrite\n", path)
return workflow.ExitOperational
}
} else if !os.IsNotExist(err) {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %v\n", err)
return workflow.ExitOperational
}
parent := filepath.Dir(path)
if info, err := os.Lstat(parent); err == nil {
if !info.IsDir() {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %s is not a real directory\n", parent)
return workflow.ExitOperational
}
} else if os.IsNotExist(err) {
if err := os.MkdirAll(parent, 0o700); err != nil {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %v\n", err)
return workflow.ExitOperational
}
} else {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %v\n", err)
return workflow.ExitOperational
}
if info, err := os.Lstat(parent); err != nil {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %v\n", err)
return workflow.ExitOperational
} else if !info.IsDir() {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %s is not a real directory\n", parent)
return workflow.ExitOperational
}
if err := writeDocumentReviewPrompt(path, []byte(config.DocumentReviewPrompt+"\n"), force); err != nil {
fmt.Fprintf(stderr, "code-converge init-document-review-prompt: %v\n", err)
return workflow.ExitOperational
}
fmt.Fprintln(stdout, path)
return workflow.ExitSuccess
}
cwd := a.Cwd
if cwd == "" {
var err error
Expand Down Expand Up @@ -220,14 +291,14 @@ func (a App) Run(ctx context.Context, args []string) int {
}
}
}
reviewScope := &repository.ReviewScope{Runner: processRunner, Base: cfg.ReviewBase, Root: cfg.Root}
reviewScope := &repository.ReviewScope{Runner: processRunner, Base: cfg.ReviewBase, Root: cfg.Root, DocumentReview: cfg.DocumentReview}
defer reviewScope.Close()
var agentOutput func(string, []byte)
if view != nil {
agentOutput = logger.AgentOutput
}
agent := codex.Adapter{Runner: processRunner, Config: cfg, ReviewScope: reviewScope, Output: agentOutput}
w := workflow.Workflow{Config: cfg, Agent: agent, Repository: repository.Status{Runner: processRunner}, Log: &logger, Err: stderr, Now: a.Now}
w := workflow.Workflow{Config: cfg, Agent: &agent, Repository: repository.Status{Runner: processRunner}, Log: &logger, Err: stderr, Now: a.Now}
return w.Run(runCtx)
}

Expand All @@ -237,6 +308,7 @@ func rootUsage(out io.Writer) {
fmt.Fprintln(out, "Commands:")
fmt.Fprintln(out, " config Show effective configuration and its sources.")
fmt.Fprintln(out, " update [--yes|-y] Check for and install a newer release.")
fmt.Fprintln(out, " init-document-review-prompt [--force] Write the editable document-review prompt.")
fmt.Fprintln(out, "")
fmt.Fprintln(out, "Global options:")
group := ""
Expand Down Expand Up @@ -270,11 +342,24 @@ func helpCommand(out io.Writer, args []string) bool {
fmt.Fprintln(out, "")
fmt.Fprintln(out, "Check for and install a newer release. --yes and -y skip confirmation.")
return true
case "init-document-review-prompt":
fmt.Fprintln(out, "usage: code-converge init-document-review-prompt [--force]")
return true
default:
return false
}
}

func initDocumentReviewArgs(args []string) (bool, error) {
if len(args) == 0 {
return false, nil
}
if len(args) == 1 && args[0] == "--force" {
return true, nil
}
return false, fmt.Errorf("usage: code-converge init-document-review-prompt [--force]")
}

func updateArgs(args []string) (bool, error) {
if len(args) == 0 {
return false, nil
Expand Down
68 changes: 68 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,74 @@ func TestVersionCommand(t *testing.T) {
}
}

func TestInitDocumentReviewPromptForceRepairsPermissions(t *testing.T) {
root, home := testRepo(t)
promptPath := filepath.Join(root, ".code-converge", "default.md")
if err := os.MkdirAll(filepath.Dir(promptPath), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(promptPath, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := (App{Stdout: &stdout, Stderr: &stderr, Cwd: root, Home: home}).Run(context.Background(), []string{"init-document-review-prompt", "--force"})
if code != workflow.ExitSuccess || stderr.Len() != 0 {
t.Fatalf("code=%d stderr=%q", code, stderr.String())
}
info, err := os.Stat(promptPath)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("prompt mode = %o, want 600", got)
}
if got, err := os.ReadFile(promptPath); err != nil || string(got) != config.DocumentReviewPrompt+"\n" {
t.Fatalf("prompt = %q, error=%v", got, err)
}
}

func TestInitDocumentReviewPromptForceRejectsSymlink(t *testing.T) {
root, home := testRepo(t)
promptDir := filepath.Join(root, ".code-converge")
if err := os.MkdirAll(promptDir, 0o700); err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "outside.md")
if err := os.WriteFile(outside, []byte("must remain\n"), 0o600); err != nil {
t.Fatal(err)
}
promptPath := filepath.Join(promptDir, "default.md")
if err := os.Symlink(outside, promptPath); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}

var stdout, stderr bytes.Buffer
code := (App{Stdout: &stdout, Stderr: &stderr, Cwd: root, Home: home}).Run(context.Background(), []string{"init-document-review-prompt", "--force"})
if code != workflow.ExitOperational || !strings.Contains(stderr.String(), "not a regular file") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if got, err := os.ReadFile(outside); err != nil || string(got) != "must remain\n" {
t.Fatalf("outside prompt = %q, error=%v", got, err)
}
}

func TestInitDocumentReviewPromptRejectsSymlinkedDirectory(t *testing.T) {
root, home := testRepo(t)
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, ".code-converge")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}

var stdout, stderr bytes.Buffer
code := (App{Stdout: &stdout, Stderr: &stderr, Cwd: root, Home: home}).Run(context.Background(), []string{"init-document-review-prompt"})
if code != workflow.ExitOperational || !strings.Contains(stderr.String(), "not a real directory") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if _, err := os.Stat(filepath.Join(outside, "default.md")); !os.IsNotExist(err) {
t.Fatalf("outside prompt exists or could not be checked: %v", err)
}
}

func TestRootHelpAliasesExitBeforeOperationalSetup(t *testing.T) {
for _, args := range [][]string{{"-h"}, {"--help"}} {
t.Run(args[0], func(t *testing.T) {
Expand Down
47 changes: 47 additions & 0 deletions internal/app/prompt_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package app

import (
"fmt"
"os"
"path/filepath"
)

// writeDocumentReviewPrompt never writes through an existing directory entry.
// In force mode it writes a sibling temporary file and renames it into place;
// rename replaces a symlink itself rather than following it.
func writeDocumentReviewPrompt(path string, data []byte, force bool) error {
if !force {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
return err
}
return file.Close()
}

temporary, err := os.CreateTemp(filepath.Dir(path), ".default.md.tmp-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)

if err := temporary.Chmod(0o600); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(data); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, path); err != nil {
return fmt.Errorf("replace prompt: %w", err)
}
return nil
}
Loading