@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.
- package/.rulesync/reference/INDEX.md +1 -1
- package/.rulesync/reference/law.md +1 -1
- package/CHANGELOG.md +8 -0
- package/cli/init.mjs +92 -6
- package/cli/init.test.ts +198 -0
- package/package.json +1 -1
- package/scripts/doctor.mjs +15 -2
- package/scripts/guarded-md.mjs +28 -0
- package/scripts/listen.mjs +2 -1
- package/scripts/pipeline.mjs +84 -2
- package/scripts/session-map.mjs +1 -1
- package/scripts/speak.mjs +2 -0
|
@@ -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
package/scripts/doctor.mjs
CHANGED
|
@@ -444,7 +444,17 @@ export function runDoctor({
|
|
|
444
444
|
return { code: missingRequired.length || missingMaps.length ? 1 : 0, stdout, stderr: "" };
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
|
|
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
|
}
|
package/scripts/guarded-md.mjs
CHANGED
|
@@ -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));
|
package/scripts/listen.mjs
CHANGED
|
@@ -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());
|
package/scripts/pipeline.mjs
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
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
|
}
|
package/scripts/session-map.mjs
CHANGED
|
@@ -63,7 +63,7 @@ function main() {
|
|
|
63
63
|
JSON.stringify({
|
|
64
64
|
hookSpecificOutput: {
|
|
65
65
|
hookEventName: "SessionStart",
|
|
66
|
-
additionalContext: `${header(/
|
|
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();
|