agent-harness-kit 0.5.1 → 0.6.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.
@@ -11,9 +11,9 @@
11
11
  "source": {
12
12
  "source": "github",
13
13
  "repo": "tuanle96/agent-harness-kit",
14
- "ref": "v0.5.1"
14
+ "ref": "v0.6.0"
15
15
  },
16
- "version": "0.5.1",
16
+ "version": "0.6.0",
17
17
  "description": "Solo-dev harness engineering kit — layered architecture, GC ritual, structural tests, review subagents.",
18
18
  "category": "development",
19
19
  "keywords": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-harness-kit",
3
- "version": "0.5.1",
3
+ "version": "0.6.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": {
@@ -68,6 +68,22 @@ export async function detectStack(cwd) {
68
68
  // primary language. Walks 1 level deep into common monorepo dirs.
69
69
  await probePolyglot(cwd, result);
70
70
 
71
+ // Rust workspace — must be checked BEFORE package.json because a polyglot
72
+ // repo (Rust backend + Next.js frontend) typically has BOTH at the root,
73
+ // and we want the Rust adapter installed by default since structural-test
74
+ // enforcement matters more for the workspace than for the marketing site.
75
+ // Single-crate Cargo.toml falls through to the legacy check at the bottom.
76
+ const rootCargo = await readTextSafe(resolve(cwd, "Cargo.toml"));
77
+ if (rootCargo && /^\s*\[workspace\]/m.test(rootCargo)) {
78
+ result.language = "rust";
79
+ result.framework = "rust-workspace";
80
+ result.packageManager = "cargo";
81
+ result.monorepo = true;
82
+ result.suggestedPreset = "generic";
83
+ result.availablePresets = ["generic"];
84
+ return result;
85
+ }
86
+
71
87
  // JavaScript/TypeScript.
72
88
  const pkg = await readJsonSafe(resolve(cwd, "package.json"));
73
89
  if (pkg) {
@@ -2,16 +2,26 @@
2
2
  //
3
3
  // Reads harness.config.json. For each domain, walks every .rs file under
4
4
  // the domain root (excluding target/, .git/, vendor/) and asserts no
5
- // `use crate::<layer>::...` import goes "backward" through the layer order.
5
+ // import goes "backward" through the layer order.
6
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"`.
7
+ // Two layouts are supported:
8
+ //
9
+ // * Single-crate (default): a file's layer is the first path segment
10
+ // after `<root>/`. Intra-crate dependencies are written as
11
+ // `use crate::<layer>::...`.
12
+ //
13
+ // * Workspace mode (`layerDirPattern` + `useIdentPattern` in
14
+ // `harness.config.json`): each layer is its own crate. The directory
15
+ // pattern maps layer name → folder (e.g. `unibot-{layer}` →
16
+ // `unibot-types/`), and the use-ident pattern maps layer name → the
17
+ // crate identifier in `use` statements (e.g. `unibot_{layer}` →
18
+ // `use unibot_types::`). Both default to `{layer}` and preserve the
19
+ // legacy single-crate behavior.
10
20
  //
11
21
  // Why Node + regex (not a Cargo binary):
12
22
  // - Avoids polluting the user's Cargo workspace with a check crate.
13
23
  // - Node is already required to install the kit (npx).
14
- // - Regex over `use crate::<X>` is sufficient — we never need full
24
+ // - Regex over `use <crate>::<X>` is sufficient — we never need full
15
25
  // parse trees because the layer rule is a syntactic property.
16
26
  // - `super::` and `self::` are scoped to the current module, which is
17
27
  // by definition the same layer, so we ignore them.
@@ -68,6 +78,12 @@ function* walkRustFiles(root) {
68
78
  }
69
79
 
70
80
  // Returns { layer, domain } or null.
81
+ //
82
+ // Resolution:
83
+ // 1. Strip the domain root prefix from the path.
84
+ // 2. The first segment is the candidate layer directory.
85
+ // 3. Match it against `layerDirPattern` (default `{layer}`) — if the
86
+ // pattern resolves a layer name, use it.
71
87
  function layerOf(relPath, cfg) {
72
88
  for (const d of cfg.domains) {
73
89
  const altPrefix = d.root + "/";
@@ -78,12 +94,72 @@ function layerOf(relPath, cfg) {
78
94
  stripped = relPath.slice(sepPrefix.length);
79
95
  else continue;
80
96
  const first = stripped.split(/[\/\\]/)[0];
81
- if (d.layers.includes(first)) return { layer: first, domain: d };
97
+ const pattern = d.layerDirPattern || "{layer}";
98
+ const layer = resolveLayerFromDir(first, pattern, d.layers);
99
+ if (layer) return { layer, domain: d };
100
+ }
101
+ return null;
102
+ }
103
+
104
+ // Given a directory name and a pattern like `unibot-{layer}`, return the
105
+ // matching layer name from `layers` (or null). Handles the legacy
106
+ // `{layer}` pattern as the identity case.
107
+ function resolveLayerFromDir(dirName, pattern, layers) {
108
+ if (pattern === "{layer}") {
109
+ return layers.includes(dirName) ? dirName : null;
110
+ }
111
+ // Escape regex specials in the surrounding pattern fragments.
112
+ const [prefix, suffix] = pattern.split("{layer}");
113
+ const pre = (prefix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
114
+ const suf = (suffix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115
+ const re = new RegExp(`^${pre}(.+?)${suf}$`);
116
+ const m = dirName.match(re);
117
+ if (m && layers.includes(m[1])) return m[1];
118
+ return null;
119
+ }
120
+
121
+ // Capture the first identifier after `use ` (or `pub use ...`). The trailing
122
+ // `::` is optional — `use demo_service;` is legal Rust even though most
123
+ // real usage is `use demo_service::foo`.
124
+ const USE_RE = /\b(?:pub\s+)?use\s+([a-zA-Z_][a-zA-Z0-9_]*)/g;
125
+
126
+ function parseUseTargets(line, domain) {
127
+ const useIdent = domain.useIdentPattern || "crate";
128
+ const matches = [...line.matchAll(USE_RE)];
129
+ const layers = [];
130
+ for (const m of matches) {
131
+ const ident = m[1];
132
+ const layer = resolveLayerFromUseIdent(ident, useIdent, domain.layers);
133
+ if (layer) layers.push(layer);
134
+ }
135
+ return layers;
136
+ }
137
+
138
+ // Map a `use <ident>` to a layer. For single-crate mode this is
139
+ // `use crate::<layer>::...` — `ident == "crate"` and the layer is the
140
+ // SECOND segment. For workspace mode it is `use <crate>::...` where the
141
+ // crate name itself encodes the layer.
142
+ function resolveLayerFromUseIdent(ident, useIdentPattern, layers) {
143
+ if (useIdentPattern === "crate") {
144
+ // Legacy single-crate mode: only `use crate::...` matters; the layer
145
+ // is read from the captured ident only when ident === "crate" but
146
+ // the actual layer name is the segment AFTER `crate::` — see the
147
+ // separate `USE_CRATE_RE` path used by the caller for compatibility.
148
+ return null;
149
+ }
150
+ if (useIdentPattern === "{layer}") {
151
+ return layers.includes(ident) ? ident : null;
82
152
  }
153
+ const [prefix, suffix] = useIdentPattern.split("{layer}");
154
+ const pre = (prefix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
155
+ const suf = (suffix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
156
+ const re = new RegExp(`^${pre}(.+?)${suf}$`);
157
+ const m = ident.match(re);
158
+ if (m && layers.includes(m[1])) return m[1];
83
159
  return null;
84
160
  }
85
161
 
86
- // Capture the first identifier after `use crate::` (or `pub use crate::`).
162
+ // Backwards-compat: also keep the original single-crate matcher.
87
163
  const USE_CRATE_RE = /\b(?:pub\s+)?use\s+crate::([a-zA-Z_][a-zA-Z0-9_]*)/g;
88
164
 
89
165
  function parseUseCrate(line) {
@@ -134,9 +210,14 @@ function main() {
134
210
 
135
211
  const content = readFileSync(file, "utf8");
136
212
  const lines = content.split("\n");
213
+ // Workspace mode uses `useIdentPattern` (e.g. "unibot_{layer}");
214
+ // single-crate mode keeps the historical `use crate::<layer>::` form.
215
+ const workspaceMode = !!src.domain.useIdentPattern;
137
216
  for (let i = 0; i < lines.length; i++) {
138
217
  const codeOnly = stripCommentsAndStrings(lines[i]);
139
- const targets = parseUseCrate(codeOnly);
218
+ const targets = workspaceMode
219
+ ? parseUseTargets(codeOnly, src.domain)
220
+ : parseUseCrate(codeOnly);
140
221
  for (const tgtLayer of targets) {
141
222
  if (!src.domain.layers.includes(tgtLayer)) continue;
142
223
  const tgtIdx = src.domain.layers.indexOf(tgtLayer);
@@ -2,7 +2,7 @@
2
2
  # pre-push hook — Stripe "shift-feedback-left" pattern. Runs only the
3
3
  # deterministic checks (structural test + linter + tests on changed files).
4
4
  # Lives in scripts/ so it ships with the repo; install via install-git-hooks.sh.
5
- set -e
5
+ set -eo pipefail
6
6
 
7
7
  # Baseline monotonic guard. .harness/structural-baseline.json is decreasing-
8
8
  # only — fixes REMOVE entries; no path should ADD them. Catches the "mask
@@ -33,11 +33,20 @@ if [ -f "$BASELINE_FILE" ] \
33
33
  fi
34
34
  fi
35
35
 
36
- echo "[pre-push] running structural test…"
37
- if [ -f harness.config.json ] && grep -q '"language": "python"' harness.config.json; then
38
- python -m harness.structural_test
36
+ # Structural test. Skipped when `structuralTest.engine` is explicitly "none"
37
+ # (e.g. during scaffold of a polyglot repo where the adapter is not yet
38
+ # wired). Without this guard the push fails silently because
39
+ # `npm run harness:check` has no matching script.
40
+ if [ -f harness.config.json ] \
41
+ && grep -qE '"engine"[[:space:]]*:[[:space:]]*"none"' harness.config.json; then
42
+ echo "[pre-push] structural test skipped (structuralTest.engine: none)"
39
43
  else
40
- npm run --silent harness:check
44
+ echo "[pre-push] running structural test…"
45
+ if [ -f harness.config.json ] && grep -q '"language": "python"' harness.config.json; then
46
+ python -m harness.structural_test
47
+ else
48
+ npm run --silent harness:check
49
+ fi
41
50
  fi
42
51
 
43
52
  echo "[pre-push] running lint…"
@@ -39,8 +39,12 @@ run_check() {
39
39
  fi
40
40
  }
41
41
 
42
- # Structural test.
43
- if [ -f harness.config.json ]; then
42
+ # Structural test. Skipped when `structuralTest.engine` is explicitly "none"
43
+ # (e.g. during scaffold of a polyglot repo where the adapter is not yet
44
+ # wired). Without this guard the check fails silently with an empty body
45
+ # because `npm run harness:check` has no matching script.
46
+ if [ -f harness.config.json ] \
47
+ && ! grep -qE '"engine"[[:space:]]*:[[:space:]]*"none"' harness.config.json; then
44
48
  if grep -q '"language": "python"' harness.config.json; then
45
49
  run_check structural-test python -m harness.structural_test || true
46
50
  else
@@ -107,11 +111,17 @@ if [ -f harness.config.json ] && command -v jq >/dev/null 2>&1 && command -v git
107
111
  while [ "$i" -lt "$NUM_DOMAINS" ]; do
108
112
  ROOT=$(jq -r ".domains[$i].root" harness.config.json)
109
113
  DOMAIN=$(jq -r ".domains[$i].name" harness.config.json)
114
+ # Optional layerDirPattern — supports conventions where the layer
115
+ # directory is not literally `{layer}`. Example: a Rust workspace
116
+ # with crates named `unibot-types`, `unibot-crypto`, ... uses
117
+ # `"layerDirPattern": "unibot-{layer}"`. Defaults to `{layer}`.
118
+ LAYER_PATTERN=$(jq -r ".domains[$i].layerDirPattern // \"{layer}\"" harness.config.json)
110
119
  TOUCHED_COUNT=0
111
120
  TOUCHED_NAMES=""
112
121
  while IFS= read -r layer; do
113
122
  [ -z "$layer" ] && continue
114
- if echo "$CHANGED" | grep -qE "^${ROOT}/${layer}(/|$)"; then
123
+ LAYER_DIR=$(printf '%s' "$LAYER_PATTERN" | sed "s/{layer}/$layer/g")
124
+ if echo "$CHANGED" | grep -qE "^${ROOT}/${LAYER_DIR}(/|$)"; then
115
125
  TOUCHED_COUNT=$((TOUCHED_COUNT + 1))
116
126
  TOUCHED_NAMES="$TOUCHED_NAMES $layer"
117
127
  fi
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env bash
2
2
  # PostToolUse hook — runs the structural test on the file just edited.
3
3
  # Defensive: never blocks on missing tooling. Exit code 2 = block + Claude reads stderr.
4
- set -e
4
+ #
5
+ # `pipefail` is critical — without it, `cmd | tail` swallows cmd's exit code
6
+ # and a real structural-test failure looks clean to the agent.
7
+ set -eo pipefail
5
8
 
6
9
  INPUT=$(cat)
7
10
  if ! command -v jq >/dev/null 2>&1; then
@@ -15,6 +18,7 @@ FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
15
18
  case "$FILE" in
16
19
  *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs) ENGINE=ts ;;
17
20
  *.py) ENGINE=py ;;
21
+ *.rs) ENGINE=rust ;;
18
22
  *) exit 0 ;;
19
23
  esac
20
24
 
@@ -25,6 +29,14 @@ if [ "${AHK_HOOK_MODE:-}" = "warn" ]; then
25
29
  exit 0
26
30
  fi
27
31
 
32
+ # Skip cleanly when the structural test is explicitly disabled (polyglot
33
+ # scaffolds where the adapter is not yet wired). Without this guard every
34
+ # edit fires a failing hook that the agent can't actually fix.
35
+ if [ -f harness.config.json ] \
36
+ && grep -qE '"engine"[[:space:]]*:[[:space:]]*"none"' harness.config.json; then
37
+ exit 0
38
+ fi
39
+
28
40
  # Run the structural test scoped to this file. Capture output so we can
29
41
  # return only the relevant lines via stderr to Claude.
30
42
  if [ "$ENGINE" = "ts" ]; then
@@ -46,6 +58,23 @@ Structural test failed for $FILE.
46
58
  Layer order: see harness.config.json.
47
59
  Run \`python -m harness.structural_test\` for full output.
48
60
  Fix the violation before continuing — do NOT disable the test.
61
+ EOF
62
+ exit 2
63
+ fi
64
+ elif [ "$ENGINE" = "rust" ]; then
65
+ # The Rust adapter is a Node script (`harness/structural-check.mjs`); it
66
+ # scans the whole workspace rather than a single file because the regex
67
+ # is cheap. If the script isn't present yet, exit 0 (graceful degrade).
68
+ if [ ! -f harness/structural-check.mjs ]; then
69
+ exit 0
70
+ fi
71
+ if ! node harness/structural-check.mjs 2>&1 | tail -50 >&2; then
72
+ cat >&2 <<EOF
73
+
74
+ Structural test failed (triggered by edit to $FILE).
75
+ Layer order: see harness.config.json.
76
+ Run \`node harness/structural-check.mjs\` for full output.
77
+ Fix the violation before continuing — do NOT disable the test.
49
78
  EOF
50
79
  exit 2
51
80
  fi