Skip to content

Commit f3c330c

Browse files
committed
Add doctor specs and shared harness ping
1 parent 77a7cc0 commit f3c330c

5 files changed

Lines changed: 204 additions & 72 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
dun:
3+
id: F-022
4+
depends_on:
5+
- helix.prd
6+
---
7+
# Feature Spec: F-022 Doctor Command
8+
9+
## Summary
10+
11+
Provide a `dun doctor` command that checks harness/tool availability, performs
12+
basic harness liveness pings, and reports actionable fixes when dependencies are
13+
missing. Persist a cache of available harnesses for quorum defaults.
14+
15+
## Requirements
16+
17+
- Provide a `dun doctor` CLI command with no flags.
18+
- Detect installed harness CLIs (codex, claude, gemini, opencode).
19+
- Perform a short liveness ping per available harness.
20+
- Report model info when the harness provides it.
21+
- Detect project helpers based on repo signals:
22+
- Go: `go`, `go tool cover`, `staticcheck`, `govulncheck`, `gosec`.
23+
- Git: `git`, and configured hook tools (lefthook or pre-commit).
24+
- Beads: `bd` when `.beads` exists.
25+
- Provide actionable next steps when tools are missing.
26+
- Write `~/.dun/harnesses.json` with liveness results for quorum defaults.
27+
- Do not fail when tools are missing; only fail on runtime errors (for example,
28+
unable to write the cache file).
29+
30+
## Non-Goals
31+
32+
- Installing or configuring tools automatically.
33+
- Deep validation of tool configuration beyond presence and liveness.
34+
35+
## Acceptance Criteria
36+
37+
- `dun doctor` prints harness availability and liveness status.
38+
- `dun doctor` prints helper/tool availability with actionable hints for missing tools.
39+
- `dun doctor` writes/updates `~/.dun/harnesses.json`.
40+
- Missing tools produce warnings but do not return a non-zero exit code.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
dun:
3+
id: US-015
4+
depends_on:
5+
- F-022
6+
---
7+
# US-015: Diagnose Tooling Readiness
8+
9+
As a user, I want to know if all supporting tools I need to use Dun are
10+
available, with help fixing them when they are missing.
11+
12+
## Acceptance Criteria
13+
14+
- `dun doctor` runs without flags and produces a human-readable report.
15+
- The report lists harness availability and liveness results.
16+
- The report lists project helpers (Go, Git hooks, Beads) when applicable.
17+
- Missing tools include a clear next step (install hint or command).
18+
- `~/.dun/harnesses.json` is updated after each run.
19+
- Missing tools do not cause `dun doctor` to fail.

internal/dun/doctor.go

Lines changed: 4 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package dun
22

33
import (
44
"context"
5-
"encoding/json"
65
"fmt"
76
"os"
87
"os/exec"
@@ -105,82 +104,15 @@ func defaultHarnessCommand(name string) string {
105104
}
106105
}
107106

