@lovrozagar/grunt 0.9.4 → 0.9.5

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.
@@ -10,7 +10,7 @@ Reserved (do not reuse in consumer custom skills): `ask` `auto` `browser` `clasp
10
10
 
11
11
  Override: same name → one SSOT path (`.rulesync/skills/<name>/`). Re-init / `grunt upgrade` force-refresh overwrites grunt-owned names; consumer extras kept. Maps `origin` badge ≠ content picker.
12
12
 
13
- Upgrade copies the current package trees and product scripts, then deletes cumulative retired grunt-owned names (skills `parent` `solo` `cascade`; agents `implementer` `thinker`; scripts `telemetry.mjs` `grunt-config.mjs`; paths `.grok/parent.md` `.grok/skills/shared` `.rulesync/grunt.config.jsonc` `.rulesync/grunt.config.local.jsonc` `.rulesync/grunt.config.local.jsonc.example`) and any reserved skill dir this package no longer ships. `fs.cpSync` does not remove dest extras; prune is what drops them. Consumer skills/refs/scripts not on those lists stay.
13
+ Upgrade copies the current package trees and product scripts, then deletes cumulative retired grunt-owned names (skills `parent` `solo` `cascade`; agents `implementer` `thinker`; scripts `telemetry.mjs` `grunt-config.mjs`; paths `.grok/parent.md` `.grok/skills/shared` `.rulesync/grunt.config.jsonc` `.rulesync/grunt.config.local.jsonc` `.rulesync/grunt.config.local.jsonc.example`) and any reserved skill dir this package no longer ships. `fs.cpSync` does not remove dest extras; prune is what drops them. Consumer skills/refs/scripts not on those lists stay. Extra `##` headings in shared reference docs (e.g. `law.md`) are kept. `grunt:*` package scripts that differ from a known grunt default stay; `grunt:test:e2e` is added only when a vitest e2e config exists. Check steps omitted from dest `scripts/pipeline.mjs` seed `.rulesync/grunt.pipeline.jsonc`. doctor/listen/speak call `scripts/lib/env.mjs` `loadEnv` when that file exists.
14
14
 
15
15
  # Skills
16
16
 
@@ -8,4 +8,4 @@ Reserved (do not reuse in consumer custom skills): `ask` `auto` `browser` `clasp
8
8
 
9
9
  Override: same name → one SSOT path (`.rulesync/skills/<name>/`). Re-init / `grunt upgrade` force-refresh overwrites grunt-owned names; consumer extras kept. Maps `origin` badge ≠ content picker.
10
10
 
11
- Upgrade copies the current package trees and product scripts, then deletes cumulative retired grunt-owned names (skills `parent` `solo` `cascade`; agents `implementer` `thinker`; scripts `telemetry.mjs` `grunt-config.mjs`; paths `.grok/parent.md` `.grok/skills/shared` `.rulesync/grunt.config.jsonc` `.rulesync/grunt.config.local.jsonc` `.rulesync/grunt.config.local.jsonc.example`) and any reserved skill dir this package no longer ships. `fs.cpSync` does not remove dest extras; prune is what drops them. Consumer skills/refs/scripts not on those lists stay.
11
+ Upgrade copies the current package trees and product scripts, then deletes cumulative retired grunt-owned names (skills `parent` `solo` `cascade`; agents `implementer` `thinker`; scripts `telemetry.mjs` `grunt-config.mjs`; paths `.grok/parent.md` `.grok/skills/shared` `.rulesync/grunt.config.jsonc` `.rulesync/grunt.config.local.jsonc` `.rulesync/grunt.config.local.jsonc.example`) and any reserved skill dir this package no longer ships. `fs.cpSync` does not remove dest extras; prune is what drops them. Consumer skills/refs/scripts not on those lists stay. Extra `##` headings in shared reference docs (e.g. `law.md`) are kept. `grunt:*` package scripts that differ from a known grunt default stay; `grunt:test:e2e` is added only when a vitest e2e config exists. Check steps omitted from dest `scripts/pipeline.mjs` seed `.rulesync/grunt.pipeline.jsonc`. doctor/listen/speak call `scripts/lib/env.mjs` `loadEnv` when that file exists.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Fixed
11
+
12
+ - `grunt upgrade` keeps extra `##` sections in shared reference docs such as `law.md`, so consumer INDEX content survives the copy
13
+ - Custom `grunt:*` package scripts that are not a known grunt default stay; `grunt:test:e2e` is added only when a vitest e2e config exists
14
+ - Check steps a consumer omitted from `scripts/pipeline.mjs` seed `.rulesync/grunt.pipeline.jsonc` and are skipped; a failed `grunt:rulesync:check` lists every failing step and points at that overlay
15
+ - doctor, listen, and speak call `scripts/lib/env.mjs` `loadEnv` when that file exists, so consumers do not need to patch those scripts
16
+ - `scripts/session-map.mjs` no longer trips ESLint `no-regex-spaces`
17
+
10
18
  ## [0.9.1] - 2026-10-04
