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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/bin/cli.mjs +5 -0
- package/package.json +1 -1
- package/src/core/doctor.mjs +23 -0
- package/src/core/render-templates.mjs +90 -5
- package/src/templates/.claude/hooks/hooks.json +24 -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 +9 -5
- package/src/templates/CLAUDE.md.vi.hbs +9 -5
- package/src/templates/scripts/notify-on-block.sh.hbs +73 -0
- package/src/templates/scripts/statusline.mjs +63 -0
- package/src/templates/scripts/userprompt-guard.sh.hbs +100 -0
|
@@ -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/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.
|
|
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": {
|
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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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",
|
|
@@ -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
|
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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. **
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
-
|
|
33
|
+
Các dòng dưới dùng cú 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
|