@lovrozagar/grunt 0.9.3 → 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,13 +496,13 @@ 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
  }
426
503
  rewritePackageManagerRunRefs(destPkg.scripts, srcKeys)
427
504
  destPkg.devDependencies = { ...(destPkg.devDependencies || {}) }
428
- destPkg.devDependencies["smol-toml"] = srcPkg.devDependencies["smol-toml"]
505
+ destPkg.devDependencies["smol-toml"] = srcPkg.dependencies["smol-toml"]
429
506
  destPkg.devDependencies["rulesync"] = srcPkg.devDependencies["rulesync"]
430
507
  const clack = srcPkg.dependencies?.["@clack/prompts"]
431
508
  if (clack) destPkg.devDependencies["@clack/prompts"] = clack
@@ -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(() => {
@@ -137,8 +146,10 @@ function stubPkgRoot(pkg: Record<string, unknown> = {
137
146
  alpha: "a",
138
147
  "rulesync:generate": "gen",
139
148
  },
140
- devDependencies: {
149
+ dependencies: {
141
150
  "smol-toml": "^1.8.0",
151
+ },
152
+ devDependencies: {
142
153
  rulesync: "latest",
143
154
  zzz: "1",
144
155
  },
@@ -1115,7 +1126,8 @@ describe("mergePackageJson", () => {
1115
1126
  const pkgRoot = stubPkgRoot({
1116
1127
  name: "fixture-pkg",
1117
1128
  scripts: { "rulesync:generate": "rulesync generate -t bar" },
1118
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1129
+ dependencies: { "smol-toml": "^1.8.0" },
1130
+ devDependencies: { rulesync: "latest" },
1119
1131
  });
1120
1132
  const dest = tmp("pj-suffix-");
1121
1133
  fs.writeFileSync(
@@ -1140,7 +1152,8 @@ describe("mergePackageJson", () => {
1140
1152
  scripts: {
1141
1153
  "rulesync:generate": "rulesync generate -t bar && node scripts/emit.mjs",
1142
1154
  },
1143
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1155
+ dependencies: { "smol-toml": "^1.8.0" },
1156
+ devDependencies: { rulesync: "latest" },
1144
1157
  });
1145
1158
  const dest = tmp("pj-multi-suffix-");
1146
1159
  fs.writeFileSync(
@@ -1163,7 +1176,8 @@ describe("mergePackageJson", () => {
1163
1176
  const pkgRoot = stubPkgRoot({
1164
1177
  name: "fixture-pkg",
1165
1178
  scripts: { alpha: "a" },
1166
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1179
+ dependencies: { "smol-toml": "^1.8.0" },
1180
+ devDependencies: { rulesync: "latest" },
1167
1181
  });
1168
1182
  const dest = tmp("pj-nonowned-suffix-");
1169
1183
  fs.writeFileSync(
@@ -1183,7 +1197,8 @@ describe("mergePackageJson", () => {
1183
1197
  const pkgRoot = stubPkgRoot({
1184
1198
  name: "fixture-pkg",
1185
1199
  scripts: { "rulesync:generate": "rulesync generate -t bar" },
1186
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1200
+ dependencies: { "smol-toml": "^1.8.0" },
1201
+ devDependencies: { rulesync: "latest" },
1187
1202
  });
1188
1203
  const dest = tmp("pj-semi-");
1189
1204
  fs.writeFileSync(
@@ -1205,7 +1220,8 @@ describe("mergePackageJson", () => {
1205
1220
  const pkgRoot = stubPkgRoot({
1206
1221
  name: "fixture-pkg",
1207
1222
  scripts: { "rulesync:generate": "rulesync generate -t bar" },
1208
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1223
+ dependencies: { "smol-toml": "^1.8.0" },
1224
+ devDependencies: { rulesync: "latest" },
1209
1225
  });
1210
1226
  const dest = tmp("pj-equal-");
1211
1227
  fs.writeFileSync(
@@ -1222,7 +1238,8 @@ describe("mergePackageJson", () => {
1222
1238
  const pkgRoot = stubPkgRoot({
1223
1239
  name: "fixture-pkg",
1224
1240
  scripts: { "rulesync:generate": "rulesync generate -t bar" },
1225
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1241
+ dependencies: { "smol-toml": "^1.8.0" },
1242
+ devDependencies: { rulesync: "latest" },
1226
1243
  });
1227
1244
  const dest = tmp("pj-prefix-");
1228
1245
  const cur = "rulesync generate -t bar && node scripts/codex-sync.mjs";
@@ -1266,7 +1283,8 @@ describe("mergePackageJson", () => {
1266
1283
  "rulesync:check:raw": "echo check-raw",
1267
1284
  "rulesync:watch:raw": "echo watch-raw",
1268
1285
  },
1269
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1286
+ dependencies: { "smol-toml": "^1.8.0" },
1287
+ devDependencies: { rulesync: "latest" },
1270
1288
  });
1271
1289
  const dest = tmp("pj-raw-src-");
1272
1290
  mergePackageJson(dest, pkgRoot);
@@ -1282,7 +1300,8 @@ describe("mergePackageJson", () => {
1282
1300
  const pkgRoot = stubPkgRoot({
1283
1301
  name: "fixture-pkg",
1284
1302
  scripts: { "rulesync:generate": "node ./scripts/guarded-roots.mjs generate" },
1285
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1303
+ dependencies: { "smol-toml": "^1.8.0" },
1304
+ devDependencies: { rulesync: "latest" },
1286
1305
  });
1287
1306
  const dest = tmp("pj-raw-dest-");
1288
1307
  fs.writeFileSync(
@@ -1301,7 +1320,8 @@ describe("mergePackageJson", () => {
1301
1320
  const pkgRoot = stubPkgRoot({
1302
1321
  name: "fixture-pkg",
1303
1322
  scripts: { "rulesync:generate": "rulesync generate -t bar", "sync:globals:apply": "node scripts/sync-global-settings.mjs --apply" },
1304
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1323
+ dependencies: { "smol-toml": "^1.8.0" },
1324
+ devDependencies: { rulesync: "latest" },
1305
1325
  });
1306
1326
  const dest = tmp("pj-owned-");
1307
1327
  fs.writeFileSync(
@@ -1329,7 +1349,8 @@ describe("mergePackageJson", () => {
1329
1349
  it("src without scripts uses empty object", () => {
1330
1350
  const pkgRoot = stubPkgRoot({
1331
1351
  name: "no-scripts",
1332
- devDependencies: { "smol-toml": "1", rulesync: "2" },
1352
+ dependencies: { "smol-toml": "1" },
1353
+ devDependencies: { rulesync: "2" },
1333
1354
  });
1334
1355
  const dest = tmp("pj-noscripts-");
1335
1356
  mergePackageJson(dest, pkgRoot);
@@ -1362,7 +1383,8 @@ describe("mergePackageJson", () => {
1362
1383
  "purge:global-mcps:apply": "node scripts/purge-global-mcps.mjs --apply",
1363
1384
  test: "vitest run --coverage",
1364
1385
  },
1365
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1386
+ dependencies: { "smol-toml": "^1.8.0" },
1387
+ devDependencies: { rulesync: "latest" },
1366
1388
  });
1367
1389
  const dest = tmp("pj-autorun-");
1368
1390
  fs.writeFileSync(
@@ -1429,7 +1451,8 @@ describe("mergePackageJson", () => {
1429
1451
  const pkgRoot = stubPkgRoot({
1430
1452
  name: "fixture-pkg",
1431
1453
  scripts: { doctor: "node ./scripts/doctor.mjs" },
1432
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1454
+ dependencies: { "smol-toml": "^1.8.0" },
1455
+ devDependencies: { rulesync: "latest" },
1433
1456
  });
1434
1457
  const dest = tmp("pj-custom-doctor-");
1435
1458
  fs.writeFileSync(
@@ -1449,7 +1472,8 @@ describe("mergePackageJson", () => {
1449
1472
  const pkgRoot = stubPkgRoot({
1450
1473
  name: "fixture-pkg",
1451
1474
  scripts: { "rulesync:generate": "rulesync generate -t bar" },
1452
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1475
+ dependencies: { "smol-toml": "^1.8.0" },
1476
+ devDependencies: { rulesync: "latest" },
1453
1477
  });
1454
1478
  const dest = tmp("pj-both-");
1455
1479
  fs.writeFileSync(
@@ -1473,7 +1497,8 @@ describe("mergePackageJson", () => {
1473
1497
  const pkgRoot = stubPkgRoot({
1474
1498
  name: "fixture-pkg",
1475
1499
  scripts: { "rulesync:check": "node ./scripts/guarded-roots.mjs check" },
1476
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1500
+ dependencies: { "smol-toml": "^1.8.0" },
1501
+ devDependencies: { rulesync: "latest" },
1477
1502
  });
1478
1503
  const dest = tmp("pj-raw-rewrite-");
1479
1504
  fs.writeFileSync(
@@ -1498,7 +1523,8 @@ describe("mergePackageJson", () => {
1498
1523
  const pkgRoot = stubPkgRoot({
1499
1524
  name: "fixture-pkg",
1500
1525
  scripts: { "rulesync:check": "node ./scripts/guarded-roots.mjs check" },
1501
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1526
+ dependencies: { "smol-toml": "^1.8.0" },
1527
+ devDependencies: { rulesync: "latest" },
1502
1528
  });
1503
1529
  const dest = tmp("pj-pm-rewrite-");
1504
1530
  fs.writeFileSync(
@@ -1522,7 +1548,8 @@ describe("mergePackageJson", () => {
1522
1548
  name: GRUNT_PACKAGE,
1523
1549
  version: "0.6.2",
1524
1550
  scripts: { "rulesync:generate": "gen" },
1525
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1551
+ dependencies: { "smol-toml": "^1.8.0" },
1552
+ devDependencies: { rulesync: "latest" },
1526
1553
  });
1527
1554
  const dest = tmp("pj-grunt-dep-");
1528
1555
  fs.writeFileSync(
@@ -1544,7 +1571,8 @@ describe("mergePackageJson", () => {
1544
1571
  name: GRUNT_PACKAGE,
1545
1572
  version: "0.7.0",
1546
1573
  scripts: {},
1547
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1574
+ dependencies: { "smol-toml": "^1.8.0" },
1575
+ devDependencies: { rulesync: "latest" },
1548
1576
  });
1549
1577
  const dest = tmp("pj-grunt-onlydep-");
1550
1578
  fs.writeFileSync(
@@ -1562,7 +1590,8 @@ describe("mergePackageJson", () => {
1562
1590
  name: GRUNT_PACKAGE,
1563
1591
  version: "0.7.0",
1564
1592
  scripts: {},
1565
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1593
+ dependencies: { "smol-toml": "^1.8.0" },
1594
+ devDependencies: { rulesync: "latest" },
1566
1595
  });
1567
1596
  const dest = tmp("pj-grunt-add-");
1568
1597
  fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ name: "app" }));
@@ -1576,7 +1605,8 @@ describe("mergePackageJson", () => {
1576
1605
  name: GRUNT_PACKAGE,
1577
1606
  version: "0.7.0",
1578
1607
  scripts: {},
1579
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1608
+ dependencies: { "smol-toml": "^1.8.0" },
1609
+ devDependencies: { rulesync: "latest" },
1580
1610
  });
1581
1611
  const dest = tmp("pj-grunt-keep-");
1582
1612
  fs.writeFileSync(
@@ -1605,7 +1635,8 @@ describe("mergePackageJson", () => {
1605
1635
  const pkgRoot = stubPkgRoot({
1606
1636
  name: "fixture-pkg",
1607
1637
  scripts: { "rulesync:check": "node ./scripts/guarded-roots.mjs check" },
1608
- devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
1638
+ dependencies: { "smol-toml": "^1.8.0" },
1639
+ devDependencies: { rulesync: "latest" },
1609
1640
  });
1610
1641
  const dest = tmp("pj-nonstr-");
1611
1642
  fs.writeFileSync(
@@ -1692,6 +1723,158 @@ describe("mergePackageJson", () => {
1692
1723
  expect(pkg.scripts[k]).toBe(v);
1693
1724
  }
1694
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
+ });
1695
1878
  });
1696
1879
 
1697
1880
  describe("init", () => {
@@ -2198,6 +2381,43 @@ describe("init", () => {
2198
2381
  ).toThrow("emit-maps failed");
2199
2382
  });
2200
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
+
2201
2421
  it("does not stamp package INDEX over dest; re-emits union", () => {
2202
2422
  const dest = tmp("grunt-index-keep-");
2203
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.3",
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",
@@ -76,7 +76,8 @@
76
76
  },
77
77
  "dependencies": {
78
78
  "@clack/prompts": "^0.11.0",
79
- "cross-spawn": "^7.0.6"
79
+ "cross-spawn": "^7.0.6",
80
+ "smol-toml": "^1.8.0"
80
81
  },
81
82
  "scripts": {
82
83
  "rulesync:generate": "node ./scripts/guarded-roots.mjs generate",
@@ -104,7 +105,6 @@
104
105
  "@types/node": "^24.13.3",
105
106
  "@vitest/coverage-v8": "^3.2.7",
106
107
  "rulesync": "16.17.0",
107
- "smol-toml": "^1.8.0",
108
108
  "tar": "^7.5.22",
109
109
  "typescript": "^5.9.3",
110
110
  "verdaccio": "^6.10.5",
@@ -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();