@lovrozagar/grunt 0.4.0 → 0.4.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.
Files changed (50) hide show
  1. package/.agents/agents/grunt.md +2 -2
  2. package/.agents/agents/implementer.md +3 -2
  3. package/.agents/agents/orchestrator.md +17 -11
  4. package/.agents/agents/thinker.md +7 -2
  5. package/.agents/skills/explain/SKILL.md +2 -0
  6. package/.agents/skills/handoff/SKILL.md +1 -1
  7. package/.agents/skills/solo/SKILL.md +1 -1
  8. package/.claude/agents/grunt.md +2 -2
  9. package/.claude/agents/implementer.md +5 -2
  10. package/.claude/agents/orchestrator.md +17 -11
  11. package/.claude/agents/thinker.md +9 -2
  12. package/.claude/skills/explain/SKILL.md +2 -0
  13. package/.claude/skills/handoff/SKILL.md +1 -1
  14. package/.claude/skills/solo/SKILL.md +1 -1
  15. package/.codex/agents/grunt.toml +2 -2
  16. package/.codex/agents/implementer.toml +3 -2
  17. package/.codex/agents/orchestrator.toml +17 -11
  18. package/.codex/agents/thinker.toml +7 -2
  19. package/.grok/agents/grunt.md +2 -2
  20. package/.grok/agents/implementer.md +3 -2
  21. package/.grok/agents/orchestrator.md +17 -11
  22. package/.grok/agents/thinker.md +7 -2
  23. package/.grok/hooks/orchestrate-parent.js +28 -110
  24. package/.grok/skills/explain/SKILL.md +2 -0
  25. package/.grok/skills/handoff/SKILL.md +1 -1
  26. package/.grok/skills/solo/SKILL.md +1 -1
  27. package/.rulesync/reference/cascade.md +12 -6
  28. package/.rulesync/reference/hooks.md +4 -4
  29. package/.rulesync/reference/map.md +2 -3
  30. package/.rulesync/reference/output.md +38 -1
  31. package/.rulesync/rules/CLAUDE.md +18 -12
  32. package/.rulesync/rules/overview.md +18 -12
  33. package/.rulesync/skills/explain/SKILL.md +2 -0
  34. package/.rulesync/skills/handoff/SKILL.md +1 -1
  35. package/.rulesync/skills/solo/SKILL.md +1 -1
  36. package/.rulesync/subagents/grunt.md +2 -2
  37. package/.rulesync/subagents/implementer.md +3 -2
  38. package/.rulesync/subagents/orchestrator.md +17 -11
  39. package/.rulesync/subagents/thinker.md +7 -2
  40. package/AGENTS.md +20 -12
  41. package/CHANGELOG.md +187 -0
  42. package/CLAUDE.md +20 -12
  43. package/README.md +32 -15
  44. package/cli/init.mjs +195 -29
  45. package/cli/init.test.ts +458 -43
  46. package/package.json +11 -7
  47. package/scripts/emit-gemini.mjs +7 -1
  48. package/scripts/grunt-job.mjs +0 -9
  49. package/scripts/guarded-roots.mjs +83 -0
  50. package/scripts/telemetry.mjs +0 -38
package/cli/init.mjs CHANGED
@@ -9,6 +9,7 @@ const PRODUCT_SCRIPTS = [
9
9
  "check-globals.mjs",
10
10
  "emit-agent-shell-tools.mjs",
11
11
  "emit-gemini.mjs",
12
+ "guarded-roots.mjs",
12
13
  "emit-mcp-policy.mjs",
13
14
  "gate-fat-tools.mjs",
14
15
  "hooks-union.mjs",
@@ -20,7 +21,6 @@ const PRODUCT_SCRIPTS = [
20
21
  "scrub-spawn-prompt.mjs",
21
22
  "scrub-text-lib.mjs",
22
23
  "sync-global-settings.mjs",
23
- "telemetry.mjs",
24
24
  "browser.mjs",
25
25
  "doctor.mjs",
26
26
  "scrub-text",
@@ -28,12 +28,16 @@ const PRODUCT_SCRIPTS = [
28
28
 
29
29
  const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"]
30
30
  const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"]
31
+ export const GUARDED_ROOT_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]
31
32
  // .mcp.json is intentionally not copied here: scripts/emit-mcp-policy.mjs
32
33
  // (run via `rulesync:generate`, see mergeClaudeSettings ordering) owns and
33
34
  // merges it, so a plain-copy would just be clobbered/redundant.
34
35
  const OWNED_HOOK_FILES = ["scrub-spawn-prompt.mjs", "gate-fat-tools.mjs", "orchestrate-parent.js"]
35
- const SENTINEL_BEGIN = "<!-- grunt:begin -->"
36
- const SENTINEL_END = "<!-- grunt:end -->"
36
+ export const SENTINEL_BEGIN = "<!-- grunt:begin -->"
37
+ export const SENTINEL_END = "<!-- grunt:end -->"
38
+ export const MAX_GUARDED_MARKDOWN_BYTES = 2 * 1024 * 1024
39
+ const GRUNT_REGION_RE = /<!-- grunt:begin -->\r?\n?[\s\S]*?<!-- grunt:end -->\r?\n?/g
40
+ const GRUNT_INTERIOR_RE = /<!-- grunt:begin -->\r?\n?([\s\S]*?)<!-- grunt:end -->/g
37
41
 
38
42
  function sortKeys(obj) {
39
43
  return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]))
