@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/AGENTS.md +11 -4
- package/CLAUDE.md +11 -4
- package/CONTRIBUTING.md +6 -1
- package/cli/doctor.js +122 -0
- package/cli/index.js +4 -0
- package/cli/install.js +8 -0
- package/cli/lib/namespace-migrate.cjs +216 -0
- package/cli/migrate-namespace.js +61 -0
- package/cli/update.js +19 -0
- package/dist/rcode.js +215 -208
- package/package.json +14 -12
- package/rcode/bin/lib/brain.cjs +353 -0
- package/rcode/bin/lib/gitignore.cjs +126 -0
- package/rcode/bin/lib/memory-drift.cjs +237 -0
- package/rcode/bin/lib/memory-select.cjs +263 -0
- package/rcode/bin/lib/progress.cjs +440 -0
- package/rcode/bin/lib/state-reader.cjs +127 -0
- package/rcode/bin/lib/summary.cjs +82 -0
- package/rcode/bin/rcode-hooks.cjs +194 -64
- package/rcode/bin/rcode-tools.cjs +106 -985
- package/rcode/data/intent-table.json +19 -19
- package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/heartbeat.sh +0 -0
- package/rcode/templates/memory/INDEX.md +6 -1
- package/rcode/templates/settings-hooks.json +12 -1
- package/rcode/workflows/do.md +2 -2
- package/rcode/workflows/enable-hooks.md +3 -2
- package/rcode/workflows/new-project-roadmap.md +2 -2
package/package.json
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzlaa/rcode",
|
|
3
|
-
"version": "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 };
|