@lovrozagar/grunt 0.4.0 → 0.4.1

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 (46) 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 +25 -10
  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 +2 -2
  29. package/.rulesync/reference/output.md +38 -1
  30. package/.rulesync/rules/CLAUDE.md +17 -11
  31. package/.rulesync/rules/overview.md +17 -11
  32. package/.rulesync/skills/explain/SKILL.md +2 -0
  33. package/.rulesync/skills/handoff/SKILL.md +1 -1
  34. package/.rulesync/skills/solo/SKILL.md +1 -1
  35. package/.rulesync/subagents/grunt.md +2 -2
  36. package/.rulesync/subagents/implementer.md +3 -2
  37. package/.rulesync/subagents/orchestrator.md +17 -11
  38. package/.rulesync/subagents/thinker.md +7 -2
  39. package/AGENTS.md +19 -11
  40. package/CLAUDE.md +19 -11
  41. package/README.md +6 -6
  42. package/cli/init.mjs +194 -26
  43. package/cli/init.test.ts +442 -21
  44. package/package.json +9 -5
  45. package/scripts/emit-gemini.mjs +7 -1
  46. package/scripts/guarded-roots.mjs +83 -0
package/CLAUDE.md CHANGED
@@ -1,36 +1,44 @@
1
+ <!-- grunt:begin -->
1
2
  Voice: `.rulesync/reference/output.md` — cite once; apply every turn.
2
- en-US unless asked. every output maximal superterse. Fragments OK. Sacrifice grammar; keep meaning. Parent and child. Every turn, not final-only. no mid-turn chat; no narration while Agent runs; user-visible = legal `[role]:` + one-line echo only. Zero user-visible tokens before spawn/peek. In-flight host Stop → only `[orchestrator]: wait grunt`.
3
+ en-US unless asked. every output maximal superterse. Fragments OK. Sacrifice grammar; keep meaning. Parent and child. Every turn, not final-only. no mid-turn chat; no narration while Agent runs; user-visible = legal `[role]:` + one-line echo. Advise leftover: numbered pick each on own line after recap. Zero user-visible tokens before spawn/peek. In-flight host Stop → only `[orchestrator]: wait grunt`.
3
4
  All output exclude AI attribution, Co-Authored-By, trailers, generated markers.
4
5
  Protocol: `.rulesync/reference/cascade.md`. Do not paste.
5
6
  Always-do:
6
7
 
7
8
  | signal | next |
8
9
  |---|---|
9
- | create/change product files | small/simple or prompt-is-spec → implementer; large/complex + no spec → thinker then implementer. Never parent Write/Bash/Skill |
10
- | plan/spec ready + file writes remain | persist plan if needed; spawn implementer; do not recap-stop |
10
+ | create/change product files | grunt facts → thinker plan/recap-stop before writes; default thinker unless prompt-is-spec or small/simple → implementer. Thinker recap ≠ spec-ready; advise-stop until Implement pick / `/implement-plan` / explicit implement. Never parent Write/Bash/Skill |
11
+ | plan/spec ready + file writes remain | Implement pick / `/implement-plan` / explicit implement only; persist `/write-plan` path + continue. `ok`/`yes`/`y`/`continue` ≠ implement after conversational advise. Thinker recap alone = recap-stop |
11
12
  | tools/facts/git/test/mechanical write | spawn grunt |
12
13
  | world fact | spawn grunt `job:web` |
13
- | write defined solution | spawn implementer |
14
- | plan/deep reason | spawn thinker |
15
- | child returned | recap `[orchestrator]:` `[grunt]:` `[implementer]:` `[thinker]:` `[handoff]:` echo |
14
+ | write defined solution | spawn implementer; **blocked if advise-class stems present** unless prompt-is-spec / obvious comment-spec false+ |
15
+ | think / plan / advise / recommend / how / why / explain (ask us; not slash `/explain` not one-cmd/last-log/source-token) | spawn thinker; facts=grunt/`need:`; **no implementer this turn**. Repeat how/why/… + last [thinker] still visible + no new input/tools since → no spawn; echo last or /explain. Unsure → thinker |
16
+ | child returned | recap per output.md (echo=report not command). e.g. `[orchestrator]: wrote /abs/a` |
16
17
  | child `need:` | parse-need + parallel grunt; `resume_from` + new verdicts; max 3 |
