@lovrozagar/grunt 0.5.0 → 0.6.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 (153) hide show
  1. package/.agents/agents/grunt.md +2 -23
  2. package/.agents/agents/orchestrator.md +5 -45
  3. package/.agents/skills/ask/SKILL.md +11 -0
  4. package/.agents/skills/auto/SKILL.md +13 -0
  5. package/.agents/skills/browser/SKILL.md +4 -7
  6. package/.agents/skills/clasp/SKILL.md +11 -0
  7. package/.agents/skills/commit/SKILL.md +3 -1
  8. package/.agents/skills/commit-push/SKILL.md +2 -0
  9. package/.agents/skills/commit-push-deploy/SKILL.md +2 -0
  10. package/.agents/skills/commit-push-release/SKILL.md +2 -0
  11. package/.agents/skills/explain/SKILL.md +7 -13
  12. package/.agents/skills/google-workspace/SKILL.md +13 -0
  13. package/.agents/skills/handoff/SKILL.md +7 -11
  14. package/.agents/skills/implement-plan/SKILL.md +5 -108
  15. package/.agents/skills/listen/SKILL.md +15 -0
  16. package/.agents/skills/pickup/SKILL.md +6 -12
  17. package/.agents/skills/speak/SKILL.md +13 -0
  18. package/.agents/skills/tmp/SKILL.md +54 -0
  19. package/.agents/skills/write-plan/SKILL.md +5 -74
  20. package/.claude/agents/grunt.md +2 -25
  21. package/.claude/agents/orchestrator.md +12 -45
  22. package/.claude/skills/ask/SKILL.md +11 -0
  23. package/.claude/skills/auto/SKILL.md +13 -0
  24. package/.claude/skills/browser/SKILL.md +4 -7
  25. package/.claude/skills/clasp/SKILL.md +11 -0
  26. package/.claude/skills/commit/SKILL.md +3 -1
  27. package/.claude/skills/commit-push/SKILL.md +2 -0
  28. package/.claude/skills/commit-push-deploy/SKILL.md +2 -0
  29. package/.claude/skills/commit-push-release/SKILL.md +2 -0
  30. package/.claude/skills/explain/SKILL.md +7 -13
  31. package/.claude/skills/google-workspace/SKILL.md +13 -0
  32. package/.claude/skills/handoff/SKILL.md +7 -11
  33. package/.claude/skills/implement-plan/SKILL.md +5 -108
  34. package/.claude/skills/listen/SKILL.md +15 -0
  35. package/.claude/skills/pickup/SKILL.md +6 -12
  36. package/.claude/skills/speak/SKILL.md +13 -0
  37. package/.claude/skills/tmp/SKILL.md +54 -0
  38. package/.claude/skills/write-plan/SKILL.md +5 -74
  39. package/.codex/agents/grunt.toml +2 -25
  40. package/.codex/agents/orchestrator.toml +4 -45
  41. package/.grok/agents/grunt.md +2 -23
  42. package/.grok/agents/orchestrator.md +4 -43
  43. package/.grok/global-settings.toml +1 -3
  44. package/.grok/hooks/orchestrate-parent.js +278 -228
  45. package/.grok/skills/ask/SKILL.md +11 -0
  46. package/.grok/skills/auto/SKILL.md +13 -0
  47. package/.grok/skills/browser/SKILL.md +4 -7
  48. package/.grok/skills/clasp/SKILL.md +11 -0
  49. package/.grok/skills/commit/SKILL.md +3 -1
  50. package/.grok/skills/commit-push/SKILL.md +2 -0
  51. package/.grok/skills/commit-push-deploy/SKILL.md +2 -0
  52. package/.grok/skills/commit-push-release/SKILL.md +2 -0
  53. package/.grok/skills/explain/SKILL.md +7 -13
  54. package/.grok/skills/google-workspace/SKILL.md +13 -0
  55. package/.grok/skills/handoff/SKILL.md +7 -11
  56. package/.grok/skills/implement-plan/SKILL.md +5 -108
  57. package/.grok/skills/listen/SKILL.md +15 -0
  58. package/.grok/skills/pickup/SKILL.md +6 -12
  59. package/.grok/skills/speak/SKILL.md +13 -0
  60. package/.grok/skills/tmp/SKILL.md +54 -0
  61. package/.grok/skills/write-plan/SKILL.md +5 -74
  62. package/.rulesync/hooks.jsonc +1 -1
  63. package/.rulesync/mcp-policy.jsonc +0 -2
  64. package/.rulesync/reference/INDEX.md +30 -17
  65. package/.rulesync/reference/browser.md +13 -6
  66. package/.rulesync/reference/cascade.md +6 -84
  67. package/.rulesync/reference/clasp.md +21 -0
  68. package/.rulesync/reference/google-workspace.md +79 -0
  69. package/.rulesync/reference/hooks.md +7 -9
  70. package/.rulesync/reference/implementation-format.md +113 -0
  71. package/.rulesync/reference/law.md +5 -3
  72. package/.rulesync/reference/listen.md +80 -0
  73. package/.rulesync/reference/map.md +39 -31
  74. package/.rulesync/reference/output.md +3 -41
  75. package/.rulesync/reference/plan-format.md +14 -12
  76. package/.rulesync/reference/refs-map.md +12 -5
  77. package/.rulesync/reference/rtk.md +3 -2
  78. package/.rulesync/reference/scope.md +37 -0
  79. package/.rulesync/reference/setup.md +27 -0
  80. package/.rulesync/reference/skills-map.md +13 -9
  81. package/.rulesync/reference/speak.md +59 -0
  82. package/.rulesync/rules/CLAUDE.md +3 -44
  83. package/.rulesync/rules/overview.md +9 -42
  84. package/.rulesync/skills/ask/SKILL.md +11 -0
  85. package/.rulesync/skills/auto/SKILL.md +11 -0
  86. package/.rulesync/skills/browser/SKILL.md +4 -8
  87. package/.rulesync/skills/clasp/SKILL.md +9 -0
  88. package/.rulesync/skills/commit/SKILL.md +3 -1
  89. package/.rulesync/skills/commit-push/SKILL.md +2 -0
  90. package/.rulesync/skills/commit-push-deploy/SKILL.md +2 -0
  91. package/.rulesync/skills/commit-push-release/SKILL.md +2 -0
  92. package/.rulesync/skills/explain/SKILL.md +7 -14
  93. package/.rulesync/skills/google-workspace/SKILL.md +11 -0
  94. package/.rulesync/skills/handoff/SKILL.md +7 -11
  95. package/.rulesync/skills/implement-plan/SKILL.md +5 -116
  96. package/.rulesync/skills/listen/SKILL.md +13 -0
  97. package/.rulesync/skills/pickup/SKILL.md +6 -12
  98. package/.rulesync/skills/speak/SKILL.md +13 -0
  99. package/.rulesync/skills/tmp/SKILL.md +54 -0
  100. package/.rulesync/skills/write-plan/SKILL.md +5 -80
  101. package/.rulesync/subagents/grunt.md +2 -23
  102. package/.rulesync/subagents/orchestrator.md +14 -54
  103. package/AGENTS.md +9 -42
  104. package/CHANGELOG.md +89 -0
  105. package/CLAUDE.md +1 -44
  106. package/README.md +97 -88
  107. package/cli/grunt.mjs +29 -13
  108. package/cli/grunt.test.ts +62 -20
  109. package/cli/init.mjs +218 -31
  110. package/cli/init.test.ts +669 -46
  111. package/cli/prompt.mjs +8 -0
  112. package/cli/prompt.test.ts +14 -0
  113. package/package.json +13 -1
  114. package/scripts/browser.mjs +98 -5
  115. package/scripts/doctor.mjs +82 -1
  116. package/scripts/emit-gemini.mjs +1 -1
  117. package/scripts/emit-mcp-policy.mjs +1 -1
  118. package/scripts/gate-fat-tools.mjs +223 -173
  119. package/scripts/google-workspace.mjs +713 -0
  120. package/scripts/grunt-job.mjs +453 -69
  121. package/scripts/listen.mjs +829 -0
  122. package/scripts/parse-need.mjs +21 -2
  123. package/scripts/persist-implementation.mjs +278 -0
  124. package/scripts/persist-plan.mjs +17 -2
  125. package/scripts/persist-tmp.mjs +123 -0
  126. package/scripts/scrub-spawn-prompt.mjs +4 -51
  127. package/scripts/setup.mjs +746 -0
  128. package/scripts/speak.mjs +291 -0
  129. package/.agents/agents/implementer.md +0 -32
  130. package/.agents/agents/thinker.md +0 -28
  131. package/.agents/skills/cascade/SKILL.md +0 -26
  132. package/.agents/skills/parent/SKILL.md +0 -6
  133. package/.agents/skills/solo/SKILL.md +0 -23
  134. package/.claude/agents/implementer.md +0 -37
  135. package/.claude/agents/thinker.md +0 -35
  136. package/.claude/skills/cascade/SKILL.md +0 -26
  137. package/.claude/skills/parent/SKILL.md +0 -6
  138. package/.claude/skills/solo/SKILL.md +0 -23
  139. package/.codex/agents/implementer.toml +0 -26
  140. package/.codex/agents/thinker.toml +0 -25
  141. package/.grok/agents/implementer.md +0 -27
  142. package/.grok/agents/thinker.md +0 -26
  143. package/.grok/parent.md +0 -1
  144. package/.grok/roles/implementer.toml +0 -5
  145. package/.grok/roles/thinker.toml +0 -5
  146. package/.grok/skills/cascade/SKILL.md +0 -26
  147. package/.grok/skills/parent/SKILL.md +0 -6
  148. package/.grok/skills/solo/SKILL.md +0 -23
  149. package/.rulesync/skills/cascade/SKILL.md +0 -26
  150. package/.rulesync/skills/parent/SKILL.md +0 -6
  151. package/.rulesync/skills/solo/SKILL.md +0 -23
  152. package/.rulesync/subagents/implementer.md +0 -49
  153. package/.rulesync/subagents/thinker.md +0 -48
