@lovrozagar/grunt 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/grunt.md +4 -2
- package/.agents/hooks.json +11 -0
- package/.agents/skills/commit/SKILL.md +21 -0
- package/.agents/skills/commit-and-push/SKILL.md +15 -0
- package/.agents/skills/explain/SKILL.md +14 -0
- package/.agents/skills/handoff/SKILL.md +84 -0
- package/.agents/skills/parent/SKILL.md +11 -0
- package/.agents/skills/solo/SKILL.md +21 -0
- package/.claude/agents/grunt.md +2 -0
- package/.claude/settings.json +33 -0
- package/.claude/skills/commit/SKILL.md +21 -0
- package/.claude/skills/commit-and-push/SKILL.md +15 -0
- package/.claude/skills/explain/SKILL.md +14 -0
- package/.claude/skills/handoff/SKILL.md +84 -0
- package/.claude/skills/parent/SKILL.md +11 -0
- package/.claude/skills/solo/SKILL.md +21 -0
- package/.codex/agents/grunt.toml +4 -2
- package/.codex/hooks.json +33 -0
- package/.grok/agents/grunt.md +4 -2
- package/.grok/hooks/orchestrate-parent.js +109 -19
- package/.grok/skills/commit/SKILL.md +21 -0
- package/.grok/skills/commit-and-push/SKILL.md +15 -0
- package/.grok/skills/handoff/SKILL.md +84 -0
- package/.grok/skills/solo/SKILL.md +21 -0
- package/.rulesync/hooks.jsonc +8 -1
- package/.rulesync/reference/cascade.md +3 -2
- package/.rulesync/reference/hooks.md +5 -1
- package/.rulesync/reference/map.md +18 -3
- package/.rulesync/rules/CLAUDE.md +2 -1
- package/.rulesync/rules/overview.md +2 -1
- package/.rulesync/skills/commit/SKILL.md +22 -0
- package/.rulesync/skills/commit-and-push/SKILL.md +16 -0
- package/.rulesync/skills/handoff/SKILL.md +84 -0
- package/.rulesync/skills/solo/SKILL.md +21 -0
- package/.rulesync/subagents/grunt.md +6 -2
- package/.rulesync/subagents/implementer.md +2 -0
- package/.rulesync/subagents/orchestrator.md +2 -0
- package/.rulesync/subagents/thinker.md +2 -0
- package/AGENTS.md +2 -1
- package/CLAUDE.md +2 -1
- package/README.md +2 -2
- package/cli/init.mjs +105 -6
- package/cli/init.test.ts +303 -6
- package/package.json +12 -6
- package/scripts/emit-agent-shell-tools.mjs +118 -0
- package/scripts/emit-gemini.mjs +219 -0
- package/scripts/emit-mcp-policy.mjs +23 -0
- package/scripts/hooks-union.mjs +391 -0
- package/scripts/persist-handoff.mjs +231 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: solo
|
|
3
|
+
description: >
|
|
4
|
+
Session mode. /solo disables the grunt cascade for this session — one normal
|
|
5
|
+
agent, no spawn, no orchestrator. /cascade restores it. Unlike /parent and
|
|
6
|
+
/explain this IS a mode: it persists until /cascade or session end.
|
|
7
|
+
---
|
|
8
|
+
Session mode, not one-turn. Persists until `/cascade` or session end.
|
|
9
|
+
|
|
10
|
+
This session: cascade suspended. You are one normal agent.
|
|
11
|
+
|
|
12
|
+
- Do not spawn. No grunt|implementer|thinker.
|
|
13
|
+
- No `[agent]:` recap. Answer the user directly.
|
|
14
|
+
- No spawn-first rule. No `need:`/`verdict:` grammar.
|
|
15
|
+
- Voice unchanged: `.rulesync/reference/output.md`. `/explain` is still the voice escape.
|
|
16
|
+
|
|
17
|
+
Fat-tool caps stay on — token guards, not cascade.
|
|
18
|
+
|
|
19
|
+
Claude/Codex/Grok: `/solo` via submit hook writes `.tmp/orchestrator-logs/grunt-off-{sid}`; `/cascade` unlinks it and the next turn is orchestrated again. Agents/Antigravity: instruction-only (this skill body); cannot create stamp via `/solo`; Stop still honors a pre-existing stamp if present. Session-scoped, never global.
|
|
20
|
+
|
|
21
|
+
For advisory/design turns, debugging the cascade itself, or back-and-forth where spawning costs more than it saves.
|
|
@@ -27,18 +27,22 @@ antigravity-cli:
|
|
|
27
27
|
inheritMcp: false
|
|
28
28
|
commandExecutionPolicy: eager
|
|
29
29
|
tools: [view_file, grep_search, run_command, replace_file_content]
|
|
30
|
+
geminicli:
|
|
31
|
+
model: gemini-2.5-flash
|
|
30
32
|
---
|
|
31
33
|
Voice: `.rulesync/reference/output.md` — must follow.
|
|
32
34
|
|
|
33
|
-
Tools (shell/host; and more):
|
|
35
|
+
Tools (shell/host; and more): read_file, grep, list_dir, run_terminal_command, write, search_replace, web_search, web_fetch. Git. Test runners (vitest, playwright, and similar). Other system tools. Commands of project dependencies. Installing and managing dependencies. Running dev, build, preview, deploys and similar.
|
|
36
|
+
You CAN run npm/git/bash via `run_terminal_command`. Do NOT wait on MCP. Do NOT request MCP for shell. `mcpInheritance: none` means no MCP — use shell/files instead.
|
|
34
37
|
|
|
35
38
|
Run tools. Return output. Never spawn. Never implement complex/feature solutions (implementer agent). Never plan (thinker agent).
|
|
36
39
|
May write simple structural folders/files (scaffold/mkdir/touch/boilerplate only). Not feature solutions.
|
|
37
40
|
Low-reason write: mechanical/repetitive/obvious; volume OK.
|
|
41
|
+
Scratch/tmp → `.tmp/grunt/` only (mkdir as needed). Never `.tmp/` root, `.tmp/plans/`, `.tmp/grunt/handoffs/`, or `.tmp/orchestrator-logs/`. Repo scaffold/mkdir/touch/boilerplate stays in the real tree.
|
|
38
42
|
|
|
39
43
|
`job: search|exec` → first action `node scripts/grunt-job.mjs --job search|exec --query …`; whole reply = stdout.
|
|
40
44
|
`job: test` → grunt-job; `FALLBACK` (exit 2) → LLM tools.
|
|
41
|
-
`job: web` and messy test → LLM grunt (
|
|
45
|
+
`job: web` and messy test → LLM grunt (web_search/web_fetch).
|
|
42
46
|
Isolation/write reply ≤8 lines. Verdict grammar only — no dumps/recap/HTML/JSON/full logs:
|
|
43
47
|
|
|
44
48
|
```
|
|
@@ -29,6 +29,8 @@ antigravity-cli:
|
|
|
29
29
|
inheritMcp: false
|
|
30
30
|
commandExecutionPolicy: eager
|
|
31
31
|
tools: [view_file, grep_search, run_command]
|
|
32
|
+
geminicli:
|
|
33
|
+
model: gemini-2.5-flash
|
|
32
34
|
---
|
|
33
35
|
Voice: `.rulesync/reference/output.md` — must follow.
|
|
34
36
|
Protocol: `.rulesync/reference/cascade.md` (peek/kill table need:/resume). Do not paste. Do not open first.
|
package/AGENTS.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Voice: `.rulesync/reference/output.md` once.
|
|
2
|
+
All output exclude AI attribution, Co-Authored-By, trailers, generated markers.
|
|
2
3
|
Protocol: `.rulesync/reference/cascade.md`. Do not paste. Do not open first.
|
|
3
4
|
Parent = orchestrator. You do not talk. Spawn wait echo.
|
|
4
5
|
Spawn only grunt|implementer|thinker. Never spawn orchestrator. Children never spawn.
|
|
5
|
-
First token = spawn. No try-then-spawn. No parent probe. After child: `[agent]:` echo only. No skip-spawn. /parent = one-turn.
|
|
6
|
+
First token = spawn. No try-then-spawn. No parent probe. After child: `[agent]:` echo only. No skip-spawn. /parent = one-turn. /handoff = one-turn in-parent write to `.tmp/grunt/handoffs/`, recap `[handoff]:`.
|
|
6
7
|
grunt ← tools (facts/search/exec/git/web/test/low-reason mechanical write; world fact: `job: web` never memory). implementer ← write defined solution. thinker ← plan/deep reason.
|
|
7
8
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
|
8
9
|
Peek every 60s/child; quote real host fields; `done|alive|stuck`; no invent; no auto-kill. `timeout_ms=60000` every peek. GAP: block on spawn return = `done`. Stuck/blocked: quote host fields only.
|
package/CLAUDE.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Voice: `.rulesync/reference/output.md`
|
|
2
|
+
All output exclude AI attribution, Co-Authored-By, trailers, generated markers.
|
|
2
3
|
Protocol: `.rulesync/reference/cascade.md`. Do not paste. Do not open first.
|
|
3
4
|
Parent = this file not `.claude/agents/orchestrator.md`. You do not talk. Spawn wait echo.
|
|
4
5
|
Spawn only grunt|implementer|thinker. Never spawn orchestrator. Children never spawn.
|
|
5
|
-
First token = spawn. No try-then-spawn. No parent probe. After child: `[agent]:` echo only. No skip-spawn. /parent = one-turn.
|
|
6
|
+
First token = spawn. No try-then-spawn. No parent probe. After child: `[agent]:` echo only. No skip-spawn. /parent = one-turn. /handoff = one-turn in-parent write to `.tmp/grunt/handoffs/`, recap `[handoff]:`.
|
|
6
7
|
grunt ← tools (facts/search/exec/git/web/test/low-reason mechanical write; world fact: `job: web` never memory). implementer ← write defined solution. thinker ← plan/deep reason.
|
|
7
8
|
Child prompt = `You are {agent} subagent.` + task + abs paths + verdicts only.
|
|
8
9
|
Peek every 60s/child; quote real host fields; `done|alive|stuck`; no invent; no auto-kill. `timeout_ms=60000` every peek. GAP: block on spawn return = `done`. Stuck/blocked: quote host fields only.
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Contributors to this repo only: local `npm i && npm test`.
|
|
|
17
17
|
|
|
18
18
|
# GOAL
|
|
19
19
|
|
|
20
|
-
- synced configs across all 4/5 major cli providers (grok build, claude code, codex, gemini cli, antigravity)! Emit matrix: grok/claude/codex/antigravity (`rulesync generate`).
|
|
20
|
+
- synced configs across all 4/5 major cli providers (grok build, claude code, codex, gemini cli, antigravity)! Emit matrix: grok/claude/codex/antigravity (`rulesync generate`) then `emit-mcp-policy` + `emit-gemini` (`GEMINI.md`, `.gemini/agents/{id}/agent.md`; MCP `.gemini/settings.json`) + `emit-agent-shell-tools` (Claude grunt body `Bash`; other hosts `run_terminal_command`). No `-t geminicli`.
|
|
21
21
|
- max situational speed relative to task intensity
|
|
22
22
|
- max free token savings:
|
|
23
23
|
maximally superterse without loosing value - sacrifice grammar for sake of concision
|
|
@@ -27,7 +27,7 @@ Contributors to this repo only: local `npm i && npm test`.
|
|
|
27
27
|
context engineering
|
|
28
28
|
- max reasoning and effort where needed and minimal reasoning and effort where not needed
|
|
29
29
|
- agents:
|
|
30
|
-
- orchestrator (low model, low effort) -> always spawn+prompt; never user-facing tokens except `[agent]:` echo. `/parent` one-turn only.
|
|
30
|
+
- orchestrator (low model, low effort) -> always spawn+prompt; never user-facing tokens except `[agent]:` echo. `/parent` one-turn only; `/handoff` writes `.tmp/grunt/handoffs/{serial}-{slug}-{stamp}.md` and hands the session to a fresh one.
|
|
31
31
|
- subagents:
|
|
32
32
|
- grunt (low model, low effort) -> tools. facts/search/exec/git/web/test/low-reason mechanical write. never feature solution. never spawn.
|
|
33
33
|
- implementer (medium model, medium effort) -> write already-defined solution. does not plan or invent design. never spawn.
|
package/cli/init.mjs
CHANGED
|
@@ -7,10 +7,14 @@ const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
|
7
7
|
|
|
8
8
|
const PRODUCT_SCRIPTS = [
|
|
9
9
|
"check-globals.mjs",
|
|
10
|
+
"emit-agent-shell-tools.mjs",
|
|
11
|
+
"emit-gemini.mjs",
|
|
10
12
|
"emit-mcp-policy.mjs",
|
|
11
13
|
"gate-fat-tools.mjs",
|
|
14
|
+
"hooks-union.mjs",
|
|
12
15
|
"grunt-job.mjs",
|
|
13
16
|
"parse-need.mjs",
|
|
17
|
+
"persist-handoff.mjs",
|
|
14
18
|
"persist-plan.mjs",
|
|
15
19
|
"purge-global-mcps.mjs",
|
|
16
20
|
"scrub-spawn-prompt.mjs",
|
|
@@ -21,7 +25,13 @@ const PRODUCT_SCRIPTS = [
|
|
|
21
25
|
]
|
|
22
26
|
|
|
23
27
|
const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"]
|
|
24
|
-
const
|
|
28
|
+
const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"]
|
|
29
|
+
// .mcp.json is intentionally not copied here: scripts/emit-mcp-policy.mjs
|
|
30
|
+
// (run via `rulesync:generate`, see mergeClaudeSettings ordering) owns and
|
|
31
|
+
// merges it, so a plain-copy would just be clobbered/redundant.
|
|
32
|
+
const OWNED_HOOK_FILES = ["scrub-spawn-prompt.mjs", "gate-fat-tools.mjs", "orchestrate-parent.js"]
|
|
33
|
+
const SENTINEL_BEGIN = "<!-- grunt:begin -->"
|
|
34
|
+
const SENTINEL_END = "<!-- grunt:end -->"
|
|
25
35
|
|
|
26
36
|
function sortKeys(obj) {
|
|
27
37
|
return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]))
|
|
@@ -48,6 +58,87 @@ export function mergeGitignore(dest) {
|
|
|
48
58
|
fs.appendFileSync(gi, `${prefix}.tmp/\n`)
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
export function mergeGuardedMarkdown(dest, pkgRoot, file) {
|
|
62
|
+
const src = path.join(pkgRoot, file)
|
|
63
|
+
const d = path.join(dest, file)
|
|
64
|
+
if (samePath(src, d)) return
|
|
65
|
+
const srcText = fs.readFileSync(src, "utf8")
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(d)) {
|
|
68
|
+
fs.writeFileSync(d, `${SENTINEL_BEGIN}\n${srcText}\n${SENTINEL_END}\n`)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const destText = fs.readFileSync(d, "utf8")
|
|
73
|
+
const hasSentinel = [SENTINEL_BEGIN, SENTINEL_END].every((marker) => destText.includes(marker))
|
|
74
|
+
|
|
75
|
+
if (!hasSentinel) {
|
|
76
|
+
const gruntFile = file.replace(/\.md$/, ".grunt.md")
|
|
77
|
+
fs.writeFileSync(path.join(dest, gruntFile), srcText)
|
|
78
|
+
console.log(`${file} exists without grunt sentinel; wrote ${gruntFile} instead (original untouched)`)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const beginIdx = destText.indexOf(SENTINEL_BEGIN)
|
|
83
|
+
const endIdx = destText.indexOf(SENTINEL_END)
|
|
84
|
+
const before = destText.slice(0, beginIdx)
|
|
85
|
+
const after = destText.slice(endIdx + SENTINEL_END.length)
|
|
86
|
+
fs.writeFileSync(d, `${before}${SENTINEL_BEGIN}\n${srcText}\n${SENTINEL_END}${after}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function mergeClaudeSettings(destRoot, pkgRoot) {
|
|
90
|
+
const srcPath = path.join(pkgRoot, ".claude", "settings.json")
|
|
91
|
+
const destPath = path.join(destRoot, ".claude", "settings.json")
|
|
92
|
+
|
|
93
|
+
if (!fs.existsSync(destPath)) {
|
|
94
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
|
95
|
+
fs.copyFileSync(srcPath, destPath)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let destSettings
|
|
100
|
+
try {
|
|
101
|
+
destSettings = JSON.parse(fs.readFileSync(destPath, "utf8"))
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw new Error(`malformed JSON in ${destPath}: ${err.message}`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const srcSettings = JSON.parse(fs.readFileSync(srcPath, "utf8"))
|
|
107
|
+
const isOwnedGroup = (group) =>
|
|
108
|
+
group.hooks.every((entry) => OWNED_HOOK_FILES.some((f) => entry.command.includes(f)))
|
|
109
|
+
|
|
110
|
+
const destHooks = { ...(destSettings.hooks || {}) }
|
|
111
|
+
for (const [event, srcGroups] of Object.entries(srcSettings.hooks)) {
|
|
112
|
+
const destGroups = destHooks[event] || []
|
|
113
|
+
destHooks[event] = [...destGroups.filter((group) => !isOwnedGroup(group)), ...srcGroups]
|
|
114
|
+
}
|
|
115
|
+
destSettings.hooks = destHooks
|
|
116
|
+
|
|
117
|
+
const destPerms = { ...(destSettings.permissions || {}) }
|
|
118
|
+
for (const key of ["deny", "allow"]) {
|
|
119
|
+
const destList = destPerms[key] || []
|
|
120
|
+
const srcList = srcSettings.permissions[key] || []
|
|
121
|
+
const seen = new Set(destList)
|
|
122
|
+
const merged = [...destList]
|
|
123
|
+
for (const item of srcList) {
|
|
124
|
+
if (!seen.has(item)) {
|
|
125
|
+
seen.add(item)
|
|
126
|
+
merged.push(item)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
destPerms[key] = merged
|
|
130
|
+
}
|
|
131
|
+
destSettings.permissions = destPerms
|
|
132
|
+
|
|
133
|
+
for (const key of Object.keys(srcSettings)) {
|
|
134
|
+
if (key.toLowerCase().includes("mcp")) {
|
|
135
|
+
destSettings[key] = srcSettings[key]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fs.writeFileSync(destPath, `${JSON.stringify(destSettings, null, 2)}\n`)
|
|
140
|
+
}
|
|
141
|
+
|
|
51
142
|
export function mergePackageJson(dest, pkgRoot) {
|
|
52
143
|
const destPath = path.join(dest, "package.json")
|
|
53
144
|
let destPkg = {}
|
|
@@ -78,13 +169,21 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
|
|
|
78
169
|
const src = path.join(pkgRoot, dir)
|
|
79
170
|
const d = path.join(dest, dir)
|
|
80
171
|
fs.mkdirSync(d, { recursive: true })
|
|
81
|
-
if (
|
|
172
|
+
if (samePath(src, d)) continue
|
|
173
|
+
if (dir === ".claude") {
|
|
174
|
+
fs.cpSync(src, d, {
|
|
175
|
+
recursive: true,
|
|
176
|
+
force: true,
|
|
177
|
+
filter: (s) => path.basename(s) !== "settings.json",
|
|
178
|
+
})
|
|
179
|
+
mergeClaudeSettings(dest, pkgRoot)
|
|
180
|
+
} else {
|
|
181
|
+
fs.cpSync(src, d, { recursive: true, force: true })
|
|
182
|
+
}
|
|
82
183
|
}
|
|
83
184
|
|
|
84
|
-
for (const file of
|
|
85
|
-
|
|
86
|
-
const d = path.join(dest, file)
|
|
87
|
-
if (!samePath(src, d)) fs.copyFileSync(src, d)
|
|
185
|
+
for (const file of GUARDED_MD_FILES) {
|
|
186
|
+
mergeGuardedMarkdown(dest, pkgRoot, file)
|
|
88
187
|
}
|
|
89
188
|
|
|
90
189
|
fs.mkdirSync(path.join(dest, "scripts"), { recursive: true })
|
package/cli/init.test.ts
CHANGED
|
@@ -4,7 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import {
|
|
6
6
|
init,
|
|
7
|
+
mergeClaudeSettings,
|
|
7
8
|
mergeGitignore,
|
|
9
|
+
mergeGuardedMarkdown,
|
|
8
10
|
mergePackageJson,
|
|
9
11
|
samePath,
|
|
10
12
|
} from "./init.mjs";
|
|
@@ -23,13 +25,17 @@ function tmp(prefix: string) {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"];
|
|
26
|
-
const
|
|
28
|
+
const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
27
29
|
const PRODUCT_FILES = [
|
|
28
30
|
"check-globals.mjs",
|
|
31
|
+
"emit-agent-shell-tools.mjs",
|
|
32
|
+
"emit-gemini.mjs",
|
|
29
33
|
"emit-mcp-policy.mjs",
|
|
30
34
|
"gate-fat-tools.mjs",
|
|
35
|
+
"hooks-union.mjs",
|
|
31
36
|
"grunt-job.mjs",
|
|
32
37
|
"parse-need.mjs",
|
|
38
|
+
"persist-handoff.mjs",
|
|
33
39
|
"persist-plan.mjs",
|
|
34
40
|
"purge-global-mcps.mjs",
|
|
35
41
|
"scrub-spawn-prompt.mjs",
|
|
@@ -38,6 +44,46 @@ const PRODUCT_FILES = [
|
|
|
38
44
|
"telemetry.mjs",
|
|
39
45
|
];
|
|
40
46
|
|
|
47
|
+
const SRC_CLAUDE_SETTINGS = {
|
|
48
|
+
permissions: {
|
|
49
|
+
deny: ["Agent(Explore)", "Agent(orchestrator)", "mcp__*"],
|
|
50
|
+
},
|
|
51
|
+
hooks: {
|
|
52
|
+
PreToolUse: [
|
|
53
|
+
{
|
|
54
|
+
matcher: "spawn",
|
|
55
|
+
hooks: [
|
|
56
|
+
{
|
|
57
|
+
type: "command",
|
|
58
|
+
command: 'node "${ROOT}/scripts/scrub-spawn-prompt.mjs"',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
matcher: "fat",
|
|
64
|
+
hooks: [
|
|
65
|
+
{
|
|
66
|
+
type: "command",
|
|
67
|
+
command: 'node "${ROOT}/scripts/gate-fat-tools.mjs"',
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
Stop: [
|
|
73
|
+
{
|
|
74
|
+
hooks: [
|
|
75
|
+
{
|
|
76
|
+
type: "command",
|
|
77
|
+
command: 'node "${ROOT}/.grok/hooks/orchestrate-parent.js"',
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
enableAllProjectMcpServers: false,
|
|
84
|
+
enabledMcpjsonServers: [],
|
|
85
|
+
};
|
|
86
|
+
|
|
41
87
|
function stubPkgRoot(pkg: Record<string, unknown> = {
|
|
42
88
|
name: "fixture-pkg",
|
|
43
89
|
scripts: {
|
|
@@ -57,8 +103,12 @@ function stubPkgRoot(pkg: Record<string, unknown> = {
|
|
|
57
103
|
fs.mkdirSync(path.join(root, d));
|
|
58
104
|
fs.writeFileSync(path.join(root, d, "marker"), d);
|
|
59
105
|
}
|
|
60
|
-
|
|
61
|
-
|
|
106
|
+
fs.writeFileSync(
|
|
107
|
+
path.join(root, ".claude", "settings.json"),
|
|
108
|
+
`${JSON.stringify(SRC_CLAUDE_SETTINGS, null, 2)}\n`,
|
|
109
|
+
);
|
|
110
|
+
for (const f of GUARDED_MD_FILES) {
|
|
111
|
+
fs.writeFileSync(path.join(root, f), `${f} content`);
|
|
62
112
|
}
|
|
63
113
|
fs.mkdirSync(path.join(root, "scripts"));
|
|
64
114
|
for (const name of PRODUCT_FILES) {
|
|
@@ -132,6 +182,205 @@ describe("mergeGitignore", () => {
|
|
|
132
182
|
});
|
|
133
183
|
});
|
|
134
184
|
|
|
185
|
+
describe("mergeGuardedMarkdown", () => {
|
|
186
|
+
it("self: dest === src is a no-op", () => {
|
|
187
|
+
const dir = tmp("md-self-");
|
|
188
|
+
fs.writeFileSync(path.join(dir, "CLAUDE.md"), "raw content");
|
|
189
|
+
mergeGuardedMarkdown(dir, dir, "CLAUDE.md");
|
|
190
|
+
expect(fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8")).toBe("raw content");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("dest absent: writes package copy wrapped in sentinels", () => {
|
|
194
|
+
const pkgRoot = stubPkgRoot();
|
|
195
|
+
const dest = tmp("md-absent-");
|
|
196
|
+
mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
|
|
197
|
+
expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
|
|
198
|
+
"<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\n",
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("dest exists without sentinel: writes CLAUDE.grunt.md, leaves original untouched", () => {
|
|
203
|
+
const pkgRoot = stubPkgRoot();
|
|
204
|
+
const dest = tmp("md-nosentinel-");
|
|
205
|
+
fs.writeFileSync(path.join(dest, "CLAUDE.md"), "hand-written consumer doc\n");
|
|
206
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
207
|
+
mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
|
|
208
|
+
expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
|
|
209
|
+
"hand-written consumer doc\n",
|
|
210
|
+
);
|
|
211
|
+
expect(fs.readFileSync(path.join(dest, "CLAUDE.grunt.md"), "utf8")).toBe("CLAUDE.md content");
|
|
212
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
213
|
+
expect(logSpy.mock.calls[0][0]).toContain("CLAUDE.grunt.md");
|
|
214
|
+
logSpy.mockRestore();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("dest exists with sentinel: replaces only the marked region", () => {
|
|
218
|
+
const pkgRoot = stubPkgRoot();
|
|
219
|
+
const dest = tmp("md-sentinel-");
|
|
220
|
+
fs.writeFileSync(
|
|
221
|
+
path.join(dest, "CLAUDE.md"),
|
|
222
|
+
"before\n<!-- grunt:begin -->\nold generated stuff\n<!-- grunt:end -->\nafter\n",
|
|
223
|
+
);
|
|
224
|
+
mergeGuardedMarkdown(dest, pkgRoot, "CLAUDE.md");
|
|
225
|
+
expect(fs.readFileSync(path.join(dest, "CLAUDE.md"), "utf8")).toBe(
|
|
226
|
+
"before\n<!-- grunt:begin -->\nCLAUDE.md content\n<!-- grunt:end -->\nafter\n",
|
|
227
|
+
);
|
|
228
|
+
expect(fs.existsSync(path.join(dest, "CLAUDE.grunt.md"))).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("mergeClaudeSettings", () => {
|
|
233
|
+
it("dest .claude/settings.json absent: plain copy", () => {
|
|
234
|
+
const pkgRoot = stubPkgRoot();
|
|
235
|
+
const destRoot = tmp("settings-absent-");
|
|
236
|
+
mergeClaudeSettings(destRoot, pkgRoot);
|
|
237
|
+
const out = JSON.parse(
|
|
238
|
+
fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
|
|
239
|
+
);
|
|
240
|
+
expect(out).toEqual(SRC_CLAUDE_SETTINGS);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("malformed dest JSON throws with the path in the message", () => {
|
|
244
|
+
const pkgRoot = stubPkgRoot();
|
|
245
|
+
const destRoot = tmp("settings-malformed-");
|
|
246
|
+
const destSettingsPath = path.join(destRoot, ".claude", "settings.json");
|
|
247
|
+
fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
|
|
248
|
+
fs.writeFileSync(destSettingsPath, "{ not valid json");
|
|
249
|
+
expect(() => mergeClaudeSettings(destRoot, pkgRoot)).toThrowError(
|
|
250
|
+
new RegExp(destSettingsPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("consumer hook group with only OWNED commands is replaced", () => {
|
|
255
|
+
const pkgRoot = stubPkgRoot();
|
|
256
|
+
const destRoot = tmp("settings-owned-");
|
|
257
|
+
fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
|
|
258
|
+
fs.writeFileSync(
|
|
259
|
+
path.join(destRoot, ".claude", "settings.json"),
|
|
260
|
+
JSON.stringify({
|
|
261
|
+
permissions: { deny: ["Agent(Explore)"] },
|
|
262
|
+
hooks: {
|
|
263
|
+
PreToolUse: [
|
|
264
|
+
{
|
|
265
|
+
matcher: "spawn",
|
|
266
|
+
hooks: [
|
|
267
|
+
{
|
|
268
|
+
type: "command",
|
|
269
|
+
command: 'node "${OLD_ROOT}/scripts/scrub-spawn-prompt.mjs"',
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
enableAllProjectMcpServers: true,
|
|
276
|
+
enabledMcpjsonServers: ["stale"],
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
mergeClaudeSettings(destRoot, pkgRoot);
|
|
280
|
+
const out = JSON.parse(
|
|
281
|
+
fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
|
|
282
|
+
);
|
|
283
|
+
expect(out.hooks.PreToolUse).toEqual(SRC_CLAUDE_SETTINGS.hooks.PreToolUse);
|
|
284
|
+
expect(out.hooks.Stop).toEqual(SRC_CLAUDE_SETTINGS.hooks.Stop);
|
|
285
|
+
expect(out.enableAllProjectMcpServers).toBe(false);
|
|
286
|
+
expect(out.enabledMcpjsonServers).toEqual([]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("consumer hook group with only consumer commands survives", () => {
|
|
290
|
+
const pkgRoot = stubPkgRoot();
|
|
291
|
+
const destRoot = tmp("settings-consumer-");
|
|
292
|
+
fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
|
|
293
|
+
const consumerGroup = {
|
|
294
|
+
matcher: "custom",
|
|
295
|
+
hooks: [{ type: "command", command: "node scripts/my-custom-hook.mjs" }],
|
|
296
|
+
};
|
|
297
|
+
fs.writeFileSync(
|
|
298
|
+
path.join(destRoot, ".claude", "settings.json"),
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
permissions: { deny: ["Agent(Explore)"] },
|
|
301
|
+
hooks: { PreToolUse: [consumerGroup] },
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
mergeClaudeSettings(destRoot, pkgRoot);
|
|
305
|
+
const out = JSON.parse(
|
|
306
|
+
fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
|
|
307
|
+
);
|
|
308
|
+
expect(out.hooks.PreToolUse).toEqual([consumerGroup, ...SRC_CLAUDE_SETTINGS.hooks.PreToolUse]);
|
|
309
|
+
// event absent from dest entirely still gets appended
|
|
310
|
+
expect(out.hooks.Stop).toEqual(SRC_CLAUDE_SETTINGS.hooks.Stop);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("MIXED group (one OWNED + one consumer command) survives intact", () => {
|
|
314
|
+
const pkgRoot = stubPkgRoot();
|
|
315
|
+
const destRoot = tmp("settings-mixed-");
|
|
316
|
+
fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
|
|
317
|
+
const mixedGroup = {
|
|
318
|
+
matcher: "mixed",
|
|
319
|
+
hooks: [
|
|
320
|
+
{ type: "command", command: 'node "${OLD_ROOT}/scripts/gate-fat-tools.mjs"' },
|
|
321
|
+
{ type: "command", command: "node scripts/my-custom-hook.mjs" },
|
|
322
|
+
],
|
|
323
|
+
};
|
|
324
|
+
fs.writeFileSync(
|
|
325
|
+
path.join(destRoot, ".claude", "settings.json"),
|
|
326
|
+
JSON.stringify({
|
|
327
|
+
permissions: { deny: [] },
|
|
328
|
+
hooks: { PreToolUse: [mixedGroup] },
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
mergeClaudeSettings(destRoot, pkgRoot);
|
|
332
|
+
const out = JSON.parse(
|
|
333
|
+
fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
|
|
334
|
+
);
|
|
335
|
+
expect(out.hooks.PreToolUse).toEqual([mixedGroup, ...SRC_CLAUDE_SETTINGS.hooks.PreToolUse]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("permissions.deny union does not grow on a second run (idempotency)", () => {
|
|
339
|
+
const pkgRoot = stubPkgRoot();
|
|
340
|
+
const destRoot = tmp("settings-idempotent-");
|
|
341
|
+
fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
|
|
342
|
+
fs.writeFileSync(
|
|
343
|
+
path.join(destRoot, ".claude", "settings.json"),
|
|
344
|
+
JSON.stringify({
|
|
345
|
+
permissions: { deny: ["Agent(Explore)", "Agent(orchestrator)"] },
|
|
346
|
+
hooks: {},
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
mergeClaudeSettings(destRoot, pkgRoot);
|
|
350
|
+
const first = JSON.parse(
|
|
351
|
+
fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
|
|
352
|
+
);
|
|
353
|
+
mergeClaudeSettings(destRoot, pkgRoot);
|
|
354
|
+
const second = JSON.parse(
|
|
355
|
+
fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
|
|
356
|
+
);
|
|
357
|
+
expect(second.permissions.deny).toEqual(first.permissions.deny);
|
|
358
|
+
expect(second.permissions.deny).toEqual([
|
|
359
|
+
"Agent(Explore)",
|
|
360
|
+
"Agent(orchestrator)",
|
|
361
|
+
"mcp__*",
|
|
362
|
+
]);
|
|
363
|
+
expect(second.permissions.allow).toEqual(first.permissions.allow);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("unknown consumer top-level key preserved; missing hooks/permissions default to empty", () => {
|
|
367
|
+
const pkgRoot = stubPkgRoot();
|
|
368
|
+
const destRoot = tmp("settings-unknown-key-");
|
|
369
|
+
fs.mkdirSync(path.join(destRoot, ".claude"), { recursive: true });
|
|
370
|
+
fs.writeFileSync(
|
|
371
|
+
path.join(destRoot, ".claude", "settings.json"),
|
|
372
|
+
JSON.stringify({ someCustomFlag: true }),
|
|
373
|
+
);
|
|
374
|
+
mergeClaudeSettings(destRoot, pkgRoot);
|
|
375
|
+
const out = JSON.parse(
|
|
376
|
+
fs.readFileSync(path.join(destRoot, ".claude", "settings.json"), "utf8"),
|
|
377
|
+
);
|
|
378
|
+
expect(out.someCustomFlag).toBe(true);
|
|
379
|
+
expect(out.hooks).toEqual(SRC_CLAUDE_SETTINGS.hooks);
|
|
380
|
+
expect(out.permissions.deny).toEqual(SRC_CLAUDE_SETTINGS.permissions.deny);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
135
384
|
describe("mergePackageJson", () => {
|
|
136
385
|
it("creates {} dest and merges scripts skipping test, injects deps, sorts keys", () => {
|
|
137
386
|
const pkgRoot = stubPkgRoot();
|
|
@@ -208,20 +457,37 @@ describe("init", () => {
|
|
|
208
457
|
expect(fs.readFileSync(path.join(pkgRoot, "scripts", "telemetry.mjs"), "utf8")).toBe(
|
|
209
458
|
"telemetry.mjs",
|
|
210
459
|
);
|
|
460
|
+
// self-skip: markdown/settings stay exactly as-is, no sentinel wrap
|
|
461
|
+
expect(fs.readFileSync(path.join(pkgRoot, "CLAUDE.md"), "utf8")).toBe("CLAUDE.md content");
|
|
462
|
+
const settings = JSON.parse(
|
|
463
|
+
fs.readFileSync(path.join(pkgRoot, ".claude", "settings.json"), "utf8"),
|
|
464
|
+
);
|
|
465
|
+
expect(settings).toEqual(SRC_CLAUDE_SETTINGS);
|
|
211
466
|
});
|
|
212
467
|
|
|
213
|
-
it("non-self: file vs dir product scripts, then npm pipeline in order", () => {
|
|
468
|
+
it("non-self: file vs dir product scripts, guarded md + settings merge, then npm pipeline in order", () => {
|
|
214
469
|
const pkgRoot = stubPkgRoot();
|
|
215
470
|
const dest = tmp("grunt-dest-");
|
|
216
471
|
const exec = vi.fn();
|
|
217
472
|
init(dest, { pkgRoot, execFileSync: exec });
|
|
218
473
|
|
|
219
474
|
for (const d of COPY_DIRS) {
|
|
475
|
+
if (d === ".claude") continue;
|
|
220
476
|
expect(fs.readFileSync(path.join(dest, d, "marker"), "utf8")).toBe(d);
|
|
221
477
|
}
|
|
222
|
-
|
|
223
|
-
|
|
478
|
+
expect(fs.readFileSync(path.join(dest, ".claude", "marker"), "utf8")).toBe(".claude");
|
|
479
|
+
const settings = JSON.parse(
|
|
480
|
+
fs.readFileSync(path.join(dest, ".claude", "settings.json"), "utf8"),
|
|
481
|
+
);
|
|
482
|
+
expect(settings).toEqual(SRC_CLAUDE_SETTINGS);
|
|
483
|
+
|
|
484
|
+
for (const f of GUARDED_MD_FILES) {
|
|
485
|
+
expect(fs.readFileSync(path.join(dest, f), "utf8")).toBe(
|
|
486
|
+
`<!-- grunt:begin -->\n${f} content\n<!-- grunt:end -->\n`,
|
|
487
|
+
);
|
|
224
488
|
}
|
|
489
|
+
expect(fs.existsSync(path.join(dest, ".mcp.json"))).toBe(false);
|
|
490
|
+
|
|
225
491
|
for (const name of PRODUCT_FILES) {
|
|
226
492
|
expect(fs.statSync(path.join(dest, "scripts", name)).isFile()).toBe(true);
|
|
227
493
|
}
|
|
@@ -245,6 +511,36 @@ describe("init", () => {
|
|
|
245
511
|
}
|
|
246
512
|
});
|
|
247
513
|
|
|
514
|
+
it("re-running init on an already-initialized dest merges settings.json instead of clobbering", () => {
|
|
515
|
+
const pkgRoot = stubPkgRoot();
|
|
516
|
+
const dest = tmp("grunt-rerun-");
|
|
517
|
+
const exec = vi.fn();
|
|
518
|
+
fs.mkdirSync(path.join(dest, ".claude"), { recursive: true });
|
|
519
|
+
const consumerGroup = {
|
|
520
|
+
matcher: "custom",
|
|
521
|
+
hooks: [{ type: "command", command: "node scripts/my-custom-hook.mjs" }],
|
|
522
|
+
};
|
|
523
|
+
fs.writeFileSync(
|
|
524
|
+
path.join(dest, ".claude", "settings.json"),
|
|
525
|
+
JSON.stringify({
|
|
526
|
+
permissions: { deny: ["Agent(Explore)"] },
|
|
527
|
+
hooks: { PreToolUse: [consumerGroup] },
|
|
528
|
+
someCustomFlag: true,
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
init(dest, { pkgRoot, execFileSync: exec });
|
|
532
|
+
const settings = JSON.parse(
|
|
533
|
+
fs.readFileSync(path.join(dest, ".claude", "settings.json"), "utf8"),
|
|
534
|
+
);
|
|
535
|
+
expect(settings.someCustomFlag).toBe(true);
|
|
536
|
+
expect(settings.hooks.PreToolUse).toEqual([
|
|
537
|
+
consumerGroup,
|
|
538
|
+
...SRC_CLAUDE_SETTINGS.hooks.PreToolUse,
|
|
539
|
+
]);
|
|
540
|
+
expect(settings.permissions.deny).toContain("Agent(Explore)");
|
|
541
|
+
expect(settings.permissions.deny).toContain("mcp__*");
|
|
542
|
+
});
|
|
543
|
+
|
|
248
544
|
it("omitted execFileSync uses default; self-skip never invokes it", () => {
|
|
249
545
|
const pkgRoot = stubPkgRoot();
|
|
250
546
|
init(pkgRoot, { pkgRoot });
|
|
@@ -258,6 +554,7 @@ describe("init", () => {
|
|
|
258
554
|
init(dest, { execFileSync: exec });
|
|
259
555
|
expect(fs.existsSync(path.join(dest, "AGENTS.md"))).toBe(true);
|
|
260
556
|
expect(fs.existsSync(path.join(dest, "scripts", "scrub-text"))).toBe(true);
|
|
557
|
+
expect(fs.existsSync(path.join(dest, ".claude", "settings.json"))).toBe(true);
|
|
261
558
|
expect(exec.mock.calls.map((c) => c[1])).toEqual([
|
|
262
559
|
["install"],
|
|
263
560
|
["run", "rulesync:generate"],
|