17
18
  | ⚠ / validate / sim | spawn implementer with findings; do not recap; spawn; no parent-edit |
18
- | work remains | spawn; do not stop |
19
- | children done + no findings | recap; stop allowed only if no writes remain and spawn count > 0 when the user asked for a file change |
19
+ | work remains | spawn; do not stop. Advise recap ≠ leftover writes |
20
+ | children done + no findings | recap; advise-first stop wins. Else stop only if no writes remain and spawn count > 0 when the user asked for a file change |
20
21
  | `/parent` | spawn-first; hook last-ditch `parent-escape`; never Read/Bash |
21
22
  | `/handoff` | one-turn write `.tmp/grunt/handoffs/`; recap `[handoff]:` |
22
23
  | `/pickup` | spawn-first; grunt resolve if needed; never parent Read; not a mode |
23
- | `/write-plan` plan-only | recap + `next: /implement-plan` |
24
+ | `/write-plan` plan-only | recap; remainder `/implement-plan` (ask) |
24
25
  | `/explain` | spawn if facts/work; then human recap of child output; screenshot/visible=context no Read |
25
26
  | `/solo` | session escape only; stamp grunt-off-{sid}; else spawn-first |
26
27
  | `/cascade` | unlink solo stamp; spawn-first |
27
28
 
29
+ Precedence: slash → obvious fact/false+/reuse → advise-class row → prompt-is-spec → write. Advise-class + write-class → thinker first; implementer only after spec / `/implement-plan`.
30
+ False+: I think terraform plan /implement-plan /solo source tokens one-cmd how- last-log why.
31
+ Advise finals: numbered pick each on own line after tagged recap:
32
+ 1. Implement
33
+ 2. Tweak
34
+ Slash `/implement-plan` + explicit implement = Implement. `ok`/`yes`/`y`/`continue` ≠ implement after conversational advise.
28
35
  Parent = this file not `.claude/agents/orchestrator.md`. You do not talk. Spawn wait echo.
29
36
  Spawn only grunt|implementer|thinker. Never spawn orchestrator. Children never spawn.
30
- You do not talk. First token = spawn. Illegal tools (never consider never call): Read read_file Grep grep Glob list_dir Bash run_terminal_command view_file grep_search run_command. Not in toolkit. Hook deny = backstop not UX. Next=spawn not retry. Only /solo (stamp grunt-off-{sid} this session) escapes spawn workflow. /explain=voice+post-child recap never parent Read. No try-then-spawn. No parent probe. After child: legal tag + one-line echo only. Siblings still run: `[grunt]:` echo. No skip-spawn. /handoff = one-turn in-parent write to `.tmp/grunt/handoffs/`; recap `[handoff]:`. `/pickup` spawn-first; grunt resolve if needed; never parent Read; not a mode.
37
+ You do not talk. First token = spawn. Illegal tools (never consider never call): Read read_file Grep grep Glob list_dir Bash run_terminal_command view_file grep_search run_command. Not in toolkit. Hook deny = backstop not UX. Next=spawn not retry. Only /solo (stamp grunt-off-{sid} this session) escapes spawn workflow. /explain=voice+post-child recap never parent Read. No try-then-spawn. No parent probe. After child: legal tag + one-line recap; advise leftover numbered pick on following lines not crammed. Echo = report not command. Siblings still run: `[grunt]:` echo. No skip-spawn. /handoff = one-turn in-parent write to `.tmp/grunt/handoffs/`; recap `[handoff]:`. `/pickup` spawn-first; grunt resolve if needed; never parent Read; not a mode.
31
38
  `/parent` hook last-ditch only. Skills spawn-first. Only `/solo` escapes spawn workflow.
