@hanzlaa/rcode 4.4.4 → 4.6.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.
Files changed (43) hide show
  1. package/AGENTS.md +11 -4
  2. package/CLAUDE.md +11 -4
  3. package/CONTRIBUTING.md +6 -1
  4. package/cli/doctor.js +122 -0
  5. package/cli/index.js +4 -0
  6. package/cli/install.js +8 -0
  7. package/cli/lib/namespace-migrate.cjs +216 -0
  8. package/cli/migrate-namespace.js +61 -0
  9. package/cli/update.js +19 -0
  10. package/dist/rcode.js +215 -208
  11. package/package.json +14 -12
  12. package/rcode/agents/rcode-mariam.md +6 -0
  13. package/rcode/agents/rcode-sadiq.md +6 -0
  14. package/rcode/agents/rcode-waleed.md +6 -0
  15. package/rcode/bin/lib/brain.cjs +353 -0
  16. package/rcode/bin/lib/gitignore.cjs +126 -0
  17. package/rcode/bin/lib/memory-drift.cjs +237 -0
  18. package/rcode/bin/lib/memory-select.cjs +263 -0
  19. package/rcode/bin/lib/progress.cjs +440 -0
  20. package/rcode/bin/lib/state-reader.cjs +127 -0
  21. package/rcode/bin/lib/summary.cjs +82 -0
  22. package/rcode/bin/rcode-hooks.cjs +230 -64
  23. package/rcode/bin/rcode-tools.cjs +106 -985
  24. package/rcode/data/intent-table.json +19 -19
  25. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/heartbeat.sh +0 -0
  26. package/rcode/skills/agents/mariam-marketing/SKILL.md +1 -0
  27. package/rcode/skills/agents/sadiq-analyst/SKILL.md +1 -0
  28. package/rcode/skills/agents/waleed-architect/SKILL.md +1 -0
  29. package/rcode/templates/memory/INDEX.md +6 -1
  30. package/rcode/templates/settings-hooks.json +12 -1
  31. package/rcode/workflows/council.md +29 -1
  32. package/rcode/workflows/do.md +2 -2
  33. package/rcode/workflows/enable-hooks.md +3 -2
  34. package/rcode/workflows/new-project-roadmap.md +2 -2
  35. package/server/dashboard.js +6 -3
  36. package/server/lib/html/client/components/App.js +1 -1
  37. package/server/lib/html/client/components/OrchPanel.js +2 -2
  38. package/server/lib/html/client/components/XtermPanel.js +98 -23
  39. package/server/lib/html/client/orchestrator.js +28 -15
  40. package/server/lib/html/client/views/OrchestrationView.js +247 -169
  41. package/server/lib/html/css.js +596 -227
  42. package/server/lib/html/shell.js +9 -3
  43. package/server/orchestrator.js +3 -4
@@ -1031,12 +1031,42 @@ function cmdState(subArgs) {
1031
1031
  }
1032
1032
  try {
1033
1033
  const raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
1034
- return migrateState(raw);
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 { updated: true, phase: phaseKey, status: 'complete', previous_status: previousStatus };
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 a project CLAUDE.md scaffold. Used by new-project-roadmap.md.
4465
- * Refuses to overwrite an existing CLAUDE.md unless --force is set.
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 && fs.existsSync(claudeMdPath),
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
- result = cmdBrain(args);
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
- result = cmdProgress(args);
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
- result = cmdSummaryExtract(args);
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
- result = cmdStateSnapshot();
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
- result = cmdGitignore(args);
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;