harnery 0.7.1 → 0.9.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 (98) hide show
  1. package/README.md +2 -1
  2. package/dist/commander.d.ts +9 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/callers.d.ts.map +1 -1
  6. package/dist/commands/callers.js +69 -17
  7. package/dist/commands/doctor.d.ts.map +1 -1
  8. package/dist/commands/doctor.js +84 -2
  9. package/dist/commands/grep.d.ts +77 -0
  10. package/dist/commands/grep.d.ts.map +1 -1
  11. package/dist/commands/grep.js +598 -109
  12. package/dist/commands/init.d.ts +15 -5
  13. package/dist/commands/init.d.ts.map +1 -1
  14. package/dist/commands/init.js +125 -14
  15. package/dist/commands/workflow.d.ts +4 -0
  16. package/dist/commands/workflow.d.ts.map +1 -0
  17. package/dist/commands/workflow.js +89 -0
  18. package/dist/core/agents/cli.js +10 -3
  19. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  20. package/dist/core/agents/rules/claim-conflict.js +26 -1
  21. package/dist/core/agents/rules/stop-hook.d.ts +8 -0
  22. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  23. package/dist/core/agents/rules/stop-hook.js +8 -0
  24. package/dist/core/agents/state/heartbeat-projector.d.ts +2 -0
  25. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  26. package/dist/core/agents/state/heartbeat-projector.js +13 -2
  27. package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
  28. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  29. package/dist/core/agents/state/heartbeat-writer.js +21 -4
  30. package/dist/core/config.d.ts +18 -0
  31. package/dist/core/config.d.ts.map +1 -1
  32. package/dist/core/config.js +38 -0
  33. package/dist/core/hooks/cli.js +29 -3
  34. package/dist/core/hooks/events/schema.d.ts +4 -0
  35. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  36. package/dist/core/hooks/harness/events.d.ts +7 -0
  37. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  38. package/dist/core/hooks/harness/events.js +1 -0
  39. package/dist/core/hooks/resolve/coord-root.d.ts +11 -0
  40. package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
  41. package/dist/core/hooks/resolve/coord-root.js +28 -6
  42. package/dist/core/workflow/billing.d.ts +48 -0
  43. package/dist/core/workflow/billing.d.ts.map +1 -0
  44. package/dist/core/workflow/billing.js +102 -0
  45. package/dist/core/workflow/child-env.d.ts +30 -0
  46. package/dist/core/workflow/child-env.d.ts.map +1 -0
  47. package/dist/core/workflow/child-env.js +43 -0
  48. package/dist/core/workflow/engine.d.ts +21 -0
  49. package/dist/core/workflow/engine.d.ts.map +1 -0
  50. package/dist/core/workflow/engine.js +340 -0
  51. package/dist/core/workflow/harnesses.d.ts +17 -0
  52. package/dist/core/workflow/harnesses.d.ts.map +1 -0
  53. package/dist/core/workflow/harnesses.js +30 -0
  54. package/dist/core/workflow/spawn-claude.d.ts +22 -0
  55. package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
  56. package/dist/core/workflow/spawn-claude.js +82 -0
  57. package/dist/core/workflow/spawn-codex.d.ts +19 -0
  58. package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
  59. package/dist/core/workflow/spawn-codex.js +66 -0
  60. package/dist/core/workflow/spawn-cursor.d.ts +26 -0
  61. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
  62. package/dist/core/workflow/spawn-cursor.js +72 -0
  63. package/dist/core/workflow/types.d.ts +145 -0
  64. package/dist/core/workflow/types.d.ts.map +1 -0
  65. package/dist/core/workflow/types.js +9 -0
  66. package/dist/core/workflow/validate.d.ts +15 -0
  67. package/dist/core/workflow/validate.d.ts.map +1 -0
  68. package/dist/core/workflow/validate.js +70 -0
  69. package/dist/lib/tools/ripgrep.d.ts +61 -0
  70. package/dist/lib/tools/ripgrep.d.ts.map +1 -0
  71. package/dist/lib/tools/ripgrep.js +217 -0
  72. package/package.json +1 -1
  73. package/src/commander.ts +11 -0
  74. package/src/commands/callers.ts +90 -26
  75. package/src/commands/doctor.ts +95 -2
  76. package/src/commands/grep.ts +737 -113
  77. package/src/commands/init.ts +138 -17
  78. package/src/commands/workflow.ts +132 -0
  79. package/src/core/agents/cli.ts +11 -4
  80. package/src/core/agents/rules/claim-conflict.ts +26 -1
  81. package/src/core/agents/rules/stop-hook.ts +17 -0
  82. package/src/core/agents/state/heartbeat-projector.ts +13 -1
  83. package/src/core/agents/state/heartbeat-writer.ts +30 -5
  84. package/src/core/config.ts +47 -0
  85. package/src/core/hooks/cli.ts +30 -3
  86. package/src/core/hooks/events/schema.ts +4 -0
  87. package/src/core/hooks/harness/events.ts +8 -0
  88. package/src/core/hooks/resolve/coord-root.ts +28 -6
  89. package/src/core/workflow/billing.ts +146 -0
  90. package/src/core/workflow/child-env.ts +47 -0
  91. package/src/core/workflow/engine.ts +394 -0
  92. package/src/core/workflow/harnesses.ts +38 -0
  93. package/src/core/workflow/spawn-claude.ts +99 -0
  94. package/src/core/workflow/spawn-codex.ts +74 -0
  95. package/src/core/workflow/spawn-cursor.ts +89 -0
  96. package/src/core/workflow/types.ts +153 -0
  97. package/src/core/workflow/validate.ts +75 -0
  98. package/src/lib/tools/ripgrep.ts +244 -0
