@lovrozagar/grunt 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/init.mjs CHANGED
@@ -16,6 +16,15 @@ import {
16
16
  remergeGuardedRoots,
17
17
  withGuardedCheckInteriors,
18
18
  } from "../scripts/guarded-md.mjs"
19
+ import {
20
+ PACKAGE_MANAGERS,
21
+ UNKNOWN_PACKAGE_MANAGER,
22
+ detectPackageManager,
23
+ installArgs,
24
+ isPackageManager,
25
+ runScriptArgs,
26
+ } from "../scripts/package-manager.mjs"
27
+ import { LAW_REL, emitMaps } from "../scripts/emit-maps.mjs"
19
28
 
20
29
  export {
21
30
  GUARDED_ROOT_FILES,
@@ -44,11 +53,13 @@ export const PRODUCT_SCRIPTS = [
44
53
  "guarded-md.mjs",
45
54
  "guarded-roots.mjs",
46
55
  "emit-mcp-policy.mjs",
56
+ "folder-map.mjs",
47
57
  "gate-fat-tools.mjs",
48
58
  "hooks-union.mjs",
49
59
  "pipeline.mjs",
50
60
  "grunt-job.mjs",
51
61
  "parse-need.mjs",
62
+ "package-manager.mjs",
52
63
  "persist-handoff.mjs",
53
64
  "persist-implementation.mjs",
54
65
  "persist-tmp.mjs",
@@ -56,6 +67,7 @@ export const PRODUCT_SCRIPTS = [
56
67
  "purge-global-mcps.mjs",
57
68
  "scrub-spawn-prompt.mjs",
58
69
  "scrub-text-lib.mjs",
70
+ "session-map.mjs",
59
71
  "sync-global-settings.mjs",
60
72
  "browser.mjs",
61
73
  "speak.mjs",
@@ -70,6 +82,9 @@ export const PRODUCT_SCRIPTS = [
70
82
  ]
71
83
 
72
84
  const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"]
85
+ /** Generated on dest from the dest tree (grunt refs + consumer extras). Never stamp the package copies. */
86
+ export const GENERATED_MAP_FILES = new Set(["INDEX.md", "skills-map.md", "refs-map.md"])
87
+ const COPY_ROOT_IF_MISSING = [".mcp.json"]
73
88
  export const RETIRED_SKILLS = ["parent", "solo", "cascade"]
74
89
  export const RETIRED_AGENTS = ["implementer", "thinker"]
75
90
  /** Cumulative. Dest `scripts/<name>` deleted on init/upgrade even if no longer shipped. */
@@ -99,6 +114,7 @@ export const RESERVED_SKILLS = [
99
114
  "listen",
100
115
  "pickup",
101
116
  "speak",
117
+ "su",
102
118
  "tmp",
103
119
  "write-plan",
104
120
  ]
@@ -116,9 +132,9 @@ const AGENT_MIRROR_DIRS = [
116
132
  [".codex", "agents"],
117
133
  ]
118
134
  const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"]
119
- // .mcp.json is intentionally not copied here: scripts/emit-mcp-policy.mjs
120
- // (run via consumer `grunt:rulesync:generate`, see mergeClaudeSettings ordering) owns and
121
- // merges it, so a plain-copy would just be clobbered/redundant.
135
+ // .mcp.json: copy when dest is missing so first init has a root file. emit-mcp-policy
136
+ // (generate) still owns the merge from .rulesync/mcp-policy.jsonc.
137
+ export const GRUNT_PACKAGE = "@lovrozagar/grunt"
122
138
  export const GRUNT_NPM_PREFIX = "grunt:"
123
139
  export function toGruntScriptName(k) {
124
140
  return k.startsWith(GRUNT_NPM_PREFIX) ? k : `${GRUNT_NPM_PREFIX}${k}`
@@ -130,7 +146,12 @@ export const LAUNCH_SCRIPTS = {
130
146
  gemini: "gemini --yolo",
131
147
  grok: "grok --yolo",
132
148
  }
133
- const OWNED_HOOK_FILES = ["scrub-spawn-prompt.mjs", "gate-fat-tools.mjs", "orchestrate-parent.js"]
149
+ const OWNED_HOOK_FILES = [
150
+ "scrub-spawn-prompt.mjs",
151
+ "gate-fat-tools.mjs",
152
+ "orchestrate-parent.js",
153
+ "session-map.mjs",
154
+ ]
134
155
 
135
156
  function sortKeys(obj) {
136
157
  return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]))
@@ -279,7 +300,7 @@ export function mergeClaudeSettings(destRoot, pkgRoot) {
279
300
  }
280
301
 
281
302
  function looksGruntOwnedPrefix(prefix) {
282
- return /rulesync|sync:globals|^npm run |guarded-roots\.mjs|hooks-union\.mjs|doctor\.mjs|sync-global-settings\.mjs|check-globals\.mjs|purge-global-mcps\.mjs/.test(
303
+ return /rulesync|sync:globals|^(?:npm|yarn|pnpm|bun) run |guarded-roots\.mjs|hooks-union\.mjs|doctor\.mjs|sync-global-settings\.mjs|check-globals\.mjs|purge-global-mcps\.mjs/.test(
283
304
  prefix,
284
305
  )
285
306
  }
@@ -317,24 +338,47 @@ function mergeScriptValue(newSrc, cur) {
317
338
  return newSrc
318
339
  }
319
340
 
320
- function rewriteNpmRunRefs(scripts, srcKeys) {
341
+ function rewritePackageManagerRunRefs(scripts, srcKeys) {
321
342
  const keys = srcKeys
322
343
  .filter((k) => k !== toGruntScriptName(k))
323
344
  .sort((a, b) => b.length - a.length)
324
345
  if (!keys.length) return
346
+ const pm = PACKAGE_MANAGERS.join("|")
325
347
  for (const name of Object.keys(scripts)) {
326
348
  let val = scripts[name]
327
349
  if (typeof val !== "string") continue
328
350
  for (const k of keys) {
329
351
  val = val.replace(
330
- new RegExp(`npm run ${escapeRe(k)}(?=$|\\s|;|&)`, "g"),
331
- `npm run ${toGruntScriptName(k)}`,
352
+ new RegExp(`(${pm}) run ${escapeRe(k)}(?=$|\\s|;|&)`, "g"),
353
+ `$1 run ${toGruntScriptName(k)}`,
332
354
  )
333
355
  }
334
356
  scripts[name] = val
335
357
  }
336
358
  }
337
359
 
360
+ export function placeGruntDevDependency(destPkg, srcPkg) {
361
+ const deps =
362
+ destPkg.dependencies && typeof destPkg.dependencies === "object"
363
+ ? destPkg.dependencies
364
+ : null
365
+ destPkg.devDependencies = { ...(destPkg.devDependencies || {}) }
366
+ const fromDep = deps?.[GRUNT_PACKAGE]
367
+ const fromDev = destPkg.devDependencies[GRUNT_PACKAGE]
368
+ if (fromDep != null && deps) {
369
+ delete destPkg.dependencies[GRUNT_PACKAGE]
370
+ if (fromDev == null) destPkg.devDependencies[GRUNT_PACKAGE] = fromDep
371
+ if (Object.keys(destPkg.dependencies).length === 0) delete destPkg.dependencies
372
+ }
373
+ if (
374
+ destPkg.devDependencies[GRUNT_PACKAGE] == null &&
375
+ srcPkg?.name === GRUNT_PACKAGE &&
376
+ srcPkg.version
377
+ ) {
378
+ destPkg.devDependencies[GRUNT_PACKAGE] = srcPkg.version
379
+ }
380
+ }
381
+
338
382
  export function mergePackageJson(dest, pkgRoot) {
339
383
  const destPath = path.join(dest, "package.json")
340
384
  let destPkg = {}
@@ -377,18 +421,47 @@ export function mergePackageJson(dest, pkgRoot) {
377
421
  const staleKey = `grunt:yolo:${k}`
378
422
  if (destPkg.scripts[staleKey] === v) delete destPkg.scripts[staleKey]
379
423
  }
380
- rewriteNpmRunRefs(destPkg.scripts, srcKeys)
424
+ rewritePackageManagerRunRefs(destPkg.scripts, srcKeys)
381
425
  destPkg.devDependencies = { ...(destPkg.devDependencies || {}) }
382
426
  destPkg.devDependencies["smol-toml"] = srcPkg.devDependencies["smol-toml"]
383
427
  destPkg.devDependencies["rulesync"] = srcPkg.devDependencies["rulesync"]
384
428
  const clack = srcPkg.dependencies?.["@clack/prompts"]
385
429
  if (clack) destPkg.devDependencies["@clack/prompts"] = clack
430
+ placeGruntDevDependency(destPkg, srcPkg)
386
431
  destPkg.scripts = sortKeys(destPkg.scripts)
387
432
  destPkg.devDependencies = sortKeys(destPkg.devDependencies)
388
433
  fs.writeFileSync(destPath, `${JSON.stringify(destPkg, null, 2)}\n`)
389
434
  }
390
435
 
391
- export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase } = {}) {
436
+ function copyRootIfMissing(dest, pkgRoot) {
437
+ for (const file of COPY_ROOT_IF_MISSING) {
438
+ const src = path.join(pkgRoot, file)
439
+ const d = path.join(dest, file)
440
+ if (!fs.existsSync(src) || fs.existsSync(d)) continue
441
+ fs.copyFileSync(src, d)
442
+ }
443
+ }
444
+
445
+ function emitDestMaps(dest) {
446
+ if (!fs.existsSync(path.join(dest, LAW_REL))) return
447
+ const maps = emitMaps({ workspaceRoot: dest })
448
+ if (!maps.ok) throw new Error(maps.error || "emit-maps failed")
449
+ }
450
+
451
+ export function resolveInitPackageManager(dest, { packageManager, env = process.env } = {}) {
452
+ if (packageManager) {
453
+ const name = String(packageManager).toLowerCase()
454
+ if (!isPackageManager(name)) {
455
+ throw new Error(UNKNOWN_PACKAGE_MANAGER)
456
+ }
457
+ return name
458
+ }
459
+ const detected = detectPackageManager({ cwd: dest, env })
460
+ if (!detected.manager) throw new Error(UNKNOWN_PACKAGE_MANAGER)
461
+ return detected.manager
462
+ }
463
+
464
+ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase, packageManager, env = process.env } = {}) {
392
465
  dest = path.resolve(dest)
393
466
  const pkgRoot = path.resolve(pkgRootOpt ?? PKG_ROOT)
394
467
  const skipGlobalsApply =
@@ -429,6 +502,12 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
429
502
  filter: (s) => path.basename(s) !== "settings.json",
430
503
  })