108-
const doctorHarnessTimeout = 30 * time.Second
109-
110-
const doctorPrompt = `Reply with JSON on one line: {"ok":true,"model":"<model name>"}. If unknown, use "unknown".`
111-
112-
type doctorPing struct {
113-
OK bool `json:"ok"`
114-
Model string `json:"model"`
115-
}
116-
117107
func checkHarnessLiveness(name string) (bool, string, string) {
118-
ctx, cancel := context.WithTimeout(context.Background(), doctorHarnessTimeout)
119-
defer cancel()
120-
121-
harness, err := DefaultRegistry.Get(name, HarnessConfig{
108+
result, err := PingHarness(context.Background(), name, HarnessConfig{
122109
Name: name,
123110
AutomationMode: AutomationAuto,
124-
Timeout: doctorHarnessTimeout,
125111
})
126-
if err != nil {
127-
return false, "", err.Error()
128-
}
129-
130-
response, err := harness.Execute(ctx, doctorPrompt)
131-
if err != nil {
132-
return false, "", err.Error()
133-
}
134-
135-
model, detail := parseDoctorResponse(response)
136-
return true, model, detail
137-
}
138-
139-
func parseDoctorResponse(response string) (string, string) {
140-
candidate := extractJSON(response)
141-
if candidate != "" {
142-
var ping doctorPing
143-
if err := json.Unmarshal([]byte(candidate), &ping); err == nil {
144-
model := strings.TrimSpace(ping.Model)
145-
if model == "" {
146-
model = "unknown"
147-
}
148-
return model, ""
149-
}
150-
}
151-
model := extractModelHint(response)
152-
if model != "" {
153-
return model, "non-json response"
154-
}
155-
return "", "unexpected response"
156-
}
157-
158-
func extractJSON(response string) string {
159-
start := strings.Index(response, "{")
160-
end := strings.LastIndex(response, "}")
161-
if start == -1 || end == -1 || end <= start {
162-
return ""
163-
}
164-
return response[start : end+1]
165-
}
166-
167-
func extractModelHint(response string) string {
168-
lower := strings.ToLower(response)
169-
for _, key := range []string{"model:", "model="} {
170-
if idx := strings.Index(lower, key); idx != -1 {
171-
fragment := response[idx+len(key):]
172-
fragment = strings.TrimSpace(fragment)
173-
if fragment == "" {
174-
return ""
175-
}
176-
fields := strings.Fields(fragment)
177-
if len(fields) == 0 {
178-
return ""
179-
}
180-
return strings.Trim(fields[0], "\"',.")
181-
}
112+
if err != nil && result.Detail == "" {
113+
result.Detail = err.Error()
182114
}
183-
return ""
115+
return result.Live, result.Model, result.Detail
184116
}
185117

186118
func checkProjectHelpers(root string) []HelperStatus {

internal/dun/harness_ping.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package dun
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
8+
"time"
9+
)
10+
11+
const HarnessPingPrompt = `Reply with JSON on one line: {"ok":true,"model":"<model name>"}. If unknown, use "unknown".`
12+
13+
const defaultHarnessPingTimeout = 30 * time.Second
14+
15+
// HarnessPingResult captures the outcome of a harness liveness ping.
16+
type HarnessPingResult struct {
17+
Live bool
18+
Model string
19+
Detail string
20+
Response string
21+
}
22+
23+
// PingHarness sends a short liveness prompt to the harness and parses the response.
24+
func PingHarness(ctx context.Context, name string, config HarnessConfig) (HarnessPingResult, error) {
25+
if config.AutomationMode == "" {
26+
config.AutomationMode = AutomationAuto
27+
}
28+
if config.Timeout == 0 {
29+
config.Timeout = defaultHarnessPingTimeout
30+
}
31+
if config.Timeout > 0 {
32+
var cancel context.CancelFunc
33+
ctx, cancel = context.WithTimeout(ctx, config.Timeout)
34+
defer cancel()
35+
}
36+
37+
harness, err := DefaultRegistry.Get(name, config)
38+
if err != nil {
39+
return HarnessPingResult{Live: false, Detail: err.Error()}, err
40+
}
41+
if !harness.SupportsAutomation(config.AutomationMode) {
42+
err := fmt.Errorf("harness %s does not support automation mode %s", name, config.AutomationMode)
43+
return HarnessPingResult{Live: false, Detail: err.Error()}, err
44+
}
45+
46+
response, err := harness.Execute(ctx, HarnessPingPrompt)
47+
if err != nil {
48+
return HarnessPingResult{Live: false, Detail: err.Error()}, err
49+
}
50+
51+
model, detail := parseHarnessPingResponse(response)
52+
return HarnessPingResult{Live: true, Model: model, Detail: detail, Response: response}, nil
53+
}
54+
55+
func parseHarnessPingResponse(response string) (string, string) {
56+
candidate := extractJSON(response)
57+
if candidate != "" {
58+
var ping struct {
59+
OK bool `json:"ok"`
60+
Model string `json:"model"`
61+
}
62+
if err := json.Unmarshal([]byte(candidate), &ping); err == nil {
63+
model := strings.TrimSpace(ping.Model)
64+
if model == "" {
65+
model = "unknown"
66+
}
67+
return model, ""
68+
}
69+
}
70+
model := extractModelHint(response)
71+
if model != "" {
72+
return model, "non-json response"
73+
}
74+
return "", "unexpected response"
75+
}
76+
77+
func extractJSON(response string) string {
78+
start := strings.Index(response, "{")
79+
end := strings.LastIndex(response, "}")
80+
if start == -1 || end == -1 || end <= start {
81+
return ""
82+
}
83+
return response[start : end+1]
84+
}
85+
86+
func extractModelHint(response string) string {
87+
lower := strings.ToLower(response)
88+
for _, key := range []string{"model:", "model="} {
89+
if idx := strings.Index(lower, key); idx != -1 {
90+
fragment := response[idx+len(key):]
91+
fragment = strings.TrimSpace(fragment)
92+
if fragment == "" {
93+
return ""
94+
}
95+
fields := strings.Fields(fragment)
96+
if len(fields) == 0 {
97+
return ""
98+
}
99+
return strings.Trim(fields[0], "\"',.")
100+
}
101+
}
102+
return ""
103+
}

internal/dun/harness_ping_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package dun
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestPingHarnessParsesJSON(t *testing.T) {
9+
result, err := PingHarness(context.Background(), "mock", HarnessConfig{MockResponse: `{"ok":true,"model":"test-model"}`})
10+
if err != nil {
11+
t.Fatalf("ping harness: %v", err)
12+
}
13+
if !result.Live {
14+
t.Fatalf("expected live ping")
15+
}
16+
if result.Model != "test-model" {
17+
t.Fatalf("expected model test-model, got %q", result.Model)
18+
}
19+
if result.Detail != "" {
20+
t.Fatalf("expected empty detail, got %q", result.Detail)
21+
}
22+
}
23+
24+
func TestPingHarnessNonJSONFallback(t *testing.T) {
25+
result, err := PingHarness(context.Background(), "mock", HarnessConfig{MockResponse: "model: alpha"})
26+
if err != nil {
27+
t.Fatalf("ping harness: %v", err)
28+
}
29+
if !result.Live {
30+
t.Fatalf("expected live ping")
31+
}
32+
if result.Model != "alpha" {
33+
t.Fatalf("expected model alpha, got %q", result.Model)
34+
}
35+
if result.Detail == "" {
36+
t.Fatalf("expected detail for non-json response")
37+
}
38+
}

0 commit comments

Comments
 (0)