@lovrozagar/grunt 0.1.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 (79) hide show
  1. package/.agents/agents/grunt.md +36 -0
  2. package/.agents/agents/implementer.md +40 -0
  3. package/.agents/agents/orchestrator.md +50 -0
  4. package/.agents/agents/thinker.md +27 -0
  5. package/.agents/hooks.json +26 -0
  6. package/.agents/mcp_config.json +3 -0
  7. package/.claude/agents/grunt.md +44 -0
  8. package/.claude/agents/implementer.md +46 -0
  9. package/.claude/agents/orchestrator.md +55 -0
  10. package/.claude/agents/thinker.md +34 -0
  11. package/.claude/settings.json +36 -0
  12. package/.codex/agents/grunt.toml +30 -0
  13. package/.codex/agents/implementer.toml +34 -0
  14. package/.codex/agents/orchestrator.toml +45 -0
  15. package/.codex/agents/thinker.toml +23 -0
  16. package/.codex/config.toml +2 -0
  17. package/.codex/hooks.json +26 -0
  18. package/.grok/agents/grunt.md +31 -0
  19. package/.grok/agents/implementer.md +35 -0
  20. package/.grok/agents/orchestrator.md +47 -0
  21. package/.grok/agents/thinker.md +25 -0
  22. package/.grok/config.toml +6 -0
  23. package/.grok/global-settings.toml +35 -0
  24. package/.grok/hooks/orchestrate-parent.js +476 -0
  25. package/.grok/hooks/orchestrate-parent.json +60 -0
  26. package/.grok/hooks/rtk-grok.js +137 -0
  27. package/.grok/hooks/rtk.json +16 -0
  28. package/.grok/hooks/rulesync.json +3 -0
  29. package/.grok/parent.md +1 -0
  30. package/.grok/roles/grunt.toml +5 -0
  31. package/.grok/roles/implementer.toml +5 -0
  32. package/.grok/roles/thinker.toml +5 -0
  33. package/.grok/skills/explain/SKILL.md +15 -0
  34. package/.grok/skills/implement-plan/SKILL.md +91 -0
  35. package/.grok/skills/parent/SKILL.md +12 -0
  36. package/.grok/skills/shared/plan-format.md +158 -0
  37. package/.grok/skills/write-plan/SKILL.md +84 -0
  38. package/.mcp.json +3 -0
  39. package/.rulesync/global-settings/antigravity.json +1 -0
  40. package/.rulesync/global-settings/claude.json +1 -0
  41. package/.rulesync/global-settings/codex.toml +1 -0
  42. package/.rulesync/global-settings/manifest.json +37 -0
  43. package/.rulesync/hooks.jsonc +22 -0
  44. package/.rulesync/mcp-policy.jsonc +15 -0
  45. package/.rulesync/reference/cascade.md +85 -0
  46. package/.rulesync/reference/hooks.md +19 -0
  47. package/.rulesync/reference/map.md +57 -0
  48. package/.rulesync/reference/rtk.md +23 -0
  49. package/.rulesync/rules/CLAUDE.md +13 -0
  50. package/.rulesync/rules/overview.md +23 -0
  51. package/.rulesync/skills/explain/SKILL.md +15 -0
  52. package/.rulesync/skills/parent/SKILL.md +12 -0
  53. package/.rulesync/skills/terse/SKILL.md +8 -0
  54. package/.rulesync/subagents/grunt.md +51 -0
  55. package/.rulesync/subagents/implementer.md +54 -0
  56. package/.rulesync/subagents/orchestrator.md +69 -0
  57. package/.rulesync/subagents/thinker.md +44 -0
  58. package/AGENTS.md +12 -0
  59. package/CLAUDE.md +7 -0
  60. package/LICENSE +21 -0
  61. package/README.md +56 -0
  62. package/bin/grunt.js +4 -0
  63. package/cli/grunt.mjs +54 -0
  64. package/cli/grunt.test.ts +117 -0
  65. package/cli/init.mjs +113 -0
  66. package/cli/init.test.ts +268 -0
  67. package/package.json +77 -0
  68. package/scripts/check-globals.mjs +98 -0
  69. package/scripts/emit-mcp-policy.mjs +434 -0
  70. package/scripts/gate-fat-tools.mjs +485 -0
  71. package/scripts/grunt-job.mjs +482 -0
  72. package/scripts/parse-need.mjs +75 -0
  73. package/scripts/persist-plan.mjs +309 -0
  74. package/scripts/purge-global-mcps.mjs +291 -0
  75. package/scripts/scrub-spawn-prompt.mjs +219 -0
  76. package/scripts/scrub-text +58 -0
  77. package/scripts/scrub-text-lib.mjs +210 -0
  78. package/scripts/sync-global-settings.mjs +239 -0
  79. package/scripts/telemetry.mjs +38 -0
