agent-harness-kit 0.7.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.
@@ -11,9 +11,9 @@
11
11
  "source": {
12
12
  "source": "github",
13
13
  "repo": "tuanle96/agent-harness-kit",
14
- "ref": "v0.7.0"
14
+ "ref": "v0.8.0"
15
15
  },
16
- "version": "0.7.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.7.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/bin/cli.mjs CHANGED
@@ -48,6 +48,10 @@ program
48
48
  "human language for the CLAUDE.md template (en, vi)",
49
49
  "en",
50
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
+ )
51
55
  .action(async (opts) => {
52
56
  const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
53
57
  console.log(pc.bold(pc.cyan(`\nagent-harness-kit v${pkg.version}\n`)));
@@ -196,6 +200,7 @@ program
196
200
  installCi,
197
201
  kitVersion: pkg.version,
198
202
  humanLanguage: opts.lang || "en",
203
+ model: opts.model,
199
204
  });
200
205
 
201
206
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-harness-kit",
3
- "version": "0.7.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": {
@@ -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",
@@ -97,7 +97,21 @@ async function* walk(dir) {
97
97
  // will route it. Default is "en" → uses the suffix-less CLAUDE.md.hbs.
98
98
  export const SUPPORTED_HUMAN_LANGS = new Set(["en", "vi"]);
99
99
 
100
- function buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage }) {
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 }) {
101
115
  const installCmd = (() => {
102
116
  if (stack.language === "python") return "pip install -e '.[dev]'";
103
117
  if (stack.language === "go") return "go mod download";
@@ -164,6 +178,7 @@ function buildContext({ projectName, preset, layers, stack, kitVersion, humanLan
164
178
  lintCmd,
165
179
  kitVersion,
166
180
  humanLanguage: humanLanguage || "en",
181
+ model: model || DEFAULT_MODEL,
167
182
  isTypescript: stack.language === "typescript",
168
183
  isPython: stack.language === "python",
169
184
  isGo: stack.language === "go",
@@ -183,7 +198,7 @@ function buildContext({ projectName, preset, layers, stack, kitVersion, humanLan
183
198
  // Decide whether a template path should be rendered for this stack/preset.
184
199
  // Adapter-specific files live under templates/_adapter-<id>/ and are merged
185
200
  // into the root.
186
- function pathForStack(rel, stack, humanLanguage = "en") {
201
+ export function pathForStack(rel, stack, humanLanguage = "en") {
187
202
  // CLAUDE.md locale routing. `CLAUDE.md.hbs` (no language suffix) is the
188
203
  // English default. `CLAUDE.md.<lang>.hbs` is rendered into the same
189
204
  // target path (`CLAUDE.md`) when the user picks that locale. The
@@ -243,6 +258,59 @@ function sha256(buf) {
243
258
  return createHash("sha256").update(buf).digest("hex");
244
259
  }
245
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
+
246
314
  // Merge .claude/hooks/hooks.json#hooks into .claude/settings.json#hooks.
247
315
  // Claude Code only reads hooks from settings.json — a free-standing
248
316
  // hooks.json is ignored by the runtime. Kept as a file because the plugin
@@ -295,6 +363,7 @@ export async function renderAll({
295
363
  installCi,
296
364
  kitVersion,
297
365
  humanLanguage = "en",
366
+ model,
298
367
  }) {
299
368
  registerHelpers();
300
369
  if (!SUPPORTED_HUMAN_LANGS.has(humanLanguage)) {
@@ -302,7 +371,12 @@ export async function renderAll({
302
371
  `Unsupported humanLanguage "${humanLanguage}". Supported: ${[...SUPPORTED_HUMAN_LANGS].join(", ")}`,
303
372
  );
304
373
  }
305
- const ctx = buildContext({ projectName, preset, layers, stack, kitVersion, humanLanguage });
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 });
306
380
 
307
381
  const written = [];
308
382
  const skipped = [];
@@ -365,6 +439,17 @@ export async function renderAll({
365
439
  }
366
440
  }
367
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
+
368
453
  // Write the lockfile last (after we know the full set of files).
369
454
  const lockTarget = resolve(cwd, ".harness/installed.json");
370
455
  await mkdir(dirname(lockTarget), { recursive: true });
@@ -13,6 +13,18 @@
13
13
  ]
14
14
  }
15
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
+ ],
16
28
  "PreToolUse": [
17
29
  {
18
30
  "matcher": "Bash",
@@ -25,6 +37,18 @@
25
37
  ]
26
38
  }
27
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
+ ],
28
52
  "PostToolUse": [
29
53
  {
30
54
  "matcher": "Write|Edit|MultiEdit",
@@ -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
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // scan-paths.mjs — deterministic step for /doc-drift-scan.
3
+ // Walks docs/ + CLAUDE.md, extracts backtick paths, checks existsSync.
4
+ // Output JSON: { stats, drift }.
5
+
6
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
7
+ import { join, relative, resolve } from "node:path";
8
+
9
+ const ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
10
+ const PATH_IN_BACKTICKS = /`([^`\s][^`]*?)`/g;
11
+ const PATH_LIKE = /^[^|&;$][\w./@-]+\/[\w./@-]+/;
12
+
13
+ function walkText() {
14
+ const out = [];
15
+ if (existsSync(join(ROOT, "CLAUDE.md"))) out.push(join(ROOT, "CLAUDE.md"));
16
+ if (existsSync(join(ROOT, "docs"))) {
17
+ for (const f of walkRecursive(join(ROOT, "docs"))) {
18
+ if (/\.(md|markdown|mdx)$/i.test(f)) out.push(f);
19
+ }
20
+ }
21
+ return out;
22
+ }
23
+ function* walkRecursive(d) {
24
+ for (const e of readdirSync(d, { withFileTypes: true })) {
25
+ const p = join(d, e.name);
26
+ if (e.isDirectory()) yield* walkRecursive(p);
27
+ else yield p;
28
+ }
29
+ }
30
+ function extractPaths(body) {
31
+ const found = new Set();
32
+ let m;
33
+ while ((m = PATH_IN_BACKTICKS.exec(body)) !== null) {
34
+ const candidate = m[1].trim();
35
+ if (!PATH_LIKE.test(candidate)) continue;
36
+ if (/^https?:\/\//.test(candidate)) continue;
37
+ found.add(candidate);
38
+ }
39
+ return [...found];
40
+ }
41
+ function fileExistsRelative(p) {
42
+ const clean = p.replace(/:\d+(-\d+)?$/, "");
43
+ return existsSync(resolve(ROOT, clean));
44
+ }
45
+
46
+ function main() {
47
+ const files = walkText();
48
+ const drift = [];
49
+ const stats = { docs_scanned: files.length, refs_found: 0, refs_missing: 0 };
50
+ for (const doc of files) {
51
+ let body;
52
+ try { body = readFileSync(doc, "utf8"); } catch { continue; }
53
+ for (const ref of extractPaths(body)) {
54
+ stats.refs_found++;
55
+ if (!fileExistsRelative(ref)) {
56
+ stats.refs_missing++;
57
+ drift.push({ doc: relative(ROOT, doc), ref });
58
+ }
59
+ }
60
+ }
61
+ process.stdout.write(JSON.stringify({ stats, drift }, null, 2) + "\n");
62
+ }
63
+
64
+ main();
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: garbage-collection
3
3
  description: Use this skill on Fridays, before tagging a release, or when the user mentions "cleanup", "tech debt", "AI slop", "GC", or "garbage collection". Runs the deterministic linters, structural tests, and doc-drift scans, then proposes the top-3 highest-leverage cleanups (with risk/cost/benefit) — does NOT auto-merge. This is the solo-dev shrunk version of OpenAI's Friday garbage-collection ritual.
4
- allowed-tools: Read, Glob, Grep, Bash(npm run:*), Bash(pytest:*), Bash(ruff:*), Bash(git:*), Bash(gh:*)
4
+ allowed-tools: Read, Glob, Grep, Bash(npm run:*), Bash(pytest:*), Bash(ruff:*), Bash(git:*), Bash(gh:*), Bash(node .claude/skills/garbage-collection/scripts/gc-classify.mjs:*)
5
5
  suggested-turns: 15
6
6
  ---
7
7
 
@@ -19,10 +19,19 @@ suggested-turns: 15
19
19
  - **Doc drift** (a path in `docs/architecture.md` no longer exists) →
20
20
  invoke `doc-drift-scan` skill.
21
21
  - **Hand-rolled helper** matching a shared utility → propose replacement.
22
- 3. **Score** each candidate fix on three 1–5 dimensions:
23
- - **Risk**: 1 = trivial rename, 5 = touches multiple modules.
24
- - **Cost**: tokens + minutes.
25
- - **Benefit**: how often this drift recurs (read `.harness/gc-history.json`).
22
+ 3. **Score** each candidate fix on three 1–5 dimensions via the side-car
23
+ script (replaces the previous LLM-scored turn deterministic and
24
+ auditable):
25
+
26
+ ```bash
27
+ node .claude/skills/garbage-collection/scripts/gc-classify.mjs \
28
+ --baseline .harness/gc-<date>.json \
29
+ --history .harness/gc-history.json
30
+ ```
31
+
32
+ The script applies the mechanical rubric: `risk = 1 + ceil(touched/3)`,
33
+ `cost = 1 + ceil(lines/30)`, `benefit = recurrenceCount(class)`. Read
34
+ the JSON `candidates[]` sorted by `(benefit desc, cost asc, risk asc)`.
26
35
  4. **Propose ONLY the top 3** cleanups (solo-dev cap; OpenAI does dozens, you
27
36
  do 3). Open them as separate PRs with `gh pr create --label gc --draft`.
28
37
  5. **Append a row** to `.harness/gc-history.json`:
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // gc-classify.mjs — deterministic scoring step for /garbage-collection.
3
+ // Replaces "LLM-scored risk/cost/benefit" turn with mechanical rubric.
4
+ //
5
+ // Usage:
6
+ // gc-classify.mjs --baseline <gc-snapshot.json> [--history <hist.json>] [--out <file>]
7
+ //
8
+ // Rubric:
9
+ // risk = 1 + ceil(touched_files / 3) capped at 5
10
+ // cost = 1 + ceil(lines_to_change / 30) capped at 5
11
+ // benefit = recurrenceCount(class) from gc-history capped at 5
12
+
13
+ import { readFileSync, existsSync, writeFileSync } from "node:fs";
14
+ import { resolve } from "node:path";
15
+
16
+ function loadJSON(p, fallback = null) {
17
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return fallback; }
18
+ }
19
+
20
+ function parseArgs(argv) {
21
+ const out = { baseline: null, history: ".harness/gc-history.json", out: null };
22
+ for (let i = 0; i < argv.length; i++) {
23
+ if (argv[i] === "--baseline") out.baseline = argv[++i];
24
+ else if (argv[i] === "--history") out.history = argv[++i];
25
+ else if (argv[i] === "--out") out.out = argv[++i];
26
+ }
27
+ if (!out.baseline) {
28
+ console.error("usage: gc-classify.mjs --baseline <gc-snapshot.json> [--history <hist.json>] [--out <file>]");
29
+ process.exit(2);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function recurrenceCount(history, klass) {
35
+ if (!history?.runs) return 1;
36
+ let n = 0;
37
+ for (const run of history.runs) {
38
+ if (Array.isArray(run.classes_seen) && run.classes_seen.includes(klass)) n++;
39
+ }
40
+ return n;
41
+ }
42
+
43
+ function cap5(n) { return Math.max(1, Math.min(5, n)); }
44
+
45
+ function classify(baseline, history) {
46
+ const violations = Array.isArray(baseline?.violations) ? baseline.violations : [];
47
+ return violations.map((v) => {
48
+ const touched = Number(v.files_touched) || 1;
49
+ const lines = Number(v.lines_estimate) || 5;
50
+ return {
51
+ class: v.class || "unknown",
52
+ path: v.path || "(unspecified)",
53
+ summary: v.summary || `${v.class} at ${v.path || "(unspecified)"}`,
54
+ risk: cap5(1 + Math.ceil(touched / 3)),
55
+ cost: cap5(1 + Math.ceil(lines / 30)),
56
+ benefit: cap5(recurrenceCount(history, v.class)),
57
+ };
58
+ });
59
+ }
60
+
61
+ function main() {
62
+ const { baseline, history: histPath, out } = parseArgs(process.argv.slice(2));
63
+ const base = loadJSON(resolve(baseline));
64
+ if (!base) {
65
+ console.error(`gc-classify: cannot read baseline at ${baseline}`);
66
+ process.exit(2);
67
+ }
68
+ const hist = existsSync(resolve(histPath)) ? loadJSON(resolve(histPath), { runs: [] }) : { runs: [] };
69
+ const scored = classify(base, hist);
70
+ scored.sort((a, b) => b.benefit - a.benefit || a.cost - b.cost || a.risk - b.risk);
71
+ const payload = { total: scored.length, candidates: scored };
72
+ const text = JSON.stringify(payload, null, 2);
73
+ if (out) writeFileSync(resolve(out), text + "\n");
74
+ else process.stdout.write(text + "\n");
75
+ }
76
+
77
+ main();
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: inspect-module
3
3
  description: Use this skill whenever the user mentions "explore", "inspect", "understand", "what does X do", "where is Y", or before adding a new feature in an unfamiliar area. Produces a structured map of one module — files, exports, dependencies, layer assignment, and recent commits — without reading the entire codebase. Always invoke this skill before editing an unfamiliar module so the agent has accurate context, not guesses.
4
- allowed-tools: Read, Glob, Grep, Bash(git log:*), Bash(git ls-tree:*), Bash(tree:*)
4
+ allowed-tools: Read, Glob, Grep, Bash(git log:*), Bash(git ls-tree:*), Bash(tree:*), Bash(node .claude/skills/inspect-module/scripts/module-summary.mjs:*)
5
5
  suggested-turns: 6
6
6
  ---
7
7
 
@@ -15,19 +15,22 @@ feature Y, what's in the area?", "explore <path>", "show me the shape of
15
15
 
16
16
  1. **Resolve the target.** If the user gave a feature name (not a path), grep
17
17
  `feature_list.json` for it. If multiple paths match, ask the user which.
18
- 2. **Recent activity.** Run `git log --oneline -20 -- <target>` and read the
19
- top 3 commit messages.
20
- 3. **Layer.** Cross-reference the path against `harness.config.json` →
21
- `domains[].layers` to determine the layer.
22
- 4. **Public surface.** List exported symbols:
23
- - TypeScript: `grep -nE "^export " <target>/**/*.ts`
24
- - Python: `grep -nE "^def |^class |^[A-Z_][A-Z0-9_]+ ?=" <target>/**/*.py`
25
- 5. **Dependencies.** List inbound imports (who depends on this module) and
26
- outbound imports (what this module depends on). Verify both respect the
27
- forward-only layer order; if not, **stop and report the violation** before
28
- proceeding with any change plan.
29
- 6. **Risks.** Flag any of: dynamic imports, eval, shell-out with
30
- interpolation, missing tests for an exported function.
18
+ 2. **One-shot summary (deterministic).** Run the side-car script bundles
19
+ exports + inbound + outbound deps + layer + recent commits into one JSON
20
+ blob, replacing three LLM turns of grep:
21
+
22
+ ```bash
23
+ node .claude/skills/inspect-module/scripts/module-summary.mjs <target>
24
+ ```
25
+
26
+ Read the JSON. If `layer` is `null`, the file is outside any configured
27
+ layer root flag that and ask whether the user wants to add it.
28
+ 3. **Forward-only check.** Walk `outbound[]` and verify each crosses layers
29
+ forward only (never backward). The structural test enforces this
30
+ mechanically too, but flagging here short-circuits a wasted write step.
31
+ 4. **Risks.** Flag any of: dynamic imports, eval, shell-out with
32
+ interpolation, missing tests for an exported function. (LLM judgment —
33
+ the side-car reports facts, not risks.)
31
34
 
32
35
  ## Output contract
33
36
 
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // module-summary.mjs — deterministic step for /inspect-module.
3
+ // Bundles exports + outbound + inbound + layer + recent commits in JSON.
4
+ // Prefer ripgrep, fallback grep -rE.
5
+
6
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
7
+ import { resolve, relative, join } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ const ROOT = process.env.CLAUDE_PROJECT_DIR || process.cwd();
11
+
12
+ function bail(msg) {
13
+ console.error("module-summary: " + msg);
14
+ process.exit(2);
15
+ }
16
+
17
+ // Walk a path (file or directory) and yield matching source files. Skip
18
+ // node_modules, .git, dist, build — folders that contain mountains of
19
+ // irrelevant exports and blow up the result set.
20
+ const SOURCE_EXTS = /\.(ts|tsx|js|jsx|mjs|cjs|py|rs|go|swift|kt|kts)$/i;
21
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".harness", "dist", "build", "target", ".next"]);
22
+
23
+ function* walkSources(absPath) {
24
+ let st;
25
+ try { st = statSync(absPath); } catch { return; }
26
+ if (st.isFile()) {
27
+ if (SOURCE_EXTS.test(absPath)) yield absPath;
28
+ return;
29
+ }
30
+ if (!st.isDirectory()) return;
31
+ for (const entry of readdirSync(absPath, { withFileTypes: true })) {
32
+ if (entry.name.startsWith(".") && entry.name !== "." && entry.name !== "..") {
33
+ if (SKIP_DIRS.has(entry.name)) continue;
34
+ }
35
+ if (SKIP_DIRS.has(entry.name)) continue;
36
+ yield* walkSources(join(absPath, entry.name));
37
+ }
38
+ }
39
+
40
+ // scan: read each file line-by-line, run the regex, collect matches with
41
+ // per-line annotation `path:line: content`. Pure Node — no external grep
42
+ // dependency, so the script works the same on macOS local, Linux CI,
43
+ // minimal Alpine, etc. (Previous shell-out to grep failed on CI with an
44
+ // empty result set; root cause: spawn-time differences between BSD and
45
+ // GNU grep when the target argument is a single file. Node fs is the
46
+ // portable answer.)
47
+ function scan(target, regex) {
48
+ const lines = [];
49
+ const absTarget = resolve(ROOT, target);
50
+ for (const file of walkSources(absTarget)) {
51
+ let body;
52
+ try { body = readFileSync(file, "utf8"); } catch { continue; }
53
+ const rel = relative(ROOT, file);
54
+ const fileLines = body.split("\n");
55
+ for (let i = 0; i < fileLines.length; i++) {
56
+ if (regex.test(fileLines[i])) {
57
+ lines.push(`${rel}:${i + 1}: ${fileLines[i]}`);
58
+ }
59
+ }
60
+ }
61
+ return lines;
62
+ }
63
+
64
+ function listExports(target) {
65
+ const out = new Set();
66
+ for (const line of scan(target, /^export /)) {
67
+ const m = line.match(/^([^:]+):(\d+):\s*export\s+(.*)$/);
68
+ if (m) out.add(`${m[3].slice(0, 80)} (${m[1]}:${m[2]})`);
69
+ }
70
+ for (const line of scan(target, /^(def |class )/)) {
71
+ const m = line.match(/^([^:]+):(\d+):\s*(def|class)\s+(\w+)/);
72
+ if (m) out.add(`${m[3]} ${m[4]} (${m[1]}:${m[2]})`);
73
+ }
74
+ return [...out].slice(0, 50);
75
+ }
76
+
77
+ function outboundDeps(target) {
78
+ const out = new Set();
79
+ for (const line of scan(target, /^(import |from |use crate)/)) {
80
+ const m = line.match(/^[^:]+:\d+:\s*(.+)$/);
81
+ if (m) out.add(m[1].trim().slice(0, 100));
82
+ }
83
+ return [...out].slice(0, 50);
84
+ }
85
+
86
+ function inboundDeps(target) {
87
+ const relTarget = relative(ROOT, resolve(ROOT, target));
88
+ const name = relTarget.split("/").pop().replace(/\.[a-z]+$/i, "");
89
+ if (!name) return [];
90
+ const seen = new Set();
91
+ // Search the whole project root for references back to the target
92
+ // module. Filter out self-references.
93
+ const re = new RegExp(`(import|from|require\\().*['"][^'"]*${name.replace(/[.*+?^${}()|[\\\]\\\\]/g, "\\\\$&")}`);
94
+ for (const line of scan(".", re)) {
95
+ const m = line.match(/^([^:]+):\d+:/);
96
+ if (m && m[1] !== relTarget && !m[1].endsWith(`/${relTarget}`)) seen.add(m[1]);
97
+ }
98
+ return [...seen].slice(0, 30);
99
+ }
100
+
101
+ function readLayers() {
102
+ try { return JSON.parse(readFileSync(resolve(ROOT, "harness.config.json"), "utf8")); }
103
+ catch { return null; }
104
+ }
105
+
106
+ function whichLayer(target, cfg) {
107
+ if (!cfg?.domains) return null;
108
+ const rel = relative(ROOT, resolve(ROOT, target));
109
+ for (const d of cfg.domains) {
110
+ if (!d?.layers || !d.root) continue;
111
+ for (const layer of d.layers) {
112
+ const prefix = `${d.root}/${layer}/`;
113
+ if (rel.startsWith(prefix) || rel === `${d.root}/${layer}`) {
114
+ return { domain: d.name || "default", layer };
115
+ }
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ function recentCommits(target) {
122
+ const r = spawnSync("git", ["log", "--oneline", "-5", "--", target], { cwd: ROOT, encoding: "utf8" });
123
+ if (r.status !== 0) return [];
124
+ return (r.stdout || "").split("\n").filter(Boolean);
125
+ }
126
+
127
+ function main() {
128
+ const target = process.argv[2];
129
+ if (!target) bail("missing target path argument");
130
+ const abs = resolve(ROOT, target);
131
+ if (!existsSync(abs)) bail(`target not found: ${target}`);
132
+ const cfg = readLayers();
133
+ const out = {
134
+ module: relative(ROOT, abs),
135
+ layer: whichLayer(target, cfg),
136
+ exports: listExports(target),
137
+ outbound: outboundDeps(target),
138
+ inbound: inboundDeps(target),
139
+ recent: recentCommits(target),
140
+ };
141
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
142
+ }
143
+
144
+ main();
@@ -31,11 +31,15 @@ Full list: `docs/golden-principles.md`.
31
31
 
32
32
  ## Where to look (read on demand)
33
33
 
34
- - `docs/architecture.md` — read when adding a new module or moving code.
35
- - `docs/adr/` — read when changing public APIs.
36
- - `docs/golden-principles.md` — read before any refactor.
37
- - `feature_list.json` — read before claiming a feature is done.
38
- - `.harness/PROGRESS.md` read at session start; write at session end.
34
+ The lines below use Claude Code 2.1+ `@`-imports Claude loads the file
35
+ into context only when this section is referenced, keeping the working
36
+ CLAUDE.md tiny.
37
+
38
+ - @docs/architecture.md when adding a new module or moving code.
39
+ - @docs/adr/ — when changing public APIs.
40
+ - @docs/golden-principles.md — before any refactor.
41
+ - @feature_list.json — before claiming a feature is done.
42
+ - `.harness/PROGRESS.md` — read at session start; append at session end (kit-managed, not @-imported).
39
43
 
40
44
  ## Skills you should use
41
45
 
@@ -30,11 +30,15 @@ Danh sách đầy đủ: `docs/golden-principles.md`.
30
30
 
31
31
  ## Đọc khi cần (read on demand)
32
32
 
33
- - `docs/architecture.md` — đọc khi thêm module hoặc dời code.
34
- - `docs/adr/` — đọc khi đổi public API.
35
- - `docs/golden-principles.md` — đọc trước mọi refactor.
36
- - `feature_list.json` — đọc trước khi tuyên bố một feature đã xong.
37
- - `.harness/PROGRESS.md` đọc đầu session; ghi cuối session.
33
+ Các dòng dưới dùng pháp `@`-import của Claude Code 2.1+ — Claude chỉ
34
+ nạp file vào context khi section này được tham chiếu, giữ CLAUDE.md
35
+ luôn gọn.
36
+
37
+ - @docs/architecture.md khi thêm module hoặc dời code.
38
+ - @docs/adr/ — khi đổi public API.
39
+ - @docs/golden-principles.md — trước mọi refactor.
40
+ - @feature_list.json — trước khi tuyên bố một feature đã xong.
41
+ - `.harness/PROGRESS.md` — đọc đầu session; append cuối session (kit quản lý, không @-import).
38
42
 
39
43
  ## Skills nên dùng
40
44
 
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bash
2
+ # Notification hook — OS-native notification when Claude wants attention.
3
+ # macOS osascript / Linux notify-send / Windows skip.
4
+ # Never blocks. Always exits 0. Opt-out: AHK_DISABLE_NOTIFY=1.
5
+ set -eo pipefail
6
+
7
+ INPUT=$(cat)
8
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
+ have_jq() {
10
+ [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
11
+ command -v jq >/dev/null 2>&1
12
+ }
13
+ have_jp() {
14
+ have_jq && return 0
15
+ command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
16
+ return 1
17
+ }
18
+ jp() {
19
+ if have_jq; then
20
+ if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
21
+ else
22
+ if [ -n "$2" ]; then
23
+ node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
24
+ else
25
+ node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
26
+ fi
27
+ fi
28
+ }
29
+
30
+ if [ "${AHK_DISABLE_NOTIFY:-}" = "1" ]; then
31
+ exit 0
32
+ fi
33
+
34
+ TYPE=""
35
+ TITLE=""
36
+ BODY=""
37
+ if have_jp; then
38
+ TYPE=$(echo "$INPUT" | jp '.notification.type // empty')
39
+ TITLE=$(echo "$INPUT" | jp '.notification.title // empty')
40
+ BODY=$(echo "$INPUT" | jp '.notification.body // empty')
41
+ fi
42
+
43
+ [ -z "$TITLE" ] && TITLE="Claude Code"
44
+ if [ -n "$TYPE" ]; then
45
+ BODY="[$TYPE] ${BODY}"
46
+ fi
47
+ [ -z "$BODY" ] && BODY="Claude Code wants your attention."
48
+
49
+ mkdir -p .harness
50
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
51
+ ESCAPED_TITLE=${TITLE//\"/\\\"}
52
+ ESCAPED_BODY=${BODY//\"/\\\"}
53
+ printf '{"ts":"%s","hook":"Notification","type":"%s","title":"%s","body":"%s"}\n' \
54
+ "$TS" "$TYPE" "$ESCAPED_TITLE" "$ESCAPED_BODY" >> .harness/telemetry.jsonl
55
+
56
+ OS_KIND=$(uname -s 2>/dev/null || echo "Unknown")
57
+ case "$OS_KIND" in
58
+ Darwin)
59
+ OSA_TITLE=${TITLE//\"/\\\"}
60
+ OSA_BODY=${BODY//\"/\\\"}
61
+ osascript -e "display notification \"$OSA_BODY\" with title \"$OSA_TITLE\"" >/dev/null 2>&1 || true
62
+ ;;
63
+ Linux)
64
+ if command -v notify-send >/dev/null 2>&1; then
65
+ notify-send -a "Claude Code" "$TITLE" "$BODY" >/dev/null 2>&1 || true
66
+ fi
67
+ ;;
68
+ *)
69
+ :
70
+ ;;
71
+ esac
72
+
73
+ exit 0
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // statusLine — single compact line into Claude Code's TUI status bar.
3
+ // Reads stdin (Claude Code payload), augments with kit state, emits to
4
+ // stdout. Failure mode: print nothing rather than crash.
5
+
6
+ import { readFileSync, existsSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ const CWD = process.env.CLAUDE_PROJECT_DIR || process.cwd();
11
+
12
+ function safeRead(rel) {
13
+ try { return readFileSync(resolve(CWD, rel), "utf8"); }
14
+ catch { return null; }
15
+ }
16
+ function safeJSON(rel) {
17
+ const raw = safeRead(rel);
18
+ if (!raw) return null;
19
+ try { return JSON.parse(raw); } catch { return null; }
20
+ }
21
+ function readStdinSync() {
22
+ try { return readFileSync(0, "utf8"); } catch { return ""; }
23
+ }
24
+
25
+ function pieces() {
26
+ const out = [];
27
+ const lock = safeJSON(".harness/installed.json");
28
+ if (lock?.version) out.push(`{kit-v${lock.version}}`);
29
+
30
+ const features = safeJSON("feature_list.json");
31
+ if (features?.features && Array.isArray(features.features)) {
32
+ const open = features.features.find((f) => f.passes === false);
33
+ out.push(open ? `feat:${open.id}` : "feat:clean");
34
+ }
35
+
36
+ try {
37
+ const br = spawnSync("git", ["branch", "--show-current"], {
38
+ cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
39
+ });
40
+ const status = spawnSync("git", ["status", "--short"], {
41
+ cwd: CWD, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
42
+ });
43
+ if (br.status === 0 && br.stdout.trim()) {
44
+ const dirty = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
45
+ out.push(dirty > 0 ? `${br.stdout.trim()}(±${dirty})` : br.stdout.trim());
46
+ }
47
+ } catch { /* git not on PATH — skip */ }
48
+
49
+ const raw = readStdinSync();
50
+ let payload = null;
51
+ if (raw) { try { payload = JSON.parse(raw); } catch { /* ignore */ } }
52
+ if (payload?.context && typeof payload.context.percentage === "number") {
53
+ out.push(`ctx:${Math.round(payload.context.percentage)}%`);
54
+ }
55
+ if (payload?.cost && typeof payload.cost.total === "number") {
56
+ const v = payload.cost.total;
57
+ out.push(`$${v < 1 ? v.toFixed(2) : v.toFixed(1)}`);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ const line = pieces().join(" • ");
63
+ if (line) process.stdout.write(line);
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # UserPromptSubmit hook — denies prompt patterns that undo harness safety
3
+ # rules. Hard guardrail replacing soft CLAUDE.md guidance.
4
+ #
5
+ # Denied patterns:
6
+ # 1. "ignore previous instructions" / "disregard above"
7
+ # 2. "disable the structural test" / "skip the structural check"
8
+ # 3. "bypass the (Stop|PreToolUse|hook) (rules?|checks?)"
9
+ # 4. "remove the .harness" / "delete .harness directory"
10
+ # 5. "set disableAllHooks: true"
11
+ #
12
+ # Escape hatch: AHK_ALLOW_BYPASS=1 logs to .harness/bypass.log + lets through.
13
+ set -eo pipefail
14
+
15
+ INPUT=$(cat)
16
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
17
+ have_jq() {
18
+ [ "${AHK_DISABLE_JQ:-}" = "1" ] && return 1
19
+ command -v jq >/dev/null 2>&1
20
+ }
21
+ have_jp() {
22
+ have_jq && return 0
23
+ command -v node >/dev/null 2>&1 && [ -f "$SCRIPT_DIR/_lib/json-pick.mjs" ] && return 0
24
+ return 1
25
+ }
26
+ jp() {
27
+ if have_jq; then
28
+ if [ -n "$2" ]; then jq -r "$1" "$2"; else jq -r "$1"; fi
29
+ else
30
+ if [ -n "$2" ]; then
31
+ node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1" "$2"
32
+ else
33
+ node "$SCRIPT_DIR/_lib/json-pick.mjs" "$1"
34
+ fi
35
+ fi
36
+ }
37
+
38
+ if ! have_jp; then
39
+ exit 0
40
+ fi
41
+
42
+ PROMPT=$(echo "$INPUT" | jp '.prompt // empty')
43
+ [ -z "$PROMPT" ] && exit 0
44
+
45
+ LOWER=$(printf '%s' "$PROMPT" | tr '[:upper:]' '[:lower:]')
46
+
47
+ REASON=""
48
+
49
+ if printf '%s' "$LOWER" | grep -qE '(ignore|disregard|forget) (the|all|any|your|previous|prior|above)'; then
50
+ REASON="Prompts that ask Claude to ignore previous instructions defeat the harness's safety rules. State the actual change you need; the structural test and Stop checklist are deterministic and stay enforced."
51
+ fi
52
+
53
+ if [ -z "$REASON" ] \
54
+ && printf '%s' "$LOWER" | grep -qE '(disable|skip|turn off|bypass) (the )?(structural|layer|stop hook|stop check|precompletion|lint|harness:check)'; then
55
+ REASON="Disabling the structural test or Stop checklist is not how the kit is meant to be used. Fix the violation in code, or open an ADR if the layer rule itself needs to change."
56
+ fi
57
+
58
+ if [ -z "$REASON" ] \
59
+ && printf '%s' "$LOWER" | grep -qE 'bypass (the )?(pretooluse|posttooluse|sessionstart|sessionend|precompact|hook|hooks|rules?|checks?)'; then
60
+ REASON="Prompts that ask to bypass kit hooks defeat their purpose. If a specific hook is wrong for your workflow, edit it explicitly with a commit message; do not phrase the request as 'bypass'."
61
+ fi
62
+
63
+ if [ -z "$REASON" ] \
64
+ && printf '%s' "$LOWER" | grep -qE '(remove|delete|wipe|rm -rf|drop) (the )?(\.harness|\.claude)( |/|$|\.)'; then
65
+ REASON="Removing .harness/ or .claude/ deletes the kit's lockfile, structural baseline, skills, agents, and hooks. Use 'agent-harness-kit upgrade' to refresh installed files; do not delete by hand."
66
+ fi
67
+
68
+ if [ -z "$REASON" ] \
69
+ && printf '%s' "$LOWER" | grep -qE 'disableallhooks.*true'; then
70
+ REASON="disableAllHooks: true defeats every protection the kit installs. Remove specific hooks explicitly if needed; do not flip the master switch."
71
+ fi
72
+
73
+ if [ -z "$REASON" ]; then
74
+ exit 0
75
+ fi
76
+
77
+ if [ "${AHK_ALLOW_BYPASS:-}" = "1" ]; then
78
+ mkdir -p .harness
79
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
80
+ SHA=$(git rev-parse --short HEAD 2>/dev/null || echo 'no-git')
81
+ ESCAPED_PROMPT=${PROMPT//$'\n'/ }
82
+ ESCAPED_PROMPT=${ESCAPED_PROMPT//\"/\\\"}
83
+ printf '{"ts":"%s","sha":"%s","bypass":"AHK_ALLOW_BYPASS","reason":"%s","prompt":"%s","hook":"UserPromptSubmit"}\n' \
84
+ "$TS" "$SHA" "${REASON//\"/\\\"}" "$ESCAPED_PROMPT" >> .harness/bypass.log
85
+ exit 0
86
+ fi
87
+
88
+ if command -v node >/dev/null 2>&1; then
89
+ node -e "
90
+ const reason = process.argv[1];
91
+ const out = { decision: 'block', reason };
92
+ process.stdout.write(JSON.stringify(out));
93
+ " "$REASON"
94
+ elif have_jq; then
95
+ jq -nc --arg r "$REASON" '{decision:"block", reason:$r}'
96
+ else
97
+ echo "$REASON" >&2
98
+ exit 2
99
+ fi
100
+ exit 0