@@ -1,14 +1,17 @@
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
7
  * `harn callers <symbol>`: find references to a symbol across the monorepo with
7
- * kind classification (call / import / type / decl / ref). Thin wrapper over
8
- * grep -rnw with post-filtering: declarations + single-line comments are
9
- * filtered out by default, and lines starting with " * " (multi-line comment
10
- * continuation) are dropped. Inherent text-based-search limitations: multi-line
11
- * string literals and block-comment interiors aren't excluded.
8
+ * kind classification (call / import / type / decl / ref). Whole-word search
9
+ * (ripgrep when available, GNU grep fallback — same engine + provisioning path
10
+ * as `harn grep`, honoring HARNERY_GREP_ENGINE) with post-filtering:
11
+ * declarations + single-line comments are filtered out by default, and lines
12
+ * starting with " * " (multi-line comment continuation) are dropped. Inherent
13
+ * text-based-search limitations: multi-line string literals and block-comment
14
+ * interiors aren't excluded. Repos are searched in parallel.
12
15
  */
13
16
 
14
17
  interface CallersOpts {
@@ -116,21 +119,31 @@ async function runCallers(
116
119
  }
117
120
 
118
121
  const started = Date.now();
122
+ const { engine, rgBin } = await resolveSearchEngine("callers");
119
123
  const repos = resolveRepos(opts, context);
120
124
  const limit = opts.limit ? Number.parseInt(opts.limit, 10) : 500;
121
125
 
126
+ // Search every repo concurrently, each capped at the global limit, then
127
+ // apply the global budget in repo order so truncation stays deterministic.
128
+ // In --all-repos mode the parent scan prunes submodule dirs (each submodule
129
+ // is scanned on its own), so a match is attributed to exactly one repo.
130
+ const perRepo = await Promise.all(
131
+ repos.map((repo) => {
132
+ const pruneDirs = opts.allRepos && repo.name === "parent" ? (context?.submodules ?? []) : [];
133
+ return searchInRepo(symbol, opts, repo, limit, engine, rgBin, pruneDirs);
134
+ }),
135
+ );
136
+
122
137
  const allCallers: Caller[] = [];
123
138
  let truncated = false;
124
-
125
- for (const repo of repos) {
126
- if (allCallers.length >= limit) {
127
- truncated = true;
128
- break;
129
- }
130
- const remaining = limit - allCallers.length;
131
- const found = await grepInRepo(symbol, opts, repo, remaining);
139
+ let budget = limit;
140
+ for (let i = 0; i < repos.length; i++) {
141
+ const found = perRepo[i] ?? { callers: [], truncated: false };
132
142
  if (found.truncated) truncated = true;
133
- allCallers.push(...found.callers);
143
+ const take = Math.min(found.callers.length, Math.max(0, budget));
144
+ if (found.callers.length > take) truncated = true;
145
+ allCallers.push(...found.callers.slice(0, take));
146
+ budget -= take;
134
147
  }
135
148
 
136
149
  const by_kind: Record<string, number> = {};
@@ -184,24 +197,72 @@ function resolveRepos(
184
197
  return [{ name: "cwd", cwd: process.cwd() }];
185
198
  }
186
199
 
187
- function grepInRepo(
188
- symbol: string,
189
- opts: CallersOpts,
190
- repo: { name: string; cwd: string },
191
- limit: number,
192
- ): Promise<{ callers: Caller[]; truncated: boolean }> {
193
- const args: string[] = ["-rnw", "--color=never", "-I", "-E"];
194
- for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--exclude-dir=${d}`);
195
- for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--exclude=${f}`);
200
+ function resolveLangGlobs(opts: CallersOpts): string[] | undefined {
196
201
  const langGlobs = opts.lang ? LANG_GLOBS[opts.lang] : undefined;
197
202
  if (opts.lang && !langGlobs) {
198
203
  throw new Error(`unknown --lang "${opts.lang}". Valid: ${Object.keys(LANG_GLOBS).join(", ")}`);
199
204
  }
205
+ return langGlobs;
206
+ }
207
+
208
+ function buildCallersGrepArgs(
209
+ symbol: string,
210
+ opts: CallersOpts,
211
+ extraExcludeDirs: readonly string[],
212
+ ): string[] {
213
+ const args: string[] = ["-rnw", "-H", "--color=never", "-I", "-E"];
214
+ for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--exclude-dir=${d}`);
215
+ for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--exclude=${f}`);
216
+ for (const d of extraExcludeDirs) args.push(`--exclude-dir=${d}`);
217
+ const langGlobs = resolveLangGlobs(opts);
200
218
  if (langGlobs) for (const g of langGlobs) args.push(`--include=${g}`);
201
219
  args.push("--", symbol, ".");
220
+ return args;
221
+ }
222
+
223
+ function buildCallersRgArgs(
224
+ symbol: string,
225
+ opts: CallersOpts,
226
+ extraExcludeDirs: readonly string[],
227
+ ): string[] {
228
+ // --hidden --no-ignore --no-config: match grep semantics (dotdirs searched,
229
+ // .gitignore ignored, user rg config can't skew results). -w = whole word.
230
+ // Lang globs (positives) go before dir excludes (negatives) since rg globs
231
+ // are last-match-wins.
232
+ const args: string[] = [
233
+ "-nw",
234
+ "--no-heading",
235
+ "--with-filename",
236
+ "--color=never",
237
+ "--no-config",
238
+ "--hidden",
239
+ "--no-ignore",
240
+ ];
241
+ const langGlobs = resolveLangGlobs(opts);
242
+ if (langGlobs) for (const g of langGlobs) args.push(`--glob=${g}`);
243
+ for (const d of DEFAULT_EXCLUDE_DIRS) args.push(`--glob=!${d}`);
244
+ for (const f of DEFAULT_EXCLUDE_FILES) args.push(`--glob=!${f}`);
245
+ for (const d of extraExcludeDirs) args.push(`--glob=!${d}`);
246
+ args.push("--", symbol, ".");
247
+ return args;
248
+ }
249
+
250
+ function searchInRepo(
251
+ symbol: string,
252
+ opts: CallersOpts,
253
+ repo: { name: string; cwd: string },
254
+ limit: number,
255
+ engine: SearchEngine,
256
+ rgBin: string,
257
+ extraExcludeDirs: readonly string[],
258
+ ): Promise<{ callers: Caller[]; truncated: boolean }> {
259
+ const [bin, args] =
260
+ engine === "rg"
261
+ ? ([rgBin, buildCallersRgArgs(symbol, opts, extraExcludeDirs)] as const)
262
+ : (["grep", buildCallersGrepArgs(symbol, opts, extraExcludeDirs)] as const);
202
263
 
203
264
  return new Promise((resolveP, reject) => {
204
- const proc = spawn("grep", args, { cwd: repo.cwd, stdio: ["ignore", "pipe", "pipe"] });
265
+ const proc = spawn(bin, args, { cwd: repo.cwd, stdio: ["ignore", "pipe", "pipe"] });
205
266
  const callers: Caller[] = [];
206
267
  let buffer = "";
207
268
  let stderr = "";
@@ -237,8 +298,11 @@ function grepInRepo(
237
298
  const parsed = parseCallerLine(buffer, symbol, opts, repo.name);
238
299
  if (parsed) callers.push(parsed);
239
300
  }
240
- if (code !== null && code !== 0 && code !== 1 && !truncated) {
241
- reject(new Error(`grep exited ${code}: ${stderr.trim() || "(no stderr)"}`));
301
+ // Exit 1 = no matches (both engines): normal. Exit 2 with nothing
302
+ // collected = a real failure; with matches, a partial walk error we
303
+ // tolerate (surface what was found).
304
+ if (code !== null && code !== 0 && code !== 1 && !truncated && callers.length === 0) {
305
+ reject(new Error(`${bin} exited ${code}: ${stderr.trim() || "(no stderr)"}`));
242
306
  return;
243
307
  }
244
308
  resolveP({ callers, truncated });
@@ -17,8 +17,16 @@ import os from "node:os";
17
17
  import path from "node:path";
18
18
  import type { Command } from "commander";
19
19
  import type { EmitContext } from "../commander.ts";
20
- import { resolveBinName } from "../core/config.ts";
20
+ import { resolveBinName, ripgrepAutoInstall } from "../core/config.ts";
21
21
  import { loadHarnessWiring } from "../core/hooks/harness/wiring.ts";
22
+ import { probeBilling } from "../core/workflow/billing.ts";
23
+ import {
24
+ HARNESS_BINARIES,
25
+ HARNESS_INSTALL_HINTS,
26
+ HARNESS_LOGIN_HINTS,
27
+ } from "../core/workflow/harnesses.ts";
28
+ import type { HarnessName } from "../core/workflow/types.ts";
29
+ import { findRg, installRg, managedRgPath, rgInstallSupported } from "../lib/tools/ripgrep.ts";
22
30
 
23
31
  type Severity = "ok" | "warn" | "fail";
24
32
 
@@ -31,6 +39,7 @@ interface Check {
31
39
 
32
40
  interface CheckOpts {
33
41
  json?: boolean;
42
+ fix?: boolean;
34
43
  }
35
44
 
36
45
  export function registerDoctorCommand(program: Command, emit: EmitContext): void {
@@ -41,7 +50,18 @@ export function registerDoctorCommand(program: Command, emit: EmitContext): void
41
50
  "unless a required dep (Node, git) is missing.",
42
51
  )
43
52
  .option("--json", "Machine-readable JSON output")
44
- .action((opts: CheckOpts) => {
53
+ .option(
54
+ "--fix",
55
+ "Install missing managed tools (currently: ripgrep, pinned + checksum-verified)",
56
+ )
57
+ .action(async (opts: CheckOpts) => {
58
+ if (opts.fix && !findRg() && rgInstallSupported()) {
59
+ try {
60
+ await installRg((line) => emit.text(`${line}\n`));
61
+ } catch (err) {
62
+ emit.text(`ripgrep install failed: ${(err as Error).message}\n`);
63
+ }
64
+ }
45
65
  const checks = runChecks();
46
66
  const requiredFailed = checks.some((c) => c.severity === "fail");
47
67
 
@@ -80,8 +100,12 @@ export function runChecks(): Check[] {
80
100
  checkNode(),
81
101
  checkGit(),
82
102
  checkBun(),
103
+ checkRipgrep(),
83
104
  checkHarneryDir(),
84
105
  checkHarnessHooks(),
106
+ checkWorkflowHarness("claude-code"),
107
+ checkWorkflowHarness("codex"),
108
+ checkWorkflowHarness("cursor"),
85
109
  checkRestic(),
86
110
  checkRclone(),
87
111
  checkPlaywright(),
@@ -89,6 +113,40 @@ export function runChecks(): Check[] {
89
113
  ];
90
114
  }
91
115
 
116
+ function checkRipgrep(): Check {
117
+ const found = findRg();
118
+ if (found) {
119
+ const r = whichVersion(found);
120
+ const managed = found === managedRgPath() ? ", managed" : "";
121
+ return {
122
+ name: "ripgrep",
123
+ severity: "ok",
124
+ detail: `${r.out.replace(/^ripgrep\s*/, "")} (${found === "rg" ? "PATH" : found}${managed})`,
125
+ };
126
+ }
127
+ if (!rgInstallSupported()) {
128
+ return {
129
+ name: "ripgrep",
130
+ severity: "warn",
131
+ detail: "missing (grep fallback; no pinned artifact for this OS/arch)",
132
+ hint: "https://github.com/BurntSushi/ripgrep#installation",
133
+ };
134
+ }
135
+ if (ripgrepAutoInstall()) {
136
+ return {
137
+ name: "ripgrep",
138
+ severity: "warn",
139
+ detail: "missing (autoInstall on: will self-provision on first grep)",
140
+ };
141
+ }
142
+ return {
143
+ name: "ripgrep",
144
+ severity: "warn",
145
+ detail: "missing (grep fallback works, just slower)",
146
+ hint: `${resolveBinName()} doctor --fix (pinned + checksum-verified, installs to the harnery tools dir)`,
147
+ };
148
+ }
149
+
92
150
  function whichVersion(bin: string, args: string[] = ["--version"]): { ok: boolean; out: string } {
93
151
  const r = spawnSync(bin, args, { encoding: "utf-8" });
94
152
  if (r.status !== 0) return { ok: false, out: "" };
@@ -96,6 +154,41 @@ function whichVersion(bin: string, args: string[] = ["--version"]): { ok: boolea
96
154
  return { ok: true, out };
97
155
  }
98
156
 
157
+ /**
158
+ * One workflow spawn target: is the harness CLI installed, and how will its
159
+ * headless children bill (subscription login vs API key — see billing.ts)?
160
+ * Missing is a warn, not a fail: workflows degrade to the harnesses you have.
161
+ */
162
+ function checkWorkflowHarness(harness: HarnessName): Check {
163
+ const bin = HARNESS_BINARIES[harness];
164
+ const name = `workflow:${harness}`;
165
+ const r = whichVersion(bin);
166
+ if (!r.ok) {
167
+ return {
168
+ name,
169
+ severity: "warn",
170
+ detail: `${bin} missing (workflow --harness ${harness} unavailable)`,
171
+ hint: `${HARNESS_INSTALL_HINTS[harness]} then: ${HARNESS_LOGIN_HINTS[harness]}`,
172
+ };
173
+ }
174
+ const probe = probeBilling(harness);
175
+ if (probe.login === "absent" && !probe.apiKeyPresent) {
176
+ return {
177
+ name,
178
+ severity: "warn",
179
+ detail: `${r.out} — installed, but no stored login or API key detected`,
180
+ hint: HARNESS_LOGIN_HINTS[harness],
181
+ };
182
+ }
183
+ const billing =
184
+ probe.mode === "subscription"
185
+ ? probe.login === "present"
186
+ ? "billing: subscription"
187
+ : "billing: subscription (login unverifiable, CLI is the authority)"
188
+ : `billing: ${probe.mode} (${probe.apiKeySource})`;
189
+ return { name, severity: "ok", detail: `${r.out} — ${billing}` };
190
+ }
191
+
99
192
  function checkNode(): Check {
100
193
  const v = process.versions.node;
101
194
  const major = Number.parseInt(v.split(".")[0], 10);