agent-harness-kit 0.4.0 → 0.5.1

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.
@@ -11,9 +11,9 @@
11
11
  "source": {
12
12
  "source": "github",
13
13
  "repo": "tuanle96/agent-harness-kit",
14
- "ref": "v0.3.0"
14
+ "ref": "v0.5.1"
15
15
  },
16
- "version": "0.3.0",
16
+ "version": "0.5.1",
17
17
  "description": "Solo-dev harness engineering kit — layered architecture, GC ritual, structural tests, review subagents.",
18
18
  "category": "development",
19
19
  "keywords": [
package/README.md CHANGED
@@ -67,9 +67,13 @@ Option B: install as a Claude Code plugin
67
67
  is ~100 lines). The kit's CLAUDE.md is 50–80 lines.
68
68
  2. **Every agent failure becomes a permanent harness change** (Hashimoto's
69
69
  discipline). The `/propose-harness-improvement` skill enforces this.
70
- 3. **Computational sensors before LLM sensors** (Fowler/Böckeler). The TS and
70
+ 3. **Computational sensors as safety net** (Fowler/Böckeler). The TS and
71
71
  Python adapters ship one deterministic structural test per language; LLM
72
- subagents are reserved for semantic judgment.
72
+ subagents are reserved for semantic judgment. Note: in our 1-shot bench
73
+ (n=3, ts-layered), the agent already followed visible seed patterns and
74
+ produced 0 boundary violations without enforcement. Treat structural tests
75
+ as a safety net for drift in long sessions, not as a happy-path
76
+ differentiator — see [Honest expectations](#honest-expectations).
73
77
  4. **Garbage collection over Friday cleanup, scaled to solo** (OpenAI's
74
78
  ritual, shrunk to top-3 fixes per week).
75
79
 
@@ -140,6 +144,33 @@ agent-harness-kit doctor # diagnose installed kit + Claude Code env
140
144
  agent-harness-kit --version
141
145
  ```
142
146
 
147
+ ## Honest expectations
148
+
149
+ What this kit **does** differentiate from bare claude-cli (anecdotal + design-level):
150
+
151
+ - Opinionated CLAUDE.md template (50–80 lines) so context isn't blown on style
152
+ - 10 skills (`/add-feature`, `/garbage-collection`, `/propose-harness-improvement`, …) that codify Hashimoto/OpenAI rituals
153
+ - 5 read-only review subagents for cheap second-opinion passes
154
+ - `feature_list.json` + ADR template + GC ritual for solo-scale planning hygiene
155
+ - Solo-dev cost defaults (~$2/day) and per-run budget enforcement
156
+
157
+ What it does **not** measurably differentiate (5 consecutive null benches, May 2026):
158
+
159
+ - Structural enforcement on happy-path 1-shot tasks. When seed code shows the
160
+ layer pattern, claude-cli follows it — the boundaries lint has nothing to
161
+ catch. We measured 0/6 ui→repo violations across bare and kit arms on the
162
+ `ts-layered` fixture.
163
+
164
+ Where the structural test *might* still earn its keep (untested, listed for
165
+ honesty, not as a claim):
166
+
167
+ - Long multi-turn sessions where pattern context drifts
168
+ - Adversarial "make it fast" pressure that tempts shortcuts
169
+ - Greenfield code with no existing pattern to follow
170
+ - Weaker model substrates (haiku, gpt-4o-mini)
171
+
172
+ Use the lint as a **safety net**, not as the reason you adopted the kit.
173
+
143
174
  ## Token / cost expectations
144
175
 
145
176
  A typical day with the default model split (Sonnet 4.6 main + Haiku 4.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-harness-kit",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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": "echo 'no-op (kit-level structural rules are TBD; see .harness/eval/tasks/03-add-structural-rule.json)'"
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
- ? "pip install -e '.[dev]'"
93
- : stack.packageManager === "pnpm"
94
- ? "pnpm install"
95
- : stack.packageManager === "yarn"
96
- ? "yarn"
97
- : stack.packageManager === "bun"
98
- ? "bun install"
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
- return stack.language === "python" ? "python -m app" : "npm run dev";
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
- return stack.language === "python" ? "pytest -x" : "npm test";
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" ? "ruff check ." : "npm run lint";
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
- return stack.language === "typescript" ? rel.slice("_adapter-typescript/".length) : null;
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
  }
@@ -67,6 +67,37 @@ export async function syncHarnessConfigVersion(cwd, kitVersion) {
67
67
  return { changed: true, reason: "synced" };
68
68
  }
69
69
 
70
+ // Ensure .claude/settings.json includes the critical write-tool permissions.
71
+ // Older kit versions shipped a template without Edit/Write/MultiEdit, which
72
+ // causes agents to silently no-op when they try to modify files. This patch
73
+ // adds any missing entries to the existing `permissions.allow` array without
74
+ // touching anything else the user customized.
75
+ //
76
+ // Exported for unit tests; called from `upgrade()` below.
77
+ export async function ensureWritePermissions(cwd) {
78
+ const settingsPath = resolve(cwd, ".claude/settings.json");
79
+ if (!existsSync(settingsPath)) return { changed: false, reason: "missing" };
80
+ const raw = await readFile(settingsPath, "utf8");
81
+ let cfg;
82
+ try {
83
+ cfg = JSON.parse(raw);
84
+ } catch {
85
+ return { changed: false, reason: "invalid-json" };
86
+ }
87
+ const allow = cfg?.permissions?.allow;
88
+ if (!Array.isArray(allow)) return { changed: false, reason: "no-allow-list" };
89
+
90
+ const required = ["Edit", "Write", "MultiEdit"];
91
+ const missing = required.filter((p) => !allow.includes(p));
92
+ if (missing.length === 0) return { changed: false, reason: "already-present" };
93
+
94
+ // Prepend missing entries so they appear before other Bash(...) rules,
95
+ // matching the template's ordering.
96
+ cfg.permissions.allow = [...missing, ...allow];
97
+ await writeFile(settingsPath, JSON.stringify(cfg, null, 2) + "\n");
98
+ return { changed: true, reason: "patched", added: missing };
99
+ }
100
+
70
101
  const __dirname = dirname(fileURLToPath(import.meta.url));
71
102
  const TEMPLATES_ROOT = resolve(__dirname, "..", "templates");
72
103
 
@@ -115,11 +146,19 @@ export async function upgrade({ cwd, kitVersion, yes }) {
115
146
  // older `version`/`$schema` (it's user-owned and skipped by the file walk).
116
147
  // Sync those two fields so doctor stops flagging drift.
117
148
  const cfgSync = await syncHarnessConfigVersion(cwd, kitVersion);
149
+ // Also patch settings.json if it's missing write permissions (legacy bug).
150
+ const permSync = await ensureWritePermissions(cwd);
118
151
  if (cfgSync.changed) {
119
152
  console.log(
120
153
  pc.green(`harness.config.json version + $schema synced to v${kitVersion}.`),
121
154
  );
122
- } else {
155
+ }
156
+ if (permSync.changed) {
157
+ console.log(
158
+ pc.green(`.claude/settings.json patched: added ${permSync.added.join(", ")}.`),
159
+ );
160
+ }
161
+ if (!cfgSync.changed && !permSync.changed) {
123
162
  console.log(pc.green(`Already on v${kitVersion}. Nothing to do.`));
124
163
  }
125
164
  return;
@@ -263,6 +302,16 @@ export async function upgrade({ cwd, kitVersion, yes }) {
263
302
  console.log(pc.dim(` ${pc.green("~")} harness.config.json (version + $schema synced)`));
264
303
  }
265
304
 
305
+ // Patch .claude/settings.json if it's missing the critical write
306
+ // permissions (Edit/Write/MultiEdit). Old kit versions shipped without
307
+ // these — agents would silently no-op. Idempotent.
308
+ const permSync = await ensureWritePermissions(cwd);
309
+ if (permSync.changed) {
310
+ console.log(
311
+ pc.dim(` ${pc.green("~")} .claude/settings.json (added ${permSync.added.join(", ")})`),
312
+ );
313
+ }
314
+
266
315
  console.log(pc.bold(pc.green(`\n✓ upgrade complete (v${kitVersion}).`)));
267
316
  if (sidecars.length > 0) {
268
317
  console.log(
@@ -2,6 +2,9 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
3
3
  "permissions": {
4
4
  "allow": [
5
+ "Edit",
6
+ "Write",
7
+ "MultiEdit",
5
8
  "Bash(npm run harness:*)",
6
9
  "Bash(npm run lint:*)",
7
10
  "Bash(npm test:*)",
@@ -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();
@@ -22,15 +22,17 @@ export default [
22
22
  "boundaries/include": ["src/**/*"],
23
23
  },
24
24
  rules: {
25
- "boundaries/dependencies": [2, {
25
+ // eslint-plugin-boundaries v5: rule name is `element-types`, not `dependencies`.
26
+ // Schema: `{ from: ["t1"], allow: ["t2", "t3"] }` — flat arrays of element-type names.
27
+ "boundaries/element-types": [2, {
26
28
  default: "disallow",
27
29
  rules: [
28
- { from: { type: "ui" }, allow: { to: { type: ["runtime","service","config","types"] } } },
29
- { from: { type: "runtime" }, allow: { to: { type: ["service","repo","config","types"] } } },
30
- { from: { type: "service" }, allow: { to: { type: ["repo","config","types"] } } },
31
- { from: { type: "repo" }, allow: { to: { type: ["config","types"] } } },
32
- { from: { type: "config" }, allow: { to: { type: ["types"] } } },
33
- { from: { type: "types" }, disallow: { to: { type: "*" } } },
30
+ { from: ["ui"], allow: ["runtime", "service", "config", "types"] },
31
+ { from: ["runtime"], allow: ["service", "repo", "config", "types"] },
32
+ { from: ["service"], allow: ["repo", "config", "types"] },
33
+ { from: ["repo"], allow: ["config", "types"] },
34
+ { from: ["config"], allow: ["types"] },
35
+ { from: ["types"], disallow: ["*"] },
34
36
  ],
35
37
  }],
36
38
  },
@@ -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: set AHK_HEADLESS_RECOVER=1 to spawn `claude -p` in the background
8
- # for one turn of recovery (costs tokens; off by default).
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
- # Optional: opt-in headless recovery. Spawns a one-turn `claude -p` to
167
- # attempt the fix autonomously. Useful for unattended CI / cron contexts.
168
- # Off by default because it costs tokens.
169
- if [ "${AHK_HEADLESS_RECOVER:-}" = "1" ] && command -v claude >/dev/null 2>&1; then
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] AHK_HEADLESS_RECOVER=1 — spawning recovery turn for: $FAILED_LIST" >&2
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 \