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
@@ -0,0 +1,286 @@
1
+ // harness/structural-check.mjs — forward-only layer enforcement for Kotlin.
2
+ //
3
+ // Reads harness.config.json. For each domain, walks every .kt/.kts file
4
+ // under the domain root (excluding build/, .gradle/, out/, .git/) and
5
+ // asserts no `import` statement pulls in a layer that comes BEFORE the
6
+ // source layer.
7
+ //
8
+ // Kotlin's layer-from-import is read via `useIdentPattern` in
9
+ // harness.config.json. Typical convention: `com.example.{layer}` so
10
+ // `import com.example.types.Foo` resolves to layer "types". Default
11
+ // pattern is `{layer}`, suitable for projects using top-level packages
12
+ // matched to layer names.
13
+ //
14
+ // Skip-zones handled by stripNonCode():
15
+ // - line comment `// ... \n`
16
+ // - block comment `/* ... */` (Kotlin allows nesting)
17
+ // - regular string `"..."` (with `\"` and `\\` escapes)
18
+ // - raw / triple-quoted string `"""..."""`
19
+ //
20
+ // Exit codes:
21
+ // 0 — clean (or only baselined violations; or first-run baseline write)
22
+ // 2 — new violations found
23
+
24
+ import {
25
+ readFileSync,
26
+ writeFileSync,
27
+ existsSync,
28
+ mkdirSync,
29
+ readdirSync,
30
+ } from "node:fs";
31
+ import { join, relative, sep } from "node:path";
32
+
33
+ const repoRoot = process.cwd();
34
+ const SKIP_DIRS = new Set([
35
+ ".git",
36
+ "build",
37
+ ".gradle",
38
+ "out",
39
+ ".idea",
40
+ "node_modules",
41
+ ".harness",
42
+ ]);
43
+
44
+ function readConfig() {
45
+ return JSON.parse(readFileSync(join(repoRoot, "harness.config.json"), "utf8"));
46
+ }
47
+ function readBaseline() {
48
+ const path = join(repoRoot, ".harness/structural-baseline.json");
49
+ if (!existsSync(path)) return { exists: false, set: new Set() };
50
+ try {
51
+ const arr = JSON.parse(readFileSync(path, "utf8"));
52
+ return { exists: true, set: new Set(Array.isArray(arr) ? arr : []) };
53
+ } catch {
54
+ return { exists: true, set: new Set() };
55
+ }
56
+ }
57
+
58
+ function* walkKotlinFiles(root) {
59
+ if (!existsSync(root)) return;
60
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
61
+ if (SKIP_DIRS.has(ent.name) || ent.name.startsWith(".")) continue;
62
+ const full = join(root, ent.name);
63
+ if (ent.isDirectory()) yield* walkKotlinFiles(full);
64
+ else if (ent.isFile() && (ent.name.endsWith(".kt") || ent.name.endsWith(".kts"))) {
65
+ yield full;
66
+ }
67
+ }
68
+ }
69
+
70
+ // stripNonCode — blank out comments + strings, preserve newlines.
71
+ // Block comments nest in Kotlin. Triple-quoted strings span lines and
72
+ // don't process escapes (raw strings).
73
+ export function stripNonCode(src) {
74
+ const n = src.length;
75
+ const out = new Array(n);
76
+ let i = 0;
77
+ while (i < n) {
78
+ const c = src[i];
79
+ const next = src[i + 1];
80
+ if (c === "/" && next === "/") {
81
+ while (i < n && src[i] !== "\n") {
82
+ out[i] = src[i] === "\n" ? "\n" : " ";
83
+ i++;
84
+ }
85
+ continue;
86
+ }
87
+ if (c === "/" && next === "*") {
88
+ let depth = 1;
89
+ out[i] = " ";
90
+ out[i + 1] = " ";
91
+ i += 2;
92
+ while (i < n && depth > 0) {
93
+ if (src[i] === "/" && src[i + 1] === "*") {
94
+ depth++;
95
+ out[i] = " ";
96
+ out[i + 1] = " ";
97
+ i += 2;
98
+ } else if (src[i] === "*" && src[i + 1] === "/") {
99
+ depth--;
100
+ out[i] = " ";
101
+ out[i + 1] = " ";
102
+ i += 2;
103
+ } else {
104
+ out[i] = src[i] === "\n" ? "\n" : " ";
105
+ i++;
106
+ }
107
+ }
108
+ continue;
109
+ }
110
+ // Triple-quoted raw string
111
+ if (c === '"' && next === '"' && src[i + 2] === '"') {
112
+ out[i] = out[i + 1] = out[i + 2] = " ";
113
+ let m = i + 3;
114
+ while (m < n) {
115
+ if (src[m] === '"' && src[m + 1] === '"' && src[m + 2] === '"') {
116
+ out[m] = out[m + 1] = out[m + 2] = " ";
117
+ m += 3;
118
+ break;
119
+ }
120
+ out[m] = src[m] === "\n" ? "\n" : " ";
121
+ m++;
122
+ }
123
+ i = m;
124
+ continue;
125
+ }
126
+ if (c === '"') {
127
+ out[i] = " ";
128
+ i++;
129
+ while (i < n) {
130
+ if (src[i] === "\\" && i + 1 < n) {
131
+ out[i] = " ";
132
+ out[i + 1] = " ";
133
+ i += 2;
134
+ continue;
135
+ }
136
+ if (src[i] === '"') {
137
+ out[i] = " ";
138
+ i++;
139
+ break;
140
+ }
141
+ out[i] = src[i] === "\n" ? "\n" : " ";
142
+ i++;
143
+ }
144
+ continue;
145
+ }
146
+ out[i] = c;
147
+ i++;
148
+ }
149
+ return out.join("");
150
+ }
151
+
152
+ function layerOf(relPath, cfg) {
153
+ for (const d of cfg.domains) {
154
+ const altPrefix = d.root + "/";
155
+ const sepPrefix = d.root + sep;
156
+ let stripped;
157
+ if (relPath.startsWith(altPrefix)) stripped = relPath.slice(altPrefix.length);
158
+ else if (relPath.startsWith(sepPrefix)) stripped = relPath.slice(sepPrefix.length);
159
+ else continue;
160
+ const first = stripped.split(/[\/\\]/)[0];
161
+ const pattern = d.layerDirPattern || "{layer}";
162
+ const layer = resolveFromPattern(first, pattern, d.layers);
163
+ if (layer) return { layer, domain: d };
164
+ }
165
+ return null;
166
+ }
167
+
168
+ function resolveFromPattern(ident, pattern, layers) {
169
+ if (pattern === "{layer}") return layers.includes(ident) ? ident : null;
170
+ const [prefix, suffix] = pattern.split("{layer}");
171
+ const pre = (prefix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
172
+ const suf = (suffix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
173
+ const re = new RegExp(`^${pre}(.+?)${suf}$`);
174
+ const m = ident.match(re);
175
+ if (m && layers.includes(m[1])) return m[1];
176
+ return null;
177
+ }
178
+
179
+ // `import com.example.layer.Foo` — extract the full dotted path (incl. `.*`),
180
+ // then run resolveFromPattern on each segment.
181
+ const IMPORT_RE = /\bimport\s+([A-Za-z_][\w.]*?)(?:\.\*)?\s*(?:as\s+\w+)?\s*[;\n\r]/g;
182
+
183
+ function findImportLayers(code, domain) {
184
+ const out = [];
185
+ const ident = domain.useIdentPattern || "{layer}";
186
+ IMPORT_RE.lastIndex = 0;
187
+ let m;
188
+ while ((m = IMPORT_RE.exec(code)) !== null) {
189
+ const path = m[1];
190
+ const segments = path.split(".");
191
+ // Try matching from the most-specific (full path) down to single segments
192
+ // — useful when the pattern is e.g. `com.example.{layer}` and we need
193
+ // to match against the prefix-stripped segment.
194
+ let layer = null;
195
+ if (ident.includes("{layer}")) {
196
+ // Walk prefix lengths: try full match against pattern across the full
197
+ // dotted path, then progressively shorter.
198
+ // For pattern `com.example.{layer}`, candidates: full path "com.example.types",
199
+ // then "com.example", "com", and segments themselves.
200
+ const candidates = [];
201
+ for (let i = 1; i <= segments.length; i++) {
202
+ candidates.push(segments.slice(0, i).join("."));
203
+ }
204
+ for (const c of candidates) {
205
+ const found = resolveFromPattern(c, ident, domain.layers);
206
+ if (found) {
207
+ layer = found;
208
+ break;
209
+ }
210
+ }
211
+ // Fall back: per-segment match
212
+ if (!layer) {
213
+ for (const s of segments) {
214
+ const found = resolveFromPattern(s, "{layer}", domain.layers);
215
+ if (found) {
216
+ layer = found;
217
+ break;
218
+ }
219
+ }
220
+ }
221
+ }
222
+ if (layer) {
223
+ const line = code.slice(0, m.index).split("\n").length;
224
+ out.push({ layer, line });
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+
230
+ function main() {
231
+ const cfg = readConfig();
232
+ const { exists: baselineExists, set: baselineSet } = readBaseline();
233
+ const violations = [];
234
+ for (const d of cfg.domains) {
235
+ const rootDir = join(repoRoot, d.root);
236
+ for (const file of walkKotlinFiles(rootDir)) {
237
+ const relPath = relative(repoRoot, file);
238
+ const src = layerOf(relPath, cfg);
239
+ if (!src) continue;
240
+ const srcIdx = src.domain.layers.indexOf(src.layer);
241
+ const content = readFileSync(file, "utf8");
242
+ const code = stripNonCode(content);
243
+ for (const { layer: tgtLayer, line } of findImportLayers(code, src.domain)) {
244
+ const tgtIdx = src.domain.layers.indexOf(tgtLayer);
245
+ if (tgtIdx === -1) continue;
246
+ if (srcIdx < tgtIdx) {
247
+ const key = `${relPath}::${tgtLayer}`;
248
+ if (baselineSet.has(key)) continue;
249
+ violations.push({
250
+ file: relPath,
251
+ line,
252
+ from: src.layer,
253
+ to: tgtLayer,
254
+ domain: src.domain.name,
255
+ key,
256
+ });
257
+ }
258
+ }
259
+ }
260
+ }
261
+ if (!baselineExists && violations.length > 0) {
262
+ mkdirSync(join(repoRoot, ".harness"), { recursive: true });
263
+ const keys = [...new Set(violations.map((v) => v.key))].sort();
264
+ writeFileSync(
265
+ join(repoRoot, ".harness/structural-baseline.json"),
266
+ JSON.stringify(keys, null, 2) + "\n",
267
+ );
268
+ console.log(`✓ structural test: baselined ${keys.length} existing violation(s).`);
269
+ process.exit(0);
270
+ }
271
+ if (violations.length === 0) {
272
+ console.log("✓ structural test passed");
273
+ process.exit(0);
274
+ }
275
+ for (const v of violations) {
276
+ console.error(`✖ ${v.file}:${v.line} layer=${v.from} → ${v.to} (must be forward-only)`);
277
+ }
278
+ console.error(`\n${violations.length} new layer violation(s). Fix the import direction.`);
279
+ if (cfg.domains.length > 0) {
280
+ console.error(`Layer order for domain "${cfg.domains[0].name}": ${cfg.domains[0].layers.join(" → ")}`);
281
+ }
282
+ process.exit(2);
283
+ }
284
+
285
+ const isMain = import.meta.url === `file://${process.argv[1]}`;
286
+ if (isMain) main();