@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
|
@@ -1031,12 +1031,42 @@ function cmdState(subArgs) {
|
|
|
1031
1031
|
}
|
|
1032
1032
|
try {
|
|
1033
1033
|
const raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
1034
|
-
|
|
1034
|
+
const migrated = migrateState(raw);
|
|
1035
|
+
// One-time idempotent status migration (#955): persist the normalized
|
|
1036
|
+
// phase statuses back to disk the first time legacy aliases are found,
|
|
1037
|
+
// so state.json itself becomes canonical and every other reader (e.g.
|
|
1038
|
+
// resolveActivePhase() in state-reader.cjs, which reads the file
|
|
1039
|
+
// directly rather than through this helper) sees clean values too.
|
|
1040
|
+
// Idempotent: once written, raw already matches migrated and this is a
|
|
1041
|
+
// no-op on every subsequent load.
|
|
1042
|
+
const rawPhases = Array.isArray(raw?.phases) ? raw.phases : [];
|
|
1043
|
+
const migratedPhases = Array.isArray(migrated?.phases) ? migrated.phases : [];
|
|
1044
|
+
const hasLegacyStatus = rawPhases.some((p, i) => p?.status !== migratedPhases[i]?.status);
|
|
1045
|
+
if (hasLegacyStatus) {
|
|
1046
|
+
writeState(migrated);
|
|
1047
|
+
}
|
|
1048
|
+
return migrated;
|
|
1035
1049
|
} catch (e) {
|
|
1036
1050
|
throw new Error(`Invalid JSON in state.json: ${e.message}`);
|
|
1037
1051
|
}
|
|
1038
1052
|
}
|
|
1039
1053
|
|
|
1054
|
+
// Canonical phase status enum (#955). Legacy state files accumulated four
|
|
1055
|
+
// spellings for the same three states — normalize on every read so callers
|
|
1056
|
+
// never have to special-case 'completed'/'executed' against 'complete'.
|
|
1057
|
+
const PHASE_STATUS_ALIASES = {
|
|
1058
|
+
completed: 'complete',
|
|
1059
|
+
executed: 'complete',
|
|
1060
|
+
verified: 'complete',
|
|
1061
|
+
};
|
|
1062
|
+
const PHASE_STATUS_ENUM = new Set(['planned', 'executing', 'complete']);
|
|
1063
|
+
|
|
1064
|
+
/** Map a legacy status spelling to the canonical enum value (idempotent). */
|
|
1065
|
+
function normalizePhaseStatus(status) {
|
|
1066
|
+
if (typeof status !== 'string') return status;
|
|
1067
|
+
return PHASE_STATUS_ALIASES[status] ?? status;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1040
1070
|
/**
|
|
1041
1071
|
* migrateState — pure normalizer that upgrades any legacy state shape to v2.
|
|
1042
1072
|
*
|
|
@@ -1081,7 +1111,7 @@ function cmdState(subArgs) {
|
|
|
1081
1111
|
number: number ?? p.id ?? null,
|
|
1082
1112
|
id: id ?? null,
|
|
1083
1113
|
name: p.name ?? null,
|
|
1084
|
-
status: p.status ?? 'planned',
|
|
1114
|
+
status: normalizePhaseStatus(p.status ?? 'planned'),
|
|
1085
1115
|
started: p.started ?? null,
|
|
1086
1116
|
completed: p.completed ?? null,
|
|
1087
1117
|
sprints: Array.isArray(p.sprints) ? p.sprints : [],
|
|
@@ -3177,10 +3207,36 @@ function cmdState(subArgs) {
|
|
|
3177
3207
|
if (previousStatus === 'planned') {
|
|
3178
3208
|
process.stderr.write(`Warning: completing phase ${phaseKey} from 'planned' without executing.\n`);
|
|
3179
3209
|
}
|
|
3210
|
+
|
|
3211
|
+
// State-hygiene gate (#955): if an earlier-numbered phase is still stuck
|
|
3212
|
+
// 'executing' while this later phase gets marked complete, that's exactly
|
|
3213
|
+
// the drift that misorients resolveActivePhase() / the SessionStart greeter.
|
|
3214
|
+
// Warn rather than block — completing out of order is sometimes correct
|
|
3215
|
+
// (parallel workstreams), but it must never happen silently.
|
|
3216
|
+
const thisNum = parseFloat(phaseKey);
|
|
3217
|
+
const stalePhases = Number.isNaN(thisNum) ? [] : state.phases.filter((p) => {
|
|
3218
|
+
if (!p || p.status !== 'executing') return false;
|
|
3219
|
+
const n = parseFloat(p.number ?? p.id);
|
|
3220
|
+
return !Number.isNaN(n) && n < thisNum;
|
|
3221
|
+
});
|
|
3222
|
+
if (stalePhases.length > 0 && !flags.force) {
|
|
3223
|
+
const staleList = stalePhases.map((p) => p.number ?? p.id).join(', ');
|
|
3224
|
+
process.stderr.write(
|
|
3225
|
+
`Warning: phase ${phaseKey} marked complete while earlier phase(s) ${staleList} are still 'executing'. ` +
|
|
3226
|
+
`Use --force to suppress this warning, or close out the stale phase(s) first.\n`
|
|
3227
|
+
);
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3180
3230
|
entry.status = 'complete';
|
|
3181
3231
|
entry.completed = new Date().toISOString();
|
|
3182
3232
|
writeState(state);
|
|
3183
|
-
return {
|
|
3233
|
+
return {
|
|
3234
|
+
updated: true,
|
|
3235
|
+
phase: phaseKey,
|
|
3236
|
+
status: 'complete',
|
|
3237
|
+
previous_status: previousStatus,
|
|
3238
|
+
stale_executing_phases: stalePhases.map((p) => p.number ?? p.id),
|
|
3239
|
+
};
|
|
3184
3240
|
}
|
|
3185
3241
|
|
|
3186
3242
|
// Truncates execution state but preserves decisions, council_sessions, and workstreams.
|
|
@@ -4459,15 +4515,21 @@ function cmdCommit(argv) {
|
|
|
4459
4515
|
}
|
|
4460
4516
|
|
|
4461
4517
|
/**
|
|
4462
|
-
* cmdGenerateClaudeMd — Phase 11 / #467 / closes part of #465.
|
|
4518
|
+
* cmdGenerateClaudeMd — Phase 11 / #467 / closes part of #465. Phase 42 / #946.
|
|
4463
4519
|
*
|
|
4464
|
-
* Bootstrap
|
|
4465
|
-
*
|
|
4520
|
+
* Bootstrap project agent-rules scaffolds. Writes the same rule set to both
|
|
4521
|
+
* CLAUDE.md (Claude Code, Grok) and AGENTS.md (the cross-tool open standard read
|
|
4522
|
+
* by Codex, Cursor, Windsurf, Antigravity, Gemini) so the rcode Command Routing
|
|
4523
|
+
* rule reaches every supported agent — not just Claude. Used by
|
|
4524
|
+
* new-project-roadmap.md. Refuses to overwrite an existing CLAUDE.md unless
|
|
4525
|
+
* --force is set; AGENTS.md is written when absent (or with --force) so an
|
|
4526
|
+
* install-managed roster section is never clobbered.
|
|
4466
4527
|
*/
|
|
4467
4528
|
function cmdGenerateClaudeMd(rawArgs) {
|
|
4468
4529
|
const args = (rawArgs || '').split(/\s+/).filter(Boolean);
|
|
4469
4530
|
const force = args.includes('--force');
|
|
4470
4531
|
const claudeMdPath = path.join(PROJECT_ROOT, 'CLAUDE.md');
|
|
4532
|
+
const agentsMdPath = path.join(PROJECT_ROOT, 'AGENTS.md');
|
|
4471
4533
|
|
|
4472
4534
|
if (fs.existsSync(claudeMdPath) && !force) {
|
|
4473
4535
|
throw new Error(`CLAUDE.md already exists at ${claudeMdPath}. Use --force to overwrite.`);
|
|
@@ -4554,15 +4616,40 @@ If you have a real reason to bypass (e.g. retroactively documenting a phase that
|
|
|
4554
4616
|
|
|
4555
4617
|
---
|
|
4556
4618
|
|
|
4619
|
+
## rcode Command Routing
|
|
4620
|
+
|
|
4621
|
+
Before handling planning, exploration, auditing, refactoring, or multi-step build work ad-hoc, check whether a matching rcode command exists first.
|
|
4622
|
+
|
|
4623
|
+
**How to check:** read \`.rcode/workflows/do.md\` (installed by rcode) for the intent → command routing table — it is the single source of truth. Common cases: planning a phase → \`/rcode-plan\`, adding a phase → \`/rcode-add-phase\`, exploring/brainstorming → \`/rcode-brainstorm\`, auditing → \`/rcode-audit\`, executing a sprint → \`/rcode-execute\`, mapping the codebase → \`/rcode-map-codebase\`. Always consult \`do.md\` — never infer from memory alone, as the table changes with the release.
|
|
4624
|
+
|
|
4625
|
+
**Why:** rcode commands record outcomes in \`.rcode/state.json\` and \`.planning/\`. Work done ad-hoc creates silent state divergence. If you must proceed ad-hoc, run \`/rcode-memory-update\` afterward to keep long-term memory consistent.
|
|
4626
|
+
|
|
4627
|
+
---
|
|
4628
|
+
|
|
4557
4629
|
**This file is part of the project. Treat it as load-bearing.**
|
|
4558
4630
|
`;
|
|
4559
4631
|
|
|
4632
|
+
const claudeExisted = fs.existsSync(claudeMdPath);
|
|
4560
4633
|
fs.writeFileSync(claudeMdPath, content);
|
|
4634
|
+
|
|
4635
|
+
// Mirror the same rules to AGENTS.md (the cross-tool standard Codex, Cursor,
|
|
4636
|
+
// Windsurf, Antigravity, and Gemini read). Skip when it already exists without
|
|
4637
|
+
// --force so an install-appended "## rcode Agents (installed)" roster survives.
|
|
4638
|
+
const agentsExisted = fs.existsSync(agentsMdPath);
|
|
4639
|
+
const wroteAgents = !agentsExisted || force;
|
|
4640
|
+
if (wroteAgents) {
|
|
4641
|
+
fs.writeFileSync(agentsMdPath, content);
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4561
4644
|
return {
|
|
4562
4645
|
ok: true,
|
|
4563
4646
|
path: path.relative(PROJECT_ROOT, claudeMdPath),
|
|
4647
|
+
paths: wroteAgents
|
|
4648
|
+
? [path.relative(PROJECT_ROOT, claudeMdPath), path.relative(PROJECT_ROOT, agentsMdPath)]
|
|
4649
|
+
: [path.relative(PROJECT_ROOT, claudeMdPath)],
|
|
4564
4650
|
project_name: projectName,
|
|
4565
|
-
overwritten: force &&
|
|
4651
|
+
overwritten: force && claudeExisted,
|
|
4652
|
+
agents_md_skipped: !wroteAgents,
|
|
4566
4653
|
};
|
|
4567
4654
|
}
|
|
4568
4655
|
|
|
@@ -6055,20 +6142,6 @@ function cmdNotesCount() {
|
|
|
6055
6142
|
return { count };
|
|
6056
6143
|
}
|
|
6057
6144
|
|
|
6058
|
-
/**
|
|
6059
|
-
* cmdBrain — pull rcode brain content from configured sources.
|
|
6060
|
-
*
|
|
6061
|
-
* Subcommands:
|
|
6062
|
-
* brain pull Fetch all configured sources into rcode/brain/
|
|
6063
|
-
* brain pull <name> Fetch a single named source
|
|
6064
|
-
* brain status Report cache freshness and placeholder status
|
|
6065
|
-
* brain list Print configured sources
|
|
6066
|
-
*
|
|
6067
|
-
* Uses git sparse-checkout so we pull only the paths listed per source.
|
|
6068
|
-
* Placeholder URLs (containing `<PLACEHOLDER`) are skipped with a clear
|
|
6069
|
-
* message — useful in v2.0 before M5 lands real rcode repo URLs.
|
|
6070
|
-
*/
|
|
6071
|
-
|
|
6072
6145
|
/**
|
|
6073
6146
|
* cmdHandoff — cross-skill continuation token system. Closes #741.
|
|
6074
6147
|
*
|
|
@@ -6157,827 +6230,7 @@ function cmdHandoff(args) {
|
|
|
6157
6230
|
return { ok: false, error: `Unknown handoff subcommand: ${sub}. Valid: write, read, clear` };
|
|
6158
6231
|
}
|
|
6159
6232
|
|
|
6160
|
-
function cmdBrain(args) {
|
|
6161
|
-
const sub = args[0] || 'help';
|
|
6162
|
-
// sources.yaml lives under .rcode/brain/ in user installs (v2.2+).
|
|
6163
|
-
// Older installs may have it at rcode/brain/ (pre-v2.2) — fall back for compat.
|
|
6164
|
-
let sourcesPath = path.join(RCODE_DIR, 'brain', 'sources.yaml');
|
|
6165
|
-
let brainDir = path.join(RCODE_DIR, 'brain');
|
|
6166
|
-
if (!fs.existsSync(sourcesPath)) {
|
|
6167
|
-
const legacyPath = path.join(PROJECT_ROOT, 'rcode', 'brain', 'sources.yaml');
|
|
6168
|
-
if (fs.existsSync(legacyPath)) {
|
|
6169
|
-
sourcesPath = legacyPath;
|
|
6170
|
-
brainDir = path.join(PROJECT_ROOT, 'rcode', 'brain');
|
|
6171
|
-
}
|
|
6172
|
-
}
|
|
6173
|
-
|
|
6174
|
-
// Resolve a source's dest directory relative to brainDir.
|
|
6175
|
-
// Accepts legacy absolute-looking values ("rcode/brain/rcode-github/") by
|
|
6176
|
-
// stripping any leading "rcode/brain/" so the resolved path sits inside the
|
|
6177
|
-
// chosen brainDir. New sources.yaml should use bare names ("rcode-github/").
|
|
6178
|
-
function resolveDest(dest) {
|
|
6179
|
-
const trimmed = String(dest || '').replace(/^rcode\/brain\//, '').replace(/^\/+/, '');
|
|
6180
|
-
return path.join(brainDir, trimmed);
|
|
6181
|
-
}
|
|
6182
|
-
|
|
6183
|
-
if (!fs.existsSync(sourcesPath)) {
|
|
6184
|
-
return {
|
|
6185
|
-
ok: false,
|
|
6186
|
-
error: `sources.yaml missing at ${sourcesPath}. Run install or see issue #158.`,
|
|
6187
|
-
};
|
|
6188
|
-
}
|
|
6189
|
-
|
|
6190
|
-
// Minimal YAML reader specifically for sources.yaml — not a general parser.
|
|
6191
|
-
// Handles: `version: 1`, `defaults:` block, `sources:` list where each
|
|
6192
|
-
// entry is a `- name: X` block with sibling key: value lines and an
|
|
6193
|
-
// `paths:` sub-list of strings.
|
|
6194
|
-
function parseSourcesYaml(text) {
|
|
6195
|
-
const root = { version: null, defaults: {}, sources: [] };
|
|
6196
|
-
const lines = text.split('\n');
|
|
6197
|
-
let section = null;
|
|
6198
|
-
let current = null; // current source map
|
|
6199
|
-
let inPaths = false;
|
|
6200
|
-
let inDescription = false;
|
|
6201
|
-
let descLines = [];
|
|
6202
|
-
|
|
6203
|
-
function unquote(s) { return s.replace(/^['"]|['"]$/g, ''); }
|
|
6204
|
-
|
|
6205
|
-
for (const raw of lines) {
|
|
6206
|
-
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
6207
|
-
|
|
6208
|
-
// Flush description if we were collecting
|
|
6209
|
-
if (inDescription && raw.match(/^ {4}\S/) && !raw.trim().startsWith('-')) {
|
|
6210
|
-
// still inside the description block
|
|
6211
|
-
const m = raw.match(/^ *(.*)$/);
|
|
6212
|
-
if (m) descLines.push(m[1]);
|
|
6213
|
-
continue;
|
|
6214
|
-
} else if (inDescription) {
|
|
6215
|
-
current.description = descLines.join(' ').trim();
|
|
6216
|
-
inDescription = false;
|
|
6217
|
-
descLines = [];
|
|
6218
|
-
}
|
|
6219
|
-
|
|
6220
|
-
// Top-level keys
|
|
6221
|
-
const top = raw.match(/^(\w+):\s*(.*)$/);
|
|
6222
|
-
if (top) {
|
|
6223
|
-
const key = top[1], val = top[2].trim();
|
|
6224
|
-
if (key === 'version') { root.version = unquote(val); section = null; continue; }
|
|
6225
|
-
if (key === 'defaults') { section = 'defaults'; continue; }
|
|
6226
|
-
if (key === 'sources') { section = 'sources'; continue; }
|
|
6227
|
-
}
|
|
6228
|
-
|
|
6229
|
-
// defaults: indented key-value
|
|
6230
|
-
if (section === 'defaults') {
|
|
6231
|
-
const m = raw.match(/^ +([\w_]+):\s*(.*)$/);
|
|
6232
|
-
if (m) root.defaults[m[1]] = unquote(m[2]);
|
|
6233
|
-
continue;
|
|
6234
|
-
}
|
|
6235
|
-
|
|
6236
|
-
// sources: list items
|
|
6237
|
-
if (section === 'sources') {
|
|
6238
|
-
const startItem = raw.match(/^ *- ([\w_-]+):\s*(.*)$/);
|
|
6239
|
-
if (startItem) {
|
|
6240
|
-
current = {};
|
|
6241
|
-
current[startItem[1]] = unquote(startItem[2]);
|
|
6242
|
-
root.sources.push(current);
|
|
6243
|
-
inPaths = false;
|
|
6244
|
-
continue;
|
|
6245
|
-
}
|
|
6246
|
-
// paths: list-of-strings under current
|
|
6247
|
-
const pathsStart = raw.match(/^ +paths:\s*$/);
|
|
6248
|
-
if (pathsStart) { current.paths = []; inPaths = true; continue; }
|
|
6249
|
-
if (inPaths) {
|
|
6250
|
-
const pItem = raw.match(/^ *- (.*)$/);
|
|
6251
|
-
if (pItem) { current.paths.push(unquote(pItem[1])); continue; }
|
|
6252
|
-
inPaths = false;
|
|
6253
|
-
}
|
|
6254
|
-
// description: block scalar `>`
|
|
6255
|
-
const descStart = raw.match(/^ +description:\s*>\s*$/);
|
|
6256
|
-
if (descStart) { inDescription = true; descLines = []; continue; }
|
|
6257
|
-
// Regular key: value on current item
|
|
6258
|
-
const kv = raw.match(/^ +([\w_-]+):\s*(.*)$/);
|
|
6259
|
-
if (kv && current) {
|
|
6260
|
-
current[kv[1]] = unquote(kv[2]);
|
|
6261
|
-
}
|
|
6262
|
-
}
|
|
6263
|
-
}
|
|
6264
|
-
// final flush
|
|
6265
|
-
if (inDescription && current) current.description = descLines.join(' ').trim();
|
|
6266
|
-
return root;
|
|
6267
|
-
}
|
|
6268
|
-
|
|
6269
|
-
const cfg = parseSourcesYaml(fs.readFileSync(sourcesPath, 'utf8'));
|
|
6270
|
-
const sources = Array.isArray(cfg.sources) ? cfg.sources : [];
|
|
6271
|
-
|
|
6272
|
-
if (sub === 'list') {
|
|
6273
|
-
return {
|
|
6274
|
-
ok: true,
|
|
6275
|
-
version: cfg.version,
|
|
6276
|
-
sources: sources.map(s => ({
|
|
6277
|
-
name: s.name,
|
|
6278
|
-
repo: s.repo,
|
|
6279
|
-
dest: s.dest,
|
|
6280
|
-
placeholder: String(s.repo || '').includes('<PLACEHOLDER'),
|
|
6281
|
-
})),
|
|
6282
|
-
};
|
|
6283
|
-
}
|
|
6284
|
-
|
|
6285
|
-
if (sub === 'status') {
|
|
6286
|
-
const report = { ok: true, sources: [] };
|
|
6287
|
-
for (const s of sources) {
|
|
6288
|
-
const destPath = resolveDest(s.dest);
|
|
6289
|
-
const exists = fs.existsSync(destPath);
|
|
6290
|
-
report.sources.push({
|
|
6291
|
-
name: s.name,
|
|
6292
|
-
dest: s.dest,
|
|
6293
|
-
fetched: exists,
|
|
6294
|
-
placeholder: String(s.repo || '').includes('<PLACEHOLDER'),
|
|
6295
|
-
});
|
|
6296
|
-
}
|
|
6297
|
-
return report;
|
|
6298
|
-
}
|
|
6299
|
-
|
|
6300
|
-
if (sub !== 'pull') {
|
|
6301
|
-
return {
|
|
6302
|
-
ok: false,
|
|
6303
|
-
error: `Unknown brain subcommand: ${sub}. Try: pull | status | list`,
|
|
6304
|
-
};
|
|
6305
|
-
}
|
|
6306
|
-
|
|
6307
|
-
// sub === 'pull'
|
|
6308
|
-
const onlyName = args[1];
|
|
6309
|
-
const report = { ok: true, pulled: [], skipped: [], errors: [] };
|
|
6310
|
-
|
|
6311
|
-
for (const s of sources) {
|
|
6312
|
-
if (onlyName && s.name !== onlyName) continue;
|
|
6313
|
-
const repo = String(s.repo || '');
|
|
6314
|
-
|
|
6315
|
-
if (repo.includes('<PLACEHOLDER')) {
|
|
6316
|
-
report.skipped.push({ name: s.name, reason: 'placeholder URL — fill in via issue #162 (M5)' });
|
|
6317
|
-
continue;
|
|
6318
|
-
}
|
|
6319
|
-
|
|
6320
|
-
if (repo === 'self') {
|
|
6321
|
-
// In-repo copy — use rsync-ish node copy from paths under project root.
|
|
6322
|
-
const destPath = resolveDest(s.dest);
|
|
6323
|
-
fs.mkdirSync(destPath, { recursive: true });
|
|
6324
|
-
const paths = Array.isArray(s.paths) ? s.paths : [];
|
|
6325
|
-
let copied = 0;
|
|
6326
|
-
for (const pattern of paths) {
|
|
6327
|
-
// Very simple glob: expand ** to recursive copy.
|
|
6328
|
-
const base = pattern.split('**')[0].replace(/\/$/, '');
|
|
6329
|
-
const srcDir = path.join(PROJECT_ROOT, base);
|
|
6330
|
-
if (!fs.existsSync(srcDir)) continue;
|
|
6331
|
-
// Recursive copy of .md files
|
|
6332
|
-
function walk(dir) {
|
|
6333
|
-
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
6334
|
-
const full = path.join(dir, e.name);
|
|
6335
|
-
if (e.isDirectory()) { walk(full); continue; }
|
|
6336
|
-
if (!e.isFile()) continue;
|
|
6337
|
-
if (!full.endsWith('.md')) continue;
|
|
6338
|
-
const rel = path.relative(srcDir, full);
|
|
6339
|
-
const out = path.join(destPath, rel);
|
|
6340
|
-
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
6341
|
-
fs.copyFileSync(full, out);
|
|
6342
|
-
copied++;
|
|
6343
|
-
}
|
|
6344
|
-
}
|
|
6345
|
-
walk(srcDir);
|
|
6346
|
-
}
|
|
6347
|
-
report.pulled.push({ name: s.name, kind: 'self', files: copied });
|
|
6348
|
-
continue;
|
|
6349
|
-
}
|
|
6350
|
-
|
|
6351
|
-
// #925 — supply-chain guard. `brain pull` clones a remote repo and copies
|
|
6352
|
-
// its content into every rcode user's project context, so an attacker who
|
|
6353
|
-
// can edit sources.yaml (or a typo) must not silently pull untrusted code.
|
|
6354
|
-
// Only allow github.com URLs under an approved org allowlist; anything else
|
|
6355
|
-
// is rejected unless the user explicitly opts in with
|
|
6356
|
-
// RCODE_BRAIN_ALLOW_UNVERIFIED=1. Pinning to a commit SHA (source.ref) is
|
|
6357
|
-
// recommended over a moving branch — warn when a source tracks a branch.
|
|
6358
|
-
const BRAIN_ALLOWED_HOSTS = new Set(['github.com']);
|
|
6359
|
-
const BRAIN_ALLOWED_ORGS = new Set(['hanzlahabib', 'rcode-om']);
|
|
6360
|
-
if (process.env.RCODE_BRAIN_ALLOW_UNVERIFIED !== '1') {
|
|
6361
|
-
let host = '', org = '';
|
|
6362
|
-
const mm = repo.match(/(?:https?:\/\/|git@)([^/:]+)[/:]([^/]+)\//);
|
|
6363
|
-
if (mm) { host = mm[1]; org = mm[2]; }
|
|
6364
|
-
if (!BRAIN_ALLOWED_HOSTS.has(host) || !BRAIN_ALLOWED_ORGS.has(org)) {
|
|
6365
|
-
report.skipped.push({
|
|
6366
|
-
name: s.name,
|
|
6367
|
-
reason: `repo not in brain allowlist (${host || 'unknown host'}/${org || '?'}). ` +
|
|
6368
|
-
`Add the org to BRAIN_ALLOWED_ORGS or set RCODE_BRAIN_ALLOW_UNVERIFIED=1 to override.`,
|
|
6369
|
-
});
|
|
6370
|
-
continue;
|
|
6371
|
-
}
|
|
6372
|
-
if (!s.ref) {
|
|
6373
|
-
// Tracking a branch is mutable — a force-push changes what you pull.
|
|
6374
|
-
// Not fatal, but surface it so maintainers can pin a SHA via `ref:`.
|
|
6375
|
-
report.skipped.push({
|
|
6376
|
-
name: s.name,
|
|
6377
|
-
reason: `no pinned 'ref:' SHA — tracking branch '${s.branch || root.defaults.branch || 'main'}' is mutable. ` +
|
|
6378
|
-
`Pin a commit SHA in sources.yaml, or set RCODE_BRAIN_ALLOW_UNVERIFIED=1 to pull the branch tip.`,
|
|
6379
|
-
});
|
|
6380
|
-
continue;
|
|
6381
|
-
}
|
|
6382
|
-
}
|
|
6383
|
-
|
|
6384
|
-
// External git source — use sparse checkout into a tmp dir then copy.
|
|
6385
|
-
// #170 — global brain cache at ~/.rcode/brain-cache/<sha1(repo+branch+paths)>/.
|
|
6386
|
-
// Same source pulled from N projects = N clones today, 1 clone + N copies
|
|
6387
|
-
// after this change. Cache TTL is configurable per source (defaults to 6h).
|
|
6388
|
-
const { execSync, execFileSync: execFileSyncBrain } = require('child_process');
|
|
6389
|
-
const crypto = require('crypto');
|
|
6390
|
-
const os = require('os');
|
|
6391
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rcode-brain-'));
|
|
6392
|
-
const branch = s.branch || cfg.defaults?.branch || 'main';
|
|
6393
|
-
const sparsePaths = Array.isArray(s.paths) ? s.paths : [];
|
|
6394
|
-
|
|
6395
|
-
// Cache key = sha1(repo + branch + sparsePaths joined). Changing any of
|
|
6396
|
-
// those gets a fresh cache slot. Different projects pulling the same
|
|
6397
|
-
// (repo, branch, paths) tuple share one cached download.
|
|
6398
|
-
const cacheKey = crypto
|
|
6399
|
-
.createHash('sha1')
|
|
6400
|
-
.update(`${repo}\n${branch}\n${sparsePaths.sort().join(',')}`)
|
|
6401
|
-
.digest('hex')
|
|
6402
|
-
.slice(0, 16);
|
|
6403
|
-
const cacheRoot = path.join(os.homedir(), '.rcode', 'brain-cache');
|
|
6404
|
-
const cacheDir = path.join(cacheRoot, cacheKey);
|
|
6405
|
-
const cacheManifest = path.join(cacheDir, '.cache-manifest.json');
|
|
6406
|
-
|
|
6407
|
-
// Parse cache_ttl: accept '6h', '15m', '2d', or seconds as bare number.
|
|
6408
|
-
function parseTtlSeconds(raw, fallback) {
|
|
6409
|
-
if (raw == null || raw === '') return fallback;
|
|
6410
|
-
const s = String(raw).trim();
|
|
6411
|
-
const m = s.match(/^(\d+)([smhd]?)$/i);
|
|
6412
|
-
if (!m) return fallback;
|
|
6413
|
-
const n = parseInt(m[1], 10);
|
|
6414
|
-
switch ((m[2] || 's').toLowerCase()) {
|
|
6415
|
-
case 'd': return n * 86400;
|
|
6416
|
-
case 'h': return n * 3600;
|
|
6417
|
-
case 'm': return n * 60;
|
|
6418
|
-
default: return n;
|
|
6419
|
-
}
|
|
6420
|
-
}
|
|
6421
|
-
const ttlSeconds = parseTtlSeconds(s.cache_ttl || cfg.defaults?.cache_ttl, 6 * 3600);
|
|
6422
|
-
|
|
6423
|
-
function readCacheManifest() {
|
|
6424
|
-
if (!fs.existsSync(cacheManifest)) return null;
|
|
6425
|
-
try { return JSON.parse(fs.readFileSync(cacheManifest, 'utf8')); }
|
|
6426
|
-
catch { return null; }
|
|
6427
|
-
}
|
|
6428
|
-
function isCacheFresh(manifest) {
|
|
6429
|
-
if (!manifest || typeof manifest.pulled_at !== 'string') return false;
|
|
6430
|
-
const ageMs = Date.now() - Date.parse(manifest.pulled_at);
|
|
6431
|
-
return Number.isFinite(ageMs) && (ageMs / 1000) < ttlSeconds;
|
|
6432
|
-
}
|
|
6433
|
-
function copyTree(src, dst) {
|
|
6434
|
-
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
|
6435
|
-
if (e.name === '.git' || e.name === '.cache-manifest.json') continue;
|
|
6436
|
-
const sp = path.join(src, e.name);
|
|
6437
|
-
const dp = path.join(dst, e.name);
|
|
6438
|
-
if (e.isDirectory()) { fs.mkdirSync(dp, { recursive: true }); copyTree(sp, dp); }
|
|
6439
|
-
else if (e.isFile()) fs.copyFileSync(sp, dp);
|
|
6440
|
-
}
|
|
6441
|
-
}
|
|
6442
|
-
|
|
6443
|
-
const destPath = resolveDest(s.dest);
|
|
6444
|
-
try {
|
|
6445
|
-
// Cache hit path — copy from ~/.rcode/brain-cache/<key>/ directly.
|
|
6446
|
-
const cached = readCacheManifest();
|
|
6447
|
-
if (cached && isCacheFresh(cached)) {
|
|
6448
|
-
fs.mkdirSync(destPath, { recursive: true });
|
|
6449
|
-
copyTree(cacheDir, destPath);
|
|
6450
|
-
report.pulled.push({ name: s.name, kind: 'git', repo, branch, cache: 'hit', cache_key: cacheKey });
|
|
6451
|
-
continue;
|
|
6452
|
-
}
|
|
6453
|
-
|
|
6454
|
-
// Cache miss — clone, then warm the cache for next time.
|
|
6455
|
-
// Use --no-checkout + explicit sparse-checkout init + set + checkout
|
|
6456
|
-
// because `git clone --sparse` combined with --filter=blob:none has
|
|
6457
|
-
// an intermittent failure mode where git misreads the URL as a path.
|
|
6458
|
-
// execFileSync — repo/branch/tmp/sparsePaths from user config; no shell so
|
|
6459
|
-
// values with spaces, quotes, or semicolons cannot inject commands (#754).
|
|
6460
|
-
execFileSyncBrain('git', [
|
|
6461
|
-
'clone', '--depth=1', '--filter=blob:none', '--no-checkout',
|
|
6462
|
-
`--branch=${branch}`, repo, tmp,
|
|
6463
|
-
], { stdio: 'pipe' });
|
|
6464
|
-
execFileSyncBrain('git', ['-C', tmp, 'sparse-checkout', 'init', '--no-cone'], { stdio: 'pipe' });
|
|
6465
|
-
execFileSyncBrain('git', ['-C', tmp, 'sparse-checkout', 'set', ...sparsePaths], { stdio: 'pipe' });
|
|
6466
|
-
execFileSyncBrain('git', ['-C', tmp, 'checkout'], { stdio: 'pipe' });
|
|
6467
|
-
|
|
6468
|
-
// Warm cache before destination copy so a copy failure to dest still
|
|
6469
|
-
// saves the next pull. Replace any stale slot atomically.
|
|
6470
|
-
try {
|
|
6471
|
-
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
6472
|
-
fs.mkdirSync(cacheDir, { recursive: true });
|
|
6473
|
-
copyTree(tmp, cacheDir);
|
|
6474
|
-
const commitSha = (() => {
|
|
6475
|
-
try { return execFileSyncBrain('git', ['-C', tmp, 'rev-parse', 'HEAD'], { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); }
|
|
6476
|
-
catch { return null; }
|
|
6477
|
-
})();
|
|
6478
|
-
fs.writeFileSync(cacheManifest, JSON.stringify({
|
|
6479
|
-
repo, branch, paths: sparsePaths,
|
|
6480
|
-
pulled_at: new Date().toISOString(),
|
|
6481
|
-
commit_sha: commitSha,
|
|
6482
|
-
ttl_seconds: ttlSeconds,
|
|
6483
|
-
}, null, 2));
|
|
6484
|
-
} catch (_) { /* cache warming is best-effort */ }
|
|
6485
|
-
|
|
6486
|
-
fs.mkdirSync(destPath, { recursive: true });
|
|
6487
|
-
copyTree(tmp, destPath);
|
|
6488
|
-
report.pulled.push({ name: s.name, kind: 'git', repo, branch, cache: 'miss', cache_key: cacheKey });
|
|
6489
|
-
} catch (e) {
|
|
6490
|
-
report.errors.push({ name: s.name, error: String(e.message || e).slice(0, 200) });
|
|
6491
|
-
} finally {
|
|
6492
|
-
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
6493
|
-
}
|
|
6494
|
-
}
|
|
6495
|
-
|
|
6496
|
-
if (report.errors.length) report.ok = false;
|
|
6497
|
-
return report;
|
|
6498
|
-
}
|
|
6499
6233
|
|
|
6500
|
-
/**
|
|
6501
|
-
* cmdProgress — single pre-computed progress blob (issue #159).
|
|
6502
|
-
*
|
|
6503
|
-
* Subcommands:
|
|
6504
|
-
* progress init Full snapshot — everything /rcode-progress needs.
|
|
6505
|
-
* progress bar --raw ASCII bar only (e.g. "[████░░░░] 50%").
|
|
6506
|
-
* progress insights insights[] array (drift warnings, between-milestone detection).
|
|
6507
|
-
* progress routes intent-tree routes[] for Next Up menu.
|
|
6508
|
-
*
|
|
6509
|
-
* Pushing logic into the CLI lets the workflow file shrink to pure
|
|
6510
|
-
* rendering — no ROADMAP.md parsing, no SUMMARY.md walking, no grep.
|
|
6511
|
-
*/
|
|
6512
|
-
function cmdProgress(args) {
|
|
6513
|
-
const sub = args[0] || 'init';
|
|
6514
|
-
const rawMode = args.includes('--raw');
|
|
6515
|
-
// #200 — opt-in strict mode: exit 1 when insights contain drift/undercount.
|
|
6516
|
-
// Off by default (warning preserves the soft-surface UX). Toggle via --strict
|
|
6517
|
-
// flag or RCODE_STRICT_STATE=true env var. Used by CI / pre-deploy gates.
|
|
6518
|
-
const strictMode = args.includes('--strict')
|
|
6519
|
-
|| /^(true|1|yes)$/i.test(process.env.RCODE_STRICT_STATE || '');
|
|
6520
|
-
|
|
6521
|
-
// Resolve paths — workflow files may run this from any subdirectory.
|
|
6522
|
-
const statePath = path.join(RCODE_DIR, 'state.json');
|
|
6523
|
-
const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
|
|
6524
|
-
const phasesDir = path.join(PLANNING_DIR, 'phases');
|
|
6525
|
-
|
|
6526
|
-
function readState() {
|
|
6527
|
-
if (!fs.existsSync(statePath)) return null;
|
|
6528
|
-
try { return JSON.parse(fs.readFileSync(statePath, 'utf8')); }
|
|
6529
|
-
catch { return null; }
|
|
6530
|
-
}
|
|
6531
|
-
|
|
6532
|
-
function parseRoadmapPhases() {
|
|
6533
|
-
if (!fs.existsSync(roadmapPath)) return [];
|
|
6534
|
-
const text = fs.readFileSync(roadmapPath, 'utf8');
|
|
6535
|
-
const phases = [];
|
|
6536
|
-
const seen = new Set();
|
|
6537
|
-
|
|
6538
|
-
// Format A — markdown pipe tables: | 07 | Name | Goal |
|
|
6539
|
-
// Phase 14 / #476 — \d+ supports high-N phases (1000+, hot-track).
|
|
6540
|
-
const rowRe = /^\|\s*(\d+(?:\.\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm;
|
|
6541
|
-
let m;
|
|
6542
|
-
while ((m = rowRe.exec(text)) !== null) {
|
|
6543
|
-
const num = m[1].trim();
|
|
6544
|
-
const name = m[2].trim();
|
|
6545
|
-
const goal = m[3].trim();
|
|
6546
|
-
if (!/^\d/.test(num)) continue;
|
|
6547
|
-
if (name.toLowerCase() === 'phase') continue;
|
|
6548
|
-
if (seen.has(num)) continue;
|
|
6549
|
-
seen.add(num);
|
|
6550
|
-
phases.push({ number: num, name, goal });
|
|
6551
|
-
}
|
|
6552
|
-
|
|
6553
|
-
// Format B — heading style: ## Phase 07 — Name / ### Phase 07: Name / ## Phase 07 - Name
|
|
6554
|
-
// Phase 14 / #476 — \d+ supports high-N phases (1000+, hot-track).
|
|
6555
|
-
const headRe = /^#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)\s*[—\-:]\s*([^\n]+)$/gm;
|
|
6556
|
-
while ((m = headRe.exec(text)) !== null) {
|
|
6557
|
-
const num = m[1].trim();
|
|
6558
|
-
const name = m[2].trim();
|
|
6559
|
-
if (seen.has(num)) continue;
|
|
6560
|
-
seen.add(num);
|
|
6561
|
-
// Goal: pull the first non-empty line after the heading that starts with **Goal:** or is plain text
|
|
6562
|
-
const after = text.slice(headRe.lastIndex).split(/\n/).slice(0, 8).join('\n');
|
|
6563
|
-
const goalMatch = after.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
|
|
6564
|
-
phases.push({ number: num, name, goal: goalMatch ? goalMatch[1].trim() : '' });
|
|
6565
|
-
}
|
|
6566
|
-
|
|
6567
|
-
// Sort numerically (handles "07" vs "10" string ordering correctly)
|
|
6568
|
-
phases.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
|
|
6569
|
-
return phases;
|
|
6570
|
-
}
|
|
6571
|
-
|
|
6572
|
-
function extractMilestoneName() {
|
|
6573
|
-
// 1. Try ROADMAP.md headings — match any milestone header form
|
|
6574
|
-
if (fs.existsSync(roadmapPath)) {
|
|
6575
|
-
const text = fs.readFileSync(roadmapPath, 'utf8');
|
|
6576
|
-
// Bold form: **Milestone: v1.0 — Name** or **Milestone v1.0 — Name**
|
|
6577
|
-
let m = text.match(/\*\*\s*Milestone\s*:?\s*([^\n*]+?)\s*\*\*/i);
|
|
6578
|
-
if (m) return m[1].trim();
|
|
6579
|
-
// Header form: ## Milestone v1.0 — Name / ## Milestone: v1.0 — Name
|
|
6580
|
-
m = text.match(/^#{1,4}\s+Milestone\s*:?\s*([^\n]+)$/m);
|
|
6581
|
-
if (m) return m[1].trim();
|
|
6582
|
-
}
|
|
6583
|
-
// 2. Fall back to state.json milestone field
|
|
6584
|
-
try {
|
|
6585
|
-
if (fs.existsSync(statePath)) {
|
|
6586
|
-
const s = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
6587
|
-
if (s && s.milestone) return String(s.milestone).trim();
|
|
6588
|
-
}
|
|
6589
|
-
} catch { /* ignore */ }
|
|
6590
|
-
return null;
|
|
6591
|
-
}
|
|
6592
|
-
|
|
6593
|
-
// Treat any of `number`, `id`, or `name` as the phase identifier.
|
|
6594
|
-
// Different commands historically write different field names — accept all.
|
|
6595
|
-
function phaseKey(p) {
|
|
6596
|
-
return String(p?.number ?? p?.id ?? p?.name ?? '').trim();
|
|
6597
|
-
}
|
|
6598
|
-
|
|
6599
|
-
function walkPhaseDirs() {
|
|
6600
|
-
if (!fs.existsSync(phasesDir)) return {};
|
|
6601
|
-
const byNum = {};
|
|
6602
|
-
for (const entry of fs.readdirSync(phasesDir)) {
|
|
6603
|
-
const full = path.join(phasesDir, entry);
|
|
6604
|
-
if (!fs.statSync(full).isDirectory()) continue;
|
|
6605
|
-
// Phase 14 / #476 — \d+ supports high-N phase dirs (1000+).
|
|
6606
|
-
const numMatch = entry.match(/^(\d+(?:\.\d+)?)/);
|
|
6607
|
-
if (!numMatch) continue;
|
|
6608
|
-
const num = numMatch[1];
|
|
6609
|
-
const files = fs.readdirSync(full);
|
|
6610
|
-
byNum[num] = {
|
|
6611
|
-
path: full,
|
|
6612
|
-
dirName: entry,
|
|
6613
|
-
plan_count: files.filter(f => /-SPRINT\.md$/i.test(f)).length,
|
|
6614
|
-
summary_count: files.filter(f => /SUMMARY\.md$|-SUMMARY\.md$/.test(f)).length,
|
|
6615
|
-
has_research: files.includes('RESEARCH.md'),
|
|
6616
|
-
has_context: files.includes('CONTEXT.md'),
|
|
6617
|
-
has_verification: files.some(f => /VERIFICATION\.md$/i.test(f)),
|
|
6618
|
-
};
|
|
6619
|
-
}
|
|
6620
|
-
return byNum;
|
|
6621
|
-
}
|
|
6622
|
-
|
|
6623
|
-
// #200 — opt-in strict gate. Walks insights for drift/undercount kinds and
|
|
6624
|
-
// exits 1 with the failure list to stderr. No-op when strictMode=false.
|
|
6625
|
-
function enforceStrictGate(insightsList) {
|
|
6626
|
-
if (!strictMode) return;
|
|
6627
|
-
const blocking = (insightsList || []).filter(i =>
|
|
6628
|
-
i && (i.kind === 'drift' || i.kind === 'undercount') && i.severity !== 'info'
|
|
6629
|
-
);
|
|
6630
|
-
if (blocking.length === 0) return;
|
|
6631
|
-
process.stderr.write('✖ State drift detected — state.json is out of sync with disk.\n');
|
|
6632
|
-
for (const i of blocking) process.stderr.write(` • ${i.message}\n`);
|
|
6633
|
-
process.stderr.write('\n Auto-fix: node .rcode/bin/rcode-tools.cjs state sync --from-disk\n');
|
|
6634
|
-
process.stderr.write(' Inspect: node .rcode/bin/rcode-tools.cjs state read\n');
|
|
6635
|
-
process.exit(1);
|
|
6636
|
-
}
|
|
6637
|
-
|
|
6638
|
-
function detectInsights(state, roadmapPhases, diskByNum) {
|
|
6639
|
-
const insights = [];
|
|
6640
|
-
const statePhases = (state && (state.state?.phases || state.phases)) || [];
|
|
6641
|
-
|
|
6642
|
-
// Drift: ROADMAP phase count vs state.json phase count
|
|
6643
|
-
if (roadmapPhases.length > 0 && statePhases.length !== roadmapPhases.length) {
|
|
6644
|
-
insights.push({
|
|
6645
|
-
kind: 'drift',
|
|
6646
|
-
severity: 'warn',
|
|
6647
|
-
message: `ROADMAP.md has ${roadmapPhases.length} phases, state.json has ${statePhases.length}. Run: node .rcode/bin/rcode-tools.cjs state sync --from-disk`,
|
|
6648
|
-
});
|
|
6649
|
-
}
|
|
6650
|
-
|
|
6651
|
-
// Undercount: phases that exist on disk but not in state.
|
|
6652
|
-
// Accept any of `number`, `id`, or `name` as the phase identifier — the codebase historically writes different fields.
|
|
6653
|
-
// Also normalize "07" / "7" / 7 to a comparable form.
|
|
6654
|
-
const norm = (k) => String(k ?? '').replace(/^0+(\d)/, '$1');
|
|
6655
|
-
const statePhaseNums = new Set(statePhases.map(p => norm(phaseKey(p))));
|
|
6656
|
-
const diskPhaseNums = Object.keys(diskByNum);
|
|
6657
|
-
const missingFromState = diskPhaseNums.filter(n => !statePhaseNums.has(norm(n)));
|
|
6658
|
-
if (missingFromState.length > 0) {
|
|
6659
|
-
insights.push({
|
|
6660
|
-
kind: 'undercount',
|
|
6661
|
-
severity: 'warn',
|
|
6662
|
-
message: `${missingFromState.length} phase dir(s) on disk not registered in state.json: ${missingFromState.slice(0, 5).join(', ')}`,
|
|
6663
|
-
});
|
|
6664
|
-
}
|
|
6665
|
-
|
|
6666
|
-
// Phantom-complete: phase claimed Complete (in ROADMAP or state) but missing
|
|
6667
|
-
// PLAN.md AND SUMMARY.md on disk. User-visible bug: /rcode-status would
|
|
6668
|
-
// happily report 'all complete' while /rcode-audit correctly flagged the
|
|
6669
|
-
// gap because the two read different sources of truth.
|
|
6670
|
-
// Surfaced 2026-04-29 in a real session — siraaj phases 07-12 had ROADMAP
|
|
6671
|
-
// markers but zero artifacts.
|
|
6672
|
-
const phantomCompletes = [];
|
|
6673
|
-
const claimedComplete = (p) => {
|
|
6674
|
-
if (!p) return false;
|
|
6675
|
-
const s = String(p.status ?? '').toLowerCase();
|
|
6676
|
-
return p.completed || s === 'complete' || s === 'completed' || s === 'done';
|
|
6677
|
-
};
|
|
6678
|
-
// Walk ROADMAP-claimed completes and state-claimed completes, both directions.
|
|
6679
|
-
const completeKeys = new Set();
|
|
6680
|
-
for (const p of roadmapPhases) if (claimedComplete(p)) completeKeys.add(norm(phaseKey(p)));
|
|
6681
|
-
for (const p of statePhases) if (claimedComplete(p)) completeKeys.add(norm(phaseKey(p)));
|
|
6682
|
-
for (const k of completeKeys) {
|
|
6683
|
-
const disk = diskByNum[k] || diskByNum[k.padStart(2, '0')];
|
|
6684
|
-
// Only flag when the phase dir EXISTS — purely-state-only entries are a
|
|
6685
|
-
// separate problem (drift/undercount above). Here we want claim-vs-files.
|
|
6686
|
-
if (!disk) continue;
|
|
6687
|
-
if (disk.plan_count === 0 && disk.summary_count === 0) {
|
|
6688
|
-
phantomCompletes.push(k);
|
|
6689
|
-
}
|
|
6690
|
-
}
|
|
6691
|
-
if (phantomCompletes.length > 0) {
|
|
6692
|
-
insights.push({
|
|
6693
|
-
kind: 'phantom-complete',
|
|
6694
|
-
severity: 'warn',
|
|
6695
|
-
message: `${phantomCompletes.length} phase(s) marked Complete but missing both PLAN.md and SUMMARY.md on disk: ${phantomCompletes.slice(0, 5).join(', ')}. The completion claim is unsupported. Run /rcode-audit phase <N> to inspect.`,
|
|
6696
|
-
});
|
|
6697
|
-
}
|
|
6698
|
-
|
|
6699
|
-
// Between-milestones heuristic: no current_phase + previous milestone's last phase is complete
|
|
6700
|
-
if (state && state.current_phase === null && statePhases.length > 0) {
|
|
6701
|
-
const allComplete = statePhases.every(p => p.status === 'complete' || p.completed);
|
|
6702
|
-
if (allComplete) {
|
|
6703
|
-
insights.push({
|
|
6704
|
-
kind: 'between-milestones',
|
|
6705
|
-
severity: 'info',
|
|
6706
|
-
message: 'All registered phases complete — effectively between milestones. Consider /rcode-audit-milestone or /rcode-new-milestone.',
|
|
6707
|
-
});
|
|
6708
|
-
}
|
|
6709
|
-
}
|
|
6710
|
-
|
|
6711
|
-
// Stuck-phase: in_progress phase with no commits touching its .planning dir in 7+ days
|
|
6712
|
-
try {
|
|
6713
|
-
const inProgressPhases = statePhases.filter(p => {
|
|
6714
|
-
const s = String(p.status ?? '').toLowerCase();
|
|
6715
|
-
return s === 'in_progress' || s === 'in-progress' || s === 'executing';
|
|
6716
|
-
});
|
|
6717
|
-
for (const p of inProgressPhases) {
|
|
6718
|
-
const key = norm(phaseKey(p));
|
|
6719
|
-
const disk = diskByNum[key] || diskByNum[key.padStart(2, '0')];
|
|
6720
|
-
if (!disk) continue;
|
|
6721
|
-
const dirName = disk.dirName;
|
|
6722
|
-
const gitArgs = ['log', '--oneline', '--since=7 days ago', '--', `.planning/phases/${dirName}/`];
|
|
6723
|
-
let recentCommits = '';
|
|
6724
|
-
try {
|
|
6725
|
-
recentCommits = require('child_process').execSync(
|
|
6726
|
-
`git ${gitArgs.join(' ')}`,
|
|
6727
|
-
{ cwd: PROJECT_ROOT, stdio: 'pipe', timeout: 5000 }
|
|
6728
|
-
).toString().trim();
|
|
6729
|
-
} catch { /* git not available or no history */ }
|
|
6730
|
-
if (recentCommits === '') {
|
|
6731
|
-
insights.push({
|
|
6732
|
-
kind: 'stuck-phase',
|
|
6733
|
-
severity: 'warn',
|
|
6734
|
-
message: `Phase ${key} is in progress but has no commits in the last 7 days. It may be stuck. Run /rcode-status or /rcode-audit phase ${key} to investigate.`,
|
|
6735
|
-
});
|
|
6736
|
-
}
|
|
6737
|
-
}
|
|
6738
|
-
} catch { /* non-fatal — git unavailable or project root not set */ }
|
|
6739
|
-
|
|
6740
|
-
return insights;
|
|
6741
|
-
}
|
|
6742
|
-
|
|
6743
|
-
function deriveRoutes(state, roadmapPhases, diskByNum, insights) {
|
|
6744
|
-
const routes = [];
|
|
6745
|
-
const statePhases = (state && (state.state?.phases || state.phases)) || [];
|
|
6746
|
-
|
|
6747
|
-
// Route A — phases with pending plans (ready to execute).
|
|
6748
|
-
// Issue #653 — never recommend executing a phase whose state.json status
|
|
6749
|
-
// is already complete/done/verified, even if its on-disk plan_count >
|
|
6750
|
-
// summary_count. Missing second summary file is not the canonical
|
|
6751
|
-
// completion signal; state.json is. Run /rcode-audit phase <N> for
|
|
6752
|
-
// disk-vs-state drift, but stop steering users into re-executing
|
|
6753
|
-
// finished work.
|
|
6754
|
-
const isPhaseDone = (p) => {
|
|
6755
|
-
const s = String((p && p.status) || '').toLowerCase();
|
|
6756
|
-
return s === 'complete' || s === 'completed' || s === 'done' || s === 'verified' || Boolean(p && p.completed);
|
|
6757
|
-
};
|
|
6758
|
-
const pendingExec = statePhases.filter(p => {
|
|
6759
|
-
if (isPhaseDone(p)) return false;
|
|
6760
|
-
const disk = diskByNum[phaseKey(p)];
|
|
6761
|
-
return disk && disk.plan_count > disk.summary_count;
|
|
6762
|
-
}).slice(0, 3);
|
|
6763
|
-
for (const p of pendingExec) {
|
|
6764
|
-
const k = phaseKey(p);
|
|
6765
|
-
routes.push({ letter: 'A', label: '', command: `/rcode-execute ${k}` });
|
|
6766
|
-
}
|
|
6767
|
-
|
|
6768
|
-
// Route B — phases with research but no plans
|
|
6769
|
-
const researchOnly = Object.entries(diskByNum)
|
|
6770
|
-
.filter(([num, d]) => d.has_research && d.plan_count === 0)
|
|
6771
|
-
.slice(0, 3);
|
|
6772
|
-
for (const [num] of researchOnly) {
|
|
6773
|
-
routes.push({ letter: 'B', label: '', command: `/rcode-plan ${num}` });
|
|
6774
|
-
}
|
|
6775
|
-
|
|
6776
|
-
// Route B' — in-progress phases without plans
|
|
6777
|
-
const inProgressNoPlan = statePhases
|
|
6778
|
-
.filter(p => (p.status === 'in_progress' || p.status === 'in-progress'))
|
|
6779
|
-
.filter(p => {
|
|
6780
|
-
const disk = diskByNum[phaseKey(p)];
|
|
6781
|
-
return !disk || disk.plan_count === 0;
|
|
6782
|
-
})
|
|
6783
|
-
.slice(0, 2);
|
|
6784
|
-
for (const p of inProgressNoPlan) {
|
|
6785
|
-
const k = phaseKey(p);
|
|
6786
|
-
routes.push({ letter: 'B', label: '', command: `/rcode-plan ${k}` });
|
|
6787
|
-
}
|
|
6788
|
-
|
|
6789
|
-
// Route C — close out milestone if everything seems done
|
|
6790
|
-
const allDone = statePhases.length > 0 && statePhases.every(p => p.status === 'complete' || p.completed);
|
|
6791
|
-
if (allDone) {
|
|
6792
|
-
// Count unverified phases (complete but no VERIFICATION.md on disk)
|
|
6793
|
-
const unverifiedCount = statePhases.filter(p => {
|
|
6794
|
-
const disk = diskByNum[phaseKey(p)];
|
|
6795
|
-
return (p.status === 'complete' || p.completed) && disk && !disk.has_verification;
|
|
6796
|
-
}).length;
|
|
6797
|
-
const hasDrift = (insights || []).some(i => i.kind === 'roadmap-drift' || (i.message && i.message.includes('ROADMAP')));
|
|
6798
|
-
const auditArgs = [];
|
|
6799
|
-
if (unverifiedCount > 0) auditArgs.push(String(unverifiedCount));
|
|
6800
|
-
if (hasDrift) auditArgs.push('--fix-drift');
|
|
6801
|
-
const auditCmd = auditArgs.length > 0
|
|
6802
|
-
? `/rcode-audit-milestone ${auditArgs.join(' ')}`
|
|
6803
|
-
: '/rcode-audit-milestone';
|
|
6804
|
-
routes.push({ letter: 'C', label: '', command: auditCmd });
|
|
6805
|
-
routes.push({ letter: 'C', label: '', command: '/rcode-complete-milestone' });
|
|
6806
|
-
}
|
|
6807
|
-
|
|
6808
|
-
// Fallback — nothing obvious: offer status
|
|
6809
|
-
if (routes.length === 0) {
|
|
6810
|
-
routes.push({ letter: 'A', label: '', command: '/rcode-progress' });
|
|
6811
|
-
routes.push({ letter: 'B', label: '', command: '/rcode-council' });
|
|
6812
|
-
}
|
|
6813
|
-
|
|
6814
|
-
return routes;
|
|
6815
|
-
}
|
|
6816
|
-
|
|
6817
|
-
function buildBar(completed, total) {
|
|
6818
|
-
if (!total) return '[░░░░░░░░░░░░░░░░░░░░] 0/0 (0%)';
|
|
6819
|
-
const pct = Math.round((completed / total) * 100);
|
|
6820
|
-
const width = 20;
|
|
6821
|
-
const filled = Math.min(width, Math.round((completed / total) * width));
|
|
6822
|
-
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
6823
|
-
return `[${bar}] ${completed}/${total} (${pct}%)`;
|
|
6824
|
-
}
|
|
6825
|
-
|
|
6826
|
-
/**
|
|
6827
|
-
* Compute weighted progress that recognizes intermediate phase states.
|
|
6828
|
-
* Weights: has_context only = 0.15, has_research = 0.25, has plan = 0.5,
|
|
6829
|
-
* has verification or summary = 1.0.
|
|
6830
|
-
* Returns { weighted: number (0..total), pct: number (0..100) }.
|
|
6831
|
-
*/
|
|
6832
|
-
function computeWeightedProgress(stPhases, diskMap) {
|
|
6833
|
-
if (!stPhases.length) return { weighted: 0, pct: 0 };
|
|
6834
|
-
const norm = (k) => String(k ?? '').replace(/^0+(\d)/, '$1');
|
|
6835
|
-
let sum = 0;
|
|
6836
|
-
for (const p of stPhases) {
|
|
6837
|
-
const k = norm(phaseKey(p));
|
|
6838
|
-
if (p.status === 'complete' || p.completed) { sum += 1; continue; }
|
|
6839
|
-
const disk = diskMap[k] || diskMap[phaseKey(p)];
|
|
6840
|
-
if (!disk) continue;
|
|
6841
|
-
if (disk.summary_count > 0) { sum += 1; continue; }
|
|
6842
|
-
if (disk.has_verification) { sum += 0.85; continue; }
|
|
6843
|
-
if (disk.plan_count > 0) { sum += 0.5; continue; }
|
|
6844
|
-
if (disk.has_research) { sum += 0.25; continue; }
|
|
6845
|
-
if (disk.has_context) { sum += 0.15; continue; }
|
|
6846
|
-
}
|
|
6847
|
-
const total = Math.max(stPhases.length, 1);
|
|
6848
|
-
return { weighted: Math.round(sum * 100) / 100, pct: Math.round((sum / total) * 100) };
|
|
6849
|
-
}
|
|
6850
|
-
|
|
6851
|
-
function buildWeightedBar(stPhases, diskMap, total) {
|
|
6852
|
-
const { weighted, pct } = computeWeightedProgress(stPhases, diskMap);
|
|
6853
|
-
if (!total) return '[░░░░░░░░░░░░░░░░░░░░] 0/0 (0%)';
|
|
6854
|
-
const width = 20;
|
|
6855
|
-
const filled = Math.min(width, Math.round((weighted / total) * width));
|
|
6856
|
-
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
6857
|
-
return `[${bar}] ~${pct}% weighted`;
|
|
6858
|
-
}
|
|
6859
|
-
|
|
6860
|
-
// Build the core snapshot once — all subcommands derive from it.
|
|
6861
|
-
const state = readState();
|
|
6862
|
-
const roadmapPhases = parseRoadmapPhases();
|
|
6863
|
-
const diskByNum = walkPhaseDirs();
|
|
6864
|
-
const statePhases = (state && (state.state?.phases || state.phases)) || [];
|
|
6865
|
-
const completedCount = statePhases.filter(p => p.status === 'complete' || p.completed).length;
|
|
6866
|
-
const phaseCount = Math.max(statePhases.length, roadmapPhases.length);
|
|
6867
|
-
|
|
6868
|
-
if (sub === 'bar') {
|
|
6869
|
-
const bar = buildBar(completedCount, phaseCount);
|
|
6870
|
-
if (rawMode) { console.log(bar); process.exit(0); }
|
|
6871
|
-
return { ok: true, bar, completed: completedCount, total: phaseCount };
|
|
6872
|
-
}
|
|
6873
|
-
|
|
6874
|
-
if (sub === 'insights') {
|
|
6875
|
-
const insightsList = detectInsights(state, roadmapPhases, diskByNum);
|
|
6876
|
-
enforceStrictGate(insightsList);
|
|
6877
|
-
return { ok: true, insights: insightsList };
|
|
6878
|
-
}
|
|
6879
|
-
|
|
6880
|
-
if (sub === 'routes') {
|
|
6881
|
-
const routeInsights = detectInsights(state, roadmapPhases, diskByNum);
|
|
6882
|
-
return { ok: true, routes: deriveRoutes(state, roadmapPhases, diskByNum, routeInsights) };
|
|
6883
|
-
}
|
|
6884
|
-
|
|
6885
|
-
// sub === 'init' (default) — full snapshot
|
|
6886
|
-
const currentPhase = state && state.current_phase;
|
|
6887
|
-
const insights = detectInsights(state, roadmapPhases, diskByNum);
|
|
6888
|
-
enforceStrictGate(insights);
|
|
6889
|
-
const routes = deriveRoutes(state, roadmapPhases, diskByNum, insights);
|
|
6890
|
-
const { weighted: weightedCompleted, pct: weightedPct } = computeWeightedProgress(statePhases, diskByNum);
|
|
6891
|
-
|
|
6892
|
-
return {
|
|
6893
|
-
ok: true,
|
|
6894
|
-
project: state && state.project,
|
|
6895
|
-
milestone: extractMilestoneName(),
|
|
6896
|
-
current_phase: currentPhase,
|
|
6897
|
-
phase_count: phaseCount,
|
|
6898
|
-
completed_count: completedCount,
|
|
6899
|
-
weighted_progress: weightedPct,
|
|
6900
|
-
bar: buildBar(completedCount, phaseCount),
|
|
6901
|
-
weighted_bar: buildWeightedBar(statePhases, diskByNum, phaseCount),
|
|
6902
|
-
phases: (() => {
|
|
6903
|
-
// Prefer ROADMAP-parsed phases when available; fall back to state.phases
|
|
6904
|
-
// when the roadmap doesn't use a parseable format. Normalize "07" / "7" / 7.
|
|
6905
|
-
const norm = (k) => String(k ?? '').replace(/^0+(\d)/, '$1');
|
|
6906
|
-
const source = roadmapPhases.length > 0 ? roadmapPhases : statePhases.map(p => ({
|
|
6907
|
-
number: phaseKey(p),
|
|
6908
|
-
name: p.name || '',
|
|
6909
|
-
goal: p.goal || '',
|
|
6910
|
-
status: p.status,
|
|
6911
|
-
}));
|
|
6912
|
-
return source.map(p => {
|
|
6913
|
-
const k = phaseKey(p);
|
|
6914
|
-
const sp = statePhases.find(x => norm(phaseKey(x)) === norm(k));
|
|
6915
|
-
return {
|
|
6916
|
-
...p,
|
|
6917
|
-
number: k,
|
|
6918
|
-
status: p.status || (sp && sp.status) || null,
|
|
6919
|
-
disk: diskByNum[k] || null,
|
|
6920
|
-
in_state: !!sp,
|
|
6921
|
-
};
|
|
6922
|
-
});
|
|
6923
|
-
})(),
|
|
6924
|
-
decisions: state ? (state.decisions || []).slice(-3) : [],
|
|
6925
|
-
blockers: state ? (state.blockers || []).filter(b => !b.resolved).slice(0, 5) : [],
|
|
6926
|
-
insights,
|
|
6927
|
-
routes,
|
|
6928
|
-
updated: state && state.updated,
|
|
6929
|
-
};
|
|
6930
|
-
}
|
|
6931
|
-
|
|
6932
|
-
/**
|
|
6933
|
-
* cmdSummaryExtract — surgically pull named fields from a SUMMARY.md.
|
|
6934
|
-
* Avoids whole-file loads when the caller only wants one or two headings.
|
|
6935
|
-
* Usage: summary-extract <path> --fields one_liner,status
|
|
6936
|
-
*/
|
|
6937
|
-
function cmdSummaryExtract(args) {
|
|
6938
|
-
const filePath = args[0];
|
|
6939
|
-
const fieldsFlag = args.indexOf('--fields');
|
|
6940
|
-
const fields = fieldsFlag >= 0 ? (args[fieldsFlag + 1] || '').split(',').map(s => s.trim()).filter(Boolean) : ['one_liner'];
|
|
6941
|
-
|
|
6942
|
-
if (!filePath) return { ok: false, error: 'Usage: summary-extract <path> [--fields a,b,c]' };
|
|
6943
|
-
if (!fs.existsSync(filePath)) return { ok: false, error: `file not found: ${filePath}` };
|
|
6944
|
-
|
|
6945
|
-
const text = fs.readFileSync(filePath, 'utf8');
|
|
6946
|
-
const out = { ok: true, path: filePath };
|
|
6947
|
-
|
|
6948
|
-
const fieldToPatterns = {
|
|
6949
|
-
one_liner: [/^##\s+One[-\s]?liner\s*\n([\s\S]*?)(?=\n##|\n---|$)/im, /^##\s+Summary\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6950
|
-
status: [/^##\s+Status\s*\n([\s\S]*?)(?=\n##|\n---|$)/im, /^status:\s*(.+)$/im],
|
|
6951
|
-
outcomes: [/^##\s+Outcomes?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6952
|
-
decisions: [/^##\s+Decisions?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6953
|
-
blockers: [/^##\s+Blockers?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6954
|
-
followups: [/^##\s+Follow[-\s]?ups?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im, /^##\s+Next[-\s]?steps?\s*\n([\s\S]*?)(?=\n##|\n---|$)/im],
|
|
6955
|
-
};
|
|
6956
|
-
|
|
6957
|
-
for (const f of fields) {
|
|
6958
|
-
const patterns = fieldToPatterns[f] || [new RegExp(`^##\\s+${f.replace(/_/g, '[ _-]?')}\\s*\\n([\\s\\S]*?)(?=\\n##|\\n---|$)`, 'im')];
|
|
6959
|
-
let value = null;
|
|
6960
|
-
for (const re of patterns) {
|
|
6961
|
-
const m = text.match(re);
|
|
6962
|
-
if (m && m[1]) { value = m[1].trim().split('\n').map(l => l.trim()).filter(Boolean).join('\n'); break; }
|
|
6963
|
-
}
|
|
6964
|
-
// Fallback for one_liner: first non-empty paragraph after H1
|
|
6965
|
-
if (f === 'one_liner' && !value) {
|
|
6966
|
-
const afterH1 = text.replace(/^#[^\n]*\n/, '');
|
|
6967
|
-
const firstPara = afterH1.match(/^[^\n#][^\n]*(?:\n(?!\n)[^\n#][^\n]*)*/m);
|
|
6968
|
-
if (firstPara) value = firstPara[0].trim();
|
|
6969
|
-
}
|
|
6970
|
-
out[f] = value;
|
|
6971
|
-
}
|
|
6972
|
-
|
|
6973
|
-
return out;
|
|
6974
|
-
}
|
|
6975
|
-
|
|
6976
|
-
/**
|
|
6977
|
-
* cmdStateSnapshot — compact, display-friendly state extract.
|
|
6978
|
-
* Hides internal machinery (lock metadata, full history) from callers
|
|
6979
|
-
* that only need a render-ready summary.
|
|
6980
|
-
*/
|
|
6981
6234
|
/**
|
|
6982
6235
|
* cmdProjectStatus — classify project lifecycle state into one of:
|
|
6983
6236
|
* uninstalled — no .rcode/config.yaml
|
|
@@ -7291,144 +6544,6 @@ function milestoneCloseNudge() {
|
|
|
7291
6544
|
return { milestone_health: summary, nudge };
|
|
7292
6545
|
}
|
|
7293
6546
|
|
|
7294
|
-
function cmdStateSnapshot() {
|
|
7295
|
-
const statePath = path.join(RCODE_DIR, 'state.json');
|
|
7296
|
-
if (!fs.existsSync(statePath)) return { ok: true, state: null };
|
|
7297
|
-
let state;
|
|
7298
|
-
try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); }
|
|
7299
|
-
catch (e) { return { ok: false, error: `invalid state.json: ${e.message}` }; }
|
|
7300
|
-
|
|
7301
|
-
return {
|
|
7302
|
-
ok: true,
|
|
7303
|
-
project: state.project,
|
|
7304
|
-
current_phase: state.current_phase,
|
|
7305
|
-
current_plan: state.current_plan,
|
|
7306
|
-
current_sprint: state.current_sprint,
|
|
7307
|
-
phase_count: (state.phases || []).length,
|
|
7308
|
-
decisions_count: (state.decisions || []).length,
|
|
7309
|
-
blockers_open: (state.blockers || []).filter(b => !b.resolved).length,
|
|
7310
|
-
last_session: state.last_session,
|
|
7311
|
-
updated: state.updated,
|
|
7312
|
-
active_workstream: state.active_workstream,
|
|
7313
|
-
};
|
|
7314
|
-
}
|
|
7315
|
-
|
|
7316
|
-
/**
|
|
7317
|
-
* cmdGitignore — re-render the rcode-managed block in .gitignore based on
|
|
7318
|
-
* current config (specifically commit_planning from .rcode/config.yaml).
|
|
7319
|
-
*
|
|
7320
|
-
* Subcommands:
|
|
7321
|
-
* gitignore refresh rewrite the rcode block in-place
|
|
7322
|
-
* gitignore status report current commit_planning + block presence
|
|
7323
|
-
*
|
|
7324
|
-
* Mirrors the logic in cli/install.js ensureRcodeGitignore — kept in sync
|
|
7325
|
-
* by convention. Any change to the block format should update both.
|
|
7326
|
-
* Closes #189 — runtime toggle for commit_planning.
|
|
7327
|
-
*/
|
|
7328
|
-
function cmdGitignore(args) {
|
|
7329
|
-
const sub = args[0] || 'refresh';
|
|
7330
|
-
const gitignorePath = path.join(PROJECT_ROOT, '.gitignore');
|
|
7331
|
-
const configPath = path.join(RCODE_DIR, 'config.yaml');
|
|
7332
|
-
|
|
7333
|
-
// Read commit_planning from config; default true if missing.
|
|
7334
|
-
let commitPlanning = true;
|
|
7335
|
-
if (fs.existsSync(configPath)) {
|
|
7336
|
-
const cfg = fs.readFileSync(configPath, 'utf8');
|
|
7337
|
-
const m = cfg.match(/^\s*commit_planning:\s*(true|false)\s*$/m);
|
|
7338
|
-
if (m) commitPlanning = (m[1] === 'true');
|
|
7339
|
-
}
|
|
7340
|
-
|
|
7341
|
-
const BEGIN = '# ===== rcode-managed gitignore block (npx @hanzlaa/rcode install) =====';
|
|
7342
|
-
const END = '# ===== end rcode-managed gitignore block =====';
|
|
7343
|
-
|
|
7344
|
-
if (sub === 'status') {
|
|
7345
|
-
const exists = fs.existsSync(gitignorePath);
|
|
7346
|
-
const hasBlock = exists && fs.readFileSync(gitignorePath, 'utf8').includes(BEGIN);
|
|
7347
|
-
return {
|
|
7348
|
-
ok: true,
|
|
7349
|
-
gitignore_exists: exists,
|
|
7350
|
-
block_present: hasBlock,
|
|
7351
|
-
commit_planning: commitPlanning,
|
|
7352
|
-
};
|
|
7353
|
-
}
|
|
7354
|
-
|
|
7355
|
-
if (sub !== 'refresh') {
|
|
7356
|
-
return { ok: false, error: `Unknown gitignore subcommand: ${sub}. Try: refresh | status` };
|
|
7357
|
-
}
|
|
7358
|
-
|
|
7359
|
-
const lines = [
|
|
7360
|
-
'',
|
|
7361
|
-
BEGIN,
|
|
7362
|
-
'# Added automatically on rcode install. Idempotent — safe to re-run.',
|
|
7363
|
-
'# Edit `commit_planning` in .rcode/config.yaml, then: rcode-tools gitignore refresh',
|
|
7364
|
-
'',
|
|
7365
|
-
'# Installed methodology files (regenerate with: npx @hanzlaa/rcode install)',
|
|
7366
|
-
'.claude/',
|
|
7367
|
-
'.rcode/bin/',
|
|
7368
|
-
'.rcode/workflows/',
|
|
7369
|
-
'.rcode/references/',
|
|
7370
|
-
'.rcode/commands/',
|
|
7371
|
-
'.rcode/skills/',
|
|
7372
|
-
'',
|
|
7373
|
-
'# Pulled rcode brain content (refresh with: rcode brain pull)',
|
|
7374
|
-
'.rcode/brain/rcode-github/',
|
|
7375
|
-
'.rcode/brain/rcode-docs/',
|
|
7376
|
-
'.rcode/brain/best-practices/',
|
|
7377
|
-
'',
|
|
7378
|
-
'# Runtime noise',
|
|
7379
|
-
'node_modules/',
|
|
7380
|
-
'.rcode/state.json.lock',
|
|
7381
|
-
'.planning/debug/',
|
|
7382
|
-
'.planning/_backup/',
|
|
7383
|
-
];
|
|
7384
|
-
if (!commitPlanning) {
|
|
7385
|
-
lines.push('', '# Planning artifacts — kept local (commit_planning: false)', '.planning/');
|
|
7386
|
-
}
|
|
7387
|
-
lines.push(
|
|
7388
|
-
'',
|
|
7389
|
-
'# What you DO commit:',
|
|
7390
|
-
'# .rcode/config.yaml - project mode/language/profile/commit_planning',
|
|
7391
|
-
'# .rcode/state.json - decisions, roadmap pointer, blockers',
|
|
7392
|
-
'# .rcode/brain/sources.yaml - brain source manifest',
|
|
7393
|
-
commitPlanning
|
|
7394
|
-
? '# .planning/ - PRD, roadmap, sprints, SUMMARY.md files'
|
|
7395
|
-
: '# (planning artifacts are NOT committed — see commit_planning in config)',
|
|
7396
|
-
END,
|
|
7397
|
-
''
|
|
7398
|
-
);
|
|
7399
|
-
const BLOCK = lines.join('\n');
|
|
7400
|
-
|
|
7401
|
-
/** Replace the rcode block in text using indexOf — safer than regex. */
|
|
7402
|
-
function spliceBlock(existing, newBlock) {
|
|
7403
|
-
const start = existing.indexOf(BEGIN);
|
|
7404
|
-
if (start < 0) return null;
|
|
7405
|
-
const endIdx = existing.indexOf(END, start);
|
|
7406
|
-
if (endIdx < 0) return null;
|
|
7407
|
-
// Include trailing newline after END if present, and leading newline before BEGIN.
|
|
7408
|
-
let sliceStart = start;
|
|
7409
|
-
if (sliceStart > 0 && existing[sliceStart - 1] === '\n') sliceStart -= 1;
|
|
7410
|
-
let sliceEnd = endIdx + END.length;
|
|
7411
|
-
if (existing[slice_end] === '\n') slice_end += 1;
|
|
7412
|
-
return existing.slice(0, sliceStart) + newBlock + existing.slice(slice_end);
|
|
7413
|
-
}
|
|
7414
|
-
|
|
7415
|
-
if (!fs.existsSync(gitignorePath)) {
|
|
7416
|
-
fs.writeFileSync(gitignorePath, BLOCK);
|
|
7417
|
-
return { ok: true, action: 'created', commit_planning: commitPlanning };
|
|
7418
|
-
}
|
|
7419
|
-
const existing = fs.readFileSync(gitignorePath, 'utf8');
|
|
7420
|
-
if (existing.includes(BEGIN)) {
|
|
7421
|
-
const rewritten = spliceBlock(existing, BLOCK);
|
|
7422
|
-
if (rewritten !== null && rewritten !== existing) {
|
|
7423
|
-
fs.writeFileSync(gitignorePath, rewritten);
|
|
7424
|
-
return { ok: true, action: 'updated', commit_planning: commitPlanning };
|
|
7425
|
-
}
|
|
7426
|
-
return { ok: true, action: 'no-change', commit_planning: commitPlanning };
|
|
7427
|
-
}
|
|
7428
|
-
fs.writeFileSync(gitignorePath, existing + BLOCK);
|
|
7429
|
-
return { ok: true, action: 'appended', commit_planning: commitPlanning };
|
|
7430
|
-
}
|
|
7431
|
-
|
|
7432
6547
|
function cmdFindFiles(rawArgs) {
|
|
7433
6548
|
const flags = {};
|
|
7434
6549
|
const parts = rawArgs.split(/\s+/).filter(p => p);
|
|
@@ -7898,7 +7013,8 @@ async function main() {
|
|
|
7898
7013
|
break;
|
|
7899
7014
|
}
|
|
7900
7015
|
case 'brain': {
|
|
7901
|
-
|
|
7016
|
+
const brain = require(path.join(__dirname, 'lib', 'brain.cjs'));
|
|
7017
|
+
result = brain.cmdBrain(args, { PROJECT_ROOT, RCODE_DIR });
|
|
7902
7018
|
break;
|
|
7903
7019
|
}
|
|
7904
7020
|
case 'handoff': {
|
|
@@ -7906,19 +7022,23 @@ async function main() {
|
|
|
7906
7022
|
break;
|
|
7907
7023
|
}
|
|
7908
7024
|
case 'progress': {
|
|
7909
|
-
|
|
7025
|
+
const progress = require(path.join(__dirname, 'lib', 'progress.cjs'));
|
|
7026
|
+
result = progress.cmdProgress(args, { PROJECT_ROOT, RCODE_DIR, PLANNING_DIR });
|
|
7910
7027
|
break;
|
|
7911
7028
|
}
|
|
7912
7029
|
case 'summary-extract': {
|
|
7913
|
-
|
|
7030
|
+
const summary = require(path.join(__dirname, 'lib', 'summary.cjs'));
|
|
7031
|
+
result = summary.cmdSummaryExtract(args);
|
|
7914
7032
|
break;
|
|
7915
7033
|
}
|
|
7916
7034
|
case 'state-snapshot': {
|
|
7917
|
-
|
|
7035
|
+
const summary = require(path.join(__dirname, 'lib', 'summary.cjs'));
|
|
7036
|
+
result = summary.cmdStateSnapshot({ RCODE_DIR });
|
|
7918
7037
|
break;
|
|
7919
7038
|
}
|
|
7920
7039
|
case 'gitignore': {
|
|
7921
|
-
|
|
7040
|
+
const gitignore = require(path.join(__dirname, 'lib', 'gitignore.cjs'));
|
|
7041
|
+
result = gitignore.cmdGitignore(args, { PROJECT_ROOT, RCODE_DIR });
|
|
7922
7042
|
break;
|
|
7923
7043
|
}
|
|
7924
7044
|
case 'agent-skills':
|
|
@@ -7943,8 +7063,9 @@ async function main() {
|
|
|
7943
7063
|
// Closes #836 — top-level health check so agents can call
|
|
7944
7064
|
// `rcode-tools.cjs health` directly without the CLI wrapper.
|
|
7945
7065
|
// Returns a combined snapshot: milestone health + state snapshot + project status.
|
|
7066
|
+
const summary = require(path.join(__dirname, 'lib', 'summary.cjs'));
|
|
7946
7067
|
const mh = cmdMilestoneHealth();
|
|
7947
|
-
const ss = cmdStateSnapshot();
|
|
7068
|
+
const ss = summary.cmdStateSnapshot({ RCODE_DIR });
|
|
7948
7069
|
const ps = cmdProjectStatus();
|
|
7949
7070
|
result = { ok: mh.ok && ss.ok, milestone_health: mh, state: ss, project: ps };
|
|
7950
7071
|
break;
|