32
- 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.
39
+ grunt ← tools (facts/search/exec/git/web/test/low-reason mechanical write; world fact: `job: web` never memory). implementer ← write defined solution (spec or small/simple); blocked if advise-class stems unless prompt-is-spec/false+. thinker ← think/plan/advise/recommend/how/why/explain (default product change; no implementer this turn).
33
40
  Child prompt first sentence only: `You are {agent} subagent.` Then task + abs paths + verdicts only.
34
41
  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.
35
42
  `need:` JSON → parse-need + parallel grunt; one `resume_from` + new verdicts; max 3; no re-send task; no fresh-spawn.
36
43
  Child: this file does not apply; your agent file gates you. Never spawn.
44
+ <!-- grunt:end -->
package/README.md CHANGED
@@ -105,10 +105,10 @@ SoT: `.rulesync/subagents/{orchestrator grunt implementer thinker}.md`
105
105
 
106
106
  Emit: `.claude/` `.grok/` `.agents/` `.gemini/`
107
107
 
108
- - **orchestrator** (parent) — always spawn+prompt; user-facing only `[orchestrator]:` (or child role tag) one-line echo. `/parent` one-turn; `/handoff` writes `.tmp/grunt/handoffs/{serial}-{slug}-{stamp}.md`; `/pickup` spawn-first pickup (inverse of `/handoff`; not a mode). `/solo` session escape; `/cascade` restores it. Small/low router. First token spawn. No parent Read/Bash/Grep. Does not implement, plan, or fetch world facts
108
+ - **orchestrator** (parent) — always spawn+prompt; user-facing `[orchestrator]:` (or child role tag) one-line recap; advise leftover numbered pick each on own line after that recap (do not cram `1. Implement 2. Tweak` onto the recap line). `/parent` one-turn; `/handoff` writes `.tmp/grunt/handoffs/{serial}-{slug}-{stamp}.md`; `/pickup` spawn-first pickup (inverse of `/handoff`; not a mode). `/solo` session escape; `/cascade` restores it. Small/low router. First token spawn. No parent Read/Bash/Grep. Does not implement, plan, or fetch world facts
109
109
  - **grunt** — tools: facts/search/exec/git/web/test/low-reason mechanical write. Isolation `verdict:`. Never feature solution. Never spawn. World fact: `job: web`
110
110
  - **implementer** — write already-defined solution on allowlisted paths. TDD when spec/plan says tests. Validate + sim after write. Fat dumps via `need:`. Never spawn. Never plan. Id is **implementer** (not Implementor)
111
- - **thinker** — plan/deep reason; edge cases. Read-only named-file Read of prompt SSOT; trees/search/exec/web/test → `need:`. Never spawn. No bash. Never implement
111
+ - **thinker** — think/plan/advise/recommend/how/why/explain; unsure→thinker; cheap false+; edge cases. Read-only named-file Read of prompt SSOT; trees/search/exec/web/test → `need:`. Never spawn. No bash. Never implement
112
112
 
113
113
  Children never spawn. Spawn only grunt|implementer|thinker. Omit model on spawn; frontmatter on agent files picks haiku/sonnet/opus vs grok-4.5 / grok-4.6 / other hosts. Voice: `.rulesync/reference/output.md`. Protocol: `.rulesync/reference/cascade.md`. `AGENTS.md`/`CLAUDE.md` spawn-first. `GEMINI.md` → `@AGENTS.md`. Goals: synced configs across grok build claude code codex gemini cli antigravity; max situational speed; superterse token savings; max/min reasoning by role.
114
114
 
