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.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/core/detect-stack.mjs +16 -0
- package/src/templates/_adapter-rust/harness/structural-check.mjs.hbs +89 -8
- package/src/templates/scripts/pre-push.sh +14 -5
- package/src/templates/scripts/precompletion-checklist.sh.hbs +13 -3
- package/src/templates/scripts/structural-test-on-edit.sh.hbs +30 -1
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"source": {
|
|
12
12
|
"source": "github",
|
|
13
13
|
"repo": "tuanle96/agent-harness-kit",
|
|
14
|
-
"ref": "v0.
|
|
14
|
+
"ref": "v0.6.0"
|
|
15
15
|
},
|
|
16
|
-
"version": "0.
|
|
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.
|
|
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
|
-
//
|
|
5
|
+
// import goes "backward" through the layer order.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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 -
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|