@lovrozagar/grunt 0.2.0 → 0.3.1

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 (55) hide show
  1. package/.agents/agents/grunt.md +4 -2
  2. package/.agents/agents/orchestrator.md +2 -2
  3. package/.agents/hooks.json +12 -1
  4. package/.agents/skills/commit/SKILL.md +21 -0
  5. package/.agents/skills/commit-and-push/SKILL.md +15 -0
  6. package/.agents/skills/explain/SKILL.md +14 -0
  7. package/.agents/skills/handoff/SKILL.md +84 -0
  8. package/.agents/skills/parent/SKILL.md +11 -0
  9. package/.agents/skills/solo/SKILL.md +21 -0
  10. package/.claude/agents/grunt.md +2 -0
  11. package/.claude/agents/orchestrator.md +3 -3
  12. package/.claude/settings.json +34 -1
  13. package/.claude/skills/commit/SKILL.md +21 -0
  14. package/.claude/skills/commit-and-push/SKILL.md +15 -0
  15. package/.claude/skills/explain/SKILL.md +14 -0
  16. package/.claude/skills/handoff/SKILL.md +84 -0
  17. package/.claude/skills/parent/SKILL.md +11 -0
  18. package/.claude/skills/solo/SKILL.md +21 -0
  19. package/.codex/agents/grunt.toml +4 -2
  20. package/.codex/agents/orchestrator.toml +2 -2
  21. package/.codex/hooks.json +34 -1
  22. package/.grok/agents/grunt.md +4 -2
  23. package/.grok/agents/orchestrator.md +2 -2
  24. package/.grok/hooks/orchestrate-parent.js +263 -29
  25. package/.grok/skills/commit/SKILL.md +21 -0
  26. package/.grok/skills/commit-and-push/SKILL.md +15 -0
  27. package/.grok/skills/handoff/SKILL.md +84 -0
  28. package/.grok/skills/solo/SKILL.md +21 -0
  29. package/.rulesync/hooks.jsonc +11 -3
  30. package/.rulesync/reference/cascade.md +4 -3
  31. package/.rulesync/reference/hooks.md +5 -1
  32. package/.rulesync/reference/map.md +18 -3
  33. package/.rulesync/rules/CLAUDE.md +2 -1
  34. package/.rulesync/rules/overview.md +2 -1
  35. package/.rulesync/skills/commit/SKILL.md +22 -0
  36. package/.rulesync/skills/commit-and-push/SKILL.md +16 -0
  37. package/.rulesync/skills/handoff/SKILL.md +84 -0
  38. package/.rulesync/skills/solo/SKILL.md +21 -0
  39. package/.rulesync/subagents/grunt.md +6 -2
  40. package/.rulesync/subagents/implementer.md +2 -0
  41. package/.rulesync/subagents/orchestrator.md +4 -2
  42. package/.rulesync/subagents/thinker.md +2 -0
  43. package/AGENTS.md +2 -1
  44. package/CLAUDE.md +2 -1
  45. package/README.md +2 -2
  46. package/cli/init.mjs +105 -6
  47. package/cli/init.test.ts +303 -6
  48. package/package.json +12 -6
  49. package/scripts/emit-agent-shell-tools.mjs +118 -0
  50. package/scripts/emit-gemini.mjs +219 -0
  51. package/scripts/emit-mcp-policy.mjs +23 -0
  52. package/scripts/gate-fat-tools.mjs +64 -0
  53. package/scripts/hooks-union.mjs +391 -0
  54. package/scripts/persist-handoff.mjs +231 -0
  55. package/scripts/scrub-spawn-prompt.mjs +37 -17
package/cli/init.test.ts CHANGED
@@ -4,7 +4,9 @@ import path from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import {
6
6
  init,
7
+ mergeClaudeSettings,
7
8
  mergeGitignore,
9
+ mergeGuardedMarkdown,
8
10
  mergePackageJson,
9
11
  samePath,
10
12
  } from "./init.mjs";
@@ -23,13 +25,17 @@ function tmp(prefix: string) {
23
25
  }
24
26
 
25
27
  const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"];