11
19
 
12
20
  ### Fixed
package/cli/init.mjs CHANGED
@@ -15,7 +15,13 @@ import {
15
15
  snapshotGuardedRoots,
16
16
  remergeGuardedRoots,
17
17
  withGuardedCheckInteriors,
18
+ mergeReferenceMarkdown,
18
19
  } from "../scripts/guarded-md.mjs"
20
+ import {
21
+ COMMANDS as PIPELINE_COMMANDS,
22
+ PIPELINE_CONFIG_REL,
23
+ inferPipelineSkip,
24
+ } from "../scripts/pipeline.mjs"
19
25
  import {
20
26
  PACKAGE_MANAGERS,
21
27
  UNKNOWN_PACKAGE_MANAGER,
@@ -41,6 +47,8 @@ export {
41
47
  remergeGuardedRoots,
42
48
  healGuardedRootFile,
43
49
  withGuardedCheckInteriors,
50
+ extraH2Sections,
51
+ mergeReferenceMarkdown,
44
52
  } from "../scripts/guarded-md.mjs"
45
53
 
46
54
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
@@ -147,6 +155,23 @@ export const LAUNCH_SCRIPTS = {
147
155
  gemini: "gemini --yolo",
148
156
  grok: "grok --yolo",
149
157
  }
158
+ /** Exact prior grunt defaults. A dest script equal to one of these migrates to the current value. */
159
+ export const HISTORICAL_SCRIPT_DEFAULTS = {
160
+ antigravity: ["antigravity"],
161
+ claude: ["claude"],
162
+ codex: ["codex"],
163
+ gemini: ["gemini"],
164
+ grok: ["grok"],
165
+ }
166
+ export const E2E_VITEST_CONFIGS = [
167
+ "vitest.e2e.config.ts",
168
+ "vitest.e2e.config.mts",
169
+ "vitest.e2e.config.js",
170
+ "vitest.e2e.config.mjs",
171
+ "vitest.e2e.config.cjs",
172
+ ]
173
+ export const REFERENCE_REL = ".rulesync/reference"
174
+ export { PIPELINE_CONFIG_REL }
150
175
  const OWNED_HOOK_FILES = [
151
176
  "scrub-spawn-prompt.mjs",
152
177
  "gate-fat-tools.mjs",
@@ -333,11 +358,61 @@ function extraOwnedSuffix(cur, newSrc) {
333
358
  return null
334
359
  }
335
360
 
336
- function mergeScriptValue(newSrc, cur) {
361
+ function mergeScriptValue(newSrc, cur, historical = []) {
337
362
  if (cur.startsWith(newSrc)) return cur
338
363
  const suffix = extraOwnedSuffix(cur, newSrc)
339
364
  if (suffix != null) return newSrc + suffix
340
- return newSrc
365
+ if (historical.includes(cur)) return newSrc
366
+ if (looksGruntOwnedPrefix(cur)) return newSrc
367
+ return cur
368
+ }
369
+
370
+ function historicalFor(key) {
371
+ return HISTORICAL_SCRIPT_DEFAULTS[key] || []
372
+ }
373
+
374
+ function hasE2eConfig(dest) {
375
+ return E2E_VITEST_CONFIGS.some((name) => fs.existsSync(path.join(dest, name)))
376
+ }
377
+
378
+ export function snapshotReferenceDocs(dest) {
379
+ const dir = path.join(dest, REFERENCE_REL)
380
+ const snap = {}
381
+ if (!fs.existsSync(dir)) return snap
382
+ for (const name of fs.readdirSync(dir)) {
383
+ if (!name.endsWith(".md") || GENERATED_MAP_FILES.has(name)) continue
384
+ const abs = path.join(dir, name)
385
+ if (!fs.statSync(abs).isFile()) continue
386
+ snap[name] = fs.readFileSync(abs, "utf8")
387
+ }
388
+ return snap
389
+ }
390
+
391
+ export function remergeReferenceDocs(dest, snap, pkgRoot) {
392
+ const pkgDir = path.join(pkgRoot, REFERENCE_REL)
393
+ const destDir = path.join(dest, REFERENCE_REL)
394
+ for (const [name, existing] of Object.entries(snap)) {
395
+ const pkgFile = path.join(pkgDir, name)
396
+ if (!fs.existsSync(pkgFile)) continue
397
+ const incoming = fs.readFileSync(pkgFile, "utf8")
398
+ fs.mkdirSync(destDir, { recursive: true })
399
+ fs.writeFileSync(path.join(destDir, name), mergeReferenceMarkdown(existing, incoming))
400
+ }
401
+ }
402
+
403
+ export function seedPipelineConfig(dest, destPipelineSrc) {
404
+ if (!destPipelineSrc || !/export const COMMANDS/.test(destPipelineSrc)) return false
405
+ const abs = path.join(dest, PIPELINE_CONFIG_REL)
406
+ if (fs.existsSync(abs)) return false
407
+ const overlay = {}
408
+ for (const mode of Object.keys(PIPELINE_COMMANDS)) {
409
+ const skip = inferPipelineSkip(destPipelineSrc, mode)
410
+ if (skip.length) overlay[mode] = { skip }
411
+ }
412
+ if (!Object.keys(overlay).length) return false
413
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
414
+ fs.writeFileSync(abs, `${JSON.stringify(overlay, null, 2)}\n`)
415
+ return true
341
416
  }
342
417
 
343
418
  function rewritePackageManagerRunRefs(scripts, srcKeys) {
@@ -394,17 +469,19 @@ export function mergePackageJson(dest, pkgRoot) {
394
469
  const srcKeys = []
395
470
  for (const [k, v] of Object.entries(srcPkg.scripts || {})) {
396
471
  if (k === "test" || k.endsWith(":raw") || k in LAUNCH_SCRIPTS) continue
397
- srcKeys.push(k)
398
472
  const destKey = toGruntScriptName(k)
399
473
  const current = destPkg.scripts[destKey]
400
474
  const legacy = destPkg.scripts[k]
475
+ if (k === "test:e2e" && current == null && legacy == null && !hasE2eConfig(dest)) continue
476
+ srcKeys.push(k)
477
+ const historical = historicalFor(k)
401
478
  const ownedLegacy =
402
479
  legacy != null &&
403
480
  (legacy.startsWith(v) || extraOwnedSuffix(legacy, v) != null || looksGruntOwnedPrefix(legacy))
404
481
  if (current != null) {
405
- destPkg.scripts[destKey] = mergeScriptValue(v, current)
482
+ destPkg.scripts[destKey] = mergeScriptValue(v, current, historical)
406
483
  } else if (ownedLegacy) {
407
- destPkg.scripts[destKey] = mergeScriptValue(v, legacy)
484
+ destPkg.scripts[destKey] = mergeScriptValue(v, legacy, historical)
408
485
  } else {
409
486
  destPkg.scripts[destKey] = v
410
487
  }
@@ -419,7 +496,7 @@ export function mergePackageJson(dest, pkgRoot) {
419
496
  for (const [k, v] of Object.entries(LAUNCH_SCRIPTS)) {
420
497
  const destKey = toGruntScriptName(k)
421
498
  const current = destPkg.scripts[destKey]
422
- destPkg.scripts[destKey] = current != null ? mergeScriptValue(v, current) : v
499
+ destPkg.scripts[destKey] = current != null ? mergeScriptValue(v, current, historicalFor(k)) : v
423
500
  const staleKey = `grunt:yolo:${k}`
424
501
  if (destPkg.scripts[staleKey] === v) delete destPkg.scripts[staleKey]
425
502
  }
@@ -507,6 +584,13 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
507
584
  }
508
585
  }
509
586
 
587
+ const refSnap = self ? {} : snapshotReferenceDocs(dest)
588
+ let destPipelineSrc = ""
589
+ if (!self) {
590
+ const destPipeline = path.join(dest, "scripts", "pipeline.mjs")
591
+ if (fs.existsSync(destPipeline)) destPipelineSrc = fs.readFileSync(destPipeline, "utf8")
592
+ }
593
+
510
594
  for (const dir of COPY_DIRS) {
511
595
  const src = path.join(pkgRoot, dir)
512
596
  const d = path.join(dest, dir)
@@ -540,6 +624,8 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
540
624
  mergeGitignore(dest)
541
625
 
542
626
  if (!self) {
627
+ remergeReferenceDocs(dest, refSnap, pkgRoot)
628
+ seedPipelineConfig(dest, destPipelineSrc)
543
629
  mergePackageJson(dest, pkgRoot)
544
630
  copyRootIfMissing(dest, pkgRoot)
545
631
  emitDestMaps(dest)
package/cli/init.test.ts CHANGED
@@ -37,6 +37,14 @@ import {
37
37
  RETIRED_PATHS,
38
38
  RESERVED_SKILLS,
39
39
  LAUNCH_SCRIPTS,
40
+ HISTORICAL_SCRIPT_DEFAULTS,
41
+ E2E_VITEST_CONFIGS,
42
+ extraH2Sections,
43
+ mergeReferenceMarkdown,
44
+ snapshotReferenceDocs,
45
+ remergeReferenceDocs,
46
+ seedPipelineConfig,
47
+ PIPELINE_CONFIG_REL,
40
48
  mergeClaudeSettings,
41
49
  mergeGitignore,
42
50
  mergeGuardedContent,
@@ -55,6 +63,7 @@ import {
55
63
  } from "./init.mjs";
56
64
  import { UNKNOWN_PACKAGE_MANAGER } from "../scripts/package-manager.mjs";
57
65
  import { runGuardedRoots } from "../scripts/guarded-roots.mjs";
66
+ import { COMMANDS as PIPELINE_COMMANDS } from "../scripts/pipeline.mjs";
58
67
 
59
68
  const tmpDirs: string[] = [];
60
69
  afterEach(() => {
@@ -1714,6 +1723,158 @@ describe("mergePackageJson", () => {
1714
1723
  expect(pkg.scripts[k]).toBe(v);
1715
1724
  }
1716
1725
  });
1726
+
1727
+ it("keeps customized grunt launchers that are not a known default", () => {
1728
+ const pkgRoot = stubPkgRoot();
1729
+ const dest = tmp("pj-keep-custom-launch-");
1730
+ const wrapped = {
1731
+ "grunt:antigravity": "node scripts/with-env.mjs agy --dangerously-skip-permissions",
1732
+ "grunt:claude": "node scripts/with-env.mjs claude --dangerously-skip-permissions",
1733
+ "grunt:codex": "node scripts/with-env.mjs codex --dangerously-bypass-approvals-and-sandbox",
1734
+ "grunt:gemini": "node scripts/with-env.mjs gemini --yolo",
1735
+ "grunt:grok": "node scripts/with-env.mjs grok --yolo",
1736
+ };
1737
+ fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ scripts: wrapped }));
1738
+ mergePackageJson(dest, pkgRoot);
1739
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1740
+ expect(out.scripts["grunt:antigravity"]).toBe(wrapped["grunt:antigravity"]);
1741
+ expect(out.scripts["grunt:claude"]).toBe(wrapped["grunt:claude"]);
1742
+ expect(out.scripts["grunt:codex"]).toBe(wrapped["grunt:codex"]);
1743
+ expect(out.scripts["grunt:gemini"]).toBe(wrapped["grunt:gemini"]);
1744
+ expect(out.scripts["grunt:grok"]).toBe(wrapped["grunt:grok"]);
1745
+ });
1746
+
1747
+ it("migrates a historical bare launcher to the current default", () => {
1748
+ const pkgRoot = stubPkgRoot();
1749
+ const dest = tmp("pj-hist-launch-");
1750
+ fs.writeFileSync(
1751
+ path.join(dest, "package.json"),
1752
+ JSON.stringify({ scripts: { "grunt:antigravity": "antigravity" } }),
1753
+ );
1754
+ mergePackageJson(dest, pkgRoot);
1755
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1756
+ expect(out.scripts["grunt:antigravity"]).toBe(LAUNCH_SCRIPTS.antigravity);
1757
+ expect(HISTORICAL_SCRIPT_DEFAULTS.antigravity).toContain("antigravity");
1758
+ });
1759
+
1760
+ it("does not add grunt:test:e2e when dest has no e2e vitest config", () => {
1761
+ const dest = tmp("pj-noe2e-");
1762
+ fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ name: "app", scripts: {} }));
1763
+ mergePackageJson(dest, PKG_ROOT);
1764
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1765
+ expect(out.scripts["grunt:test:e2e"]).toBeUndefined();
1766
+ expect(out.scripts["grunt:rulesync:check"]).toBe("node ./scripts/guarded-roots.mjs check");
1767
+ });
1768
+
1769
+ it("adds grunt:test:e2e when dest has a vitest e2e config", () => {
1770
+ const dest = tmp("pj-e2e-");
1771
+ fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ name: "app", scripts: {} }));
1772
+ fs.writeFileSync(path.join(dest, E2E_VITEST_CONFIGS[0]), "export default {}\n");
1773
+ mergePackageJson(dest, PKG_ROOT);
1774
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1775
+ expect(out.scripts["grunt:test:e2e"]).toBe("vitest run -c vitest.e2e.config.ts");
1776
+ });
1777
+
1778
+ it("keeps an existing grunt:test:e2e even without an e2e config", () => {
1779
+ const dest = tmp("pj-e2e-keep-");
1780
+ fs.writeFileSync(
1781
+ path.join(dest, "package.json"),
1782
+ JSON.stringify({ scripts: { "grunt:test:e2e": "node scripts/e2e.mjs" } }),
1783
+ );
1784
+ mergePackageJson(dest, PKG_ROOT);
1785
+ const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
1786
+ expect(out.scripts["grunt:test:e2e"]).toBe("node scripts/e2e.mjs");
1787
+ });
1788
+ });
1789
+
1790
+ describe("reference markdown merge", () => {
1791
+ it("extraH2Sections keeps titles incoming does not have, including CRLF", () => {
1792
+ const existing = "# Law\r\n\r\n## Skills naming\r\n\r\nstock\r\n\r\n## Autorun\r\n\r\ngo-live\r\n";
1793
+ const incoming = "# Law\n\n## Skills naming\n\nstock plus su\n";
1794
+ expect(extraH2Sections(existing, incoming)).toBe("## Autorun\n\ngo-live");
1795
+ expect(extraH2Sections("# Law\n\nno headings\n", incoming)).toBe("");
1796
+ expect(extraH2Sections("## \nempty title\n## Autorun\nkeep\n", incoming)).toBe("## Autorun\nkeep");
1797
+ });
1798
+
1799
+ it("mergeReferenceMarkdown appends extras and normalizes a trailing newline", () => {
1800
+ const incoming = "# Law\n\n## Skills naming\n\nstock";
1801
+ expect(mergeReferenceMarkdown("", incoming)).toBe("# Law\n\n## Skills naming\n\nstock\n");
1802
+ expect(mergeReferenceMarkdown("# Law\n\n## Skills naming\n\nstock\n\n## Autorun\n\nX\n", incoming)).toBe(
1803
+ "# Law\n\n## Skills naming\n\nstock\n\n## Autorun\n\nX\n",
1804
+ );
1805
+ });
1806
+
1807
+ it("snapshotReferenceDocs skips generated maps, non-md, and directories", () => {
1808
+ const dest = tmp("ref-snap-");
1809
+ expect(snapshotReferenceDocs(dest)).toEqual({});
1810
+ const dir = path.join(dest, ".rulesync", "reference");
1811
+ fs.mkdirSync(dir, { recursive: true });
1812
+ fs.writeFileSync(path.join(dir, "law.md"), "# Law\n");
1813
+ fs.writeFileSync(path.join(dir, "INDEX.md"), "generated\n");
1814
+ fs.writeFileSync(path.join(dir, "notes.txt"), "nope\n");
1815
+ fs.mkdirSync(path.join(dir, "nested.md"));
1816
+ expect(snapshotReferenceDocs(dest)).toEqual({ "law.md": "# Law\n" });
1817
+ });
1818
+
1819
+ it("remergeReferenceDocs writes extras for packaged refs and leaves consumer-only files", () => {
1820
+ const pkgRoot = stubPkgRoot();
1821
+ const pkgRef = path.join(pkgRoot, ".rulesync", "reference");
1822
+ fs.mkdirSync(pkgRef, { recursive: true });
1823
+ fs.writeFileSync(path.join(pkgRef, "law.md"), "# Law\n\n## Skills naming\n\nstock\n");
1824
+ const dest = tmp("ref-remerge-");
1825
+ const destRef = path.join(dest, ".rulesync", "reference");
1826
+ fs.mkdirSync(destRef, { recursive: true });
1827
+ fs.writeFileSync(
1828
+ path.join(destRef, "law.md"),
1829
+ "# Law\n\n## Skills naming\n\nold\n\n## Autorun\n\nkeep\n",
1830
+ );
1831
+ fs.writeFileSync(path.join(destRef, "stripe.md"), "# Stripe\n");
1832
+ remergeReferenceDocs(
1833
+ dest,
1834
+ {
1835
+ "law.md": fs.readFileSync(path.join(destRef, "law.md"), "utf8"),
1836
+ "stripe.md": "# Stripe\n",
1837
+ },
1838
+ pkgRoot,
1839
+ );
1840
+ expect(fs.readFileSync(path.join(destRef, "law.md"), "utf8")).toBe(
1841
+ "# Law\n\n## Skills naming\n\nstock\n\n## Autorun\n\nkeep\n",
1842
+ );
1843
+ expect(fs.readFileSync(path.join(destRef, "stripe.md"), "utf8")).toBe("# Stripe\n");
1844
+ });
1845
+ });
1846
+
1847
+ describe("pipeline seed", () => {
1848
+ it("writes skip overlay from dest pipeline and does not overwrite an existing file", () => {
1849
+ const dest = tmp("pipe-seed-");
1850
+ const skipped = PIPELINE_COMMANDS.check[1];
1851
+ const src = `export const COMMANDS = {
1852
+ check: [
1853
+ ${PIPELINE_COMMANDS.check.filter((c) => c !== skipped).map((c) => JSON.stringify(c)).join(",\n ")}
1854
+ ],
1855
+ };
1856
+ `;
1857
+ expect(seedPipelineConfig(dest, src)).toBe(true);
1858
+ const cfg = path.join(dest, PIPELINE_CONFIG_REL);
1859
+ expect(JSON.parse(fs.readFileSync(cfg, "utf8"))).toEqual({ check: { skip: [skipped] } });
1860
+ fs.writeFileSync(cfg, '{"check":{"skip":["keep"]}}\n');
1861
+ expect(seedPipelineConfig(dest, src)).toBe(false);
1862
+ expect(JSON.parse(fs.readFileSync(cfg, "utf8"))).toEqual({ check: { skip: ["keep"] } });
1863
+ });
1864
+
1865
+ it("skips seed when dest is not a COMMANDS module or matches stock", () => {
1866
+ const dest = tmp("pipe-seed-skip-");
1867
+ expect(seedPipelineConfig(dest, "")).toBe(false);
1868
+ expect(seedPipelineConfig(dest, "console.log(1)\n")).toBe(false);
1869
+ const stock = `export const COMMANDS = {
1870
+ generate: [${PIPELINE_COMMANDS.generate.map((c) => JSON.stringify(c)).join(", ")}],
1871
+ check: [${PIPELINE_COMMANDS.check.map((c) => JSON.stringify(c)).join(", ")}],
1872
+ watch: [${PIPELINE_COMMANDS.watch.map((c) => JSON.stringify(c)).join(", ")}],
1873
+ };
1874
+ `;
1875
+ expect(seedPipelineConfig(dest, stock)).toBe(false);
1876
+ expect(fs.existsSync(path.join(dest, PIPELINE_CONFIG_REL))).toBe(false);
1877
+ });
1717
1878
  });
