agent-harness-kit 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/core/render-templates.mjs +42 -15
- package/src/templates/CLAUDE.md.hbs +1 -1
- package/src/templates/_adapter-go/harness/structural_check.go.hbs +263 -0
- package/src/templates/_adapter-rust/harness/structural-check.mjs.hbs +198 -0
- package/src/templates/harness.config.json.hbs +5 -2
- package/src/templates/scripts/precompletion-checklist.sh.hbs +25 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-harness-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Solo-dev harness engineering kit for Claude Code. Layered architecture, structural tests, garbage-collection ritual, review subagents — without the enterprise overhead.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"lint": "echo 'no-op (kit is plain ESM JS)'",
|
|
43
43
|
"selftest": "node bin/cli.mjs --version",
|
|
44
44
|
"harness:eval": "node src/templates/_adapter-typescript/harness/eval-runner.mjs",
|
|
45
|
-
"harness:check": "
|
|
45
|
+
"harness:check": "node scripts/kit-structural-check.mjs"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@inquirer/prompts": "^7.0.0",
|
|
@@ -87,16 +87,15 @@ async function* walk(dir) {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
90
|
-
const installCmd =
|
|
91
|
-
stack.language === "python"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
: "npm install";
|
|
90
|
+
const installCmd = (() => {
|
|
91
|
+
if (stack.language === "python") return "pip install -e '.[dev]'";
|
|
92
|
+
if (stack.language === "go") return "go mod download";
|
|
93
|
+
if (stack.language === "rust") return "cargo build";
|
|
94
|
+
if (stack.packageManager === "pnpm") return "pnpm install";
|
|
95
|
+
if (stack.packageManager === "yarn") return "yarn";
|
|
96
|
+
if (stack.packageManager === "bun") return "bun install";
|
|
97
|
+
return "npm install";
|
|
98
|
+
})();
|
|
100
99
|
const devCmd = (() => {
|
|
101
100
|
switch (stack.framework) {
|
|
102
101
|
case "nextjs": return "npm run dev";
|
|
@@ -107,18 +106,28 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
|
107
106
|
case "django": return "python manage.py runserver";
|
|
108
107
|
case "flask": return "flask --app app run --debug";
|
|
109
108
|
default:
|
|
110
|
-
|
|
109
|
+
if (stack.language === "python") return "python -m app";
|
|
110
|
+
if (stack.language === "go") return "go run ./cmd/...";
|
|
111
|
+
if (stack.language === "rust") return "cargo run";
|
|
112
|
+
return "npm run dev";
|
|
111
113
|
}
|
|
112
114
|
})();
|
|
113
115
|
const testCmd = (() => {
|
|
114
116
|
switch (stack.framework) {
|
|
115
117
|
case "django": return "python manage.py test";
|
|
116
118
|
default:
|
|
117
|
-
|
|
119
|
+
if (stack.language === "python") return "pytest -x";
|
|
120
|
+
if (stack.language === "go") return "go test ./...";
|
|
121
|
+
if (stack.language === "rust") return "cargo test";
|
|
122
|
+
return "npm test";
|
|
118
123
|
}
|
|
119
124
|
})();
|
|
120
|
-
const lintCmd =
|
|
121
|
-
stack.language === "python"
|
|
125
|
+
const lintCmd = (() => {
|
|
126
|
+
if (stack.language === "python") return "ruff check .";
|
|
127
|
+
if (stack.language === "go") return "go vet ./...";
|
|
128
|
+
if (stack.language === "rust") return "cargo clippy --all-targets -- -D warnings";
|
|
129
|
+
return "npm run lint";
|
|
130
|
+
})();
|
|
122
131
|
|
|
123
132
|
return {
|
|
124
133
|
projectName,
|
|
@@ -137,6 +146,8 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
|
137
146
|
kitVersion,
|
|
138
147
|
isTypescript: stack.language === "typescript",
|
|
139
148
|
isPython: stack.language === "python",
|
|
149
|
+
isGo: stack.language === "go",
|
|
150
|
+
isRust: stack.language === "rust",
|
|
140
151
|
isNextjs: stack.framework === "nextjs",
|
|
141
152
|
isFastapi: stack.framework === "fastapi",
|
|
142
153
|
isExpress: stack.framework === "express",
|
|
@@ -152,11 +163,27 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
|
152
163
|
// into the root.
|
|
153
164
|
function pathForStack(rel, stack) {
|
|
154
165
|
if (rel.startsWith("_adapter-typescript/")) {
|
|
155
|
-
|
|
166
|
+
const stripped = rel.slice("_adapter-typescript/".length);
|
|
167
|
+
if (stack.language === "typescript") return stripped;
|
|
168
|
+
// eval-runner.mjs is language-agnostic — share with Go/Rust users who
|
|
169
|
+
// don't ship a native runner. Avoids duplicating ~322 lines per adapter.
|
|
170
|
+
if (
|
|
171
|
+
stripped === "harness/eval-runner.mjs" &&
|
|
172
|
+
(stack.language === "go" || stack.language === "rust")
|
|
173
|
+
) {
|
|
174
|
+
return stripped;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
156
177
|
}
|
|
157
178
|
if (rel.startsWith("_adapter-python/")) {
|
|
158
179
|
return stack.language === "python" ? rel.slice("_adapter-python/".length) : null;
|
|
159
180
|
}
|
|
181
|
+
if (rel.startsWith("_adapter-go/")) {
|
|
182
|
+
return stack.language === "go" ? rel.slice("_adapter-go/".length) : null;
|
|
183
|
+
}
|
|
184
|
+
if (rel.startsWith("_adapter-rust/")) {
|
|
185
|
+
return stack.language === "rust" ? rel.slice("_adapter-rust/".length) : null;
|
|
186
|
+
}
|
|
160
187
|
if (rel.startsWith("_preset-nextjs/")) {
|
|
161
188
|
return stack.framework === "nextjs" ? rel.slice("_preset-nextjs/".length) : null;
|
|
162
189
|
}
|
|
@@ -10,7 +10,7 @@ an encyclopedia.
|
|
|
10
10
|
- Dev: `{{devCmd}}`
|
|
11
11
|
- Test: `{{testCmd}}`
|
|
12
12
|
- Lint: `{{lintCmd}}`
|
|
13
|
-
- Structural: `{{#if isPython}}python -m harness.structural_test{{else}}npm run harness:check{{/if}}` (must pass before any PR)
|
|
13
|
+
- Structural: `{{#if isPython}}python -m harness.structural_test{{else}}{{#if isGo}}go run harness/structural_check.go{{else}}{{#if isRust}}node harness/structural-check.mjs{{else}}npm run harness:check{{/if}}{{/if}}{{/if}}` (must pass before any PR)
|
|
14
14
|
|
|
15
15
|
## Architecture (brief)
|
|
16
16
|
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// harness/structural_test.go — forward-only layer enforcement for Go.
|
|
2
|
+
//
|
|
3
|
+
// Reads harness.config.json + go.mod. For each domain, parses every .go
|
|
4
|
+
// file's imports (via go/parser stdlib) and asserts that no internal
|
|
5
|
+
// import goes "backward" through the layer order.
|
|
6
|
+
//
|
|
7
|
+
// Layer assignment: a file's layer = first path segment after `<root>/`.
|
|
8
|
+
// E.g. `internal/repo/store.go` belongs to the `repo` layer when
|
|
9
|
+
// `domains[0].root == "internal"`.
|
|
10
|
+
//
|
|
11
|
+
// External imports (stdlib, third-party modules) are ignored — only
|
|
12
|
+
// imports starting with `<module-path>/<root>/` are checked.
|
|
13
|
+
//
|
|
14
|
+
// Exit codes:
|
|
15
|
+
// 0 — clean (or only baselined violations)
|
|
16
|
+
// 2 — new violations found
|
|
17
|
+
//
|
|
18
|
+
// Run: go run harness/structural_test.go [--file <path>]
|
|
19
|
+
|
|
20
|
+
package main
|
|
21
|
+
|
|
22
|
+
import (
|
|
23
|
+
"encoding/json"
|
|
24
|
+
"fmt"
|
|
25
|
+
"go/parser"
|
|
26
|
+
"go/token"
|
|
27
|
+
"os"
|
|
28
|
+
"path/filepath"
|
|
29
|
+
"regexp"
|
|
30
|
+
"sort"
|
|
31
|
+
"strings"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
type Domain struct {
|
|
35
|
+
Name string `json:"name"`
|
|
36
|
+
Root string `json:"root"`
|
|
37
|
+
Layers []string `json:"layers"`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Config struct {
|
|
41
|
+
Domains []Domain `json:"domains"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Violation struct {
|
|
45
|
+
File string
|
|
46
|
+
Line int
|
|
47
|
+
From string
|
|
48
|
+
To string
|
|
49
|
+
Domain string
|
|
50
|
+
Key string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func readModulePath(repoRoot string) (string, error) {
|
|
54
|
+
data, err := os.ReadFile(filepath.Join(repoRoot, "go.mod"))
|
|
55
|
+
if err != nil {
|
|
56
|
+
return "", err
|
|
57
|
+
}
|
|
58
|
+
re := regexp.MustCompile(`(?m)^module\s+(\S+)`)
|
|
59
|
+
m := re.FindStringSubmatch(string(data))
|
|
60
|
+
if len(m) < 2 {
|
|
61
|
+
return "", fmt.Errorf("module declaration not found in go.mod")
|
|
62
|
+
}
|
|
63
|
+
return m[1], nil
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func readConfig(repoRoot string) (*Config, error) {
|
|
67
|
+
data, err := os.ReadFile(filepath.Join(repoRoot, "harness.config.json"))
|
|
68
|
+
if err != nil {
|
|
69
|
+
return nil, err
|
|
70
|
+
}
|
|
71
|
+
var c Config
|
|
72
|
+
if err := json.Unmarshal(data, &c); err != nil {
|
|
73
|
+
return nil, err
|
|
74
|
+
}
|
|
75
|
+
return &c, nil
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func readBaseline(repoRoot string) map[string]bool {
|
|
79
|
+
out := map[string]bool{}
|
|
80
|
+
data, err := os.ReadFile(filepath.Join(repoRoot, ".harness/structural-baseline.json"))
|
|
81
|
+
if err != nil {
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
var keys []string
|
|
85
|
+
if err := json.Unmarshal(data, &keys); err != nil {
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
for _, k := range keys {
|
|
89
|
+
out[k] = true
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Returns (layerName, domainPtr) or ("", nil) if file doesn't belong to any layer.
|
|
95
|
+
func layerOf(relPath string, cfg *Config) (string, *Domain) {
|
|
96
|
+
for i := range cfg.Domains {
|
|
97
|
+
d := &cfg.Domains[i]
|
|
98
|
+
if !strings.HasPrefix(relPath, d.Root+string(os.PathSeparator)) &&
|
|
99
|
+
!strings.HasPrefix(relPath, d.Root+"/") {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
stripped := strings.TrimPrefix(relPath, d.Root+string(os.PathSeparator))
|
|
103
|
+
stripped = strings.TrimPrefix(stripped, d.Root+"/")
|
|
104
|
+
parts := strings.SplitN(stripped, "/", 2)
|
|
105
|
+
if len(parts) == 0 {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
first := parts[0]
|
|
109
|
+
for _, l := range d.Layers {
|
|
110
|
+
if l == first {
|
|
111
|
+
return l, d
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return "", nil
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// For an import path like "github.com/foo/bar/internal/repo/store",
|
|
119
|
+
// returns ("repo", domainPtr) when domain.root == "internal" and
|
|
120
|
+
// modulePath == "github.com/foo/bar". Returns ("", nil) for external imports.
|
|
121
|
+
func importLayer(importPath, modulePath string, cfg *Config) (string, *Domain) {
|
|
122
|
+
prefix := modulePath + "/"
|
|
123
|
+
if !strings.HasPrefix(importPath, prefix) {
|
|
124
|
+
return "", nil
|
|
125
|
+
}
|
|
126
|
+
relPath := strings.TrimPrefix(importPath, prefix)
|
|
127
|
+
return layerOf(relPath, cfg)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func indexOf(slice []string, s string) int {
|
|
131
|
+
for i, v := range slice {
|
|
132
|
+
if v == s {
|
|
133
|
+
return i
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return -1
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func main() {
|
|
140
|
+
repoRoot, err := os.Getwd()
|
|
141
|
+
if err != nil {
|
|
142
|
+
fmt.Fprintln(os.Stderr, err)
|
|
143
|
+
os.Exit(1)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
scopedFile := ""
|
|
147
|
+
for i, a := range os.Args {
|
|
148
|
+
if a == "--file" && i+1 < len(os.Args) {
|
|
149
|
+
scopedFile = os.Args[i+1]
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
cfg, err := readConfig(repoRoot)
|
|
154
|
+
if err != nil {
|
|
155
|
+
fmt.Fprintln(os.Stderr, "read harness.config.json:", err)
|
|
156
|
+
os.Exit(1)
|
|
157
|
+
}
|
|
158
|
+
modulePath, err := readModulePath(repoRoot)
|
|
159
|
+
if err != nil {
|
|
160
|
+
fmt.Fprintln(os.Stderr, "read go.mod:", err)
|
|
161
|
+
os.Exit(1)
|
|
162
|
+
}
|
|
163
|
+
baseline := readBaseline(repoRoot)
|
|
164
|
+
baselineExists := len(baseline) > 0 || fileExists(filepath.Join(repoRoot, ".harness/structural-baseline.json"))
|
|
165
|
+
|
|
166
|
+
violations := []Violation{}
|
|
167
|
+
fset := token.NewFileSet()
|
|
168
|
+
walkErr := filepath.Walk(repoRoot, func(path string, info os.FileInfo, err error) error {
|
|
169
|
+
if err != nil {
|
|
170
|
+
return err
|
|
171
|
+
}
|
|
172
|
+
if info.IsDir() {
|
|
173
|
+
if info.Name() == "vendor" || info.Name() == ".git" || strings.HasPrefix(info.Name(), ".") {
|
|
174
|
+
if path == repoRoot {
|
|
175
|
+
return nil
|
|
176
|
+
}
|
|
177
|
+
return filepath.SkipDir
|
|
178
|
+
}
|
|
179
|
+
return nil
|
|
180
|
+
}
|
|
181
|
+
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
|
182
|
+
return nil
|
|
183
|
+
}
|
|
184
|
+
if scopedFile != "" {
|
|
185
|
+
abs, _ := filepath.Abs(scopedFile)
|
|
186
|
+
if path != abs && path != filepath.Join(repoRoot, scopedFile) {
|
|
187
|
+
return nil
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
relPath, _ := filepath.Rel(repoRoot, path)
|
|
191
|
+
srcLayer, srcDomain := layerOf(relPath, cfg)
|
|
192
|
+
if srcDomain == nil {
|
|
193
|
+
return nil
|
|
194
|
+
}
|
|
195
|
+
srcIdx := indexOf(srcDomain.Layers, srcLayer)
|
|
196
|
+
|
|
197
|
+
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
|
|
198
|
+
if err != nil {
|
|
199
|
+
return nil
|
|
200
|
+
}
|
|
201
|
+
for _, imp := range f.Imports {
|
|
202
|
+
impPath := strings.Trim(imp.Path.Value, `"`)
|
|
203
|
+
tgtLayer, tgtDomain := importLayer(impPath, modulePath, cfg)
|
|
204
|
+
if tgtDomain == nil || tgtDomain.Name != srcDomain.Name {
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
tgtIdx := indexOf(tgtDomain.Layers, tgtLayer)
|
|
208
|
+
if srcIdx < tgtIdx {
|
|
209
|
+
key := fmt.Sprintf("%s::%s", relPath, impPath)
|
|
210
|
+
if baseline[key] {
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
violations = append(violations, Violation{
|
|
214
|
+
File: relPath,
|
|
215
|
+
Line: fset.Position(imp.Pos()).Line,
|
|
216
|
+
From: srcLayer,
|
|
217
|
+
To: tgtLayer,
|
|
218
|
+
Domain: srcDomain.Name,
|
|
219
|
+
Key: key,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return nil
|
|
224
|
+
})
|
|
225
|
+
if walkErr != nil {
|
|
226
|
+
fmt.Fprintln(os.Stderr, "walk:", walkErr)
|
|
227
|
+
os.Exit(1)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// First-run baseline: write current violations + exit 0.
|
|
231
|
+
if !baselineExists && len(violations) > 0 {
|
|
232
|
+
_ = os.MkdirAll(filepath.Join(repoRoot, ".harness"), 0o755)
|
|
233
|
+
keys := make([]string, len(violations))
|
|
234
|
+
for i, v := range violations {
|
|
235
|
+
keys[i] = v.Key
|
|
236
|
+
}
|
|
237
|
+
sort.Strings(keys)
|
|
238
|
+
out, _ := json.MarshalIndent(keys, "", " ")
|
|
239
|
+
_ = os.WriteFile(filepath.Join(repoRoot, ".harness/structural-baseline.json"), append(out, '\n'), 0o644)
|
|
240
|
+
fmt.Printf("✓ structural test: baselined %d existing violations (.harness/structural-baseline.json).\n", len(violations))
|
|
241
|
+
fmt.Println(" New violations introduced after this point will block. Existing ones can be fixed incrementally.")
|
|
242
|
+
os.Exit(0)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if len(violations) == 0 {
|
|
246
|
+
fmt.Println("✓ structural test passed")
|
|
247
|
+
os.Exit(0)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for _, v := range violations {
|
|
251
|
+
fmt.Fprintf(os.Stderr, "✖ %s:%d layer=%s → %s (must be forward-only)\n", v.File, v.Line, v.From, v.To)
|
|
252
|
+
}
|
|
253
|
+
fmt.Fprintf(os.Stderr, "\n%d new layer violation(s). Fix the import direction.\n", len(violations))
|
|
254
|
+
if len(cfg.Domains) > 0 {
|
|
255
|
+
fmt.Fprintf(os.Stderr, "Layer order for domain %q: %s\n", cfg.Domains[0].Name, strings.Join(cfg.Domains[0].Layers, " → "))
|
|
256
|
+
}
|
|
257
|
+
os.Exit(2)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
func fileExists(p string) bool {
|
|
261
|
+
_, err := os.Stat(p)
|
|
262
|
+
return err == nil
|
|
263
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// harness/structural-check.mjs — forward-only layer enforcement for Rust.
|
|
2
|
+
//
|
|
3
|
+
// Reads harness.config.json. For each domain, walks every .rs file under
|
|
4
|
+
// the domain root (excluding target/, .git/, vendor/) and asserts no
|
|
5
|
+
// `use crate::<layer>::...` import goes "backward" through the layer order.
|
|
6
|
+
//
|
|
7
|
+
// Layer assignment: a file's layer = first path segment after `<root>/`.
|
|
8
|
+
// E.g. `src/repo/store.rs` belongs to the `repo` layer when
|
|
9
|
+
// `domains[0].root == "src"`.
|
|
10
|
+
//
|
|
11
|
+
// Why Node + regex (not a Cargo binary):
|
|
12
|
+
// - Avoids polluting the user's Cargo workspace with a check crate.
|
|
13
|
+
// - Node is already required to install the kit (npx).
|
|
14
|
+
// - Regex over `use crate::<X>` is sufficient — we never need full
|
|
15
|
+
// parse trees because the layer rule is a syntactic property.
|
|
16
|
+
// - `super::` and `self::` are scoped to the current module, which is
|
|
17
|
+
// by definition the same layer, so we ignore them.
|
|
18
|
+
//
|
|
19
|
+
// Exit codes:
|
|
20
|
+
// 0 — clean (or only baselined violations; or first-run baseline write)
|
|
21
|
+
// 2 — new violations found
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
readFileSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
existsSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
readdirSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { join, relative, sep } from "node:path";
|
|
31
|
+
|
|
32
|
+
const repoRoot = process.cwd();
|
|
33
|
+
const SKIP_DIRS = new Set([
|
|
34
|
+
".git",
|
|
35
|
+
"target",
|
|
36
|
+
"node_modules",
|
|
37
|
+
".harness",
|
|
38
|
+
"vendor",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function readConfig() {
|
|
42
|
+
const path = join(repoRoot, "harness.config.json");
|
|
43
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readBaseline() {
|
|
47
|
+
const path = join(repoRoot, ".harness/structural-baseline.json");
|
|
48
|
+
if (!existsSync(path)) return { exists: false, set: new Set() };
|
|
49
|
+
try {
|
|
50
|
+
const arr = JSON.parse(readFileSync(path, "utf8"));
|
|
51
|
+
return { exists: true, set: new Set(Array.isArray(arr) ? arr : []) };
|
|
52
|
+
} catch {
|
|
53
|
+
return { exists: true, set: new Set() };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function* walkRustFiles(root) {
|
|
58
|
+
if (!existsSync(root)) return;
|
|
59
|
+
for (const ent of readdirSync(root, { withFileTypes: true })) {
|
|
60
|
+
if (SKIP_DIRS.has(ent.name) || ent.name.startsWith(".")) continue;
|
|
61
|
+
const full = join(root, ent.name);
|
|
62
|
+
if (ent.isDirectory()) {
|
|
63
|
+
yield* walkRustFiles(full);
|
|
64
|
+
} else if (ent.isFile() && ent.name.endsWith(".rs")) {
|
|
65
|
+
yield full;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Returns { layer, domain } or null.
|
|
71
|
+
function layerOf(relPath, cfg) {
|
|
72
|
+
for (const d of cfg.domains) {
|
|
73
|
+
const altPrefix = d.root + "/";
|
|
74
|
+
const sepPrefix = d.root + sep;
|
|
75
|
+
let stripped;
|
|
76
|
+
if (relPath.startsWith(altPrefix)) stripped = relPath.slice(altPrefix.length);
|
|
77
|
+
else if (relPath.startsWith(sepPrefix))
|
|
78
|
+
stripped = relPath.slice(sepPrefix.length);
|
|
79
|
+
else continue;
|
|
80
|
+
const first = stripped.split(/[\/\\]/)[0];
|
|
81
|
+
if (d.layers.includes(first)) return { layer: first, domain: d };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Capture the first identifier after `use crate::` (or `pub use crate::`).
|
|
87
|
+
const USE_CRATE_RE = /\b(?:pub\s+)?use\s+crate::([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
88
|
+
|
|
89
|
+
function parseUseCrate(line) {
|
|
90
|
+
return [...line.matchAll(USE_CRATE_RE)].map((m) => m[1]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Return only the code portion of a line — strip line comments (//) and
|
|
94
|
+
// double-quoted string contents so the regex can't match `use crate::X`
|
|
95
|
+
// inside text like `"use crate::service"` or `// use crate::service`.
|
|
96
|
+
// Block comments and char literals (single quotes — also Rust lifetimes)
|
|
97
|
+
// are not handled; collisions are rare and would only cause noise, not
|
|
98
|
+
// missed real violations.
|
|
99
|
+
function stripCommentsAndStrings(line) {
|
|
100
|
+
let result = "";
|
|
101
|
+
let inStr = false;
|
|
102
|
+
for (let i = 0; i < line.length; i++) {
|
|
103
|
+
const c = line[i];
|
|
104
|
+
if (inStr) {
|
|
105
|
+
if (c === "\\" && i + 1 < line.length) {
|
|
106
|
+
i++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (c === '"') inStr = false;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (c === '"') {
|
|
113
|
+
inStr = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === "/" && i + 1 < line.length && line[i + 1] === "/") break;
|
|
117
|
+
result += c;
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function main() {
|
|
123
|
+
const cfg = readConfig();
|
|
124
|
+
const { exists: baselineExists, set: baselineSet } = readBaseline();
|
|
125
|
+
const violations = [];
|
|
126
|
+
|
|
127
|
+
for (const d of cfg.domains) {
|
|
128
|
+
const rootDir = join(repoRoot, d.root);
|
|
129
|
+
for (const file of walkRustFiles(rootDir)) {
|
|
130
|
+
const relPath = relative(repoRoot, file);
|
|
131
|
+
const src = layerOf(relPath, cfg);
|
|
132
|
+
if (!src) continue;
|
|
133
|
+
const srcIdx = src.domain.layers.indexOf(src.layer);
|
|
134
|
+
|
|
135
|
+
const content = readFileSync(file, "utf8");
|
|
136
|
+
const lines = content.split("\n");
|
|
137
|
+
for (let i = 0; i < lines.length; i++) {
|
|
138
|
+
const codeOnly = stripCommentsAndStrings(lines[i]);
|
|
139
|
+
const targets = parseUseCrate(codeOnly);
|
|
140
|
+
for (const tgtLayer of targets) {
|
|
141
|
+
if (!src.domain.layers.includes(tgtLayer)) continue;
|
|
142
|
+
const tgtIdx = src.domain.layers.indexOf(tgtLayer);
|
|
143
|
+
if (srcIdx < tgtIdx) {
|
|
144
|
+
const key = `${relPath}::${tgtLayer}`;
|
|
145
|
+
if (baselineSet.has(key)) continue;
|
|
146
|
+
violations.push({
|
|
147
|
+
file: relPath,
|
|
148
|
+
line: i + 1,
|
|
149
|
+
from: src.layer,
|
|
150
|
+
to: tgtLayer,
|
|
151
|
+
domain: src.domain.name,
|
|
152
|
+
key,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// First-run baseline: no baseline file + violations → record + exit 0.
|
|
161
|
+
if (!baselineExists && violations.length > 0) {
|
|
162
|
+
mkdirSync(join(repoRoot, ".harness"), { recursive: true });
|
|
163
|
+
const keys = [...new Set(violations.map((v) => v.key))].sort();
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(repoRoot, ".harness/structural-baseline.json"),
|
|
166
|
+
JSON.stringify(keys, null, 2) + "\n",
|
|
167
|
+
);
|
|
168
|
+
console.log(
|
|
169
|
+
`✓ structural test: baselined ${keys.length} existing violation(s) (.harness/structural-baseline.json).`,
|
|
170
|
+
);
|
|
171
|
+
console.log(
|
|
172
|
+
" New violations introduced after this point will block. Existing ones can be fixed incrementally.",
|
|
173
|
+
);
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (violations.length === 0) {
|
|
178
|
+
console.log("✓ structural test passed");
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const v of violations) {
|
|
183
|
+
console.error(
|
|
184
|
+
`✖ ${v.file}:${v.line} layer=${v.from} → ${v.to} (must be forward-only)`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
console.error(
|
|
188
|
+
`\n${violations.length} new layer violation(s). Fix the import direction.`,
|
|
189
|
+
);
|
|
190
|
+
if (cfg.domains.length > 0) {
|
|
191
|
+
console.error(
|
|
192
|
+
`Layer order for domain "${cfg.domains[0].name}": ${cfg.domains[0].layers.join(" → ")}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
process.exit(2);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
main();
|
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
"domains": [
|
|
8
8
|
{
|
|
9
9
|
"name": "default",
|
|
10
|
-
"root": "{{#if isPython}}app{{else}}src{{/if}}",
|
|
10
|
+
"root": "{{#if isPython}}app{{else}}{{#if isGo}}internal{{else}}src{{/if}}{{/if}}",
|
|
11
11
|
"layers": [{{#each layers}}"{{this}}"{{#unless @last}}, {{/unless}}{{/each}}]
|
|
12
12
|
}
|
|
13
13
|
],
|
|
14
14
|
"providers": ["auth", "telemetry", "feature-flags"],
|
|
15
15
|
"goldenPrinciples": "docs/golden-principles.md",
|
|
16
16
|
"structuralTest": {
|
|
17
|
-
"engine": "{{#if isPython}}libcst{{else}}ts-morph{{/if}}",
|
|
17
|
+
"engine": "{{#if isPython}}libcst{{else}}{{#if isGo}}go-parser{{else}}{{#if isRust}}rust-regex{{else}}ts-morph{{/if}}{{/if}}{{/if}}",
|
|
18
18
|
"configPath": ".harness/structural-test.config.json",
|
|
19
19
|
"blockOnViolation": true
|
|
20
20
|
},
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"path": "CLAUDE.md",
|
|
33
33
|
"maxInstructions": 200
|
|
34
34
|
},
|
|
35
|
+
"recovery": {
|
|
36
|
+
"headless": false
|
|
37
|
+
},
|
|
35
38
|
"models": {
|
|
36
39
|
"main": "claude-sonnet-4-6",
|
|
37
40
|
"reviewers": "claude-sonnet-4-6",
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
# failure context (not just check names) via stderr and exit 2. On second
|
|
5
5
|
# stop (stop_hook_active=true), exit 0 to allow real exit.
|
|
6
6
|
#
|
|
7
|
-
# Optional:
|
|
8
|
-
# for one turn of recovery
|
|
7
|
+
# Optional headless recovery: when enabled, spawn `claude -p` in the background
|
|
8
|
+
# for one turn of recovery on failure. Costs tokens; off by default. Configure
|
|
9
|
+
# via harness.config.json `.recovery.headless` (persistent), or override per-run
|
|
10
|
+
# with AHK_HEADLESS_RECOVER=1 (env var wins).
|
|
9
11
|
set -e
|
|
10
12
|
|
|
11
13
|
INPUT=$(cat)
|
|
@@ -163,12 +165,28 @@ fi
|
|
|
163
165
|
echo "the task complete. Do NOT disable a check to make the hook pass."
|
|
164
166
|
} >&2
|
|
165
167
|
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
|
|
168
|
+
# Opt-in headless recovery. Spawns a one-turn `claude -p` to attempt the fix
|
|
169
|
+
# autonomously. Useful for unattended CI / cron contexts. Off by default
|
|
170
|
+
# because it costs tokens.
|
|
171
|
+
#
|
|
172
|
+
# Resolution order (first wins):
|
|
173
|
+
# 1. AHK_HEADLESS_RECOVER=1 (env-var override, per-run)
|
|
174
|
+
# 2. harness.config.json `.recovery.headless: true` (persistent)
|
|
175
|
+
HEADLESS_RECOVER=0
|
|
176
|
+
HEADLESS_SOURCE=""
|
|
177
|
+
if [ "${AHK_HEADLESS_RECOVER:-}" = "1" ]; then
|
|
178
|
+
HEADLESS_RECOVER=1
|
|
179
|
+
HEADLESS_SOURCE="AHK_HEADLESS_RECOVER"
|
|
180
|
+
elif [ -f harness.config.json ] && command -v jq >/dev/null 2>&1; then
|
|
181
|
+
CFG_VAL=$(jq -r '.recovery.headless // false' harness.config.json 2>/dev/null)
|
|
182
|
+
if [ "$CFG_VAL" = "true" ]; then
|
|
183
|
+
HEADLESS_RECOVER=1
|
|
184
|
+
HEADLESS_SOURCE="harness.config.json:.recovery.headless"
|
|
185
|
+
fi
|
|
186
|
+
fi
|
|
187
|
+
if [ "$HEADLESS_RECOVER" = "1" ] && command -v claude >/dev/null 2>&1; then
|
|
170
188
|
FAILED_LIST=$(tr '\n' ' ' < "$TMPDIR_HOOK/failed.list")
|
|
171
|
-
echo "[ahk]
|
|
189
|
+
echo "[ahk] headless recovery enabled ($HEADLESS_SOURCE) — spawning recovery turn for: $FAILED_LIST" >&2
|
|
172
190
|
claude -p \
|
|
173
191
|
"The pre-completion checklist failed: $FAILED_LIST. Read the failure output in $TMPDIR_HOOK and apply the smallest fix. Do not disable any check." \
|
|
174
192
|
--max-turns 5 \
|