@lovrozagar/grunt 0.3.11 → 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 (61) hide show
  1. package/.agents/agents/grunt.md +3 -2
  2. package/.agents/agents/implementer.md +4 -2
  3. package/.agents/agents/orchestrator.md +19 -12
  4. package/.agents/agents/thinker.md +7 -2
  5. package/.agents/skills/browser/SKILL.md +17 -0
  6. package/.agents/skills/explain/SKILL.md +2 -0
  7. package/.agents/skills/handoff/SKILL.md +3 -3
  8. package/.agents/skills/pickup/SKILL.md +64 -0
  9. package/.agents/skills/solo/SKILL.md +2 -2
  10. package/.claude/agents/grunt.md +3 -2
  11. package/.claude/agents/implementer.md +6 -2
  12. package/.claude/agents/orchestrator.md +19 -12
  13. package/.claude/agents/thinker.md +9 -2
  14. package/.claude/skills/browser/SKILL.md +17 -0
  15. package/.claude/skills/explain/SKILL.md +2 -0
  16. package/.claude/skills/handoff/SKILL.md +3 -3
  17. package/.claude/skills/pickup/SKILL.md +64 -0
  18. package/.claude/skills/solo/SKILL.md +2 -2
  19. package/.codex/agents/grunt.toml +3 -2
  20. package/.codex/agents/implementer.toml +4 -2
  21. package/.codex/agents/orchestrator.toml +19 -12
  22. package/.codex/agents/thinker.toml +7 -2
  23. package/.grok/agents/grunt.md +3 -2
  24. package/.grok/agents/implementer.md +4 -2
  25. package/.grok/agents/orchestrator.md +19 -12
  26. package/.grok/agents/thinker.md +7 -2
  27. package/.grok/hooks/orchestrate-parent.js +26 -10
  28. package/.grok/skills/browser/SKILL.md +17 -0
  29. package/.grok/skills/explain/SKILL.md +2 -0
  30. package/.grok/skills/handoff/SKILL.md +3 -3
  31. package/.grok/skills/pickup/SKILL.md +64 -0
  32. package/.grok/skills/solo/SKILL.md +2 -2
  33. package/.rulesync/reference/browser.md +59 -0
  34. package/.rulesync/reference/cascade.md +13 -6
  35. package/.rulesync/reference/hooks.md +2 -2
  36. package/.rulesync/reference/map.md +6 -3
  37. package/.rulesync/reference/output.md +38 -1
  38. package/.rulesync/reference/rtk.md +4 -1
  39. package/.rulesync/rules/CLAUDE.md +18 -11
  40. package/.rulesync/rules/overview.md +18 -11
  41. package/.rulesync/skills/browser/SKILL.md +19 -0
  42. package/.rulesync/skills/explain/SKILL.md +2 -0
  43. package/.rulesync/skills/handoff/SKILL.md +3 -3
  44. package/.rulesync/skills/pickup/SKILL.md +64 -0
  45. package/.rulesync/skills/solo/SKILL.md +2 -2
  46. package/.rulesync/subagents/grunt.md +3 -2
  47. package/.rulesync/subagents/implementer.md +4 -2
  48. package/.rulesync/subagents/orchestrator.md +19 -12
  49. package/.rulesync/subagents/thinker.md +7 -2
  50. package/AGENTS.md +20 -11
  51. package/CLAUDE.md +20 -11
  52. package/README.md +40 -10
  53. package/cli/grunt.mjs +2 -2
  54. package/cli/grunt.test.ts +3 -3
  55. package/cli/init.mjs +196 -26
  56. package/cli/init.test.ts +444 -21
  57. package/package.json +11 -4
  58. package/scripts/browser.mjs +764 -0
  59. package/scripts/doctor.mjs +252 -0
  60. package/scripts/emit-gemini.mjs +7 -1
  61. package/scripts/guarded-roots.mjs +83 -0
