@hanzlaa/rcode 4.8.0 → 4.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +7 -5
  4. package/cli/doctor.js +4 -2
  5. package/cli/github-sync.js +26 -17
  6. package/cli/install.js +59 -34
  7. package/cli/lib/namespace-migrate.cjs +62 -4
  8. package/cli/migrate-namespace.js +4 -0
  9. package/dist/rcode.js +207 -207
  10. package/package.json +1 -1
  11. package/rcode/agents/rcode-code-reviewer.md +1 -1
  12. package/rcode/agents/rcode-docs-auditor.md +1 -1
  13. package/rcode/agents/rcode-edge-case-hunter.md +1 -1
  14. package/rcode/agents/rcode-security-adversary.md +1 -1
  15. package/rcode/agents/rcode-security-auditor.md +1 -1
  16. package/rcode/agents/rcode-sprint-checker.md +1 -1
  17. package/rcode/agents/rcode-verifier.md +1 -1
  18. package/rcode/bin/lib/brain.cjs +16 -1
  19. package/rcode/bin/lib/gitignore.cjs +3 -5
  20. package/rcode/bin/rcode-hooks.cjs +22 -5
  21. package/rcode/bin/rcode-tools.cjs +166 -14
  22. package/rcode/data/intent-table.json +1 -1
  23. package/rcode/references/git-preflight.md +5 -2
  24. package/rcode/references/output-format.md +5 -5
  25. package/rcode/workflows/add-phase.md +33 -14
  26. package/rcode/workflows/do.md +33 -1
  27. package/rcode/workflows/execute-sprint.md +3 -4
  28. package/rcode/workflows/execute-waves.md +25 -32
  29. package/rcode/workflows/execute.md +80 -21
  30. package/rcode/workflows/init.md +28 -6
  31. package/rcode/workflows/plan-research-validation.md +10 -5
  32. package/rcode/workflows/plan-spawn-planner.md +9 -13
  33. package/rcode/workflows/plan.md +2 -2
  34. package/rcode/workflows/scaffold-skill.md +19 -1
  35. package/rcode/workflows/scan.md +22 -1
  36. package/rcode/workflows/secure-phase.md +7 -1
  37. package/rcode/workflows/validate-phase.md +7 -1
  38. package/server/lib/html/client/components/Sidebar.js +8 -5
  39. package/server/lib/html/client/views/PhasesView.js +1 -1
  40. package/server/lib/html/css.js +4 -0
  41. package/server/lib/scanner.js +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzlaa/rcode",
3
- "version": "4.8.0",
3
+ "version": "4.9.1",
4
4
  "description": "rcode — the AI team that never forgets. Persistent memory, specialist agents, and slash commands for AI IDEs. Works in Claude Code, Cursor, Gemini, VS Code, and Antigravity.",
5
5
  "main": "cli/index.js",