@@ -0,0 +1,485 @@
1
+ #!/usr/bin/env node
2
+ /** PreToolUse: fat Read/Grep/Glob/Bash gate. Fail-open: parse/crash → empty stdout, exit 0. */
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+
7
+ /** Parent Grep head_limit when the model omits one. */
8
+ export const DEFAULT_GREP_HEAD_LIMIT = 50;
9
+ /** Parent Read line limit when the model omits one. */
10
+ export const DEFAULT_READ_LIMIT = 200;
11
+ /** Implementer/thinker (and unknown non-grunt) Grep head_limit when omitted. */
12
+ export const CHILD_GREP_HEAD_LIMIT = 150;
13
+ /** Implementer/thinker (and unknown non-grunt) Read line limit when omitted. */
14
+ export const CHILD_READ_LIMIT = 400;
15
+ /** Deny if requested Grep head_limit or Read limit exceeds this. */
16
+ export const MAX_REQUEST_LIMIT = 500;
17
+ /** Deny Read when the file is larger than this, even with a line limit. */
18
+ export const DENY_FILE_BYTES = 200 * 1024;
19
+
20
+ export const DENY_PATH_SEGMENTS = new Set([
21
+ "node_modules",
22
+ "dist",
23
+ ".next",
24
+ "build",
25
+ "coverage",
26
+ ]);
27
+ export const DENY_LOCKFILES = new Set([
28
+ "package-lock.json",
29
+ "pnpm-lock.yaml",
30
+ "yarn.lock",
31
+ ]);
32
+
33
+ export const REASON_DENYLIST =
34
+ "path denylist; use package API / spawn grunt job:search";
35
+ export const REASON_HEAD_LIMIT =
36
+ "spawn grunt job:search; head_limit>500";
37
+ export const REASON_FILE_SIZE = "spawn grunt job:search; file >200KB";
38
+ export const REASON_IMPLEMENTER_BASH =
39
+ "need: grunt job:exec|test query:…";
40
+
41
+ const READ_TOOLS = new Set(["read", "readfile"]);
42
+ const GREP_TOOLS = new Set(["grep", "grepsearch"]);
43
+ const GLOB_TOOLS = new Set(["glob", "listdir"]);
44
+ const BASH_TOOLS = new Set(["bash", "runterminalcommand"]);
45
+ const PATH_FIELDS = [
46
+ "path",
47
+ "file_path",
48
+ "filePath",
49
+ "target_file",
50
+ "targetFile",
51
+ "target_directory",
52
+ "targetDirectory",
53
+ "glob",
54
+ "glob_pattern",
55
+ "globPattern",
56
+ ];
57
+ const BASH_DUMP_CMDS =
58
+ /\b(cat|tac|less|more|head|tail|nl|bat|hexdump|xxd|rg|grep|egrep|fgrep|find|fd)\b/;
59
+ const BASH_SEARCH = /\b(rg|grep|egrep|fgrep|find|fd)\b/;
60
+ const BASH_WEB = /\b(curl|wget)\b/;
61
+ const BASH_TEST =
62
+ /\b(?:npm|pnpm|yarn|bun)\s+test\b|\bcargo\s+test\b|\bpytest\b|\bvitest\b|\bjest\b/;
63
+ export const SHELL_META = /[|&;`$(){}<>\n\r]/;
64
+ const GRUNT_JOB_FLAGS = new Set(["--job", "--query", "--path", "--glob", "--cwd"]);
65
+
66
+ export function eventKey(s) {
67
+ return String(s || "")
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9]/g, "");
70
+ }
71
+
72
+ export function subagentTypeOf(data) {
73
+ if (!data || typeof data !== "object") return "";
74
+ const raw =
75
+ data.subagentType ??
76
+ data.subagent_type ??
77
+ data.agentType ??
78
+ data.agent_type ??
79
+ "";
80
+ return String(raw).trim().toLowerCase();
81
+ }
82
+
83
+ export function isParentOrchestrator(data) {
84
+ return !subagentTypeOf(data);
85
+ }
86
+
87
+ export function toolInputOf(data) {
88
+ if (!data || typeof data !== "object") return null;
89
+ let toolInput = data.toolInput;
90
+ if (toolInput == null) toolInput = data.tool_input;
91
+ if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) {
92
+ return null;
93
+ }
94
+ return toolInput;
95
+ }
96
+
97
+ export function pathIsDenied(p) {
98
+ if (p == null) return false;
99
+ const n = String(p).replace(/\\/g, "/").trim();
100
+ if (!n) return false;
101
+ if (DENY_LOCKFILES.has(path.posix.basename(n))) return true;
102
+ if (gitDirIn(n)) return true;
103
+ return n.split("/").some((seg) => DENY_PATH_SEGMENTS.has(seg));
104
+ }
105
+
106
+ export function denyResponse(reason) {
107
+ return {
108
+ decision: "deny",
109
+ reason,
110
+ hookSpecificOutput: {
111
+ hookEventName: "PreToolUse",
112
+ permissionDecision: "deny",
113
+ permissionDecisionReason: reason,
114
+ },
115
+ };
116
+ }
117
+
118
+ export function hookResponse(updated) {
119
+ return {
120
+ hookSpecificOutput: {
121
+ hookEventName: "PreToolUse",
122
+ updatedInput: updated,
123
+ },
124
+ };
125
+ }
126
+
127
+ export function workspaceRootOf(data) {
128
+ return (
129
+ process.env.GROK_WORKSPACE_ROOT ||
130
+ (data && (data.workspaceRoot || data.workspace_root || data.cwd)) ||
131
+ process.cwd()
132
+ );
133
+ }
134
+
135
+ function asNumber(v) {
136
+ if (v == null || v === "") return null;
137
+ const n = Number(v);
138
+ return Number.isFinite(n) ? n : null;
139
+ }
140
+
141
+ function requestedLimit(input, snake, camel) {
142
+ return asNumber(input[snake] ?? input[camel]);
143
+ }
144
+
145
+ function hasLimitField(input, snake, camel) {
146
+ return (
147
+ Object.prototype.hasOwnProperty.call(input, snake) ||
148
+ Object.prototype.hasOwnProperty.call(input, camel)
149
+ );
150
+ }
151
+
152
+ function usesCamelInput(input) {
153
+ return Object.keys(input).some(
154
+ (k) =>
155
+ k === "targetFile" ||
156
+ k === "filePath" ||
157
+ k === "headLimit" ||
158
+ k === "targetDirectory" ||
159
+ k === "globPattern" ||
160
+ k === "ignoreGlobs",
161
+ );
162
+ }
163
+
164
+ function setMissingLimit(next, input, snake, camel, value) {
165
+ if (hasLimitField(input, snake, camel)) return false;
166
+ if (usesCamelInput(input) && snake !== camel) next[camel] = value;
167
+ else next[snake] = value;
168
+ return true;
169
+ }
170
+
171
+ export const GLOB_IGNORE = [...DENY_PATH_SEGMENTS, ".git"];
172
+
173
+ function setMissingIgnore(next, input) {
174
+ if (hasLimitField(input, "ignore", "ignoreGlobs")) return false;
175
+ if (usesCamelInput(input)) next.ignoreGlobs = GLOB_IGNORE.slice();
176
+ else next.ignore = GLOB_IGNORE.slice();
177
+ return true;
178
+ }
179
+
180
+ function collectPathValues(input) {
181
+ const out = [];
182
+ for (const key of PATH_FIELDS) {
183
+ const v = input[key];
184
+ if (typeof v === "string" && v) out.push(v);
185
+ }
186
+ return out;
187
+ }
188
+
189
+ function readFilePath(input) {
190
+ return (
191
+ input.target_file ||
192
+ input.file_path ||
193
+ input.targetFile ||
194
+ input.filePath ||
195
+ ""
196
+ );
197
+ }
198
+
199
+ export function resolveReadPath(filePath, data) {
200
+ if (!filePath || typeof filePath !== "string") return null;
201
+ if (path.isAbsolute(filePath)) return filePath;
202
+ return path.resolve(workspaceRootOf(data), filePath);
203
+ }
204
+
205
+ export function fileSizeBytes(absPath) {
206
+ if (!absPath) return null;
207
+ try {
208
+ const st = fs.statSync(absPath);
209
+ if (!st.isFile()) return null;
210
+ return st.size;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ export function bashTargetsDenylist(command) {
217
+ const cmd = String(command || "");
218
+ if (!cmd.trim()) return false;
219
+ if (!BASH_DUMP_CMDS.test(cmd) && !DENY_LOCKFILES_IN_CMD(cmd)) return false;
220
+ if (DENY_LOCKFILES_IN_CMD(cmd) && BASH_DUMP_CMDS.test(cmd)) return true;
221
+ if (gitDirIn(cmd) && BASH_DUMP_CMDS.test(cmd)) return true;
222
+ for (const seg of DENY_PATH_SEGMENTS) {
223
+ const re = new RegExp(
224
+ `(^|[\\/\\s'"\`])${escapeRe(seg)}([\\/\\s'"\`]|$)`,
225
+ );
226
+ if (re.test(cmd) && BASH_DUMP_CMDS.test(cmd)) return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ function gitDirIn(s) {
232
+ return /(^|[\s/])\.git(\/|$)/.test(String(s).replace(/\\/g, "/"));
233
+ }
234
+
235
+ function DENY_LOCKFILES_IN_CMD(cmd) {
236
+ return /\b(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)\b/.test(cmd);
237
+ }
238
+
239
+ function escapeRe(s) {
240
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
241
+ }
242
+
243
+ export function alreadyRtk(command) {
244
+ const first = String(command || "")
245
+ .trim()
246
+ .split(/\s+/, 1)[0] || "";
247
+ return first === "rtk" || first.endsWith("/rtk");
248
+ }
249
+
250
+ export function stripFlagValue(cmd, flag) {
251
+ const f = escapeRe(flag);
252
+ const val = `(?:"[^"]*"|'[^']*'|\\S+)`;
253
+ return String(cmd || "")
254
+ .replace(new RegExp(`${f}=${val}`, "g"), `${flag}=_`)
255
+ .replace(new RegExp(`${f}\\s+${val}`, "g"), `${flag} _`);
256
+ }
257
+
258
+ function gruntJobRest(command, workspaceRoot) {
259
+ const cmd = String(command || "").trim();
260
+ if (!cmd || !workspaceRoot) return null;
261
+ const parts = cmd.split(/\s+/).filter(Boolean);
262
+ let i = 0;
263
+ const first = parts[0] || "";
264
+ if (first === "rtk" || first.endsWith("/rtk")) i = 1;
265
+ const bin = parts[i] || "";
266
+ if (bin !== "node" && !bin.endsWith("/node")) return null;
267
+ i += 1;
268
+ const scriptArg = parts[i];
269
+ if (!scriptArg) return null;
270
+ const abs = path.isAbsolute(scriptArg)
271
+ ? path.resolve(scriptArg)
272
+ : path.resolve(workspaceRoot, scriptArg);
273
+ const expected = path.resolve(workspaceRoot, "scripts", "grunt-job.mjs");
274
+ if (abs !== expected) return null;
275
+ return parts.slice(i + 1);
276
+ }
277
+
278
+ /** True when argv targets workspace scripts/grunt-job.mjs (flags may be messy). */
279
+ export function isWorkspaceGruntJobScript(command, workspaceRoot) {
280
+ return gruntJobRest(command, workspaceRoot) != null;
281
+ }
282
+
283
+ export function parseGruntJobCommand(command, workspaceRoot) {
284
+ const cmd = String(command || "").trim();
285
+ if (!cmd || !workspaceRoot) return null;
286
+ if (SHELL_META.test(stripFlagValue(cmd, "--query"))) return null;
287
+ const rest = gruntJobRest(cmd, workspaceRoot);
288
+ if (!rest) return null;
289
+ let job = "";
290
+ let hasQuery = false;
291
+ for (let j = 0; j < rest.length; j++) {
292
+ const a = rest[j];
293
+ let flag = a;
294
+ let inline = false;
295
+ if (typeof a === "string" && a.startsWith("--") && a.includes("=")) {
296
+ flag = a.slice(0, a.indexOf("="));
297
+ inline = true;
298
+ }
299
+ if (!GRUNT_JOB_FLAGS.has(flag)) return null;
300
+ if (!inline) j += 1;
301
+ if (flag === "--job") {
302
+ job = String(inline ? a.slice(a.indexOf("=") + 1) : rest[j] || "").toLowerCase();
303
+ } else if (flag === "--query") {
304
+ hasQuery = true;
305
+ }
306
+ }
307
+ return { job, hasQuery };
308
+ }
309
+
310
+ export function isWorkspaceGruntJob(command, workspaceRoot, allowedJobs) {
311
+ const parsed = parseGruntJobCommand(command, workspaceRoot);
312
+ if (!parsed || !parsed.hasQuery) return false;
313
+ const allow = allowedJobs || ["search", "exec", "test"];
314
+ return allow.includes(parsed.job);
315
+ }
316
+
317
+ export function implementerBashReason(command, workspaceRoot) {
318
+ const cmd = String(command || "");
319
+ if (bashTargetsDenylist(cmd)) return REASON_DENYLIST;
320
+ if (alreadyRtk(cmd)) return null;
321
+ if (isWorkspaceGruntJob(cmd, workspaceRoot, ["search", "exec", "test"])) {
322
+ return null;
323
+ }
324
+ const wsGrunt = isWorkspaceGruntJobScript(cmd, workspaceRoot);
325
+ if (BASH_WEB.test(cmd)) return REASON_IMPLEMENTER_BASH;
326
+ if (!wsGrunt && (BASH_SEARCH.test(cmd) || BASH_TEST.test(cmd))) {
327
+ return REASON_IMPLEMENTER_BASH;
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * @returns {null | { type: "deny", reason: string } | { type: "rewrite", updatedInput: object }}
334
+ */
335
+ export function processFatTools(data) {
336
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
337
+ const toolKey = eventKey(data.toolName || data.tool_name || "");
338
+ const input = toolInputOf(data);
339
+ if (!input) return null;
340
+ const sub = subagentTypeOf(data);
341
+ if (sub === "grunt") return null;
342
+
343
+ const parent = isParentOrchestrator(data);
344
+ const isRead = READ_TOOLS.has(toolKey);
345
+ const isGrep = GREP_TOOLS.has(toolKey);
346
+ const isGlob = GLOB_TOOLS.has(toolKey);
347
+ const isBash = BASH_TOOLS.has(toolKey);
348
+
349
+ if (isRead || isGrep || isGlob) {
350
+ for (const v of collectPathValues(input)) {
351
+ if (pathIsDenied(v)) return { type: "deny", reason: REASON_DENYLIST };
352
+ }
353
+ if (isGlob) {
354
+ const raw =
355
+ input.target_directory ||
356
+ input.targetDirectory ||
357
+ input.path ||
358
+ "";
359
+ if (raw) {
360
+ const abs = path.isAbsolute(raw)
361
+ ? raw
362
+ : path.resolve(workspaceRootOf(data), raw);
363
+ if (pathIsDenied(abs) || pathIsDenied(path.basename(abs))) {
364
+ return { type: "deny", reason: REASON_DENYLIST };
365
+ }
366
+ }
367
+ }
368
+
369
+ if (isGrep) {
370
+ const n = requestedLimit(input, "head_limit", "headLimit");
371
+ if (n != null && n > MAX_REQUEST_LIMIT) {
372
+ return { type: "deny", reason: REASON_HEAD_LIMIT };
373
+ }
374
+ }
375
+ if (isRead) {
376
+ const n = requestedLimit(input, "limit", "limit");
377
+ if (n != null && n > MAX_REQUEST_LIMIT) {
378
+ return { type: "deny", reason: REASON_HEAD_LIMIT };
379
+ }
380
+ const abs = resolveReadPath(readFilePath(input), data);
381
+ const size = fileSizeBytes(abs);
382
+ if (size != null && size > DENY_FILE_BYTES) {
383
+ return { type: "deny", reason: REASON_FILE_SIZE };
384
+ }
385
+ }
386
+
387
+ const grepDefault = parent ? DEFAULT_GREP_HEAD_LIMIT : CHILD_GREP_HEAD_LIMIT;
388
+ const readDefault = parent ? DEFAULT_READ_LIMIT : CHILD_READ_LIMIT;
389
+ const next = Object.assign({}, input);
390
+ let changed = false;
391
+ if (isGrep) {
392
+ changed =
393
+ setMissingLimit(
394
+ next,
395
+ input,
396
+ "head_limit",
397
+ "headLimit",
398
+ grepDefault,
399
+ ) || changed;
400
+ }
401
+ if (isRead) {
402
+ changed =
403
+ setMissingLimit(
404
+ next,
405
+ input,
406
+ "limit",
407
+ "limit",
408
+ readDefault,
409
+ ) || changed;
410
+ }
411
+ if (isGlob) {
412
+ changed = setMissingIgnore(next, input) || changed;
413
+ }
414
+ return changed ? { type: "rewrite", updatedInput: next } : null;
415
+ }
416
+
417
+ if (isBash) {
418
+ const cmd = typeof input.command === "string" ? input.command : "";
419
+ if (bashTargetsDenylist(cmd)) {
420
+ return { type: "deny", reason: REASON_DENYLIST };
421
+ }
422
+ if (sub === "implementer") {
423
+ const reason = implementerBashReason(cmd, workspaceRootOf(data));
424
+ if (reason) return { type: "deny", reason };
425
+ }
426
+ return null;
427
+ }
428
+
429
+ return null;
430
+ }
431
+
432
+ export function fatHookOutput(result) {
433
+ if (!result) return null;
434
+ if (result.type === "deny") return denyResponse(result.reason);
435
+ if (result.type === "rewrite") return hookResponse(result.updatedInput);
436
+ return null;
437
+ }
438
+
439
+ export function processHookPayload(data) {
440
+ return fatHookOutput(processFatTools(data));
441
+ }
442
+
443
+ function main() {
444
+ try {
445
+ const data = readJsonValue();
446
+ const out = processHookPayload(data);
447
+ if (!out) return 0;
448
+ process.stdout.write(JSON.stringify(out));
449
+ return 0;
450
+ } catch {
451
+ return 0;
452
+ }
453
+ }
454
+
455
+ function readJsonValue() {
456
+ let buf = Buffer.alloc(0);
457
+ const tmp = Buffer.alloc(8192);
458
+ for (;;) {
459
+ let n;
460
+ try {
461
+ n = fs.readSync(0, tmp, 0, tmp.length, null);
462
+ } catch {
463
+ break;
464
+ }
465
+ if (n === 0) break;
466
+ buf = Buffer.concat([buf, tmp.subarray(0, n)]);
467
+ try {
468
+ return JSON.parse(buf.toString("utf8"));
469
+ } catch {
470
+ // incomplete
471
+ }
472
+ }
473
+ if (!buf.length) return undefined;
474
+ try {
475
+ return JSON.parse(buf.toString("utf8"));
476
+ } catch {
477
+ return undefined;
478
+ }
479
+ }
480
+
481
+ const thisFile = fileURLToPath(import.meta.url);
482
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
483
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
484
+ process.exit(main());
485
+ }