@lovrozagar/grunt 0.4.2 → 0.5.2

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 (96) hide show
  1. package/.agents/agents/grunt.md +2 -1
  2. package/.agents/agents/implementer.md +1 -1
  3. package/.agents/agents/orchestrator.md +19 -13
  4. package/.agents/agents/thinker.md +8 -6
  5. package/.agents/skills/ask/SKILL.md +18 -0
  6. package/.agents/skills/auto/SKILL.md +18 -0
  7. package/.agents/skills/browser/SKILL.md +4 -3
  8. package/.agents/skills/cascade/SKILL.md +5 -3
  9. package/.agents/skills/commit-and-push/SKILL.md +3 -3
  10. package/.agents/skills/explain/SKILL.md +1 -0
  11. package/.agents/skills/handoff/SKILL.md +1 -1
  12. package/.agents/skills/implement-plan/SKILL.md +117 -0
  13. package/.agents/skills/solo/SKILL.md +5 -3
  14. package/.agents/skills/tmp/SKILL.md +58 -0
  15. package/.agents/skills/write-plan/SKILL.md +85 -0
  16. package/.claude/agents/grunt.md +2 -1
  17. package/.claude/agents/implementer.md +1 -1
  18. package/.claude/agents/orchestrator.md +19 -13
  19. package/.claude/agents/thinker.md +8 -6
  20. package/.claude/skills/ask/SKILL.md +18 -0
  21. package/.claude/skills/auto/SKILL.md +18 -0
  22. package/.claude/skills/browser/SKILL.md +4 -3
  23. package/.claude/skills/cascade/SKILL.md +5 -3
  24. package/.claude/skills/commit-and-push/SKILL.md +3 -3
  25. package/.claude/skills/explain/SKILL.md +1 -0
  26. package/.claude/skills/handoff/SKILL.md +1 -1
  27. package/.claude/skills/implement-plan/SKILL.md +117 -0
  28. package/.claude/skills/solo/SKILL.md +5 -3
  29. package/.claude/skills/tmp/SKILL.md +58 -0
  30. package/.claude/skills/write-plan/SKILL.md +85 -0
  31. package/.codex/agents/grunt.toml +2 -1
  32. package/.codex/agents/implementer.toml +1 -1
  33. package/.codex/agents/orchestrator.toml +19 -13
  34. package/.codex/agents/thinker.toml +8 -6
  35. package/.grok/agents/grunt.md +2 -1
  36. package/.grok/agents/implementer.md +1 -1
  37. package/.grok/agents/orchestrator.md +19 -13
  38. package/.grok/agents/thinker.md +8 -6
  39. package/.grok/hooks/orchestrate-parent.js +280 -62
  40. package/.grok/skills/ask/SKILL.md +18 -0
  41. package/.grok/skills/auto/SKILL.md +18 -0
  42. package/.grok/skills/browser/SKILL.md +4 -3
  43. package/.grok/skills/cascade/SKILL.md +5 -3
  44. package/.grok/skills/commit-and-push/SKILL.md +3 -3
  45. package/.grok/skills/explain/SKILL.md +1 -0
  46. package/.grok/skills/handoff/SKILL.md +1 -1
  47. package/.grok/skills/implement-plan/SKILL.md +49 -20
  48. package/.grok/skills/solo/SKILL.md +5 -3
  49. package/.grok/skills/tmp/SKILL.md +58 -0
  50. package/.grok/skills/write-plan/SKILL.md +17 -14
  51. package/.rulesync/grunt.config.jsonc +7 -0
  52. package/.rulesync/reference/INDEX.md +46 -0
  53. package/.rulesync/reference/cascade.md +19 -15
  54. package/.rulesync/reference/hooks.md +6 -4
  55. package/.rulesync/reference/law.md +9 -0
  56. package/.rulesync/reference/map.md +20 -10
  57. package/.rulesync/reference/output.md +23 -8
  58. package/{.grok/skills/shared → .rulesync/reference}/plan-format.md +12 -11
  59. package/.rulesync/reference/refs-map.md +14 -0
  60. package/.rulesync/reference/skills-map.md +23 -0
  61. package/.rulesync/rules/CLAUDE.md +22 -15
  62. package/.rulesync/rules/overview.md +22 -15
  63. package/.rulesync/skills/ask/SKILL.md +18 -0
  64. package/.rulesync/skills/auto/SKILL.md +18 -0
  65. package/.rulesync/skills/browser/SKILL.md +2 -2
  66. package/.rulesync/skills/cascade/SKILL.md +5 -3
  67. package/.rulesync/skills/commit-and-push/SKILL.md +3 -3
  68. package/.rulesync/skills/explain/SKILL.md +1 -0
  69. package/.rulesync/skills/handoff/SKILL.md +1 -1
  70. package/.rulesync/skills/implement-plan/SKILL.md +127 -0
  71. package/.rulesync/skills/solo/SKILL.md +5 -3
  72. package/.rulesync/skills/tmp/SKILL.md +58 -0
  73. package/.rulesync/skills/write-plan/SKILL.md +92 -0
  74. package/.rulesync/subagents/grunt.md +2 -1
  75. package/.rulesync/subagents/implementer.md +1 -1
  76. package/.rulesync/subagents/orchestrator.md +19 -13
  77. package/.rulesync/subagents/thinker.md +8 -6
  78. package/AGENTS.md +22 -15
  79. package/CHANGELOG.md +56 -0
  80. package/CLAUDE.md +22 -15
  81. package/README.md +27 -22
  82. package/cli/grunt.mjs +12 -12
  83. package/cli/grunt.test.ts +29 -20
  84. package/cli/init.mjs +135 -22
  85. package/cli/init.test.ts +624 -36
  86. package/package.json +11 -4
  87. package/scripts/doctor.mjs +64 -2
  88. package/scripts/emit-maps.mjs +422 -0
  89. package/scripts/gate-fat-tools.mjs +2 -2
  90. package/scripts/grunt-config.mjs +50 -0
  91. package/scripts/guarded-roots.mjs +4 -7
  92. package/scripts/persist-plan.mjs +17 -2
  93. package/scripts/persist-tmp.mjs +123 -0
  94. package/scripts/pipeline.mjs +67 -0
  95. package/scripts/scrub-spawn-prompt.mjs +1 -1
  96. package/scripts/skill-conflicts.mjs +71 -0