6
6
  "bin": {
@@ -6,7 +6,7 @@ color: purple
6
6
  ---
7
7
 
8
8
  @.rcode/references/response-style.md
9
- @.rcode/references/karpathy-guidelines-full.md
9
+ @.rcode/references/karpathy-guidelines.md
10
10
  @.rcode/references/no-unauthorized-git-ops.md
11
11
  @.rcode/references/code-reviewer-playbook.md
12
12
 
@@ -6,7 +6,7 @@ color: yellow
6
6
  ---
7
7
 
8
8
  @.rcode/references/response-style.md
9
- @.rcode/references/karpathy-guidelines-full.md
9
+ @.rcode/references/karpathy-guidelines.md
10
10
  @.rcode/references/no-unauthorized-git-ops.md
11
11
  @.rcode/references/auditor-shared-checklists.md
12
12
  @.rcode/references/docs-auditor-playbook.md
@@ -6,7 +6,7 @@ color: red
6
6
  ---
7
7
 
8
8
  @.rcode/references/response-style.md
9
- @.rcode/references/karpathy-guidelines-full.md
9
+ @.rcode/references/karpathy-guidelines.md
10
10
  @.rcode/references/no-unauthorized-git-ops.md
11
11
  @.rcode/references/auditor-shared-checklists.md
12
12
 
@@ -6,7 +6,7 @@ color: red
6
6
  ---
7
7
 
8
8
  @.rcode/references/response-style.md
9
- @.rcode/references/karpathy-guidelines-full.md
9
+ @.rcode/references/karpathy-guidelines.md
10
10
  @.rcode/references/no-unauthorized-git-ops.md
11
11
  @.rcode/references/auditor-shared-checklists.md
12
12
 
@@ -6,7 +6,7 @@ color: purple
6
6
  ---
7
7
 
8
8
  @.rcode/references/response-style.md
9
- @.rcode/references/karpathy-guidelines-full.md
9
+ @.rcode/references/karpathy-guidelines.md
10
10
  @.rcode/references/no-unauthorized-git-ops.md
11
11
  @.rcode/references/auditor-shared-checklists.md
12
12
 
@@ -6,7 +6,7 @@ color: green
6
6
  ---
7
7
 
8
8
  @.rcode/references/response-style.md
9
- @.rcode/references/karpathy-guidelines-full.md
9
+ @.rcode/references/karpathy-guidelines.md
10
10
  @.rcode/references/sprint-checker-playbook.md
11
11
 
12
12
  <role>
@@ -6,7 +6,7 @@ color: green
6
6
  ---
7
7
 
8
8
  @.rcode/references/response-style.md
9
- @.rcode/references/karpathy-guidelines-full.md
9
+ @.rcode/references/karpathy-guidelines.md
10
10
  @.rcode/references/no-unauthorized-git-ops.md
11
11
  @.rcode/references/verifier-playbook.md
12
12
 
@@ -243,7 +243,22 @@ function cmdBrain(args, { PROJECT_ROOT, RCODE_DIR }) {
243
243
  const os = require('os');
244
244
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rcode-brain-'));
245
245
  const branch = s.branch || cfg.defaults?.branch || 'main';
246
- const sparsePaths = Array.isArray(s.paths) ? s.paths : [];
246
+ // #1029 `sparse-checkout set --no-cone` treats each path as a
247
+ // .gitignore-style pattern, not a literal path pin. A bare filename
248
+ // (no '/', no wildcard) matches that filename at any depth in the repo
249
+ // tree, over-fetching every same-named file repo-wide. Anchor bare
250
+ // filenames to the repo root with a leading '/' so they pin the
251
+ // root-level file only. Patterns that already start with '/', contain a
252
+ // '/', or use wildcards (already scoped or intentionally recursive) are
253
+ // left untouched.
254
+ function anchorBareFilename(p) {
255
+ const str = String(p || '');
256
+ if (str.startsWith('/')) return str;
257
+ if (/[*?[]/.test(str)) return str;
258
+ if (str.includes('/')) return str;
259
+ return `/${str}`;
260
+ }
261
+ const sparsePaths = (Array.isArray(s.paths) ? s.paths : []).map(anchorBareFilename);
247
262
 
248
263
  // Cache key = sha1(repo + branch + sparsePaths joined). Changing any of
249
264
  // those gets a fresh cache slot. Different projects pulling the same
@@ -1,7 +1,5 @@
1
1
  /**
2
- * Gitignore — extracted from rcode-tools.cjs (issue #204). Pure mechanical
3
- * move, no behavior change (including the pre-existing `slice_end` typo in
4
- * spliceBlock, left untouched — out of scope for this extraction).
2
+ * Gitignore — extracted from rcode-tools.cjs (issue #204).
5
3
  */
6
4
 
7
5
  const fs = require('fs');
@@ -102,8 +100,8 @@ function cmdGitignore(args, { PROJECT_ROOT, RCODE_DIR }) {
102
100
  let sliceStart = start;
103
101
  if (sliceStart > 0 && existing[sliceStart - 1] === '\n') sliceStart -= 1;
104
102
  let sliceEnd = endIdx + END.length;
105
- if (existing[slice_end] === '\n') slice_end += 1;
106
- return existing.slice(0, sliceStart) + newBlock + existing.slice(slice_end);
103
+ if (existing[sliceEnd] === '\n') sliceEnd += 1;
104
+ return existing.slice(0, sliceStart) + newBlock + existing.slice(sliceEnd);
107
105
  }
108
106
 
109
107
  if (!fs.existsSync(gitignorePath)) {
@@ -743,7 +743,10 @@ function parseSimpleYamlInline(text) {
743
743
  /**
744
744
  * Read prompt_nudge from .rcode/config.yaml.
745
745
  * Returns 'every' | 'once-per-intent' | 'when-stale' | 'off'.
746
- * Defaults to 'every' when key is absent, file is missing, or value is unknown.
746
+ * Defaults to 'once-per-intent' when key is absent, file is missing, or value
747
+ * is unknown — #953: 'every' re-nudged on every matching prompt during an
748
+ * active session, which read as noise once the keyword matcher's false
749
+ * positives compounded it. 'every' is still available as an opt-in.
747
750
  */
748
751
  function readPromptNudgeToggle(cwd) {
749
752
  const VALID = new Set(['every', 'once-per-intent', 'when-stale', 'off']);
@@ -752,9 +755,9 @@ function readPromptNudgeToggle(cwd) {
752
755
  const text = fs.readFileSync(cfgPath, 'utf8');
753
756
  const parsed = parseSimpleYamlInline(text);
754
757
  const val = (parsed.prompt_nudge || '').trim().toLowerCase();
755
- return VALID.has(val) ? val : 'every';
758
+ return VALID.has(val) ? val : 'once-per-intent';
756
759
  } catch {
757
- return 'every';
760
+ return 'once-per-intent';
758
761
  }
759
762
  }
760
763
 
@@ -791,6 +794,20 @@ function isStateStaleFallbackTrue(cwd) {
791
794
  }
792
795
  }
793
796
 
797
+ /**
798
+ * Word-boundary keyword match — #953: plain `lower.includes(kw)` matched
799
+ * generic keywords like "bug" and "crash" inside unrelated words ("debugger",
800
+ * "crashing"), over-firing the debug nudge. Boundaries are checked against
801
+ * Unicode letters/numbers (not just ASCII \w) so Arabic/Urdu keyword phrases
802
+ * still match correctly.
803
+ */
804
+ function keywordMatches(lower, kw) {
805
+ const kwLower = kw.toLowerCase();
806
+ const escaped = kwLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
807
+ const re = new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'u');
808
+ return re.test(lower);
809
+ }
810
+
794
811
  /**
795
812
  * prompt-router: Nudge toward rcode commands for memory consistency (#892).
796
813
  * Reads stdin synchronously (NOT async — rejects bad JSON). Keyword-matches INTENT_TABLE,
@@ -868,12 +885,12 @@ function promptRouter() {
868
885
  process.exit(0);
869
886
  }
870
887
 
871
- // ── Keyword match (first-match-wins, case-insensitive) ───────────────
888
+ // ── Keyword match (first-match-wins, case-insensitive, word-boundary)
872
889
  const lower = prompt.toLowerCase();
873
890
  let matched = null;
874
891
  for (const entry of INTENT_TABLE) {
875
892
  for (const kw of entry.keywords) {
876
- if (lower.includes(kw.toLowerCase())) {
893
+ if (keywordMatches(lower, kw)) {
877
894
  matched = entry;
878
895
  break;
879
896
  }
@@ -4547,9 +4547,11 @@ function cmdGenerateClaudeMd(rawArgs) {
4547
4547
  const force = args.includes('--force');
4548
4548
  const claudeMdPath = path.join(PROJECT_ROOT, 'CLAUDE.md');
4549
4549
  const agentsMdPath = path.join(PROJECT_ROOT, 'AGENTS.md');
4550
+ const claudeExisted = fs.existsSync(claudeMdPath);
4551
+ const agentsExisted = fs.existsSync(agentsMdPath);
4550
4552
 
4551
- if (fs.existsSync(claudeMdPath) && !force) {
4552
- throw new Error(`CLAUDE.md already exists at ${claudeMdPath}. Use --force to overwrite.`);
4553
+ if (claudeExisted && agentsExisted && !force) {
4554
+ throw new Error(`CLAUDE.md and AGENTS.md already exist at ${PROJECT_ROOT}. Use --force to overwrite.`);
4553
4555
  }
4554
4556
 
4555
4557
  // Resolve project name from package.json or directory.
@@ -4647,26 +4649,33 @@ Before handling planning, exploration, auditing, refactoring, or multi-step buil
4647
4649
  **This file is part of the project. Treat it as load-bearing.**
4648
4650
  `;
4649
4651
 
4650
- const claudeExisted = fs.existsSync(claudeMdPath);
4651
- fs.writeFileSync(claudeMdPath, content);
4652
+ // Each file's own existence gates only its own write — a project with an
4653
+ // existing CLAUDE.md must still get a missing AGENTS.md backfilled, and
4654
+ // vice versa (#1025).
4655
+ const wroteClaude = !claudeExisted || force;
4656
+ if (wroteClaude) {
4657
+ fs.writeFileSync(claudeMdPath, content);
4658
+ }
4652
4659
 
4653
4660
  // Mirror the same rules to AGENTS.md (the cross-tool standard Codex, Cursor,
4654
4661
  // Windsurf, Antigravity, and Gemini read). Skip when it already exists without
4655
4662
  // --force so an install-appended "## rcode Agents (installed)" roster survives.
4656
- const agentsExisted = fs.existsSync(agentsMdPath);
4657
4663
  const wroteAgents = !agentsExisted || force;
4658
4664
  if (wroteAgents) {
4659
4665
  fs.writeFileSync(agentsMdPath, content);
4660
4666
  }
4661
4667
 
4668
+ const writtenPaths = [];
4669
+ if (wroteClaude) writtenPaths.push(path.relative(PROJECT_ROOT, claudeMdPath));
4670
+ if (wroteAgents) writtenPaths.push(path.relative(PROJECT_ROOT, agentsMdPath));
4671
+
4662
4672
  return {
4663
4673
  ok: true,
4664
4674
  path: path.relative(PROJECT_ROOT, claudeMdPath),
4665
- paths: wroteAgents
4666
- ? [path.relative(PROJECT_ROOT, claudeMdPath), path.relative(PROJECT_ROOT, agentsMdPath)]
4667
- : [path.relative(PROJECT_ROOT, claudeMdPath)],
4675
+ paths: writtenPaths,
4668
4676
  project_name: projectName,
4669
- overwritten: force && claudeExisted,
4677
+ overwritten: force && (claudeExisted || agentsExisted),
4678
+ claude_md_skipped: !wroteClaude,
4670
4679
  agents_md_skipped: !wroteAgents,
4671
4680
  };
4672
4681
  }
@@ -5283,18 +5292,55 @@ function cmdPhasePlanIndex(rawArgs) {
5283
5292
  const stem = file.replace(/-SPRINT\.md$/i, '');
5284
5293
  const text = fs.readFileSync(path.join(phaseDir, file), 'utf8');
5285
5294
  const { frontmatter, body } = parseFrontmatter(text);
5295
+ let block = '';
5296
+ if (text.startsWith('---\n')) {
5297
+ const end = text.indexOf('\n---\n', 4);
5298
+ if (end !== -1) block = text.slice(4, end);
5299
+ }
5286
5300
  const id = frontmatter.sprint || frontmatter.plan || stem;
5287
- const wave = parseInt(frontmatter.wave || '1', 10) || 1;
5288
- const autonomous = String(frontmatter.autonomous || '').toLowerCase() === 'true';
5301
+ // `wave:` may be an explicit scalar key, or absent — in which case it's
5302
+ // derived from `depends_on` below (block-list or inline form, issue #951).
5303
+ const hasExplicitWave = /^wave\s*:\s*\d+/m.test(block);
5304
+ const wave = hasExplicitWave ? (parseInt(frontmatter.wave, 10) || 1) : null;
5305
+ const dependsOn = fmListField(block, 'depends_on');
5306
+ const hasAutonomousKey = /^autonomous\s*:/m.test(block);
5307
+ const autonomous = hasAutonomousKey
5308
+ ? String(frontmatter.autonomous || '').toLowerCase() === 'true'
5309
+ : /<automated>/i.test(body);
5289
5310
  const gapClosure = String(frontmatter.gap_closure || frontmatter.type || '').toLowerCase() === 'gap_closure';
5290
5311
  const objMatch = body.match(/^##\s+(?:Objective|Goal)\s*\n+([^\n]+)/mi);
5291
5312
  const objective = objMatch ? objMatch[1].trim() : (frontmatter.goal || '').replace(/^["']|["']$/g, '');
5292
- const taskCount = (body.match(/^[-*]\s+\[[ xX]\]/gm) || []).length;
5293
- const filesModified = (body.match(/^\s*-\s*path:\s*["']?([^"'\n]+)/gm) || []).length;
5313
+ const checkboxCount = (body.match(/^[-*]\s+\[[ xX]\]/gm) || []).length;
5314
+ const storyHeaderCount = (body.match(/^###\s+Story\s+\S+/gm) || []).length;
5315
+ const taskCount = checkboxCount > 0 ? checkboxCount : storyHeaderCount;
5316
+ const filesModifiedList = fmListField(block, 'files_modified');
5317
+ const filesModified = filesModifiedList.length > 0
5318
+ ? filesModifiedList.length
5319
+ : (body.match(/^\s*-\s*path:\s*["']?([^"'\n]+)/gm) || []).length;
5294
5320
  const hasSummary = summarySet.has(stem);
5295
5321
  if (/checkpoint/i.test(body)) hasCheckpoints = true;
5296
- return { id, wave, autonomous, gap_closure: gapClosure, objective, task_count: taskCount, files_modified: filesModified, has_summary: hasSummary, file: path.relative(PROJECT_ROOT, path.join(phaseDir, file)) };
5322
+ return { id, wave, dependsOn, autonomous, gap_closure: gapClosure, objective, task_count: taskCount, files_modified: filesModified, has_summary: hasSummary, file: path.relative(PROJECT_ROOT, path.join(phaseDir, file)) };
5297
5323
  });
5324
+
5325
+ // Resolve waves left undetermined (no explicit `wave:` key) from depends_on:
5326
+ // wave(p) = 1 + max(wave of each same-phase dependency), or 1 if none.
5327
+ const idToPlan = new Map(plans.map((p) => [p.id, p]));
5328
+ function resolveWave(p, seen) {
5329
+ if (p.wave !== null) return p.wave;
5330
+ if (seen.has(p.id)) { p.wave = 1; return 1; }
5331
+ seen.add(p.id);
5332
+ let maxDepWave = 0;
5333
+ for (const depId of p.dependsOn) {
5334
+ const dep = idToPlan.get(depId);
5335
+ if (!dep) continue;
5336
+ maxDepWave = Math.max(maxDepWave, resolveWave(dep, seen));
5337
+ }
5338
+ p.wave = maxDepWave > 0 ? maxDepWave + 1 : 1;
5339
+ return p.wave;
5340
+ }
5341
+ for (const p of plans) resolveWave(p, new Set());
5342
+ for (const p of plans) delete p.dependsOn;
5343
+
5298
5344
  const waves = {};
5299
5345
  for (const p of plans) {
5300
5346
  const k = String(p.wave);
@@ -5402,6 +5448,109 @@ function cmdPlanCheckWaveOverlaps(rawArgs) {
5402
5448
  return { phase: phaseArg, phase_dir: path.relative(PROJECT_ROOT, phaseDir), plans_checked: plans.length, conflicts };
5403
5449
  }
5404
5450
 
5451
+ /**
5452
+ * Deterministic frontend/backend glob check for classify-plan (issue #1021).
5453
+ * Mirrors the FRONTEND_GLOBS / BACKEND_GLOBS rules formerly hand-applied by
5454
+ * the orchestrating LLM in execute-waves.md — kept here as the single source
5455
+ * of truth so both the CLI and the workflow doc describe the same behavior.
5456
+ */
5457
+ function matchesFrontendGlob(file) {
5458
+ const f = String(file || '').toLowerCase();
5459
+ if (/\.(tsx|jsx|css)$/.test(f)) return true;
5460
+ return f.includes('client') || f.includes('ui');
5461
+ }
5462
+ function matchesBackendGlob(file) {
5463
+ const f = String(file || '').toLowerCase();
5464
+ return f.includes('api') || f.includes('server') || f.includes('db') || f.includes('service');
5465
+ }
5466
+
5467
+ const CLASSIFY_PLAN_ROUTE = { frontend: 'rcode-haitham', backend: 'rcode-yousef', 'full-stack': 'rcode-hanzla', other: 'rcode-executor' };
5468
+
5469
+ function classifyPlanFiles(files, objective) {
5470
+ const touchesFrontend = files.some(matchesFrontendGlob);
5471
+ const touchesBackend = files.some(matchesBackendGlob);
5472
+ let classification;
5473
+ if (touchesFrontend && touchesBackend) classification = 'full-stack';
5474
+ else if (touchesFrontend) classification = 'frontend';
5475
+ else if (touchesBackend) classification = 'backend';
5476
+ else classification = 'other';
5477
+
5478
+ if (classification === 'other') {
5479
+ const obj = String(objective || '').toLowerCase();
5480
+ const frontendKeywords = ['react', 'component', 'ui', 'css', 'tailwind', 'frontend', 'client-side', 'accessibility', 'a11y'];
5481
+ const backendKeywords = ['api', 'endpoint', 'database', 'schema', 'service', 'queue', 'backend', 'server-side'];
5482
+ if (frontendKeywords.some((k) => obj.includes(k))) classification = 'frontend';
5483
+ else if (backendKeywords.some((k) => obj.includes(k))) classification = 'backend';
5484
+ }
5485
+ return classification;
5486
+ }
5487
+
5488
+ /**
5489
+ * classify-plan — deterministic replacement for execute-waves.md's
5490
+ * hand-computed FRONTEND_GLOBS/BACKEND_GLOBS classification (issue #1021).
5491
+ * A live execution run showed the orchestrating LLM never actually carried
5492
+ * out the prose pseudocode, so a plan with a "db"-containing path still fell
5493
+ * back to rcode-executor instead of rcode-yousef.
5494
+ *
5495
+ * Two call shapes:
5496
+ * classify-plan <phase> <plan-id> — reads files_modified/objective from the plan's SPRINT.md
5497
+ * classify-plan --files=a,b,c --objective="..." — classify an already-parsed list directly
5498
+ */
5499
+ function cmdClassifyPlan(args) {
5500
+ const flags = {};
5501
+ const positional = [];
5502
+ for (const t of args) {
5503
+ if (t.startsWith('--files=')) flags.files = t.slice('--files='.length);
5504
+ else if (t.startsWith('--objective=')) flags.objective = t.slice('--objective='.length);
5505
+ else positional.push(t);
5506
+ }
5507
+
5508
+ let files = [];
5509
+ let objective = '';
5510
+
5511
+ if (flags.files !== undefined || flags.objective !== undefined) {
5512
+ files = flags.files ? flags.files.split(',').map((s) => s.trim()).filter(Boolean) : [];
5513
+ objective = flags.objective || '';
5514
+ } else {
5515
+ const [phaseArg, planArg] = positional;
5516
+ if (!phaseArg || !planArg) {
5517
+ throw new Error('Usage: classify-plan <phase> <plan-id> OR classify-plan --files=a,b,c --objective="text"');
5518
+ }
5519
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
5520
+ const norm = phaseArg.replace(/^0+/, '') || '0';
5521
+ let phaseDir = null;
5522
+ if (fs.existsSync(phasesDir)) {
5523
+ for (const d of fs.readdirSync(phasesDir)) {
5524
+ const m = d.match(/^(\d+)(?:[-.])/);
5525
+ if (m && (m[1].replace(/^0+/, '') || '0') === norm) { phaseDir = path.join(phasesDir, d); break; }
5526
+ }
5527
+ }
5528
+ if (!phaseDir) throw new Error(`Phase not found: ${phaseArg}`);
5529
+ let planFile = null;
5530
+ for (const file of fs.readdirSync(phaseDir).filter((f) => /-SPRINT\.md$/i.test(f)).sort()) {
5531
+ const stem = file.replace(/-SPRINT\.md$/i, '');
5532
+ if (stem === planArg || stem.endsWith(`-${planArg}`)) { planFile = file; break; }
5533
+ const text = fs.readFileSync(path.join(phaseDir, file), 'utf8');
5534
+ const { frontmatter } = parseFrontmatter(text);
5535
+ if ((frontmatter.sprint || frontmatter.plan) === planArg) { planFile = file; break; }
5536
+ }
5537
+ if (!planFile) throw new Error(`Plan not found: ${planArg} in phase ${phaseArg}`);
5538
+ const text = fs.readFileSync(path.join(phaseDir, planFile), 'utf8');
5539
+ const { frontmatter, body } = parseFrontmatter(text);
5540
+ let block = '';
5541
+ if (text.startsWith('---\n')) {
5542
+ const end = text.indexOf('\n---\n', 4);
5543
+ if (end !== -1) block = text.slice(4, end);
5544
+ }
5545
+ files = fmListField(block, 'files_modified');
5546
+ const objMatch = body.match(/^##\s+(?:Objective|Goal)\s*\n+([^\n]+)/mi);
5547
+ objective = objMatch ? objMatch[1].trim() : (frontmatter.goal || '').replace(/^["']|["']$/g, '');
5548
+ }
5549
+
5550
+ const classification = classifyPlanFiles(files, objective);
5551
+ return { classification, subagent_type: CLASSIFY_PLAN_ROUTE[classification], files_checked: files.length };
5552
+ }
5553
+
5405
5554
  /** phases list — directory inventory under .planning/phases with optional --type filter and --pick path. */
5406
5555
  function cmdPhasesList(args) {
5407
5556
  const argv = Array.isArray(args) ? args : String(args || '').trim().split(/\s+/).filter(Boolean);
@@ -6658,6 +6807,9 @@ async function main() {
6658
6807
  case 'phase-plan-index':
6659
6808
  result = cmdPhasePlanIndex(args.join(' '));
6660
6809
  break;
6810
+ case 'classify-plan':
6811
+ result = cmdClassifyPlan(args);
6812
+ break;
6661
6813
  case 'phases':
6662
6814
  if (args[0] === 'list') { result = cmdPhasesList(args.slice(1)); if (result === undefined) return; }
6663
6815
  else { console.error('Unknown phases subcommand. Valid: list'); process.exit(1); }
@@ -11,7 +11,7 @@
11
11
  },
12
12
  {
13
13
  "intent": "debug",
14
- "keywords": ["bug", "getting an error", "throwing an error", "error in the", "fix the error", "debug this", "crash", "failure", "broken", "not working", "fails", "exception", "traceback", "kharab", "masla", "ye kaam nahi kar raha", "yeh kaam nahi kar raha", "error a raha hai", "theek karo", "khud se crash", "خطأ", "مشكلة", "لا يعمل", "أصلح الخطأ", "تعطل البرنامج"],
14
+ "keywords": ["getting an error", "throwing an error", "error in the", "fix the error", "debug this", "crash", "not working", "exception", "traceback", "kharab", "masla", "ye kaam nahi kar raha", "yeh kaam nahi kar raha", "error a raha hai", "theek karo", "khud se crash", "خطأ", "مشكلة", "لا يعمل", "أصلح الخطأ", "تعطل البرنامج"],
15
15
  "command": "/rcode-debug"
16
16
  },
17
17
  {
@@ -14,8 +14,11 @@ Run these read-only commands in order. Any failure halts the workflow with the f
14
14
  # Check 1: working tree clean
15
15
  DIRTY=$(git status --porcelain 2>/dev/null)
16
16
 
17
- # Check 2: not on a protected branch
17
+ # Check 2: not on a protected branch (skipped entirely when `git.branching_strategy`
18
+ # config is `none` — committing directly to main/master is the deliberately configured
19
+ # workflow in that case)
18
20
  BRANCH=$(git branch --show-current 2>/dev/null)
21
+ BRANCHING_STRATEGY=$(node .rcode/bin/rcode-tools.cjs config-get git.branching_strategy 2>/dev/null)
19
22
  PROTECTED="main master develop v2-prototype"
20
23
 
21
24
  # Check 3: branch follows naming convention
@@ -35,7 +38,7 @@ fi
35
38
  The workflow MUST stop and print the banner below if ANY of:
36
39
 
37
40
  - `DIRTY` is non-empty AND user did not pass `--allow-dirty`
38
- - `BRANCH` is in `$PROTECTED` AND user did not pass `--on-main`
41
+ - `BRANCH` is in `$PROTECTED` AND `BRANCHING_STRATEGY` is not `none` AND user did not pass `--on-main`
39
42
  - `BRANCH_OK` is `no` AND user did not pass `--allow-dirty` (branch-name lint is advisory if working tree is dirty AND user accepted the dirty override)
40
43
  - `OUT_OF_SCOPE` is non-empty AND user did not pass `--allow-scope-drift`
41
44
 
@@ -42,7 +42,7 @@ Use for major workflow transitions.
42
42
 
43
43
  ```
44
44
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
45
- RCODE ► {STAGE NAME}
45
+ rcode ► {STAGE NAME}
46
46
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
47
47
  ```
48
48
 
@@ -69,7 +69,7 @@ Use this when a router command dispatches to another command:
69
69
 
70
70
  ```
71
71
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
72
- RCODE ► ROUTING
72
+ rcode ► ROUTING
73
73
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
74
74
 
75
75
  Input: {user's question or intent}
@@ -328,7 +328,7 @@ Use standard markdown pipe tables with status symbols:
328
328
  **Majlis banner** (multi-agent council):
329
329
  ```
330
330
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
331
- RCODE ► MAJLIS CONVENING
331
+ rcode ► MAJLIS CONVENING
332
332
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
333
333
  ```
334
334
 
@@ -354,7 +354,7 @@ the banner, not inside it.
354
354
 
355
355
  ```
356
356
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
357
- RCODE ► PLANNING SPRINT 01.1
357
+ rcode ► PLANNING SPRINT 01.1
358
358
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
359
359
  التخطيط للسباق 01.1 — يرجى الانتظار
360
360
  ```
@@ -389,7 +389,7 @@ translated prose goes outside the art, on its own line(s).
389
389
 
390
390
  - Varying box/banner widths within same output
391
391
  - Mixing banner styles (`===`, `---`, `***`)
392
- - Skipping `RCODE ►` prefix in stage banners
392
+ - Skipping `rcode ►` prefix in stage banners
393
393
  - Random emoji (`🚀`, `✨`, `💫`) outside the approved set
394
394
  - Missing Next Up block after workflow completions
395
395
  - Hardcoding references to other methodologies in rcode's UX
@@ -29,11 +29,21 @@ Exit.
29
29
  Load phase operation context:
30
30
 
31
31
  ```bash
32
- INIT=$(node ".rcode/bin/rcode-tools.cjs" init phase-op "0" 2>/dev/null)
32
+ INIT=$(node ".rcode/bin/rcode-tools.cjs" init phase-op "1" 2>/dev/null)
33
33
  if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
34
34
  ```
35
35
 
36
- If `INIT` is empty or `INIT.ok` is false, print error and exit:
36
+ `init phase-op` only populates the phase-aware fields (`roadmap_exists`,
37
+ `planning_exists`, `phase_found`, ...) when its `question` argument's first
38
+ token parses as an integer `> 0` (rcode-tools.cjs's `phase-op` handler gates
39
+ on `phaseNum > 0`). add-phase has no target phase to pass — it's creating a
40
+ new one — so a placeholder is required. `"0"` fails that guard, silently
41
+ dropping `roadmap_exists` from every response (#1017); `"1"` satisfies it.
42
+ The dummy value only affects unused fields like `phase_found`/`phase_name`
43
+ (harmless here) — `roadmap_exists` itself is purely a file-existence check
44
+ and doesn't depend on phase 1 actually existing.
45
+
46
+ If `INIT` is empty, print error and exit:
37
47
  ```
38
48
  Error: rcode-tools init failed. Verify .rcode/ is installed and state.json is valid.
39
49
  ```
@@ -92,7 +102,13 @@ The CLI handles:
92
102
  - Creating the phase directory (`.planning/phases/{NN}-{slug}/`)
93
103
  - Inserting the phase entry into ROADMAP.md with Goal, Depends on, and Plans sections
94
104
 
95
- Extract from result: `phase_number`, `padded`, `name`, `slug`, `directory`.
105
+ Extract from result: `phase_number`, `padded`, `name`, `slug`, `directory`,
106
+ `milestone_health` (object: `open_phases`, `recommendation`, `threshold_should`,
107
+ `threshold_consider`), `nudge` (present only when `recommendation` isn't
108
+ `healthy` — a ready-to-print one-liner naming the milestone). `phase add`
109
+ computes these via `milestoneCloseNudge()` (issue #942) as part of the same
110
+ call, so `milestone_health_check` below reads them straight off `$RESULT`
111
+ instead of re-deriving them.
96
112
 
97
113
  **If `BULK_MODE=true`:** after the CLI returns, write the bulk body to `${directory}/TASKS.md` per the structure defined in `detect_task_list`. This step is non-destructive — it only ADDs a TASKS.md file inside the new phase directory.
98
114
  </step>
@@ -110,26 +126,30 @@ If "Roadmap Evolution" section doesn't exist, create it.
110
126
  </step>
111
127
 
112
128
  <step name="milestone_health_check">
113
- After the phase is added, run the milestone-health gauge (issue #718):
129
+ After the phase is added, read the milestone-health gauge (issue #718)
130
+ straight off the `phase add` result captured in `add_phase` — no extra
131
+ subprocess calls. Previously this step spawned a separate `milestone-health`
132
+ call plus 3 `node -e` JSON field extractions to re-derive data that `phase
133
+ add` already returns inline via `milestoneCloseNudge()` (#942); that was
134
+ 4 wasted calls per phase-add for data already sitting in `$RESULT` (#1018).
114
135
 
115
- ```bash
116
- HEALTH=$(node ".rcode/bin/rcode-tools.cjs" milestone-health 2>/dev/null)
117
- RECOMMENDATION=$(echo "$HEALTH" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).recommendation||'unknown')}catch{console.log('unknown')}})")
118
- OPEN_COUNT=$(echo "$HEALTH" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).open_phases||0)}catch{console.log(0)}})")
119
- MILESTONE_NAME=$(echo "$HEALTH" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).milestone||'')}catch{console.log('')}})")
136
+ ```
137
+ RECOMMENDATION="$RESULT.milestone_health.recommendation" # e.g. healthy | consider-closing | should-close
138
+ OPEN_COUNT="$RESULT.milestone_health.open_phases"
139
+ NUDGE="$RESULT.nudge" # ready-to-print, names the milestone; absent when healthy
120
140
  ```