26
- const COPY_FILES = ["AGENTS.md", "CLAUDE.md", ".mcp.json"];
28
+ const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"];
27
29
  const PRODUCT_FILES = [
28
30
  "check-globals.mjs",
31
+ "emit-agent-shell-tools.mjs",
32
+ "emit-gemini.mjs",
29
33
  "emit-mcp-policy.mjs",
30
34
  "gate-fat-tools.mjs",
35
+ "hooks-union.mjs",
31
36
  "grunt-job.mjs",
32
37
  "parse-need.mjs",
38
+ "persist-handoff.mjs",
33
39
  "persist-plan.mjs",
34
40
  "purge-global-mcps.mjs",
35
41
  "scrub-spawn-prompt.mjs",
@@ -38,6 +44,46 @@ const PRODUCT_FILES = [
38
44
  "telemetry.mjs",
39
45
  ];
40
46
 
47
+ const SRC_CLAUDE_SETTINGS = {
48
+ permissions: {
49
+ deny: ["Agent(Explore)", "Agent(orchestrator)", "mcp__*"],
50
+ },
51
+ hooks: {
52
+ PreToolUse: [
53
+ {
54
+ matcher: "spawn",
55
+ hooks: [
56
+ {
57
+ type: "command",
58
+ command: 'node "${ROOT}/scripts/scrub-spawn-prompt.mjs"',
59
+ },
60
+ ],
61
+ },
62
+ {
63
+ matcher: "fat",
64
+ hooks: [
65
+ {
66
+ type: "command",
67
+ command: 'node "${ROOT}/scripts/gate-fat-tools.mjs"',
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ Stop: [
73
+ {
74
+ hooks: [
75
+ {
76
+ type: "command",
77
+ command: 'node "${ROOT}/.grok/hooks/orchestrate-parent.js"',
78
+ },
79
+ ],
80
+ },
81
+ ],
82
+ },
83
+ enableAllProjectMcpServers: false,
84
+ enabledMcpjsonServers: [],
85
+ };
86
+
41
87
  function stubPkgRoot(pkg: Record<string, unknown> = {
42
88
  name: "fixture-pkg",
43
89
  scripts: {
@@ -57,8 +103,12 @@ function stubPkgRoot(pkg: Record<string, unknown> = {
57
103
  fs.mkdirSync(path.join(root, d));
58
104
  fs.writeFileSync(path.join(root, d, "marker"), d);
59
105
  }
60
- for (const f of COPY_FILES) {
61
- fs.writeFileSync(path.join(root, f), f);
106
+ fs.writeFileSync(
107
+ path.join(root, ".claude", "settings.json"),
108
+ `${JSON.stringify(SRC_CLAUDE_SETTINGS, null, 2)}\n`,
109
+ );
110
+ for (const f of GUARDED_MD_FILES) {
111
+ fs.writeFileSync(path.join(root, f), `${f} content`);
62
112
  }
63
113
  fs.mkdirSync(path.join(root, "scripts"));
64
114
  for (const name of PRODUCT_FILES) {
@@ -132,6 +182,205 @@ describe("mergeGitignore", () => {
132
182
  });
133
183
  });
134
184
 
185
+ describe("mergeGuardedMarkdown", () => {
186
+ it("self: dest === src is a no-op", () => {
187
+ const dir = tmp("md-self-");
188
+ fs.writeFileSync(path.join(dir, "CLAUDE.md"), "raw content");
189
+ mergeGuardedMarkdown(dir, dir, "CLAUDE.md");
190
+ expect(fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8")).toBe("raw content");
191
+ });
192
+
193
+ it("dest absent: writes package copy wrapped in sentinels", () => {
194
+ const pkgRoot = stubPkgRoot();
195
+ const dest = tmp("md-absent-");
196
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
197
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
198
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n",
199
+ );
200
+ });
201
+
202
+ it("dest exists without sentinel: writes CLAUDE.grunt.md, leaves original untouched", () => {
203
+ const pkgRoot = stubPkgRoot();
204
+ const dest = tmp("md-nosentinel-");
205
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "hand-written consumer doc\n");
206
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
207
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
208
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
209
+ "hand-written consumer doc\n",
210
+ );
211
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.grunt.md"), "utf8")).toBe("CLAUDE.md content");
212
+ expect(logSpy).toHaveBeenCalledTimes(1);
213
+ expect(logSpy.mock.calls[0][0]).toContain("CLAUDE.grunt.md");
214
+ logSpy.mockRestore();
215
+ });
216
+
217
+ it("dest exists with sentinel: replaces only the marked region", () => {
218
+ const pkgRoot = stubPkgRoot();
219
+ const dest = tmp("md-sentinel-");
220
+ fs.writeFileSync(
221
+ path.join(dest, "CLAUDE.md"),
222
+ "before\n<!-- grunt:begin -->\nold generated stuff\n<!-- grunt:end -->\nafter\n",
223
+ );
224
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
225
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
226
+ "before\n<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nafter\n",
227
+ );
228
+ expect(fs.existsSync(path.join(dest, "CLAUDE.grunt.md"))).toBe(false);
229
+ });
230
+ });
231
+
232
+ describe("mergeClaudeSettings", () => {
233
+ it("dest .claude/settings.json absent: plain copy", () => {
234
+ const pkgRoot = stubPkgRoot();
235
+ const destRoot = tmp("settings-absent-");
236
+ mergeClaudeSettings(destRoot, pkgRoot);
237
+ const out = JSON.parse(
238
+ fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
239
+ );
240
+ expect(out).toEqual(SRC_CLAUDE_SETTINGS);
241
+ });
242
+
243
+ it("malformed dest JSON throws with the path in the message", () => {
244
+ const pkgRoot = stubPkgRoot();
245
+ const destRoot = tmp("settings-malformed-");
246
+ const destSettingsPath = path.join(destRoot, ".claude", "settings.json");
247
+ fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
248
+ fs.writeFileSync(destSettingsPath, "{ not valid json");
249
+ expect(() => mergeClaudeSettings(destRoot, pkgRoot)).toThrowError(
250
+ new RegExp(destSettingsPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
251
+ );
252
+ });
253
+
254
+ it("consumer hook group with only OWNED commands is replaced", () => {
255
+ const pkgRoot = stubPkgRoot();
256
+ const destRoot = tmp("settings-owned-");
257
+ fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
258
+ fs.writeFileSync(
259
+ path.join(destRoot, ".claude", "settings.json"),
260
+ JSON.stringify({
261
+ permissions: { deny: ["Agent(Explore)"] },
262
+ hooks: {
263
+ PreToolUse: [
264
+ {
265
+ matcher: "spawn",
266
+ hooks: [
267
+ {
268
+ type: "command",
269
+ command: 'node "${OLD_ROOT}/scripts/scrub-spawn-prompt.mjs"',
270
+ },
271
+ ],
272
+ },
273
+ ],
274
+ },
275
+ enableAllProjectMcpServers: true,
276
+ enabledMcpjsonServers: ["stale"],
277
+ }),
278
+ );
279
+ mergeClaudeSettings(destRoot, pkgRoot);
280
+ const out = JSON.parse(
281
+ fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
282
+ );
283
+ expect(out.hooks.PreToolUse).toEqual(SRC_CLAUDE_SETTINGS.hooks.PreToolUse);
284
+ expect(out.hooks.Stop).toEqual(SRC_CLAUDE_SETTINGS.hooks.Stop);
285
+ expect(out.enableAllProjectMcpServers).toBe(false);
286
+ expect(out.enabledMcpjsonServers).toEqual([]);
287
+ });
288
+
289
+ it("consumer hook group with only consumer commands survives", () => {
290
+ const pkgRoot = stubPkgRoot();
291
+ const destRoot = tmp("settings-consumer-");
292
+ fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
293
+ const consumerGroup = {
294
+ matcher: "custom",
295
+ hooks: [{ type: "command", command: "node scripts/my-custom-hook.mjs" }],
296
+ };
297
+ fs.writeFileSync(
298
+ path.join(destRoot, ".claude", "settings.json"),
299
+ JSON.stringify({
300
+ permissions: { deny: ["Agent(Explore)"] },
301
+ hooks: { PreToolUse: [consumerGroup] },
302
+ }),
303
+ );
304
+ mergeClaudeSettings(destRoot, pkgRoot);
305
+ const out = JSON.parse(
306
+ fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
307
+ );
308
+ expect(out.hooks.PreToolUse).toEqual([consumerGroup, ...SRC_CLAUDE_SETTINGS.hooks.PreToolUse]);
309
+ // event absent from dest entirely still gets appended
310
+ expect(out.hooks.Stop).toEqual(SRC_CLAUDE_SETTINGS.hooks.Stop);
311
+ });
312
+
313
+ it("MIXED group (one OWNED + one consumer command) survives intact", () => {
314
+ const pkgRoot = stubPkgRoot();
315
+ const destRoot = tmp("settings-mixed-");
316
+ fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
317
+ const mixedGroup = {
318
+ matcher: "mixed",
319
+ hooks: [
320
+ { type: "command", command: 'node "${OLD_ROOT}/scripts/gate-fat-tools.mjs"' },
321
+ { type: "command", command: "node scripts/my-custom-hook.mjs" },
322
+ ],
323
+ };
324
+ fs.writeFileSync(
325
+ path.join(destRoot, ".claude", "settings.json"),
326
+ JSON.stringify({
327
+ permissions: { deny: [] },
328
+ hooks: { PreToolUse: [mixedGroup] },
329
+ }),
330
+ );
331
+ mergeClaudeSettings(destRoot, pkgRoot);
332
+ const out = JSON.parse(
333
+ fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
334
+ );
335
+ expect(out.hooks.PreToolUse).toEqual([mixedGroup, ...SRC_CLAUDE_SETTINGS.hooks.PreToolUse]);
336
+ });
337
+
338
+ it("permissions.deny union does not grow on a second run (idempotency)", () => {
339
+ const pkgRoot = stubPkgRoot();
340
+ const destRoot = tmp("settings-idempotent-");
341
+ fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
342
+ fs.writeFileSync(
343
+ path.join(destRoot, ".claude", "settings.json"),
344
+ JSON.stringify({
345
+ permissions: { deny: ["Agent(Explore)", "Agent(orchestrator)"] },
346
+ hooks: {},
347
+ }),
348
+ );
349
+ mergeClaudeSettings(destRoot, pkgRoot);
350
+ const first = JSON.parse(
351
+ fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
352
+ );
353
+ mergeClaudeSettings(destRoot, pkgRoot);
354
+ const second = JSON.parse(
355
+ fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
356
+ );
357
+ expect(second.permissions.deny).toEqual(first.permissions.deny);
358
+ expect(second.permissions.deny).toEqual([
359
+ "Agent(Explore)",
360
+ "Agent(orchestrator)",
361
+ "mcp__*",
362
+ ]);
363
+ expect(second.permissions.allow).toEqual(first.permissions.allow);
364
+ });
365
+
366
+ it("unknown consumer top-level key preserved; missing hooks/permissions default to empty", () => {
367
+ const pkgRoot = stubPkgRoot();
368
+ const destRoot = tmp("settings-unknown-key-");
369
+ fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
370
+ fs.writeFileSync(
371
+ path.join(destRoot, ".claude", "settings.json"),
372
+ JSON.stringify({ someCustomFlag: true }),
373
+ );
374
+ mergeClaudeSettings(destRoot, pkgRoot);
375
+ const out = JSON.parse(
376
+ fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
377
+ );
378
+ expect(out.someCustomFlag).toBe(true);
379
+ expect(out.hooks).toEqual(SRC_CLAUDE_SETTINGS.hooks);
380
+ expect(out.permissions.deny).toEqual(SRC_CLAUDE_SETTINGS.permissions.deny);
381
+ });
382
+ });
383
+
135
384
  describe("mergePackageJson", () => {
136
385
  it("creates {} dest and merges scripts skipping test, injects deps, sorts keys", () => {
137
386
  const pkgRoot = stubPkgRoot();
@@ -208,20 +457,37 @@ describe("init", () => {
208
457
  expect(fs.readFileSync(path.join(pkgRoot, "scripts", "telemetry.mjs"), "utf8")).toBe(
209
458
  "telemetry.mjs",
210
459
  );
460
+ // self-skip: markdown/settings stay exactly as-is, no sentinel wrap
461
+ expect(fs.readFileSync(path.join(pkgRoot, "CLAUDE.md"), "utf8")).toBe("CLAUDE.md content");
462
+ const settings = JSON.parse(
463
+ fs.readFileSync(path.join(pkgRoot, ".claude", "settings.json"), "utf8"),
464
+ );
465
+ expect(settings).toEqual(SRC_CLAUDE_SETTINGS);
211
466
  });
212
467
 
213
- it("non-self: file vs dir product scripts, then npm pipeline in order", () => {
468
+ it("non-self: file vs dir product scripts, guarded md + settings merge, then npm pipeline in order", () => {
214
469
  const pkgRoot = stubPkgRoot();
215
470
  const dest = tmp("grunt-dest-");
216
471
  const exec = vi.fn();
217
472
  init(dest, { pkgRoot, execFileSync: exec });
218
473
 
219
474
  for (const d of COPY_DIRS) {
475
+ if (d === ".claude") continue;
220
476
  expect(fs.readFileSync(path.join(dest, d, "marker"), "utf8")).toBe(d);
221
477
  }
222
- for (const f of COPY_FILES) {
223
- expect(fs.readFileSync(path.join(dest, f), "utf8")).toBe(f);
478
+ expect(fs.readFileSync(path.join(dest, ".claude", "marker"), "utf8")).toBe(".claude");
479
+ const settings = JSON.parse(
480
+ fs.readFileSync(path.join(dest, ".claude", "settings.json"), "utf8"),
481
+ );
482
+ expect(settings).toEqual(SRC_CLAUDE_SETTINGS);
483
+
484
+ for (const f of GUARDED_MD_FILES) {
485
+ expect(fs.readFileSync(path.join(dest, f), "utf8")).toBe(
486
+ `<!-- grunt:begin -->\n${f} content\n<!-- grunt:end -->\n`,
487
+ );
224
488
  }
489
+ expect(fs.existsSync(path.join(dest, ".mcp.json"))).toBe(false);
490
+
225
491
  for (const name of PRODUCT_FILES) {
226
492
  expect(fs.statSync(path.join(dest, "scripts", name)).isFile()).toBe(true);
227
493
  }
@@ -245,6 +511,36 @@ describe("init", () => {
245
511
  }
246
512
  });
247
513
 
514
+ it("re-running init on an already-initialized dest merges settings.json instead of clobbering", () => {
515
+ const pkgRoot = stubPkgRoot();
516
+ const dest = tmp("grunt-rerun-");
517
+ const exec = vi.fn();
518
+ fs.mkdirSync(path.join(dest, ".claude"), { recursive: true });
519
+ const consumerGroup = {
520
+ matcher: "custom",
521
+ hooks: [{ type: "command", command: "node scripts/my-custom-hook.mjs" }],
522
+ };
523
+ fs.writeFileSync(
524
+ path.join(dest, ".claude", "settings.json"),
525
+ JSON.stringify({
526
+ permissions: { deny: ["Agent(Explore)"] },
527
+ hooks: { PreToolUse: [consumerGroup] },
528
+ someCustomFlag: true,
529
+ }),
530
+ );
531
+ init(dest, { pkgRoot, execFileSync: exec });
532
+ const settings = JSON.parse(
533
+ fs.readFileSync(path.join(dest, ".claude", "settings.json"), "utf8"),
534
+ );
535
+ expect(settings.someCustomFlag).toBe(true);
536
+ expect(settings.hooks.PreToolUse).toEqual([
537
+ consumerGroup,
538
+ ...SRC_CLAUDE_SETTINGS.hooks.PreToolUse,
539
+ ]);
540
+ expect(settings.permissions.deny).toContain("Agent(Explore)");
541
+ expect(settings.permissions.deny).toContain("mcp__*");
542
+ });
543
+
248
544
  it("omitted execFileSync uses default; self-skip never invokes it", () => {
249
545
  const pkgRoot = stubPkgRoot();
250
546
  init(pkgRoot, { pkgRoot });
@@ -258,6 +554,7 @@ describe("init", () => {
258
554
  init(dest, { execFileSync: exec });
259
555
  expect(fs.existsSync(path.join(dest, "AGENTS.md"))).toBe(true);
260
556
  expect(fs.existsSync(path.join(dest, "scripts", "scrub-text"))).toBe(true);
557
+ expect(fs.existsSync(path.join(dest, ".claude", "settings.json"))).toBe(true);
261
558
  expect(exec.mock.calls.map((c) => c[1])).toEqual([
262
559
  ["install"],
263
560
  ["run", "rulesync:generate"],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.2.0",
4
- "description": "Multi-agent orchestrator SoT — deep-merge rulesync trees, product scripts, and generate pipeline into a consumer repo",
3
+ "version": "0.3.1",
4
+ "description": "Multi-agent orchestrator SoT \u2014 deep-merge rulesync trees, product scripts, and generate pipeline into a consumer repo",
5
5
  "keywords": [
6
6
  "agents",
7
7
  "grunt",
@@ -25,10 +25,14 @@
25
25
  "bin/grunt.js",
26
26
  "cli",
27
27
  "scripts/check-globals.mjs",
28
+ "scripts/emit-agent-shell-tools.mjs",
29
+ "scripts/emit-gemini.mjs",
28
30
  "scripts/emit-mcp-policy.mjs",
29
31
  "scripts/gate-fat-tools.mjs",
32
+ "scripts/hooks-union.mjs",
30
33
  "scripts/grunt-job.mjs",
31
34
  "scripts/parse-need.mjs",
35
+ "scripts/persist-handoff.mjs",
32
36
  "scripts/persist-plan.mjs",
33
37
  "scripts/purge-global-mcps.mjs",
34
38
  "scripts/scrub-spawn-prompt.mjs",
@@ -52,9 +56,11 @@
52
56
  "access": "public"
53
57
  },
54
58
  "scripts": {
55
- "rulesync:generate": "rulesync generate -t claudecode,codexcli,antigravity-cli,grokcli -f rules,subagents && rulesync generate -t claudecode,codexcli,antigravity-cli -f hooks && node scripts/emit-mcp-policy.mjs",
56
- "rulesync:watch": "node scripts/emit-mcp-policy.mjs && rulesync generate -t claudecode,codexcli,antigravity-cli,grokcli -f rules,subagents --watch",
57
- "rulesync:check": "rulesync generate -t claudecode,codexcli,antigravity-cli,grokcli -f rules,subagents --check && rulesync generate -t claudecode,codexcli,antigravity-cli -f hooks --check && node scripts/emit-mcp-policy.mjs --check && node scripts/check-globals.mjs",
59
+ "rulesync:generate": "rulesync generate -t claudecode,codexcli,antigravity-cli,grokcli -f rules,subagents,skills && rulesync generate -t claudecode,codexcli,antigravity-cli -f hooks && node scripts/emit-mcp-policy.mjs && node scripts/emit-gemini.mjs && node scripts/emit-agent-shell-tools.mjs && node scripts/hooks-union.mjs",
60
+ "rulesync:watch": "node scripts/emit-mcp-policy.mjs && node scripts/emit-gemini.mjs && node scripts/emit-agent-shell-tools.mjs && rulesync generate -t claudecode,codexcli,antigravity-cli,grokcli -f rules,subagents,skills --watch",
61
+ "rulesync:check": "rulesync generate -t claudecode,codexcli,antigravity-cli,grokcli -f rules,skills --check && rulesync generate -t codexcli,antigravity-cli,grokcli -f subagents --check && rulesync generate -t claudecode,codexcli,antigravity-cli -f hooks --check && node scripts/emit-mcp-policy.mjs --check && node scripts/emit-gemini.mjs --check && node scripts/emit-agent-shell-tools.mjs --check && node scripts/check-globals.mjs && node scripts/hooks-union.mjs --check",
62
+ "hooks:union": "node scripts/hooks-union.mjs",
63
+ "hooks:check": "node scripts/hooks-union.mjs --check",
58
64
  "rulesync:doctor": "rulesync doctor",
59
65
  "sync:globals": "node scripts/sync-global-settings.mjs",
60
66
  "sync:globals:check": "node scripts/check-globals.mjs",
@@ -66,7 +72,7 @@
66
72
  "devDependencies": {
67
73
  "@types/node": "^24.13.3",
68
74
  "@vitest/coverage-v8": "^3.2.7",
69
- "rulesync": "latest",
75
+ "rulesync": "16.17.0",
70
76
  "smol-toml": "^1.8.0",
71
77
  "typescript": "^5.9.3",
72
78
  "vitest": "^3.2.7"
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /** After rulesync subagent emit: Claude grunt body uses Bash; other targets keep run_terminal_command. */
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+
7
+ export const SSOT_REL = ".rulesync/subagents/grunt.md";
8
+ export const CLAUDE_GRUNT_REL = ".claude/agents/grunt.md";
9
+
10
+ export const GENERIC_TOOL_LIST =
11
+ "read_file, grep, list_dir, run_terminal_command, write, search_replace, web_search, web_fetch";
12
+ export const CLAUDE_TOOL_LIST = "Read, Grep, Glob, Bash, Write, Edit, Web";
13
+
14
+ export function parseArgv(argv) {
15
+ const args = Array.isArray(argv) ? argv : [];
16
+ let check = false;
17
+ for (const a of args) {
18
+ if (a === "--check") {
19
+ check = true;
20
+ continue;
21
+ }
22
+ return { ok: false, error: `unknown flag: ${a}` };
23
+ }
24
+ return { ok: true, check };
25
+ }
26
+
27
+ export function splitFrontmatter(text) {
28
+ const src = String(text ?? "");
29
+ if (!src.startsWith("---")) return { ok: false, error: "missing frontmatter" };
30
+ const rest = src.slice(3).replace(/^\r?\n/, "");
31
+ const m = rest.match(/\r?\n---\s*(?:\r?\n|$)/);
32
+ if (!m || m.index == null) return { ok: false, error: "missing frontmatter close" };
33
+ return { ok: true, fm: rest.slice(0, m.index), body: rest.slice(m.index + m[0].length) };
34
+ }
35
+
36
+ export function toClaudeShellBody(body) {
37
+ return String(body)
38
+ .split(GENERIC_TOOL_LIST)
39
+ .join(CLAUDE_TOOL_LIST)
40
+ .split("web_search/web_fetch")
41
+ .join("WebSearch/WebFetch")
42
+ .split("run_terminal_command")
43
+ .join("Bash");
44
+ }
45
+
46
+ function readText(abs) {
47
+ try {
48
+ return fs.readFileSync(abs, "utf8");
49
+ } catch (err) {
50
+ if (err && err.code === "ENOENT") return null;
51
+ throw err;
52
+ }
53
+ }
54
+
55
+ function joinMarkdown(fm, body) {
56
+ const b = body.endsWith("\n") || body === "" ? body : `${body}\n`;
57
+ return `---\n${fm}\n---\n${b}`;
58
+ }
59
+
60
+ export function emitAgentShellTools({ workspaceRoot, check = false } = {}) {
61
+ const ws = workspaceRoot || process.cwd();
62
+ const ssotAbs = path.join(ws, SSOT_REL);
63
+ const claudeAbs = path.join(ws, CLAUDE_GRUNT_REL);
64
+ const ssotRaw = readText(ssotAbs);
65
+ if (ssotRaw == null) {
66
+ return { ok: false, check, error: `missing ${SSOT_REL}` };
67
+ }
68
+ const ssot = splitFrontmatter(ssotRaw);
69
+ if (!ssot.ok) {
70
+ return { ok: false, check, error: `ssot: ${ssot.error}` };
71
+ }
72
+ const claudeRaw = readText(claudeAbs);
73
+ if (claudeRaw == null) {
74
+ return { ok: false, check, error: `missing ${CLAUDE_GRUNT_REL}` };
75
+ }
76
+ const claude = splitFrontmatter(claudeRaw);
77
+ if (!claude.ok) {
78
+ return { ok: false, check, error: `claude grunt: ${claude.error}` };
79
+ }
80
+ const next = joinMarkdown(claude.fm, toClaudeShellBody(ssot.body));
81
+ if (check) {
82
+ if (claudeRaw !== next) {
83
+ return { ok: false, check: true, error: `drift: ${CLAUDE_GRUNT_REL}`, drift: [CLAUDE_GRUNT_REL] };
84
+ }
85
+ return { ok: true, check: true, drift: [] };
86
+ }
87
+ fs.mkdirSync(path.dirname(claudeAbs), { recursive: true });
88
+ fs.writeFileSync(claudeAbs, next);
89
+ return { ok: true, check: false };
90
+ }
91
+
92
+ function main() {
93
+ try {
94
+ const parsed = parseArgv(process.argv.slice(2));
95
+ if (!parsed.ok) {
96
+ process.stderr.write((parsed.error || "invalid argv") + "\n");
97
+ return 1;
98
+ }
99
+ const result = emitAgentShellTools({
100
+ workspaceRoot: process.cwd(),
101
+ check: parsed.check,
102
+ });
103
+ if (!result.ok) {
104
+ process.stderr.write((result.error || "emit failed") + "\n");
105
+ return 1;
106
+ }
107
+ return 0;
108
+ } catch (err) {
109
+ process.stderr.write(String(err && err.message ? err.message : err) + "\n");
110
+ return 1;
111
+ }
112
+ }
113
+
114
+ const thisFile = fileURLToPath(import.meta.url);
115
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
116
+ if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
117
+ process.exit(main());
118
+ }