@lovrozagar/grunt 0.3.0 → 0.3.2
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/.agents/agents/grunt.md +1 -1
- package/.agents/agents/orchestrator.md +2 -2
- package/.agents/hooks.json +1 -1
- package/.agents/skills/solo/SKILL.md +1 -1
- package/.claude/agents/grunt.md +1 -1
- package/.claude/agents/orchestrator.md +3 -3
- package/.claude/settings.json +1 -1
- package/.claude/skills/solo/SKILL.md +1 -1
- package/.codex/agents/grunt.toml +1 -1
- package/.codex/agents/orchestrator.toml +2 -2
- package/.codex/hooks.json +1 -1
- package/.grok/agents/grunt.md +1 -1
- package/.grok/agents/orchestrator.md +2 -2
- package/.grok/hooks/orchestrate-parent.js +177 -33
- package/.grok/skills/solo/SKILL.md +1 -1
- package/.rulesync/hooks.jsonc +3 -2
- package/.rulesync/reference/cascade.md +2 -2
- package/.rulesync/reference/hooks.md +1 -1
- package/.rulesync/rules/CLAUDE.md +1 -1
- package/.rulesync/rules/overview.md +1 -1
- package/.rulesync/skills/solo/SKILL.md +1 -1
- package/.rulesync/subagents/grunt.md +1 -1
- package/.rulesync/subagents/orchestrator.md +2 -2
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/README.md +11 -1
- package/cli/grunt.mjs +7 -2
- package/cli/grunt.test.ts +16 -2
- package/cli/init.mjs +68 -5
- package/cli/init.test.ts +239 -1
- package/package.json +1 -1
- package/scripts/gate-fat-tools.mjs +64 -0
- package/scripts/hooks-union.mjs +2 -2
- package/scripts/scrub-spawn-prompt.mjs +37 -17
package/cli/grunt.mjs
CHANGED
|
@@ -16,6 +16,9 @@ Commands:
|
|
|
16
16
|
check npm run rulesync:check
|
|
17
17
|
help Show this help
|
|
18
18
|
version Print package version
|
|
19
|
+
|
|
20
|
+
Flags:
|
|
21
|
+
--skip-globals Skip sync:globals:apply (auto-skipped when already initialized)
|
|
19
22
|
`
|
|
20
23
|
|
|
21
24
|
function pkgVersion() {
|
|
@@ -28,7 +31,9 @@ function npmRun(script) {
|
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export function start() {
|
|
31
|
-
const
|
|
34
|
+
const args = process.argv.slice(2)
|
|
35
|
+
const skipGlobals = args.includes("--skip-globals")
|
|
36
|
+
const [cmd] = args.filter((a) => a !== "--skip-globals")
|
|
32
37
|
if (cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
33
38
|
process.stdout.write(USAGE)
|
|
34
39
|
return
|
|
@@ -38,7 +43,7 @@ export function start() {
|
|
|
38
43
|
return
|
|
39
44
|
}
|
|
40
45
|
if (!cmd || cmd === "init") {
|
|
41
|
-
init(process.cwd())
|
|
46
|
+
init(process.cwd(), { skipGlobals })
|
|
42
47
|
return
|
|
43
48
|
}
|
|
44
49
|
if (cmd === "generate") {
|
package/cli/grunt.test.ts
CHANGED
|
@@ -21,6 +21,9 @@ Commands:
|
|
|
21
21
|
check npm run rulesync:check
|
|
22
22
|
help Show this help
|
|
23
23
|
version Print package version
|
|
24
|
+
|
|
25
|
+
Flags:
|
|
26
|
+
--skip-globals Skip sync:globals:apply (auto-skipped when already initialized)
|
|
24
27
|
`;
|
|
25
28
|
|
|
26
29
|
const pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -74,7 +77,7 @@ describe("start", () => {
|
|
|
74
77
|
process.argv = ["node", "grunt"];
|
|
75
78
|
start();
|
|
76
79
|
expect(init).toHaveBeenCalledOnce();
|
|
77
|
-
expect(init).toHaveBeenCalledWith(process.cwd());
|
|
80
|
+
expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: false });
|
|
78
81
|
expect(execFileSync).not.toHaveBeenCalled();
|
|
79
82
|
});
|
|
80
83
|
|
|
@@ -82,7 +85,18 @@ describe("start", () => {
|
|
|
82
85
|
process.argv = ["node", "grunt", "init"];
|
|
83
86
|
start();
|
|
84
87
|
expect(init).toHaveBeenCalledOnce();
|
|
85
|
-
expect(init).toHaveBeenCalledWith(process.cwd());
|
|
88
|
+
expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: false });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.each([
|
|
92
|
+
["node", "grunt", "--skip-globals"],
|
|
93
|
+
["node", "grunt", "init", "--skip-globals"],
|
|
94
|
+
["node", "grunt", "--skip-globals", "init"],
|
|
95
|
+
])("passes skipGlobals when argv is %s", (...argv) => {
|
|
96
|
+
process.argv = argv;
|
|
97
|
+
start();
|
|
98
|
+
expect(init).toHaveBeenCalledOnce();
|
|
99
|
+
expect(init).toHaveBeenCalledWith(process.cwd(), { skipGlobals: true });
|
|
86
100
|
});
|
|
87
101
|
|
|
88
102
|
it("generate npm-runs rulesync:generate", () => {
|
package/cli/init.mjs
CHANGED
|
@@ -58,7 +58,22 @@ export function mergeGitignore(dest) {
|
|
|
58
58
|
fs.appendFileSync(gi, `${prefix}.tmp/\n`)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
function destHasGruntSentinel(dest) {
|
|
62
|
+
return GUARDED_MD_FILES.some((file) => {
|
|
63
|
+
const p = path.join(dest, file)
|
|
64
|
+
return fs.existsSync(p) && fs.readFileSync(p, "utf8").includes(SENTINEL_BEGIN)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function destAlreadyInited(dest) {
|
|
69
|
+
return (
|
|
70
|
+
fs.existsSync(path.join(dest, "scripts", "telemetry.mjs")) ||
|
|
71
|
+
fs.existsSync(path.join(dest, ".grok", "hooks", "orchestrate-parent.js")) ||
|
|
72
|
+
fs.existsSync(path.join(dest, ".rulesync"))
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited = false } = {}) {
|
|
62
77
|
const src = path.join(pkgRoot, file)
|
|
63
78
|
const d = path.join(dest, file)
|
|
64
79
|
if (samePath(src, d)) return
|
|
@@ -73,6 +88,7 @@ export function mergeGuardedMarkdown(dest, pkgRoot, file) {
|
|
|
73
88
|
const hasSentinel = [SENTINEL_BEGIN, SENTINEL_END].every((marker) => destText.includes(marker))
|
|
74
89
|
|
|
75
90
|
if (!hasSentinel) {
|
|
91
|
+
if (alreadyInited) return "skipped-side-file"
|
|
76
92
|
const gruntFile = file.replace(/\.md$/, ".grunt.md")
|
|
77
93
|
fs.writeFileSync(path.join(dest, gruntFile), srcText)
|
|
78
94
|
console.log(`${file} exists without grunt sentinel; wrote ${gruntFile} instead (original untouched)`)
|
|
@@ -139,6 +155,42 @@ export function mergeClaudeSettings(destRoot, pkgRoot) {
|
|
|
139
155
|
fs.writeFileSync(destPath, `${JSON.stringify(destSettings, null, 2)}\n`)
|
|
140
156
|
}
|
|
141
157
|
|
|
158
|
+
function looksGruntOwnedPrefix(prefix) {
|
|
159
|
+
return /rulesync|sync:globals|^npm run /.test(prefix)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function commandCount(script) {
|
|
163
|
+
return script.split(/\s*(?:&&|;)\s*/).filter(Boolean).length
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function extraOwnedSuffix(cur, newSrc) {
|
|
167
|
+
const nNew = commandCount(newSrc)
|
|
168
|
+
const nCur = commandCount(cur)
|
|
169
|
+
if (nCur <= nNew) return null
|
|
170
|
+
let completed = 1
|
|
171
|
+
const re = / &&| ;|&&/g
|
|
172
|
+
let m
|
|
173
|
+
while ((m = re.exec(cur))) {
|
|
174
|
+
if (completed === nNew) {
|
|
175
|
+
const prefix = cur.slice(0, m.index)
|
|
176
|
+
if (!looksGruntOwnedPrefix(prefix)) return null
|
|
177
|
+
return cur.slice(m.index)
|
|
178
|
+
}
|
|
179
|
+
completed++
|
|
180
|
+
}
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mergeScriptValue(name, newSrc, cur) {
|
|
185
|
+
if (cur == null) return newSrc
|
|
186
|
+
if (cur.startsWith(newSrc)) return cur
|
|
187
|
+
const suffix = extraOwnedSuffix(cur, newSrc)
|
|
188
|
+
if (suffix != null) return newSrc + suffix
|
|
189
|
+
if (looksGruntOwnedPrefix(cur)) return newSrc
|
|
190
|
+
console.warn(`script \`${name}\` left untouched (unrelated customization)`)
|
|
191
|
+
return cur
|
|
192
|
+
}
|
|
193
|
+
|
|
142
194
|
export function mergePackageJson(dest, pkgRoot) {
|
|
143
195
|
const destPath = path.join(dest, "package.json")
|
|
144
196
|
let destPkg = {}
|
|
@@ -151,7 +203,7 @@ export function mergePackageJson(dest, pkgRoot) {
|
|
|
151
203
|
destPkg.scripts = { ...(destPkg.scripts || {}) }
|
|
152
204
|
for (const [k, v] of Object.entries(srcPkg.scripts || {})) {
|
|
153
205
|
if (k === "test") continue
|
|
154
|
-
destPkg.scripts[k] = v
|
|
206
|
+
destPkg.scripts[k] = mergeScriptValue(k, v, destPkg.scripts[k])
|
|
155
207
|
}
|
|
156
208
|
destPkg.devDependencies = { ...(destPkg.devDependencies || {}) }
|
|
157
209
|
destPkg.devDependencies["smol-toml"] = srcPkg.devDependencies["smol-toml"]
|
|
@@ -161,9 +213,12 @@ export function mergePackageJson(dest, pkgRoot) {
|
|
|
161
213
|
fs.writeFileSync(destPath, `${JSON.stringify(destPkg, null, 2)}\n`)
|
|
162
214
|
}
|
|
163
215
|
|
|
164
|
-
export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync } = {}) {
|
|
216
|
+
export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false } = {}) {
|
|
165
217
|
dest = path.resolve(dest)
|
|
166
218
|
const pkgRoot = path.resolve(pkgRootOpt ?? PKG_ROOT)
|
|
219
|
+
const alreadyInited = destAlreadyInited(dest)
|
|
220
|
+
const skipGlobalsApply =
|
|
221
|
+
skipGlobals || destHasGruntSentinel(dest) || fs.existsSync(path.join(dest, "scripts", "telemetry.mjs"))
|
|
167
222
|
|
|
168
223
|
for (const dir of COPY_DIRS) {
|
|
169
224
|
const src = path.join(pkgRoot, dir)
|
|
@@ -182,8 +237,14 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
|
|
|
182
237
|
}
|
|
183
238
|
}
|
|
184
239
|
|
|
240
|
+
let skippedSideFile = false
|
|
185
241
|
for (const file of GUARDED_MD_FILES) {
|
|
186
|
-
mergeGuardedMarkdown(dest, pkgRoot, file)
|
|
242
|
+
if (mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited }) === "skipped-side-file") {
|
|
243
|
+
skippedSideFile = true
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (skippedSideFile) {
|
|
247
|
+
console.log("AGENTS.md/CLAUDE.md lack markers and were left alone")
|
|
187
248
|
}
|
|
188
249
|
|
|
189
250
|
fs.mkdirSync(path.join(dest, "scripts"), { recursive: true })
|
|
@@ -207,6 +268,8 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
|
|
|
207
268
|
mergePackageJson(dest, pkgRoot)
|
|
208
269
|
exec("npm", ["install"], { cwd: dest, stdio: "inherit" })
|
|
209
270
|
exec("npm", ["run", "rulesync:generate"], { cwd: dest, stdio: "inherit" })
|
|
210
|
-
|
|
271
|
+
if (!skipGlobalsApply) {
|
|
272
|
+
exec("npm", ["run", "sync:globals:apply"], { cwd: dest, stdio: "inherit" })
|
|
273
|
+
}
|
|
211
274
|
exec("npm", ["run", "rulesync:check"], { cwd: dest, stdio: "inherit" })
|
|
212
275
|
}
|
package/cli/init.test.ts
CHANGED
|
@@ -415,13 +415,170 @@ describe("mergePackageJson", () => {
|
|
|
415
415
|
mergePackageJson(dest, pkgRoot);
|
|
416
416
|
const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
|
|
417
417
|
expect(out.scripts.test).toBe("jest");
|
|
418
|
-
expect(out.scripts.alpha).toBe("
|
|
418
|
+
expect(out.scripts.alpha).toBe("old");
|
|
419
419
|
expect(out.scripts.foo).toBe("bar");
|
|
420
420
|
expect(out.devDependencies.lodash).toBe("4");
|
|
421
421
|
expect(out.devDependencies["smol-toml"]).toBe("^1.8.0");
|
|
422
422
|
expect(out.devDependencies.rulesync).toBe("latest");
|
|
423
423
|
});
|
|
424
424
|
|
|
425
|
+
it("preserves dest suffix after an upgraded grunt script prefix", () => {
|
|
426
|
+
const pkgRoot = stubPkgRoot({
|
|
427
|
+
name: "fixture-pkg",
|
|
428
|
+
scripts: { "rulesync:generate": "rulesync generate -t bar" },
|
|
429
|
+
devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
|
|
430
|
+
});
|
|
431
|
+
const dest = tmp("pj-suffix-");
|
|
432
|
+
fs.writeFileSync(
|
|
433
|
+
path.join(dest, "package.json"),
|
|
434
|
+
JSON.stringify({
|
|
435
|
+
scripts: {
|
|
436
|
+
"rulesync:generate": "rulesync generate -t foo && node scripts/codex-sync.mjs",
|
|
437
|
+
},
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
mergePackageJson(dest, pkgRoot);
|
|
441
|
+
const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
|
|
442
|
+
expect(out.scripts["rulesync:generate"]).toBe(
|
|
443
|
+
"rulesync generate -t bar && node scripts/codex-sync.mjs",
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("preserves suffix after a multi-command grunt script upgrade", () => {
|
|
448
|
+
const pkgRoot = stubPkgRoot({
|
|
449
|
+
name: "fixture-pkg",
|
|
450
|
+
scripts: {
|
|
451
|
+
"rulesync:generate": "rulesync generate -t bar && node scripts/emit.mjs",
|
|
452
|
+
},
|
|
453
|
+
devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
|
|
454
|
+
});
|
|
455
|
+
const dest = tmp("pj-multi-suffix-");
|
|
456
|
+
fs.writeFileSync(
|
|
457
|
+
path.join(dest, "package.json"),
|
|
458
|
+
JSON.stringify({
|
|
459
|
+
scripts: {
|
|
460
|
+
"rulesync:generate":
|
|
461
|
+
"rulesync generate -t foo && node scripts/emit.mjs && node scripts/codex-sync.mjs",
|
|
462
|
+
},
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
mergePackageJson(dest, pkgRoot);
|
|
466
|
+
const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
|
|
467
|
+
expect(out.scripts["rulesync:generate"]).toBe(
|
|
468
|
+
"rulesync generate -t bar && node scripts/emit.mjs && node scripts/codex-sync.mjs",
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("does not treat a non-owned prefix plus extra && as a grunt suffix", () => {
|
|
473
|
+
const pkgRoot = stubPkgRoot({
|
|
474
|
+
name: "fixture-pkg",
|
|
475
|
+
scripts: { alpha: "a" },
|
|
476
|
+
devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
|
|
477
|
+
});
|
|
478
|
+
const dest = tmp("pj-nonowned-suffix-");
|
|
479
|
+
fs.writeFileSync(
|
|
480
|
+
path.join(dest, "package.json"),
|
|
481
|
+
JSON.stringify({ scripts: { alpha: "echo custom && extra" } }),
|
|
482
|
+
);
|
|
483
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
484
|
+
mergePackageJson(dest, pkgRoot);
|
|
485
|
+
const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
|
|
486
|
+
expect(out.scripts.alpha).toBe("echo custom && extra");
|
|
487
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
488
|
+
warnSpy.mockRestore();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("falls through suffix scan when extra commands use bare semicolons", () => {
|
|
492
|
+
const pkgRoot = stubPkgRoot({
|
|
493
|
+
name: "fixture-pkg",
|
|
494
|
+
scripts: { "rulesync:generate": "rulesync generate -t bar" },
|
|
495
|
+
devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
|
|
496
|
+
});
|
|
497
|
+
const dest = tmp("pj-semi-");
|
|
498
|
+
fs.writeFileSync(
|
|
499
|
+
path.join(dest, "package.json"),
|
|
500
|
+
JSON.stringify({
|
|
501
|
+
scripts: { "rulesync:generate": "echo custom;echo extra" },
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
505
|
+
mergePackageJson(dest, pkgRoot);
|
|
506
|
+
const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
|
|
507
|
+
expect(out.scripts["rulesync:generate"]).toBe("echo custom;echo extra");
|
|
508
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
509
|
+
warnSpy.mockRestore();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("keeps dest when dest script already equals src", () => {
|
|
513
|
+
const pkgRoot = stubPkgRoot({
|
|
514
|
+
name: "fixture-pkg",
|
|
515
|
+
scripts: { "rulesync:generate": "rulesync generate -t bar" },
|
|
516
|
+
devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
|
|
517
|
+
});
|
|
518
|
+
const dest = tmp("pj-equal-");
|
|
519
|
+
fs.writeFileSync(
|
|
520
|
+
path.join(dest, "package.json"),
|
|
521
|
+
JSON.stringify({ scripts: { "rulesync:generate": "rulesync generate -t bar" } }),
|
|
522
|
+
);
|
|
523
|
+
mergePackageJson(dest, pkgRoot);
|
|
524
|
+
const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
|
|
525
|
+
expect(out.scripts["rulesync:generate"]).toBe("rulesync generate -t bar");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("keeps dest when it already starts with the new src value plus suffix", () => {
|
|
529
|
+
const pkgRoot = stubPkgRoot({
|
|
530
|
+
name: "fixture-pkg",
|
|
531
|
+
scripts: { "rulesync:generate": "rulesync generate -t bar" },
|
|
532
|
+
devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
|
|
533
|
+
});
|
|
534
|
+
const dest = tmp("pj-prefix-");
|
|
535
|
+
const cur = "rulesync generate -t bar && node scripts/codex-sync.mjs";
|
|
536
|
+
fs.writeFileSync(path.join(dest, "package.json"), JSON.stringify({ scripts: { "rulesync:generate": cur } }));
|
|
537
|
+
mergePackageJson(dest, pkgRoot);
|
|
538
|
+
const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
|
|
539
|
+
expect(out.scripts["rulesync:generate"]).toBe(cur);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("keeps unrelated custom script values and dest-only keys, warns on skip", () => {
|
|
543
|
+
const pkgRoot = stubPkgRoot();
|
|
544
|
+
const dest = tmp("pj-unrelated-");
|
|
545
|
+
fs.writeFileSync(
|
|
546
|
+
path.join(dest, "package.json"),
|
|
547
|
+
JSON.stringify({
|
|
548
|
+
scripts: { alpha: "echo custom", foo: "bar", test: "jest" },
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
552
|
+
mergePackageJson(dest, pkgRoot);
|
|
553
|
+
const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
|
|
554
|
+
expect(out.scripts.alpha).toBe("echo custom");
|
|
555
|
+
expect(out.scripts.foo).toBe("bar");
|
|
556
|
+
expect(out.scripts.test).toBe("jest");
|
|
557
|
+
expect(out.scripts.zeta).toBe("z");
|
|
558
|
+
expect(warnSpy.mock.calls.some((c) => String(c[0]).includes("alpha") && String(c[0]).includes("untouched"))).toBe(
|
|
559
|
+
true,
|
|
560
|
+
);
|
|
561
|
+
warnSpy.mockRestore();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("replaces a grunt-owned script with no suffix", () => {
|
|
565
|
+
const pkgRoot = stubPkgRoot({
|
|
566
|
+
name: "fixture-pkg",
|
|
567
|
+
scripts: { "rulesync:generate": "rulesync generate -t bar", "sync:globals:apply": "node scripts/sync-global-settings.mjs --apply" },
|
|
568
|
+
devDependencies: { "smol-toml": "^1.8.0", rulesync: "latest" },
|
|
569
|
+
});
|
|
570
|
+
const dest = tmp("pj-owned-");
|
|
571
|
+
fs.writeFileSync(
|
|
572
|
+
path.join(dest, "package.json"),
|
|
573
|
+
JSON.stringify({
|
|
574
|
+
scripts: { "rulesync:generate": "rulesync generate -t foo" },
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
mergePackageJson(dest, pkgRoot);
|
|
578
|
+
const out = JSON.parse(fs.readFileSync(path.join(dest, "package.json"), "utf8"));
|
|
579
|
+
expect(out.scripts["rulesync:generate"]).toBe("rulesync generate -t bar");
|
|
580
|
+
});
|
|
581
|
+
|
|
425
582
|
it("early return when name is @lovrozagar/grunt", () => {
|
|
426
583
|
const pkgRoot = stubPkgRoot();
|
|
427
584
|
const dest = tmp("pj-self-");
|
|
@@ -562,4 +719,85 @@ describe("init", () => {
|
|
|
562
719
|
["run", "rulesync:check"],
|
|
563
720
|
]);
|
|
564
721
|
});
|
|
722
|
+
|
|
723
|
+
it("--skip-globals skips sync:globals:apply", () => {
|
|
724
|
+
const pkgRoot = stubPkgRoot();
|
|
725
|
+
const dest = tmp("grunt-skip-globals-");
|
|
726
|
+
const exec = vi.fn();
|
|
727
|
+
init(dest, { pkgRoot, execFileSync: exec, skipGlobals: true });
|
|
728
|
+
expect(exec.mock.calls.map((c) => [c[0], c[1]])).toEqual([
|
|
729
|
+
["npm", ["install"]],
|
|
730
|
+
["npm", ["run", "rulesync:generate"]],
|
|
731
|
+
["npm", ["run", "rulesync:check"]],
|
|
732
|
+
]);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("auto-skip globals when telemetry.mjs exists", () => {
|
|
736
|
+
const pkgRoot = stubPkgRoot();
|
|
737
|
+
const dest = tmp("grunt-auto-tel-");
|
|
738
|
+
fs.mkdirSync(path.join(dest, "scripts"), { recursive: true });
|
|
739
|
+
fs.writeFileSync(path.join(dest, "scripts", "telemetry.mjs"), "existing");
|
|
740
|
+
const exec = vi.fn();
|
|
741
|
+
init(dest, { pkgRoot, execFileSync: exec });
|
|
742
|
+
expect(exec.mock.calls.map((c) => c[1])).toEqual([
|
|
743
|
+
["install"],
|
|
744
|
+
["run", "rulesync:generate"],
|
|
745
|
+
["run", "rulesync:check"],
|
|
746
|
+
]);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("auto-skip globals when AGENTS.md already has a grunt sentinel", () => {
|
|
750
|
+
const pkgRoot = stubPkgRoot();
|
|
751
|
+
const dest = tmp("grunt-auto-sent-");
|
|
752
|
+
fs.writeFileSync(
|
|
753
|
+
path.join(dest, "AGENTS.md"),
|
|
754
|
+
"<!-- grunt:begin -->\nold\n<!-- grunt:end -->\n",
|
|
755
|
+
);
|
|
756
|
+
const exec = vi.fn();
|
|
757
|
+
init(dest, { pkgRoot, execFileSync: exec });
|
|
758
|
+
expect(exec.mock.calls.map((c) => c[1])).toEqual([
|
|
759
|
+
["install"],
|
|
760
|
+
["run", "rulesync:generate"],
|
|
761
|
+
["run", "rulesync:check"],
|
|
762
|
+
]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("re-run without sentinel does not create/overwrite *.grunt.md", () => {
|
|
766
|
+
const pkgRoot = stubPkgRoot();
|
|
767
|
+
const dest = tmp("grunt-rerun-nosent-");
|
|
768
|
+
fs.writeFileSync(path.join(dest, "CLAUDE.md"), "hand-written consumer doc\n");
|
|
769
|
+
fs.writeFileSync(path.join(dest, "AGENTS.md"), "hand-written consumer doc\n");
|
|
770
|
+
fs.mkdirSync(path.join(dest, "scripts"), { recursive: true });
|
|
771
|
+
fs.writeFileSync(path.join(dest, "scripts", "telemetry.mjs"), "existing");
|
|
772
|
+
fs.writeFileSync(path.join(dest, "CLAUDE.grunt.md"), "stale side");
|
|
773
|
+
const exec = vi.fn();
|
|
774
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
775
|
+
init(dest, { pkgRoot, execFileSync: exec });
|
|
776
|
+
expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe("hand-written consumer doc\n");
|
|
777
|
+
expect(fs.readFileSync(path.join(dest, "AGENTS.md"), "utf8")).toBe("hand-written consumer doc\n");
|
|
778
|
+
expect(fs.readFileSync(path.join(dest, "CLAUDE.grunt.md"), "utf8")).toBe("stale side");
|
|
779
|
+
expect(fs.existsSync(path.join(dest, "AGENTS.grunt.md"))).toBe(false);
|
|
780
|
+
expect(logSpy.mock.calls.filter((c) => String(c[0]).includes("lack markers"))).toHaveLength(1);
|
|
781
|
+
logSpy.mockRestore();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("first init without sentinel still creates *.grunt.md", () => {
|
|
785
|
+
const pkgRoot = stubPkgRoot();
|
|
786
|
+
const dest = tmp("grunt-first-nosent-");
|
|
787
|
+
fs.writeFileSync(path.join(dest, "CLAUDE.md"), "hand-written consumer doc\n");
|
|
788
|
+
fs.writeFileSync(path.join(dest, "AGENTS.md"), "hand-written consumer doc\n");
|
|
789
|
+
const exec = vi.fn();
|
|
790
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
791
|
+
init(dest, { pkgRoot, execFileSync: exec });
|
|
792
|
+
expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe("hand-written consumer doc\n");
|
|
793
|
+
expect(fs.readFileSync(path.join(dest, "CLAUDE.grunt.md"), "utf8")).toBe("CLAUDE.md content");
|
|
794
|
+
expect(fs.readFileSync(path.join(dest, "AGENTS.grunt.md"), "utf8")).toBe("AGENTS.md content");
|
|
795
|
+
expect(exec.mock.calls.map((c) => c[1])).toEqual([
|
|
796
|
+
["install"],
|
|
797
|
+
["run", "rulesync:generate"],
|
|
798
|
+
["run", "sync:globals:apply"],
|
|
799
|
+
["run", "rulesync:check"],
|
|
800
|
+
]);
|
|
801
|
+
logSpy.mockRestore();
|
|
802
|
+
});
|
|
565
803
|
});
|
package/package.json
CHANGED
|
@@ -42,6 +42,14 @@ const READ_TOOLS = new Set(["read", "readfile"]);
|
|
|
42
42
|
const GREP_TOOLS = new Set(["grep", "grepsearch"]);
|
|
43
43
|
const GLOB_TOOLS = new Set(["glob", "listdir"]);
|
|
44
44
|
const BASH_TOOLS = new Set(["bash", "runterminalcommand"]);
|
|
45
|
+
const WRITE_TOOLS = new Set(["write", "edit", "searchreplace"]);
|
|
46
|
+
const WRITE_PATH_FIELDS = [
|
|
47
|
+
"path",
|
|
48
|
+
"file_path",
|
|
49
|
+
"filePath",
|
|
50
|
+
"target_file",
|
|
51
|
+
"targetFile",
|
|
52
|
+
];
|
|
45
53
|
const PATH_FIELDS = [
|
|
46
54
|
"path",
|
|
47
55
|
"file_path",
|
|
@@ -124,6 +132,47 @@ export function hookResponse(updated) {
|
|
|
124
132
|
};
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
export function rewriteGruntScratchPath(filePath, workspaceRoot) {
|
|
136
|
+
if (filePath == null) return null;
|
|
137
|
+
const raw = String(filePath);
|
|
138
|
+
if (!raw || !workspaceRoot) return null;
|
|
139
|
+
const posix = raw.replace(/\\/g, "/");
|
|
140
|
+
const marker = ".tmp/grunt/";
|
|
141
|
+
const idx = posix.indexOf(marker);
|
|
142
|
+
if (idx === -1) return null;
|
|
143
|
+
|
|
144
|
+
const wsRoot = path.resolve(workspaceRoot);
|
|
145
|
+
const absIn = path.isAbsolute(raw)
|
|
146
|
+
? path.resolve(raw)
|
|
147
|
+
: path.resolve(wsRoot, raw);
|
|
148
|
+
const relWs = path.relative(wsRoot, absIn);
|
|
149
|
+
if (relWs !== "" && !relWs.startsWith("..") && !path.isAbsolute(relWs)) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const rel = posix.slice(idx + marker.length);
|
|
154
|
+
if (!rel) return null;
|
|
155
|
+
if (path.posix.isAbsolute(rel) || rel.startsWith("/")) return null;
|
|
156
|
+
const relNorm = path.posix.normalize(rel);
|
|
157
|
+
if (
|
|
158
|
+
!relNorm ||
|
|
159
|
+
relNorm === "." ||
|
|
160
|
+
relNorm === ".." ||
|
|
161
|
+
relNorm.startsWith("../") ||
|
|
162
|
+
rel.split("/").includes("..") ||
|
|
163
|
+
relNorm.split("/").includes("..")
|
|
164
|
+
) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (path.posix.isAbsolute(relNorm)) return null;
|
|
168
|
+
|
|
169
|
+
const destRoot = path.resolve(wsRoot, ".tmp", "grunt");
|
|
170
|
+
const dest = path.resolve(destRoot, relNorm);
|
|
171
|
+
const check = path.relative(destRoot, dest);
|
|
172
|
+
if (!check || check.startsWith("..") || path.isAbsolute(check)) return null;
|
|
173
|
+
return dest;
|
|
174
|
+
}
|
|
175
|
+
|
|
127
176
|
export function workspaceRootOf(data) {
|
|
128
177
|
return (
|
|
129
178
|
process.env.GROK_WORKSPACE_ROOT ||
|
|
@@ -337,6 +386,21 @@ export function processFatTools(data) {
|
|
|
337
386
|
const toolKey = eventKey(data.toolName || data.tool_name || "");
|
|
338
387
|
const input = toolInputOf(data);
|
|
339
388
|
if (!input) return null;
|
|
389
|
+
const isWrite = WRITE_TOOLS.has(toolKey);
|
|
390
|
+
if (isWrite) {
|
|
391
|
+
const ws = workspaceRootOf(data);
|
|
392
|
+
const next = Object.assign({}, input);
|
|
393
|
+
let changed = false;
|
|
394
|
+
for (const key of WRITE_PATH_FIELDS) {
|
|
395
|
+
if (typeof next[key] !== "string" || !next[key]) continue;
|
|
396
|
+
const dest = rewriteGruntScratchPath(next[key], ws);
|
|
397
|
+
if (dest && dest !== next[key]) {
|
|
398
|
+
next[key] = dest;
|
|
399
|
+
changed = true;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return changed ? { type: "rewrite", updatedInput: next } : null;
|
|
403
|
+
}
|
|
340
404
|
const sub = subagentTypeOf(data);
|
|
341
405
|
if (sub === "grunt") return null;
|
|
342
406
|
|
package/scripts/hooks-union.mjs
CHANGED
|
@@ -103,7 +103,7 @@ function gruntPreToolUse() {
|
|
|
103
103
|
hooks: [hookCmd(SCRUB, 5)],
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
|
-
matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command",
|
|
106
|
+
matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command|Write|Edit|write|search_replace",
|
|
107
107
|
hooks: [hookCmd(GATE_FAT, 5)],
|
|
108
108
|
},
|
|
109
109
|
];
|
|
@@ -205,7 +205,7 @@ function consumerNamed(root) {
|
|
|
205
205
|
};
|
|
206
206
|
named["gate-fat-tools"] = {
|
|
207
207
|
event: "PreToolUse",
|
|
208
|
-
matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command",
|
|
208
|
+
matcher: "Read|read_file|Grep|grep|Glob|list_dir|Bash|run_terminal_command|Write|Edit|write|search_replace",
|
|
209
209
|
command: GATE_FAT,
|
|
210
210
|
timeout: 5,
|
|
211
211
|
};
|
|
@@ -4,9 +4,10 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { scrubText } from "./scrub-text-lib.mjs";
|
|
7
|
+
import { denyResponse } from "./gate-fat-tools.mjs";
|
|
7
8
|
|
|
8
9
|
const ALLOWED_TYPES = new Set(["grunt", "implementer", "thinker"]);
|
|
9
|
-
export const MAX_PROMPT_CHARS =
|
|
10
|
+
export const MAX_PROMPT_CHARS = 100000;
|
|
10
11
|
export const TRUNCATE_SUFFIX = "…[truncated]";
|
|
11
12
|
/** `router` is a legacy transcript prefix; keep matching it to strip old pastes. */
|
|
12
13
|
const TRANSCRIPT_PREFIX =
|
|
@@ -72,14 +73,25 @@ export function extractVerdictBlocks(text) {
|
|
|
72
73
|
return { body: body.join("\n"), blocks };
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
export function truncatePrompt(text,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
export function truncatePrompt(text, _max = MAX_PROMPT_CHARS) {
|
|
77
|
+
return String(text);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function workspaceRootOf(data) {
|
|
81
|
+
return (
|
|
82
|
+
process.env.GROK_WORKSPACE_ROOT ||
|
|
83
|
+
(data && (data.workspaceRoot || data.workspace_root || data.cwd)) ||
|
|
84
|
+
process.env.CLAUDE_PROJECT_DIR ||
|
|
85
|
+
process.cwd() ||
|
|
86
|
+
""
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function spawnCapReason(workspaceRoot) {
|
|
91
|
+
const root = workspaceRoot || process.cwd() || "";
|
|
92
|
+
return (
|
|
93
|
+
`spawn prompt exceeds ${MAX_PROMPT_CHARS} chars after scrub; write it under ${root}/.tmp/plans/ and re-spawn with that abs path`
|
|
94
|
+
);
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
const INLINE_TRANSCRIPT =
|
|
@@ -108,13 +120,7 @@ export function capSpawnPrompt(prompt) {
|
|
|
108
120
|
const again = extractVerdictBlocks(s);
|
|
109
121
|
const blocks = extracted.blocks.concat(again.blocks);
|
|
110
122
|
const verdictPart = blocks.length ? "\n" + blocks.join("\n") : "";
|
|
111
|
-
|
|
112
|
-
let core = again.body;
|
|
113
|
-
if (core.length > maxCore) {
|
|
114
|
-
const cut = Math.max(0, maxCore - TRUNCATE_SUFFIX.length);
|
|
115
|
-
core = core.slice(0, cut) + TRUNCATE_SUFFIX;
|
|
116
|
-
}
|
|
117
|
-
return (core + verdictPart).trim();
|
|
123
|
+
return (again.body + verdictPart).trim();
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
export function rewriteSpawnToolInput(toolInput, { defaultGrunt = false } = {}) {
|
|
@@ -160,9 +166,19 @@ export function processHookPayload(data) {
|
|
|
160
166
|
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
|
|
161
167
|
let toolInput = data.toolInput;
|
|
162
168
|
if (toolInput == null) toolInput = data.tool_input;
|
|
163
|
-
|
|
169
|
+
const updated = rewriteSpawnToolInput(toolInput, {
|
|
164
170
|
defaultGrunt: grokDefaultGrunt(),
|
|
165
171
|
});
|
|
172
|
+
const prompt =
|
|
173
|
+
updated && typeof updated.prompt === "string"
|
|
174
|
+
? updated.prompt
|
|
175
|
+
: toolInput && typeof toolInput.prompt === "string"
|
|
176
|
+
? toolInput.prompt
|
|
177
|
+
: "";
|
|
178
|
+
if (prompt.length > MAX_PROMPT_CHARS) {
|
|
179
|
+
return denyResponse(spawnCapReason(workspaceRootOf(data)));
|
|
180
|
+
}
|
|
181
|
+
return updated;
|
|
166
182
|
}
|
|
167
183
|
|
|
168
184
|
export function hookResponse(updated) {
|
|
@@ -179,6 +195,10 @@ function main() {
|
|
|
179
195
|
const data = readJsonValue();
|
|
180
196
|
const updated = processHookPayload(data);
|
|
181
197
|
if (!updated) return 0;
|
|
198
|
+
if (updated.decision === "deny") {
|
|
199
|
+
process.stdout.write(JSON.stringify(updated));
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
182
202
|
process.stdout.write(JSON.stringify(hookResponse(updated)));
|
|
183
203
|
return 0;
|
|
184
204
|
} catch {
|