@@ -123,7 +123,7 @@ Lightpanda-first session CLI: `node scripts/browser.mjs nav|snap|click|fill|shot
123
123
  Present under `.claude` / `.rulesync` / `.agents`:
124
124
 
125
125
  - `browser` `cascade` `commit` `commit-and-push` `commit-push` `commit-push-deploy` `commit-push-release` `explain` `handoff` `pickup` `parent` `solo`
126
- - Grok-only: `write-plan` `implement-plan` (`/write-plan` plan-only → `next: /implement-plan`)
126
+ - Grok-only: `write-plan` `implement-plan` (`/write-plan` plan-only → `next: /implement-plan`; advise unlock = pick **1. Implement**, not `ok`/`yes`/`go`; slash `/implement-plan` still valid alias)
127
127
 
128
128
  ## Generate
129
129
 
@@ -162,7 +162,7 @@ Same topology as the session recap; every legal edge labeled.
162
162
  ```
163
163
  USER
164
164
  │
165
- │ session in/out (parent only; tagged one-line recap)
165
+ │ session in/out (parent only; tagged one-line recap; advise leftover 1./2. own lines)
166
166
  │ TUI local; completion tokens from Model API (mixed)
167
167
  ▼
168
168
  ┌──────────────────────── CLI HOST (this process) ────────────────────────┐
@@ -251,7 +251,7 @@ Implementer or thinker that still needs a fat dump **stops** on a `need:` JSON l
251
251
  | node | inside host? | local vs AI-server | notes |
252
252
  | --- | --- | --- | --- |
253
253
  | User | no | mixed | Speaks only to parent session. Never a child edge. |
254
- | Session in/out / TUI | yes | mixed | TUI is local; recap tokens come from the model. Legal `[role]:` one-line echo only. |
254
+ | Session in/out / TUI | yes | mixed | TUI is local; recap tokens come from the model. Legal `[role]:` one-line recap; advise leftover numbered pick each on own line after (not crammed onto the recap line). |
255
255
  | CLI host process | yes (is the box) | local process | Grok Build / Claude Code / Codex / Gemini / Antigravity. Not a peer of another CLI. |
256
256
  | Parent orchestrator | yes | AI-server **turn** | Spawn-first low router. No parent Read/Bash/Grep. Lives in this session, not a sidecar. |
257
257
  | `grunt` sibling | yes | AI-server **turn** | Facts/tools/mechanical write. Never spawn. Never feature solution. |
@@ -349,7 +349,7 @@ npm i && npm test
349
349
 
350
350
  Keep these four flows only:
351
351
 
352
- 1. “Create me a react weather app” → thinker plan → implementer write → orchestrator recap
352
+ 1. “Create me a react weather app” → thinker plan/recap-stop + pick 1.Implement 2.Tweak → user picks 1 or `/implement-plan` → implementer write → recap
353
353
  2. “What is 2+2” → grunt → `[grunt]:` echo
354
354
  3. Marvel theatrical next → grunt `job:web` → recap
355
355
  4. `.logs` 3/6/2021 tag `framework bug` → grunt local search → recap
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",
@@ -28,12 +29,16 @@ const PRODUCT_SCRIPTS = [
28
29
 
29
30
  const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"]
30
31
  const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"]
32
+ export const GUARDED_ROOT_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]
31
33
  // .mcp.json is intentionally not copied here: scripts/emit-mcp-policy.mjs
32
34
  // (run via `rulesync:generate`, see mergeClaudeSettings ordering) owns and
33
35
  // merges it, so a plain-copy would just be clobbered/redundant.
34
36
  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 -->"
37
+ export const SENTINEL_BEGIN = "<!-- grunt:begin -->"
38
+ export const SENTINEL_END = "<!-- grunt:end -->"
39
+ export const MAX_GUARDED_MARKDOWN_BYTES = 2 * 1024 * 1024
40
+ const GRUNT_REGION_RE = /<!-- grunt:begin -->\r?\n?[\s\S]*?<!-- grunt:end -->\r?\n?/g
41
+ const GRUNT_INTERIOR_RE = /<!-- grunt:begin -->\r?\n?([\s\S]*?)<!-- grunt:end -->/g
37
42
 
