@lovrozagar/grunt 0.4.0 → 0.4.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 (46) hide show
  1. package/.agents/agents/grunt.md +2 -2
  2. package/.agents/agents/implementer.md +3 -2
  3. package/.agents/agents/orchestrator.md +17 -11
  4. package/.agents/agents/thinker.md +7 -2
  5. package/.agents/skills/explain/SKILL.md +2 -0
  6. package/.agents/skills/handoff/SKILL.md +1 -1
  7. package/.agents/skills/solo/SKILL.md +1 -1
  8. package/.claude/agents/grunt.md +2 -2
  9. package/.claude/agents/implementer.md +5 -2
  10. package/.claude/agents/orchestrator.md +17 -11
  11. package/.claude/agents/thinker.md +9 -2
  12. package/.claude/skills/explain/SKILL.md +2 -0
  13. package/.claude/skills/handoff/SKILL.md +1 -1
  14. package/.claude/skills/solo/SKILL.md +1 -1
  15. package/.codex/agents/grunt.toml +2 -2
  16. package/.codex/agents/implementer.toml +3 -2
  17. package/.codex/agents/orchestrator.toml +17 -11
  18. package/.codex/agents/thinker.toml +7 -2
  19. package/.grok/agents/grunt.md +2 -2
  20. package/.grok/agents/implementer.md +3 -2
  21. package/.grok/agents/orchestrator.md +17 -11
  22. package/.grok/agents/thinker.md +7 -2
  23. package/.grok/hooks/orchestrate-parent.js +25 -10
  24. package/.grok/skills/explain/SKILL.md +2 -0
  25. package/.grok/skills/handoff/SKILL.md +1 -1
  26. package/.grok/skills/solo/SKILL.md +1 -1
  27. package/.rulesync/reference/cascade.md +12 -6
  28. package/.rulesync/reference/hooks.md +2 -2
  29. package/.rulesync/reference/output.md +38 -1
  30. package/.rulesync/rules/CLAUDE.md +17 -11
  31. package/.rulesync/rules/overview.md +17 -11
  32. package/.rulesync/skills/explain/SKILL.md +2 -0
  33. package/.rulesync/skills/handoff/SKILL.md +1 -1
  34. package/.rulesync/skills/solo/SKILL.md +1 -1
  35. package/.rulesync/subagents/grunt.md +2 -2
  36. package/.rulesync/subagents/implementer.md +3 -2
  37. package/.rulesync/subagents/orchestrator.md +17 -11
  38. package/.rulesync/subagents/thinker.md +7 -2
  39. package/AGENTS.md +19 -11
  40. package/CLAUDE.md +19 -11
  41. package/README.md +6 -6
  42. package/cli/init.mjs +194 -26
  43. package/cli/init.test.ts +442 -21
  44. package/package.json +9 -5
  45. package/scripts/emit-gemini.mjs +7 -1
  46. package/scripts/guarded-roots.mjs +83 -0
package/cli/init.test.ts CHANGED
@@ -3,15 +3,27 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import {
6
+ MAX_GUARDED_MARKDOWN_BYTES,
7
+ composeGuardedMarkdown,
6
8
  destAlreadyInited,
9
+ extractGruntBody,
10
+ GUARDED_ROOT_FILES,
11
+ guardedMarkdownDrift,
12
+ healGuardedRootFile,
7
13
  init,
8
14
  mergeClaudeSettings,
9
15
  mergeGitignore,
16
+ mergeGuardedContent,
10
17
  mergeGuardedMarkdown,
11
18
  mergePackageJson,
19
+ remergeGuardedRoots,
12
20
  samePath,
13
21
  shouldAutoSkipGlobals,
22
+ snapshotGuardedRoots,
23
+ withGuardedCheckInteriors,
24
+ writeMergedGuardedFile,
14
25
  } from "./init.mjs";
26
+ import { runGuardedRoots } from "../scripts/guarded-roots.mjs";
15
27
 
16
28
  const tmpDirs: string[] = [];
