@hanzlaa/rcode 4.4.4 → 4.5.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/package.json CHANGED
@@ -1,11 +1,23 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "4.4.4",
3
+ "version": "4.5.0",
4
4
  "description": "rcode — the AI team that never forgets. Persistent memory, specialist agents, and slash commands for AI IDEs. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
7
7
  "rcode": "dist/rcode.js"
8
8
  },
9
+ "scripts": {
10
+ "dashboard": "node server/dashboard.js",
11
+ "test": "node --test",
12
+ "test:ci": "node --test --test-reporter=spec",
13
+ "postinstall": "node cli/postinstall.js",
14
+ "build:cli": "node scripts/build.cjs",
15
+ "build": "node scripts/build.cjs",
16
+ "prepack": "node scripts/prepack-strip.cjs && node scripts/build.cjs",
17
+ "postpack": "node scripts/postpack-restore.cjs",
18
+ "dogfood": "bash scripts/dogfood-check.sh",
19
+ "test:skills": "node test/skill-snapshot.test.cjs"
20
+ },
9
21
  "files": [
10
22
  "cli/",
11
23
  "rcode/",
@@ -59,15 +71,5 @@
59
71
  },
60
72
  "optionalDependencies": {
61
73
  "@lydell/node-pty": "1.2.0-beta.12"
62
- },
63
- "scripts": {
64
- "dashboard": "node server/dashboard.js",
65
- "test": "node --test",
66
- "test:ci": "node --test --test-reporter=spec",
67
- "postinstall": "node cli/postinstall.js",
68
- "build:cli": "node scripts/build.cjs",
69
- "build": "node scripts/build.cjs",
70
- "dogfood": "bash scripts/dogfood-check.sh",
71
- "test:skills": "node test/skill-snapshot.test.cjs"
72
74
  }