1718
1879
 
1719
1880
  describe("init", () => {
@@ -2220,6 +2381,43 @@ describe("init", () => {
2220
2381
  ).toThrow("emit-maps failed");
2221
2382
  });
2222
2383
 
2384
+ it("keeps extra ## sections in dest law.md and INDEX", () => {
2385
+ const dest = tmp("grunt-law-extra-");
2386
+ fs.mkdirSync(path.join(dest, ".rulesync", "reference"), { recursive: true });
2387
+ const pkgLaw = fs.readFileSync(path.join(PKG_ROOT, ".rulesync", "reference", "law.md"), "utf8");
2388
+ fs.writeFileSync(
2389
+ path.join(dest, ".rulesync", "reference", "law.md"),
2390
+ `${pkgLaw.trimEnd()}\n\n## Autorun\n\npush → go-live/\n`,
2391
+ );
2392
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" });
2393
+ const law = fs.readFileSync(path.join(dest, ".rulesync", "reference", "law.md"), "utf8");
2394
+ expect(law).toMatch(/## Skills naming/);
2395
+ expect(law).toMatch(/## Autorun/);
2396
+ expect(law).toMatch(/push → go-live\//);
2397
+ const index = fs.readFileSync(path.join(dest, ".rulesync", "reference", "INDEX.md"), "utf8");
2398
+ expect(index).toMatch(/## Autorun/);
2399
+ expect(index).toMatch(/push → go-live\//);
2400
+ });
2401
+
2402
+ it("seeds grunt.pipeline.jsonc from dest pipeline omissions during init", () => {
2403
+ const dest = tmp("grunt-pipe-init-");
2404
+ fs.mkdirSync(path.join(dest, "scripts"), { recursive: true });
2405
+ const skipped = PIPELINE_COMMANDS.check.find((c) => c.includes("subagents"));
2406
+ const src = `export const COMMANDS = {
2407
+ check: [
2408
+ ${PIPELINE_COMMANDS.check.filter((c) => c !== skipped).map((c) => JSON.stringify(c)).join(",\n ")}
2409
+ ],
2410
+ };
2411
+ `;
2412
+ fs.writeFileSync(path.join(dest, "scripts", "pipeline.mjs"), src);
2413
+ init(dest, { execFileSync: vi.fn(), skipGlobals: true, packageManager: "npm" });
2414
+ const cfg = JSON.parse(fs.readFileSync(path.join(dest, PIPELINE_CONFIG_REL), "utf8"));
2415
+ expect(cfg.check.skip).toEqual([skipped]);
2416
+ expect(fs.readFileSync(path.join(dest, "scripts", "pipeline.mjs"), "utf8")).toContain(
2417
+ "subagents --check",
2418
+ );
2419
+ });
2420
+
2223
2421
  it("does not stamp package INDEX over dest; re-emits union", () => {
2224
2422
  const dest = tmp("grunt-index-keep-");
2225
2423
  fs.mkdirSync(path.join(dest, ".rulesync", "reference"), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovrozagar/grunt",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
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",
@@ -444,7 +444,17 @@ export function runDoctor({
444
444
  return { code: missingRequired.length || missingMaps.length ? 1 : 0, stdout, stderr: "" };
445
445
  }
446
446
 
447
- function main() {
447
+ /** Load dest `scripts/lib/env.mjs` `loadEnv` when that file exists next to this script. */
448
+ export async function loadOptionalConsumerEnv(baseHref = import.meta.url) {
449
+ const file = fileURLToPath(new URL("./lib/env.mjs", baseHref));
450
+ if (!fs.existsSync(file)) return false;
451
+ const { loadEnv } = await import(pathToFileURL(file).href);
452
+ if (typeof loadEnv === "function") loadEnv();
453
+ return true;
454
+ }
455
+
456
+ async function main() {
457
+ await loadOptionalConsumerEnv();
448
458
  const r = runDoctor();
449
459
  if (r.stdout) process.stdout.write(r.stdout);
450
460
  if (r.stderr) process.stderr.write(r.stderr);
@@ -454,5 +464,8 @@ function main() {
454
464
  const thisFile = fileURLToPath(import.meta.url);
455
465
  const invoked = process.argv[1] ? path.resolve(process.argv[1]) : "";
456
466
  if (invoked === thisFile || import.meta.url === pathToFileURL(invoked).href) {
457
- main();
467
+ main().catch((err) => {
468
+ process.stderr.write((err && err.message ? err.message : String(err)) + "\n");
469
+ process.exit(1);
470
+ });
458
471
  }
@@ -111,6 +111,34 @@ export function mergeGuardedContent(existingText, gruntBody) {
111
111
  return composeGuardedMarkdown(gruntBody, user, nl);
112
112
  }
113
113
 
114
+ /** `##` blocks in `existing` whose titles are not in `incoming`. */
115
+ export function extraH2Sections(existing, incoming) {
116
+ const incomingTitles = new Set(
117
+ [...String(incoming ?? "").matchAll(/^## (.+)$/gm)].map((m) => m[1].trim()),
118
+ );
119
+ const text = String(existing ?? "").replace(/\r\n/g, "\n");
120
+ const matches = [...text.matchAll(/^## /gm)];
121
+ if (!matches.length) return "";
122
+ const chunks = [];
123
+ for (let i = 0; i < matches.length; i++) {
124
+ const start = matches[i].index;
125
+ const end = i + 1 < matches.length ? matches[i + 1].index : text.length;
126
+ const block = text.slice(start, end).replace(/(?:\n)+$/, "");
127
+ const title = block.match(/^## (.+)$/m)?.[1].trim();
128
+ if (title && !incomingTitles.has(title)) chunks.push(block);
129
+ }
130
+ return chunks.join("\n\n");
131
+ }
132
+
133
+ /** Grunt body plus consumer-only `##` sections. No sentinels (INDEX inlines law.md). */
134
+ export function mergeReferenceMarkdown(existing, incoming) {
135
+ const src = String(incoming ?? "");
136
+ const extras = extraH2Sections(existing ?? "", src);
137
+ const base = src.replace(/(?:\r?\n)+$/, "");
138
+ if (!extras.trim()) return `${base}\n`;
139
+ return `${base}\n\n${extras.replace(/(?:\n)+$/, "")}\n`;
140
+ }
141
+
114
142
  export function writeMergedGuardedFile(destPath, gruntBody) {
115
143
  if (!fs.existsSync(destPath)) {
116
144
  fs.writeFileSync(destPath, mergeGuardedContent("", gruntBody));
@@ -5,7 +5,7 @@ import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
- import { whichBin } from "./doctor.mjs";
8
+ import { loadOptionalConsumerEnv, whichBin } from "./doctor.mjs";
9
9
 
10
10
  export const DEFAULT_MODEL = "whisper-1";
11
11
  export const DEFAULT_GGML = "ggml-base.en.bin";
@@ -576,6 +576,7 @@ function waitPidExit(pid, killFn, timeoutMs = 8000) {
576
576
 
577
577
  export async function main(argv = process.argv.slice(2), opts = {}) {
578
578
  const { _, flags } = parseArgv(argv);
579
+ if (!opts.env) await loadOptionalConsumerEnv();
579
580
  const env = opts.env || process.env;
580
581
  const home = opts.home || str(env.HOME || env.USERPROFILE, os.homedir());
581
582
  const cwd = str(flags.cwd || opts.cwd, process.cwd());
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /** Inner generate/check/watch chain. Called by guarded-roots; not a public npm script. */
3
3
  import { execSync } from "node:child_process";
4
+ import fs from "node:fs";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
7
+ import { stripJsonc } from "./jsonc.mjs";
8
+
9
+ export const PIPELINE_CONFIG_REL = ".rulesync/grunt.pipeline.jsonc";
6
10
 
7
11
  export const COMMANDS = {
8
12
  generate: [
@@ -41,12 +45,90 @@ export function envWithLocalBin(cwd, env = process.env) {
41
45
  return merged;
42
46
  }
43
47
 
44
- export function runPipeline(mode, { cwd = process.cwd(), exec = execSync } = {}) {
45
- const cmds = COMMANDS[mode];
48
+ function asStringList(v) {
49
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x) : [];
50
+ }
51
+
52
+ export function quotedCommandsInBlock(src, key) {
53
+ const m = String(src ?? "").match(new RegExp(`${key}:\\s*\\[([\\s\\S]*?)\\]`, "m"));
54
+ if (!m) return [];
55
+ const out = [];
56
+ for (const line of m[1].split(/\r?\n/)) {
57
+ const code = line.replace(/\/\/.*$/, "").trim();
58
+ if (!code) continue;
59
+ for (const x of code.matchAll(/"((?:\\.|[^"\\])*)"/g)) {
60
+ out.push(x[1].replace(/\\"/g, '"'));
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export function inferPipelineSkip(src, mode) {
67
+ const stock = COMMANDS[mode];
68
+ if (!stock || !src || !new RegExp(`${mode}:\\s*\\[`).test(src)) return [];
69
+ const active = new Set(quotedCommandsInBlock(src, mode));
70
+ return stock.filter((c) => !active.has(c));
71
+ }
72
+
73
+ export function loadPipelineOverlay(cwd, mode, warn = (m) => process.stderr.write(`${m}\n`)) {
74
+ const abs = path.join(cwd, PIPELINE_CONFIG_REL);
75
+ if (!fs.existsSync(abs)) return { skip: [], extra: [] };
76
+ let raw;
77
+ try {
78
+ raw = JSON.parse(stripJsonc(fs.readFileSync(abs, "utf8")));
79
+ } catch (err) {
80
+ warn(`pipeline: ignoring ${PIPELINE_CONFIG_REL}: ${err.message}`);
81
+ return { skip: [], extra: [] };
82
+ }
83
+ const section = raw && typeof raw === "object" && !Array.isArray(raw) ? raw[mode] : null;
84
+ if (!section || typeof section !== "object" || Array.isArray(section)) {
85
+ return { skip: [], extra: [] };
86
+ }
87
+ return { skip: asStringList(section.skip), extra: asStringList(section.extra) };
88
+ }
89
+
90
+ export function resolvePipelineCommands(mode, cwd, warn) {
91
+ const stock = COMMANDS[mode];
92
+ if (!stock) return null;
93
+ const { skip, extra } = loadPipelineOverlay(cwd, mode, warn);
94
+ const skipSet = new Set(skip);
95
+ return [...stock.filter((c) => !skipSet.has(c)), ...extra];
96
+ }
97
+
98
+ export function formatCheckFailures(failures) {
99
+ const n = failures.length;
100
+ const lines = [`pipeline check failed (${n} step${n === 1 ? "" : "s"}):`];
101
+ for (const { cmd, err } of failures) {
102
+ lines.push(`- ${cmd}`);
103
+ const msg = err && err.message ? err.message : String(err);
104
+ for (const line of msg.split(/\r?\n/)) {
105
+ if (line) lines.push(` ${line}`);
106
+ }
107
+ }
108
+ lines.push(
109
+ "If a consumer post-step rewrites this output (hooks-union, codex-sync), add the stock command to check.skip in .rulesync/grunt.pipeline.jsonc",
110
+ );
111
+ return lines.join("\n");
112
+ }
113
+
114
+ export function runPipeline(mode, { cwd = process.cwd(), exec = execSync, warn } = {}) {
115
+ const cmds = resolvePipelineCommands(mode, cwd, warn);
46
116
  if (!cmds) {
47
117
  throw new Error("usage: pipeline.mjs generate|check|watch");
48
118
  }
49
119
  const env = envWithLocalBin(cwd);
120
+ if (mode === "check") {
121
+ const failures = [];
122
+ for (const cmd of cmds) {
123
+ try {
124
+ exec(cmd, { cwd, stdio: "inherit", shell: true, env });
125
+ } catch (err) {
126
+ failures.push({ cmd, err });
127
+ }
128
+ }
129
+ if (failures.length) throw new Error(formatCheckFailures(failures));
130
+ return;
131
+ }
50
132
  for (const cmd of cmds) {
51
133
  exec(cmd, { cwd, stdio: "inherit", shell: true, env });
52
134
  }
@@ -63,7 +63,7 @@ function main() {
63
63
  JSON.stringify({
64
64
  hookSpecificOutput: {
65
65
  hookEventName: "SessionStart",
66
- additionalContext: `${header(/ …$/m.test(out.text) ? out.depth : null)}\n${out.text}`,
66
+ additionalContext: `${header(/ {2}…$/m.test(out.text) ? out.depth : null)}\n${out.text}`,
67
67
  },
68
68
  }),
69
69
  );
package/scripts/speak.mjs CHANGED
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { loadOptionalConsumerEnv } from "./doctor.mjs";
8
9
 
9
10
  export const DEFAULT_ELEVEN_VOICE = "JBFqnCBsd6RMkjVDRZzb";
10
11
  export const DEFAULT_ELEVEN_MODEL = "eleven_multilingual_v2";
@@ -246,6 +247,7 @@ export async function say({
246
247
 
247
248
  export async function main(argv = process.argv.slice(2), opts = {}) {
248
249
  const { _, flags } = parseArgv(argv);
250
+ if (!opts.env) await loadOptionalConsumerEnv();
249
251
  const env = opts.env || process.env;
250
252
  const home = opts.home || os.homedir();
251
253
  const cwd = opts.cwd || process.cwd();