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
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Reads harness.config.json. For each domain, walks every .rs file under
4
4
  // the domain root (excluding target/, .git/, vendor/) and asserts no
5
- // import goes "backward" through the layer order.
5
+ // `use` statement imports a layer that comes BEFORE the source layer.
6
6
  //
7
7
  // Two layouts are supported:
8
8
  //
@@ -18,13 +18,20 @@
18
18
  // `use unibot_types::`). Both default to `{layer}` and preserve the
19
19
  // legacy single-crate behavior.
20
20
  //
21
- // Why Node + regex (not a Cargo binary):
22
- // - Avoids polluting the user's Cargo workspace with a check crate.
23
- // - Node is already required to install the kit (npx).
24
- // - Regex over `use <crate>::<X>` is sufficient — we never need full
25
- // parse trees because the layer rule is a syntactic property.
26
- // - `super::` and `self::` are scoped to the current module, which is
27
- // by definition the same layer, so we ignore them.
21
+ // Approach (changed in v0.7): a proper Rust lexer-lite. The v0.5 / v0.6
22
+ // implementation ran regex over each line after stripping line comments
23
+ // and double-quoted strings on a per-line basis, which produced false
24
+ // positives on:
25
+ // - multi-line block comments containing `use crate::X`
26
+ // - raw strings (r"...", r#"..."#)
27
+ // - char literals confused with lifetimes
28
+ // and produced false negatives on:
29
+ // - braced use lists: `use crate::{types, service}` (only first ident matched)
30
+ // - nested braces: `use crate::{a::{b, c}, d}`
31
+ // The new approach: first pass blanks out every non-code character
32
+ // (preserving newlines for line numbers); second pass walks the code-only
33
+ // string from each `use`/`pub use` and recursively extracts every
34
+ // candidate-layer identifier through brace expansions.
28
35
  //
29
36
  // Exit codes:
30
37
  // 0 — clean (or only baselined violations; or first-run baseline write)
@@ -77,13 +84,224 @@ function* walkRustFiles(root) {
77
84
  }
78
85
  }
79
86
 