17
29
  afterEach(() => {
@@ -32,6 +44,7 @@ const PRODUCT_FILES = [
32
44
  "check-globals.mjs",
33
45
  "emit-agent-shell-tools.mjs",
34
46
  "emit-gemini.mjs",
47
+ "guarded-roots.mjs",
35
48
  "emit-mcp-policy.mjs",
36
49
  "gate-fat-tools.mjs",
37
50
  "hooks-union.mjs",
@@ -203,22 +216,39 @@ describe("mergeGuardedMarkdown", () => {
203
216
  );
204
217
  });
205
218
 
206
- it("dest exists without sentinel: writes CLAUDE.grunt.md, leaves original untouched", () => {
219
+ it("dest exists without sentinel: prepends wrapped grunt, keeps full old body", () => {
207
220
  const pkgRoot = stubPkgRoot();
208
221
  const dest = tmp("md-nosentinel-");
209
222
  fs.writeFileSync(path.join(dest, "CLAUDE.md"), "hand-written consumer doc\n");
210
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
211
223
  mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
212
224
  expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
213
- "hand-written consumer doc\n",
225
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nhand-written consumer doc\n",
214
226
  );
215
- expect(fs.readFileSync(path.join(dest, "CLAUDE.grunt.md"), "utf8")).toBe("CLAUDE.md content");
216
- expect(logSpy).toHaveBeenCalledTimes(1);
217
- expect(logSpy.mock.calls[0][0]).toContain("CLAUDE.grunt.md");
218
- logSpy.mockRestore();
227
+ expect(fs.existsSync(path.join(dest, "CLAUDE.grunt.md"))).toBe(false);
228
+ });
229
+
230
+ it("alreadyInited unmarked still prepends; skip is not safe", () => {
231
+ const pkgRoot = stubPkgRoot();
232
+ const dest = tmp("md-inited-nosent-");
233
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "hand-written consumer doc\n");
234
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md", { alreadyInited: true });
235
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
236
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nhand-written consumer doc\n",
237
+ );
238
+ expect(fs.existsSync(path.join(dest, "CLAUDE.grunt.md"))).toBe(false);
219
239
  });
220
240
 
221
- it("dest exists with sentinel: replaces only the marked region", () => {
241
+ it("empty dest file is treated as missing", () => {
242
+ const pkgRoot = stubPkgRoot();
243
+ const dest = tmp("md-empty-");
244
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "");
245
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
246
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
247
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n",
248
+ );
249
+ });
250
+
251
+ it("dest exists with sentinel: grunt block at top, outside kept at bottom", () => {
222
252
  const pkgRoot = stubPkgRoot();
223
253
  const dest = tmp("md-sentinel-");
224
254
  fs.writeFileSync(
@@ -227,10 +257,368 @@ describe("mergeGuardedMarkdown", () => {
227
257
  );
228
258
  mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
229
259
  expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
230
- "before\n<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nafter\n",
260
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nbefore\nafter\n",
231
261
  );
232
262
  expect(fs.existsSync(path.join(dest, "CLAUDE.grunt.md"))).toBe(false);
233
263
  });
264
+
265
+ it("collapses duplicate sentinel pairs to one top block", () => {
266
+ const pkgRoot = stubPkgRoot();
267
+ const dest = tmp("md-dup-sent-");
268
+ fs.writeFileSync(
269
+ path.join(dest, "CLAUDE.md"),
270
+ "<!-- grunt:begin -->\nold-a\n<!-- grunt:end -->\nkeep-a\n<!-- grunt:begin -->\nold-b\n<!-- grunt:end -->\nkeep-b\n",
271
+ );
272
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
273
+ const out = fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8");
274
+ expect(out).toBe(
275
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nkeep-a\nkeep-b\n",
276
+ );
277
+ expect(out.split("<!-- grunt:begin -->")).toHaveLength(2);
278
+ });
279
+
280
+ it("second merge is idempotent and does not double-prepend", () => {
281
+ const pkgRoot = stubPkgRoot();
282
+ const dest = tmp("md-idem-");
283
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "hand-written consumer doc\n");
284
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
285
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
286
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
287
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nhand-written consumer doc\n",
288
+ );
289
+ });
290
+
291
+ it("does not fuzzy-dedupe unmarked protocol-looking user text", () => {
292
+ const pkgRoot = stubPkgRoot();
293
+ const dest = tmp("md-proto-");
294
+ const user = "Voice: `.rulesync/reference/output.md` — cite once.\nspawn grunt\n";
295
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), user);
296
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
297
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
298
+ `<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n${user}`,
299
+ );
300
+ });
301
+
302
+ it("preserves CRLF from dest", () => {
303
+ const pkgRoot = stubPkgRoot();
304
+ const dest = tmp("md-crlf-");
305
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "user line\r\n");
306
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
307
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
308
+ "<!-- grunt:begin -->\r\nCLAUDE.md content\r\n<!-- grunt:end -->\r\nuser line\r\n",
309
+ );
310
+ });
311
+
312
+ it("unmarked dest equal to grunt body wraps without duplicating", () => {
313
+ const pkgRoot = stubPkgRoot();
314
+ const dest = tmp("md-equal-");
315
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "CLAUDE.md content\n");
316
+ mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
317
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
318
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n",
319
+ );
320
+ });
321
+
322
+ it("binary dest is left untouched", () => {
323
+ const pkgRoot = stubPkgRoot();
324
+ const dest = tmp("md-bin-");
325
+ const raw = Buffer.from([0x68, 0x69, 0x00, 0xff]);
326
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), raw);
327
+ expect(mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md")).toBe("aborted-unsafe");
328
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"))).toEqual(raw);
329
+ });
330
+
331
+ it("leaves nested AGENTS.md alone", () => {
332
+ const pkgRoot = stubPkgRoot();
333
+ const dest = tmp("md-nested-");
334
+ fs.mkdirSync(path.join(dest, "pkg"), { recursive: true });
335
+ fs.writeFileSync(path.join(dest, "pkg", "AGENTS.md"), "nested user\n");
336
+ mergeGuardedMarkdown(dest, pkgRoot, "AGENTS.md");
337
+ expect(fs.readFileSync(path.join(dest, "pkg", "AGENTS.md"), "utf8")).toBe("nested user\n");
338
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
339
+ "<!-- grunt:begin -->\nAGENTS.md content\n<!-- grunt:end -->\n",
340
+ );
341
+ });
342
+ });
343
+
344
+ describe("snapshot/remerge guarded roots", () => {
345
+ it("remerge after clobber keeps user bottom and refreshes grunt interior", () => {
346
+ const dest = tmp("snap-clobber-");
347
+ fs.writeFileSync(
348
+ path.join(dest, "AGENTS.md"),
349
+ "<!-- grunt:begin -->\nold\n<!-- grunt:end -->\nconsumer agents\n",
350
+ );
351
+ fs.writeFileSync(path.join(dest, "GEMINI.md"), "user gemini\n");
352
+ const snap = snapshotGuardedRoots(dest);
353
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "generated agents\n");
354
+ fs.writeFileSync(path.join(dest, "GEMINI.md"), "@AGENTS.md\n");
355
+ remergeGuardedRoots(dest, snap);
356
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
357
+ "<!-- grunt:begin -->\ngenerated agents\n<!-- grunt:end -->\nconsumer agents\n",
358
+ );
359
+ expect(fs.readFileSync(path.join(dest, "GEMINI.md"), "utf8")).toBe(
360
+ "<!-- grunt:begin -->\n@AGENTS.md\n<!-- grunt:end -->\nuser gemini\n",
361
+ );
362
+ });
363
+
364
+ it("withGuardedCheckInteriors is restored even if check throws", () => {
365
+ const dest = tmp("check-int-");
366
+ const live =
367
+ "<!-- grunt:begin -->\ngrunt body\n<!-- grunt:end -->\nuser bottom\n";
368
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), live);
369
+ expect(() =>
370
+ withGuardedCheckInteriors(dest, () => {
371
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe("grunt body\n");
372
+ throw new Error("check failed");
373
+ }),
374
+ ).toThrow("check failed");
375
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(live);
376
+ });
377
+
378
+ it("compose/extract/drift/write helpers cover remaining branches", () => {
379
+ expect(composeGuardedMarkdown("body", null)).toBe(
380
+ "<!-- grunt:begin -->\nbody\n<!-- grunt:end -->\n",
381
+ );
382
+ expect(composeGuardedMarkdown("body", "\n\n")).toBe(
383
+ "<!-- grunt:begin -->\nbody\n<!-- grunt:end -->\n",
384
+ );
385
+ expect(extractGruntBody("no markers")).toBeNull();
386
+ expect(
387
+ extractGruntBody(
388
+ "<!-- grunt:begin -->\na\n<!-- grunt:end -->\n<!-- grunt:begin -->\nb\n<!-- grunt:end -->\n",
389
+ ),
390
+ ).toBe("a\nb");
391
+ expect(mergeGuardedContent(null, "g")).toBe(
392
+ "<!-- grunt:begin -->\ng\n<!-- grunt:end -->\n",
393
+ );
394
+ expect(mergeGuardedContent("", "a\r\nb")).toBe(
395
+ "<!-- grunt:begin -->\r\na\r\nb\r\n<!-- grunt:end -->\r\n",
396
+ );
397
+ expect(composeGuardedMarkdown("b", "\r\n")).toBe(
398
+ "<!-- grunt:begin -->\nb\n<!-- grunt:end -->\n",
399
+ );
400
+ expect(mergeGuardedContent("keep\n", "g\r\n")).toBe(
401
+ "<!-- grunt:begin -->\ng\n<!-- grunt:end -->\nkeep\n",
402
+ );
403
+ expect(mergeGuardedContent("<!-- grunt:begin --> only", "g")).toContain("only");
404
+ const dest = tmp("md-helpers-");
405
+ const missing = path.join(dest, "nope.md");
406
+ expect(guardedMarkdownDrift(missing, "g")).toBe(true);
407
+ writeMergedGuardedFile(missing, "g");
408
+ expect(fs.readFileSync(missing, "utf8")).toBe(
409
+ "<!-- grunt:begin -->\ng\n<!-- grunt:end -->\n",
410
+ );
411
+ expect(guardedMarkdownDrift(missing, "g")).toBe(false);
412
+ expect(guardedMarkdownDrift(missing, "other")).toBe(true);
413
+ fs.writeFileSync(missing, "plain\n");
414
+ expect(guardedMarkdownDrift(missing, "plain\n")).toBe(false);
415
+ expect(guardedMarkdownDrift(missing, "x")).toBe(true);
416
+ const bin = path.join(dest, "bin.md");
417
+ fs.writeFileSync(bin, Buffer.from([0]));
418
+ expect(guardedMarkdownDrift(bin, "g")).toBe(true);
419
+ const huge = path.join(dest, "huge.md");
420
+ fs.writeFileSync(huge, Buffer.alloc(MAX_GUARDED_MARKDOWN_BYTES + 1, 65));
421
+ expect(writeMergedGuardedFile(huge, "g")).toBe("aborted-unsafe");
422
+ });
423
+
424
+ it("remerge restores unsafe, missing, binary current, and created files", () => {
425
+ const dest = tmp("remerge-edges-");
426
+ const agents = path.join(dest, "AGENTS.md");
427
+ const claude = path.join(dest, "CLAUDE.md");
428
+ const gemini = path.join(dest, "GEMINI.md");
429
+ const rawBuf = Buffer.from([0x41, 0x00]);
430
+ fs.writeFileSync(agents, rawBuf);
431
+ fs.writeFileSync(claude, "keep claude\n");
432
+ const snap = snapshotGuardedRoots(dest);
433
+ expect(snap["AGENTS.md"].unsafe).toBe("binary");
434
+ fs.writeFileSync(agents, "clobbered\n");
435
+ fs.rmSync(claude);
436
+ fs.writeFileSync(gemini, "@AGENTS.md\n");
437
+ const binClaude = path.join(dest, "CLAUDE.md");
438
+ remergeGuardedRoots(dest, snap);
439
+ expect(fs.readFileSync(agents)).toEqual(rawBuf);
440
+ expect(fs.readFileSync(claude, "utf8")).toBe("keep claude\n");
441
+ expect(fs.readFileSync(gemini, "utf8")).toBe(
442
+ "<!-- grunt:begin -->\n@AGENTS.md\n<!-- grunt:end -->\n",
443
+ );
444
+ fs.writeFileSync(claude, Buffer.from([0x00]));
445
+ remergeGuardedRoots(dest, snap);
446
+ expect(fs.readFileSync(binClaude, "utf8")).toBe("keep claude\n");
447
+ remergeGuardedRoots(dest, {});
448
+ fs.writeFileSync(gemini, Buffer.from([0x00]));
449
+ remergeGuardedRoots(dest, {});
450
+ expect(fs.readFileSync(gemini)).toEqual(Buffer.from([0x00]));
451
+ fs.rmSync(gemini);
452
+ remergeGuardedRoots(dest, {});
453
+ expect(fs.existsSync(gemini)).toBe(false);
454
+ });
455
+
456
+ it("remerge clears user when unmarked snap equals new grunt body", () => {
457
+ const dest = tmp("remerge-eq-");
458
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "same\n");
459
+ const snap = snapshotGuardedRoots(dest);
460
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "same\n");
461
+ remergeGuardedRoots(dest, snap);
462
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe("same\n");
463
+ fs.writeFileSync(
464
+ path.join(dest, "AGENTS.md"),
465
+ "<!-- grunt:begin -->\nsame\n<!-- grunt:end -->\n",
466
+ );
467
+ remergeGuardedRoots(dest, snap);
468
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
469
+ "<!-- grunt:begin -->\nsame\n<!-- grunt:end -->\n",
470
+ );
471
+ });
472
+
473
+ it("withGuardedCheckInteriors skips unmarked and unsafe then restores", () => {
474
+ const dest = tmp("check-skip-");
475
+ const live = "plain user\n";
476
+ const rawBuf = Buffer.from([0x00, 0x01]);
477
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), live);
478
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), rawBuf);
479
+ const ret = withGuardedCheckInteriors(dest, () => {
480
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(live);
481
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"))).toEqual(rawBuf);
482
+ return 7;
483
+ });
484
+ expect(ret).toBe(7);
485
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(live);
486
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"))).toEqual(rawBuf);
487
+ });
488
+
489
+ it("runGuardedRoots generate rematches after inner clobber", () => {
490
+ const dest = tmp("guarded-run-");
491
+ fs.writeFileSync(
492
+ path.join(dest, "AGENTS.md"),
493
+ "<!-- grunt:begin -->\nold\n<!-- grunt:end -->\nkeep me\n",
494
+ );
495
+ const exec = vi.fn(() => {
496
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "fresh ssot\n");
497
+ });
498
+ runGuardedRoots("generate", { cwd: dest, exec });
499
+ expect(exec).toHaveBeenCalledTimes(1);
500
+ expect(String(exec.mock.calls[0][0])).toContain("rulesync:generate:raw");
501
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
502
+ "<!-- grunt:begin -->\nfresh ssot\n<!-- grunt:end -->\nkeep me\n",
503
+ );
504
+ });
505
+
506
+ it("healGuardedRootFile rematches unmarked clobber and keeps later user edits", () => {
507
+ const dest = tmp("heal-watch-");
508
+ fs.writeFileSync(
509
+ path.join(dest, "AGENTS.md"),
510
+ "<!-- grunt:begin -->\nold\n<!-- grunt:end -->\nkeep me\n",
511
+ );
512
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "user claude\n");
513
+ const snap = snapshotGuardedRoots(dest);
514
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "watch gen 1\n");
515
+ healGuardedRootFile(dest, "AGENTS.md", snap);
516
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
517
+ "<!-- grunt:begin -->\nwatch gen 1\n<!-- grunt:end -->\nkeep me\n",
518
+ );
519
+ fs.writeFileSync(
520
+ path.join(dest, "AGENTS.md"),
521
+ "<!-- grunt:begin -->\nwatch gen 1\n<!-- grunt:end -->\nedited bottom\n",
522
+ );
523
+ healGuardedRootFile(dest, "AGENTS.md", snap);
524
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "watch gen 2\n");
525
+ healGuardedRootFile(dest, "AGENTS.md", snap);
526
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
527
+ "<!-- grunt:begin -->\nwatch gen 2\n<!-- grunt:end -->\nedited bottom\n",
528
+ );
529
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "watch claude\n");
530
+ healGuardedRootFile(dest, "CLAUDE.md", snap);
531
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
532
+ "<!-- grunt:begin -->\nwatch claude\n<!-- grunt:end -->\nuser claude\n",
533
+ );
534
+ healGuardedRootFile(dest, "README.md", snap);
535
+ expect(GUARDED_ROOT_FILES).toEqual(["AGENTS.md", "CLAUDE.md", "GEMINI.md"]);
536
+ const eq = tmp("heal-eq-");
537
+ fs.writeFileSync(path.join(eq, "AGENTS.md"), "same\n");
538
+ const eqSnap = snapshotGuardedRoots(eq);
539
+ healGuardedRootFile(eq, "AGENTS.md", eqSnap);
540
+ expect(fs.readFileSync(path.join(eq, "AGENTS.md"), "utf8")).toBe("same\n");
541
+ fs.writeFileSync(path.join(eq, "AGENTS.md"), "same");
542
+ healGuardedRootFile(eq, "AGENTS.md", eqSnap);
543
+ expect(fs.readFileSync(path.join(eq, "AGENTS.md"), "utf8")).toBe(
544
+ "<!-- grunt:begin -->\nsame\n<!-- grunt:end -->\n",
545
+ );
546
+ fs.writeFileSync(path.join(eq, "GEMINI.md"), "@AGENTS.md\n");
547
+ healGuardedRootFile(eq, "GEMINI.md", {});
548
+ expect(fs.readFileSync(path.join(eq, "GEMINI.md"), "utf8")).toBe(
549
+ "<!-- grunt:begin -->\n@AGENTS.md\n<!-- grunt:end -->\n",
550
+ );
551
+ });
552
+
553
+ it("healGuardedRootFile restores unsafe clobber and ignores missing", () => {
554
+ const dest = tmp("heal-unsafe-");
555
+ const rawBuf = Buffer.from([0x00, 0x02]);
556
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), rawBuf);
557
+ const snap = snapshotGuardedRoots(dest);
558
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "clobbered\n");
559
+ healGuardedRootFile(dest, "AGENTS.md", snap);
560
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"))).toEqual(rawBuf);
561
+ healGuardedRootFile(dest, "GEMINI.md", snap);
562
+ expect(fs.existsSync(path.join(dest, "GEMINI.md"))).toBe(false);
563
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "ok\n");
564
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), Buffer.from([0x00]));
565
+ const snap2 = snapshotGuardedRoots(dest);
566
+ snap2["CLAUDE.md"] = { raw: "ok\n", user: "ok\n" };
567
+ healGuardedRootFile(dest, "CLAUDE.md", snap2);
568
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe("ok\n");
569
+ fs.writeFileSync(path.join(dest, "GEMINI.md"), Buffer.from([0x00]));
570
+ healGuardedRootFile(dest, "GEMINI.md", {});
571
+ expect(fs.readFileSync(path.join(dest, "GEMINI.md"))).toEqual(Buffer.from([0x00]));
572
+ });
573
+
574
+ it("runGuardedRoots watch rematches after each inner clobber", () => {
575
+ const dest = tmp("guarded-watch-");
576
+ fs.writeFileSync(
577
+ path.join(dest, "AGENTS.md"),
578
+ "<!-- grunt:begin -->\nold\n<!-- grunt:end -->\nkeep me\n",
579
+ );
580
+ let heal;
581
+ const exec = vi.fn(() => {
582
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "watch1\n");
583
+ heal("AGENTS.md");
584
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
585
+ "<!-- grunt:begin -->\nwatch1\n<!-- grunt:end -->\nkeep me\n",
586
+ );
587
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "watch2\n");
588
+ heal("AGENTS.md");
589
+ });
590
+ const stop = vi.fn();
591
+ runGuardedRoots("watch", {
592
+ cwd: dest,
593
+ exec,
594
+ attachWatchers: ({ heal: h }) => {
595
+ heal = h;
596
+ return stop;
597
+ },
598
+ });
599
+ expect(exec).toHaveBeenCalledTimes(1);
600
+ expect(String(exec.mock.calls[0][0])).toContain("rulesync:watch:raw");
601
+ expect(stop).toHaveBeenCalledTimes(1);
602
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
603
+ "<!-- grunt:begin -->\nwatch2\n<!-- grunt:end -->\nkeep me\n",
604
+ );
605
+ });
606
+
607
+ it("runGuardedRoots watch default watchers still rematch on exit", () => {
608
+ const dest = tmp("guarded-watch-def-");
609
+ fs.writeFileSync(
610
+ path.join(dest, "AGENTS.md"),
611
+ "<!-- grunt:begin -->\nold\n<!-- grunt:end -->\nkeep me\n",
612
+ );
613
+ const exec = vi.fn(() => {
614
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "from-watch\n");
615
+ });
616
+ runGuardedRoots("watch", { cwd: dest, exec });
617
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
618
+ "<!-- grunt:begin -->\nfrom-watch\n<!-- grunt:end -->\nkeep me\n",
619
+ );
620
+ expect(() => runGuardedRoots("nope")).toThrow(/generate\|check\|watch/);
621
+ });
234
622
  });