package/cli/init.test.ts CHANGED
@@ -1,16 +1,21 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { afterEach, describe, expect, it, vi } from "vitest";
5
6
  import {
6
7
  MAX_GUARDED_MARKDOWN_BYTES,
7
8
  composeGuardedMarkdown,
8
9
  destAlreadyInited,
9
10
  extractGruntBody,
11
+ extractUserMarkdown,
10
12
  GUARDED_ROOT_FILES,
13
+ SENTINEL_BEGIN,
14
+ SENTINEL_END,
11
15
  guardedMarkdownDrift,
12
16
  healGuardedRootFile,
13
17
  init,
18
+ LAUNCH_SCRIPTS,
14
19
  mergeClaudeSettings,
15
20
  mergeGitignore,
16
21
  mergeGuardedContent,
@@ -20,10 +25,12 @@ import {
20
25
  samePath,
21
26
  shouldAutoSkipGlobals,
22
27
  snapshotGuardedRoots,
28
+ toGruntScriptName,
23
29
  withGuardedCheckInteriors,
24
30
  writeMergedGuardedFile,
25
31
  } from "./init.mjs";
26
32
  import { runGuardedRoots } from "../scripts/guarded-roots.mjs";
33
+ import { stripJsonc } from "../scripts/grunt-config.mjs";
27
34
 
28
35
  const tmpDirs: string[] = [];
29
36
  afterEach(() => {
@@ -39,18 +46,28 @@ function tmp(prefix: string) {
39
46
  }
40
47
 
41
48
  const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"];
49
+ const GRUNT_CONFIG_REL = path.join(".rulesync", "grunt.config.jsonc");
50
+ const GRUNT_CONFIG_DEFAULTS = {
51
+ version: 1,
52
+ leftoverGate: "ask",
53
+ spawnMode: "cascade",
54
+ };
42
55
  const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"];
43
56
  const PRODUCT_FILES = [
44
57
  "check-globals.mjs",
45
58
  "emit-agent-shell-tools.mjs",
46
59
  "emit-gemini.mjs",
60
+ "emit-maps.mjs",
47
61
  "guarded-roots.mjs",
48
62
  "emit-mcp-policy.mjs",
49
63
  "gate-fat-tools.mjs",
50
64
  "hooks-union.mjs",
65
+ "pipeline.mjs",
51
66
  "grunt-job.mjs",
67
+ "grunt-config.mjs",
52
68
  "parse-need.mjs",
53
69
  "persist-handoff.mjs",
70
+ "persist-tmp.mjs",
54
71
  "persist-plan.mjs",
55
72
  "purge-global-mcps.mjs",
56
73
  "scrub-spawn-prompt.mjs",
@@ -58,6 +75,7 @@ const PRODUCT_FILES = [
58
75
  "sync-global-settings.mjs",
59
76
  "browser.mjs",
60
77
  "doctor.mjs",
78
+ "skill-conflicts.mjs",
61
79
  ];
62
80
 
63
81
  const SRC_CLAUDE_SETTINGS = {
@@ -100,6 +118,12 @@ const SRC_CLAUDE_SETTINGS = {
100
118
  enabledMcpjsonServers: [],
101
119
  };
102
120
 
121
+ function readGruntConfig(root: string) {
122
+ const p = path.join(root, GRUNT_CONFIG_REL);
123
+ expect(fs.existsSync(p)).toBe(true);
124
+ return JSON.parse(stripJsonc(fs.readFileSync(p, "utf8")));
125
+ }
126
+
103
127
  function stubPkgRoot(pkg: Record<string, unknown> = {
104
128
  name: "fixture-pkg",
105
129
  scripts: {
@@ -119,6 +143,10 @@ function stubPkgRoot(pkg: Record<string, unknown> = {
119
143
  fs.mkdirSync(path.join(root, d));
120
144
  fs.writeFileSync(path.join(root, d, "marker"), d);
121
145
  }
146
+ fs.writeFileSync(
147
+ path.join(root, GRUNT_CONFIG_REL),
148
+ `${JSON.stringify(GRUNT_CONFIG_DEFAULTS, null, 2)}\n`,
149
+ );
122
150
  fs.writeFileSync(
123
151
  path.join(root, ".claude", "settings.json"),
124
152
  `${JSON.stringify(SRC_CLAUDE_SETTINGS, null, 2)}\n`,
@@ -287,6 +315,67 @@ describe("mergeGuardedMarkdown", () => {
287
315
  );
288
316
  });
289
317
 
318
+ it("already-guarded package src stays begin=1 end=1", () => {
319
+ const pkgRoot = stubPkgRoot();
320
+ fs.writeFileSync(
321
+ path.join(pkgRoot, "CLAUDE.md"),
322
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n",
323
+ );
324
+ const dest = tmp("md-wrapped-src-");
325
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
326
+ const out = fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8");
327
+ expect(out).toBe("<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n");
328
+ expect(out.split("<!-- grunt:begin -->")).toHaveLength(2);
329
+ expect(out.split("<!-- grunt:end -->")).toHaveLength(2);
330
+ });
331
+
332
+ it("merge heals dest with trailing orphan grunt:end", () => {
333
+ const pkgRoot = stubPkgRoot();
334
+ const dest = tmp("md-orphan-end-");
335
+ fs.writeFileSync(
336
+ path.join(dest, "CLAUDE.md"),
337
+ "<!-- grunt:begin -->\nold\n<!-- grunt:end -->\n<!-- grunt:end -->\n",
338
+ );
339
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
340
+ const out = fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8");
341
+ expect(out).toBe("<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n");
342
+ expect(out.split("<!-- grunt:end -->")).toHaveLength(2);
343
+ });
344
+
345
+ it("dest stacked researcher shape heals to exactly one pair", () => {
346
+ const pkgRoot = stubPkgRoot();
347
+ fs.writeFileSync(
348
+ path.join(pkgRoot, "CLAUDE.md"),
349
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n",
350
+ );
351
+ const dest = tmp("md-stacked-");
352
+ fs.writeFileSync(
353
+ path.join(dest, "CLAUDE.md"),
354
+ "<!-- grunt:begin -->\n<!-- grunt:begin -->\nBODY\n<!-- grunt:end -->\n<!-- grunt:end -->\n",
355
+ );
356
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
357
+ const out = fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8");
358
+ expect(out).toBe("<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n");
359
+ expect(out.split("<!-- grunt:begin -->")).toHaveLength(2);
360
+ expect(out.split("<!-- grunt:end -->")).toHaveLength(2);
361
+ });
362
+
363
+ it("dest stacked keeps user prefix/suffix outside the pair", () => {
364
+ const pkgRoot = stubPkgRoot();
365
+ const dest = tmp("md-stacked-user-");
366
+ fs.writeFileSync(
367
+ path.join(dest, "CLAUDE.md"),
368
+ "prefix\n<!-- grunt:begin -->\n<!-- grunt:begin -->\nBODY\n<!-- grunt:end -->\n<!-- grunt:end -->\nsuffix\n",
369
+ );
370
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
371
+ const out = fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8");
372
+ expect(out).toBe(
373
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nprefix\nsuffix\n",
374
+ );
375
+ expect(out.split("<!-- grunt:begin -->")).toHaveLength(2);
376
+ expect(out.split("<!-- grunt:end -->")).toHaveLength(2);
377
+ });
378
+
290
379
  it("does not fuzzy-dedupe unmarked protocol-looking user text", () => {
291
380
  const pkgRoot = stubPkgRoot();
292
381
  const dest = tmp("md-proto-");
@@ -360,6 +449,23 @@ describe("snapshot/remerge guarded roots", () => {
360
449
  );
361
450
  });
362
451
 
452
+ it("remerge after wrapped-src merge+generate does not reattach orphan end", () => {
453
+ const pkgRoot = stubPkgRoot();
454
+ fs.writeFileSync(
455
+ path.join(pkgRoot, "AGENTS.md"),
456
+ "<!-- grunt:begin -->\nAGENTS.md content\n<!-- grunt:end -->\n",
457
+ );
458
+ const dest = tmp("snap-wrap-src-");
459
+ mergeGuardedMarkdown(dest, pkgRoot, "AGENTS.md");
460
+ const snap = snapshotGuardedRoots(dest);
461
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "generated agents\n");
462
+ remergeGuardedRoots(dest, snap);
463
+ const out = fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8");
464
+ expect(out).toBe("<!-- grunt:begin -->\ngenerated agents\n<!-- grunt:end -->\n");
465
+ expect(out.split("<!-- grunt:begin -->")).toHaveLength(2);
466
+ expect(out.split("<!-- grunt:end -->")).toHaveLength(2);
467
+ });
468
+
363
469
  it("withGuardedCheckInteriors is restored even if check throws", () => {
364
470
  const dest = tmp("check-int-");
365
471
  const live =
@@ -375,6 +481,22 @@ describe("snapshot/remerge guarded roots", () => {
375
481
  });
376
482
 
377
483
  it("compose/extract/drift/write helpers cover remaining branches", () => {
484
+ expect(
485
+ extractUserMarkdown(
486
+ "<!-- grunt:begin -->\nbody\n<!-- grunt:end -->\n<!-- grunt:end -->\n",
487
+ ),
488
+ ).toBe("");
489
+ expect(
490
+ extractUserMarkdown(
491
+ "<!-- grunt:begin -->\nbody\n<!-- grunt:end -->\nkeep\n<!-- grunt:end -->\n",
492
+ ),
493
+ ).toBe("keep\n");
494
+ expect(
495
+ composeGuardedMarkdown(
496
+ "<!-- grunt:begin -->\nbody\n<!-- grunt:end -->\n",
497
+ "<!-- grunt:end -->\n",
498
+ ),
499
+ ).toBe("<!-- grunt:begin -->\nbody\n<!-- grunt:end -->\n");
378
500
  expect(composeGuardedMarkdown("body", null)).toBe(
379
501
  "<!-- grunt:begin -->\nbody\n<!-- grunt:end -->\n",
380
502
  );
@@ -399,7 +521,9 @@ describe("snapshot/remerge guarded roots", () => {
399
521
  expect(mergeGuardedContent("keep\n", "g\r\n")).toBe(
400
522
  "<!-- grunt:begin -->\ng\n<!-- grunt:end -->\nkeep\n",
401
523
  );
402
- expect(mergeGuardedContent("<!-- grunt:begin --> only", "g")).toContain("only");
524
+ expect(mergeGuardedContent("<!-- grunt:begin --> only", "g")).toBe(
525
+ "<!-- grunt:begin -->\ng\n<!-- grunt:end -->\n",
526
+ );
403
527
  const dest = tmp("md-helpers-");
404
528
  const missing = path.join(dest, "nope.md");
405
529
  expect(guardedMarkdownDrift(missing, "g")).toBe(true);
@@ -420,6 +544,73 @@ describe("snapshot/remerge guarded roots", () => {
420
544
  expect(writeMergedGuardedFile(huge, "g")).toBe("aborted-unsafe");
421
545
  });
422
546
 
547
+ it("unclosed begin unwraps interior; inline orphan end is stripped", () => {
548
+ expect(extractGruntBody("<!-- grunt:begin -->\nhello")).toBe("hello");
549
+ const composed = composeGuardedMarkdown("<!-- grunt:begin -->\nhello", "");
550
+ expect(composed).toBe("<!-- grunt:begin -->\nhello\n<!-- grunt:end -->\n");
551
+ expect(composed.split(SENTINEL_BEGIN)).toHaveLength(2);
552
+ expect(composed.split(SENTINEL_END)).toHaveLength(2);
553
+ expect(composed).not.toContain(`${SENTINEL_BEGIN}\n${SENTINEL_BEGIN}`);
554
+ expect(mergeGuardedContent("note\n<!-- grunt:begin -->\nstale", "new")).toBe(
555
+ "<!-- grunt:begin -->\nnew\n<!-- grunt:end -->\nnote\n",
556
+ );
557
+ expect(extractUserMarkdown("keep <!-- grunt:end --> me")).not.toContain(SENTINEL_END);
558
+ expect(extractUserMarkdown("keep <!-- grunt:end --> me")).toBe("keep me");
559
+ expect(extractUserMarkdown("keep\n<!-- grunt:end -->\nme")).toBe("keep\nme");
560
+ expect(extractUserMarkdown("keep\n <!-- grunt:end --> \nme")).toBe("keep\nme");
561
+ expect(extractGruntBody("<!-- grunt:begin -->\r\nhello")).toBe("hello");
562
+ expect(extractGruntBody("<!-- grunt:begin -->")).toBe("");
563
+ const emptyWrap = composeGuardedMarkdown("<!-- grunt:begin -->", "");
564
+ expect(emptyWrap.split(SENTINEL_BEGIN)).toHaveLength(2);
565
+ expect(emptyWrap).toBe("<!-- grunt:begin -->\n\n<!-- grunt:end -->\n");
566
+ const mixed =
567
+ "<!-- grunt:begin -->\nok\n<!-- grunt:end -->\nuser bit\n<!-- grunt:begin -->\nstale";
568
+ expect(extractGruntBody(mixed)).toBe("ok");
569
+ expect(extractUserMarkdown(mixed)).toBe("user bit\n");
570
+ expect(
571
+ composeGuardedMarkdown("<!-- grunt:begin -->\nbody\n<!-- grunt:end -->\n", ""),
572
+ ).toBe("<!-- grunt:begin -->\nbody\n<!-- grunt:end -->\n");
573
+ });
574
+
575
+ it("extract/compose peel stacked dest and trailing orphan end", () => {
576
+ const stacked =
577
+ "<!-- grunt:begin -->\n<!-- grunt:begin -->\nBODY\n<!-- grunt:end -->\n<!-- grunt:end -->\n";
578
+ expect(extractGruntBody(stacked)).toBe("BODY");
579
+ const stackedOut = composeGuardedMarkdown(stacked, "");
580
+ expect(stackedOut).toBe("<!-- grunt:begin -->\nBODY\n<!-- grunt:end -->\n");
581
+ expect(stackedOut.split(SENTINEL_BEGIN)).toHaveLength(2);
582
+ expect(stackedOut.split(SENTINEL_END)).toHaveLength(2);
583
+ expect(composeGuardedMarkdown("BODY\n<!-- grunt:end -->\n", "")).toBe(
584
+ "<!-- grunt:begin -->\nBODY\n<!-- grunt:end -->\n",
585
+ );
586
+ expect(extractGruntBody("BODY\n\n<!-- grunt:end -->")).toBe("BODY");
587
+ expect(mergeGuardedContent(stacked, "SRC")).toBe(
588
+ "<!-- grunt:begin -->\nSRC\n<!-- grunt:end -->\n",
589
+ );
590
+ expect(mergeGuardedContent("keep\n<!-- grunt:end -->\n", "SRC")).toBe(
591
+ "<!-- grunt:begin -->\nSRC\n<!-- grunt:end -->\nkeep\n",
592
+ );
593
+ expect(
594
+ extractGruntBody("<!-- grunt:begin --> \nBODY\n<!-- grunt:end --> \n"),
595
+ ).toBe("BODY");
596
+ expect(
597
+ extractGruntBody(" <!-- grunt:begin -->\nBODY\n <!-- grunt:end -->\n"),
598
+ ).toBe("BODY");
599
+ expect(extractGruntBody("<!-- grunt:begin -->\n<!-- grunt:end -->")).toBe("");
600
+ expect(
601
+ extractGruntBody(
602
+ "<!-- grunt:begin -->\nsee <!-- grunt:end --> here\n<!-- grunt:end -->\n",
603
+ ),
604
+ ).toBe("see <!-- grunt:end --> here");
605
+ const midLine =
606
+ "<!-- grunt:begin -->\nBODY\n<!-- grunt:end -->\nstill user\n<!-- grunt:end -->\n";
607
+ expect(extractGruntBody(midLine)).toBe("BODY");
608
+ expect(extractUserMarkdown(midLine)).toBe("still user\n");
609
+ expect(composeGuardedMarkdown(extractGruntBody(midLine) ?? "", extractUserMarkdown(midLine))).toBe(
610
+ "<!-- grunt:begin -->\nBODY\n<!-- grunt:end -->\nstill user\n",
611
+ );
612
+ });
613
+
423
614
  it("remerge restores unsafe, missing, binary current, and created files", () => {
424
615
  const dest = tmp("remerge-edges-");
425
616
  const agents = path.join(dest, "AGENTS.md");
@@ -495,8 +686,15 @@ describe("snapshot/remerge guarded roots", () => {
495
686
  fs.writeFileSync(path.join(dest, "AGENTS.md"), "fresh ssot\n");
496
687
  });
497
688
  runGuardedRoots("generate", { cwd: dest, exec });
498
- expect(exec).toHaveBeenCalledTimes(1);
499
- expect(String(exec.mock.calls[0][0])).toContain("rulesync:generate:raw");
689
+ expect(exec.mock.calls.map((c) => c[0])).toEqual([
690
+ "rulesync generate -t claudecode,codexcli,antigravity-cli,grokcli -f rules,subagents,skills",
691
+ "rulesync generate -t claudecode,codexcli,antigravity-cli -f hooks",
692
+ "node scripts/emit-mcp-policy.mjs",
693
+ "node scripts/emit-gemini.mjs",
694
+ "node scripts/emit-agent-shell-tools.mjs",
695
+ "node scripts/emit-maps.mjs",
696
+ "node scripts/hooks-union.mjs",
697
+ ]);
500
698
  expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
501
699
  "<!-- grunt:begin -->\nfresh ssot\n<!-- grunt:end -->\nkeep me\n",
502
700
  );
@@ -595,8 +793,12 @@ describe("snapshot/remerge guarded roots", () => {
595
793
  return stop;
596
794
  },
597
795
  });
598
- expect(exec).toHaveBeenCalledTimes(1);
599
- expect(String(exec.mock.calls[0][0])).toContain("rulesync:watch:raw");
796
+ expect(exec.mock.calls.map((c) => c[0])).toEqual([
797
+ "node scripts/emit-mcp-policy.mjs",
798
+ "node scripts/emit-gemini.mjs",
799
+ "node scripts/emit-agent-shell-tools.mjs",
800
+ "rulesync generate -t claudecode,codexcli,antigravity-cli,grokcli -f rules,subagents,skills --watch",
801
+ ]);
600
802
  expect(stop).toHaveBeenCalledTimes(1);
601
803
  expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
602
804
  "<!-- grunt:begin -->\nwatch2\n<!-- grunt:end -->\nkeep me\n",
@@ -772,6 +974,13 @@ describe("mergeClaudeSettings", () => {
772
974
  });
773
975
  });
774
976
 
977
+ describe("toGruntScriptName", () => {
978
+ it("prefixes unprefixed keys and does not double-prefix", () => {
979
+ expect(toGruntScriptName("rulesync:generate")).toBe("grunt:rulesync:generate");
980
+ expect(toGruntScriptName("grunt:doctor")).toBe("grunt:doctor");
981
+ });
982
+ });
983
+
775
984
  describe("mergePackageJson", () => {
776
985
  it("creates {} dest and merges scripts skipping test, injects deps, sorts keys", () => {
777
986
  const pkgRoot = stubPkgRoot();
@@ -779,11 +988,25 @@ describe("mergePackageJson", () => {
779
988
  mergePackageJson(dest, pkgRoot);
780
989
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
781
990
  expect(out.scripts).toEqual({
782
- alpha: "a",
783
- "rulesync:generate": "gen",
784
- zeta: "z",
991
+ "grunt:alpha": "a",
992
+ "grunt:antigravity": LAUNCH_SCRIPTS.antigravity,
993
+ "grunt:claude": LAUNCH_SCRIPTS.claude,
994
+ "grunt:codex": LAUNCH_SCRIPTS.codex,
995
+ "grunt:gemini": LAUNCH_SCRIPTS.gemini,
996
+ "grunt:grok": LAUNCH_SCRIPTS.grok,
997
+ "grunt:rulesync:generate": "gen",
998
+ "grunt:zeta": "z",
785
999
  });
786
- expect(Object.keys(out.scripts)).toEqual(["alpha", "rulesync:generate", "zeta"]);
1000
+ expect(Object.keys(out.scripts)).toEqual([
1001
+ "grunt:alpha",
1002
+ "grunt:antigravity",
1003
+ "grunt:claude",
1004
+ "grunt:codex",
1005
+ "grunt:gemini",
1006
+ "grunt:grok",
1007
+ "grunt:rulesync:generate",
1008
+ "grunt:zeta",
1009
+ ]);
787
1010
  expect(out.devDependencies).toEqual({
788
1011
  rulesync: "latest",
789
1012
  "smol-toml": "^1.8.0",
@@ -803,14 +1026,21 @@ describe("mergePackageJson", () => {
803
1026
  devDependencies: { lodash: "4" },
804
1027
  }),
805
1028
  );
1029
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
806
1030
  mergePackageJson(dest, pkgRoot);
807
1031
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
808
1032
  expect(out.scripts.test).toBe("jest");
809
1033
  expect(out.scripts.alpha).toBe("old");
1034
+ expect(out.scripts["grunt:alpha"]).toBe("a");
1035
+ expect(out.scripts["grunt:zeta"]).toBe("z");
810
1036
  expect(out.scripts.foo).toBe("bar");
811
1037
  expect(out.devDependencies.lodash).toBe("4");
812
1038
  expect(out.devDependencies["smol-toml"]).toBe("^1.8.0");
813
1039
  expect(out.devDependencies.rulesync).toBe("latest");
1040
+ expect(warnSpy.mock.calls.some((c) => String(c[0]).includes("alpha") && String(c[0]).includes("untouched"))).toBe(
1041
+ true,
1042
+ );
1043
+ warnSpy.mockRestore();
814
1044
  });
815
1045
 
816
1046
  it("preserves dest suffix after an upgraded grunt script prefix", () => {
@@ -830,9 +1060,10 @@ describe("mergePackageJson", () => {
830
1060
  );
831
1061
  mergePackageJson(dest, pkgRoot);
832
1062
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
833
- expect(out.scripts["rulesync:generate"]).toBe(
1063
+ expect(out.scripts["grunt:rulesync:generate"]).toBe(
834
1064
  "rulesync generate -t bar && node scripts/codex-sync.mjs",
835
1065
  );
1066
+ expect(out.scripts["rulesync:generate"]).toBeUndefined();
836
1067
  });
837
1068
 
838
1069
  it("preserves suffix after a multi-command grunt script upgrade", () => {
@@ -855,7 +1086,7 @@ describe("mergePackageJson", () => {
855
1086
  );
856
1087
  mergePackageJson(dest, pkgRoot);
857
1088
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
858
- expect(out.scripts["rulesync:generate"]).toBe(
1089
+ expect(out.scripts["grunt:rulesync:generate"]).toBe(
859
1090
  "rulesync generate -t bar && node scripts/emit.mjs && node scripts/codex-sync.mjs",
860
1091
  );
861
1092
  });
@@ -875,6 +1106,7 @@ describe("mergePackageJson", () => {
875
1106
  mergePackageJson(dest, pkgRoot);
876
1107
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
877
1108
  expect(out.scripts.alpha).toBe("echo custom && extra");
1109
+ expect(out.scripts["grunt:alpha"]).toBe("a");
878
1110
  expect(warnSpy).toHaveBeenCalled();
879
1111
  warnSpy.mockRestore();
880
1112
  });
@@ -896,6 +1128,7 @@ describe("mergePackageJson", () => {
896
1128
  mergePackageJson(dest, pkgRoot);
897
1129
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
898
1130
  expect(out.scripts["rulesync:generate"]).toBe("echo custom;echo extra");
1131
+ expect(out.scripts["grunt:rulesync:generate"]).toBe("rulesync generate -t bar");
899
1132
  expect(warnSpy).toHaveBeenCalled();
900
1133
  warnSpy.mockRestore();
901
1134
  });
@@ -913,7 +1146,8 @@ describe("mergePackageJson", () => {
913
1146
  );
914
1147
  mergePackageJson(dest, pkgRoot);
915
1148
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
916
- expect(out.scripts["rulesync:generate"]).toBe("rulesync generate -t bar");
1149
+ expect(out.scripts["grunt:rulesync:generate"]).toBe("rulesync generate -t bar");
1150
+ expect(out.scripts["rulesync:generate"]).toBeUndefined();
917
1151
  });
918
1152
 
919
1153
  it("keeps dest when it already starts with the new src value plus suffix", () => {
@@ -927,7 +1161,8 @@ describe("mergePackageJson", () => {
927
1161
  fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ scripts: { "rulesync:generate": cur } }));
928
1162
  mergePackageJson(dest, pkgRoot);
929
1163
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
930
- expect(out.scripts["rulesync:generate"]).toBe(cur);
1164
+ expect(out.scripts["grunt:rulesync:generate"]).toBe(cur);
1165
+ expect(out.scripts["rulesync:generate"]).toBeUndefined();
931
1166
  });
932
1167
 
933
1168
  it("keeps unrelated custom script values and dest-only keys, warns on skip", () => {
@@ -943,15 +1178,57 @@ describe("mergePackageJson", () => {
943
1178
  mergePackageJson(dest, pkgRoot);
944
1179
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
945
1180
  expect(out.scripts.alpha).toBe("echo custom");
1181
+ expect(out.scripts["grunt:alpha"]).toBe("a");
946
1182
  expect(out.scripts.foo).toBe("bar");
947
1183
  expect(out.scripts.test).toBe("jest");
948
- expect(out.scripts.zeta).toBe("z");
1184
+ expect(out.scripts["grunt:zeta"]).toBe("z");
1185
+ expect(out.scripts.zeta).toBeUndefined();
949
1186
  expect(warnSpy.mock.calls.some((c) => String(c[0]).includes("alpha") && String(c[0]).includes("untouched"))).toBe(
950
1187
  true,
951
1188
  );
952
1189
  warnSpy.mockRestore();
953
1190
  });
954
1191
 
1192
+ it("does not merge :raw scripts from src", () => {
1193
+ const pkgRoot = stubPkgRoot({
1194
+ name: "fixture-pkg",
1195
+ scripts: {
1196
+ "rulesync:generate": "node ./scripts/guarded-roots.mjs generate",
1197
+ "rulesync:generate:raw": "echo raw",
1198
+ "rulesync:check:raw": "echo check-raw",
1199
+ "rulesync:watch:raw": "echo watch-raw",
1200
+ },
1201
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1202
+ });
1203
+ const dest = tmp("pj-raw-src-");
1204
+ mergePackageJson(dest, pkgRoot);
1205
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1206
+ expect(out.scripts["grunt:rulesync:generate"]).toBe("node ./scripts/guarded-roots.mjs generate");
1207
+ expect(out.scripts["rulesync:generate"]).toBeUndefined();
1208
+ expect(out.scripts["rulesync:generate:raw"]).toBeUndefined();
1209
+ expect(out.scripts["rulesync:check:raw"]).toBeUndefined();
1210
+ expect(out.scripts["rulesync:watch:raw"]).toBeUndefined();
1211
+ });
1212
+
1213
+ it("leaves dest-only :raw keys", () => {
1214
+ const pkgRoot = stubPkgRoot({
1215
+ name: "fixture-pkg",
1216
+ scripts: { "rulesync:generate": "node ./scripts/guarded-roots.mjs generate" },
1217
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1218
+ });
1219
+ const dest = tmp("pj-raw-dest-");
1220
+ fs.writeFileSync(
1221
+ path.join(dest, "package.json"),
1222
+ JSON.stringify({
1223
+ scripts: { "rulesync:generate:raw": "echo consumer-raw" },
1224
+ }),
1225
+ );
1226
+ mergePackageJson(dest, pkgRoot);
1227
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1228
+ expect(out.scripts["grunt:rulesync:generate"]).toBe("node ./scripts/guarded-roots.mjs generate");
1229
+ expect(out.scripts["rulesync:generate:raw"]).toBe("echo consumer-raw");
1230
+ });
1231
+
955
1232
  it("replaces a grunt-owned script with no suffix", () => {
956
1233
  const pkgRoot = stubPkgRoot({
957
1234
  name: "fixture-pkg",
@@ -967,7 +1244,9 @@ describe("mergePackageJson", () => {
967
1244
  );
968
1245
  mergePackageJson(dest, pkgRoot);
969
1246
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
970
- expect(out.scripts["rulesync:generate"]).toBe("rulesync generate -t bar");
1247
+ expect(out.scripts["grunt:rulesync:generate"]).toBe("rulesync generate -t bar");
1248
+ expect(out.scripts["grunt:sync:globals:apply"]).toBe("node scripts/sync-global-settings.mjs --apply");
1249
+ expect(out.scripts["rulesync:generate"]).toBeUndefined();
971
1250
  });
972
1251
 
973
1252
  it("early return when name is @lovrozagar/grunt", () => {
@@ -987,9 +1266,257 @@ describe("mergePackageJson", () => {
987
1266
  const dest = tmp("pj-noscripts-");
988
1267
  mergePackageJson(dest, pkgRoot);
989
1268
  const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
990
- expect(out.scripts).toEqual({});
1269
+ expect(out.scripts).toEqual({
1270
+ "grunt:antigravity": LAUNCH_SCRIPTS.antigravity,
1271
+ "grunt:claude": LAUNCH_SCRIPTS.claude,
1272
+ "grunt:codex": LAUNCH_SCRIPTS.codex,
1273
+ "grunt:gemini": LAUNCH_SCRIPTS.gemini,
1274
+ "grunt:grok": LAUNCH_SCRIPTS.grok,
1275
+ });
991
1276
  expect(out.devDependencies).toEqual({ rulesync: "2", "smol-toml": "1" });
992
1277
  });
1278
+
1279
+ it("golden autorun-apps: suffix keep, doctor move, check npm run rewrite, old keys gone", () => {
1280
+ const pkgRoot = stubPkgRoot({
1281
+ name: "fixture-pkg",
1282
+ scripts: {
1283
+ "rulesync:generate": "node ./scripts/guarded-roots.mjs generate",
1284
+ "rulesync:watch": "node ./scripts/guarded-roots.mjs watch",
1285
+ "rulesync:check": "node ./scripts/guarded-roots.mjs check",
1286
+ "hooks:union": "node scripts/hooks-union.mjs",
1287
+ "hooks:check": "node scripts/hooks-union.mjs --check",
1288
+ doctor: "node ./scripts/doctor.mjs",
1289
+ "rulesync:doctor": "rulesync doctor",
1290
+ "sync:globals": "node scripts/sync-global-settings.mjs",
1291
+ "sync:globals:check": "node scripts/check-globals.mjs",
1292
+ "sync:globals:apply": "node scripts/sync-global-settings.mjs --apply",
1293
+ "purge:global-mcps": "node scripts/purge-global-mcps.mjs",
1294
+ "purge:global-mcps:apply": "node scripts/purge-global-mcps.mjs --apply",
1295
+ test: "vitest run --coverage",
1296
+ },
1297
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1298
+ });
1299
+ const dest = tmp("pj-autorun-");
1300
+ fs.writeFileSync(
1301
+ path.join(dest, "package.json"),
1302
+ JSON.stringify({
1303
+ scripts: {
1304
+ "apps:doctor": "node scripts/audit.mjs",
1305
+ "apps:mental-model:viewer:build":
1306
+ "npm --prefix scripts/excalidraw/viewer install && npm --prefix scripts/excalidraw/viewer run build",
1307
+ check:
1308
+ "node scripts/portability-check.mjs && npm run rulesync:check && npm run lint && npm test && node scripts/media-fixtures.mjs --check",
1309
+ "codex:sync": "node scripts/codex-sync.mjs",
1310
+ doctor: "node ./scripts/doctor.mjs",
1311
+ "hooks:check": "node scripts/hooks-union.mjs --check",
1312
+ "hooks:union": "node scripts/hooks-union.mjs",
1313
+ lint: "eslint scripts tests eslint.config.mjs",
1314
+ "purge:global-mcps": "node scripts/purge-global-mcps.mjs",
1315
+ "purge:global-mcps:apply": "node scripts/purge-global-mcps.mjs --apply",
1316
+ "rulesync:check": "node ./scripts/guarded-roots.mjs check && node scripts/codex-sync.mjs --check",
1317
+ "rulesync:doctor": "rulesync doctor",
1318
+ "rulesync:generate": "node ./scripts/guarded-roots.mjs generate && node scripts/codex-sync.mjs",
1319
+ "rulesync:watch": "node ./scripts/guarded-roots.mjs watch",
1320
+ "sync:globals": "node scripts/sync-global-settings.mjs",
1321
+ "sync:globals:apply": "node scripts/sync-global-settings.mjs --apply",
1322
+ "sync:globals:check": "node scripts/check-globals.mjs",
1323
+ test: "node scripts/run-tests.mjs",
1324
+ },
1325
+ }),
1326
+ );
1327
+ mergePackageJson(dest, pkgRoot);
1328
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1329
+ expect(out.scripts["grunt:rulesync:generate"]).toBe(
1330
+ "node ./scripts/guarded-roots.mjs generate && node scripts/codex-sync.mjs",
1331
+ );
1332
+ expect(out.scripts["grunt:rulesync:check"]).toBe(
1333
+ "node ./scripts/guarded-roots.mjs check && node scripts/codex-sync.mjs --check",
1334
+ );
1335
+ expect(out.scripts["grunt:rulesync:watch"]).toBe("node ./scripts/guarded-roots.mjs watch");
1336
+ expect(out.scripts["grunt:rulesync:doctor"]).toBe("rulesync doctor");
1337
+ expect(out.scripts["grunt:hooks:union"]).toBe("node scripts/hooks-union.mjs");
1338
+ expect(out.scripts["grunt:hooks:check"]).toBe("node scripts/hooks-union.mjs --check");
1339
+ expect(out.scripts["grunt:sync:globals"]).toBe("node scripts/sync-global-settings.mjs");
1340
+ expect(out.scripts["grunt:sync:globals:apply"]).toBe("node scripts/sync-global-settings.mjs --apply");
1341
+ expect(out.scripts["grunt:sync:globals:check"]).toBe("node scripts/check-globals.mjs");
1342
+ expect(out.scripts["grunt:purge:global-mcps"]).toBe("node scripts/purge-global-mcps.mjs");
1343
+ expect(out.scripts["grunt:purge:global-mcps:apply"]).toBe("node scripts/purge-global-mcps.mjs --apply");
1344
+ expect(out.scripts["grunt:doctor"]).toBe("node ./scripts/doctor.mjs");
1345
+ expect(out.scripts.doctor).toBeUndefined();
1346
+ expect(out.scripts["rulesync:generate"]).toBeUndefined();
1347
+ expect(out.scripts["rulesync:check"]).toBeUndefined();
1348
+ expect(out.scripts["rulesync:watch"]).toBeUndefined();
1349
+ expect(out.scripts["hooks:union"]).toBeUndefined();
1350
+ expect(out.scripts["sync:globals"]).toBeUndefined();
1351
+ expect(out.scripts.check).toBe(
1352
+ "node scripts/portability-check.mjs && npm run grunt:rulesync:check && npm run lint && npm test && node scripts/media-fixtures.mjs --check",
1353
+ );
1354
+ expect(out.scripts["apps:doctor"]).toBe("node scripts/audit.mjs");
1355
+ expect(out.scripts["codex:sync"]).toBe("node scripts/codex-sync.mjs");
1356
+ expect(out.scripts.lint).toBe("eslint scripts tests eslint.config.mjs");
1357
+ expect(out.scripts.test).toBe("node scripts/run-tests.mjs");
1358
+ });
1359
+
1360
+ it("leaves custom doctor and writes grunt:doctor from SoT", () => {
1361
+ const pkgRoot = stubPkgRoot({
1362
+ name: "fixture-pkg",
1363
+ scripts: { doctor: "node ./scripts/doctor.mjs" },
1364
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1365
+ });
1366
+ const dest = tmp("pj-custom-doctor-");
1367
+ fs.writeFileSync(
1368
+ path.join(dest, "package.json"),
1369
+ JSON.stringify({ scripts: { doctor: "node scripts/audit.mjs" } }),
1370
+ );
1371
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1372
+ mergePackageJson(dest, pkgRoot);
1373
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1374
+ expect(out.scripts.doctor).toBe("node scripts/audit.mjs");
1375
+ expect(out.scripts["grunt:doctor"]).toBe("node ./scripts/doctor.mjs");
1376
+ expect(warnSpy).toHaveBeenCalled();
1377
+ warnSpy.mockRestore();
1378
+ });
1379
+
1380
+ it("merges destKey when both prefixed and owned legacy exist, deletes legacy", () => {
1381
+ const pkgRoot = stubPkgRoot({
1382
+ name: "fixture-pkg",
1383
+ scripts: { "rulesync:generate": "rulesync generate -t bar" },
1384
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1385
+ });
1386
+ const dest = tmp("pj-both-");
1387
+ fs.writeFileSync(
1388
+ path.join(dest, "package.json"),
1389
+ JSON.stringify({
1390
+ scripts: {
1391
+ "rulesync:generate": "rulesync generate -t foo && node scripts/codex-sync.mjs",
1392
+ "grunt:rulesync:generate": "rulesync generate -t old && node scripts/other.mjs",
1393
+ },
1394
+ }),
1395
+ );
1396
+ mergePackageJson(dest, pkgRoot);
1397
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1398
+ expect(out.scripts["grunt:rulesync:generate"]).toBe(
1399
+ "rulesync generate -t bar && node scripts/other.mjs",
1400
+ );
1401
+ expect(out.scripts["rulesync:generate"]).toBeUndefined();
1402
+ });
1403
+
1404
+ it("does not rewrite npm run rulesync:check:raw when only rulesync:check migrated", () => {
1405
+ const pkgRoot = stubPkgRoot({
1406
+ name: "fixture-pkg",
1407
+ scripts: { "rulesync:check": "node ./scripts/guarded-roots.mjs check" },
1408
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1409
+ });
1410
+ const dest = tmp("pj-raw-rewrite-");
1411
+ fs.writeFileSync(
1412
+ path.join(dest, "package.json"),
1413
+ JSON.stringify({
1414
+ scripts: {
1415
+ "rulesync:check": "node ./scripts/guarded-roots.mjs check",
1416
+ "rulesync:check:raw": "echo consumer-raw",
1417
+ check: "npm run rulesync:check && npm run rulesync:check:raw",
1418
+ },
1419
+ }),
1420
+ );
1421
+ mergePackageJson(dest, pkgRoot);
1422
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1423
+ expect(out.scripts["grunt:rulesync:check"]).toBe("node ./scripts/guarded-roots.mjs check");
1424
+ expect(out.scripts["rulesync:check"]).toBeUndefined();
1425
+ expect(out.scripts["rulesync:check:raw"]).toBe("echo consumer-raw");
1426
+ expect(out.scripts.check).toBe("npm run grunt:rulesync:check && npm run rulesync:check:raw");
1427
+ });
1428
+
1429
+ it("skips non-string dest scripts when rewriting npm run refs", () => {
1430
+ const pkgRoot = stubPkgRoot({
1431
+ name: "fixture-pkg",
1432
+ scripts: { "rulesync:check": "node ./scripts/guarded-roots.mjs check" },
1433
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1434
+ });
1435
+ const dest = tmp("pj-nonstr-");
1436
+ fs.writeFileSync(
1437
+ path.join(dest, "package.json"),
1438
+ JSON.stringify({
1439
+ scripts: {
1440
+ extra: 1,
1441
+ check: "npm run rulesync:check",
1442
+ },
1443
+ }),
1444
+ );
1445
+ mergePackageJson(dest, pkgRoot);
1446
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1447
+ expect(out.scripts.extra).toBe(1);
1448
+ expect(out.scripts.check).toBe("npm run grunt:rulesync:check");
1449
+ expect(out.scripts["grunt:rulesync:check"]).toBe("node ./scripts/guarded-roots.mjs check");
1450
+ });
1451
+
1452
+ it("emits grunt:claude into a consumer package.json", () => {
1453
+ const pkgRoot = stubPkgRoot();
1454
+ const dest = tmp("pj-launch-emit-");
1455
+ mergePackageJson(dest, pkgRoot);
1456
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1457
+ expect(out.scripts["grunt:claude"]).toBe(LAUNCH_SCRIPTS.claude);
1458
+ });
1459
+
1460
+ it("leaves a consumer's bare claude launcher untouched, no warn emitted", () => {
1461
+ const pkgRoot = stubPkgRoot();
1462
+ const dest = tmp("pj-consumer-claude-");
1463
+ fs.writeFileSync(
1464
+ path.join(dest, "package.json"),
1465
+ JSON.stringify({ scripts: { claude: "npm run build && claude" } }),
1466
+ );
1467
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
1468
+ mergePackageJson(dest, pkgRoot);
1469
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1470
+ expect(out.scripts.claude).toBe("npm run build && claude");
1471
+ expect(out.scripts["grunt:claude"]).toBe(LAUNCH_SCRIPTS.claude);
1472
+ expect(warn).not.toHaveBeenCalled();
1473
+ warn.mockRestore();
1474
+ });
1475
+
1476
+ it("does not rewrite npm run claude inside a consumer script", () => {
1477
+ const pkgRoot = stubPkgRoot();
1478
+ const dest = tmp("pj-npmrun-claude-");
1479
+ fs.writeFileSync(
1480
+ path.join(dest, "package.json"),
1481
+ JSON.stringify({
1482
+ scripts: { claude: "npm run build && claude", start: "npm run claude" },
1483
+ }),
1484
+ );
1485
+ mergePackageJson(dest, pkgRoot);
1486
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1487
+ expect(out.scripts.start).toBe("npm run claude");
1488
+ });
1489
+
1490
+ it("purges a stale grunt:yolo:claude with the exact old value; an edited variant survives", () => {
1491
+ const pkgRoot = stubPkgRoot();
1492
+ const dest = tmp("pj-purge-stale-");
1493
+ fs.writeFileSync(
1494
+ path.join(dest, "package.json"),
1495
+ JSON.stringify({
1496
+ scripts: {
1497
+ "grunt:yolo:claude": LAUNCH_SCRIPTS.claude,
1498
+ "grunt:yolo:codex": `${LAUNCH_SCRIPTS.codex} --extra`,
1499
+ },
1500
+ }),
1501
+ );
1502
+ mergePackageJson(dest, pkgRoot);
1503
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1504
+ expect(out.scripts["grunt:yolo:claude"]).toBeUndefined();
1505
+ expect(out.scripts["grunt:claude"]).toBe(LAUNCH_SCRIPTS.claude);
1506
+ expect(out.scripts["grunt:yolo:codex"]).toBe(`${LAUNCH_SCRIPTS.codex} --extra`);
1507
+ });
1508
+
1509
+ it("drift: every bare launcher key in grunt's package.json equals LAUNCH_SCRIPTS[key]", () => {
1510
+ const pkg = JSON.parse(
1511
+ fs.readFileSync(
1512
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"),
1513
+ "utf8",
1514
+ ),
1515
+ );
1516
+ for (const [k, v] of Object.entries(LAUNCH_SCRIPTS)) {
1517
+ expect(pkg.scripts[k]).toBe(v);
1518
+ }
1519
+ });
993
1520
  });
994
1521
 
995
1522
  describe("init", () => {
@@ -1023,6 +1550,9 @@ describe("init", () => {
1023
1550
  if (d === ".claude") continue;
1024
1551
  expect(fs.readFileSync(path.join(dest, d, "marker"), "utf8")).toBe(d);
1025
1552
  }
1553
+ expect(readGruntConfig(dest)).toEqual(GRUNT_CONFIG_DEFAULTS);
1554
+ expect(PRODUCT_FILES).toContain("grunt-config.mjs");
1555
+ expect(fs.statSync(path.join(dest, "scripts", "grunt-config.mjs")).isFile()).toBe(true);
1026
1556
  expect(fs.readFileSync(path.join(dest, ".claude", "marker"), "utf8")).toBe(".claude");
1027
1557
  const settings = JSON.parse(
1028
1558
  fs.readFileSync(path.join(dest, ".claude", "settings.json"), "utf8"),
@@ -1050,9 +1580,9 @@ describe("init", () => {
1050
1580
 
1051
1581
  expect(exec.mock.calls.map((c) => [c[0], c[1]])).toEqual([
1052
1582
  ["npm", ["install"]],
1053
- ["npm", ["run", "rulesync:generate"]],
1054
- ["npm", ["run", "sync:globals:apply"]],
1055
- ["npm", ["run", "rulesync:check"]],
1583
+ ["npm", ["run", "grunt:rulesync:generate"]],
1584
+ ["npm", ["run", "grunt:sync:globals:apply"]],
1585
+ ["npm", ["run", "grunt:rulesync:check"]],
1056
1586
  ]);
1057
1587
  for (const call of exec.mock.calls) {
1058
1588
  expect(call[2]).toEqual({ cwd: dest, stdio: "inherit" });
@@ -1101,13 +1631,21 @@ describe("init", () => {
1101
1631
  const exec = vi.fn();
1102
1632
  init(dest, { execFileSync: exec });
1103
1633
  expect(fs.existsSync(path.join(dest, "AGENTS.md"))).toBe(true);
1634
+ const agents = fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8");
1635
+ const claude = fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8");
1636
+ expect(agents.split("<!-- grunt:begin -->")).toHaveLength(2);
1637
+ expect(agents.split("<!-- grunt:end -->")).toHaveLength(2);
1638
+ expect(claude.split("<!-- grunt:begin -->")).toHaveLength(2);
1639
+ expect(claude.split("<!-- grunt:end -->")).toHaveLength(2);
1104
1640
  expect(fs.existsSync(path.join(dest, "scripts", "scrub-text"))).toBe(true);
1641
+ expect(fs.existsSync(path.join(dest, "scripts", "grunt-config.mjs"))).toBe(true);
1105
1642
  expect(fs.existsSync(path.join(dest, ".claude", "settings.json"))).toBe(true);
1643
+ expect(readGruntConfig(dest)).toEqual(GRUNT_CONFIG_DEFAULTS);
1106
1644
  expect(exec.mock.calls.map((c) => c[1])).toEqual([
1107
1645
  ["install"],
1108
- ["run", "rulesync:generate"],
1109
- ["run", "sync:globals:apply"],
1110
- ["run", "rulesync:check"],
1646
+ ["run", "grunt:rulesync:generate"],
1647
+ ["run", "grunt:sync:globals:apply"],
1648
+ ["run", "grunt:rulesync:check"],
1111
1649
  ]);
1112
1650
  });
1113
1651
 
@@ -1118,8 +1656,8 @@ describe("init", () => {
1118
1656
  init(dest, { pkgRoot, execFileSync: exec, skipGlobals: true });
1119
1657
  expect(exec.mock.calls.map((c) => [c[0], c[1]])).toEqual([
1120
1658
  ["npm", ["install"]],
1121
- ["npm", ["run", "rulesync:generate"]],
1122
- ["npm", ["run", "rulesync:check"]],
1659
+ ["npm", ["run", "grunt:rulesync:generate"]],
1660
+ ["npm", ["run", "grunt:rulesync:check"]],
1123
1661
  ]);
1124
1662
  });
1125
1663
 
@@ -1134,9 +1672,9 @@ describe("init", () => {
1134
1672
  init(dest, { pkgRoot, execFileSync: exec, applyGlobals: true });
1135
1673
  expect(exec.mock.calls.map((c) => c[1])).toEqual([
1136
1674
  ["install"],
1137
- ["run", "rulesync:generate"],
1138
- ["run", "sync:globals:apply"],
1139
- ["run", "rulesync:check"],
1675
+ ["run", "grunt:rulesync:generate"],
1676
+ ["run", "grunt:sync:globals:apply"],
1677
+ ["run", "grunt:rulesync:check"],
1140
1678
  ]);
1141
1679
  });
1142
1680
 
@@ -1151,8 +1689,8 @@ describe("init", () => {
1151
1689
  init(dest, { pkgRoot, execFileSync: exec });
1152
1690
  expect(exec.mock.calls.map((c) => c[1])).toEqual([
1153
1691
  ["install"],
1154
- ["run", "rulesync:generate"],
1155
- ["run", "rulesync:check"],
1692
+ ["run", "grunt:rulesync:generate"],
1693
+ ["run", "grunt:rulesync:check"],
1156
1694
  ]);
1157
1695
  });
1158
1696
 
@@ -1189,9 +1727,9 @@ describe("init", () => {
1189
1727
  expect(fs.existsSync(path.join(dest, "AGENTS.grunt.md"))).toBe(false);
1190
1728
  expect(exec.mock.calls.map((c) => c[1])).toEqual([
1191
1729
  ["install"],
1192
- ["run", "rulesync:generate"],
1193
- ["run", "sync:globals:apply"],
1194
- ["run", "rulesync:check"],
1730
+ ["run", "grunt:rulesync:generate"],
1731
+ ["run", "grunt:sync:globals:apply"],
1732
+ ["run", "grunt:rulesync:check"],
1195
1733
  ]);
1196
1734
  });
1197
1735
 
@@ -1202,7 +1740,7 @@ describe("init", () => {
1202
1740
  fs.writeFileSync(path.join(dest, "CLAUDE.md"), "consumer claude\n");
1203
1741
  fs.writeFileSync(path.join(dest, "GEMINI.md"), "consumer gemini\n");
1204
1742
  const exec = vi.fn((_cmd, args) => {
1205
- if (Array.isArray(args) && args.includes("rulesync:generate")) {
1743
+ if (Array.isArray(args) && args.includes("grunt:rulesync:generate")) {
1206
1744
  fs.writeFileSync(path.join(dest, "AGENTS.md"), "generated agents\n");
1207
1745
  fs.writeFileSync(path.join(dest, "CLAUDE.md"), "generated claude\n");
1208
1746
  fs.writeFileSync(path.join(dest, "GEMINI.md"), "@AGENTS.md\n");
@@ -1227,6 +1765,32 @@ describe("init", () => {
1227
1765
  );
1228
1766
  });
1229
1767
 
1768
+ it("wrapped package src + generate clobber stays begin=1 end=1", () => {
1769
+ const pkgRoot = stubPkgRoot();
1770
+ for (const f of GUARDED_MD_FILES) {
1771
+ fs.writeFileSync(
1772
+ path.join(pkgRoot, f),
1773
+ `<!-- grunt:begin -->\n${f} content\n<!-- grunt:end -->\n`,
1774
+ );
1775
+ }
1776
+ const dest = tmp("grunt-wrap-gen-");
1777
+ const exec = vi.fn((_cmd, args) => {
1778
+ if (Array.isArray(args) && args.includes("grunt:rulesync:generate")) {
1779
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "generated agents\n");
1780
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "generated claude\n");
1781
+ }
1782
+ });
1783
+ init(dest, { pkgRoot, execFileSync: exec });
1784
+ const agents = fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8");
1785
+ const claude = fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8");
1786
+ expect(agents).toBe("<!-- grunt:begin -->\ngenerated agents\n<!-- grunt:end -->\n");
1787
+ expect(claude).toBe("<!-- grunt:begin -->\ngenerated claude\n<!-- grunt:end -->\n");
1788
+ expect(agents.split("<!-- grunt:begin -->")).toHaveLength(2);
1789
+ expect(agents.split("<!-- grunt:end -->")).toHaveLength(2);
1790
+ expect(claude.split("<!-- grunt:begin -->")).toHaveLength(2);
1791
+ expect(claude.split("<!-- grunt:end -->")).toHaveLength(2);
1792
+ });
1793
+
1230
1794
  it("onPhase merge then npm phases stop before exec", () => {
1231
1795
  const pkgRoot = stubPkgRoot();
1232
1796
  const dest = tmp("grunt-onphase-");
@@ -1247,9 +1811,9 @@ describe("init", () => {
1247
1811
  ]);
1248
1812
  expect(exec.mock.calls.map((c) => c[1])).toEqual([
1249
1813
  ["install"],
1250
- ["run", "rulesync:generate"],
1251
- ["run", "sync:globals:apply"],
1252
- ["run", "rulesync:check"],
1814
+ ["run", "grunt:rulesync:generate"],
1815
+ ["run", "grunt:sync:globals:apply"],
1816
+ ["run", "grunt:rulesync:check"],
1253
1817
  ]);
1254
1818
  });
1255
1819
 
@@ -1262,6 +1826,30 @@ describe("init", () => {
1262
1826
  ["merge", "stop"],
1263
1827
  ]);
1264
1828
  });
1829
+
1830
+ it("warns when dest skill differs from packaged then force-overwrites; keeps extras", () => {
1831
+ const pkgRoot = stubPkgRoot();
1832
+ const pkgSkill = path.join(pkgRoot, ".rulesync", "skills", "parent");
1833
+ fs.mkdirSync(pkgSkill, { recursive: true });
1834
+ fs.writeFileSync(path.join(pkgSkill, "SKILL.md"), "grunt-parent\n");
1835
+ const dest = tmp("grunt-sk-conflict-");
1836
+ const destSkill = path.join(dest, ".rulesync", "skills", "parent");
1837
+ const destExtra = path.join(dest, ".rulesync", "skills", "my-extra");
1838
+ fs.mkdirSync(destSkill, { recursive: true });
1839
+ fs.mkdirSync(destExtra, { recursive: true });
1840
+ fs.writeFileSync(path.join(destSkill, "SKILL.md"), "custom-parent\n");
1841
+ fs.writeFileSync(path.join(destExtra, "SKILL.md"), "keep-me\n");
1842
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1843
+ init(dest, { pkgRoot, execFileSync: vi.fn(), skipGlobals: true });
1844
+ expect(
1845
+ warnSpy.mock.calls.some(
1846
+ (c) => String(c[0]).includes("parent") && String(c[0]).includes("re-init overwrites"),
1847
+ ),
1848
+ ).toBe(true);
1849
+ warnSpy.mockRestore();
1850
+ expect(fs.readFileSync(path.join(destSkill, "SKILL.md"), "utf8")).toBe("grunt-parent\n");
1851
+ expect(fs.readFileSync(path.join(destExtra, "SKILL.md"), "utf8")).toBe("keep-me\n");
1852
+ });
1265
1853
  });
1266
1854
 
1267
1855
  describe("destAlreadyInited / shouldAutoSkipGlobals", () => {