@@ -1,16 +1,35 @@
1
1
  #!/usr/bin/env node
2
- /** Deterministic grunt for job: search|exec|test. Exit 2 + FALLBACK on web/HTML/unbounded/denylist. */
2
+ /** Deterministic grunt for job: search|exec|test|slice|fetch. Exit 2 + FALLBACK on denylist/HTML-as-exec/unbounded. */
3
3
  import { spawnSync } from "node:child_process";
4
+ import crypto from "node:crypto";
4
5
  import fs from "node:fs";
5
6
  import path from "node:path";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
7
8
 
8
9
  export const FALLBACK = "FALLBACK";
9
10
  export const SHELL_META = /[|&;`$(){}<>\n\r]/;
11
+ export const MAX_FACTS = 6;
12
+ export const MAX_STDOUT = 32 * 1024;
13
+ export const STASH_CAP = 2 * 1024 * 1024;
14
+ export const MAX_HITS = 10_000;
15
+ export const STASH_REL = ".tmp/grunt/stash";
16
+
10
17
  const DENY_SEGS = new Set(["node_modules", "dist", ".next", "build", "coverage"]);
11
18
  const DENY_LOCK = /\b(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)\b/;
12
- const MAX_FACTS = 6;
13
- const MAX_STDOUT = 32 * 1024;
19
+ const FAIL_RE = /\b(FAIL(?:ED)?|ERROR|Error:|Exception|panic|E\d{3,})\b/;
20
+ const STACK_RE = /^\s+at\s/;
21
+ const PASS_RE = /^\s*(✓|√|PASS|ok |passed)\b/i;
22
+ const KNOWN_FLAGS = new Set([
23
+ "--job",
24
+ "--query",
25
+ "--path",
26
+ "--glob",
27
+ "--cwd",
28
+ "--stash",
29
+ "--from",
30
+ "--to",
31
+ ]);
32
+ const JOBS = new Set(["search", "exec", "test", "slice", "fetch"]);
14
33
 
15
34
  function takeFlag(argv, i, prefix) {
16
35
  const a = argv[i];
@@ -24,10 +43,18 @@ export function parseArgv(argv) {
24
43
  let query = "";
25
44
  let searchPath = "";
26
45
  let cwd = "";
46
+ let stash = "";
47
+ let from = "";
48
+ let to = "";
27
49
  const glob = [];
28
50
  let unknown = false;
29
51
  for (let i = 0; i < argv.length; i++) {
30
52
  const a = argv[i];
53
+ const prefix = a.startsWith("--") ? a.split("=")[0] : "";
54
+ if (prefix && !KNOWN_FLAGS.has(prefix)) {
55
+ unknown = true;
56
+ continue;
57
+ }
31
58
  const jobF = takeFlag(argv, i, "--job");
32
59
  if (jobF) {
33
60
  job = jobF.value;
@@ -58,6 +85,24 @@ export function parseArgv(argv) {
58
85
  i = globF.next;
59
86
  continue;
60
87
  }
88
+ const stashF = takeFlag(argv, i, "--stash");
89
+ if (stashF) {
90
+ stash = stashF.value;
91
+ i = stashF.next;
92
+ continue;
93
+ }
94
+ const fromF = takeFlag(argv, i, "--from");
95
+ if (fromF) {
96
+ from = fromF.value;
97
+ i = fromF.next;
98
+ continue;
99
+ }
100
+ const toF = takeFlag(argv, i, "--to");
101
+ if (toF) {
102
+ to = toF.value;
103
+ i = toF.next;
104
+ continue;
105
+ }
61
106
  unknown = true;
62
107
  }
63
108
  return {
@@ -66,6 +111,9 @@ export function parseArgv(argv) {
66
111
  path: String(searchPath),
67
112
  glob,
68
113
  cwd: String(cwd),
114
+ stash: String(stash).trim(),
115
+ from: String(from).trim(),
116
+ to: String(to).trim(),
69
117
  unknown,
70
118
  };
71
119
  }
@@ -83,8 +131,15 @@ export function resolveJobCwd(cwd, workspaceRoot) {
83
131
  return resolveUnder(workspaceRoot || process.cwd(), cwd);
84
132
  }
85
133
 
86
- export function shouldFallback(job, query) {
87
- if (job !== "search" && job !== "exec" && job !== "test") return true;
134
+ function escapeRe(s) {
135
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
136
+ }
137
+
138
+ export function shouldFallback(job, query, extra = {}) {
139
+ const j = String(job || "").trim().toLowerCase();
140
+ if (!JOBS.has(j)) return true;
141
+ if (j === "slice") return !String(extra.stash || "").trim();
142
+ if (j === "fetch") return !/^https?:\/\//i.test(String(query || "").trim());
88
143
  const q = String(query || "");
89
144
  if (!q.trim()) return true;
90
145
  if (/\b(curl|wget)\b/i.test(q)) return true;
@@ -98,33 +153,192 @@ export function shouldFallback(job, query) {
98
153
  return false;
99
154
  }
100
155
 
101
- function escapeRe(s) {
102
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
156
+ export function isHtml(stdout, stderr) {
157
+ const s = String(stdout || "") + String(stderr || "");
158
+ return /<\s*html\b/i.test(s) || /<\s*!DOCTYPE html/i.test(s);
103
159
  }
104
160
 
105
161
  export function looksHtmlOrUnbounded(stdout, stderr) {
106
162
  const s = String(stdout || "") + String(stderr || "");
107
163
  if (s.length > MAX_STDOUT) return true;
108
- if (/<\s*html\b/i.test(s) || /<\s*!DOCTYPE html/i.test(s)) return true;
109
- return false;
164
+ return isHtml(stdout, stderr);
110
165
  }
111
166
 
112
- export function formatVerdict({ status, n, facts, errors }) {
113
- const lines = [`verdict: ${status}`, `n: ${n}`];
114
- if (status === "fail") {
167
+ export function squeezLines(lines, query) {
168
+ const arr = (lines || []).map((l, i) => ({ l: String(l), i }));
169
+ if (arr.length <= MAX_FACTS) return arr.map((x) => x.l);
170
+ const q = String(query || "").trim();
171
+ let queryRe = null;
172
+ if (q) {
173
+ try {
174
+ queryRe = new RegExp(q);
175
+ } catch {
176
+ queryRe = null;
177
+ }
178
+ }
179
+ function score(item) {
180
+ const s = item.l;
181
+ if (FAIL_RE.test(s) || STACK_RE.test(s)) return 100;
182
+ if (queryRe) {
183
+ const hit = queryRe.test(s);
184
+ queryRe.lastIndex = 0;
185
+ if (hit) return 50;
186
+ } else if (q && s.includes(q)) return 50;
187
+ if (item.i === 0 || item.i === arr.length - 1) return 20;
188
+ if (PASS_RE.test(s)) return 0;
189
+ return 10;
190
+ }
191
+ const ranked = arr.map((x) => ({ ...x, sc: score(x) }));
192
+ const kept = [];
193
+ const used = new Set();
194
+ function take(pred) {
195
+ for (const x of ranked) {
196
+ if (kept.length >= MAX_FACTS) break;
197
+ if (used.has(x.i)) continue;
198
+ if (pred(x)) {
199
+ kept.push(x);
200
+ used.add(x.i);
201
+ }
202
+ }
203
+ }
204
+ take((x) => x.sc >= 100);
205
+ take((x) => x.sc >= 50);
206
+ if (!used.has(0) && kept.length < MAX_FACTS) {
207
+ kept.push(ranked[0]);
208
+ used.add(0);
209
+ }
210
+ const last = arr.length - 1;
211
+ if (!used.has(last) && kept.length < MAX_FACTS) {
212
+ kept.push(ranked[last]);
213
+ used.add(last);
214
+ }
215
+ take((x) => x.sc > 0);
216
+ take(() => true);
217
+ kept.sort((a, b) => a.i - b.i);
218
+ return kept.map((x) => x.l);
219
+ }
220
+
221
+ export function formatFacts({
222
+ kind,
223
+ n = 0,
224
+ facts,
225
+ errors,
226
+ failed = false,
227
+ stash = "",
228
+ truncated = false,
229
+ } = {}) {
230
+ const lines = [];
231
+ const shown = (facts || []).slice(0, MAX_FACTS);
232
+ if (failed) {
233
+ const head =
234
+ kind === "search" ? "Search failed." : "Command failed.";
235
+ lines.push(stash ? `${head.slice(0, -1)}. stash=${stash}` : head);
115
236
  for (const e of (errors || []).slice(0, 3)) lines.push(`- ${e}`);
237
+ return lines.slice(0, 8).join("\n") + "\n";
238
+ }
239
+ if (!n) {
240
+ lines.push(kind === "search" ? "No matches." : "No output.");
241
+ return lines.join("\n") + "\n";
242
+ }
243
+ const unit =
244
+ kind === "search" ? (n === 1 ? "match" : "matches") : n === 1 ? "line" : "lines";
245
+ const nLabel = truncated ? `${n}+` : String(n);
246
+ if (stash) {
247
+ lines.push(`${nLabel} ${unit}, ${shown.length} shown. stash=${stash}`);
248
+ } else if (kind === "search") {
249
+ lines.push(n === 1 ? "1 match." : `${n} matches.`);
116
250
  } else {
117
- for (const f of (facts || []).slice(0, MAX_FACTS)) lines.push(`- ${f}`);
251
+ lines.push(n === 1 ? "1 line." : `${n} lines.`);
118
252
  }
253
+ for (const f of shown) lines.push(`- ${f}`);
119
254
  return lines.slice(0, 8).join("\n") + "\n";
120
255
  }
121
256
 
257
+ function stashId() {
258
+ const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
259
+ const b = crypto.randomBytes(4);
260
+ let s = "";
261
+ for (const x of b) s += alphabet[x % 36];
262
+ return s;
263
+ }
264
+
265
+ export function writeStash(cwd, kind, raw) {
266
+ const root = cwd || process.cwd();
267
+ const dir = path.join(root, ".tmp", "grunt", "stash");
268
+ fs.mkdirSync(dir, { recursive: true });
269
+ const name = `${kind}-${stashId()}.txt`;
270
+ let body = String(raw || "");
271
+ const buf = Buffer.from(body, "utf8");
272
+ if (buf.length > STASH_CAP) body = buf.subarray(0, STASH_CAP).toString("utf8");
273
+ if (!body.endsWith("\n")) body += "\n";
274
+ fs.writeFileSync(path.join(dir, name), body);
275
+ return `${STASH_REL}/${name}`;
276
+ }
277
+
278
+ function relStash(abs, cwd) {
279
+ const root = cwd || process.cwd();
280
+ return path.relative(root, abs).replace(/\\/g, "/");
281
+ }
282
+
283
+ export function resolveStashFile(cwd, stash) {
284
+ const id = String(stash || "").trim();
285
+ if (!id || id.includes("..") || path.isAbsolute(id) || /[\\/]/.test(id)) {
286
+ if (id && !id.includes("..") && !path.isAbsolute(id)) {
287
+ const under = String(id).replace(/\\/g, "/");
288
+ if (under.startsWith(".tmp/grunt/stash/") && !under.includes("..")) {
289
+ const abs = resolveUnder(cwd || process.cwd(), under);
290
+ if (abs && fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
291
+ }
292
+ }
293
+ if (!id || id.includes("..") || path.isAbsolute(id)) return null;
294
+ }
295
+ const dir = path.join(cwd || process.cwd(), ".tmp", "grunt", "stash");
296
+ if (!fs.existsSync(dir)) return null;
297
+ const names = fs.readdirSync(dir);
298
+ const exact = names.find((n) => n === id || n === `${id}.txt`);
299
+ if (exact) return path.join(dir, exact);
300
+ const hit = names.find((n) => n.includes(id) && n.endsWith(".txt"));
301
+ return hit ? path.join(dir, hit) : null;
302
+ }
303
+
304
+ function verdictFromLines({
305
+ kind,
306
+ lines,
307
+ raw,
308
+ query,
309
+ cwd,
310
+ failed = false,
311
+ errors,
312
+ truncated = false,
313
+ }) {
314
+ const n = lines.length;
315
+ const shown = squeezLines(lines, query);
316
+ let stash = "";
317
+ const rawText = raw != null ? String(raw) : lines.join("\n");
318
+ if (n > MAX_FACTS || Buffer.byteLength(rawText, "utf8") > MAX_STDOUT) {
319
+ stash = writeStash(cwd, kind, rawText);
320
+ }
321
+ if (failed) {
322
+ const err = (errors && errors.length ? errors : shown).slice(0, 3);
323
+ return {
324
+ fallback: false,
325
+ text: formatFacts({ kind, failed: true, errors: err, stash, n, facts: shown }),
326
+ code: 0,
327
+ };
328
+ }
329
+ return {
330
+ fallback: false,
331
+ text: formatFacts({ kind, n, facts: shown, stash, truncated }),
332
+ code: 0,
333
+ };
334
+ }
335
+
122
336
  function runCmd(file, args, cwd) {
123
337
  return spawnSync(file, args, {
124
338
  encoding: "utf8",
125
339
  cwd: cwd || process.cwd(),
126
340
  timeout: 20_000,
127
- maxBuffer: MAX_STDOUT + 4096,
341
+ maxBuffer: STASH_CAP + 4096,
128
342
  env: process.env,
129
343
  });
130
344
  }
@@ -137,7 +351,6 @@ const SEARCH_SKIP_DIRS = new Set([
137
351
  "coverage",
138
352
  ".git",
139
353
  ]);
140
- const SEARCH_MAX_HITS = 20;
141
354
  const SEARCH_MAX_FILE = 200 * 1024;
142
355
 
143
356
  function parseSearchHits(stdout) {
@@ -150,22 +363,7 @@ function parseSearchHits(stdout) {
150
363
  if (!m) return line.slice(0, 160);
151
364
  return `${m[1]}:${m[2]} — ${m[3].trim().slice(0, 120)}`;
152
365
  });
153
- return { n: hits.length, facts };
154
- }
155
-
156
- function verdictFromHits({ n, facts }) {
157
- if (!n) {
158
- return {
159
- fallback: false,
160
- text: formatVerdict({ status: "empty", n: 0, facts: [] }),
161
- code: 0,
162
- };
163
- }
164
- return {
165
- fallback: false,
166
- text: formatVerdict({ status: "ok", n, facts }),
167
- code: 0,
168
- };
366
+ return { n: hits.length, facts, raw: hits.join("\n") };
169
367
  }
170
368
 
171
369
  export function globToRegExp(glob) {
@@ -211,11 +409,12 @@ function nodeSearch(query, { cwd, start, glob } = {}) {
211
409
  }
212
410
  const facts = [];
213
411
  let n = 0;
412
+ let bytes = 0;
214
413
  const root = cwd || process.cwd();
215
414
  const origin = start || root;
216
415
 
217
416
  function searchFile(abs) {
218
- if (n >= SEARCH_MAX_HITS) return;
417
+ if (n >= MAX_HITS || bytes >= STASH_CAP) return;
219
418
  let st;
220
419
  try {
221
420
  st = fs.statSync(abs);
@@ -239,15 +438,15 @@ function nodeSearch(query, { cwd, start, glob } = {}) {
239
438
  if (re) re.lastIndex = 0;
240
439
  if (!hit) continue;
241
440
  n += 1;
242
- if (facts.length < MAX_FACTS) {
243
- facts.push(`${rel}:${i + 1} — ${line.trim().slice(0, 120)}`);
244
- }
245
- if (n >= SEARCH_MAX_HITS) return;
441
+ const fact = `${rel}:${i + 1} — ${line.trim().slice(0, 120)}`;
442
+ facts.push(fact);
443
+ bytes += Buffer.byteLength(fact, "utf8") + 1;
444
+ if (n >= MAX_HITS || bytes >= STASH_CAP) return;
246
445
  }
247
446
  }
248
447
 
249
448
  function walk(dir) {
250
- if (n >= SEARCH_MAX_HITS) return;
449
+ if (n >= MAX_HITS || bytes >= STASH_CAP) return;
251
450
  let ents;
252
451
  try {
253
452
  ents = fs.readdirSync(dir, { withFileTypes: true });
@@ -255,7 +454,7 @@ function nodeSearch(query, { cwd, start, glob } = {}) {
255
454
  return;
256
455
  }
257
456
  for (const ent of ents) {
258
- if (n >= SEARCH_MAX_HITS) return;
457
+ if (n >= MAX_HITS || bytes >= STASH_CAP) return;
259
458
  if (SEARCH_SKIP_DIRS.has(ent.name)) continue;
260
459
  const abs = path.join(dir, ent.name);
261
460
  if (ent.isDirectory()) {
@@ -271,11 +470,11 @@ function nodeSearch(query, { cwd, start, glob } = {}) {
271
470
  try {
272
471
  st = fs.statSync(origin);
273
472
  } catch {
274
- return { n: 0, facts: [] };
473
+ return { n: 0, facts: [], truncated: false };
275
474
  }
276
475
  if (st.isFile()) searchFile(origin);
277
476
  else walk(origin);
278
- return { n, facts };
477
+ return { n, facts, truncated: n >= MAX_HITS || bytes >= STASH_CAP };
279
478
  }
280
479
 
281
480
  export function runSearch(query, { cwd, path: searchPath, glob } = {}) {
@@ -289,7 +488,7 @@ export function runSearch(query, { cwd, path: searchPath, glob } = {}) {
289
488
  const rgArgs = [
290
489
  "-n",
291
490
  "--max-count",
292
- "20",
491
+ String(MAX_HITS),
293
492
  "--max-filesize",
294
493
  "200K",
295
494
  "-g",
@@ -313,14 +512,27 @@ export function runSearch(query, { cwd, path: searchPath, glob } = {}) {
313
512
  if (!(r.error && r.error.code === "ENOENT")) {
314
513
  const stdout = String(r.stdout || "");
315
514
  const stderr = String(r.stderr || "");
316
- if (looksHtmlOrUnbounded(stdout, stderr)) {
515
+ if (isHtml(stdout, stderr)) {
317
516
  return { fallback: true, text: FALLBACK + "\n", code: 2 };
318
517
  }
319
518
  if (r.status === 1) {
320
- return verdictFromHits({ n: 0, facts: [] });
519
+ return {
520
+ fallback: false,
521
+ text: formatFacts({ kind: "search", n: 0, facts: [] }),
522
+ code: 0,
523
+ };
321
524
  }
322
525
  if (r.status === 0) {
323
- return verdictFromHits(parseSearchHits(stdout));
526
+ const parsed = parseSearchHits(stdout);
527
+ const truncated = parsed.n >= MAX_HITS;
528
+ return verdictFromLines({
529
+ kind: "search",
530
+ lines: parsed.facts,
531
+ raw: parsed.raw,
532
+ query,
533
+ cwd: root,
534
+ truncated,
535
+ });
324
536
  }
325
537
  if (r.status != null) {
326
538
  const errors = stderr
@@ -329,9 +541,9 @@ export function runSearch(query, { cwd, path: searchPath, glob } = {}) {
329
541
  .filter(Boolean);
330
542
  return {
331
543
  fallback: false,
332
- text: formatVerdict({
333
- status: "fail",
334
- n: 0,
544
+ text: formatFacts({
545
+ kind: "search",
546
+ failed: true,
335
547
  errors: errors.length ? errors : [`rg exit ${r.status}`],
336
548
  }),
337
549
  code: 0,
@@ -339,7 +551,15 @@ export function runSearch(query, { cwd, path: searchPath, glob } = {}) {
339
551
  }
340
552
  }
341
553
 
342
- return verdictFromHits(nodeSearch(query, { cwd: root, start, glob: globs }));
554
+ const node = nodeSearch(query, { cwd: root, start, glob: globs });
555
+ return verdictFromLines({
556
+ kind: "search",
557
+ lines: node.facts,
558
+ raw: node.facts.join("\n"),
559
+ query,
560
+ cwd: root,
561
+ truncated: node.truncated,
562
+ });
343
563
  }
344
564
 
345
565
  function alreadyRtk(query) {
@@ -368,9 +588,9 @@ export function runExec(query, { cwd } = {}) {
368
588
  if (r.error && r.error.code === "ENOENT") {
369
589
  return {
370
590
  fallback: false,
371
- text: formatVerdict({
372
- status: "fail",
373
- n: 0,
591
+ text: formatFacts({
592
+ kind: "exec",
593
+ failed: true,
374
594
  errors: [String(r.error.code || "ENOENT")],
375
595
  }),
376
596
  code: 0,
@@ -378,7 +598,7 @@ export function runExec(query, { cwd } = {}) {
378
598
  }
379
599
  const stdout = String(r.stdout || "");
380
600
  const stderr = String(r.stderr || "");
381
- if (looksHtmlOrUnbounded(stdout, stderr)) {
601
+ if (isHtml(stdout, stderr)) {
382
602
  return { fallback: true, text: FALLBACK + "\n", code: 2 };
383
603
  }
384
604
  if (r.status !== 0) {
@@ -393,15 +613,17 @@ export function runExec(query, { cwd } = {}) {
393
613
  .filter(Boolean);
394
614
  errors.push(...fromOut);
395
615
  }
396
- return {
397
- fallback: false,
398
- text: formatVerdict({
399
- status: "fail",
400
- n: 0,
401
- errors: errors.length ? errors : [`exit ${r.status}`],
402
- }),
403
- code: 0,
404
- };
616
+ const raw = [stdout, stderr].filter(Boolean).join("\n");
617
+ const lines = (errors.length ? errors : [`exit ${r.status}`]);
618
+ return verdictFromLines({
619
+ kind: "exec",
620
+ lines,
621
+ raw,
622
+ query: q,
623
+ cwd,
624
+ failed: true,
625
+ errors: lines,
626
+ });
405
627
  }
406
628
  const lines = stdout
407
629
  .split(/\n/)
@@ -410,31 +632,190 @@ export function runExec(query, { cwd } = {}) {
410
632
  if (!lines.length && !stderr.trim()) {
411
633
  return {
412
634
  fallback: false,
413
- text: formatVerdict({ status: "empty", n: 0, facts: [] }),
635
+ text: formatFacts({ kind: "exec", n: 0, facts: [] }),
636
+ code: 0,
637
+ };
638
+ }
639
+ const body = lines.length ? lines : [stderr.trim()];
640
+ return verdictFromLines({
641
+ kind: "exec",
642
+ lines: body,
643
+ raw: stdout || stderr,
644
+ query: q,
645
+ cwd,
646
+ });
647
+ }
648
+
649
+ export function runSlice({ stash, path: filter, from, to, cwd } = {}) {
650
+ const abs = resolveStashFile(cwd, stash);
651
+ if (!abs) {
652
+ return {
653
+ fallback: false,
654
+ text: formatFacts({
655
+ kind: "exec",
656
+ failed: true,
657
+ errors: ["missing stash"],
658
+ }),
659
+ code: 0,
660
+ };
661
+ }
662
+ let text;
663
+ try {
664
+ text = fs.readFileSync(abs, "utf8");
665
+ } catch {
666
+ return {
667
+ fallback: false,
668
+ text: formatFacts({
669
+ kind: "exec",
670
+ failed: true,
671
+ errors: ["missing stash"],
672
+ }),
673
+ code: 0,
674
+ };
675
+ }
676
+ if (text.includes("\0")) {
677
+ return {
678
+ fallback: false,
679
+ text: formatFacts({
680
+ kind: "exec",
681
+ failed: true,
682
+ errors: ["binary stash"],
683
+ }),
414
684
  code: 0,
415
685
  };
416
686
  }
417
- const facts = (lines.length ? lines : [stderr.trim()]).slice(0, MAX_FACTS);
687
+ const numbered = text.split(/\n/).map((l, i) => ({ n: i + 1, l }));
688
+ let picked = numbered;
689
+ const a = from !== "" && from != null ? Number(from) : null;
690
+ const b = to !== "" && to != null ? Number(to) : null;
691
+ if (Number.isFinite(a) || Number.isFinite(b)) {
692
+ const start = Number.isFinite(a) ? a : 1;
693
+ const end = Number.isFinite(b) ? b : numbered.length;
694
+ picked = numbered.filter((x) => x.n >= start && x.n <= end);
695
+ }
696
+ if (filter) picked = picked.filter((x) => x.l.includes(String(filter)));
697
+ const factLines = picked.map(
698
+ (x) => `stash:${x.n} — ${x.l.trim().slice(0, 120)}`,
699
+ );
700
+ const rel = relStash(abs, cwd);
701
+ if (picked.length <= MAX_FACTS) {
702
+ return {
703
+ fallback: false,
704
+ text: formatFacts({ kind: "exec", n: picked.length, facts: factLines }),
705
+ code: 0,
706
+ };
707
+ }
708
+ const shown = squeezLines(factLines, filter);
418
709
  return {
419
710
  fallback: false,
420
- text: formatVerdict({
421
- status: "ok",
422
- n: lines.length || facts.length,
423
- facts,
711
+ text: formatFacts({
712
+ kind: "exec",
713
+ n: picked.length,
714
+ facts: shown,
715
+ stash: rel,
424
716
  }),
425
717
  code: 0,
426
718
  };
427
719
  }
428
720
 
721
+ function stripHtml(html) {
722
+ const s = String(html || "");
723
+ const title = (s.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1] || "";
724
+ const text = s
725
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
726
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
727
+ .replace(/<[^>]+>/g, " ")
728
+ .replace(/&nbsp;/g, " ")
729
+ .replace(/&amp;/g, "&")
730
+ .replace(/&lt;/g, "<")
731
+ .replace(/&gt;/g, ">")
732
+ .replace(/\s+/g, " ")
733
+ .trim();
734
+ return { title: String(title).replace(/\s+/g, " ").trim(), text };
735
+ }
736
+
737
+ export function runFetch(query, { cwd } = {}) {
738
+ const url = String(query || "").trim();
739
+ if (!/^https?:\/\//i.test(url)) {
740
+ return { fallback: true, text: FALLBACK + "\n", code: 2 };
741
+ }
742
+ const script = `
743
+ const u = process.argv[1];
744
+ const cap = ${STASH_CAP};
745
+ fetch(u, { redirect: "follow" })
746
+ .then(async (r) => {
747
+ const buf = Buffer.from(await r.arrayBuffer());
748
+ process.stdout.write(buf.subarray(0, cap));
749
+ })
750
+ .catch((e) => {
751
+ process.stderr.write(String(e && e.message ? e.message : e));
752
+ process.exit(1);
753
+ });
754
+ `;
755
+ const r = spawnSync(process.execPath, ["-e", script, url], {
756
+ encoding: "utf8",
757
+ cwd: cwd || process.cwd(),
758
+ timeout: 20_000,
759
+ maxBuffer: STASH_CAP + 4096,
760
+ env: process.env,
761
+ });
762
+ if (r.status !== 0) {
763
+ const err = String(r.stderr || r.stdout || "fetch failed")
764
+ .split(/\n/)
765
+ .map((l) => l.trim())
766
+ .filter(Boolean);
767
+ return {
768
+ fallback: false,
769
+ text: formatFacts({
770
+ kind: "exec",
771
+ failed: true,
772
+ errors: err.length ? err.slice(0, 3) : ["fetch failed"],
773
+ }),
774
+ code: 0,
775
+ };
776
+ }
777
+ const raw = String(r.stdout || "");
778
+ const { title, text } = stripHtml(raw);
779
+ const lines = [];
780
+ if (title) lines.push(title);
781
+ for (const part of text.split(/(?<=\.)\s+/)) {
782
+ const p = part.trim();
783
+ if (p) lines.push(p.slice(0, 160));
784
+ }
785
+ const body = lines.length ? lines : ["(empty)"];
786
+ return verdictFromLines({
787
+ kind: "exec",
788
+ lines: body,
789
+ raw,
790
+ query: url,
791
+ cwd,
792
+ truncated: Buffer.byteLength(raw, "utf8") >= STASH_CAP,
793
+ });
794
+ }
795
+
429
796
  function fallbackResult() {
430
797
  return { fallback: true, text: FALLBACK + "\n", code: 2 };
431
798
  }
432
799
 
433
- export function runJob({ job, query, cwd, path: searchPath, glob, unknown } = {}) {
800
+ export function runJob({
801
+ job,
802
+ query,
803
+ cwd,
804
+ path: searchPath,
805
+ glob,
806
+ stash,
807
+ from,
808
+ to,
809
+ unknown,
810
+ } = {}) {
434
811
  if (unknown) return fallbackResult();
435
- if (shouldFallback(job, query)) return fallbackResult();
812
+ if (shouldFallback(job, query, { stash })) return fallbackResult();
436
813
  const root = cwd || process.cwd();
437
814
  if (job === "search") return runSearch(query, { cwd: root, path: searchPath, glob });
815
+ if (job === "slice") {
816
+ return runSlice({ stash, path: searchPath, from, to, cwd: root });
817
+ }
818
+ if (job === "fetch") return runFetch(query, { cwd: root });
438
819
  return runExec(query, { cwd: root });
439
820
  }
440
821
 
@@ -457,6 +838,9 @@ function main() {
457
838
  cwd,
458
839
  path: parsed.path,
459
840
  glob: parsed.glob,
841
+ stash: parsed.stash,
842
+ from: parsed.from,
843
+ to: parsed.to,
460
844
  });
461
845
  process.stdout.write(result.text);
462
846
  return result.code;