235
623
 
236
624
  describe("mergeClaudeSettings", () => {
@@ -781,7 +1169,7 @@ describe("init", () => {
781
1169
  ]);
782
1170
  });
783
1171
 
784
- it("re-run without sentinel does not create/overwrite *.grunt.md", () => {
1172
+ it("re-run alreadyInited unmarked still merges live files; stale *.grunt.md unused", () => {
785
1173
  const pkgRoot = stubPkgRoot();
786
1174
  const dest = tmp("grunt-rerun-nosent-");
787
1175
  fs.writeFileSync(path.join(dest, "CLAUDE.md"), "hand-written consumer doc\n");
@@ -790,34 +1178,67 @@ describe("init", () => {
790
1178
  fs.writeFileSync(path.join(dest, "scripts", "telemetry.mjs"), "existing");
791
1179
  fs.writeFileSync(path.join(dest, "CLAUDE.grunt.md"), "stale side");
792
1180
  const exec = vi.fn();
793
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
794
1181
  init(dest, { pkgRoot, execFileSync: exec });
795
- expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe("hand-written consumer doc\n");
796
- expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe("hand-written consumer doc\n");
1182
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
1183
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nhand-written consumer doc\n",
1184
+ );
1185
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
1186
+ "<!-- grunt:begin -->\nAGENTS.md content\n<!-- grunt:end -->\nhand-written consumer doc\n",
1187
+ );
797
1188
  expect(fs.readFileSync(path.join(dest, "CLAUDE.grunt.md"), "utf8")).toBe("stale side");
798
1189
  expect(fs.existsSync(path.join(dest, "AGENTS.grunt.md"))).toBe(false);
799
- expect(logSpy.mock.calls.filter((c) => String(c[0]).includes("lack markers"))).toHaveLength(1);
800
- logSpy.mockRestore();
801
1190
  });
802
1191
 
803
- it("first init without sentinel still creates *.grunt.md", () => {
1192
+ it("first init without sentinel prepends grunt and keeps user bottom", () => {
804
1193
  const pkgRoot = stubPkgRoot();
805
1194
  const dest = tmp("grunt-first-nosent-");
806
1195
  fs.writeFileSync(path.join(dest, "CLAUDE.md"), "hand-written consumer doc\n");
807
1196
  fs.writeFileSync(path.join(dest, "AGENTS.md"), "hand-written consumer doc\n");
808
1197
  const exec = vi.fn();
809
- const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
810
1198
  init(dest, { pkgRoot, execFileSync: exec });
811
- expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe("hand-written consumer doc\n");
812
- expect(fs.readFileSync(path.join(dest, "CLAUDE.grunt.md"), "utf8")).toBe("CLAUDE.md content");
813
- expect(fs.readFileSync(path.join(dest, "AGENTS.grunt.md"), "utf8")).toBe("AGENTS.md content");
1199
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
1200
+ "<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nhand-written consumer doc\n",
1201
+ );
1202
+ expect(fs.existsSync(path.join(dest, "CLAUDE.grunt.md"))).toBe(false);
1203
+ expect(fs.existsSync(path.join(dest, "AGENTS.grunt.md"))).toBe(false);
814
1204
  expect(exec.mock.calls.map((c) => c[1])).toEqual([
815
1205
  ["install"],
816
1206
  ["run", "rulesync:generate"],
817
1207
  ["run", "sync:globals:apply"],
818
1208
  ["run", "rulesync:check"],
819
1209
  ]);
820
- logSpy.mockRestore();
1210
+ });
1211
+
1212
+ it("generate clobber after merge keeps user bottom (idempotent re-init)", () => {
1213
+ const pkgRoot = stubPkgRoot();
1214
+ const dest = tmp("grunt-gen-clobber-");
1215
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "consumer agents\n");
1216
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "consumer claude\n");
1217
+ fs.writeFileSync(path.join(dest, "GEMINI.md"), "consumer gemini\n");
1218
+ const exec = vi.fn((_cmd, args) => {
1219
+ if (Array.isArray(args) && args.includes("rulesync:generate")) {
1220
+ fs.writeFileSync(path.join(dest, "AGENTS.md"), "generated agents\n");
1221
+ fs.writeFileSync(path.join(dest, "CLAUDE.md"), "generated claude\n");
1222
+ fs.writeFileSync(path.join(dest, "GEMINI.md"), "@AGENTS.md\n");
1223
+ }
1224
+ });
1225
+ init(dest, { pkgRoot, execFileSync: exec });
1226
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
1227
+ "<!-- grunt:begin -->\ngenerated agents\n<!-- grunt:end -->\nconsumer agents\n",
1228
+ );
1229
+ expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
1230
+ "<!-- grunt:begin -->\ngenerated claude\n<!-- grunt:end -->\nconsumer claude\n",
1231
+ );
1232
+ expect(fs.readFileSync(path.join(dest, "GEMINI.md"), "utf8")).toBe(
1233
+ "<!-- grunt:begin -->\n@AGENTS.md\n<!-- grunt:end -->\nconsumer gemini\n",
1234
+ );
1235
+ init(dest, { pkgRoot, execFileSync: vi.fn() });
1236
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe(
1237
+ "<!-- grunt:begin -->\nAGENTS.md content\n<!-- grunt:end -->\nconsumer agents\n",
1238
+ );
1239
+ expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8").split("consumer agents").length).toBe(
1240
+ 2,
1241
+ );
821
1242
  });