38
43
  function sortKeys(obj) {
39
44
  return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]))
@@ -79,33 +84,199 @@ export function shouldAutoSkipGlobals(dest) {
79
84
  return destHasGruntSentinel(dest) || fs.existsSync(path.join(dest, "scripts", "telemetry.mjs"))
80
85
  }
81
86
 
87
+ function detectNewline(text) {
88
+ return String(text).includes("\r\n") ? "\r\n" : "\n"
89
+ }
90
+
91
+ function toNewline(text, nl) {
92
+ return String(text).replace(/\r\n/g, "\n").replace(/\n/g, nl)
93
+ }
94
+
95
+ function trimTrailingNewlines(text) {
96
+ return String(text).replace(/(?:\r?\n)+$/, "")
97
+ }
98
+
99
+ function normEq(a, b) {
100
+ return trimTrailingNewlines(String(a).replace(/\r\n/g, "\n")) ===
101
+ trimTrailingNewlines(String(b).replace(/\r\n/g, "\n"))
102
+ }
103
+
104
+ function cloneRe(re) {
105
+ return new RegExp(re.source, re.flags)
106
+ }
107
+
108
+ function inspectGuardedBuffer(buf) {
109
+ if (buf.length > MAX_GUARDED_MARKDOWN_BYTES) return "huge"
110
+ if (buf.includes(0)) return "binary"
111
+ return null
112
+ }
113
+
114
+ export function extractUserMarkdown(text) {
115
+ return String(text).replace(cloneRe(GRUNT_REGION_RE), "")
116
+ }
117
+
118
+ export function extractGruntBody(text) {
119
+ const parts = []
120
+ const re = cloneRe(GRUNT_INTERIOR_RE)
121
+ let m
122
+ while ((m = re.exec(String(text)))) {
123
+ parts.push(trimTrailingNewlines(m[1]))
124
+ }
125
+ if (!parts.length) return null
126
+ return parts.join("\n")
127
+ }
128
+
129
+ function userRemainderIsBlank(user) {
130
+ return String(user).replace(/\r\n/g, "\n").replace(/\n/g, "") === ""
131
+ }
132
+
133
+ export function composeGuardedMarkdown(gruntBody, userRemainder, nl = "\n") {
134
+ const body = toNewline(trimTrailingNewlines(gruntBody), nl)
135
+ const block = `${SENTINEL_BEGIN}${nl}${body}${nl}${SENTINEL_END}${nl}`
136
+ const user = userRemainder == null ? "" : String(userRemainder)
137
+ if (!user || userRemainderIsBlank(user)) return block
138
+ return block + toNewline(user, nl)
139
+ }
140
+
141
+ export function mergeGuardedContent(existingText, gruntBody) {
142
+ const existing = existingText == null ? "" : String(existingText)
143
+ const nl = existing.includes("\r\n") || String(gruntBody).includes("\r\n")
144
+ ? detectNewline(existing || gruntBody)
145
+ : "\n"
146
+ if (!existing) return composeGuardedMarkdown(gruntBody, "", nl)
147
+ const hadSentinel = existing.includes(SENTINEL_BEGIN) && existing.includes(SENTINEL_END)
148
+ const user = extractUserMarkdown(existing)
149
+ if (!hadSentinel && normEq(existing, gruntBody)) {
150
+ return composeGuardedMarkdown(gruntBody, "", nl)
151
+ }
152
+ return composeGuardedMarkdown(gruntBody, user, nl)
153
+ }
154
+
155
+ export function writeMergedGuardedFile(destPath, gruntBody) {
156
+ if (!fs.existsSync(destPath)) {
157
+ fs.writeFileSync(destPath, mergeGuardedContent("", gruntBody))
158
+ return
159
+ }
160
+ const buf = fs.readFileSync(destPath)
161
+ if (inspectGuardedBuffer(buf)) return "aborted-unsafe"
162
+ fs.writeFileSync(destPath, mergeGuardedContent(buf.toString("utf8"), gruntBody))
163
+ }
164
+
165
+ export function guardedMarkdownDrift(destPath, gruntBody) {
166
+ if (!fs.existsSync(destPath)) return true
167
+ const buf = fs.readFileSync(destPath)
168
+ if (inspectGuardedBuffer(buf)) return true
169
+ const text = buf.toString("utf8")
170
+ const interior = extractGruntBody(text)
171
+ if (interior == null) return !normEq(text, gruntBody)
172
+ return !normEq(interior, gruntBody)
173
+ }
174
+
82
175
  export function mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited = false } = {}) {
176
+ void alreadyInited
83
177
  const src = path.join(pkgRoot, file)
84
178
  const d = path.join(dest, file)
85
179
  if (samePath(src, d)) return
86
180
  const srcText = fs.readFileSync(src, "utf8")
181
+ return writeMergedGuardedFile(d, srcText)
182
+ }
87
183
 
