Skip to content

Commit 54aa415

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents 09aecb5 + 6bc1e14 commit 54aa415

11 files changed

Lines changed: 683 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ dun check --changed
6161
dun list
6262
dun explain <check-id>
6363
dun respond --id <check-id> --response -
64+
dun doctor
6465
```
6566

6667
## Configuration

cmd/dun/doctor.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/easel/dun/internal/dun"
8+
)
9+
10+
func runDoctor(args []string, stdout io.Writer, stderr io.Writer) int {
11+
if len(args) > 0 {
12+
fmt.Fprintln(stderr, "usage: dun doctor")
13+
return dun.ExitUsageError
14+
}
15+
root := resolveRoot(".")
16+
report, err := dun.RunDoctor(root)
17+
fmt.Fprint(stdout, dun.FormatDoctorReport(report))
18+
if err != nil {
19+
fmt.Fprintf(stderr, "dun doctor failed: %v\n", err)
20+
return dun.ExitRuntimeError
21+
}
22+
return dun.ExitSuccess
23+
}

cmd/dun/harnesses.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package main
2+
3+
import (
4+
"sort"
5+
"strings"
6+
7+
"github.com/easel/dun/internal/dun"
8+
)
9+
10+
var defaultQuorumHarnesses = []string{"codex", "claude", "gemini"}
11+
var harnessPreferenceOrder = []string{"codex", "claude", "gemini", "opencode"}
12+
13+
func resolveHarnessesForQuorum(explicit string) []string {
14+
if strings.TrimSpace(explicit) != "" {
15+
return parseHarnessCSV(explicit)
16+
}
17+
if cached := cachedHarnesses(); len(cached) > 0 {
18+
return cached
19+
}
20+
return append([]string{}, defaultQuorumHarnesses...)
21+
}
22+
23+
func resolveHarnessesForReview(explicit string) []string {
24+
return resolveHarnessesForQuorum(explicit)
25+
}
26+
27+
func parseHarnessCSV(value string) []string {
28+
parts := strings.Split(value, ",")
29+
out := make([]string, 0, len(parts))
30+
for _, part := range parts {
31+
trimmed := strings.TrimSpace(part)
32+
if trimmed == "" {
33+
continue
34+
}
35+
out = append(out, trimmed)
36+
}
37+
return out
38+
}
39+
40+
func cachedHarnesses() []string {
41+
cache, err := dun.LoadHarnessCache()
42+
if err != nil {
43+
return nil
44+
}
45+
available := cache.AvailableHarnesses()
46+
if len(available) == 0 {
47+
return nil
48+
}
49+
filtered := filterKnownHarnesses(available)
50+
if len(filtered) == 0 {
51+
return nil
52+
}
53+
return orderHarnesses(filtered)
54+
}
55+
56+
func filterKnownHarnesses(names []string) []string {
57+
var out []string
58+
for _, name := range names {
59+
if name == "mock" {
60+
continue
61+
}
62+
if !dun.DefaultRegistry.Has(name) {
63+
continue
64+
}
65+
out = append(out, name)
66+
}
67+
return out
68+
}
69+
70+
func orderHarnesses(names []string) []string {
71+
seen := make(map[string]bool, len(names))
72+
for _, name := range names {
73+
seen[name] = true
74+
}
75+
var ordered []string
76+
for _, name := range harnessPreferenceOrder {
77+
if seen[name] {
78+
ordered = append(ordered, name)
79+
delete(seen, name)
80+
}
81+
}
82+
if len(seen) == 0 {
83+
return ordered
84+
}
85+
var rest []string
86+
for name := range seen {
87+
rest = append(rest, name)
88+
}
89+
sort.Strings(rest)
90+
return append(ordered, rest...)
91+
}

cmd/dun/loop_cache_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
"time"
7+
8+
"github.com/easel/dun/internal/dun"
9+
)
10+
11+
func TestRunLoopQuorumUsesCachedHarnesses(t *testing.T) {
12+
home := t.TempDir()
13+
t.Setenv("HOME", home)
14+
15+
cache := dun.HarnessCache{
16+
LastCheck: time.Now(),
17+
Harnesses: []dun.HarnessStatus{{Name: "codex", Command: "codex", Available: true}},
18+
}
19+
if err := cache.Save(); err != nil {
20+
t.Fatalf("save cache: %v", err)
21+
}
22+
23+
root := setupEmptyRepo(t)
24+
origCheck := checkRepo
25+
checkRepo = func(_ string, _ dun.Options) (dun.Result, error) {
26+
return dun.Result{Checks: []dun.CheckResult{{ID: "fail-check", Status: "fail", Signal: "fail"}}}, nil
27+
}
28+
t.Cleanup(func() { checkRepo = origCheck })
29+
30+
origHarness := callHarnessFn
31+
var gotHarness string
32+
callHarnessFn = func(harness, _ string, _ string) (string, error) {
33+
gotHarness = harness
34+
return "EXIT_SIGNAL: true", nil
35+
}
36+
t.Cleanup(func() { callHarnessFn = origHarness })
37+
38+
var stdout bytes.Buffer
39+
var stderr bytes.Buffer
40+
code := runInDirWithWriters(t, root, []string{"loop", "--max-iterations", "1", "--quorum", "any"}, &stdout, &stderr)
41+
if code != dun.ExitSuccess {
42+
t.Fatalf("expected success, got %d: %s", code, stderr.String())
43+
}
44+
if gotHarness != "codex" {
45+
t.Fatalf("expected cached harness codex, got %q", gotHarness)
46+
}
47+
}

cmd/dun/main.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ func run(args []string, stdout io.Writer, stderr io.Writer) int {
6060
return runRespond(args[1:], stdout, stderr)
6161
case "review":
6262
return runReview(args[1:], stdout, stderr)
63+
case "doctor":
64+
return runDoctor(args[1:], stdout, stderr)
6365
case "stamp":
6466
return runStamp(args[1:], stdout, stderr)
6567
case "install":
@@ -88,6 +90,7 @@ COMMANDS:
8890
explain Show details for a specific check
8991
respond Process agent response for a check
9092
review Run multi-agent review with synthesis
93+
doctor Diagnose harness and helper availability
9194
stamp Update doc review stamps
9295
install Install dun config and agent documentation
9396
loop Run autonomous loop with an agent harness
@@ -103,7 +106,7 @@ REVIEW MODE:
103106
Options:
104107
--config Config file path (default .dun/config.yaml; also loads user config)
105108
--principles Path to principles document (default docs/helix/01-frame/principles.md)
106-
--harnesses Comma-separated list of review harnesses (default: codex,claude,gemini)
109+
--harnesses Comma-separated list of review harnesses (default: cached or codex,claude,gemini)
107110
--synth-harness Harness to synthesize final review (default: first harness)
108111
--model Model override for selected harness(es)
109112
--models Per-harness model overrides (e.g., codex:o3,claude:sonnet)
@@ -142,7 +145,7 @@ LOOP MODE:
142145
143146
Quorum Options (multi-agent consensus):
144147
--quorum Strategy: any, majority, unanimous, or number (e.g., 2)
145-
--harnesses Comma-separated list of harnesses (e.g., codex,claude,gemini,opencode)
148+
--harnesses Comma-separated list of harnesses (default: cached or codex,claude,gemini)
146149
--cost-optimized Run harnesses sequentially to minimize cost
147150
--escalate Pause for human review on conflict
148151
--prefer Preferred harness on conflict (e.g., codex)
@@ -181,6 +184,11 @@ STAMP:
181184
Options:
182185
--all Stamp all docs with dun frontmatter
183186
187+
DOCTOR:
188+
dun doctor
189+
190+
Runs comprehensive environment checks and updates the harness cache.
191+
184192
EXIT CODES:
185193
0 Success / all checks pass
186194
1 Check failed
@@ -570,7 +578,8 @@ func runLoop(args []string, stdout io.Writer, stderr io.Writer) int {
570578
// Parse quorum configuration if specified
571579
var quorumCfg dun.QuorumConfig
572580
if *quorumFlag != "" || *harnessesFlag != "" {
573-
quorumCfg, err = dun.ParseQuorumFlags(*quorumFlag, *harnessesFlag, *costMode, *escalate, *prefer)
581+
resolvedHarnesses := resolveHarnessesForQuorum(*harnessesFlag)
582+
quorumCfg, err = dun.ParseQuorumFlags(*quorumFlag, strings.Join(resolvedHarnesses, ","), *costMode, *escalate, *prefer)
574583
if err != nil {
575584
fmt.Fprintf(stderr, "dun loop failed: quorum config error: %v\n", err)
576585
return dun.ExitUsageError

cmd/dun/review.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func runReview(args []string, stdout io.Writer, stderr io.Writer) int {
4141
fs.SetOutput(stderr)
4242
configPath := fs.String("config", explicitConfig, "path to config file (default .dun/config.yaml if present; also loads user config)")
4343
principlesPath := fs.String("principles", "docs/helix/01-frame/principles.md", "path to principles document")
44-
harnessesFlag := fs.String("harnesses", "codex,claude,gemini", "comma-separated list of review harnesses")
44+
harnessesFlag := fs.String("harnesses", "", "comma-separated list of review harnesses")
4545
synthHarness := fs.String("synth-harness", "", "harness used to synthesize final review (default: first harness)")
4646
model := fs.String("model", opts.AgentModel, "model override for selected harness(es)")
4747
models := fs.String("models", "", "per-harness model overrides (e.g., codex:o3,claude:sonnet)")
@@ -77,7 +77,8 @@ func runReview(args []string, stdout io.Writer, stderr io.Writer) int {
7777
}
7878
}
7979

80-
reviewCfg, err := dun.ParseQuorumFlags("", *harnessesFlag, false, false, "")
80+
resolvedHarnesses := resolveHarnessesForReview(*harnessesFlag)
81+
reviewCfg, err := dun.ParseQuorumFlags("", strings.Join(resolvedHarnesses, ","), false, false, "")
8182
if err != nil {
8283
fmt.Fprintf(stderr, "dun review failed: invalid harness list: %v\n", err)
8384
return dun.ExitUsageError

docs/design/contracts/API-001-dun-cli.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ proceeding (quorum is applied to the iteration prompt, not per-check prompts).
217217
- `--dry-run` : Print prompt without calling harness
218218
- `--verbose` : Print prompts sent to harnesses and responses received
219219
- `--quorum` : Quorum strategy (`any`, `majority`, `unanimous`, or number)
220-
- `--harnesses` : Comma-separated harness list for quorum (supports `name@persona`)
220+
- `--harnesses` : Comma-separated harness list for quorum (supports `name@persona`, default cached or `codex,claude,gemini`)
221221
- `--cost-optimized` : Run harnesses sequentially to minimize cost
222222
- `--escalate` : Pause for human review on conflict
223223
- `--prefer` : Preferred harness on conflict
@@ -253,14 +253,33 @@ $ dun loop --verbose
253253

254254
---
255255

256+
#### Command: doctor
257+
**Purpose**: Diagnose harness/tool availability and update the harness cache.
258+
**Usage**: `$ dun doctor`
259+
260+
**Options**: None
261+
262+
**Input**:
263+
- Format: File system + environment
264+
265+
**Output**:
266+
- Format: Human-readable status report
267+
- Side effects: writes harness cache to `~/.dun/harnesses.json`
268+
269+
**Exit Codes**:
270+
- `0`: Success
271+
- `3`: Runtime error (e.g., unable to write cache)
272+
273+
---
274+
256275
#### Command: quorum
257276
**Purpose**: Run a one-shot multi-agent quorum task and return a selected response.
258277
**Usage**: `$ dun quorum [options]`
259278

260279
**Options**:
261280
- `--task` : Task prompt (string)
262281
- `--quorum` : Quorum strategy (`any`, `majority`, `unanimous`, or number)
263-
- `--harnesses` : Comma-separated harness list (supports `name@persona`)
282+
- `--harnesses` : Comma-separated harness list (supports `name@persona`, default cached or `codex,claude,gemini`)
264283
- `--cost-optimized` : Run harnesses sequentially to minimize cost
265284
- `--escalate` : Pause for human review on conflict
266285
- `--prefer` : Preferred harness on conflict

0 commit comments

Comments
 (0)