feat: add local coding agent harness

This commit is contained in:
玉澜
2026-08-01 13:26:34 +08:00
parent 412e77f215
commit 45dc8a439c
12 changed files with 1117 additions and 348 deletions
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
exec go run ./scripts/policy/coding-agent-harness -root "$ROOT" "$@"
+403
View File
@@ -0,0 +1,403 @@
package main
import (
"bufio"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
var guideLineLimits = map[string]int{
"AGENTS.md": 80,
"internal/cli/AGENTS.md": 80,
"internal/helpers/AGENTS.md": 100,
}
var markdownLinkPattern = regexp.MustCompile(`\[[^]]+\]\(([^)]+)\)`)
type fileContract struct {
path string
required []string
}
type taskField struct {
label string
allowNone bool
allowedValue map[string]bool
}
var taskFields = []taskField{
{label: "Task kind", allowedValue: map[string]bool{"bug": true, "feature": true, "refactor": true, "docs": true, "policy": true, "release": true}},
{label: "Goal (one primary outcome)"},
{label: "Current behavior and evidence"},
{label: "Acceptance criteria"},
{label: "In scope (packages/files/surfaces)"},
{label: "Out of scope", allowNone: true},
{label: "Compatibility constraints", allowNone: true},
{label: "Interface impact (commands/flags/output/errors/exit codes/Schema)", allowNone: true},
{label: "Safety or data-mutation constraints", allowNone: true},
{label: "Expected validation"},
{label: "Known environment limitations", allowNone: true},
}
var guideContracts = []fileContract{
{
path: "AGENTS.md",
required: []string{
"docs/coding-agent-guide.md",
"docs/coding-agent-task-template.md",
"docs/schema-contributor-guide.md",
"internal/helpers/AGENTS.md",
"docs/automation.md",
"docs/agent-code.md",
"Do not depend on generated Wiki or CodeWiki content.",
},
},
{
path: "CONTRIBUTING.md",
required: []string{
"docs/coding-agent-guide.md",
"docs/schema-contributor-guide.md",
},
},
{
path: "docs/coding-agent-guide.md",
required: []string{
"## 1. Normalize the task input",
"coding-agent-task-template.md",
"make coding-agent-task TASK=",
"primary outcome per task",
"github.com/larksuite/cli/blob/",
"github.com/WecomTeam/wecom-cli/blob/",
"## 2. Establish the baseline",
"## 3. Implement from authoritative inputs",
"## 4. Select validation by change surface",
"Documentation only",
"Go implementation",
"CLI paths or flags",
"Schema registry, hints, or generators",
"CI or test sharding",
"Packaging or installers",
"Authentication, transport, or OS-specific code",
"## 5. Pre-handoff self-check",
"Outcome: what is now true",
"Validation: exact commands and results",
},
},
{
path: "docs/coding-agent-task-template.md",
required: []string{
"Goal (one primary outcome):",
"Current behavior and evidence:",
"Acceptance criteria:",
"In scope (packages/files/surfaces):",
"Out of scope:",
"Compatibility constraints:",
"Interface impact (commands/flags/output/errors/exit codes/Schema):",
"Safety or data-mutation constraints:",
"Expected validation:",
"Known environment limitations:",
"Expected stdout/stderr and exit behavior:",
"Mutation preview/confirmation behavior:",
},
},
{
path: "docs/schema-contributor-guide.md",
required: []string{
"internal/cli/schema_command_registry.json",
"internal/cli/schema_command_registry.schema.json",
"internal/cli/schema_hints/metadata/<product>.json",
"internal/cli/schema_hints/selection/<product>.json",
"internal/cli/schema_parameter_bindings.json",
"internal/cli/schema_mcp_metadata.json",
"internal/cli/schema_command_exclusions.json",
"internal/cli/schema_catalog.json",
"make generate-schema",
"make test-schema-agent-examples",
},
},
{
path: "internal/helpers/AGENTS.md",
required: []string{
"../../CONTRIBUTING.md",
"../../docs/coding-agent-guide.md",
"../../docs/schema-contributor-guide.md",
"Keep stdout machine-readable business data.",
"structured error category",
"preview/confirmation",
"Do not claim a live",
},
},
{
path: "internal/cli/AGENTS.md",
required: []string{
"../../docs/schema-contributor-guide.md",
"schema_command_registry.json",
"schema_parameter_bindings.json",
"schema_hints/metadata/<product>.json",
"schema_hints/selection/<product>.json",
"schema_command_exclusions.json",
"schema_catalog.json",
"make generate-schema",
},
},
}
var requiredPaths = []string{
"docs/automation.md",
"docs/agent-code.md",
"scripts/policy/check-runtime-confirmation-truth.sh",
"scripts/policy/check-coding-agent-harness.sh",
"scripts/policy/check-generated-drift.sh",
"scripts/policy/check-schema-catalog.sh",
"scripts/policy/check-command-surface.sh",
"scripts/release/verify-package-managers.sh",
}
var requiredMakeTargets = []string{
"build",
"coding-agent-harness",
"coding-agent-task",
"format-check",
"test",
"policy",
"interface-integrity",
"skill-command-integrity",
"test-schema-agent-examples",
"generate-schema",
"package",
}
func main() {
root := flag.String("root", ".", "repository root")
task := flag.String("task", "", "optional filled coding-agent task file to validate")
flag.Parse()
if flag.NArg() != 0 {
fmt.Fprintln(os.Stderr, "coding agent harness: unexpected positional arguments")
os.Exit(2)
}
problems := validate(*root)
if strings.TrimSpace(*task) != "" {
taskPath := *task
if !filepath.IsAbs(taskPath) {
taskPath = filepath.Join(*root, taskPath)
}
problems = append(problems, validateTask(taskPath)...)
sort.Slice(problems, func(i, j int) bool { return problems[i].Error() < problems[j].Error() })
}
if len(problems) != 0 {
fmt.Fprintln(os.Stderr, "coding agent harness: failed")
for _, problem := range problems {
fmt.Fprintf(os.Stderr, "- %v\n", problem)
}
os.Exit(1)
}
fmt.Println("coding agent harness: ok")
}
func validateTask(path string) []error {
content, err := os.ReadFile(path)
if err != nil {
return []error{fmt.Errorf("read task contract %s: %w", path, err)}
}
values := make(map[string][]string)
occurrences := make(map[string]int)
current := ""
scanner := bufio.NewScanner(strings.NewReader(string(content)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
matched := false
for _, field := range taskFields {
prefix := field.label + ":"
if strings.HasPrefix(line, prefix) {
current = field.label
occurrences[current]++
inline := strings.TrimSpace(strings.TrimPrefix(line, prefix))
if inline != "" {
values[current] = append(values[current], inline)
}
matched = true
break
}
}
if matched || current == "" || line == "" || strings.HasPrefix(line, "```") {
continue
}
values[current] = append(values[current], line)
}
if err := scanner.Err(); err != nil {
return []error{fmt.Errorf("scan task contract %s: %w", path, err)}
}
var problems []error
for _, field := range taskFields {
if occurrences[field.label] > 1 {
problems = append(problems, fmt.Errorf("task contract field %q appears %d times; each field must be unique", field.label, occurrences[field.label]))
}
value := strings.TrimSpace(strings.Join(values[field.label], "\n"))
if value == "" {
problems = append(problems, fmt.Errorf("task contract is missing a value for %q", field.label))
continue
}
normalized := strings.ToLower(value)
if len(field.allowedValue) != 0 && !field.allowedValue[normalized] {
problems = append(problems, fmt.Errorf("task contract field %q has unsupported value %q", field.label, value))
}
if isPlaceholderTaskValue(normalized) {
problems = append(problems, fmt.Errorf("task contract field %q still contains placeholder value %q", field.label, value))
}
if !field.allowNone && (normalized == "none" || normalized == "n/a" || normalized == "not applicable") {
problems = append(problems, fmt.Errorf("task contract field %q requires concrete evidence", field.label))
}
}
sort.Slice(problems, func(i, j int) bool { return problems[i].Error() < problems[j].Error() })
return problems
}
func isPlaceholderTaskValue(value string) bool {
for _, line := range strings.Split(value, "\n") {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimLeft(trimmed, "-*0123456789.) ")
switch trimmed {
case "todo", "tbd", "unknown", "fill me", "to be decided":
return true
}
if strings.HasPrefix(trimmed, "todo:") || strings.HasPrefix(trimmed, "tbd:") || strings.Contains(trimmed, "<fill") || strings.Contains(trimmed, "<todo") {
return true
}
}
return false
}
func validate(root string) []error {
absRoot, err := filepath.Abs(root)
if err != nil {
return []error{fmt.Errorf("resolve root: %w", err)}
}
var problems []error
for _, contract := range guideContracts {
content, readErr := os.ReadFile(filepath.Join(absRoot, filepath.FromSlash(contract.path)))
if readErr != nil {
problems = append(problems, fmt.Errorf("read %s: %w", contract.path, readErr))
continue
}
text := string(content)
for _, required := range contract.required {
if !strings.Contains(text, required) {
problems = append(problems, fmt.Errorf("%s is missing required contract text %q", contract.path, required))
}
}
problems = append(problems, validateLocalLinks(absRoot, contract.path, text)...)
}
for path, limit := range guideLineLimits {
if lineCount, countErr := countLines(filepath.Join(absRoot, filepath.FromSlash(path))); countErr != nil {
problems = append(problems, fmt.Errorf("count %s lines: %w", path, countErr))
} else if lineCount > limit {
problems = append(problems, fmt.Errorf("%s has %d lines; scoped guide limit is %d", path, lineCount, limit))
}
}
for _, path := range requiredPaths {
info, statErr := os.Stat(filepath.Join(absRoot, filepath.FromSlash(path)))
if statErr != nil {
problems = append(problems, fmt.Errorf("required referenced path %s: %w", path, statErr))
} else if strings.HasSuffix(path, ".sh") && info.Mode().Perm()&0o111 == 0 {
problems = append(problems, fmt.Errorf("required referenced script %s is not executable", path))
}
}
makeTargets, makeErr := loadMakeTargets(filepath.Join(absRoot, "Makefile"))
if makeErr != nil {
problems = append(problems, makeErr)
} else {
for _, target := range requiredMakeTargets {
if !makeTargets[target] {
problems = append(problems, fmt.Errorf("makefile is missing referenced target %q", target))
}
}
}
sort.Slice(problems, func(i, j int) bool { return problems[i].Error() < problems[j].Error() })
return problems
}
func validateLocalLinks(root, document, content string) []error {
var problems []error
documentDir := filepath.Join(root, filepath.Dir(filepath.FromSlash(document)))
for _, match := range markdownLinkPattern.FindAllStringSubmatch(content, -1) {
target := strings.TrimSpace(match[1])
if target == "" || strings.HasPrefix(target, "#") || strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "mailto:") {
continue
}
if cut, _, ok := strings.Cut(target, "#"); ok {
target = cut
}
if target == "" {
continue
}
if filepath.IsAbs(target) {
problems = append(problems, fmt.Errorf("%s contains absolute local link %q", document, match[1]))
continue
}
resolved := filepath.Clean(filepath.Join(documentDir, filepath.FromSlash(target)))
rel, err := filepath.Rel(root, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
problems = append(problems, fmt.Errorf("%s link %q escapes repository root", document, match[1]))
continue
}
if _, err := os.Stat(resolved); err != nil {
problems = append(problems, fmt.Errorf("%s has broken local link %q: %w", document, match[1], err))
}
}
return problems
}
func countLines(path string) (int, error) {
file, err := os.Open(path)
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
count := 0
for scanner.Scan() {
count++
}
return count, scanner.Err()
}
func loadMakeTargets(path string) (map[string]bool, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read Makefile targets: %w", err)
}
defer file.Close()
targets := make(map[string]bool)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" || line[0] == '\t' || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
name, _, ok := strings.Cut(line, ":")
if !ok || strings.ContainsAny(name, "= ") || strings.HasPrefix(name, ".") {
continue
}
targets[name] = true
}
if err := scanner.Err(); err != nil {
return nil, errors.New("scan Makefile targets: " + err.Error())
}
return targets, nil
}
@@ -0,0 +1,250 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRepositoryCodingAgentHarness(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
if problems := validate(root); len(problems) != 0 {
t.Fatalf("repository harness problems:\n%s", formatProblems(problems))
}
}
func TestCodingAgentHarnessFixturePasses(t *testing.T) {
root := newHarnessFixture(t)
if problems := validate(root); len(problems) != 0 {
t.Fatalf("valid fixture problems:\n%s", formatProblems(problems))
}
}
func TestCodingAgentTaskContractPasses(t *testing.T) {
path := filepath.Join(t.TempDir(), "task.md")
writeTaskFixture(t, path, validTaskContract)
if problems := validateTask(path); len(problems) != 0 {
t.Fatalf("valid task problems:\n%s", formatProblems(problems))
}
}
func TestCodingAgentTaskContractFailsClosed(t *testing.T) {
tests := []struct {
name string
content string
wantProblem string
}{
{
name: "missing goal",
content: strings.Replace(validTaskContract, "Goal (one primary outcome): Fix retry handling\n", "", 1),
wantProblem: `missing a value for "Goal (one primary outcome)"`,
},
{
name: "blank acceptance criteria",
content: strings.Replace(validTaskContract, "Acceptance criteria:\n- The retry succeeds once and then stops.\n", "Acceptance criteria:\n", 1),
wantProblem: `missing a value for "Acceptance criteria"`,
},
{
name: "unsupported task kind",
content: strings.Replace(validTaskContract, "Task kind: bug", "Task kind: experiment", 1),
wantProblem: `unsupported value "experiment"`,
},
{
name: "placeholder acceptance bullet",
content: strings.Replace(validTaskContract, "- The retry succeeds once and then stops.", "- TBD: decide expected retry result", 1),
wantProblem: "still contains placeholder value",
},
{
name: "duplicate goal",
content: strings.Replace(validTaskContract, "Goal (one primary outcome): Fix retry handling", "Goal (one primary outcome): Fix retry handling\nGoal (one primary outcome): Refactor transport", 1),
wantProblem: `field "Goal (one primary outcome)" appears 2 times`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "task.md")
writeTaskFixture(t, path, test.content)
problems := formatProblems(validateTask(path))
if !strings.Contains(problems, test.wantProblem) {
t.Fatalf("problems:\n%s\nwant substring %q", problems, test.wantProblem)
}
})
}
}
func TestCodingAgentHarnessFailsClosed(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, root string)
wantProblem string
}{
{
name: "root guide is no longer thin",
mutate: func(t *testing.T, root string) {
path := filepath.Join(root, "AGENTS.md")
file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
defer file.Close()
for i := 0; i < guideLineLimits["AGENTS.md"]; i++ {
if _, err := file.WriteString("extra line\n"); err != nil {
t.Fatal(err)
}
}
},
wantProblem: "scoped guide limit",
},
{
name: "task input field is removed",
mutate: func(t *testing.T, root string) {
replaceFixtureText(t, root, "docs/coding-agent-task-template.md", "Acceptance criteria:", "")
},
wantProblem: `docs/coding-agent-task-template.md is missing required contract text "Acceptance criteria:"`,
},
{
name: "route link is broken",
mutate: func(t *testing.T, root string) {
replaceFixtureText(t, root, "AGENTS.md", "[automation](docs/automation.md)", "[automation](docs/missing.md)")
},
wantProblem: "broken local link",
},
{
name: "referenced script is absent",
mutate: func(t *testing.T, root string) {
if err := os.Remove(filepath.Join(root, "scripts/policy/check-schema-catalog.sh")); err != nil {
t.Fatal(err)
}
},
wantProblem: "required referenced path scripts/policy/check-schema-catalog.sh",
},
{
name: "make target is absent",
mutate: func(t *testing.T, root string) {
replaceFixtureText(t, root, "Makefile", "package:\n", "removed-package:\n")
},
wantProblem: `makefile is missing referenced target "package"`,
},
{
name: "harness script is not executable",
mutate: func(t *testing.T, root string) {
path := filepath.Join(root, "scripts/policy/check-coding-agent-harness.sh")
if err := os.Chmod(path, 0o644); err != nil {
t.Fatal(err)
}
},
wantProblem: "required referenced script scripts/policy/check-coding-agent-harness.sh is not executable",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := newHarnessFixture(t)
test.mutate(t, root)
problems := formatProblems(validate(root))
if !strings.Contains(problems, test.wantProblem) {
t.Fatalf("problems:\n%s\nwant substring %q", problems, test.wantProblem)
}
})
}
}
func newHarnessFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
for _, contract := range guideContracts {
var content strings.Builder
content.WriteString("# Fixture\n\n")
for _, required := range contract.required {
content.WriteString(required)
content.WriteByte('\n')
}
if contract.path == "AGENTS.md" {
content.WriteString("[coding](docs/coding-agent-guide.md)\n")
content.WriteString("[schema](docs/schema-contributor-guide.md)\n")
content.WriteString("[automation](docs/automation.md)\n")
content.WriteString("[agent code](docs/agent-code.md)\n")
}
writeFixtureFile(t, root, contract.path, content.String())
}
for _, path := range requiredPaths {
writeFixtureFileMode(t, root, path, "fixture\n", 0o755)
}
var makefile strings.Builder
for _, target := range requiredMakeTargets {
makefile.WriteString(target)
makefile.WriteString(":\n\t@true\n")
}
writeFixtureFile(t, root, "Makefile", makefile.String())
return root
}
func writeFixtureFile(t *testing.T, root, path, content string) {
t.Helper()
writeFixtureFileMode(t, root, path, content, 0o644)
}
func writeFixtureFileMode(t *testing.T, root, path, content string, mode os.FileMode) {
t.Helper()
full := filepath.Join(root, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("create fixture directory: %v", err)
}
if err := os.WriteFile(full, []byte(content), mode); err != nil {
t.Fatalf("write fixture %s: %v", path, err)
}
}
func replaceFixtureText(t *testing.T, root, path, old, replacement string) {
t.Helper()
full := filepath.Join(root, filepath.FromSlash(path))
content, err := os.ReadFile(full)
if err != nil {
t.Fatalf("read fixture %s: %v", path, err)
}
updated := strings.Replace(string(content), old, replacement, 1)
if updated == string(content) {
t.Fatalf("fixture %s does not contain %q", path, old)
}
if err := os.WriteFile(full, []byte(updated), 0o644); err != nil {
t.Fatalf("update fixture %s: %v", path, err)
}
}
func formatProblems(problems []error) string {
var lines []string
for _, problem := range problems {
lines = append(lines, problem.Error())
}
return strings.Join(lines, "\n")
}
func writeTaskFixture(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write task fixture: %v", err)
}
}
const validTaskContract = `# Task
Task kind: bug
Goal (one primary outcome): Fix retry handling
Current behavior and evidence: The focused test reproduces two retries.
Acceptance criteria:
- The retry succeeds once and then stops.
In scope (packages/files/surfaces): internal/transport
Out of scope: Authentication behavior
Compatibility constraints: Preserve existing flags and output
Interface impact (commands/flags/output/errors/exit codes/Schema): None
Safety or data-mutation constraints: No external writes
Expected validation: Focused unit test and make test
Known environment limitations: Live service unavailable
`
@@ -0,0 +1,15 @@
# Retry Handling Task
Task kind: bug
Goal (one primary outcome): Fix transient retry handling
Current behavior and evidence: A focused transport test reproduces two retries after a successful response.
Acceptance criteria:
- The retry loop stops after the first successful response.
- The existing output and exit behavior remain unchanged.
In scope (packages/files/surfaces): internal/transport retry implementation and tests
Out of scope: Authentication and endpoint discovery
Compatibility constraints: Preserve existing flags, JSON output, and exit codes
Interface impact (commands/flags/output/errors/exit codes/Schema): None
Safety or data-mutation constraints: No live service calls or external writes
Expected validation: Focused transport tests, race test, formatting, and full Go test suite
Known environment limitations: Live DingTalk service is intentionally not used