80
- // Returns { layer, domain } or null.
87
+ // stripNonCode blank out every byte that's inside a comment, string, raw
88
+ // string, or char literal. Newlines are preserved so line numbers stay
89
+ // accurate; everything else inside a skip-zone becomes a single space.
90
+ // This is the layer that catches false positives like
91
+ // /* use crate::service */ or r#"use crate::service"#
92
+ // which the prior per-line regex missed.
93
+ export function stripNonCode(src) {
94
+ const n = src.length;
95
+ const out = new Array(n);
96
+ let i = 0;
97
+ while (i < n) {
98
+ const c = src[i];
99
+ const next = src[i + 1];
100
+ // Line comment
101
+ if (c === "/" && next === "/") {
102
+ while (i < n && src[i] !== "\n") {
103
+ out[i] = src[i] === "\n" ? "\n" : " ";
104
+ i++;
105
+ }
106
+ continue;
107
+ }
108
+ // Block comment (Rust allows nesting)
109
+ if (c === "/" && next === "*") {
110
+ let depth = 1;
111
+ out[i] = " ";
112
+ out[i + 1] = " ";
113
+ i += 2;
114
+ while (i < n && depth > 0) {
115
+ if (src[i] === "/" && src[i + 1] === "*") {
116
+ depth++;
117
+ out[i] = " ";
118
+ out[i + 1] = " ";
119
+ i += 2;
120
+ } else if (src[i] === "*" && src[i + 1] === "/") {
121
+ depth--;
122
+ out[i] = " ";
123
+ out[i + 1] = " ";
124
+ i += 2;
125
+ } else {
126
+ out[i] = src[i] === "\n" ? "\n" : " ";
127
+ i++;
128
+ }
129
+ }
130
+ continue;
131
+ }
132
+ // Raw string: r"..." or r#..#"..."#..# (also br"..." / br#"..."#)
133
+ if ((c === "r" || (c === "b" && next === "r")) && (i === 0 || !/[a-zA-Z0-9_]/.test(src[i - 1]))) {
134
+ let j = i;
135
+ if (src[j] === "b") {
136
+ out[j] = " ";
137
+ j++;
138
+ }
139
+ if (src[j] === "r") {
140
+ let k = j + 1;
141
+ let hashes = 0;
142
+ while (src[k] === "#") {
143
+ hashes++;
144
+ k++;
145
+ }
146
+ if (src[k] === '"') {
147
+ // Confirmed raw string. Blot from i to closing "#####"
148
+ out[j] = " ";
149
+ for (let q = j + 1; q <= k; q++) out[q] = " ";
150
+ let m = k + 1;
151
+ const closeStr = '"' + "#".repeat(hashes);
152
+ while (m < n) {
153
+ if (src.slice(m, m + closeStr.length) === closeStr) {
154
+ for (let q = m; q < m + closeStr.length; q++) out[q] = " ";
155
+ m += closeStr.length;
156
+ break;
157
+ }
158
+ out[m] = src[m] === "\n" ? "\n" : " ";
159
+ m++;
160
+ }
161
+ i = m;
162
+ continue;
163
+ }
164
+ // Not a raw string — `r` was just an identifier letter. Fall through.
165
+ }
166
+ }
167
+ // Regular string: "..." (handles \", \\, and embedded newlines)
168
+ if (c === '"') {
169
+ out[i] = " ";
170
+ i++;
171
+ while (i < n) {
172
+ if (src[i] === "\\" && i + 1 < n) {
173
+ out[i] = " ";
174
+ out[i + 1] = " ";
175
+ i += 2;
176
+ continue;
177
+ }
178
+ if (src[i] === '"') {
179
+ out[i] = " ";
180
+ i++;
181
+ break;
182
+ }
183
+ out[i] = src[i] === "\n" ? "\n" : " ";
184
+ i++;
185
+ }
186
+ continue;
187
+ }
188
+ // Char literal vs lifetime. 'X' or '\X...' is a char; 'name (no closer)
189
+ // is a lifetime. Heuristic: find the closing `'` within the next 6
190
+ // chars; if present AND the body looks like a single char or short
191
+ // escape, treat as char. Otherwise leave as lifetime (raw identifier).
192
+ if (c === "'") {
193
+ // Look for closing '
194
+ let closeAt = -1;
195
+ for (let k = i + 1; k < Math.min(n, i + 8); k++) {
196
+ if (src[k] === "'") {
197
+ closeAt = k;
198
+ break;
199
+ }
200
+ if (src[k] === "\n") break; // lifetime can't span lines
201
+ }
202
+ const body = closeAt > -1 ? src.slice(i + 1, closeAt) : "";
203
+ // Char if body is length 1 (X) OR starts with `\` and is short.
204
+ const isChar =
205
+ closeAt > -1 &&
206
+ (body.length === 1 ||
207
+ (body.startsWith("\\") && body.length <= 6) ||
208
+ /^u\{[0-9a-fA-F]+\}$/.test(body));
209
+ if (isChar) {
210
+ for (let k = i; k <= closeAt; k++) out[k] = src[k] === "\n" ? "\n" : " ";
211
+ i = closeAt + 1;
212
+ continue;
213
+ }
214
+ // Lifetime: pass through as-is.
215
+ out[i] = c;
216
+ i++;
217
+ continue;
218
+ }
219
+ // Default: pass character through.
220
+ out[i] = c;
221
+ i++;
222
+ }
223
+ return out.join("");
224
+ }
225
+
226
+ // extractUseTargets — given the code-only text and the start index right
227
+ // after `crate::` (single-crate mode) or after `use ` (workspace mode),
228
+ // return every candidate layer identifier reachable through nested braces.
229
+ // Stops at `;` at top level.
81
230
  //