88
- if (!fs.existsSync(d)) {
89
- fs.writeFileSync(d, `${SENTINEL_BEGIN}\n${srcText}\n${SENTINEL_END}\n`)
90
- return
184
+ export function snapshotGuardedRoots(root) {
185
+ const snap = {}
186
+ for (const file of GUARDED_ROOT_FILES) {
187
+ const p = path.join(root, file)
188
+ if (!fs.existsSync(p)) continue
189
+ const buf = fs.readFileSync(p)
190
+ const unsafe = inspectGuardedBuffer(buf)
191
+ if (unsafe) {
192
+ snap[file] = { unsafe, rawBuf: buf }
193
+ continue
194
+ }
195
+ const raw = buf.toString("utf8")
196
+ snap[file] = { raw, user: extractUserMarkdown(raw) }
91
197
  }
198
+ return snap
199
+ }
92
200
 
93
- const destText = fs.readFileSync(d, "utf8")
94
- const hasSentinel = [SENTINEL_BEGIN, SENTINEL_END].every((marker) => destText.includes(marker))
201
+ export function remergeGuardedRoots(root, snap) {
202
+ for (const file of GUARDED_ROOT_FILES) {
203
+ const p = path.join(root, file)
204
+ const rec = snap[file]
205
+ if (rec?.unsafe) {
206
+ fs.writeFileSync(p, rec.rawBuf)
207
+ continue
208
+ }
209
+ if (!fs.existsSync(p)) {
210
+ if (rec?.raw != null) fs.writeFileSync(p, rec.raw)
211
+ continue
212
+ }
213
+ const buf = fs.readFileSync(p)
214
+ if (inspectGuardedBuffer(buf)) {
215
+ if (rec?.raw != null) fs.writeFileSync(p, rec.raw)
216
+ continue
217
+ }
218
+ const current = buf.toString("utf8")
219
+ if (rec && current === rec.raw) continue
220
+ const gruntBody = extractGruntBody(current) ?? current
221
+ const nl = detectNewline((rec && rec.raw) || current)
222
+ let user = rec ? rec.user : extractUserMarkdown(current)
223
+ if (rec && !rec.raw.includes(SENTINEL_BEGIN) && normEq(rec.raw, gruntBody)) user = ""
224
+ if (!rec && extractGruntBody(current) == null && normEq(current, gruntBody)) user = ""
225
+ fs.writeFileSync(p, composeGuardedMarkdown(gruntBody, user, nl))
226
+ }
227
+ }
95
228
 
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)`)
229
+ export function healGuardedRootFile(root, file, snap) {
230
+ if (!GUARDED_ROOT_FILES.includes(file)) return
231
+ const p = path.join(root, file)
232
+ const rec = snap[file]
233
+ if (!fs.existsSync(p)) return
234
+ if (rec?.unsafe) {
235
+ fs.writeFileSync(p, rec.rawBuf)
236
+ return
237
+ }
238
+ const buf = fs.readFileSync(p)
239
+ if (inspectGuardedBuffer(buf)) {
240
+ if (rec?.raw != null) fs.writeFileSync(p, rec.raw)
101
241
  return
102
242
  }
243
+ const current = buf.toString("utf8")
244
+ const interior = extractGruntBody(current)
245
+ if (interior != null) {
246
+ snap[file] = { raw: current, user: extractUserMarkdown(current) }
247
+ return
248
+ }
249
+ if (rec && current === rec.raw) return
250
+ const gruntBody = current
251
+ const nl = detectNewline((rec && rec.raw) || current)
252
+ let user = rec ? rec.user : extractUserMarkdown(current)
253
+ if (rec && !rec.raw.includes(SENTINEL_BEGIN) && normEq(rec.raw, gruntBody)) user = ""
254
+ if (!rec && normEq(current, gruntBody)) user = ""
255
+ const out = composeGuardedMarkdown(gruntBody, user, nl)
256
+ fs.writeFileSync(p, out)
257
+ snap[file] = { raw: out, user }
258
+ }
103
259
 
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}`)
260
+ export function withGuardedCheckInteriors(root, fn) {
261
+ const snap = snapshotGuardedRoots(root)
262
+ try {
263
+ for (const file of GUARDED_ROOT_FILES) {
264
+ const rec = snap[file]
265
+ if (!rec || rec.unsafe) continue
266
+ const body = extractGruntBody(rec.raw)
267
+ if (body == null) continue
268
+ const nl = detectNewline(rec.raw)
269
+ fs.writeFileSync(path.join(root, file), `${toNewline(trimTrailingNewlines(body), nl)}${nl}`)
270
+ }
271
+ return fn()
272
+ } finally {
273
+ for (const file of GUARDED_ROOT_FILES) {
274
+ const rec = snap[file]
275
+ if (!rec) continue
276
+ if (rec.unsafe) fs.writeFileSync(path.join(root, file), rec.rawBuf)
277
+ else fs.writeFileSync(path.join(root, file), rec.raw)
278
+ }
279
+ }
109
280
  }
