harnery 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.
@@ -1,15 +1,29 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import type { Command } from "commander";
3
3
  import type { EmitContext, HarneryProgramContext } from "../commander.ts";
4
+ import { resolveSearchEngine, type SearchEngine } from "../lib/tools/ripgrep.ts";
4
5
 
5
6
  /**
6
- * `grep`: monorepo-aware code search. Thin wrapper over grep -rn with
7
- * smart default excludes (skip dist/.next/node_modules/.git/...), repo
7
+ * `grep`: monorepo-aware code search. Prefers ripgrep (`rg`) when it is on
8
+ * PATH and falls back to GNU `grep -rn` transparently — both engines are
9
+ * driven with equivalent flags and their output is parsed into the same
10
+ * envelope, so results are identical (pinned by tests/unit/grep-engine.test.ts).
11
+ * Smart default excludes (skip dist/.next/node_modules/.git/...), repo
8
12
  * scoping (`--repo <name>` or `--all-repos`), and language presets.
9
13
  *
10
- * Default behavior matches grep's "regex" semantics (`-E` extended). Use
11
- * `-F` / `--literal` to pin to literal-string mode. Output is line-oriented
12
- * `file:line:content` in TTY mode, `{rows, total, truncated}` in --json mode.
14
+ * Engine selection: `HARNERY_GREP_ENGINE=rg|grep` forces one; otherwise `rg`
15
+ * is resolved (managed install, PATH, or opt-in auto-provision; see resolveEngine) and used when available. Repos are searched in
16
+ * parallel, and in `--all-repos` mode the parent scan prunes submodule
17
+ * directories so each match is attributed to exactly one repo (previously the
18
+ * parent scan descended into submodules, double-scanning and double-reporting
19
+ * every submodule match).
20
+ *
21
+ * Default behavior matches extended-regex semantics (`grep -E` / ripgrep's
22
+ * default). Use `-F` / `--literal` to pin to literal-string mode. Output is
23
+ * line-oriented `file:line:content` in TTY mode, `{rows, total, truncated}`
24
+ * in --json mode. Matches are sorted (file, then line) for stable output
25
+ * across runs and engines; with `-C <n>` context, engine order is kept so
26
+ * context groups stay adjacent.
13
27
  */
14
28
 