121
141
 
122
142
  If `RECOMMENDATION` is `should-close` (≥12 open phases), surface a hard nudge:
123
143
 
124
144
  ```
125
- Milestone health: {MILESTONE_NAME} has {OPEN_COUNT} open phases.
145
+ ⚠ {NUDGE}
126
146
 
127
147
  Phase {N} is now in this milestone, but the milestone is well past the
128
148
  12-phase threshold for considering closure. Phases are accumulating without
129
149
  a milestone boundary — historically this is where roadmaps lose structure.
130
150
 
131
151
  Recommended next step:
132
- /rcode-complete-milestone close {MILESTONE_NAME} cleanly + archive done phases
152
+ /rcode-complete-milestone close the milestone cleanly + archive done phases
133
153
  /rcode-new-milestone start a fresh milestone for ongoing work
134
154
 
135
155
  If you genuinely want a giant single-milestone roadmap, ignore this and
@@ -139,11 +159,10 @@ continue. The threshold is conservative on purpose.
139
159
  If `RECOMMENDATION` is `consider-closing` (8-11 open phases), softer nudge:
140
160
 
141
161
  ```
142
- Milestone health: {MILESTONE_NAME} has {OPEN_COUNT} open phases — getting full.
143
- Consider /rcode-complete-milestone before adding more.
162
+ ℹ {NUDGE}
144
163
  ```
145
164
 
146
- If `RECOMMENDATION` is `healthy`, say nothing.
165
+ If `RECOMMENDATION` is `healthy` or `milestone_health` is absent (no state.json / no milestone), say nothing.
147
166
  </step>
148
167
 
149
168
  <step name="completion">
@@ -387,6 +387,38 @@ Scope: {one-line scope summary}
387
387
  Routing to: {chosen command}
388
388
  Reason: {one-line why}
389
389
  ```