431
504
  mergeClaudeSettings(dest, pkgRoot)
505
+ } else if (dir === ".rulesync") {
506
+ fs.cpSync(src, d, {
507
+ recursive: true,
508
+ force: true,
509
+ filter: (s) => !GENERATED_MAP_FILES.has(path.basename(s)),
510
+ })
432
511
  } else {
433
512
  fs.cpSync(src, d, { recursive: true, force: true })
434
513
  }
@@ -453,26 +532,31 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
453
532
  fs.mkdirSync(path.join(dest, ".tmp"), { recursive: true })
454
533
  mergeGitignore(dest)
455
534
 
456
- if (!self) mergePackageJson(dest, pkgRoot)
535
+ if (!self) {
536
+ mergePackageJson(dest, pkgRoot)
537
+ copyRootIfMissing(dest, pkgRoot)
538
+ emitDestMaps(dest)
539
+ }
457
540
  })
458
541
 
459
542
  // Self-skip: file/dir merge + .tmp + gitignore only. No package.json merge,
460
- // npm install, or generate/sync/check (would mutate this package in-place).
543
+ // install, or generate/sync/check (would mutate this package in-place).
461
544
  if (self) return
462
545
 
463
- const runNpm = (name, args) => {
546
+ const pm = resolveInitPackageManager(dest, { packageManager, env })
547
+ const runPm = (name, args) => {
464
548
  onPhase?.(name, "start")
465
549
  onPhase?.(name, "stop")
466
- exec("npm", args, { cwd: dest, stdio: "inherit" })
550
+ exec(pm, args, { cwd: dest, stdio: "inherit" })
467
551
  }
468
- runNpm("install", ["install"])
552
+ runPm("install", installArgs(pm))
469
553
  const guardedSnap = snapshotGuardedRoots(dest)
470
- runNpm("generate", ["run", toGruntScriptName("rulesync:generate")])
554
+ runPm("generate", runScriptArgs(pm, toGruntScriptName("rulesync:generate")))
471
555
  remergeGuardedRoots(dest, guardedSnap)
472
556
  if (!skipGlobalsApply) {
473
- runNpm("sync-globals", ["run", toGruntScriptName("sync:globals:apply")])
557
+ runPm("sync-globals", runScriptArgs(pm, toGruntScriptName("sync:globals:apply")))
474
558
  }
475
559
  withGuardedCheckInteriors(dest, () => {
476
- runNpm("check", ["run", toGruntScriptName("rulesync:check")])
560
+ runPm("check", runScriptArgs(pm, toGruntScriptName("rulesync:check")))
477
561
  })
478
562
  }