@@ -69,43 +73,208 @@ function destHasGruntSentinel(dest) {
69
73
 
70
74
  export function destAlreadyInited(dest) {
71
75
  return (
72
- fs.existsSync(path.join(dest, "scripts", "telemetry.mjs")) ||
73
76
  fs.existsSync(path.join(dest, ".grok", "hooks", "orchestrate-parent.js")) ||
74
77
  fs.existsSync(path.join(dest, ".rulesync"))
75
78
  )
76
79
  }
77
80
 
78
81
  export function shouldAutoSkipGlobals(dest) {
79
- return destHasGruntSentinel(dest) || fs.existsSync(path.join(dest, "scripts", "telemetry.mjs"))
82
+ return destHasGruntSentinel(dest)
83
+ }
84
+
85
+ function detectNewline(text) {
86
+ return String(text).includes("\r\n") ? "\r\n" : "\n"
87
+ }
88
+
89
+ function toNewline(text, nl) {
90
+ return String(text).replace(/\r\n/g, "\n").replace(/\n/g, nl)
91
+ }
92
+
93
+ function trimTrailingNewlines(text) {
94
+ return String(text).replace(/(?:\r?\n)+$/, "")
95
+ }
96
+
97
+ function normEq(a, b) {
98
+ return trimTrailingNewlines(String(a).replace(/\r\n/g, "\n")) ===
99
+ trimTrailingNewlines(String(b).replace(/\r\n/g, "\n"))
100
+ }
101
+
102
+ function cloneRe(re) {
103
+ return new RegExp(re.source, re.flags)
104
+ }
105
+
106
+ function inspectGuardedBuffer(buf) {
107
+ if (buf.length > MAX_GUARDED_MARKDOWN_BYTES) return "huge"
108
+ if (buf.includes(0)) return "binary"
109
+ return null
110
+ }
111
+
112
+ export function extractUserMarkdown(text) {
113
+ return String(text).replace(cloneRe(GRUNT_REGION_RE), "")
114
+ }
115
+
116
+ export function extractGruntBody(text) {
117
+ const parts = []
118
+ const re = cloneRe(GRUNT_INTERIOR_RE)
119
+ let m
120
+ while ((m = re.exec(String(text)))) {
121
+ parts.push(trimTrailingNewlines(m[1]))
122
+ }
123
+ if (!parts.length) return null
124
+ return parts.join("\n")
125
+ }
126
+
127
+ function userRemainderIsBlank(user) {
128
+ return String(user).replace(/\r\n/g, "\n").replace(/\n/g, "") === ""
129
+ }
130
+
131
+ export function composeGuardedMarkdown(gruntBody, userRemainder, nl = "\n") {
132
+ const body = toNewline(trimTrailingNewlines(gruntBody), nl)
133
+ const block = `${SENTINEL_BEGIN}${nl}${body}${nl}${SENTINEL_END}${nl}`
134
+ const user = userRemainder == null ? "" : String(userRemainder)
135
+ if (!user || userRemainderIsBlank(user)) return block
136
+ return block + toNewline(user, nl)
137
+ }
138
+
139
+ export function mergeGuardedContent(existingText, gruntBody) {
140
+ const existing = existingText == null ? "" : String(existingText)
141
+ const nl = existing.includes("\r\n") || String(gruntBody).includes("\r\n")
142
+ ? detectNewline(existing || gruntBody)
143
+ : "\n"
144
+ if (!existing) return composeGuardedMarkdown(gruntBody, "", nl)
145
+ const hadSentinel = existing.includes(SENTINEL_BEGIN) && existing.includes(SENTINEL_END)
146
+ const user = extractUserMarkdown(existing)
147
+ if (!hadSentinel && normEq(existing, gruntBody)) {
148
+ return composeGuardedMarkdown(gruntBody, "", nl)
149
+ }
150
+ return composeGuardedMarkdown(gruntBody, user, nl)
151
+ }
152
+
153
+ export function writeMergedGuardedFile(destPath, gruntBody) {
154
+ if (!fs.existsSync(destPath)) {
155
+ fs.writeFileSync(destPath, mergeGuardedContent("", gruntBody))
156
+ return
157
+ }
158
+ const buf = fs.readFileSync(destPath)
159
+ if (inspectGuardedBuffer(buf)) return "aborted-unsafe"
160
+ fs.writeFileSync(destPath, mergeGuardedContent(buf.toString("utf8"), gruntBody))
161
+ }
162
+
163
+ export function guardedMarkdownDrift(destPath, gruntBody) {
164
+ if (!fs.existsSync(destPath)) return true
165
+ const buf = fs.readFileSync(destPath)
166
+ if (inspectGuardedBuffer(buf)) return true
167
+ const text = buf.toString("utf8")
168
+ const interior = extractGruntBody(text)
169
+ if (interior == null) return !normEq(text, gruntBody)
170
+ return !normEq(interior, gruntBody)
80
171
  }
81
172
 
82
173
  export function mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited = false } = {}) {
174
+ void alreadyInited
83
175
  const src = path.join(pkgRoot, file)
84
176
  const d = path.join(dest, file)
85
177
  if (samePath(src, d)) return
86
178
  const srcText = fs.readFileSync(src, "utf8")
179
+ return writeMergedGuardedFile(d, srcText)
180
+ }
87
181
 