82
- // Resolution:
83
- // 1. Strip the domain root prefix from the path.
84
- // 2. The first segment is the candidate layer directory.
85
- // 3. Match it against `layerDirPattern` (default `{layer}`) if the
86
- // pattern resolves a layer name, use it.
231
+ // Examples (single-crate, called with start = position after "crate::"):
232
+ // "service;" → ["service"]
233
+ // "service::Foo;" → ["service"]
234
+ // "{a, b, c};" → ["a", "b", "c"]
235
+ // "{a::Foo, b::{x, y}};" → ["a", "b"]
236
+ // "*;" → []
237
+ // "service::Foo as Bar;" → ["service"]
238
+ export function extractUseTargets(src, start) {
239
+ let i = start;
240
+ function skipWs() {
241
+ while (i < src.length && /\s/.test(src[i])) i++;
242
+ }
243
+ function readIdent() {
244
+ skipWs();
245
+ const m = src.slice(i).match(/^[a-zA-Z_][a-zA-Z0-9_]*/);
246
+ if (!m) return null;
247
+ i += m[0].length;
248
+ return m[0];
249
+ }
250
+ // Parse one "use path item" starting at i, return list of first-ident layers.
251
+ function parseItem() {
252
+ skipWs();
253
+ if (src[i] === "{") {
254
+ i++; // {
255
+ const layers = [];
256
+ while (i < src.length) {
257
+ skipWs();
258
+ if (src[i] === "}") {
259
+ i++;
260
+ break;
261
+ }
262
+ // Could be `self`, `super`, `crate`, an ident, or `*`.
263
+ const inner = parseItem();
264
+ layers.push(...inner);
265
+ // Skip ahead to next `,` or `}` at depth 0.
266
+ let depth = 0;
267
+ while (i < src.length) {
268
+ const c = src[i];
269
+ if (c === "{") depth++;
270
+ else if (c === "}") {
271
+ if (depth === 0) break;
272
+ depth--;
273
+ } else if (c === "," && depth === 0) {
274
+ i++;
275
+ break;
276
+ }
277
+ i++;
278
+ }
279
+ }
280
+ return layers;
281
+ }
282
+ if (src[i] === "*") {
283
+ i++;
284
+ return [];
285
+ }
286
+ const id = readIdent();
287
+ if (!id) return [];
288
+ // `self`, `super`, `crate` aren't layers; they may precede `::layer::...`.
289
+ if (id === "self" || id === "super" || id === "crate") {
290
+ skipWs();
291
+ if (src[i] === ":" && src[i + 1] === ":") {
292
+ i += 2;
293
+ return parseItem();
294
+ }
295
+ return [];
296
+ }
297
+ return [id];
298
+ }
299
+ return parseItem();
300
+ }
301
+
302
+ // Returns { layer, domain } or null. Resolves the source file's layer
303
+ // (which dir bucket it lives in) by stripping the domain root prefix and
304
+ // applying the `layerDirPattern`.
87
305
  function layerOf(relPath, cfg) {
88
306
  for (const d of cfg.domains) {
89
307
  const altPrefix = d.root + "/";
@@ -101,14 +319,10 @@ function layerOf(relPath, cfg) {
101
319
  return null;
102
320
  }
103
321
 
104
- // Given a directory name and a pattern like `unibot-{layer}`, return the
105
- // matching layer name from `layers` (or null). Handles the legacy
106
- // `{layer}` pattern as the identity case.
107
322
  function resolveLayerFromDir(dirName, pattern, layers) {
108
323
  if (pattern === "{layer}") {
109
324
  return layers.includes(dirName) ? dirName : null;
110
325
  }
111
- // Escape regex specials in the surrounding pattern fragments.
112
326
  const [prefix, suffix] = pattern.split("{layer}");
113
327
  const pre = (prefix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
114
328
  const suf = (suffix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -118,35 +332,7 @@ function resolveLayerFromDir(dirName, pattern, layers) {
118
332
  return null;
119
333
  }
120
334
 
121
- // Capture the first identifier after `use ` (or `pub use ...`). The trailing
122
- // `::` is optional — `use demo_service;` is legal Rust even though most
123
- // real usage is `use demo_service::foo`.
124
- const USE_RE = /\b(?:pub\s+)?use\s+([a-zA-Z_][a-zA-Z0-9_]*)/g;
125
-
126
- function parseUseTargets(line, domain) {
127
- const useIdent = domain.useIdentPattern || "crate";
128
- const matches = [...line.matchAll(USE_RE)];
129
- const layers = [];
130
- for (const m of matches) {
131
- const ident = m[1];
132
- const layer = resolveLayerFromUseIdent(ident, useIdent, domain.layers);
133
- if (layer) layers.push(layer);
134
- }
135
- return layers;
136
- }
137
-
138
- // Map a `use <ident>` to a layer. For single-crate mode this is
139
- // `use crate::<layer>::...` — `ident == "crate"` and the layer is the
140
- // SECOND segment. For workspace mode it is `use <crate>::...` where the
141
- // crate name itself encodes the layer.
142
335
  function resolveLayerFromUseIdent(ident, useIdentPattern, layers) {
143
- if (useIdentPattern === "crate") {
144
- // Legacy single-crate mode: only `use crate::...` matters; the layer
145
- // is read from the captured ident only when ident === "crate" but
146
- // the actual layer name is the segment AFTER `crate::` — see the
147
- // separate `USE_CRATE_RE` path used by the caller for compatibility.
148
- return null;
149
- }
150
336
  if (useIdentPattern === "{layer}") {
151
337
  return layers.includes(ident) ? ident : null;
152
338
  }
@@ -159,40 +345,51 @@ function resolveLayerFromUseIdent(ident, useIdentPattern, layers) {
159
345
  return null;
160
346
  }
161
347
 
162
- // Backwards-compat: also keep the original single-crate matcher.
163
- const USE_CRATE_RE = /\b(?:pub\s+)?use\s+crate::([a-zA-Z_][a-zA-Z0-9_]*)/g;
164
-
165
- function parseUseCrate(line) {
166
- return [...line.matchAll(USE_CRATE_RE)].map((m) => m[1]);
167
- }
348
+ // USE_RE matches `use ` and `pub use ` at any position. We scan the
349
+ // code-only string for these tokens, then hand off to extractUseTargets
350
+ // from the position right after the matching prefix.
351
+ const USE_HEAD_RE = /\b(?:pub\s+)?use\s+/g;
168
352
 
169
- // Return only the code portion of a line strip line comments (//) and
170
- // double-quoted string contents so the regex can't match `use crate::X`
171
- // inside text like `"use crate::service"` or `// use crate::service`.
172
- // Block comments and char literals (single quotes — also Rust lifetimes)
173
- // are not handled; collisions are rare and would only cause noise, not
174
- // missed real violations.
175
- function stripCommentsAndStrings(line) {
176
- let result = "";
177
- let inStr = false;
178
- for (let i = 0; i < line.length; i++) {
179
- const c = line[i];
180
- if (inStr) {
181
- if (c === "\\" && i + 1 < line.length) {
182
- i++;
183
- continue;
353
+ // findUseLayers top-level scanner. For each `use ... ;` in the code-only
354
+ // string, return [{layer, line}].
355
+ function findUseLayers(codeOnly, domain) {
356
+ const out = [];
357
+ const workspaceMode = !!domain.useIdentPattern;
358
+ USE_HEAD_RE.lastIndex = 0;
359
+ let m;
360
+ while ((m = USE_HEAD_RE.exec(codeOnly)) !== null) {
361
+ const after = m.index + m[0].length;
362
+ if (workspaceMode) {
363
+ // First ident after `use ` IS the crate; decode layer from it.
364
+ const id = codeOnly.slice(after).match(/^[a-zA-Z_][a-zA-Z0-9_]*/);
365
+ if (!id) continue;
366
+ const layer = resolveLayerFromUseIdent(id[0], domain.useIdentPattern, domain.layers);
367
+ if (layer) {
368
+ const line = codeOnly.slice(0, m.index).split("\n").length;
369
+ out.push({ layer, line });
184
370
  }
185
- if (c === '"') inStr = false;
186
371
  continue;
187
372
  }
188
- if (c === '"') {
189
- inStr = true;
190
- continue;
373
+ // Single-crate: skip optional `crate::` prefix.
374
+ let cursor = after;
375
+ while (cursor < codeOnly.length && /\s/.test(codeOnly[cursor])) cursor++;
376
+ const head = codeOnly.slice(cursor).match(/^[a-zA-Z_][a-zA-Z0-9_]*/);
377
+ if (!head) continue;
378
+ if (head[0] !== "crate") continue; // external crates aren't layer imports
379
+ cursor += head[0].length;
380
+ while (cursor < codeOnly.length && /\s/.test(codeOnly[cursor])) cursor++;
381
+ if (codeOnly[cursor] !== ":" || codeOnly[cursor + 1] !== ":") continue;
382
+ cursor += 2;
383
+ // Now cursor sits at the position right after `crate::`.
384
+ const layers = extractUseTargets(codeOnly, cursor);
385
+ const line = codeOnly.slice(0, m.index).split("\n").length;
386
+ for (const layer of layers) {
387
+ if (domain.layers.includes(layer)) {
388
+ out.push({ layer, line });
389
+ }
191
390
  }
192
- if (c === "/" && i + 1 < line.length && line[i + 1] === "/") break;
193
- result += c;
194
391
  }
195
- return result;
392
+ return out;
196
393
  }
197
394
 
198
395
  function main() {
@@ -209,36 +406,27 @@ function main() {
209
406
  const srcIdx = src.domain.layers.indexOf(src.layer);
210
407
 
211
408
  const content = readFileSync(file, "utf8");
212
- const lines = content.split("\n");
213
- // Workspace mode uses `useIdentPattern` (e.g. "unibot_{layer}");
214
- // single-crate mode keeps the historical `use crate::<layer>::` form.
215
- const workspaceMode = !!src.domain.useIdentPattern;
216
- for (let i = 0; i < lines.length; i++) {
217
- const codeOnly = stripCommentsAndStrings(lines[i]);
218
- const targets = workspaceMode
219
- ? parseUseTargets(codeOnly, src.domain)
220
- : parseUseCrate(codeOnly);
221
- for (const tgtLayer of targets) {
222
- if (!src.domain.layers.includes(tgtLayer)) continue;
223
- const tgtIdx = src.domain.layers.indexOf(tgtLayer);
224
- if (srcIdx < tgtIdx) {
225
- const key = `${relPath}::${tgtLayer}`;
226
- if (baselineSet.has(key)) continue;
227
- violations.push({
228
- file: relPath,
229
- line: i + 1,
230
- from: src.layer,
231
- to: tgtLayer,
232
- domain: src.domain.name,
233
- key,
234
- });
235
- }
409
+ const code = stripNonCode(content);
410
+ for (const { layer: tgtLayer, line } of findUseLayers(code, src.domain)) {
411
+ const tgtIdx = src.domain.layers.indexOf(tgtLayer);
412
+ if (tgtIdx === -1) continue;
413
+ if (srcIdx < tgtIdx) {
414
+ const key = `${relPath}::${tgtLayer}`;
415
+ if (baselineSet.has(key)) continue;
416
+ violations.push({
417
+ file: relPath,
418
+ line,
419
+ from: src.layer,
420
+ to: tgtLayer,
421
+ domain: src.domain.name,
422
+ key,
423
+ });
236
424
  }
237
425
  }
238
426
  }
239
427
  }
240
428
 
241
- // First-run baseline: no baseline file + violations → record + exit 0.
429
+ // First-run baseline.
242
430
  if (!baselineExists && violations.length > 0) {
243
431
  mkdirSync(join(repoRoot, ".harness"), { recursive: true });
244
432
  const keys = [...new Set(violations.map((v) => v.key))].sort();
@@ -276,4 +464,8 @@ function main() {
276
464
  process.exit(2);
277
465
  }
278
466
 
279
- main();
467
+ // Only run when invoked directly; allow unit tests to import the helpers.
468
+ const isMain = import.meta.url === `file://${process.argv[1]}`;
469
+ if (isMain) {
470
+ main();
471
+ }