agent-harness-kit 0.6.0 → 0.8.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/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -0
- package/bin/cli.mjs +15 -1
- package/package.json +1 -1
- package/src/core/detect-stack.mjs +16 -0
- package/src/core/doctor.mjs +23 -0
- package/src/core/render-templates.mjs +198 -6
- package/src/templates/.claude/hooks/hooks.json +111 -0
- package/src/templates/.claude/settings.json.hbs +1 -1
- package/src/templates/.claude/skills/doc-drift-scan/SKILL.md +15 -10
- package/src/templates/.claude/skills/doc-drift-scan/scripts/scan-paths.mjs +64 -0
- package/src/templates/.claude/skills/garbage-collection/SKILL.md.hbs +14 -5
- package/src/templates/.claude/skills/garbage-collection/scripts/gc-classify.mjs +77 -0
- package/src/templates/.claude/skills/inspect-module/SKILL.md.hbs +17 -14
- package/src/templates/.claude/skills/inspect-module/scripts/module-summary.mjs +144 -0
- package/src/templates/CLAUDE.md.hbs +10 -6
- package/src/templates/CLAUDE.md.vi.hbs +74 -0
- package/src/templates/_adapter-kotlin/harness/structural-check.mjs.hbs +286 -0
- package/src/templates/_adapter-rust/harness/structural-check.mjs.hbs +292 -100
- package/src/templates/_adapter-swift/harness/structural-check.mjs.hbs +285 -0
- package/src/templates/harness.config.json.hbs +5 -3
- package/src/templates/scripts/_lib/approx-tokens.mjs +48 -0
- package/src/templates/scripts/_lib/json-pick.mjs +278 -0
- package/src/templates/scripts/harness-report.mjs +95 -1
- package/src/templates/scripts/notify-on-block.sh.hbs +73 -0
- package/src/templates/scripts/pre-compact.sh.hbs +121 -0
- package/src/templates/scripts/pre-push.sh +28 -3
- package/src/templates/scripts/precompletion-checklist.sh.hbs +131 -22
- package/src/templates/scripts/pretooluse-bash-guard.sh.hbs +146 -0
- package/src/templates/scripts/session-end.sh.hbs +48 -0
- package/src/templates/scripts/session-start.sh.hbs +139 -0
- package/src/templates/scripts/statusline.mjs +63 -0
- package/src/templates/scripts/structural-test-on-edit.sh.hbs +31 -8
- package/src/templates/scripts/telemetry-on-skill.sh +32 -10
- package/src/templates/scripts/userprompt-guard.sh.hbs +100 -0
- package/src/templates/.claude/hooks/hooks.json.hbs +0 -39
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"source": {
|
|
12
12
|
"source": "github",
|
|
13
13
|
"repo": "tuanle96/agent-harness-kit",
|
|
14
|
-
"ref": "v0.
|
|
14
|
+
"ref": "v0.8.0"
|
|
15
15
|
},
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.8.0",
|
|
17
17
|
"description": "Solo-dev harness engineering kit — layered architecture, GC ritual, structural tests, review subagents.",
|
|
18
18
|
"category": "development",
|
|
19
19
|
"keywords": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-harness-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Solo-dev harness engineering kit — layered architecture, garbage-collection ritual, structural tests, review subagents. Optimized for Claude Code 2.1+.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Tuan Le"
|
package/README.md
CHANGED
|
@@ -191,6 +191,35 @@ developer. The `eval-runner` skill enforces a per-run budget set in
|
|
|
191
191
|
| Flask | python | `flask` | `flask --app app run --debug` | v0.1 |
|
|
192
192
|
| Go / Rust | core only | none | (manual) | v0.4 |
|
|
193
193
|
|
|
194
|
+
## CI: real-claude E2E test (v0.7+)
|
|
195
|
+
|
|
196
|
+
The kit ships a CI job that spawns the real `claude` binary against a fresh
|
|
197
|
+
init of itself and asserts that the SessionStart hook actually fires (with
|
|
198
|
+
the expected `additionalContext` payload). This catches the class of bug
|
|
199
|
+
that v0.6's silent-no-op hooks fell into — every synthetic test passed for
|
|
200
|
+
seven releases while not a single hook ever triggered inside a real Claude
|
|
201
|
+
Code session.
|
|
202
|
+
|
|
203
|
+
**Behavior:**
|
|
204
|
+
|
|
205
|
+
- Locally: `npm test` skips the E2E case cleanly when no `ANTHROPIC_API_KEY`
|
|
206
|
+
is set (1 skip, 0 fail).
|
|
207
|
+
- CI with secret: the `e2e-claude` job runs the driver, installs
|
|
208
|
+
`@anthropic-ai/claude-code` globally, and exercises one claude turn
|
|
209
|
+
(~$0.01–0.05). Failure means a hook system regression.
|
|
210
|
+
- CI without secret (fork PRs): emits a GitHub Actions warning and exits 0
|
|
211
|
+
so the PR can still merge — but you lose the v0.6-class bug guard for
|
|
212
|
+
that run.
|
|
213
|
+
|
|
214
|
+
To enable on your own fork: configure the `ANTHROPIC_API_KEY` repository
|
|
215
|
+
secret in GitHub Actions settings.
|
|
216
|
+
|
|
217
|
+
Local run (uses whatever auth the `claude` binary already has):
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
AHK_E2E_USE_LOCAL_AUTH=1 node scripts/e2e-claude-cli.mjs
|
|
221
|
+
```
|
|
222
|
+
|
|
194
223
|
## License
|
|
195
224
|
|
|
196
225
|
MIT.
|
package/bin/cli.mjs
CHANGED
|
@@ -43,6 +43,15 @@ program
|
|
|
43
43
|
"force init for languages without a structural-test adapter yet (go, rust, swift, kotlin)",
|
|
44
44
|
false,
|
|
45
45
|
)
|
|
46
|
+
.option(
|
|
47
|
+
"--lang <code>",
|
|
48
|
+
"human language for the CLAUDE.md template (en, vi)",
|
|
49
|
+
"en",
|
|
50
|
+
)
|
|
51
|
+
.option(
|
|
52
|
+
"--model <id>",
|
|
53
|
+
"Claude model to pin in .claude/settings.json (e.g. claude-opus-4-7, claude-sonnet-4-6, claude-haiku-4-5)",
|
|
54
|
+
)
|
|
46
55
|
.action(async (opts) => {
|
|
47
56
|
const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
|
|
48
57
|
console.log(pc.bold(pc.cyan(`\nagent-harness-kit v${pkg.version}\n`)));
|
|
@@ -104,7 +113,10 @@ program
|
|
|
104
113
|
// initing at the root of a Go/Rust/Swift/Kotlin project produces a
|
|
105
114
|
// half-broken harness (structural test missing, ts-morph engine pinned).
|
|
106
115
|
// Skills + reviewers still work, but the user should opt in explicitly.
|
|
107
|
-
|
|
116
|
+
// Swift + Kotlin shipped regex-based adapters in v0.7; Go + Rust earlier.
|
|
117
|
+
// No language is "unsupported" today — the set is empty, kept as a hook
|
|
118
|
+
// for future languages that don't yet have an adapter.
|
|
119
|
+
const UNSUPPORTED = new Set([]);
|
|
108
120
|
if (UNSUPPORTED.has(stack.language) && !opts.allowUnsupported) {
|
|
109
121
|
console.log(
|
|
110
122
|
pc.yellow(
|
|
@@ -187,6 +199,8 @@ program
|
|
|
187
199
|
installHooks,
|
|
188
200
|
installCi,
|
|
189
201
|
kitVersion: pkg.version,
|
|
202
|
+
humanLanguage: opts.lang || "en",
|
|
203
|
+
model: opts.model,
|
|
190
204
|
});
|
|
191
205
|
|
|
192
206
|
console.log("");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-harness-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|
|
@@ -157,6 +157,22 @@ export async function detectStack(cwd) {
|
|
|
157
157
|
return result;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// Swift / Kotlin — regex-based adapters (added in v0.7).
|
|
161
|
+
if (await exists(resolve(cwd, "Package.swift"))) {
|
|
162
|
+
result.language = "swift";
|
|
163
|
+
result.packageManager = "swift";
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
(await exists(resolve(cwd, "build.gradle.kts"))) ||
|
|
168
|
+
(await exists(resolve(cwd, "settings.gradle.kts"))) ||
|
|
169
|
+
(await exists(resolve(cwd, "build.gradle")))
|
|
170
|
+
) {
|
|
171
|
+
result.language = "kotlin";
|
|
172
|
+
result.packageManager = "gradle";
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
160
176
|
return result;
|
|
161
177
|
}
|
|
162
178
|
|
package/src/core/doctor.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { readFile } from "node:fs/promises";
|
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
7
|
import { execSync } from "node:child_process";
|
|
8
8
|
import pc from "picocolors";
|
|
9
|
+
import { SUPPORTED_MODELS } from "./render-templates.mjs";
|
|
9
10
|
|
|
10
11
|
function check(name, ok, info = "") {
|
|
11
12
|
const mark = ok ? pc.green("✓") : pc.red("✗");
|
|
@@ -99,6 +100,28 @@ export async function doctor({ cwd, kitVersion }) {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
// 5. Model pin in .claude/settings.json (B4). Catches obvious typos
|
|
104
|
+
// that would silently no-op in Claude Code.
|
|
105
|
+
const settingsPath = resolve(cwd, ".claude/settings.json");
|
|
106
|
+
if (existsSync(settingsPath)) {
|
|
107
|
+
try {
|
|
108
|
+
const s = JSON.parse(await readFile(settingsPath, "utf8"));
|
|
109
|
+
if (typeof s.model === "string" && s.model.length > 0) {
|
|
110
|
+
if (SUPPORTED_MODELS.has(s.model)) {
|
|
111
|
+
check(`model pin (${s.model})`, true);
|
|
112
|
+
} else {
|
|
113
|
+
allOk = false;
|
|
114
|
+
console.log(
|
|
115
|
+
pc.red(` ✗ model pin in .claude/settings.json — "${s.model}" not recognized.`),
|
|
116
|
+
);
|
|
117
|
+
console.log(
|
|
118
|
+
pc.dim(` Known: ${[...SUPPORTED_MODELS].join(", ")}. Re-run \`agent-harness-kit init --model <id>\`.`),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch { /* covered by settings parse check elsewhere */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
console.log("");
|
|
103
126
|
if (!allOk) {
|
|
104
127
|
process.exit(1);
|
|
@@ -23,7 +23,7 @@ const TEMPLATES_ROOT = resolve(__dirname, "..", "templates");
|
|
|
23
23
|
// Files that the user is expected to edit — they win every time, even on
|
|
24
24
|
// fresh init we won't overwrite if they exist. This list is hard-coded
|
|
25
25
|
// because it's tiny and security-sensitive.
|
|
26
|
-
const USER_OWNED_FILES = new Set([
|
|
26
|
+
export const USER_OWNED_FILES = new Set([
|
|
27
27
|
"CLAUDE.md",
|
|
28
28
|
"AGENTS.md",
|
|
29
29
|
"docs/architecture.md",
|
|
@@ -35,7 +35,7 @@ const USER_OWNED_FILES = new Set([
|
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
37
|
// Paths that should be made executable after rendering.
|
|
38
|
-
const EXEC_BITS = new Set([
|
|
38
|
+
export const EXEC_BITS = new Set([
|
|
39
39
|
"scripts/dev-up.sh",
|
|
40
40
|
"scripts/pre-push.sh",
|
|
41
41
|
"scripts/install-git-hooks.sh",
|
|
@@ -43,6 +43,11 @@ const EXEC_BITS = new Set([
|
|
|
43
43
|
"scripts/precompletion-checklist.sh",
|
|
44
44
|
"scripts/telemetry-on-skill.sh",
|
|
45
45
|
"scripts/harness-report.mjs",
|
|
46
|
+
// v0.7 hook expansion — SessionStart / PreToolUse / PreCompact / SessionEnd.
|
|
47
|
+
"scripts/session-start.sh",
|
|
48
|
+
"scripts/pretooluse-bash-guard.sh",
|
|
49
|
+
"scripts/pre-compact.sh",
|
|
50
|
+
"scripts/session-end.sh",
|
|
46
51
|
]);
|
|
47
52
|
|
|
48
53
|
export function registerHelpers() {
|
|
@@ -86,11 +91,33 @@ async function* walk(dir) {
|
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
|
|
94
|
+
// SUPPORTED_LANGS — human-language variants the kit ships for CLAUDE.md.
|
|
95
|
+
// Adding a new locale: drop `CLAUDE.md.<code>.hbs` alongside the English
|
|
96
|
+
// CLAUDE.md.hbs, add the code here, and the picker in pathForStack()
|
|
97
|
+
// will route it. Default is "en" → uses the suffix-less CLAUDE.md.hbs.
|
|
98
|
+
export const SUPPORTED_HUMAN_LANGS = new Set(["en", "vi"]);
|
|
99
|
+
|
|
100
|
+
// SUPPORTED_MODELS — IDs the kit accepts via `--model` and writes verbatim
|
|
101
|
+
// into `.claude/settings.json#model`. The kit does NOT try to be a model
|
|
102
|
+
// registry — it just rejects obvious typos before they silently no-op in
|
|
103
|
+
// Claude Code (which falls back to the org default on unknown IDs).
|
|
104
|
+
export const SUPPORTED_MODELS = new Set([
|
|
105
|
+
"claude-opus-4-7",
|
|
106
|
+
"claude-sonnet-4-6",
|
|
107
|
+
"claude-haiku-4-5",
|
|
108
|
+
// Legacy IDs we still accept on upgrade paths.
|
|
109
|
+
"claude-sonnet-4-5",
|
|
110
|
+
"claude-haiku-3-5",
|
|
111
|
+
]);
|
|
112
|
+
export const DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
113
|
+
|
|
114
|
+
export function buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage, model }) {
|
|
90
115
|
const installCmd = (() => {
|
|
91
116
|
if (stack.language === "python") return "pip install -e '.[dev]'";
|
|
92
117
|
if (stack.language === "go") return "go mod download";
|
|
93
118
|
if (stack.language === "rust") return "cargo build";
|
|
119
|
+
if (stack.language === "swift") return "swift package resolve";
|
|
120
|
+
if (stack.language === "kotlin") return "./gradlew build --refresh-dependencies";
|
|
94
121
|
if (stack.packageManager === "pnpm") return "pnpm install";
|
|
95
122
|
if (stack.packageManager === "yarn") return "yarn";
|
|
96
123
|
if (stack.packageManager === "bun") return "bun install";
|
|
@@ -109,6 +136,8 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
|
109
136
|
if (stack.language === "python") return "python -m app";
|
|
110
137
|
if (stack.language === "go") return "go run ./cmd/...";
|
|
111
138
|
if (stack.language === "rust") return "cargo run";
|
|
139
|
+
if (stack.language === "swift") return "swift run";
|
|
140
|
+
if (stack.language === "kotlin") return "./gradlew run";
|
|
112
141
|
return "npm run dev";
|
|
113
142
|
}
|
|
114
143
|
})();
|
|
@@ -119,6 +148,8 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
|
119
148
|
if (stack.language === "python") return "pytest -x";
|
|
120
149
|
if (stack.language === "go") return "go test ./...";
|
|
121
150
|
if (stack.language === "rust") return "cargo test";
|
|
151
|
+
if (stack.language === "swift") return "swift test";
|
|
152
|
+
if (stack.language === "kotlin") return "./gradlew test";
|
|
122
153
|
return "npm test";
|
|
123
154
|
}
|
|
124
155
|
})();
|
|
@@ -126,6 +157,8 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
|
126
157
|
if (stack.language === "python") return "ruff check .";
|
|
127
158
|
if (stack.language === "go") return "go vet ./...";
|
|
128
159
|
if (stack.language === "rust") return "cargo clippy --all-targets -- -D warnings";
|
|
160
|
+
if (stack.language === "swift") return "swift build --warnings-as-errors";
|
|
161
|
+
if (stack.language === "kotlin") return "./gradlew detekt || ./gradlew lint";
|
|
129
162
|
return "npm run lint";
|
|
130
163
|
})();
|
|
131
164
|
|
|
@@ -144,10 +177,14 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
|
144
177
|
testCmd,
|
|
145
178
|
lintCmd,
|
|
146
179
|
kitVersion,
|
|
180
|
+
humanLanguage: humanLanguage || "en",
|
|
181
|
+
model: model || DEFAULT_MODEL,
|
|
147
182
|
isTypescript: stack.language === "typescript",
|
|
148
183
|
isPython: stack.language === "python",
|
|
149
184
|
isGo: stack.language === "go",
|
|
150
185
|
isRust: stack.language === "rust",
|
|
186
|
+
isSwift: stack.language === "swift",
|
|
187
|
+
isKotlin: stack.language === "kotlin",
|
|
151
188
|
isNextjs: stack.framework === "nextjs",
|
|
152
189
|
isFastapi: stack.framework === "fastapi",
|
|
153
190
|
isExpress: stack.framework === "express",
|
|
@@ -161,7 +198,18 @@ function buildContext({ projectName, preset, layers, stack, kitVersion }) {
|
|
|
161
198
|
// Decide whether a template path should be rendered for this stack/preset.
|
|
162
199
|
// Adapter-specific files live under templates/_adapter-<id>/ and are merged
|
|
163
200
|
// into the root.
|
|
164
|
-
function pathForStack(rel, stack) {
|
|
201
|
+
export function pathForStack(rel, stack, humanLanguage = "en") {
|
|
202
|
+
// CLAUDE.md locale routing. `CLAUDE.md.hbs` (no language suffix) is the
|
|
203
|
+
// English default. `CLAUDE.md.<lang>.hbs` is rendered into the same
|
|
204
|
+
// target path (`CLAUDE.md`) when the user picks that locale. The
|
|
205
|
+
// suffixed file is skipped otherwise — and so is the unsuffixed file
|
|
206
|
+
// when a non-default locale is active, so only one variant lands.
|
|
207
|
+
if (/^CLAUDE\.md(?:\.[a-z]{2,5})?\.hbs$/.test(rel)) {
|
|
208
|
+
const m = rel.match(/^CLAUDE\.md(?:\.([a-z]{2,5}))?\.hbs$/);
|
|
209
|
+
const fileLang = m[1] || "en";
|
|
210
|
+
if (fileLang !== humanLanguage) return null;
|
|
211
|
+
return "CLAUDE.md.hbs"; // canonical target — strip locale suffix
|
|
212
|
+
}
|
|
165
213
|
if (rel.startsWith("_adapter-typescript/")) {
|
|
166
214
|
const stripped = rel.slice("_adapter-typescript/".length);
|
|
167
215
|
if (stack.language === "typescript") return stripped;
|
|
@@ -184,6 +232,16 @@ function pathForStack(rel, stack) {
|
|
|
184
232
|
if (rel.startsWith("_adapter-rust/")) {
|
|
185
233
|
return stack.language === "rust" ? rel.slice("_adapter-rust/".length) : null;
|
|
186
234
|
}
|
|
235
|
+
if (rel.startsWith("_adapter-swift/")) {
|
|
236
|
+
const stripped = rel.slice("_adapter-swift/".length);
|
|
237
|
+
if (stack.language === "swift") return stripped;
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
if (rel.startsWith("_adapter-kotlin/")) {
|
|
241
|
+
const stripped = rel.slice("_adapter-kotlin/".length);
|
|
242
|
+
if (stack.language === "kotlin") return stripped;
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
187
245
|
if (rel.startsWith("_preset-nextjs/")) {
|
|
188
246
|
return stack.framework === "nextjs" ? rel.slice("_preset-nextjs/".length) : null;
|
|
189
247
|
}
|
|
@@ -200,6 +258,101 @@ function sha256(buf) {
|
|
|
200
258
|
return createHash("sha256").update(buf).digest("hex");
|
|
201
259
|
}
|
|
202
260
|
|
|
261
|
+
// Inject a statusLine block into .claude/settings.json. Idempotent: if the
|
|
262
|
+
// existing statusLine already references the kit's script, leave it; otherwise
|
|
263
|
+
// set it to invoke scripts/statusline.mjs via node. Doesn't clobber a
|
|
264
|
+
// user-customised type:"command" entry that points at a different command.
|
|
265
|
+
//
|
|
266
|
+
// Returns {changed, rawContent} for the lockfile bookkeeping (mirrors the
|
|
267
|
+
// mergeHooksIntoSettings contract).
|
|
268
|
+
export async function mergeStatusLineIntoSettings(cwd) {
|
|
269
|
+
const settingsPath = resolve(cwd, ".claude/settings.json");
|
|
270
|
+
const scriptPath = resolve(cwd, "scripts/statusline.mjs");
|
|
271
|
+
if (!existsSync(scriptPath)) return { changed: false, rawContent: "" };
|
|
272
|
+
let settings = {};
|
|
273
|
+
let raw = "";
|
|
274
|
+
if (existsSync(settingsPath)) {
|
|
275
|
+
raw = await readFile(settingsPath, "utf8");
|
|
276
|
+
try {
|
|
277
|
+
settings = JSON.parse(raw);
|
|
278
|
+
} catch {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`mergeStatusLineIntoSettings: ${settingsPath} is not valid JSON`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const desired = {
|
|
285
|
+
type: "command",
|
|
286
|
+
command: "node scripts/statusline.mjs",
|
|
287
|
+
};
|
|
288
|
+
// Preserve a user-customised entry if it already points elsewhere. We only
|
|
289
|
+
// inject when statusLine is absent OR explicitly references our script.
|
|
290
|
+
const cur = settings.statusLine;
|
|
291
|
+
if (
|
|
292
|
+
cur &&
|
|
293
|
+
typeof cur === "object" &&
|
|
294
|
+
cur.type === "command" &&
|
|
295
|
+
typeof cur.command === "string" &&
|
|
296
|
+
!/statusline\.mjs/.test(cur.command)
|
|
297
|
+
) {
|
|
298
|
+
return { changed: false, rawContent: Buffer.from(raw) };
|
|
299
|
+
}
|
|
300
|
+
if (
|
|
301
|
+
cur &&
|
|
302
|
+
typeof cur === "object" &&
|
|
303
|
+
cur.type === desired.type &&
|
|
304
|
+
cur.command === desired.command
|
|
305
|
+
) {
|
|
306
|
+
return { changed: false, rawContent: Buffer.from(raw) };
|
|
307
|
+
}
|
|
308
|
+
settings.statusLine = desired;
|
|
309
|
+
const out = JSON.stringify(settings, null, 2) + "\n";
|
|
310
|
+
await writeFile(settingsPath, out);
|
|
311
|
+
return { changed: true, rawContent: Buffer.from(out) };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Merge .claude/hooks/hooks.json#hooks into .claude/settings.json#hooks.
|
|
315
|
+
// Claude Code only reads hooks from settings.json — a free-standing
|
|
316
|
+
// hooks.json is ignored by the runtime. Kept as a file because the plugin
|
|
317
|
+
// install path references it via .claude-plugin/plugin.json. Idempotent:
|
|
318
|
+
// re-running produces the same settings.json bytes when source hasn't
|
|
319
|
+
// changed. Returns {changed, rawContent} for the lockfile.
|
|
320
|
+
export async function mergeHooksIntoSettings(cwd) {
|
|
321
|
+
const settingsPath = resolve(cwd, ".claude/settings.json");
|
|
322
|
+
const hooksPath = resolve(cwd, ".claude/hooks/hooks.json");
|
|
323
|
+
const hooksRaw = await readFile(hooksPath, "utf8");
|
|
324
|
+
let hooksJson;
|
|
325
|
+
try {
|
|
326
|
+
hooksJson = JSON.parse(hooksRaw);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
throw new Error(`mergeHooksIntoSettings: invalid JSON in ${hooksPath}: ${e.message}`);
|
|
329
|
+
}
|
|
330
|
+
if (!hooksJson.hooks || typeof hooksJson.hooks !== "object") {
|
|
331
|
+
return { changed: false, rawContent: "" };
|
|
332
|
+
}
|
|
333
|
+
let settings = {};
|
|
334
|
+
if (existsSync(settingsPath)) {
|
|
335
|
+
const raw = await readFile(settingsPath, "utf8");
|
|
336
|
+
try {
|
|
337
|
+
settings = JSON.parse(raw);
|
|
338
|
+
} catch {
|
|
339
|
+
// Corrupt settings.json — refuse to clobber user edits. Surface clearly.
|
|
340
|
+
throw new Error(`mergeHooksIntoSettings: ${settingsPath} is not valid JSON`);
|
|
341
|
+
}
|
|
342
|
+
// Idempotency: if hooks key already matches source verbatim, nothing to do.
|
|
343
|
+
if (
|
|
344
|
+
settings.hooks &&
|
|
345
|
+
JSON.stringify(settings.hooks) === JSON.stringify(hooksJson.hooks)
|
|
346
|
+
) {
|
|
347
|
+
return { changed: false, rawContent: Buffer.from(raw) };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
settings.hooks = hooksJson.hooks;
|
|
351
|
+
const out = JSON.stringify(settings, null, 2) + "\n";
|
|
352
|
+
await writeFile(settingsPath, out);
|
|
353
|
+
return { changed: true, rawContent: Buffer.from(out) };
|
|
354
|
+
}
|
|
355
|
+
|
|
203
356
|
export async function renderAll({
|
|
204
357
|
cwd,
|
|
205
358
|
projectName,
|
|
@@ -209,9 +362,21 @@ export async function renderAll({
|
|
|
209
362
|
installHooks,
|
|
210
363
|
installCi,
|
|
211
364
|
kitVersion,
|
|
365
|
+
humanLanguage = "en",
|
|
366
|
+
model,
|
|
212
367
|
}) {
|
|
213
368
|
registerHelpers();
|
|
214
|
-
|
|
369
|
+
if (!SUPPORTED_HUMAN_LANGS.has(humanLanguage)) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Unsupported humanLanguage "${humanLanguage}". Supported: ${[...SUPPORTED_HUMAN_LANGS].join(", ")}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (model && !SUPPORTED_MODELS.has(model)) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Unsupported model "${model}". Supported: ${[...SUPPORTED_MODELS].join(", ")}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
const ctx = buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage, model });
|
|
215
380
|
|
|
216
381
|
const written = [];
|
|
217
382
|
const skipped = [];
|
|
@@ -219,7 +384,7 @@ export async function renderAll({
|
|
|
219
384
|
|
|
220
385
|
for await (const abs of walk(TEMPLATES_ROOT)) {
|
|
221
386
|
const relFromTemplates = relative(TEMPLATES_ROOT, abs).split("\\").join("/");
|
|
222
|
-
const stackRel = pathForStack(relFromTemplates, stack);
|
|
387
|
+
const stackRel = pathForStack(relFromTemplates, stack, humanLanguage);
|
|
223
388
|
if (stackRel === null) continue;
|
|
224
389
|
if (relFromTemplates.startsWith("_ci/") && !installCi) continue;
|
|
225
390
|
if (relFromTemplates.includes("hooks.json") && !installHooks) continue;
|
|
@@ -258,6 +423,33 @@ export async function renderAll({
|
|
|
258
423
|
written.push(targetRel);
|
|
259
424
|
}
|
|
260
425
|
|
|
426
|
+
// Critical fix (v0.7): merge .claude/hooks/hooks.json into
|
|
427
|
+
// .claude/settings.json#hooks. Claude Code ONLY reads hooks from
|
|
428
|
+
// settings.json — a free-standing .claude/hooks/hooks.json is ignored.
|
|
429
|
+
// Pre-v0.7 the kit silently no-op'd every hook on the scaffold path
|
|
430
|
+
// because it shipped only the free-standing file. Plugin install path
|
|
431
|
+
// continues to read .claude/hooks/hooks.json (per plugin.json manifest)
|
|
432
|
+
// so the file is kept; we just additionally inject it where Claude Code
|
|
433
|
+
// actually looks.
|
|
434
|
+
if (installHooks && existsSync(resolve(cwd, ".claude/hooks/hooks.json"))) {
|
|
435
|
+
const merged = await mergeHooksIntoSettings(cwd);
|
|
436
|
+
if (merged.changed) {
|
|
437
|
+
lockfile.files[".claude/settings.json"] = sha256(merged.rawContent);
|
|
438
|
+
written.push(".claude/settings.json (merged hooks)");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// v0.8 — statusLine injection. Runs after hook merge so the lockfile
|
|
443
|
+
// captures the final settings.json bytes. Idempotent; never clobbers a
|
|
444
|
+
// user-customised entry that points elsewhere.
|
|
445
|
+
if (existsSync(resolve(cwd, "scripts/statusline.mjs"))) {
|
|
446
|
+
const stat = await mergeStatusLineIntoSettings(cwd);
|
|
447
|
+
if (stat.changed) {
|
|
448
|
+
lockfile.files[".claude/settings.json"] = sha256(stat.rawContent);
|
|
449
|
+
written.push(".claude/settings.json (statusLine)");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
261
453
|
// Write the lockfile last (after we know the full set of files).
|
|
262
454
|
const lockTarget = resolve(cwd, ".harness/installed.json");
|
|
263
455
|
await mkdir(dirname(lockTarget), { recursive: true });
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/claude-code-hooks.json",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"SessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "startup|resume|compact",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "bash scripts/session-start.sh",
|
|
11
|
+
"timeout": 10
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"UserPromptSubmit": [
|
|
17
|
+
{
|
|
18
|
+
"matcher": "",
|
|
19
|
+
"hooks": [
|
|
20
|
+
{
|
|
21
|
+
"type": "command",
|
|
22
|
+
"command": "bash scripts/userprompt-guard.sh",
|
|
23
|
+
"timeout": 5
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"PreToolUse": [
|
|
29
|
+
{
|
|
30
|
+
"matcher": "Bash",
|
|
31
|
+
"hooks": [
|
|
32
|
+
{
|
|
33
|
+
"type": "command",
|
|
34
|
+
"command": "bash scripts/pretooluse-bash-guard.sh",
|
|
35
|
+
"timeout": 5
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"Notification": [
|
|
41
|
+
{
|
|
42
|
+
"matcher": "",
|
|
43
|
+
"hooks": [
|
|
44
|
+
{
|
|
45
|
+
"type": "command",
|
|
46
|
+
"command": "bash scripts/notify-on-block.sh",
|
|
47
|
+
"timeout": 5
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"PostToolUse": [
|
|
53
|
+
{
|
|
54
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
55
|
+
"hooks": [
|
|
56
|
+
{
|
|
57
|
+
"type": "command",
|
|
58
|
+
"command": "bash scripts/structural-test-on-edit.sh",
|
|
59
|
+
"timeout": 30
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"matcher": "Skill",
|
|
65
|
+
"hooks": [
|
|
66
|
+
{
|
|
67
|
+
"type": "command",
|
|
68
|
+
"command": "bash scripts/telemetry-on-skill.sh",
|
|
69
|
+
"timeout": 5
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"PreCompact": [
|
|
75
|
+
{
|
|
76
|
+
"matcher": "",
|
|
77
|
+
"hooks": [
|
|
78
|
+
{
|
|
79
|
+
"type": "command",
|
|
80
|
+
"command": "bash scripts/pre-compact.sh",
|
|
81
|
+
"timeout": 5
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"Stop": [
|
|
87
|
+
{
|
|
88
|
+
"matcher": "",
|
|
89
|
+
"hooks": [
|
|
90
|
+
{
|
|
91
|
+
"type": "command",
|
|
92
|
+
"command": "bash scripts/precompletion-checklist.sh",
|
|
93
|
+
"timeout": 20
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
"SessionEnd": [
|
|
99
|
+
{
|
|
100
|
+
"matcher": "",
|
|
101
|
+
"hooks": [
|
|
102
|
+
{
|
|
103
|
+
"type": "command",
|
|
104
|
+
"command": "bash scripts/session-end.sh",
|
|
105
|
+
"timeout": 5
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: doc-drift-scan
|
|
3
3
|
description: Use this skill weekly, before releases, or when the user mentions "stale docs", "doc drift", "docs are wrong", or "the README is out of date". Cross-checks every code path, file path, and command referenced in `docs/` and `CLAUDE.md` against the current repo state and produces a list of stale references — the doc-gardening agent pattern.
|
|
4
|
-
allowed-tools: Read, Glob, Grep, Bash(test -e:*), Bash(command -v:*)
|
|
5
|
-
suggested-turns:
|
|
4
|
+
allowed-tools: Read, Glob, Grep, Bash(test -e:*), Bash(command -v:*), Bash(node .claude/skills/doc-drift-scan/scripts/scan-paths.mjs:*)
|
|
5
|
+
suggested-turns: 8
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## Steps
|
|
9
9
|
|
|
10
|
-
1. **Extract references
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
1. **Extract references + validate (deterministic).** Run the side-car
|
|
11
|
+
script — walks `docs/**/*.md` + `CLAUDE.md`, extracts every backtick-path
|
|
12
|
+
containing a slash, checks `existsSync` per ref:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
node .claude/skills/doc-drift-scan/scripts/scan-paths.mjs
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Read the JSON: `{ stats: { docs_scanned, refs_found, refs_missing },
|
|
19
|
+
drift: [{ doc, ref }] }`. Replaces three LLM grep turns.
|
|
20
|
+
2. **Validate commands (LLM judgment, narrow).** Optional second pass for
|
|
21
|
+
backtick-commands the side-car doesn't classify (no slash → not a path).
|
|
22
|
+
Use `command -v <cmd>` and allow a small allowlist (`jq`, `gh`, `rg`).
|
|
18
23
|
3. **Group findings.**
|
|
19
24
|
- `missing-paths`: file moved or deleted.
|
|
20
25
|
- `wrong-layer-claim`: doc says module is in layer X, structural test
|