110
281
 
111
282
  export function mergeClaudeSettings(destRoot, pkgRoot) {
@@ -222,7 +393,6 @@ export function mergePackageJson(dest, pkgRoot) {
222
393
  export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase } = {}) {
223
394
  dest = path.resolve(dest)
224
395
  const pkgRoot = path.resolve(pkgRootOpt ?? PKG_ROOT)
225
- const alreadyInited = destAlreadyInited(dest)
226
396
  const skipGlobalsApply =
227
397
  applyGlobals === true ? false : skipGlobals || shouldAutoSkipGlobals(dest)
228
398
 
@@ -255,14 +425,8 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
255
425
  }
256
426
  }
257
427
 
258
- let skippedSideFile = false
259
428
  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")
429
+ mergeGuardedMarkdown(dest, pkgRoot, file)
266
430
  }
267
431
 
268
432
  fs.mkdirSync(path.join(dest, "scripts"), { recursive: true })
@@ -291,9 +455,13 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
291
455
  exec("npm", args, { cwd: dest, stdio: "inherit" })
292
456
  }
293
457
  runNpm("install", ["install"])
458
+ const guardedSnap = snapshotGuardedRoots(dest)
294
459
  runNpm("generate", ["run", "rulesync:generate"])
460
+ remergeGuardedRoots(dest, guardedSnap)
295
461
  if (!skipGlobalsApply) {
296
462
  runNpm("sync-globals", ["run", "sync:globals:apply"])
297
463
  }
298
- runNpm("check", ["run", "rulesync:check"])
464
+ withGuardedCheckInteriors(dest, () => {
465
+ runNpm("check", ["run", "rulesync:check"])
466
+ })
299
467
  }