15
29
  const DEFAULT_EXCLUDE_DIRS = [
@@ -51,7 +65,9 @@ const LANG_GLOBS: Record<string, string[]> = {
51
65
  rs: ["*.rs"],
52
66
  };
53
67
 
54
- interface GrepOpts {
68
+ export type GrepEngine = SearchEngine;
69
+
70
+ export interface GrepOpts {
55
71
  repo?: string;
56
72
  allRepos?: boolean;
57
73
  lang?: string;
@@ -59,6 +75,7 @@ interface GrepOpts {
59
75
  wholeWord?: boolean;
60
76
  literal?: boolean;
61
77
  filesOnly?: boolean;
78
+ files?: boolean;
62
79
  count?: boolean;
63
80
  context?: string;
64
81
  maxCount?: string;
@@ -84,7 +101,8 @@ export function registerGrepCommand(
84
101
  program
85
102
  .command("grep <pattern> [paths...]")
86
103
  .description(
87
- "Monorepo-aware code search. Skips dist/.next/node_modules/.git/... by default. " +
104
+ "Monorepo-aware code search (ripgrep when available, GNU grep fallback). " +
105
+ "Skips dist/.next/node_modules/.git/... by default. " +
88
106
  "Use --repo, --all-repos, --lang for scoping. Regex by default; -F for literal.",
89
107
  )
90
108
  .option("--repo <name>", "Scope to one submodule (`.` = parent repo root)")
@@ -94,6 +112,11 @@ export function registerGrepCommand(
94
112
  .option("-w, --whole-word", "Match whole words only")
95
113
  .option("-F, --literal", "Treat <pattern> as a literal string (no regex)")
96
114
  .option("-l, --files-only", "Only print file names containing a match")
115
+ .option(
116
+ "--files",
117
+ "Filename search: treat <pattern> as a filename glob and list matching files " +
118
+ "(rg --files when available, POSIX find fallback)",
119
+ )
97
120
  .option("-c, --count", "Print match count per file (suppresses content)")
98
121
  .option("-C, --context <n>", "Print N lines of context around each match", "0")
99
122
  .option("--max-count <n>", "Stop after N matches per file")
@@ -122,9 +145,10 @@ function collect(value: string, prev: string[]): string[] {
122
145
  return [...prev, value];
123
146
  }
124
147
 
125
- interface GrepResult {
148
+ export interface GrepResult {
126
149
  pattern: string;
127
- mode: "regex" | "literal";
150
+ mode: "regex" | "literal" | "files";
151
+ engine: GrepEngine;
128
152
  repos: { name: string; cwd: string; matches: Match[]; truncated: boolean }[];
129
153
  total_matches: number;
130
154
  total_files: number;
@@ -132,39 +156,89 @@ interface GrepResult {
132
156
  elapsed_ms: number;
133
157
  }
134
158
 
135
- async function runGrep(
159
+ /** Exported for tests (not part of the package exports map). */
160
+ export async function runGrep(
136
161
  pattern: string,
137
162
  paths: string[],
138
163
  opts: GrepOpts,
139
164
  context: HarneryProgramContext | undefined,
140
165
  ): Promise<GrepResult> {
141
166
  if (!pattern) throw new Error("pattern required");
167
+ if (opts.files) {
168
+ // Filename mode lists files by name glob; content-search flags make no
169
+ // sense here — reject loudly rather than silently ignoring them.
170
+ const incompatible: [unknown, string][] = [
171
+ [opts.lang, "--lang"],
172
+ [opts.count, "-c/--count"],
173
+ [opts.wholeWord, "-w/--whole-word"],
174
+ [opts.literal, "-F/--literal"],
175
+ [opts.maxCount, "--max-count"],
176
+ [opts.include?.length, "--include"],
177
+ [Number.parseInt(opts.context ?? "0", 10) > 0 ? true : undefined, "-C/--context"],
178
+ ];
179
+ for (const [set, flag] of incompatible) {
180
+ if (set) throw new Error(`${flag} does not apply to --files (filename glob) mode`);
181
+ }
182
+ }
142
183
  const started = Date.now();
143
184
 
185
+ const { engine, rgBin } = await resolveSearchEngine("grep");
144
186
  const repos = resolveRepos(opts, context);
145
187
  const limit = opts.limit ? Number.parseInt(opts.limit, 10) : Number.POSITIVE_INFINITY;
188
+ const contextN = Number.parseInt(opts.context ?? "0", 10);
189
+ const sortable = !(Number.isFinite(contextN) && contextN > 0);
190
+
191
+ // Host-injected default excludes (generated mirrors, vendored trees, ...)
192
+ // ride the same --no-default-excludes gate as the built-in list.
193
+ const hostExcludeDirs = opts.noDefaultExcludes ? [] : (context?.grepExcludeDirs ?? []);
194
+
195
+ // All repos are searched concurrently; each is capped at the global limit
196
+ // (a single repo can never contribute more), then the global budget is
197
+ // applied in repo order below so `--limit` semantics stay deterministic.
198
+ const perRepo = await Promise.all(
199
+ repos.map((repo) => {
200
+ // In --all-repos mode the parent scan prunes submodule dirs — each
201
+ // submodule gets its own scoped scan, so descending from the parent
202
+ // would double-scan and double-report every submodule match. This
203
+ // pruning is correctness (one repo owns each match), so it applies
204
+ // even when --no-default-excludes is set.
205
+ const dedupeDirs = opts.allRepos && repo.name === "parent" ? (context?.submodules ?? []) : [];
206
+ const extraDirs = [...hostExcludeDirs, ...dedupeDirs];
207
+ return runGrepInRepo(
208
+ pattern,
209
+ paths,
210
+ opts,
211
+ repo.cwd,
212
+ repo.name,
213
+ limit,
214
+ engine,
215
+ rgBin,
216
+ extraDirs,
217
+ );
218
+ }),
219
+ );
146
220
 
147
221
  const allRepoResults: GrepResult["repos"] = [];
148
222
  let totalMatches = 0;
149
223
  const filesSeen = new Set<string>();
150
224
  let truncated = false;
225
+ let budget = limit;
151
226
 
152
- for (const repo of repos) {
153
- if (truncated) break;
154
- const repoLimit = Number.isFinite(limit)
155
- ? Math.max(0, limit - totalMatches)
156
- : Number.POSITIVE_INFINITY;
157
- if (repoLimit === 0) {
158
- truncated = true;
159
- allRepoResults.push({ name: repo.name, cwd: repo.cwd, matches: [], truncated: true });
160
- break;
161
- }
162
- const matches = await runGrepInRepo(pattern, paths, opts, repo.cwd, repo.name, repoLimit);
163
- let repoTruncated = false;
164
- if (Number.isFinite(repoLimit) && matches.length >= repoLimit) {
165
- repoTruncated = true;
166
- truncated = true;
227
+ for (let i = 0; i < repos.length; i++) {
228
+ const repo = repos[i];
229
+ if (!repo) continue;
230
+ const collected = perRepo[i] ?? [];
231
+ if (sortable) {
232
+ collected.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
167
233
  }
234
+ const engineCapped = Number.isFinite(limit) && collected.length >= limit;
235
+ const take = Number.isFinite(budget)
236
+ ? Math.min(collected.length, Math.max(0, budget))
237
+ : collected.length;
238
+ const matches = collected.slice(0, take);
239
+ const repoTruncated = engineCapped || collected.length > take;
240
+ if (repoTruncated) truncated = true;
241
+ if (Number.isFinite(budget)) budget -= take;
168
242
  totalMatches += matches.length;
169
243
  for (const m of matches) filesSeen.add(`${repo.name}/${m.file}`);
170
244
  allRepoResults.push({ name: repo.name, cwd: repo.cwd, matches, truncated: repoTruncated });
@@ -172,7 +246,8 @@ async function runGrep(
172
246
 
173
247
  return {
174
248
  pattern,
175
- mode: opts.literal ? "literal" : "regex",
249
+ mode: opts.files ? "files" : opts.literal ? "literal" : "regex",
250
+ engine,
176
251
  repos: allRepoResults,
177
252
  total_matches: totalMatches,
178
253
  total_files: filesSeen.size,
@@ -223,8 +298,42 @@ async function runGrepInRepo(
223
298
  cwd: string,
224
299
  repoName: string,
225
300
  limit: number,
301
+ engine: GrepEngine,
302
+ rgBin: string,
303
+ extraExcludeDirs: readonly string[],
226
304
  ): Promise<Match[]> {
227
- const args: string[] = ["-rn", "--color=never", "-I"]; // -I: skip binary files
305
+ // Filename mode: `rg --files` when available; POSIX `find` otherwise (GNU
306
+ // grep has no list-by-name mode). Content mode: rg / grep as selected.
307
+ const [bin, args] = opts.files
308
+ ? engine === "rg"
309
+ ? ([rgBin, buildRgFilesArgs(pattern, paths, opts, extraExcludeDirs)] as const)
310
+ : (["find", buildFindArgs(pattern, paths, opts, extraExcludeDirs)] as const)
311
+ : engine === "rg"
312
+ ? ([rgBin, buildRgArgs(pattern, paths, opts, extraExcludeDirs)] as const)
313
+ : (["grep", buildGrepArgs(pattern, paths, opts, extraExcludeDirs)] as const);
314
+
315
+ let matches = await execSearch(bin, args, cwd, limit);
316
+ // -c mode: GNU grep prints a `path:0` row for every searched file; ripgrep
317
+ // omits zero-count rows. Filter zeros on both engines so output matches.
318
+ if (opts.count) matches = matches.filter((m) => m.line > 0);
319
+ return matches.map((m) => ({ ...m, repo: repoName }));
320
+ }
321
+
322
+ function resolveLangGlobs(opts: GrepOpts): string[] | undefined {
323
+ const langGlobs = opts.lang ? LANG_GLOBS[opts.lang] : undefined;
324
+ if (opts.lang && !langGlobs) {
325
+ throw new Error(`unknown --lang "${opts.lang}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
326
+ }
327
+ return langGlobs;
328
+ }
329
+
330
+ function buildGrepArgs(
331
+ pattern: string,
332
+ paths: string[],
333
+ opts: GrepOpts,
334
+ extraExcludeDirs: readonly string[],
335
+ ): string[] {
336
+ const args: string[] = ["-rn", "-H", "--color=never", "-I"]; // -I: skip binary files
228
337
  if (opts.ignoreCase) args.push("-i");
229
338
  if (opts.wholeWord) args.push("-w");
230
339
  if (opts.literal) args.push("-F");
@@ -242,26 +351,138 @@ async function runGrepInRepo(
242
351
  for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--exclude-dir=${d}`);
243
352
  for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--exclude=${f}`);
244
353
  }
354
+ for (const d of extraExcludeDirs) args.push(`--exclude-dir=${d}`);
245
355
  for (const e of opts.exclude ?? []) args.push(`--exclude=${e}`);
246
356
 
247
- const langGlobs = opts.lang ? LANG_GLOBS[opts.lang] : undefined;
248
- if (opts.lang && !langGlobs) {
249
- throw new Error(`unknown --lang "${opts.lang}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
250
- }
357
+ const langGlobs = resolveLangGlobs(opts);
251
358
  if (langGlobs) for (const g of langGlobs) args.push(`--include=${g}`);
252
359
  for (const g of opts.include ?? []) args.push(`--include=${g}`);
253
360
 
254
361
  args.push("--", pattern);
255
362
  if (paths.length > 0) args.push(...paths);
256
363
  else args.push(".");
364
+ return args;
365
+ }
257
366
 
258
- const matches = await execGrep(args, cwd, limit);
259
- return matches.map((m) => ({ ...m, repo: repoName }));
367
+ function buildRgArgs(
368
+ pattern: string,
369
+ paths: string[],
370
+ opts: GrepOpts,
371
+ extraExcludeDirs: readonly string[],
372
+ ): string[] {
373
+ // --hidden --no-ignore: match GNU grep's semantics (search dotdirs, ignore
374
+ // .gitignore) so the only filters are the explicit exclude lists.
375
+ // --no-config: a user's ripgrep config file must not skew results.
376
+ // --with-filename: rg drops the file prefix for a single explicit file arg,
377
+ // which would break the shared parser.
378
+ const args: string[] = [
379
+ "-n",
380
+ "--no-heading",
381
+ "--with-filename",
382
+ "--color=never",
383
+ "--no-config",
384
+ "--hidden",
385
+ "--no-ignore",
386
+ ];
387
+ if (opts.ignoreCase) args.push("-i");
388
+ if (opts.wholeWord) args.push("-w");
389
+ if (opts.literal) args.push("-F");
390
+ if (opts.filesOnly) args.push("-l");
391
+ if (opts.count) args.push("-c");
392
+ const contextN = Number.parseInt(opts.context ?? "0", 10);
393
+ if (Number.isFinite(contextN) && contextN > 0) args.push(`-C${contextN}`);
394
+ if (opts.maxCount) {
395
+ const n = Number.parseInt(opts.maxCount, 10);
396
+ if (Number.isFinite(n) && n > 0) args.push(`-m${n}`);
397
+ }
398
+
399
+ // Gitignore-style globs: a bare name matches (and prunes) at any depth,
400
+ // mirroring grep's --exclude-dir / --exclude basename semantics. ORDER
401
+ // MATTERS: rg globs are last-match-wins, so positives (includes/lang) go
402
+ // first and negatives (excludes) last — otherwise `--include '*.md'` would
403
+ // re-include a .md file inside an excluded node_modules/. grep's
404
+ // --exclude-dir always beats --include, so this keeps the engines aligned.
405
+ const langGlobs = resolveLangGlobs(opts);
406
+ if (langGlobs) for (const g of langGlobs) args.push(`--glob=${g}`);
407
+ for (const g of opts.include ?? []) args.push(`--glob=${g}`);
408
+
409
+ if (!opts.noDefaultExcludes) {
410
+ for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--glob=!${d}`);
411
+ for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--glob=!${f}`);
412
+ }
413
+ for (const d of extraExcludeDirs) args.push(`--glob=!${d}`);
414
+ for (const e of opts.exclude ?? []) args.push(`--glob=!${e}`);
415
+
416
+ args.push("--", pattern);
417
+ if (paths.length > 0) args.push(...paths);
418
+ else args.push(".");
419
+ return args;
420
+ }
421
+
422
+ /**
423
+ * Filename mode via ripgrep: `--files` lists files; a single positive glob
424
+ * acts as a whitelist, negative globs prune. `--iglob` gives -i semantics.
425
+ */
426
+ function buildRgFilesArgs(
427
+ pattern: string,
428
+ paths: string[],
429
+ opts: GrepOpts,
430
+ extraExcludeDirs: readonly string[],
431
+ ): string[] {
432
+ const args: string[] = ["--files", "--hidden", "--no-ignore", "--no-config", "--color=never"];
433
+ // Positive pattern FIRST, negatives last (rg globs are last-match-wins;
434
+ // excludes must beat the pattern — see the ordering note in buildRgArgs).
435
+ args.push(`${opts.ignoreCase ? "--iglob" : "--glob"}=${pattern}`);
436
+ if (!opts.noDefaultExcludes) {
437
+ for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--glob=!${d}`);
438
+ for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--glob=!${f}`);
439
+ }
440
+ for (const d of extraExcludeDirs) args.push(`--glob=!${d}`);
441
+ for (const e of opts.exclude ?? []) args.push(`--glob=!${e}`);
442
+ if (paths.length > 0) args.push(...paths);
443
+ else args.push(".");
444
+ return args;
260
445
  }
261
446
 
262
- function execGrep(args: string[], cwd: string, limit: number): Promise<Omit<Match, "repo">[]> {
447
+ /**
448
+ * Filename mode via POSIX find (the no-ripgrep fallback):
449
+ * find <paths> ( -name d1 -o -name d2 ... ) -prune -o -type f -name <glob> [! -name <ex>]... -print
450
+ * Sticks to POSIX operators (`(`, `-o`, `!`) so BSD/macOS find behaves the same.
451
+ */
452
+ function buildFindArgs(
453
+ pattern: string,
454
+ paths: string[],
455
+ opts: GrepOpts,
456
+ extraExcludeDirs: readonly string[],
457
+ ): string[] {
458
+ const args: string[] = paths.length > 0 ? [...paths] : ["."];
459
+ const pruneDirs = [...(opts.noDefaultExcludes ? [] : DEFAULT_EXCLUDE_DIRS), ...extraExcludeDirs];
460
+ if (pruneDirs.length > 0) {
461
+ args.push("(");
462
+ pruneDirs.forEach((d, i) => {
463
+ if (i > 0) args.push("-o");
464
+ args.push("-name", d);
465
+ });
466
+ args.push(")", "-prune", "-o");
467
+ }
468
+ args.push("-type", "f", opts.ignoreCase ? "-iname" : "-name", pattern);
469
+ const fileExcludes = [
470
+ ...(opts.noDefaultExcludes ? [] : DEFAULT_EXCLUDE_FILES),
471
+ ...(opts.exclude ?? []),
472
+ ];
473
+ for (const e of fileExcludes) args.push("!", "-name", e);
474
+ args.push("-print");
475
+ return args;
476
+ }
477
+
478
+ function execSearch(
479
+ bin: string,
480
+ args: string[],
481
+ cwd: string,
482
+ limit: number,
483
+ ): Promise<Omit<Match, "repo">[]> {
263
484
  return new Promise((resolveP, reject) => {
264
- const proc = spawn("grep", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
485
+ const proc = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
265
486
  const matches: Omit<Match, "repo">[] = [];
266
487
  let buffer = "";
267
488
  let stderr = "";
@@ -297,9 +518,11 @@ function execGrep(args: string[], cwd: string, limit: number): Promise<Omit<Matc
297
518
  const parsed = parseGrepLine(buffer);
298
519
  if (parsed) matches.push(parsed);
299
520
  }
300
- // grep exits 1 on "no matches": normal, not an error.
301
- if (code !== null && code !== 0 && code !== 1 && !truncated) {
302
- reject(new Error(`grep exited ${code}: ${stderr.trim() || "(no stderr)"}`));
521
+ // Exit 1 is "no matches" on both engines: normal, not an error. Exit 2
522
+ // with matches collected means a partial failure (an unreadable file
523
+ // mid-walk); surface the results rather than throwing them away.
524
+ if (code !== null && code !== 0 && code !== 1 && !truncated && matches.length === 0) {
525
+ reject(new Error(`${bin} exited ${code}: ${stderr.trim() || "(no stderr)"}`));
303
526
  return;
304
527
  }
305
528
  resolveP(matches);
@@ -307,21 +530,23 @@ function execGrep(args: string[], cwd: string, limit: number): Promise<Omit<Matc
307
530
  });
308
531
  }
309
532
 
310
- /** Parse `path:line:content` from grep -n output. Returns null for ambiguous (no line number) lines. */
533
+ /** Parse `path:line:content` from grep/rg -n output. Returns null for ambiguous (no line number) lines. */
311
534
  function parseGrepLine(line: string): Omit<Match, "repo"> | null {
312
535
  // First colon ends path; second colon ends line number (if numeric).
313
536
  const firstColon = line.indexOf(":");
314
537
  if (firstColon < 0) {
315
538
  // -l mode: just a path. Encode as line=0, text="".
316
- return { file: line, line: 0, text: "" };
539
+ return { file: normalizeFile(line), line: 0, text: "" };
317
540
  }
318
541
  const after = line.slice(firstColon + 1);
319
542
  const secondColon = after.indexOf(":");
320
543
  if (secondColon < 0) {
321
544
  // -c mode: `path:count`.
322
545
  const count = Number.parseInt(after, 10);
323
- if (Number.isFinite(count)) return { file: line.slice(0, firstColon), line: count, text: "" };
324
- return { file: line.slice(0, firstColon), line: 0, text: after };
546
+ if (Number.isFinite(count)) {
547
+ return { file: normalizeFile(line.slice(0, firstColon)), line: count, text: "" };
548
+ }
549
+ return { file: normalizeFile(line.slice(0, firstColon)), line: 0, text: after };
325
550
  }
326
551
  const lineNum = Number.parseInt(after.slice(0, secondColon), 10);
327
552
  if (!Number.isFinite(lineNum)) {
@@ -329,12 +554,17 @@ function parseGrepLine(line: string): Omit<Match, "repo"> | null {
329
554
  return null;
330
555
  }
331
556
  return {
332
- file: line.slice(0, firstColon),
557
+ file: normalizeFile(line.slice(0, firstColon)),
333
558
  line: lineNum,
334
559
  text: after.slice(secondColon + 1),
335
560
  };
336
561
  }
337
562
 
563
+ /** Both engines may emit a leading `./` for the default path; strip it so output is engine-identical. */
564
+ function normalizeFile(file: string): string {
565
+ return file.startsWith("./") ? file.slice(2) : file;
566
+ }
567
+
338
568
  function renderResult(r: GrepResult, opts: GrepOpts): string {
339
569
  const lines: string[] = [];
340
570
  const showRepoHeader = r.repos.length > 1;
@@ -347,7 +577,7 @@ function renderResult(r: GrepResult, opts: GrepOpts): string {
347
577
  );
348
578
  }
349
579
  for (const m of repo.matches) {
350
- if (opts.filesOnly) {
580
+ if (opts.filesOnly || opts.files) {
351
581
  lines.push(m.file);
352
582
  } else if (opts.count) {
353
583
  lines.push(`${m.file}:${m.line}`);
@@ -360,12 +590,13 @@ function renderResult(r: GrepResult, opts: GrepOpts): string {
360
590
  }
361
591
  }
362
592
  if (r.total_matches === 0) {
363
- lines.push(`(no matches for /${r.pattern}/${r.mode === "literal" ? " (literal)" : ""})`);
593
+ const modeTag = r.mode === "literal" ? " (literal)" : r.mode === "files" ? " (files)" : "";
594
+ lines.push(`(no matches for /${r.pattern}/${modeTag})`);
364
595
  } else if (r.repos.length > 1 || r.truncated) {
365
596
  lines.push("");
366
597
  const summary = `${r.total_matches} match${r.total_matches === 1 ? "" : "es"} across ${r.total_files} file${r.total_files === 1 ? "" : "s"}`;
367
598
  const tail = r.truncated ? " (truncated; use --limit)" : "";
368
- lines.push(`${summary}${tail} (${r.elapsed_ms}ms)`);
599
+ lines.push(`${summary}${tail} (${r.elapsed_ms}ms, ${r.engine})`);
369
600
  }
370
601
  return lines.join("\n");
371
602
  }
@@ -164,6 +164,39 @@ async function handleProject(root: string, rest: string[]): Promise<number> {
164
164
  return 0;
165
165
  }
166
166
 
167
+ /**
168
+ * Append a canonical `claim.release` event for a path dropped from an owner's
169
+ * files_touched. The path is canonicalized to repo-relative (matching the
170
+ * projector's normalization) so the subtraction matches on replay regardless
171
+ * of the form the caller passed. Soft-fails: a failed emit must never break
172
+ * the release/kill flow — the file mutation already happened.
173
+ */
174
+ async function emitClaimRelease(
175
+ root: string,
176
+ owner: string,
177
+ hb: { session_id?: string; platform?: string },
178
+ path: string,
179
+ reason: "explicit" | "heal",
180
+ ): Promise<void> {
181
+ try {
182
+ const { emit } = await import("./events/emit.ts");
183
+ const canonical = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
184
+ const platform = hb.platform;
185
+ const harness =
186
+ platform === "cursor" ? "cursor" : platform === "codex" ? "codex" : "claude-code";
187
+ emit(root, {
188
+ event_type: "claim.release",
189
+ instance_id: owner,
190
+ session_id: hb.session_id ?? owner,
191
+ harness,
192
+ source: "agent-coord",
193
+ data: { path: canonical, reason },
194
+ });
195
+ } catch {
196
+ /* soft-fail: never break the caller */
197
+ }
198
+ }
199
+
167
200
  async function handleStateAction(root: string, action: string, rest: string[]): Promise<number> {
168
201
  const writer = await import("./state/heartbeat-writer.ts");
169
202
  const [owner, ...args] = rest;
@@ -210,15 +243,37 @@ async function handleStateAction(root: string, action: string, rest: string[]):
210
243
  process.stderr.write("agent-coord release-claim: missing <path>\n");
211
244
  return 2;
212
245
  }
246
+ const before = writer.readHeartbeat(root, owner);
213
247
  const hb = writer.releaseClaim(root, owner, path);
214
248
  if (!hb) return 1;
249
+ // Durability: the projector rebuilds files_touched by replaying the
250
+ // permanent Edit/Write events, so a file-only release is silently
251
+ // reverted by the next full replay. Emitting claim.release puts the
252
+ // subtraction into the stream so every future replay honors it. Only
253
+ // emit when the release actually removed a held path (idempotent
254
+ // re-releases stay quiet).
255
+ const heldBefore = before?.files_touched?.length ?? 0;
256
+ const heldAfter = hb.files_touched?.length ?? 0;
257
+ if (heldBefore > heldAfter) {
258
+ await emitClaimRelease(root, owner, before ?? hb, path, "explicit");
259
+ }
215
260
  process.stdout.write(
216
261
  `${JSON.stringify({ instance_id: owner, files_touched: hb.files_touched })}\n`,
217
262
  );
218
263
  return 0;
219
264
  }
220
265
  case "kill-heartbeat": {
266
+ // Read held claims BEFORE the unlink so they can be released durably —
267
+ // killing only the file leaves the claims resurrectable from the
268
+ // permanent Edit/Write events on the next full replay (observed: a
269
+ // 6-day-dead agent's claims returning after its heartbeat was killed).
270
+ const before = writer.readHeartbeat(root, owner);
221
271
  const ok = writer.killHeartbeat(root, owner);
272
+ if (ok && before) {
273
+ for (const held of before.files_touched ?? []) {
274
+ await emitClaimRelease(root, owner, before, held, "heal");
275
+ }
276
+ }
222
277
  process.stdout.write(`${JSON.stringify({ instance_id: owner, removed: ok })}\n`);
223
278
  return ok ? 0 : 1;
224
279
  }
@@ -88,7 +88,7 @@ export function projectHeartbeats(
88
88
  if (!existing && TERMINAL.has(ev.event_type)) continue;
89
89
  perOwner[ev.instance_id] = existing ?? seed(ev, coordRoot);
90
90
  }
91
- apply(perOwner[ev.instance_id]!, ev);
91
+ apply(perOwner[ev.instance_id]!, ev, coordRoot);
92
92
  }
93
93
 
94
94
  const written: string[] = [];
@@ -153,7 +153,7 @@ function seed(ev: CanonicalEvent, coordRoot: string): V2Heartbeat {
153
153
  return hb;
154
154
  }
155
155
 
156
- function apply(hb: V2Heartbeat, ev: CanonicalEvent): void {
156
+ function apply(hb: V2Heartbeat, ev: CanonicalEvent, coordRoot: string): void {
157
157
  hb.last_heartbeat = ev.ts;
158
158
  hb.last_event_id = ev.event_id;
159
159
  hb.events_applied += 1;
@@ -285,7 +285,15 @@ function apply(hb: V2Heartbeat, ev: CanonicalEvent): void {
285
285
  case "claim.release": {
286
286
  const path = pickStr(d, "path");
287
287
  if (path && hb.files_touched) {
288
- hb.files_touched = hb.files_touched.filter((p) => p !== path);
288
+ // files_touched holds a mix of absolute-under-coordRoot and canonical
289
+ // repo-relative entries (Edit events report absolute; release-claim
290
+ // canonicalizes to relative). Normalize both sides so a release
291
+ // subtracts regardless of form — an exact-string compare silently
292
+ // no-ops on the mismatch and the claim resurrects on the next replay.
293
+ const norm = (p: string): string =>
294
+ p.startsWith(`${coordRoot}/`) ? p.slice(coordRoot.length + 1) : p;
295
+ const target = norm(path);
296
+ hb.files_touched = hb.files_touched.filter((p) => norm(p) !== target);
289
297
  }
290
298
  break;
291
299
  }
@@ -33,6 +33,13 @@ interface HarneryConfig {
33
33
  * declares it here (e.g. "scripts/setup-hooks.sh"). Unset → a generic hint.
34
34
  */
35
35
  hooksSetupHint?: string;
36
+ /**
37
+ * Managed-tool provisioning consent. `{ ripgrep: { autoInstall: true } }`
38
+ * lets `grep` download the pinned, checksum-verified ripgrep into the
39
+ * harnery tools dir on first miss. Committed by a host repo once; absent →
40
+ * a missing rg only produces a rate-limited install hint.
41
+ */
42
+ tools?: { ripgrep?: { autoInstall?: boolean } };
36
43
  [k: string]: unknown;
37
44
  }
38
45
 
@@ -144,3 +151,20 @@ export function resolveHooksSetupHint(coordRoot?: string | null): string | null
144
151
  const hint = readConfig(root).hooksSetupHint;
145
152
  return typeof hint === "string" && hint.trim() ? hint.trim() : null;
146
153
  }
154
+
155
+ /**
156
+ * Whether the host project consented to automatic ripgrep provisioning:
157
+ * `.harnery/config.jsonc` `{ "tools": { "ripgrep": { "autoInstall": true } } }`.
158
+ * A repo commits that once and every clone self-heals on first `grep`; without
159
+ * it, a missing rg only produces a rate-limited hint (`doctor --fix` installs
160
+ * explicitly). `HARNERY_TOOLS_AUTOINSTALL=1|0` overrides per process.
161
+ * `coordRoot` is resolved via `findCoordRoot()` when not passed.
162
+ */
163
+ export function ripgrepAutoInstall(coordRoot?: string | null): boolean {
164
+ const env = coordEnv("TOOLS_AUTOINSTALL");
165
+ if (env === "1") return true;
166
+ if (env === "0") return false;
167
+ const root = coordRoot ?? findCoordRoot();
168
+ if (!root) return false;
169
+ return readConfig(root).tools?.ripgrep?.autoInstall === true;
170
+ }