package/cli/init.test.ts CHANGED
@@ -4,6 +4,18 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
+
8
+ const emitMaps = vi.hoisted(() => vi.fn());
9
+ const actualEmitMaps = vi.hoisted(() => ({ fn: null as null | ((...args: unknown[]) => unknown) }));
10
+
11
+ vi.mock("../scripts/emit-maps.mjs", async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import("../scripts/emit-maps.mjs")>();
13
+ actualEmitMaps.fn = actual.emitMaps;
14
+ emitMaps.mockImplementation((...args: unknown[]) =>
15
+ actual.emitMaps(...(args as Parameters<typeof actual.emitMaps>)),
16
+ );
17
+ return { ...actual, emitMaps };
18
+ });
7
19
  import {
8
20
  MAX_GUARDED_MARKDOWN_BYTES,
9
21
  PRODUCT_SCRIPTS,
@@ -28,7 +40,10 @@ import {
28
40
  mergeGitignore,
29
41
  mergeGuardedContent,
30
42
  mergeGuardedMarkdown,
43
+ GENERATED_MAP_FILES,
44
+ GRUNT_PACKAGE,
31
45
  mergePackageJson,
46
+ placeGruntDevDependency,
32
47
  remergeGuardedRoots,
33
48
  samePath,
34
49
  shouldAutoSkipGlobals,
@@ -37,6 +52,7 @@ import {
37
52
  withGuardedCheckInteriors,
38
53
  writeMergedGuardedFile,
39
54
  } from "./init.mjs";
55
+ import { UNKNOWN_PACKAGE_MANAGER } from "../scripts/package-manager.mjs";
40
56
  import { runGuardedRoots } from "../scripts/guarded-roots.mjs";
41
57
 
42
58
  const tmpDirs: string[] = [];
@@ -44,6 +60,10 @@ afterEach(() => {
44
60
  for (const d of tmpDirs.splice(0)) {
45
61
  fs.rmSync(d, { recursive: true, force: true });
46
62
  }
63
+ emitMaps.mockReset();
64
+ emitMaps.mockImplementation((...args: unknown[]) =>
65
+ (actualEmitMaps.fn as (...a: unknown[]) => unknown)(...args),
66
+ );
47
67
  });
48
68
 
49
69
  function tmp(prefix: string) {
@@ -62,6 +82,17 @@ const SRC_CLAUDE_SETTINGS = {
62
82
  deny: ["Agent(Explore)", "Agent(orchestrator)", "mcp__*"],
63
83
  },
64
84
  hooks: {
85
+ SessionStart: [
86
+ {
87
+ hooks: [
88
+ {
89
+ type: "command",
90
+ command: 'node "${ROOT}/scripts/session-map.mjs"',
91
+ timeout: 5,
92
+ },
93
+ ],
94
+ },
95
+ ],
65
96
  PreToolUse: [
66
97
  {
67
98
  matcher: "spawn",
@@ -1408,6 +1439,113 @@ describe("mergePackageJson", () => {
1408
1439
  expect(out.scripts.check).toBe("npm run grunt:rulesync:check && npm run rulesync:check:raw");
1409
1440
  });
1410
1441
 
1442
+ it("rewrites yarn/pnpm/bun run refs the same way as npm run", () => {
1443
+ const pkgRoot = stubPkgRoot({
1444
+ name: "fixture-pkg",
1445
+ scripts: { "rulesync:check": "node ./scripts/guarded-roots.mjs check" },
1446
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1447
+ });
1448
+ const dest = tmp("pj-pm-rewrite-");
1449
+ fs.writeFileSync(
1450
+ path.join(dest, "package.json"),
1451
+ JSON.stringify({
1452
+ scripts: {
1453
+ "rulesync:check": "node ./scripts/guarded-roots.mjs check",
1454
+ check: "pnpm run rulesync:check && yarn run rulesync:check && bun run rulesync:check",
1455
+ },
1456
+ }),
1457
+ );
1458
+ mergePackageJson(dest, pkgRoot);
1459
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1460
+ expect(out.scripts.check).toBe(
1461
+ "pnpm run grunt:rulesync:check && yarn run grunt:rulesync:check && bun run grunt:rulesync:check",
1462
+ );
1463
+ });
1464
+
1465
+ it("moves @lovrozagar/grunt from dependencies to devDependencies", () => {
1466
+ const pkgRoot = stubPkgRoot({
1467
+ name: GRUNT_PACKAGE,
1468
+ version: "0.6.2",
1469
+ scripts: { "rulesync:generate": "gen" },
1470
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1471
+ });
1472
+ const dest = tmp("pj-grunt-dep-");
1473
+ fs.writeFileSync(
1474
+ path.join(dest, "package.json"),
1475
+ JSON.stringify({
1476
+ name: "app",
1477
+ dependencies: { [GRUNT_PACKAGE]: "0.6.2", lodash: "4" },
1478
+ scripts: {},
1479
+ }),
1480
+ );
1481
+ mergePackageJson(dest, pkgRoot);
1482
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1483
+ expect(out.dependencies).toEqual({ lodash: "4" });
1484
+ expect(out.devDependencies[GRUNT_PACKAGE]).toBe("0.6.2");
1485
+ });
1486
+
1487
+ it("drops empty dependencies after moving grunt", () => {
1488
+ const pkgRoot = stubPkgRoot({
1489
+ name: GRUNT_PACKAGE,
1490
+ version: "0.7.0",
1491
+ scripts: {},
1492
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1493
+ });
1494
+ const dest = tmp("pj-grunt-onlydep-");
1495
+ fs.writeFileSync(
1496
+ path.join(dest, "package.json"),
1497
+ JSON.stringify({ dependencies: { [GRUNT_PACKAGE]: "0.6.2" } }),
1498
+ );
1499
+ mergePackageJson(dest, pkgRoot);
1500
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1501
+ expect(out.dependencies).toBeUndefined();
1502
+ expect(out.devDependencies[GRUNT_PACKAGE]).toBe("0.6.2");
1503
+ });
1504
+
1505
+ it("adds missing grunt as a devDependency from the running package version", () => {
1506
+ const pkgRoot = stubPkgRoot({
1507
+ name: GRUNT_PACKAGE,
1508
+ version: "0.7.0",
1509
+ scripts: {},
1510
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1511
+ });
1512
+ const dest = tmp("pj-grunt-add-");
1513
+ fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ name: "app" }));
1514
+ mergePackageJson(dest, pkgRoot);
1515
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1516
+ expect(out.devDependencies[GRUNT_PACKAGE]).toBe("0.7.0");
1517
+ });
1518
+
1519
+ it("keeps an existing grunt devDependency version", () => {
1520
+ const pkgRoot = stubPkgRoot({
1521
+ name: GRUNT_PACKAGE,
1522
+ version: "0.7.0",
1523
+ scripts: {},
1524
+ devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1525
+ });
1526
+ const dest = tmp("pj-grunt-keep-");
1527
+ fs.writeFileSync(
1528
+ path.join(dest, "package.json"),
1529
+ JSON.stringify({
1530
+ devDependencies: { [GRUNT_PACKAGE]: "^0.6.2" },
1531
+ dependencies: { [GRUNT_PACKAGE]: "0.6.2" },
1532
+ }),
1533
+ );
1534
+ mergePackageJson(dest, pkgRoot);
1535
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1536
+ expect(out.dependencies).toBeUndefined();
1537
+ expect(out.devDependencies[GRUNT_PACKAGE]).toBe("^0.6.2");
1538
+ });
1539
+
1540
+ it("placeGruntDevDependency is a no-op when src is not grunt", () => {
1541
+ const destPkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> } = {
1542
+ dependencies: { lodash: "4" },
1543
+ };
1544
+ placeGruntDevDependency(destPkg, { name: "fixture-pkg", version: "1.0.0" });
1545
+ expect(destPkg.dependencies).toEqual({ lodash: "4" });
1546
+ expect(destPkg.devDependencies?.[GRUNT_PACKAGE]).toBeUndefined();
1547
+ });
1548
+
1411
1549
  it("skips non-string dest scripts when rewriting npm run refs", () => {
1412
1550
  const pkgRoot = stubPkgRoot({
1413
1551
  name: "fixture-pkg",
@@ -1526,7 +1664,7 @@ describe("init", () => {
1526
1664
  const pkgRoot = stubPkgRoot();
1527
1665
  const dest = tmp("grunt-dest-");
1528
1666
  const exec = vi.fn();
1529
- init(dest, { pkgRoot, execFileSync: exec });
1667
+ init(dest, { pkgRoot, execFileSync: exec, packageManager: "npm" });
1530
1668
 
1531
1669
  for (const d of COPY_DIRS) {
1532
1670
  if (d === ".claude") continue;
@@ -1601,6 +1739,24 @@ describe("init", () => {
1601
1739
  expect(settings.permissions.deny).toContain("mcp__*");
1602
1740
  });
1603
1741
 
1742
+ it("re-init replaces a prior grunt session-map SessionStart group instead of duplicating it", () => {
1743
+ const pkgRoot = stubPkgRoot();
1744
+ const dest = tmp("grunt-rerun-map-");
1745
+ fs.mkdirSync(path.join(dest, ".claude"), { recursive: true });
1746
+ const mapGroup = {
1747
+ hooks: [{ type: "command", command: 'node "$X/scripts/session-map.mjs"', timeout: 5 }],
1748
+ };
1749
+ fs.writeFileSync(
1750
+ path.join(dest, ".claude", "settings.json"),
1751
+ JSON.stringify({ permissions: {}, hooks: { SessionStart: [mapGroup] } }),
1752
+ );
1753
+ init(dest, { pkgRoot, execFileSync: vi.fn() });
1754
+ const settings = JSON.parse(
1755
+ fs.readFileSync(path.join(dest, ".claude", "settings.json"), "utf8"),
1756
+ );
1757
+ expect(settings.hooks.SessionStart).toEqual(SRC_CLAUDE_SETTINGS.hooks.SessionStart);
1758
+ });
1759
+
1604
1760
  it("omitted execFileSync uses default; self-skip never invokes it", () => {
1605
1761
  const pkgRoot = stubPkgRoot();
1606
1762
  init(pkgRoot, { pkgRoot });
@@ -1645,6 +1801,7 @@ describe("init", () => {
1645
1801
  "listen",
1646
1802
  "pickup",
1647
1803
  "speak",
1804
+ "su",
1648
1805
  "tmp",
1649
1806
  "write-plan",
1650
1807
  ]);
@@ -1741,7 +1898,7 @@ describe("init", () => {
1741
1898
  const pkgRoot = stubPkgRoot();
1742
1899
  const dest = tmp("grunt-skip-globals-");
1743
1900
  const exec = vi.fn();
1744
- init(dest, { pkgRoot, execFileSync: exec, skipGlobals: true });
1901
+ init(dest, { pkgRoot, execFileSync: exec, skipGlobals: true, packageManager: "npm" });
1745
1902
  expect(exec.mock.calls.map((c) => [c[0], c[1]])).toEqual([
1746
1903
  ["npm", ["install"]],
1747
1904
  ["npm", ["run", "grunt:rulesync:generate"]],
@@ -1879,6 +2036,128 @@ describe("init", () => {
1879
2036
  expect(claude.split("<!-- grunt:end -->")).toHaveLength(2);
1880
2037
  });
1881
2038
 
2039
+ it("pnpm-lock.yaml dest installs with pnpm even if invocation is npm", () => {
2040
+ const pkgRoot = stubPkgRoot();
2041
+ const dest = tmp("grunt-pnpm-");
2042
+ fs.writeFileSync(path.join(dest, "pnpm-lock.yaml"), "lockfileVersion: 9\n");
2043
+ const exec = vi.fn();
2044
+ init(dest, {
2045
+ pkgRoot,
2046
+ execFileSync: exec,
2047
+ env: { npm_config_user_agent: "npm/10.0.0 node/v22" },
2048
+ });
2049
+ expect(exec.mock.calls.map((c) => [c[0], c[1]])).toEqual([
2050
+ ["pnpm", ["install"]],
2051
+ ["pnpm", ["run", "grunt:rulesync:generate"]],
2052
+ ["pnpm", ["run", "grunt:sync:globals:apply"]],
2053
+ ["pnpm", ["run", "grunt:rulesync:check"]],
2054
+ ]);
2055
+ });
2056
+
2057
+ it("packageManager option wins over dest lockfile", () => {
2058
+ const pkgRoot = stubPkgRoot();
2059
+ const dest = tmp("grunt-pm-opt-");
2060
+ fs.writeFileSync(path.join(dest, "yarn.lock"), "");
2061
+ const exec = vi.fn();
2062
+ init(dest, { pkgRoot, execFileSync: exec, packageManager: "bun" });
2063
+ expect(exec.mock.calls[0][0]).toBe("bun");
2064
+ });
2065
+
2066
+ it("unknown manager throws and does not exec", () => {
2067
+ const pkgRoot = stubPkgRoot();
2068
+ const dest = tmp("grunt-pm-unknown-");
2069
+ const exec = vi.fn();
2070
+ expect(() =>
2071
+ init(dest, { pkgRoot, execFileSync: exec, env: {}, packageManager: undefined }),
2072
+ ).toThrow(UNKNOWN_PACKAGE_MANAGER);
2073
+ expect(exec).not.toHaveBeenCalled();
2074
+ });
2075
+
2076
+ it("invalid packageManager option throws", () => {
2077
+ const pkgRoot = stubPkgRoot();
2078
+ const dest = tmp("grunt-pm-bad-");
2079
+ const exec = vi.fn();
2080
+ expect(() => init(dest, { pkgRoot, execFileSync: exec, packageManager: "deno" })).toThrow(
2081
+ UNKNOWN_PACKAGE_MANAGER,
2082
+ );
2083
+ expect(exec).not.toHaveBeenCalled();
2084
+ });
2085
+
2086
+ it("yarn.lock dest uses yarn run args without npm --", () => {
2087
+ const pkgRoot = stubPkgRoot();
2088
+ const dest = tmp("grunt-yarn-");
2089
+ fs.writeFileSync(path.join(dest, "yarn.lock"), "");
2090
+ const exec = vi.fn();
2091
+ init(dest, { pkgRoot, execFileSync: exec, skipGlobals: true, env: {} });
2092
+ expect(exec.mock.calls.map((c) => [c[0], c[1]])).toEqual([
2093
+ ["yarn", ["install"]],
2094
+ ["yarn", ["run", "grunt:rulesync:generate"]],
2095
+ ["yarn", ["run", "grunt:rulesync:check"]],
2096
+ ]);
2097
+ });
2098
+
2099
+ it("merges dest refs into INDEX and writes .mcp.json when missing", () => {
2100
+ const dest = tmp("grunt-maps-merge-");
2101
+ fs.mkdirSync(path.join(dest, ".rulesync", "reference"), { recursive: true });
2102
+ fs.writeFileSync(
2103
+ path.join(dest, ".rulesync", "reference", "stripe.md"),
2104
+ "# Stripe\n\nConsumer stripe payments ref.\n",
2105
+ );
2106
+ const exec = vi.fn();
2107
+ init(dest, { execFileSync: exec, skipGlobals: true, packageManager: "npm" });
2108
+ expect(GENERATED_MAP_FILES.has("INDEX.md")).toBe(true);
2109
+ const index = fs.readFileSync(path.join(dest, ".rulesync", "reference", "INDEX.md"), "utf8");
2110
+ expect(index).toMatch(/stripe\.md/);
2111
+ expect(index).toMatch(/Consumer stripe payments ref/);
2112
+ expect(index).toMatch(/browser\.md/);
2113
+ const refsMap = fs.readFileSync(path.join(dest, ".rulesync", "reference", "refs-map.md"), "utf8");
2114
+ expect(refsMap).toMatch(/stripe\.md/);
2115
+ expect(fs.existsSync(path.join(dest, ".mcp.json"))).toBe(true);
2116
+ expect(JSON.parse(fs.readFileSync(path.join(dest, ".mcp.json"), "utf8"))).toEqual({
2117
+ mcpServers: {},
2118
+ });
2119
+ });
2120
+
2121
+ it("keeps dest .mcp.json when already present", () => {
2122
+ const dest = tmp("grunt-mcp-keep-");
2123
+ fs.writeFileSync(
2124
+ path.join(dest, ".mcp.json"),
2125
+ `${JSON.stringify({ mcpServers: { tasks: { command: "npx" } } }, null, 2)}\n`,
2126
+ );
2127
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" });
2128
+ expect(JSON.parse(fs.readFileSync(path.join(dest, ".mcp.json"), "utf8"))).toEqual({
2129
+ mcpServers: { tasks: { command: "npx" } },
2130
+ });
2131
+ });
2132
+
2133
+ it("throws when dest emit-maps fails", () => {
2134
+ emitMaps.mockReturnValue({ ok: false, error: "maps boom" });
2135
+ const dest = tmp("grunt-maps-fail-");
2136
+ expect(() =>
2137
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" }),
2138
+ ).toThrow("maps boom");
2139
+ emitMaps.mockReturnValue({ ok: false });
2140
+ const dest2 = tmp("grunt-maps-fail2-");
2141
+ expect(() =>
2142
+ init(dest2, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" }),
2143
+ ).toThrow("emit-maps failed");
2144
+ });
2145
+
2146
+ it("does not stamp package INDEX over dest; re-emits union", () => {
2147
+ const dest = tmp("grunt-index-keep-");
2148
+ fs.mkdirSync(path.join(dest, ".rulesync", "reference"), { recursive: true });
2149
+ fs.writeFileSync(path.join(dest, ".rulesync", "reference", "owned.md"), "# Owned\n\nDest-only.\n");
2150
+ fs.writeFileSync(
2151
+ path.join(dest, ".rulesync", "reference", "INDEX.md"),
2152
+ "stale grunt-only index\n",
2153
+ );
2154
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" });
2155
+ const index = fs.readFileSync(path.join(dest, ".rulesync", "reference", "INDEX.md"), "utf8");
2156
+ expect(index).not.toBe("stale grunt-only index\n");
2157
+ expect(index).toMatch(/owned\.md/);
2158
+ expect(index).toMatch(/browser\.md/);
2159
+ });
2160
+
1882
2161
  it("onPhase merge then npm phases stop before exec", () => {
1883
2162
  const pkgRoot = stubPkgRoot();
1884
2163
  const dest = tmp("grunt-onphase-");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
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",
@@ -31,11 +31,13 @@
31
31
  "scripts/guarded-md.mjs",
32
32
  "scripts/guarded-roots.mjs",
33
33
  "scripts/emit-mcp-policy.mjs",
34
+ "scripts/folder-map.mjs",
34
35
  "scripts/gate-fat-tools.mjs",
35
36
  "scripts/hooks-union.mjs",
36
37
  "scripts/pipeline.mjs",
37
38
  "scripts/grunt-job.mjs",
38
39
  "scripts/parse-need.mjs",
40
+ "scripts/package-manager.mjs",
39
41
  "scripts/persist-handoff.mjs",
40
42
  "scripts/persist-implementation.mjs",
41
43
  "scripts/persist-tmp.mjs",
@@ -43,6 +45,7 @@
43
45
  "scripts/purge-global-mcps.mjs",
44
46
  "scripts/scrub-spawn-prompt.mjs",
45
47
  "scripts/scrub-text-lib.mjs",
48
+ "scripts/session-map.mjs",
46
49
  "scripts/sync-global-settings.mjs",
47
50
  "scripts/browser.mjs",
48
51
  "scripts/speak.mjs",
@@ -4,8 +4,15 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { parse as parseToml } from "smol-toml";
7
+ import { detectPackageManager, runScriptLine } from "./package-manager.mjs";
7
8
 
8
- const APPLY_HINT = "npm run sync:globals:apply";
9
+ function applyHint(workspaceRoot) {
10
+ const { manager } = detectPackageManager({
11
+ cwd: workspaceRoot || process.cwd(),
12
+ env: process.env,
13
+ });
14
+ return runScriptLine(manager, "sync:globals:apply");
15
+ }
9
16
  const PROJECT_REL = ".grok/config.toml";
10
17
  const HOME_REL = ".grok/config.toml";
11
18
 
@@ -33,16 +40,17 @@ function parseTomlFile(abs, label) {
33
40
  export function checkGlobals({ home, workspaceRoot } = {}) {
34
41
  const homeDir = home || process.env.HOME;
35
42
  const ws = workspaceRoot || process.cwd();
43
+ const hint = applyHint(ws);
36
44
  if (!homeDir) {
37
- return { ok: false, error: `HOME required; ${APPLY_HINT}` };
45
+ return { ok: false, error: `HOME required; ${hint}` };
38
46
  }
39
47
  const homeConfig = path.resolve(homeDir, HOME_REL);
40
48
  const homeParsed = parseTomlFile(homeConfig, "home ~/.grok/config.toml");
41
49
  if (!homeParsed.ok) {
42
50
  if (homeParsed.missing) {
43
- return { ok: false, error: `missing ~/.grok/config.toml; ${APPLY_HINT}` };
51
+ return { ok: false, error: `missing ~/.grok/config.toml; ${hint}` };
44
52
  }
45
- return { ok: false, error: `${homeParsed.error}; ${APPLY_HINT}` };
53
+ return { ok: false, error: `${homeParsed.error}; ${hint}` };
46
54
  }
47
55
  const agentName = homeParsed.value.agent && homeParsed.value.agent.name;
48
56
  const compaction =
@@ -50,7 +58,7 @@ export function checkGlobals({ home, workspaceRoot } = {}) {
50
58
  if (agentName !== "orchestrator" || compaction !== true) {
51
59
  return {
52
60
  ok: false,
53
- error: `home [agent].name must be orchestrator and [features].two_pass_compaction must be true; ${APPLY_HINT}`,
61
+ error: `home [agent].name must be orchestrator and [features].two_pass_compaction must be true; ${hint}`,
54
62
  };
55
63
  }
56
64