390
+
391
+ **herdr hint (cached read only — no live check from here).** When the chosen command is
392
+ `/rcode-execute` or `/rcode-add-phase` (routes that fan out substantial multi-file
393
+ execution work), read the cached availability flag left by `/rcode-execute`'s own
394
+ availability check:
395
+
396
+ ```bash
397
+ HERDR_AVAILABLE=$(node .rcode/bin/rcode-tools.cjs config-get workflow._herdr_available 2>/dev/null || echo "false")
398
+ HERDR_AVAILABLE=${HERDR_AVAILABLE:-false}
399
+ ```
400
+
401
+ This is a plain `config-get` read — the same sanctioned category as `config-get mode`
402
+ used elsewhere in this workflow (see `<guardrails>`). It reuses the exact cache
403
+ `/rcode-execute` writes to (`workflow._herdr_checked` / `workflow._herdr_available`) —
404
+ do NOT duplicate the `command -v herdr` probe or call `config-set` here. `/rcode-do`
405
+ has no sanctioned way to run that shell probe or persist a value itself, so if the
406
+ cache hasn't been populated yet (`_herdr_checked` still false), say nothing — the
407
+ first `/rcode-execute` run downstream will perform and cache the check itself.
408
+
409
+ If `HERDR_AVAILABLE == "true"`, append one extra line to the banner above:
410
+
411
+ ```
412
+ Note: herdr is available on this machine — {chosen command} will offer a
413
+ multi-agent orchestration option if the work fans out into independent
414
+ plans; you'll be asked to confirm before anything runs via herdr.
415
+ ```
416
+
417
+ Do NOT turn this into an `AskUserQuestion` prompt and do NOT ask the user to
418
+ decide here — `/rcode-do` only routes, it never presents execution-mode choices.
419
+ The actual offer (with the full tradeoff explanation and required confirmation)
420
+ is `/rcode-execute`'s `three_options` step, which fires after dispatch regardless
421
+ of whether this hint line was shown.
390
422
  </step>