73
- }
75
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Brain — pulls/lists/status-checks external "brain" content sources
3
+ * declared in .rcode/brain/sources.yaml (issue #158).
4
+ *
5
+ * Extracted from rcode-tools.cjs's cmdBrain (issue #204) — pure mechanical
6
+ * move, no behavior change. PROJECT_ROOT/RCODE_DIR are passed in from the
7
+ * caller since this module has no access to the dispatcher's module scope.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ function cmdBrain(args, { PROJECT_ROOT, RCODE_DIR }) {
14
+ const sub = args[0] || 'help';
15
+ // sources.yaml lives under .rcode/brain/ in user installs (v2.2+).
16
+ // Older installs may have it at rcode/brain/ (pre-v2.2) — fall back for compat.
17
+ let sourcesPath = path.join(RCODE_DIR, 'brain', 'sources.yaml');
18
+ let brainDir = path.join(RCODE_DIR, 'brain');
19
+ if (!fs.existsSync(sourcesPath)) {
20
+ const legacyPath = path.join(PROJECT_ROOT, 'rcode', 'brain', 'sources.yaml');
21
+ if (fs.existsSync(legacyPath)) {
22
+ sourcesPath = legacyPath;
23
+ brainDir = path.join(PROJECT_ROOT, 'rcode', 'brain');
24
+ }
25
+ }
26
+
27
+ // Resolve a source's dest directory relative to brainDir.
28
+ // Accepts legacy absolute-looking values ("rcode/brain/rcode-github/") by
29
+ // stripping any leading "rcode/brain/" so the resolved path sits inside the
30
+ // chosen brainDir. New sources.yaml should use bare names ("rcode-github/").
31
+ function resolveDest(dest) {
32
+ const trimmed = String(dest || '').replace(/^rcode\/brain\//, '').replace(/^\/+/, '');
33
+ return path.join(brainDir, trimmed);
34
+ }
35
+
36
+ if (!fs.existsSync(sourcesPath)) {
37
+ return {
38
+ ok: false,
39
+ error: `sources.yaml missing at ${sourcesPath}. Run install or see issue #158.`,
40
+ };
41
+ }
42
+
43
+ // Minimal YAML reader specifically for sources.yaml — not a general parser.
44
+ // Handles: `version: 1`, `defaults:` block, `sources:` list where each
45
+ // entry is a `- name: X` block with sibling key: value lines and an
46
+ // `paths:` sub-list of strings.
47
+ function parseSourcesYaml(text) {
48
+ const root = { version: null, defaults: {}, sources: [] };
49
+ const lines = text.split('\n');
50
+ let section = null;
51
+ let current = null; // current source map
52
+ let inPaths = false;
53
+ let inDescription = false;
54
+ let descLines = [];
55
+
56
+ function unquote(s) { return s.replace(/^['"]|['"]$/g, ''); }
57
+
58
+ for (const raw of lines) {
59
+ if (!raw.trim() || raw.trim().startsWith('#')) continue;
60
+
61
+ // Flush description if we were collecting
62
+ if (inDescription && raw.match(/^ {4}\S/) && !raw.trim().startsWith('-')) {
63
+ // still inside the description block
64
+ const m = raw.match(/^ *(.*)$/);
65
+ if (m) descLines.push(m[1]);
66
+ continue;
67
+ } else if (inDescription) {
68
+ current.description = descLines.join(' ').trim();
69
+ inDescription = false;
70
+ descLines = [];
71
+ }
72
+
73
+ // Top-level keys
74
+ const top = raw.match(/^(\w+):\s*(.*)$/);
75
+ if (top) {
76
+ const key = top[1], val = top[2].trim();
77
+ if (key === 'version') { root.version = unquote(val); section = null; continue; }
78
+ if (key === 'defaults') { section = 'defaults'; continue; }
79
+ if (key === 'sources') { section = 'sources'; continue; }
80
+ }
81
+
82
+ // defaults: indented key-value
83
+ if (section === 'defaults') {
84
+ const m = raw.match(/^ +([\w_]+):\s*(.*)$/);
85
+ if (m) root.defaults[m[1]] = unquote(m[2]);
86
+ continue;
87
+ }
88
+
89
+ // sources: list items
90
+ if (section === 'sources') {
91
+ const startItem = raw.match(/^ *- ([\w_-]+):\s*(.*)$/);
92
+ if (startItem) {
93
+ current = {};
94
+ current[startItem[1]] = unquote(startItem[2]);
95
+ root.sources.push(current);
96
+ inPaths = false;
97
+ continue;
98
+ }
99
+ // paths: list-of-strings under current
100
+ const pathsStart = raw.match(/^ +paths:\s*$/);
101
+ if (pathsStart) { current.paths = []; inPaths = true; continue; }
102
+ if (inPaths) {
103
+ const pItem = raw.match(/^ *- (.*)$/);
104
+ if (pItem) { current.paths.push(unquote(pItem[1])); continue; }
105
+ inPaths = false;
106
+ }
107
+ // description: block scalar `>`
108
+ const descStart = raw.match(/^ +description:\s*>\s*$/);
109
+ if (descStart) { inDescription = true; descLines = []; continue; }
110
+ // Regular key: value on current item
111
+ const kv = raw.match(/^ +([\w_-]+):\s*(.*)$/);
112
+ if (kv && current) {
113
+ current[kv[1]] = unquote(kv[2]);
114
+ }
115
+ }
116
+ }
117
+ // final flush
118
+ if (inDescription && current) current.description = descLines.join(' ').trim();
119
+ return root;
120
+ }
121
+
122
+ const cfg = parseSourcesYaml(fs.readFileSync(sourcesPath, 'utf8'));
123
+ const sources = Array.isArray(cfg.sources) ? cfg.sources : [];
124
+
125
+ if (sub === 'list') {
126
+ return {
127
+ ok: true,
128
+ version: cfg.version,
129
+ sources: sources.map(s => ({
130
+ name: s.name,
131
+ repo: s.repo,
132
+ dest: s.dest,
133
+ placeholder: String(s.repo || '').includes('<PLACEHOLDER'),
134
+ })),
135
+ };
136
+ }
137
+
138
+ if (sub === 'status') {
139
+ const report = { ok: true, sources: [] };
140
+ for (const s of sources) {
141
+ const destPath = resolveDest(s.dest);
142
+ const exists = fs.existsSync(destPath);
143
+ report.sources.push({
144
+ name: s.name,
145
+ dest: s.dest,
146
+ fetched: exists,
147
+ placeholder: String(s.repo || '').includes('<PLACEHOLDER'),
148
+ });
149
+ }
150
+ return report;
151
+ }
152
+
153
+ if (sub !== 'pull') {
154
+ return {
155
+ ok: false,
156
+ error: `Unknown brain subcommand: ${sub}. Try: pull | status | list`,
157
+ };
158
+ }
159
+
160
+ // sub === 'pull'
161
+ const onlyName = args[1];
162
+ const report = { ok: true, pulled: [], skipped: [], errors: [] };
163
+
164
+ for (const s of sources) {
165
+ if (onlyName && s.name !== onlyName) continue;
166
+ const repo = String(s.repo || '');
167
+
168
+ if (repo.includes('<PLACEHOLDER')) {
169
+ report.skipped.push({ name: s.name, reason: 'placeholder URL — fill in via issue #162 (M5)' });
170
+ continue;
171
+ }
172
+
173
+ if (repo === 'self') {
174
+ // In-repo copy — use rsync-ish node copy from paths under project root.
175
+ const destPath = resolveDest(s.dest);
176
+ fs.mkdirSync(destPath, { recursive: true });
177
+ const paths = Array.isArray(s.paths) ? s.paths : [];
178
+ let copied = 0;
179
+ for (const pattern of paths) {
180
+ // Very simple glob: expand ** to recursive copy.
181
+ const base = pattern.split('**')[0].replace(/\/$/, '');
182
+ const srcDir = path.join(PROJECT_ROOT, base);
183
+ if (!fs.existsSync(srcDir)) continue;
184
+ // Recursive copy of .md files
185
+ function walk(dir) {
186
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
187
+ const full = path.join(dir, e.name);
188
+ if (e.isDirectory()) { walk(full); continue; }
189
+ if (!e.isFile()) continue;
190
+ if (!full.endsWith('.md')) continue;
191
+ const rel = path.relative(srcDir, full);
192
+ const out = path.join(destPath, rel);
193
+ fs.mkdirSync(path.dirname(out), { recursive: true });
194
+ fs.copyFileSync(full, out);
195
+ copied++;
196
+ }
197
+ }
198
+ walk(srcDir);
199
+ }
200
+ report.pulled.push({ name: s.name, kind: 'self', files: copied });
201
+ continue;
202
+ }
203
+
204
+ // #925 — supply-chain guard. `brain pull` clones a remote repo and copies
205
+ // its content into every rcode user's project context, so an attacker who
206
+ // can edit sources.yaml (or a typo) must not silently pull untrusted code.
207
+ // Only allow github.com URLs under an approved org allowlist; anything else
208
+ // is rejected unless the user explicitly opts in with
209
+ // RCODE_BRAIN_ALLOW_UNVERIFIED=1. Pinning to a commit SHA (source.ref) is
210
+ // recommended over a moving branch — warn when a source tracks a branch.
211
+ const BRAIN_ALLOWED_HOSTS = new Set(['github.com']);
212
+ const BRAIN_ALLOWED_ORGS = new Set(['hanzlahabib', 'rcode-om']);
213
+ if (process.env.RCODE_BRAIN_ALLOW_UNVERIFIED !== '1') {
214
+ let host = '', org = '';
215
+ const mm = repo.match(/(?:https?:\/\/|git@)([^/:]+)[/:]([^/]+)\//);
216
+ if (mm) { host = mm[1]; org = mm[2]; }
217
+ if (!BRAIN_ALLOWED_HOSTS.has(host) || !BRAIN_ALLOWED_ORGS.has(org)) {
218
+ report.skipped.push({
219
+ name: s.name,
220
+ reason: `repo not in brain allowlist (${host || 'unknown host'}/${org || '?'}). ` +
221
+ `Add the org to BRAIN_ALLOWED_ORGS or set RCODE_BRAIN_ALLOW_UNVERIFIED=1 to override.`,
222
+ });
223
+ continue;
224
+ }
225
+ if (!s.ref) {
226
+ // Tracking a branch is mutable — a force-push changes what you pull.
227
+ // Not fatal, but surface it so maintainers can pin a SHA via `ref:`.
228
+ report.skipped.push({
229
+ name: s.name,
230
+ reason: `no pinned 'ref:' SHA — tracking branch '${s.branch || root.defaults.branch || 'main'}' is mutable. ` +
231
+ `Pin a commit SHA in sources.yaml, or set RCODE_BRAIN_ALLOW_UNVERIFIED=1 to pull the branch tip.`,
232
+ });
233
+ continue;
234
+ }
235
+ }
236
+
237
+ // External git source — use sparse checkout into a tmp dir then copy.
238
+ // #170 — global brain cache at ~/.rcode/brain-cache/<sha1(repo+branch+paths)>/.
239
+ // Same source pulled from N projects = N clones today, 1 clone + N copies
240
+ // after this change. Cache TTL is configurable per source (defaults to 6h).
241
+ const { execSync, execFileSync: execFileSyncBrain } = require('child_process');
242
+ const crypto = require('crypto');
243
+ const os = require('os');
244
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rcode-brain-'));
245
+ const branch = s.branch || cfg.defaults?.branch || 'main';
246
+ const sparsePaths = Array.isArray(s.paths) ? s.paths : [];
247
+
248
+ // Cache key = sha1(repo + branch + sparsePaths joined). Changing any of
249
+ // those gets a fresh cache slot. Different projects pulling the same
250
+ // (repo, branch, paths) tuple share one cached download.
251
+ const cacheKey = crypto
252
+ .createHash('sha1')
253
+ .update(`${repo}\n${branch}\n${sparsePaths.sort().join(',')}`)
254
+ .digest('hex')
255
+ .slice(0, 16);
256
+ const cacheRoot = path.join(os.homedir(), '.rcode', 'brain-cache');
257
+ const cacheDir = path.join(cacheRoot, cacheKey);
258
+ const cacheManifest = path.join(cacheDir, '.cache-manifest.json');
259
+
260
+ // Parse cache_ttl: accept '6h', '15m', '2d', or seconds as bare number.
261
+ function parseTtlSeconds(raw, fallback) {
262
+ if (raw == null || raw === '') return fallback;
263
+ const s = String(raw).trim();
264
+ const m = s.match(/^(\d+)([smhd]?)$/i);
265
+ if (!m) return fallback;
266
+ const n = parseInt(m[1], 10);
267
+ switch ((m[2] || 's').toLowerCase()) {
268
+ case 'd': return n * 86400;
269
+ case 'h': return n * 3600;
270
+ case 'm': return n * 60;
271
+ default: return n;
272
+ }
273
+ }
274
+ const ttlSeconds = parseTtlSeconds(s.cache_ttl || cfg.defaults?.cache_ttl, 6 * 3600);
275
+
276
+ function readCacheManifest() {
277
+ if (!fs.existsSync(cacheManifest)) return null;
278
+ try { return JSON.parse(fs.readFileSync(cacheManifest, 'utf8')); }
279
+ catch { return null; }
280
+ }
281
+ function isCacheFresh(manifest) {
282
+ if (!manifest || typeof manifest.pulled_at !== 'string') return false;
283
+ const ageMs = Date.now() - Date.parse(manifest.pulled_at);
284
+ return Number.isFinite(ageMs) && (ageMs / 1000) < ttlSeconds;
285
+ }
286
+ function copyTree(src, dst) {
287
+ for (const e of fs.readdirSync(src, { withFileTypes: true })) {
288
+ if (e.name === '.git' || e.name === '.cache-manifest.json') continue;
289
+ const sp = path.join(src, e.name);
290
+ const dp = path.join(dst, e.name);
291
+ if (e.isDirectory()) { fs.mkdirSync(dp, { recursive: true }); copyTree(sp, dp); }
292
+ else if (e.isFile()) fs.copyFileSync(sp, dp);
293
+ }
294
+ }
295
+
296
+ const destPath = resolveDest(s.dest);
297
+ try {
298
+ // Cache hit path — copy from ~/.rcode/brain-cache/<key>/ directly.
299
+ const cached = readCacheManifest();
300
+ if (cached && isCacheFresh(cached)) {
301
+ fs.mkdirSync(destPath, { recursive: true });
302
+ copyTree(cacheDir, destPath);
303
+ report.pulled.push({ name: s.name, kind: 'git', repo, branch, cache: 'hit', cache_key: cacheKey });
304
+ continue;
305
+ }
306
+
307
+ // Cache miss — clone, then warm the cache for next time.
308
+ // Use --no-checkout + explicit sparse-checkout init + set + checkout
309
+ // because `git clone --sparse` combined with --filter=blob:none has
310
+ // an intermittent failure mode where git misreads the URL as a path.
311
+ // execFileSync — repo/branch/tmp/sparsePaths from user config; no shell so
312
+ // values with spaces, quotes, or semicolons cannot inject commands (#754).
313
+ execFileSyncBrain('git', [
314
+ 'clone', '--depth=1', '--filter=blob:none', '--no-checkout',
315
+ `--branch=${branch}`, repo, tmp,
316
+ ], { stdio: 'pipe' });
317
+ execFileSyncBrain('git', ['-C', tmp, 'sparse-checkout', 'init', '--no-cone'], { stdio: 'pipe' });
318
+ execFileSyncBrain('git', ['-C', tmp, 'sparse-checkout', 'set', ...sparsePaths], { stdio: 'pipe' });
319
+ execFileSyncBrain('git', ['-C', tmp, 'checkout'], { stdio: 'pipe' });
320
+
321
+ // Warm cache before destination copy so a copy failure to dest still
322
+ // saves the next pull. Replace any stale slot atomically.
323
+ try {
324
+ fs.rmSync(cacheDir, { recursive: true, force: true });
325
+ fs.mkdirSync(cacheDir, { recursive: true });
326
+ copyTree(tmp, cacheDir);
327
+ const commitSha = (() => {
328
+ try { return execFileSyncBrain('git', ['-C', tmp, 'rev-parse', 'HEAD'], { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); }
329
+ catch { return null; }
330
+ })();
331
+ fs.writeFileSync(cacheManifest, JSON.stringify({
332
+ repo, branch, paths: sparsePaths,
333
+ pulled_at: new Date().toISOString(),
334
+ commit_sha: commitSha,
335
+ ttl_seconds: ttlSeconds,
336
+ }, null, 2));
337
+ } catch (_) { /* cache warming is best-effort */ }
338
+
339
+ fs.mkdirSync(destPath, { recursive: true });
340
+ copyTree(tmp, destPath);
341
+ report.pulled.push({ name: s.name, kind: 'git', repo, branch, cache: 'miss', cache_key: cacheKey });
342
+ } catch (e) {
343
+ report.errors.push({ name: s.name, error: String(e.message || e).slice(0, 200) });
344
+ } finally {
345
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
346
+ }
347
+ }
348
+
349
+ if (report.errors.length) report.ok = false;
350
+ return report;
351
+ }
352
+
353
+ module.exports = { cmdBrain };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Gitignore — extracted from rcode-tools.cjs (issue #204). Pure mechanical
3
+ * move, no behavior change (including the pre-existing `slice_end` typo in
4
+ * spliceBlock, left untouched — out of scope for this extraction).
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * cmdGitignore — re-render the rcode-managed block in .gitignore based on
12
+ * current config (specifically commit_planning from .rcode/config.yaml).
13
+ *
14
+ * Subcommands:
15
+ * gitignore refresh rewrite the rcode block in-place
16
+ * gitignore status report current commit_planning + block presence
17
+ *
18
+ * Mirrors the logic in cli/install.js ensureRcodeGitignore — kept in sync
19
+ * by convention. Any change to the block format should update both.
20
+ * Closes #189 — runtime toggle for commit_planning.
21
+ */
22
+ function cmdGitignore(args, { PROJECT_ROOT, RCODE_DIR }) {
23
+ const sub = args[0] || 'refresh';
24
+ const gitignorePath = path.join(PROJECT_ROOT, '.gitignore');
25
+ const configPath = path.join(RCODE_DIR, 'config.yaml');
26
+
27
+ // Read commit_planning from config; default true if missing.
28
+ let commitPlanning = true;
29
+ if (fs.existsSync(configPath)) {
30
+ const cfg = fs.readFileSync(configPath, 'utf8');
31
+ const m = cfg.match(/^\s*commit_planning:\s*(true|false)\s*$/m);
32
+ if (m) commitPlanning = (m[1] === 'true');
33
+ }
34
+
35
+ const BEGIN = '# ===== rcode-managed gitignore block (npx @hanzlaa/rcode install) =====';
36
+ const END = '# ===== end rcode-managed gitignore block =====';
37
+
38
+ if (sub === 'status') {
39
+ const exists = fs.existsSync(gitignorePath);
40
+ const hasBlock = exists && fs.readFileSync(gitignorePath, 'utf8').includes(BEGIN);
41
+ return {
42
+ ok: true,
43
+ gitignore_exists: exists,
44
+ block_present: hasBlock,
45
+ commit_planning: commitPlanning,
46
+ };
47
+ }
48
+
49
+ if (sub !== 'refresh') {
50
+ return { ok: false, error: `Unknown gitignore subcommand: ${sub}. Try: refresh | status` };
51
+ }
52
+
53
+ const lines = [
54
+ '',
55
+ BEGIN,
56
+ '# Added automatically on rcode install. Idempotent — safe to re-run.',
57
+ '# Edit `commit_planning` in .rcode/config.yaml, then: rcode-tools gitignore refresh',
58
+ '',
59
+ '# Installed methodology files (regenerate with: npx @hanzlaa/rcode install)',
60
+ '.claude/',
61
+ '.rcode/bin/',
62
+ '.rcode/workflows/',
63
+ '.rcode/references/',
64
+ '.rcode/commands/',
65
+ '.rcode/skills/',
66
+ '',
67
+ '# Pulled rcode brain content (refresh with: rcode brain pull)',
68
+ '.rcode/brain/rcode-github/',
69
+ '.rcode/brain/rcode-docs/',
70
+ '.rcode/brain/best-practices/',
71
+ '',
72
+ '# Runtime noise',
73
+ 'node_modules/',
74
+ '.rcode/state.json.lock',
75
+ '.planning/debug/',
76
+ '.planning/_backup/',
77
+ ];
78
+ if (!commitPlanning) {
79
+ lines.push('', '# Planning artifacts — kept local (commit_planning: false)', '.planning/');
80
+ }
81
+ lines.push(
82
+ '',
83
+ '# What you DO commit:',
84
+ '# .rcode/config.yaml - project mode/language/profile/commit_planning',
85
+ '# .rcode/state.json - decisions, roadmap pointer, blockers',
86
+ '# .rcode/brain/sources.yaml - brain source manifest',
87
+ commitPlanning
88
+ ? '# .planning/ - PRD, roadmap, sprints, SUMMARY.md files'
89
+ : '# (planning artifacts are NOT committed — see commit_planning in config)',
90
+ END,
91
+ ''
92
+ );
93
+ const BLOCK = lines.join('\n');
94
+
95
+ /** Replace the rcode block in text using indexOf — safer than regex. */
96
+ function spliceBlock(existing, newBlock) {
97
+ const start = existing.indexOf(BEGIN);
98
+ if (start < 0) return null;
99
+ const endIdx = existing.indexOf(END, start);
100
+ if (endIdx < 0) return null;
101
+ // Include trailing newline after END if present, and leading newline before BEGIN.
102
+ let sliceStart = start;
103
+ if (sliceStart > 0 && existing[sliceStart - 1] === '\n') sliceStart -= 1;
104
+ let sliceEnd = endIdx + END.length;
105
+ if (existing[slice_end] === '\n') slice_end += 1;
106
+ return existing.slice(0, sliceStart) + newBlock + existing.slice(slice_end);
107
+ }
108
+
109
+ if (!fs.existsSync(gitignorePath)) {
110
+ fs.writeFileSync(gitignorePath, BLOCK);
111
+ return { ok: true, action: 'created', commit_planning: commitPlanning };
112
+ }
113
+ const existing = fs.readFileSync(gitignorePath, 'utf8');
114
+ if (existing.includes(BEGIN)) {
115
+ const rewritten = spliceBlock(existing, BLOCK);
116
+ if (rewritten !== null && rewritten !== existing) {
117
+ fs.writeFileSync(gitignorePath, rewritten);
118
+ return { ok: true, action: 'updated', commit_planning: commitPlanning };
119
+ }
120
+ return { ok: true, action: 'no-change', commit_planning: commitPlanning };
121
+ }
122
+ fs.writeFileSync(gitignorePath, existing + BLOCK);
123
+ return { ok: true, action: 'appended', commit_planning: commitPlanning };
124
+ }
125
+
126
+ module.exports = { cmdGitignore };