822
1243
 
823
1244
  it("onPhase merge then npm phases stop before exec", () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.4.0",
4
- "description": "Multi-agent orchestrator SoT \u2014 deep-merge rulesync trees, product scripts, and generate pipeline into a consumer repo",
3
+ "version": "0.4.1",
4
+ "description": "Multi-agent orchestrator SoT — deep-merge rulesync trees, product scripts, and generate pipeline into a consumer repo",
5
5
  "keywords": [
6
6
  "agents",
7
7
  "grunt",
@@ -27,6 +27,7 @@
27
27
  "scripts/check-globals.mjs",
28
28
  "scripts/emit-agent-shell-tools.mjs",
29
29
  "scripts/emit-gemini.mjs",
30
+ "scripts/guarded-roots.mjs",
30
31
  "scripts/emit-mcp-policy.mjs",
31
32
  "scripts/gate-fat-tools.mjs",
32
33
  "scripts/hooks-union.mjs",
@@ -61,9 +62,12 @@
61
62
  "@clack/prompts": "^0.11.0"
62
63
  },
63
64
  "scripts": {
64
- "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",
65
- "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",
66
- "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",
65
+ "rulesync:generate": "node ./scripts/guarded-roots.mjs generate",
66
+ "rulesync:generate:raw": "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",
67
+ "rulesync:watch": "node ./scripts/guarded-roots.mjs watch",
68
+ "rulesync:watch:raw": "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",
69
+ "rulesync:check": "node ./scripts/guarded-roots.mjs check",
70
+ "rulesync:check:raw": "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",
67
71
  "hooks:union": "node scripts/hooks-union.mjs",
68
72
  "hooks:check": "node scripts/hooks-union.mjs --check",
69
73
  "doctor": "node ./scripts/doctor.mjs",
@@ -3,6 +3,7 @@
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { guardedMarkdownDrift, writeMergedGuardedFile } from "../cli/init.mjs";
6
7
 
7
8
  export const AGENT_IDS = ["orchestrator", "implementer", "thinker", "grunt"];
8
9
  const SSOT_REL = ".rulesync/subagents";
@@ -173,6 +174,10 @@ export function emitGemini({ workspaceRoot, check = false } = {}) {
173
174
  if (check) {
174
175
  const drift = [];
175
176
  for (const f of files) {
177
+ if (f.rel === GEMINI_MD_REL) {
178
+ if (guardedMarkdownDrift(path.join(ws, f.rel), f.text)) drift.push(f.rel);
179
+ continue;
180
+ }
176
181
  const disk = readText(path.join(ws, f.rel));
177
182
  if (disk !== f.text) drift.push(f.rel);
178
183
  }
@@ -185,7 +190,8 @@ export function emitGemini({ workspaceRoot, check = false } = {}) {
185
190
  for (const f of files) {
186
191
  const abs = path.join(ws, f.rel);
187
192
  fs.mkdirSync(path.dirname(abs), { recursive: true });
188
- fs.writeFileSync(abs, f.text);
193
+ if (f.rel === GEMINI_MD_REL) writeMergedGuardedFile(abs, f.text);
194
+ else fs.writeFileSync(abs, f.text);
189
195
  }
190
196
  return { ok: true, check: false };
191
197
  }