391
423
 
392
424
  <step name="dispatch">
@@ -426,7 +458,7 @@ If the chosen command expects a phase number and one wasn't provided in the text
426
458
  <guardrails>
427
459
  **Hard prohibitions during /rcode-do execution (issue #458, refined by #1007):**
428
460
 
429
- The steps above (`parse_args`, `check_project`, `auto_init_check`, `greenfield_guard`, `explicit_intent_check`, `persona_shortcut`) legitimately call Bash for structured state/config lookups (`rcode-tools.cjs state load`, `progress init`, `config-get mode`, `classify-question`, milestone/PRD/epic detection via `ls`/`grep`) and Read for the specific persona/capability-table lookup in `persona_shortcut` step 2. That is routing plumbing, not investigation, and is allowed. What's prohibited is using those same tools to figure out the route by inspecting application code, or to do the routed work itself:
461
+ The steps above (`parse_args`, `check_project`, `auto_init_check`, `greenfield_guard`, `explicit_intent_check`, `persona_shortcut`, `display`) legitimately call Bash for structured state/config lookups (`rcode-tools.cjs state load`, `progress init`, `config-get mode`, `config-get workflow._herdr_available`, `classify-question`, milestone/PRD/epic detection via `ls`/`grep`) and Read for the specific persona/capability-table lookup in `persona_shortcut` step 2. That is routing plumbing, not investigation, and is allowed. `config-get workflow._herdr_available` is a read of a cache another workflow (`/rcode-execute`) already populated — `/rcode-do` MUST NOT run `command -v herdr` itself or call `config-set` to populate that cache; it only reads whatever is already there. What's prohibited is using those same tools to figure out the route by inspecting application code, or to do the routed work itself:
430
462
 
431
463
  - MUST NOT use Bash/Read/Grep/Glob to explore or read application source code to guess what a vague request means. The state/config lookups named above are the only sanctioned uses — anything beyond them (grepping `src/`, reading a feature file to understand behavior, etc.) means the dispatcher contract has failed — STOP and use the no-route exit instead.
432
464
  - MUST NOT call Write or Edit. The dispatcher never modifies files.