package/README.md CHANGED
@@ -5,6 +5,7 @@ Multi-agent orchestrator SoT — deep-merge rulesync trees product scripts and g
5
5
  OSS drop-in; merges existing configs; switches default provider flow to Grunt.
6
6
 
7
7
  - [Install](#install)
8
+ - [Prerequisites](#prerequisites)
8
9
  - [Usage](#usage)
9
10
  - [CLI](#cli)
10
11
  - [Init](#init)
@@ -33,6 +34,29 @@ npx @lovrozagar/grunt
33
34
  - Do not `npm test` as a consumer
34
35
  - Package: `@lovrozagar/grunt` `0.3.9` MIT · https://github.com/lovrozagar/grunt
35
36
 
37
+ ## Prerequisites
38
+
39
+ All OS. Print-only. Never auto-install.
40
+
41
+ ```
42
+ npx grunt doctor
43
+ node scripts/doctor.mjs
44
+ ```
45
+
46
+ Exit 1 if any required missing; 0 if all required ok. Optional `gh` reported only.
47
+
48
+ | tool | required | install |
49
+ | --- | --- | --- |
50
+ | node ≥22 + npm | yes | https://nodejs.org (≥22) · nvm / OS pkg · win `winget install OpenJS.NodeJS.LTS` |
51
+ | git | yes | linux `sudo apt install git` · mac `brew install git` · win `winget install Git.Git` |
52
+ | rtk | yes | linux/mac `curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh \| sh` or `brew install rtk` · win release zip `rtk.exe` on PATH or WSL curl ([docs](https://www.rtk-ai.app/docs/getting-started/installation/)) |
53
+ | rulesync | yes | `npm i -D rulesync` / npx (PATH or npx-able) |
54
+ | lightpanda | yes | `curl -fsSL https://pkg.lightpanda.io/install.sh \| bash` · mac `brew tap lightpanda-io/browser` · win WSL only |
55
+ | chromium-family | yes | linux `sudo apt install chromium` · mac `brew install --cask chromium` or `google-chrome` · win `winget install Google.Chrome` / `Microsoft.Edge` + PATH |
56
+ | gh | no | report only |
57
+
58
+ Rulesync schema doctor is separate: `npm run rulesync:doctor`.
59
+
36
60
  ## Usage
37
61
 
38
62
  - TTY no command → menu (init default; generate check sync-globals purge-mcps doctor help quit)
@@ -48,7 +72,7 @@ npx @lovrozagar/grunt
48
72
  - `check` → `npm run rulesync:check`
49
73
  - `sync-globals` → `npm run sync:globals` (dry-run); `--apply` → `sync:globals:apply`
50
74
  - `purge-mcps` → `npm run purge:global-mcps` (dry-run); `--apply` → `purge:global-mcps:apply`
51
- - `doctor` → `npm run rulesync:doctor`
75
+ - `doctor` → `npm run doctor` (`node scripts/doctor.mjs`). Rulesync schema: `npm run rulesync:doctor`
52
76
  - `help`
53
77
  - `version`
54
78
 
@@ -81,19 +105,25 @@ SoT: `.rulesync/subagents/{orchestrator grunt implementer thinker}.md`
81
105
 
82
106
  Emit: `.claude/` `.grok/` `.agents/` `.gemini/`
83
107
 
84
- - **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`. 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
85
109
  - **grunt** — tools: facts/search/exec/git/web/test/low-reason mechanical write. Isolation `verdict:`. Never feature solution. Never spawn. World fact: `job: web`
86
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)
87
- - **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
88
112
 
89
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.
90
114
 
115
+ ## Browser
116
+
117
+ Lightpanda-first session CLI: `node scripts/browser.mjs nav|snap|click|fill|shot|pdf|stop|doctor|ensure`. Zero MCP. Zero env knobs. Chromium only for `shot`/`pdf`/`trace`, Windows, missing Lightpanda, one probe-fail replay, or paint hosts. Session: `.tmp/grunt/browser/`. Spec: [`.rulesync/reference/browser.md`](.rulesync/reference/browser.md).
118
+
119
+ `node scripts/browser.mjs doctor` (alias `ensure`) runs the unified doctor (`scripts/doctor.mjs`). Install engines: [Prerequisites](#prerequisites).
120
+
91
121
  ## Skills
92
122
 
93
123
  Present under `.claude` / `.rulesync` / `.agents`:
94
124
 
95
- - `cascade` `commit` `commit-and-push` `commit-push` `commit-push-deploy` `commit-push-release` `explain` `handoff` `parent` `solo`
96
- - Grok-only: `write-plan` `implement-plan` (`/write-plan` plan-only → `next: /implement-plan`)
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`; advise unlock = pick **1. Implement**, not `ok`/`yes`/`go`; slash `/implement-plan` still valid alias)
97
127
 
98
128
  ## Generate
99
129
 
@@ -104,7 +134,7 @@ Pipeline (no `-t geminicli`):
104
134
  3. `emit-gemini.mjs` — `GEMINI.md` `.gemini/agents/{id}/agent.md` MCP `.gemini/settings.json`
105
135
  4. `emit-agent-shell-tools` — Claude grunt body `Bash`; other hosts `run_terminal_command` (hooks-union)
106
136
 
107
- `check` / `doctor` = rulesync check / doctor.
137
+ `check` = rulesync check. `doctor` = unified prereqs. Schema: `npm run rulesync:doctor`.
108
138
 
109
139
  Emit writes other-CLI trees from `.rulesync` for the **next** process of that CLI. Not a live hop into another host.
110
140
 
@@ -132,7 +162,7 @@ Same topology as the session recap; every legal edge labeled.
132
162
  ```
133
163
  USER
134
164
  │
135
- │ 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)
136
166
  │ TUI local; completion tokens from Model API (mixed)
137
167
  ▼
138
168
  ┌──────────────────────── CLI HOST (this process) ────────────────────────┐
@@ -221,7 +251,7 @@ Implementer or thinker that still needs a fat dump **stops** on a `need:` JSON l
221
251
  | node | inside host? | local vs AI-server | notes |
222
252
  | --- | --- | --- | --- |
223
253
  | User | no | mixed | Speaks only to parent session. Never a child edge. |
224
- | 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). |
225
255
  | CLI host process | yes (is the box) | local process | Grok Build / Claude Code / Codex / Gemini / Antigravity. Not a peer of another CLI. |
226
256
  | Parent orchestrator | yes | AI-server **turn** | Spawn-first low router. No parent Read/Bash/Grep. Lives in this session, not a sidecar. |
227
257
  | `grunt` sibling | yes | AI-server **turn** | Facts/tools/mechanical write. Never spawn. Never feature solution. |
@@ -288,7 +318,7 @@ Repo-relative (repository root):
288
318
 
289
319
  ## Layout
290
320
 
291
- Published (`package.json` `files`): `bin/grunt.js` `cli` `scripts/check-globals.mjs` `scripts/emit-agent-shell-tools.mjs` `scripts/emit-gemini.mjs` `scripts/emit-mcp-policy.mjs` `scripts/gate-fat-tools.mjs` `scripts/hooks-union.mjs` `scripts/grunt-job.mjs` `scripts/parse-need.mjs` `scripts/persist-handoff.mjs` `scripts/persist-plan.mjs` `scripts/purge-global-mcps.mjs` `scripts/scrub-spawn-prompt.mjs` `scripts/scrub-text-lib.mjs` `scripts/sync-global-settings.mjs` `scripts/telemetry.mjs` `scripts/scrub-text` `.rulesync` `.grok` `.codex` `.claude` `.agents` `AGENTS.md` `CLAUDE.md` `.mcp.json` `README.md` `LICENSE`
321
+ Published (`package.json` `files`): `bin/grunt.js` `cli` `scripts/check-globals.mjs` `scripts/emit-agent-shell-tools.mjs` `scripts/emit-gemini.mjs` `scripts/emit-mcp-policy.mjs` `scripts/gate-fat-tools.mjs` `scripts/hooks-union.mjs` `scripts/grunt-job.mjs` `scripts/parse-need.mjs` `scripts/persist-handoff.mjs` `scripts/persist-plan.mjs` `scripts/purge-global-mcps.mjs` `scripts/scrub-spawn-prompt.mjs` `scripts/scrub-text-lib.mjs` `scripts/sync-global-settings.mjs` `scripts/telemetry.mjs` `scripts/browser.mjs` `scripts/doctor.mjs` `scripts/scrub-text` `.rulesync` `.grok` `.codex` `.claude` `.agents` `AGENTS.md` `CLAUDE.md` `.mcp.json` `README.md` `LICENSE`
292
322
 
293
323
  No `scripts/*.test.ts` `scripts/fixtures/` `docs/` `coverage/` `vitest.config.ts` in `files`.
294
324
 
@@ -319,7 +349,7 @@ npm i && npm test
319
349
 
320
350
  Keep these four flows only:
321
351
 
322
- 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
323
353
  2. “What is 2+2” → grunt → `[grunt]:` echo
324
354
  3. Marvel theatrical next → grunt `job:web` → recap
325
355
  4. `.logs` 3/6/2021 tag `framework bug` → grunt local search → recap
package/cli/grunt.mjs CHANGED
@@ -17,7 +17,7 @@ Commands:
17
17
  check npm run rulesync:check
18
18
  sync-globals npm run sync:globals (dry-run; --apply to write)
19
19
  purge-mcps npm run purge:global-mcps (dry-run; --apply to write)
20
- doctor npm run rulesync:doctor
20
+ doctor npm run doctor
21
21
  help Show this help
22
22
  version Print package version
23
23
 
@@ -163,7 +163,7 @@ async function dispatch(cmd, flags, interactive) {
163
163
  return
164
164
  }
165
165
  if (cmd === "doctor") {
166
- npmRun("rulesync:doctor")
166
+ npmRun("doctor")
167
167
  return
168
168
  }
169
169
  process.stdout.write(USAGE)
package/cli/grunt.test.ts CHANGED
@@ -37,7 +37,7 @@ Commands:
37
37
  check npm run rulesync:check
38
38
  sync-globals npm run sync:globals (dry-run; --apply to write)
39
39
  purge-mcps npm run purge:global-mcps (dry-run; --apply to write)
40
- doctor npm run rulesync:doctor
40
+ doctor npm run doctor
41
41
  help Show this help
42
42
  version Print package version
43
43
 
@@ -236,10 +236,10 @@ describe("start", () => {
236
236
  );
237
237
  });
238
238
 
239
- it("doctor npm-runs rulesync:doctor", async () => {
239
+ it("doctor npm-runs doctor", async () => {
240
240
  process.argv = ["node", "grunt", "doctor"];
241
241
  await start();
242
- expect(execFileSync).toHaveBeenCalledWith("npm", ["run", "rulesync:doctor"], {
242
+ expect(execFileSync).toHaveBeenCalledWith("npm", ["run", "doctor"], {
243
243
  cwd: process.cwd(),
244
244
  stdio: "inherit",
245
245
  });
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",
@@ -21,17 +22,23 @@ const PRODUCT_SCRIPTS = [
21
22
  "scrub-text-lib.mjs",
22
23
  "sync-global-settings.mjs",
23
24
  "telemetry.mjs",
25
+ "browser.mjs",
26
+ "doctor.mjs",
24
27
  "scrub-text",
25
28
  ]
26
29
 
27
30
  const COPY_DIRS = [".rulesync", ".grok", ".codex", ".claude", ".agents"]
28
31
  const GUARDED_MD_FILES = ["AGENTS.md", "CLAUDE.md"]
32
+ export const GUARDED_ROOT_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"]
29
33
  // .mcp.json is intentionally not copied here: scripts/emit-mcp-policy.mjs
30
34
  // (run via `rulesync:generate`, see mergeClaudeSettings ordering) owns and
31
35
  // merges it, so a plain-copy would just be clobbered/redundant.
32
36
  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 -->"
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
35
42
 
36
43
  function sortKeys(obj) {
37
44
  return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]))
@@ -77,33 +84,199 @@ export function shouldAutoSkipGlobals(dest) {
77
84
  return destHasGruntSentinel(dest) || fs.existsSync(path.join(dest, "scripts", "telemetry.mjs"))
78
85
  }
79
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
+
80
175
  export function mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited = false } = {}) {
176
+ void alreadyInited
81
177
  const src = path.join(pkgRoot, file)
82
178
  const d = path.join(dest, file)
83
179
  if (samePath(src, d)) return
84
180
  const srcText = fs.readFileSync(src, "utf8")
181
+ return writeMergedGuardedFile(d, srcText)
182
+ }
85
183
 
86
- if (!fs.existsSync(d)) {
87
- fs.writeFileSync(d, `${SENTINEL_BEGIN}\n${srcText}\n${SENTINEL_END}\n`)
88
- 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) }
89
197
  }
198
+ return snap
199
+ }
90
200
 
91
- const destText = fs.readFileSync(d, "utf8")
92
- 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
+ }
93
228
 
94
- if (!hasSentinel) {
95
- if (alreadyInited) return "skipped-side-file"
96
- const gruntFile = file.replace(/\.md$/, ".grunt.md")
97
- fs.writeFileSync(path.join(dest, gruntFile), srcText)
98
- 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)
99
241
  return
100
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
+ }
101
259
 
102
- const beginIdx = destText.indexOf(SENTINEL_BEGIN)
103
- const endIdx = destText.indexOf(SENTINEL_END)
104
- const before = destText.slice(0, beginIdx)
105
- const after = destText.slice(endIdx + SENTINEL_END.length)
106
- 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
+ }
107
280
  }
108
281
 
109
282
  export function mergeClaudeSettings(destRoot, pkgRoot) {
@@ -220,7 +393,6 @@ export function mergePackageJson(dest, pkgRoot) {
220
393
  export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileSync, skipGlobals = false, applyGlobals, onPhase } = {}) {
221
394
  dest = path.resolve(dest)
222
395
  const pkgRoot = path.resolve(pkgRootOpt ?? PKG_ROOT)
223
- const alreadyInited = destAlreadyInited(dest)
224
396
  const skipGlobalsApply =
225
397
  applyGlobals === true ? false : skipGlobals || shouldAutoSkipGlobals(dest)
226
398
 
@@ -253,14 +425,8 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
253
425
  }
254
426
  }
255
427
 
256
- let skippedSideFile = false
257
428
  for (const file of GUARDED_MD_FILES) {
258
- if (mergeGuardedMarkdown(dest, pkgRoot, file, { alreadyInited }) === "skipped-side-file") {
259
- skippedSideFile = true
260
- }
261
- }
262
- if (skippedSideFile) {
263
- console.log("AGENTS.md/CLAUDE.md lack markers and were left alone")
429
+ mergeGuardedMarkdown(dest, pkgRoot, file)
264
430
  }
265
431
 
266
432
  fs.mkdirSync(path.join(dest, "scripts"), { recursive: true })
@@ -289,9 +455,13 @@ export function init(dest, { pkgRoot: pkgRootOpt, execFileSync: exec = execFileS
289
455
  exec("npm", args, { cwd: dest, stdio: "inherit" })
290
456
  }
291
457
  runNpm("install", ["install"])
458
+ const guardedSnap = snapshotGuardedRoots(dest)
292
459
  runNpm("generate", ["run", "rulesync:generate"])
460
+ remergeGuardedRoots(dest, guardedSnap)
293
461
  if (!skipGlobalsApply) {
294
462
  runNpm("sync-globals", ["run", "sync:globals:apply"])
295
463
  }
296
- runNpm("check", ["run", "rulesync:check"])
464
+ withGuardedCheckInteriors(dest, () => {
465
+ runNpm("check", ["run", "rulesync:check"])
466
+ })
297
467
  }