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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +29 -0
  4. package/bin/cli.mjs +15 -1
  5. package/package.json +1 -1
  6. package/src/core/detect-stack.mjs +16 -0
  7. package/src/core/doctor.mjs +23 -0
  8. package/src/core/render-templates.mjs +198 -6
  9. package/src/templates/.claude/hooks/hooks.json +111 -0
  10. package/src/templates/.claude/settings.json.hbs +1 -1
  11. package/src/templates/.claude/skills/doc-drift-scan/SKILL.md +15 -10
  12. package/src/templates/.claude/skills/doc-drift-scan/scripts/scan-paths.mjs +64 -0
  13. package/src/templates/.claude/skills/garbage-collection/SKILL.md.hbs +14 -5
  14. package/src/templates/.claude/skills/garbage-collection/scripts/gc-classify.mjs +77 -0
  15. package/src/templates/.claude/skills/inspect-module/SKILL.md.hbs +17 -14
  16. package/src/templates/.claude/skills/inspect-module/scripts/module-summary.mjs +144 -0
  17. package/src/templates/CLAUDE.md.hbs +10 -6
  18. package/src/templates/CLAUDE.md.vi.hbs +74 -0
  19. package/src/templates/_adapter-kotlin/harness/structural-check.mjs.hbs +286 -0
  20. package/src/templates/_adapter-rust/harness/structural-check.mjs.hbs +292 -100
  21. package/src/templates/_adapter-swift/harness/structural-check.mjs.hbs +285 -0
  22. package/src/templates/harness.config.json.hbs +5 -3
  23. package/src/templates/scripts/_lib/approx-tokens.mjs +48 -0
  24. package/src/templates/scripts/_lib/json-pick.mjs +278 -0
  25. package/src/templates/scripts/harness-report.mjs +95 -1
  26. package/src/templates/scripts/notify-on-block.sh.hbs +73 -0
  27. package/src/templates/scripts/pre-compact.sh.hbs +121 -0
  28. package/src/templates/scripts/pre-push.sh +28 -3
  29. package/src/templates/scripts/precompletion-checklist.sh.hbs +131 -22
  30. package/src/templates/scripts/pretooluse-bash-guard.sh.hbs +146 -0
  31. package/src/templates/scripts/session-end.sh.hbs +48 -0
  32. package/src/templates/scripts/session-start.sh.hbs +139 -0
  33. package/src/templates/scripts/statusline.mjs +63 -0
  34. package/src/templates/scripts/structural-test-on-edit.sh.hbs +31 -8
  35. package/src/templates/scripts/telemetry-on-skill.sh +32 -10
  36. package/src/templates/scripts/userprompt-guard.sh.hbs +100 -0
  37. 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.6.0"
14
+ "ref": "v0.8.0"
15
15
  },
16
- "version": "0.6.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.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
- const UNSUPPORTED = new Set(["go", "rust", "swift", "kotlin"]);
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.6.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
 
@@ -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
- function buildContext({ projectName, preset, layers, stack, kitVersion }) {
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
- const ctx = buildContext({ projectName, preset, layers, stack, kitVersion });
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
+ }
@@ -21,7 +21,7 @@
21
21
  "Bash(command -v:*)"
22
22
  ]
23
23
  },
24
- "model": "{{#if isPython}}claude-sonnet-4-6{{else}}claude-sonnet-4-6{{/if}}",
24
+ "model": "{{model}}",
25
25
  "env": {
26
26
  "AGENT_HARNESS_KIT_VERSION": "{{kitVersion}}"
27
27
  }
@@ -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: 12
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.** From `docs/**/*.md` and `CLAUDE.md`, extract:
11
- - Backtick paths matching `[a-z0-9_/.\\-]+\\.(md|ts|tsx|js|py|json|yml|yaml|sh)`
12
- - Backtick commands (first token after a backtick).
13
- - Markdown links to local files (`./...` or `../...`).
14
- 2. **Validate each.**
15
- - Paths: `test -e <path>`. If missing, mark as drift.
16
- - Commands: `command -v <cmd>`. If not on PATH, mark as drift (but allow
17
- a configured allowlist for known optional tools).
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