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.
- package/README.md +1 -1
- package/dist/commander.d.ts +9 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commands/callers.d.ts.map +1 -1
- package/dist/commands/callers.js +69 -17
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +46 -2
- package/dist/commands/grep.d.ts +44 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +246 -45
- package/dist/core/agents/cli.js +48 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +10 -3
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +19 -0
- package/dist/lib/tools/ripgrep.d.ts +61 -0
- package/dist/lib/tools/ripgrep.d.ts.map +1 -0
- package/dist/lib/tools/ripgrep.js +217 -0
- package/package.json +1 -1
- package/src/commander.ts +9 -0
- package/src/commands/callers.ts +90 -26
- package/src/commands/doctor.ts +50 -2
- package/src/commands/grep.ts +277 -46
- package/src/core/agents/cli.ts +55 -0
- package/src/core/agents/state/heartbeat-projector.ts +11 -3
- package/src/core/config.ts +24 -0
- package/src/lib/tools/ripgrep.ts +244 -0
package/src/commands/grep.ts
CHANGED
|
@@ -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.
|
|
7
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
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))
|
|
324
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/agents/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
+
}
|