88
- if (!fs.existsSync(d)) {
89
- fs.writeFileSync(d, `${SENTINEL_BEGIN}\n${srcText}\n${SENTINEL_END}\n`)
90
- return
182
+ export function snapshotGuardedRoots(root) {
183
+ const snap = {}
184
+ for (const file of GUARDED_ROOT_FILES) {
185
+ const p = path.join(root, file)
186
+ if (!fs.existsSync(p)) continue
187
+ const buf = fs.readFileSync(p)
188
+ const unsafe = inspectGuardedBuffer(buf)
189
+ if (unsafe) {
190
+ snap[file] = { unsafe, rawBuf: buf }
191
+ continue
192
+ }
193
+ const raw = buf.toString("utf8")
194
+ snap[file] = { raw, user: extractUserMarkdown(raw) }
91
195
  }
196
+ return snap
197
+ }
92
198
 
93
- const destText = fs.readFileSync(d, "utf8")
94
- const hasSentinel = [SENTINEL_BEGIN, SENTINEL_END].every((marker) => destText.includes(marker))
199
+ export function remergeGuardedRoots(root, snap) {
200
+ for (const file of GUARDED_ROOT_FILES) {
201
+ const p = path.join(root, file)
202
+ const rec = snap[file]
203
+ if (rec?.unsafe) {
204
+ fs.writeFileSync(p, rec.rawBuf)
205
+ continue
206
+ }
207
+ if (!fs.existsSync(p)) {
208
+ if (rec?.raw != null) fs.writeFileSync(p, rec.raw)
209
+ continue
210
+ }
211
+ const buf = fs.readFileSync(p)
212
+ if (inspectGuardedBuffer(buf)) {
213
+ if (rec?.raw != null) fs.writeFileSync(p, rec.raw)
214
+ continue
215
+ }
216
+ const current = buf.toString("utf8")
217
+ if (rec && current === rec.raw) continue
218
+ const gruntBody = extractGruntBody(current) ?? current
219
+ const nl = detectNewline((rec && rec.raw) || current)
220
+ let user = rec ? rec.user : extractUserMarkdown(current)
221
+ if (rec && !rec.raw.includes(SENTINEL_BEGIN) && normEq(rec.raw, gruntBody)) user = ""
222
+ if (!rec && extractGruntBody(current) == null && normEq(current, gruntBody)) user = ""
223
+ fs.writeFileSync(p, composeGuardedMarkdown(gruntBody, user, nl))
224
+ }
225
+ }
95
226
 
96
- if (!hasSentinel) {
97
- if (alreadyInited) return "skipped-side-file"
98
- const gruntFile = file.replace(/\.md$/, ".grunt.md")
99
- fs.writeFileSync(path.join(dest, gruntFile), srcText)
100
- console.log(`${file} exists without grunt sentinel; wrote ${gruntFile} instead (original untouched)`)
227
+ export function healGuardedRootFile(root, file, snap) {
228
+ if (!GUARDED_ROOT_FILES.includes(file)) return
229
+ const p = path.join(root, file)
230
+ const rec = snap[file]
231
+ if (!fs.existsSync(p)) return
232
+ if (rec?.unsafe) {
233
+ fs.writeFileSync(p, rec.rawBuf)
234
+ return
235
+ }
236
+ const buf = fs.readFileSync(p)
237
+ if (inspectGuardedBuffer(buf)) {
238
+ if (rec?.raw != null) fs.writeFileSync(p, rec.raw)
101
239
  return
102
240
  }
241
+ const current = buf.toString("utf8")
242
+ const interior = extractGruntBody(current)
243
+ if (interior != null) {
244
+ snap[file] = { raw: current, user: extractUserMarkdown(current) }
245
+ return
246
+ }
247
+ if (rec && current === rec.raw) return
248
+ const gruntBody = current
249
+ const nl = detectNewline((rec && rec.raw) || current)
250
+ let user = rec ? rec.user : extractUserMarkdown(current)
251
+ if (rec && !rec.raw.includes(SENTINEL_BEGIN) && normEq(rec.raw, gruntBody)) user = ""
252
+ if (!rec && normEq(current, gruntBody)) user = ""
253
+ const out = composeGuardedMarkdown(gruntBody, user, nl)
254
+ fs.writeFileSync(p, out)
255
+ snap[file] = { raw: out, user }
256
+ }
103
257
 
104
- const beginIdx = destText.indexOf(SENTINEL_BEGIN)
105
- const endIdx = destText.indexOf(SENTINEL_END)
106
- const before = destText.slice(0, beginIdx)
107
- const after = destText.slice(endIdx + SENTINEL_END.length)
108
- fs.writeFileSync(d, `${before}${SENTINEL_BEGIN}\n${srcText}\n${SENTINEL_END}${after}`)
258
+ export function withGuardedCheckInteriors(root, fn) {
259
+ const snap = snapshotGuardedRoots(root)
260
+ try {
261
+ for (const file of GUARDED_ROOT_FILES) {
262
+ const rec = snap[file]
263
+ if (!rec || rec.unsafe) continue
264
+ const body = extractGruntBody(rec.raw)
265
+ if (body == null) continue
266
+ const nl = detectNewline(rec.raw)
267
+ fs.writeFileSync(path.join(root, file), `${toNewline(trimTrailingNewlines(body), nl)}${nl}`)
268
+ }
269
+ return fn()
270
+ } finally {
271
+ for (const file of GUARDED_ROOT_FILES) {
272
+ const rec = snap[file]
273
+ if (!rec) continue
274
+ if (rec.unsafe) fs.writeFileSync(path.join(root, file), rec.rawBuf)
275
+ else fs.writeFileSync(path.join(root, file), rec.raw)
276
+ }
277
+ }
109
278
  }
110
279
 
111
280
  export function mergeClaudeSettings(destRoot, pkgRoot) {
@@ -222,7 +391,6 @@ export function mergePackageJson(dest, pkgRoot) {
222
391
  export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase } = {}) {
223
392
  dest = path.resolve(dest)
224
393
  const pkgRoot = path.resolve(pkgRootOpt ?? PKG_ROOT)
225
- const alreadyInited = destAlreadyInited(dest)
226
394
  const skipGlobalsApply =
227
395
  applyGlobals === true ? false : skipGlobals || shouldAutoSkipGlobals(dest)
228
396
 
@@ -255,14 +423,8 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
255
423
  }
256
424
  }
257
425
 
258
- let skippedSideFile = false
259
426
  for (const file of GUARDED_MD_FILES) {
260
- if (mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited }) === "skipped-side-file") {
261
- skippedSideFile = true
262
- }
263
- }
264
- if (skippedSideFile) {
265
- console.log("AGENTS.md/CLAUDE.md lack markers and were left alone")
427
+ mergeGuardedMarkdown(dest, pkgRoot, file)
266
428
  }
267
429
 
268
430
  fs.mkdirSync(path.join(dest, "scripts"), { recursive: true })
@@ -291,9 +453,13 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
291
453
  exec("npm", args, { cwd: dest, stdio: "inherit" })
292
454
  }
293
455
  runNpm("install", ["install"])
456
+ const guardedSnap = snapshotGuardedRoots(dest)
294
457
  runNpm("generate", ["run", "rulesync:generate"])
458
+ remergeGuardedRoots(dest, guardedSnap)
295
459
  if (!skipGlobalsApply) {
296
460
  runNpm("sync-globals", ["run", "sync:globals:apply"])
297
461
  }
298
- runNpm("check", ["run", "rulesync:check"])
462
+ withGuardedCheckInteriors(dest, () => {
463
+ runNpm("check", ["run", "rulesync:check"])
464
+ })
299
465
  }