@hanzlaa/rcode 4.3.3 → 4.4.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 (118) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +28 -5
  3. package/cli/doctor.js +21 -3
  4. package/cli/generate-command-skills.cjs +1 -1
  5. package/cli/index.js +5 -1
  6. package/cli/install.js +7 -9
  7. package/cli/postinstall.js +22 -2
  8. package/cli/uninstall.js +48 -6
  9. package/dist/rcode.js +202 -203
  10. package/package.json +13 -23
  11. package/rcode/agents/rcode-codebase-mapper.md +12 -0
  12. package/rcode/agents/rcode-phase-researcher.md +13 -0
  13. package/rcode/agents/rcode-project-researcher.md +32 -0
  14. package/rcode/agents/rcode-roadmapper.md +15 -0
  15. package/rcode/agents/rules/planner/common-patterns.md +40 -0
  16. package/rcode/bin/lib/roadmap.cjs +184 -11
  17. package/rcode/bin/rcode-hooks.cjs +102 -151
  18. package/rcode/bin/rcode-tools.cjs +669 -55
  19. package/rcode/commands/lazy.md +10 -0
  20. package/rcode/data/intent-table.json +87 -0
  21. package/rcode/modules/seo.yaml +22 -0
  22. package/rcode/references/project-types.yaml +75 -0
  23. package/rcode/references/verb-dictionary.md +1 -1
  24. package/rcode/skills/SKILLS_INDEX.md +18 -1
  25. package/rcode/skills/actions/1-analysis/rcode-prfaq/SKILL.md +1 -1
  26. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/SKILL.md +4 -21
  27. package/rcode/skills/actions/4-implementation/rcode-herdr-orchestration/templates/heartbeat.sh +0 -0
  28. package/rcode/skills/actions/4-implementation/rcode-scaffold-project/SKILL.md +60 -1
  29. package/rcode/skills/core/rcode-init/scripts/__pycache__/rcode_init.cpython-38.pyc +0 -0
  30. package/rcode/skills/core/rcode-init/scripts/tests/__pycache__/test_rcode_init.cpython-38.pyc +0 -0
  31. package/rcode/skills/seo/module.yaml +20 -0
  32. package/rcode/skills/seo/on-page-seo-auditor/SKILL.md +308 -0
  33. package/rcode/skills/seo/on-page-seo-auditor/references/audit-example.md +109 -0
  34. package/rcode/skills/seo/on-page-seo-auditor/references/audit-templates.md +427 -0
  35. package/rcode/skills/seo/on-page-seo-auditor/references/bulk-audit-playbook.md +116 -0
  36. package/rcode/skills/seo/on-page-seo-auditor/references/scoring-rubric.md +525 -0
  37. package/rcode/skills/seo/rank-and-rent-local-seo/SKILL.md +94 -0
  38. package/rcode/skills/seo/rank-and-rent-local-seo/rules/city-matrix-and-pages.md +76 -0
  39. package/rcode/skills/seo/rank-and-rent-local-seo/rules/monetization.md +70 -0
  40. package/rcode/skills/seo/rank-and-rent-local-seo/rules/niche-selection.md +60 -0
  41. package/rcode/skills/seo/rank-and-rent-local-seo/rules/subniche-discovery.md +66 -0
  42. package/rcode/skills/seo/rank-and-rent-local-seo/templates/service-city-page.md +36 -0
  43. package/rcode/skills/seo/rank-and-rent-local-seo/templates/subniche-research-prompt.md +34 -0
  44. package/rcode/skills/seo/seo-audit/SKILL.md +412 -0
  45. package/rcode/skills/seo/seo-audit/evals/evals.json +136 -0
  46. package/rcode/skills/seo/seo-audit/references/ai-writing-detection.md +200 -0
  47. package/rcode/skills/seo/seo-content-factory/SKILL.md +130 -0
  48. package/rcode/skills/seo/seo-content-factory/rules/agents.md +80 -0
  49. package/rcode/skills/seo/seo-content-factory/rules/herdr-wave-mapping.md +80 -0
  50. package/rcode/skills/seo/seo-content-factory/rules/pipeline.md +102 -0
  51. package/rcode/skills/seo/seo-content-factory/rules/programmatic-pages.md +107 -0
  52. package/rcode/skills/seo/seo-content-factory/rules/quality-gates.md +68 -0
  53. package/rcode/skills/seo/seo-content-factory/rules/weekly-cadence.md +61 -0
  54. package/rcode/skills/seo/seo-content-factory/templates/BACKLOG.md +23 -0
  55. package/rcode/skills/seo/seo-content-factory/templates/agent-prompts/01-funnel-A1-A4.md +63 -0
  56. package/rcode/skills/seo/seo-content-factory/templates/agent-prompts/02-fanout-editorial-A5-A6-A8.md +28 -0
  57. package/rcode/skills/seo/seo-content-factory/templates/agent-prompts/03-fanout-programmatic-A7-A6-A8.md +25 -0
  58. package/rcode/skills/seo/seo-content-factory/templates/agent-prompts/04-loop-A9-A10.md +34 -0
  59. package/rcode/skills/seo/seo-content-factory/templates/article.mdx +39 -0
  60. package/rcode/skills/seo/seo-content-factory/templates/cluster.schema.json +52 -0
  61. package/rcode/skills/seo/seo-content-factory/templates/content-brief.md +51 -0
  62. package/rcode/skills/seo/seo-content-factory/templates/jsonld-builders.ts +49 -0
  63. package/rcode/skills/seo/seo-content-factory/templates/keywords.csv +13 -0
  64. package/rcode/skills/seo/seo-content-factory/templates/programmatic-page.tsx +58 -0
  65. package/rcode/skills/seo/seo-content-writer/SKILL.md +263 -0
  66. package/rcode/skills/seo/seo-content-writer/references/content-structure-templates.md +875 -0
  67. package/rcode/skills/seo/seo-content-writer/references/instructions-detail.md +239 -0
  68. package/rcode/skills/seo/seo-content-writer/references/seo-writing-checklist.md +250 -0
  69. package/rcode/skills/seo/seo-content-writer/references/title-formulas.md +339 -0
  70. package/rcode/skills/seo/seo-growth-orchestrator/SKILL.md +79 -0
  71. package/rcode/skills/seo/seo-growth-orchestrator/rules/backlinks.md +29 -0
  72. package/rcode/skills/seo/seo-growth-orchestrator/rules/content-engine.md +27 -0
  73. package/rcode/skills/seo/seo-growth-orchestrator/rules/dos-and-donts.md +40 -0
  74. package/rcode/skills/seo/seo-growth-orchestrator/rules/goals-protocol.md +34 -0
  75. package/rcode/skills/seo/seo-growth-orchestrator/rules/local-seo-stack.md +35 -0
  76. package/rcode/skills/seo/seo-growth-orchestrator/rules/mrr-sprint.md +27 -0
  77. package/rcode/skills/seo/seo-growth-orchestrator/rules/tools.md +19 -0
  78. package/rcode/skills/seo/seo-growth-orchestrator/templates/backlink-research.md +29 -0
  79. package/rcode/skills/seo/seo-growth-orchestrator/templates/goal-protocol.md +35 -0
  80. package/rcode/skills/seo/seo-growth-orchestrator/templates/local-citations.md +24 -0
  81. package/rcode/skills/seo/seo-growth-orchestrator/templates/local-city-pages.md +27 -0
  82. package/rcode/skills/seo/seo-growth-orchestrator/templates/local-gbp-categories.md +23 -0
  83. package/rcode/skills/seo/seo-growth-orchestrator/templates/local-gsc-goldmine.md +27 -0
  84. package/rcode/skills/seo/seo-growth-orchestrator/templates/local-review-mining.md +26 -0
  85. package/rcode/skills/seo/seo-growth-orchestrator/templates/mrr-30day-plan.md +19 -0
  86. package/rcode/skills/seo/seo-growth-orchestrator/templates/mrr-cut.md +13 -0
  87. package/rcode/skills/seo/seo-growth-orchestrator/templates/mrr-opportunity-finder.md +17 -0
  88. package/rcode/skills/seo/seo-growth-orchestrator/templates/mrr-role-model.md +18 -0
  89. package/rcode/skills/seo/seo-site-builder/SKILL.md +149 -0
  90. package/rcode/skills/seo/seo-site-builder/rules/01-niche-discovery.md +64 -0
  91. package/rcode/skills/seo/seo-site-builder/rules/02-deep-research.md +164 -0
  92. package/rcode/skills/seo/seo-site-builder/rules/03-keyword-strategy.md +82 -0
  93. package/rcode/skills/seo/seo-site-builder/rules/04-content-plan.md +173 -0
  94. package/rcode/skills/seo/seo-site-builder/rules/05-site-build.md +187 -0
  95. package/rcode/skills/seo/seo-site-builder/rules/06-yolo.md +127 -0
  96. package/rcode/skills/seo/seo-site-builder/templates/article-stub.mdx +62 -0
  97. package/rcode/skills/seo/seo-site-builder/templates/image-prompt.md +97 -0
  98. package/rcode/skills/seo/seo-site-builder/templates/keyword-export.md +45 -0
  99. package/rcode/skills/seo/seo-site-builder/templates/niche-scorecard.md +28 -0
  100. package/rcode/skills/seo/technical-seo-checker/SKILL.md +329 -0
  101. package/rcode/skills/seo/technical-seo-checker/references/bulk-audit-playbook.md +118 -0
  102. package/rcode/skills/seo/technical-seo-checker/references/ecommerce-platform-patterns.md +166 -0
  103. package/rcode/skills/seo/technical-seo-checker/references/http-status-codes.md +705 -0
  104. package/rcode/skills/seo/technical-seo-checker/references/llm-crawler-handling.md +257 -0
  105. package/rcode/skills/seo/technical-seo-checker/references/pre-migration-playbook.md +137 -0
  106. package/rcode/skills/seo/technical-seo-checker/references/robots-txt-reference.md +717 -0
  107. package/rcode/skills/seo/technical-seo-checker/references/technical-audit-example.md +169 -0
  108. package/rcode/skills/seo/technical-seo-checker/references/technical-audit-templates.md +436 -0
  109. package/rcode/templates/settings-hooks.json +4 -0
  110. package/rcode/workflows/do.md +16 -0
  111. package/rcode/workflows/execute-sprint.md +22 -0
  112. package/rcode/workflows/execute.md +17 -3
  113. package/rcode/workflows/help.md +0 -2
  114. package/rcode/workflows/new-milestone.md +21 -9
  115. package/rcode/workflows/new-project-create-roadmap.md +2 -0
  116. package/rcode/workflows/new-project-roadmap.md +2 -0
  117. package/rcode/workflows/new-project.md +3 -0
  118. package/rcode/workflows/plan.md +15 -2
@@ -105,27 +105,46 @@ function parseSimpleYaml(text) {
105
105
  }
106
106
 
107
107
  function readConfig() {
108
- const configPath = path.join(RCODE_DIR, 'config.yaml');
109
- if (!fs.existsSync(configPath)) {
110
- return {
111
- user_name: 'User',
112
- project_name: path.basename(PROJECT_ROOT),
113
- language: 'English',
114
- mode: 'guided',
115
- };
108
+ // #733 try config.yaml first, fall back to config.json, return defaults when neither exists.
109
+ const yamlPath = path.join(RCODE_DIR, 'config.yaml');
110
+ const jsonPath = path.join(RCODE_DIR, 'config.json');
111
+
112
+ if (fs.existsSync(yamlPath)) {
113
+ try {
114
+ const parsed = parseSimpleYaml(fs.readFileSync(yamlPath, 'utf8'));
115
+ return {
116
+ ...parsed, // spread all parsed keys (model_profile, branching_strategy, etc.)
117
+ user_name: parsed.user_name || 'User',
118
+ project_name: parsed.project_name || path.basename(PROJECT_ROOT),
119
+ language: parsed.communication_language || parsed.language || 'English',
120
+ mode: parsed.mode || 'guided',
121
+ };
122
+ } catch (e) {
123
+ throw new Error(`Failed to read config.yaml: ${e.message}`);
124
+ }
116
125
  }
117
- try {
118
- const parsed = parseSimpleYaml(fs.readFileSync(configPath, 'utf8'));
119
- return {
120
- ...parsed, // spread all parsed keys (model_profile, branching_strategy, etc.)
121
- user_name: parsed.user_name || 'User',
122
- project_name: parsed.project_name || path.basename(PROJECT_ROOT),
123
- language: parsed.communication_language || parsed.language || 'English',
124
- mode: parsed.mode || 'guided',
125
- };
126
- } catch (e) {
127
- throw new Error(`Failed to read config.yaml: ${e.message}`);
126
+
127
+ if (fs.existsSync(jsonPath)) {
128
+ try {
129
+ const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
130
+ return {
131
+ ...parsed,
132
+ user_name: parsed.user_name || 'User',
133
+ project_name: parsed.project_name || path.basename(PROJECT_ROOT),
134
+ language: parsed.communication_language || parsed.language || 'English',
135
+ mode: parsed.mode || 'guided',
136
+ };
137
+ } catch (e) {
138
+ throw new Error(`Failed to read config.json: ${e.message}`);
139
+ }
128
140
  }
141
+
142
+ return {
143
+ user_name: 'User',
144
+ project_name: path.basename(PROJECT_ROOT),
145
+ language: 'English',
146
+ mode: 'guided',
147
+ };
129
148
  }
130
149
 
131
150
  /**
@@ -959,6 +978,7 @@ function cmdInitExecute(rawArgs) {
959
978
  * resolve-blocker <index> → set blockers[index].resolved = true
960
979
  * record-session → update last_session timestamp
961
980
  * record-council --slug <s> --panel <csv> --artifact <path>
981
+ * sync-from-git → recover phase/sprint state from git commit history (#915)
962
982
  */
963
983
  function cmdState(subArgs) {
964
984
  const statePath = path.join(RCODE_DIR, 'state.json');
@@ -1010,12 +1030,75 @@ function cmdState(subArgs) {
1010
1030
  throw new Error('state.json exceeds 10 MB limit — possible corruption');
1011
1031
  }
1012
1032
  try {
1013
- return JSON.parse(fs.readFileSync(statePath, 'utf8'));
1033
+ const raw = JSON.parse(fs.readFileSync(statePath, 'utf8'));
1034
+ return migrateState(raw);
1014
1035
  } catch (e) {
1015
1036
  throw new Error(`Invalid JSON in state.json: ${e.message}`);
1016
1037
  }
1017
1038
  }
1018
1039
 
1040
+ /**
1041
+ * migrateState — pure normalizer that upgrades any legacy state shape to v2.
1042
+ *
1043
+ * v0: { milestone: string, no phases[], no schema_version }
1044
+ * v1: { phases[] with mixed shapes, schema_version: 1 }
1045
+ * v2 (target): { schema_version: 2, phases[] uniform, milestones[] array }
1046
+ *
1047
+ * This function is PURE — it never writes to disk. readState() calls it on
1048
+ * every read so all callers transparently receive v2-shaped data. (#735)
1049
+ */
1050
+ function migrateState(raw) {
1051
+ if (!raw || typeof raw !== 'object') return raw;
1052
+ const state = Object.assign({}, raw);
1053
+
1054
+ // --- milestones[] array (v0 → v2) ---
1055
+ // v0 state has milestone as a plain string and no milestones array.
1056
+ if (typeof state.milestone === 'string' && !Array.isArray(state.milestones)) {
1057
+ state.milestones = [{
1058
+ id: state.milestone,
1059
+ name: state.milestone,
1060
+ status: 'active',
1061
+ }];
1062
+ }
1063
+ if (!Array.isArray(state.milestones)) {
1064
+ state.milestones = [];
1065
+ }
1066
+
1067
+ // --- phases[] uniform shape (v1 → v2) ---
1068
+ // v1 phases have mixed shapes: some {number, name}, others {id, name, status}.
1069
+ if (Array.isArray(state.phases)) {
1070
+ state.phases = state.phases.map(p => {
1071
+ if (!p || typeof p !== 'object') return p;
1072
+ // Resolve number: prefer p.number, fall back to numeric part of p.id
1073
+ let number = p.number ?? null;
1074
+ if (number === null && typeof p.id === 'string') {
1075
+ const m = p.id.match(/^(\d+(?:\.\d+)?)/);
1076
+ if (m) number = m[1];
1077
+ }
1078
+ // Resolve id: prefer p.id, synthesize from number
1079
+ const id = p.id ?? (number !== null ? String(number) : undefined);
1080
+ return {
1081
+ number: number ?? p.id ?? null,
1082
+ id: id ?? null,
1083
+ name: p.name ?? null,
1084
+ status: p.status ?? 'planned',
1085
+ started: p.started ?? null,
1086
+ completed: p.completed ?? null,
1087
+ sprints: Array.isArray(p.sprints) ? p.sprints : [],
1088
+ // Preserve any extra fields that callers may rely on
1089
+ ...Object.fromEntries(
1090
+ Object.entries(p).filter(([k]) =>
1091
+ !['number', 'id', 'name', 'status', 'started', 'completed', 'sprints'].includes(k)
1092
+ )
1093
+ ),
1094
+ };
1095
+ });
1096
+ }
1097
+
1098
+ state.schema_version = 2;
1099
+ return state;
1100
+ }
1101
+
1019
1102
  /** Atomic write: write to temp file then rename. */
1020
1103
  function writeState(state) {
1021
1104
  function isProcessAlive(pid) {
@@ -1105,11 +1188,10 @@ function cmdState(subArgs) {
1105
1188
  const now = new Date().toISOString();
1106
1189
  return {
1107
1190
  version: '1',
1108
- // #8 — explicit schema_version field for future migration framework.
1109
- // Bump when the shape changes. `state schema-status` / `state migrate-schema`
1110
- // read this. Existing state files without the field are treated as v1
1111
- // (backwards-compat — never crash on legacy state).
1112
- schema_version: 1,
1191
+ // #8 / #735 — explicit schema_version field for migration framework.
1192
+ // v2: phases[] uniform shape + milestones[] array. migrateState() upgrades
1193
+ // older state files transparently on read. New state starts at v2.
1194
+ schema_version: 2,
1113
1195
  project: projectName || path.basename(PROJECT_ROOT),
1114
1196
  created: now,
1115
1197
  updated: now,
@@ -1117,6 +1199,7 @@ function cmdState(subArgs) {
1117
1199
  current_plan: 0,
1118
1200
  current_sprint: null,
1119
1201
  phases: [],
1202
+ milestones: [],
1120
1203
  velocity_history: [],
1121
1204
  executions: [],
1122
1205
  decisions: [],
@@ -1253,6 +1336,18 @@ function cmdState(subArgs) {
1253
1336
  const name = subArgs[1];
1254
1337
  if (!name) throw new Error('set-phase requires a phase name argument');
1255
1338
  const state = readState() || defaultState();
1339
+ // Fix #854 — mark the previously active phase as completed before switching.
1340
+ if (state.current_phase && state.current_phase !== name && state.phases && state.phases.length > 0) {
1341
+ const prevIdx = state.phases.findIndex(p =>
1342
+ p.name === state.current_phase ||
1343
+ String(p.number) === String(state.current_phase) ||
1344
+ String(p.id) === String(state.current_phase)
1345
+ );
1346
+ if (prevIdx !== -1 && state.phases[prevIdx].status !== 'completed') {
1347
+ state.phases[prevIdx].status = 'completed';
1348
+ state.phases[prevIdx].completed = new Date().toISOString();
1349
+ }
1350
+ }
1256
1351
  state.current_phase = name;
1257
1352
  state.current_plan = 0;
1258
1353
  if (!state.phases) state.phases = [];
@@ -1279,7 +1374,36 @@ function cmdState(subArgs) {
1279
1374
  // Update name to canonical form when re-entering a phase
1280
1375
  state.phases[existingIdx].name = name;
1281
1376
  }
1282
- return writeState(state);
1377
+ // #894 — Proactively sync state.milestone from ROADMAP when set-phase is called.
1378
+ // If ROADMAP.md is readable, find its last active milestone heading and update
1379
+ // state.milestone if it differs (state can go stale after milestone transitions).
1380
+ try {
1381
+ const roadmapPathSP = path.join(PLANNING_DIR, 'ROADMAP.md');
1382
+ if (fs.existsSync(roadmapPathSP)) {
1383
+ const rmText = fs.readFileSync(roadmapPathSP, 'utf8');
1384
+ const mhRe = /^#{1,2}\s+(M\d+[^\n]*)/gm;
1385
+ let lastLabel = null, mhM;
1386
+ while ((mhM = mhRe.exec(rmText)) !== null) {
1387
+ if (/^milestones?\s*$/i.test(mhM[1].trim())) continue;
1388
+ lastLabel = mhM[1].trim();
1389
+ }
1390
+ if (lastLabel && lastLabel !== (state.milestone || '')) {
1391
+ state.milestone = lastLabel;
1392
+ }
1393
+ }
1394
+ } catch (_) { /* ROADMAP unreadable; leave milestone as-is */ }
1395
+
1396
+ const spResult = writeState(state);
1397
+ // Fix #855 — keep config.yaml in sync when set-phase writes state.json.
1398
+ // One-way guard: only sync if config.yaml is already present (i.e. project is initialised).
1399
+ try {
1400
+ const cfgLib = require(path.join(__dirname, 'lib', 'config.cjs'));
1401
+ const existingCfgPhase = cfgLib.cmdGet(PROJECT_ROOT, 'current_phase');
1402
+ if (String(existingCfgPhase || '') !== String(name)) {
1403
+ cfgLib.cmdSet(PROJECT_ROOT, 'current_phase', name);
1404
+ }
1405
+ } catch (_) { /* config.yaml may not exist yet; silently skip */ }
1406
+ return spResult;
1283
1407
  }
1284
1408
 
1285
1409
  // --- advance-plan ---
@@ -1929,6 +2053,146 @@ function cmdState(subArgs) {
1929
2053
  return writeState(state);
1930
2054
  }
1931
2055
 
2056
+ // --- sync-from-git ---
2057
+ // Recover execution state by inspecting git log for implementation commits.
2058
+ // For each phase that has sprints, checks whether feat:/fix:/refactor: commits
2059
+ // referencing that phase number exist. If so, marks sprints completed and phase
2060
+ // as executed (not complete — verifier should still run). Issue #915.
2061
+ if (sub === 'sync-from-git') {
2062
+ const state = readState();
2063
+ if (!state) return { ok: false, error: 'No state.json — run `state init` first.' };
2064
+
2065
+ const { execSync } = require('child_process');
2066
+ let gitLog = '';
2067
+ try {
2068
+ gitLog = execSync('git log --oneline', { cwd: PROJECT_ROOT, encoding: 'utf8' });
2069
+ } catch (e) {
2070
+ return { ok: false, error: `git log failed: ${e.message}` };
2071
+ }
2072
+
2073
+ const implPrefixRe = /^[a-f0-9]+ (feat|fix|refactor|perf|style|test|chore)\(/i;
2074
+ const implLines = gitLog.split('\n').filter(l => implPrefixRe.test(l));
2075
+
2076
+ // Read ROADMAP.md once so we can look up each phase's declared status.
2077
+ // Fix #897 — sync-from-git was ignoring ROADMAP status entirely, causing
2078
+ // all phases to stay as 'planned' even when ROADMAP said 'complete'.
2079
+ let roadmapText = '';
2080
+ try {
2081
+ const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
2082
+ if (fs.existsSync(roadmapPath)) roadmapText = fs.readFileSync(roadmapPath, 'utf8');
2083
+ } catch { /* ignore — ROADMAP is optional */ }
2084
+
2085
+ // Normalise raw status strings from ROADMAP into canonical state.json vocabulary.
2086
+ // Mirrors the normalizeStatus() defined in the `state sync` handler.
2087
+ function normalizeStatusSFG(raw) {
2088
+ if (!raw) return 'planned';
2089
+ const s = String(raw).toLowerCase().replace(/[✅\s]/g, '');
2090
+ if (['complete','completed','shipped','verified','done'].includes(s)) return 'complete';
2091
+ if (['executing','in_progress','inprogress','active','started'].includes(s)) return 'in_progress';
2092
+ return 'planned';
2093
+ }
2094
+
2095
+ // Returns the status declared in ROADMAP for a given phase number, or null
2096
+ // if the phase isn't found. Handles both pipe-table and heading-block formats.
2097
+ function readPhaseStatusFromRoadmap(phaseNum) {
2098
+ if (!roadmapText) return null;
2099
+ // Pipe-table row: | <num> | <name> | <goal> | <status> |
2100
+ const tableRe = new RegExp(
2101
+ `^\\|\\s*${phaseNum.replace('.', '\\.')}\\s*\\|[^|]+\\|[^|]*\\|(?:\\s*([^|\\n]*?)\\s*\\|)?`,
2102
+ 'm'
2103
+ );
2104
+ const tableMatch = roadmapText.match(tableRe);
2105
+ if (tableMatch && tableMatch[1] !== undefined) return normalizeStatusSFG(tableMatch[1]);
2106
+
2107
+ // Heading-block format: ## Phase <num> — <name>\n...**Status:** <value>
2108
+ const headRe = new RegExp(
2109
+ `^#{2,4}\\s*Phase\\s+${phaseNum.replace('.', '\\.')}\\s*[—\\-:]`,
2110
+ 'm'
2111
+ );
2112
+ const headMatch = headRe.exec(roadmapText);
2113
+ if (headMatch) {
2114
+ const after = roadmapText.slice(headMatch.index + headMatch[0].length).split('\n').slice(0, 8).join('\n');
2115
+ const statusMatch = after.match(/\*\*Status:\*\*\s*(.+)/i);
2116
+ if (statusMatch) return normalizeStatusSFG(statusMatch[1].trim());
2117
+ }
2118
+ return null;
2119
+ }
2120
+
2121
+ let syncedPhases = 0;
2122
+ let syncedSprints = 0;
2123
+
2124
+ const statusRankSFG = { complete: 3, verified: 3, executed: 2, in_progress: 1, planned: 0 };
2125
+
2126
+ const phases = Array.isArray(state.phases) ? state.phases : [];
2127
+ for (const phase of phases) {
2128
+ if (!phase) continue;
2129
+ const num = String(phase.number || phase.id || '').trim();
2130
+ if (!num) continue;
2131
+
2132
+ // Read the phase's declared status from ROADMAP and apply it when it
2133
+ // advances the current status — never downgrade. (#897)
2134
+ const roadmapStatus = readPhaseStatusFromRoadmap(num);
2135
+ if (roadmapStatus) {
2136
+ const currentRank = statusRankSFG[phase.status] ?? 0;
2137
+ const roadmapRank = statusRankSFG[roadmapStatus] ?? 0;
2138
+ if (roadmapRank > currentRank) {
2139
+ phase.status = roadmapStatus;
2140
+ syncedPhases++;
2141
+ }
2142
+ }
2143
+
2144
+ // Check if any implementation commit references this phase number.
2145
+ // Matches patterns: "phase 1", "phase 1.", "1.1", "(1)", "#1 "
2146
+ const phaseNumEscaped = num.replace('.', '\\.');
2147
+ const phaseRe = new RegExp(
2148
+ `(phase\\s*${phaseNumEscaped}[^\\d]|\\b${phaseNumEscaped}\\.\\d|\\(${phaseNumEscaped}\\)|\\s${phaseNumEscaped}\\s)`,
2149
+ 'i'
2150
+ );
2151
+ const hasImplCommit = implLines.some(l => phaseRe.test(l));
2152
+
2153
+ // Also check if SUMMARY.md exists for this phase
2154
+ let hasSummary = false;
2155
+ try {
2156
+ const phaseDirs = fs.existsSync(PLANNING_DIR)
2157
+ ? fs.readdirSync(PLANNING_DIR).filter(d => {
2158
+ const m = d.match(/^(\d+)/);
2159
+ return m && m[1] === num;
2160
+ })
2161
+ : [];
2162
+ if (phaseDirs.length > 0) {
2163
+ const summaryPath = path.join(PLANNING_DIR, phaseDirs[0], 'SUMMARY.md');
2164
+ hasSummary = fs.existsSync(summaryPath);
2165
+ }
2166
+ } catch { /* ignore fs errors */ }
2167
+
2168
+ const sprints = Array.isArray(phase.sprints) ? phase.sprints : [];
2169
+ if ((hasImplCommit || hasSummary) && sprints.length > 0) {
2170
+ for (const sprint of sprints) {
2171
+ if (sprint && sprint.status !== 'completed') {
2172
+ sprint.status = 'completed';
2173
+ syncedSprints++;
2174
+ }
2175
+ }
2176
+ if (phase.status !== 'complete' && phase.status !== 'verified') {
2177
+ // Git evidence upgrades to 'executed' only when ROADMAP doesn't already
2178
+ // report a higher status (complete/verified already applied above).
2179
+ if ((statusRankSFG['executed'] ?? 0) > (statusRankSFG[phase.status] ?? 0)) {
2180
+ phase.status = 'executed';
2181
+ syncedPhases++;
2182
+ }
2183
+ }
2184
+ }
2185
+ }
2186
+
2187
+ writeState(state);
2188
+ return {
2189
+ ok: true,
2190
+ message: `Synced ${syncedPhases} phases, ${syncedSprints} sprints from git history`,
2191
+ synced_phases: syncedPhases,
2192
+ synced_sprints: syncedSprints,
2193
+ };
2194
+ }
2195
+
1932
2196
  // --- record-chain ---
1933
2197
  if (sub === 'record-chain') {
1934
2198
  const flags = parseFlags(1);
@@ -3058,7 +3322,19 @@ function cmdState(subArgs) {
3058
3322
  parsed.phases_normalized = beforeClean - cleaned.length;
3059
3323
  state.phases = cleaned;
3060
3324
 
3061
- const upsertPhase = (phaseNum, phaseName, phaseGoal) => {
3325
+ // Normalise any raw status string from ROADMAP into the canonical
3326
+ // vocabulary used by state.json: 'complete' | 'in_progress' | 'planned'.
3327
+ // Fix #897 — status was never read from ROADMAP, so every phase always
3328
+ // landed as 'planned' regardless of what the doc said.
3329
+ function normalizeStatus(raw) {
3330
+ if (!raw) return 'planned';
3331
+ const s = String(raw).toLowerCase().replace(/[✅\s]/g, '');
3332
+ if (['complete','completed','shipped','verified','done'].includes(s)) return 'complete';
3333
+ if (['executing','in_progress','inprogress','active','started'].includes(s)) return 'in_progress';
3334
+ return 'planned';
3335
+ }
3336
+
3337
+ const upsertPhase = (phaseNum, phaseName, phaseGoal, phaseStatus) => {
3062
3338
  if (!/^\d/.test(phaseNum)) return;
3063
3339
  if (phaseName.toLowerCase() === 'phase') return;
3064
3340
  if (seenNums.has(phaseNum)) return;
@@ -3074,12 +3350,21 @@ function cmdState(subArgs) {
3074
3350
  String(p.id) === phaseNum ||
3075
3351
  p.name === phaseName
3076
3352
  );
3353
+ // Status precedence for advancement: complete > in_progress > planned.
3354
+ // A phase should never be downgraded by ROADMAP re-sync.
3355
+ const statusRank = { complete: 2, in_progress: 1, planned: 0 };
3356
+ const incomingStatus = normalizeStatus(phaseStatus);
3077
3357
  if (existingIdx >= 0) {
3078
3358
  // Backfill both id and number so future readers using either schema find it.
3079
3359
  state.phases[existingIdx].number = state.phases[existingIdx].number || phaseNum;
3080
3360
  state.phases[existingIdx].id = state.phases[existingIdx].id || phaseNum;
3081
3361
  state.phases[existingIdx].name = phaseName;
3082
3362
  if (phaseGoal) state.phases[existingIdx].goal = phaseGoal;
3363
+ // Only advance status — never downgrade an existing phase's status via sync.
3364
+ const currentRank = statusRank[normalizeStatus(state.phases[existingIdx].status)] ?? 0;
3365
+ if ((statusRank[incomingStatus] ?? 0) > currentRank) {
3366
+ state.phases[existingIdx].status = incomingStatus;
3367
+ }
3083
3368
  } else {
3084
3369
  // Write both id and number on every new entry so dedup works regardless
3085
3370
  // of which schema future readers expect.
@@ -3088,7 +3373,7 @@ function cmdState(subArgs) {
3088
3373
  number: phaseNum,
3089
3374
  name: phaseName,
3090
3375
  goal: phaseGoal,
3091
- status: 'planned',
3376
+ status: incomingStatus,
3092
3377
  started: null,
3093
3378
  completed: null,
3094
3379
  plan_count: 0,
@@ -3100,10 +3385,12 @@ function cmdState(subArgs) {
3100
3385
  // Format A — pipe tables
3101
3386
  // Phase number: \d+ (not \d{1,3}) — high numbers like 1001 are valid for
3102
3387
  // hot-track parking-lot phases per parking-lot-convention.md.
3103
- const rowRe = /^\|\s*(\d+(?:\.\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|/gm;
3388
+ // The optional 4th capture group reads the status column when present
3389
+ // (fix #897 — status was silently dropped before).
3390
+ const rowRe = /^\|\s*(\d+(?:\.\d+)?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|(?:\s*([^|\n]*?)\s*\|)?/gm;
3104
3391
  let m;
3105
3392
  while ((m = rowRe.exec(roadmap)) !== null) {
3106
- upsertPhase(m[1].trim(), m[2].trim(), m[3].trim());
3393
+ upsertPhase(m[1].trim(), m[2].trim(), m[3].trim(), m[4] || '');
3107
3394
  }
3108
3395
 
3109
3396
  // Format B — heading style
@@ -3113,7 +3400,11 @@ function cmdState(subArgs) {
3113
3400
  const name = m[2].trim();
3114
3401
  const after = roadmap.slice(headRe.lastIndex).split(/\n/).slice(0, 8).join('\n');
3115
3402
  const goalMatch = after.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
3116
- upsertPhase(num, name, goalMatch ? goalMatch[1].trim() : '');
3403
+ // Fix #897 read **Status:** from the post-heading block so heading-format
3404
+ // ROADMAPs propagate phase status into state just like pipe-table format.
3405
+ const statusMatch = after.match(/\*\*Status:\*\*\s*(.+)/i);
3406
+ const phaseStatus = statusMatch ? statusMatch[1].trim() : '';
3407
+ upsertPhase(num, name, goalMatch ? goalMatch[1].trim() : '', phaseStatus);
3117
3408
  }
3118
3409
  }
3119
3410
 
@@ -3253,6 +3544,25 @@ function cmdState(subArgs) {
3253
3544
  warnings.push('epics.md exists but no epics parsed — check "## EPIC-NN" or "## Epic N" heading format.');
3254
3545
  }
3255
3546
 
3547
+ // #894 — Proactively sync state.milestone from ROADMAP on state sync.
3548
+ // After upserting phases from ROADMAP, also derive the active milestone from
3549
+ // the last top-level milestone heading and correct state.milestone if stale.
3550
+ if (parsed.roadmap_exists) {
3551
+ try {
3552
+ const rmSync = fs.readFileSync(roadmapPath, 'utf8');
3553
+ const syncMhRe = /^#{1,2}\s+(M\d+[^\n]*)/gm;
3554
+ let syncLastLabel = null, syncMhM;
3555
+ while ((syncMhM = syncMhRe.exec(rmSync)) !== null) {
3556
+ if (/^milestones?\s*$/i.test(syncMhM[1].trim())) continue;
3557
+ syncLastLabel = syncMhM[1].trim();
3558
+ }
3559
+ if (syncLastLabel && syncLastLabel !== (state.milestone || '')) {
3560
+ state.milestone = syncLastLabel;
3561
+ parsed.milestone_synced = syncLastLabel;
3562
+ }
3563
+ } catch (_) { /* ROADMAP unreadable at write time; leave milestone as-is */ }
3564
+ }
3565
+
3256
3566
  writeState(state);
3257
3567
  return { ok: true, synced: true, ...parsed, ...(warnings.length ? { warnings } : {}) };
3258
3568
  }
@@ -3465,6 +3775,23 @@ function cmdPhase(subArgs) {
3465
3775
 
3466
3776
  if (fs.existsSync(roadmapPath)) {
3467
3777
  let text = fs.readFileSync(roadmapPath, 'utf8');
3778
+
3779
+ // #895 — Validate state.milestone against ROADMAP before inserting.
3780
+ // Find the last top-level milestone heading ("# M\d+" or "## M\d+") in
3781
+ // ROADMAP.md. That is the active milestone — use it as the insertion
3782
+ // target and correct state.milestone if it is stale.
3783
+ const milestoneHeadingRe = /^#{1,2}\s+(M\d+[^\n]*)/gm;
3784
+ let lastMilestoneLabel = null;
3785
+ let mh;
3786
+ while ((mh = milestoneHeadingRe.exec(text)) !== null) {
3787
+ // Skip the generic "## Milestones" index heading.
3788
+ if (/^milestones?\s*$/i.test(mh[1].trim())) continue;
3789
+ lastMilestoneLabel = mh[1].trim();
3790
+ }
3791
+ if (lastMilestoneLabel && lastMilestoneLabel !== (state.milestone || '')) {
3792
+ state.milestone = lastMilestoneLabel;
3793
+ }
3794
+
3468
3795
  const backlogMatch = text.match(/^##\s+Backlog\b/m);
3469
3796
  if (backlogMatch) {
3470
3797
  const backlogIdx = backlogMatch.index;
@@ -3851,7 +4178,84 @@ function cmdPhase(subArgs) {
3851
4178
  return { ok: true, count: created.length, phases: created, roadmap_skipped: roadmapSkipped };
3852
4179
  }
3853
4180
 
3854
- throw new Error(`Unknown phase subcommand: ${sub || '(none)'}. Valid: add, complete, sync-sprints, set-status, next-range, scaffold-milestone`);
4181
+ // =====================================================================
4182
+ // phase scaffold-all — materialise folders for every phase in ROADMAP.md
4183
+ // that lacks a directory under .planning/phases/.
4184
+ // Closes #731. No --names arg required — reads the ROADMAP table directly.
4185
+ // Only creates directories; does NOT create .md files inside them.
4186
+ // =====================================================================
4187
+ if (sub === 'scaffold-all') {
4188
+ const roadmapPath = path.join(PLANNING_DIR, 'ROADMAP.md');
4189
+ const phasesDir = path.join(PLANNING_DIR, 'phases');
4190
+
4191
+ if (!fs.existsSync(roadmapPath)) {
4192
+ throw new Error(`No ROADMAP.md found at ${roadmapPath} — run /rcode-init first`);
4193
+ }
4194
+
4195
+ const roadmap = fs.readFileSync(roadmapPath, 'utf8');
4196
+
4197
+ // Collect (number, name) pairs from pipe-table rows: | N | Phase Name | ...
4198
+ // Also pick up ## Phase N — Name headings as a fallback.
4199
+ const phases = [];
4200
+ const seen = new Set();
4201
+
4202
+ // Table rows: | 8 | Feature X | ...
4203
+ const tableRe = /^\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|/gm;
4204
+ let m;
4205
+ while ((m = tableRe.exec(roadmap)) !== null) {
4206
+ const num = m[1];
4207
+ const name = m[2].trim();
4208
+ // Skip header rows (e.g. "Phase" as the number column)
4209
+ if (!seen.has(num) && /^\d+$/.test(num)) {
4210
+ seen.add(num);
4211
+ phases.push({ num: num.padStart(2, '0'), rawNum: num, name });
4212
+ }
4213
+ }
4214
+
4215
+ // Heading rows: ## Phase 8 — Feature X
4216
+ const headRe = /^#{2,4}\s*Phase\s+(\d+)\s*[—–-]\s*(.+?)\s*$/gm;
4217
+ while ((m = headRe.exec(roadmap)) !== null) {
4218
+ const num = m[1];
4219
+ const name = m[2].trim();
4220
+ if (!seen.has(num)) {
4221
+ seen.add(num);
4222
+ phases.push({ num: num.padStart(2, '0'), rawNum: num, name });
4223
+ }
4224
+ }
4225
+
4226
+ if (phases.length === 0) {
4227
+ return { ok: true, message: 'No phases found in ROADMAP.md — nothing to scaffold', created: [], existed: [] };
4228
+ }
4229
+
4230
+ const created = [];
4231
+ const existed = [];
4232
+
4233
+ for (const p of phases) {
4234
+ const slug = p.name
4235
+ .toLowerCase()
4236
+ .replace(/[^a-z0-9\s-]/g, '')
4237
+ .replace(/\s+/g, '-')
4238
+ .replace(/-+/g, '-')
4239
+ .replace(/^-+|-+$/g, '');
4240
+ if (!slug) continue; // skip rows with no usable name (e.g. header rows)
4241
+
4242
+ const dirName = `${p.num}-${slug}`;
4243
+ const dirPath = path.join(phasesDir, dirName);
4244
+
4245
+ if (fs.existsSync(dirPath)) {
4246
+ existed.push(dirPath);
4247
+ console.log(`Exists: ${dirPath}`);
4248
+ } else {
4249
+ fs.mkdirSync(dirPath, { recursive: true });
4250
+ created.push(dirPath);
4251
+ console.log(`Created: ${dirPath}`);
4252
+ }
4253
+ }
4254
+
4255
+ return { ok: true, created: created.length, existed: existed.length, dirs: { created, existed } };
4256
+ }
4257
+
4258
+ throw new Error(`Unknown phase subcommand: ${sub || '(none)'}. Valid: add, complete, sync-sprints, set-status, next-range, scaffold-milestone, scaffold-all`);
3855
4259
  }
3856
4260
 
3857
4261
  /**
@@ -3996,14 +4400,15 @@ function cmdCommit(argv) {
3996
4400
  const tmpMsgPath = path.join(require('os').tmpdir(), `rcode-commit-msg-${Date.now()}.txt`);
3997
4401
  fs.writeFileSync(tmpMsgPath, message);
3998
4402
  try {
3999
- execSync(`git commit -F "${tmpMsgPath}"`, { cwd: PROJECT_ROOT, stdio: 'pipe' });
4403
+ // execFileSync no shell, so tmpMsgPath with special chars cannot inject (#754).
4404
+ execFileSync('git', ['commit', '-F', tmpMsgPath], { cwd: PROJECT_ROOT, stdio: 'pipe' });
4000
4405
  } finally {
4001
4406
  try { fs.unlinkSync(tmpMsgPath); } catch {}
4002
4407
  }
4003
4408
 
4004
4409
  // Capture the new HEAD SHA for return value
4005
- const sha = execSync('git rev-parse HEAD', { cwd: PROJECT_ROOT, encoding: 'utf8' }).trim();
4006
- const filesChanged = execSync(`git show --stat --format="" ${sha}`, { cwd: PROJECT_ROOT, encoding: 'utf8' })
4410
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: PROJECT_ROOT, encoding: 'utf8' }).trim();
4411
+ const filesChanged = execFileSync('git', ['show', '--stat', '--format=', sha], { cwd: PROJECT_ROOT, encoding: 'utf8' })
4007
4412
  .trim().split('\n').filter(Boolean);
4008
4413
 
4009
4414
  return {
@@ -4254,7 +4659,7 @@ function cmdCommitToSubrepo(argv) {
4254
4659
  }
4255
4660
  }
4256
4661
 
4257
- const { execSync } = require('child_process');
4662
+ const { execSync, execFileSync: execFileSyncLocal } = require('child_process');
4258
4663
  const status = execSync('git diff --cached --name-only', { cwd: subrepoPath, encoding: 'utf8' }).trim();
4259
4664
  if (!status) {
4260
4665
  throw new Error(`Nothing staged in subrepo ${subrepo}. Stage files inside the subrepo with git add first.`);
@@ -4263,12 +4668,13 @@ function cmdCommitToSubrepo(argv) {
4263
4668
  const tmpMsgPath = path.join(require('os').tmpdir(), `rcode-subrepo-msg-${Date.now()}.txt`);
4264
4669
  fs.writeFileSync(tmpMsgPath, message);
4265
4670
  try {
4266
- execSync(`git commit -F "${tmpMsgPath}"`, { cwd: subrepoPath, stdio: 'pipe' });
4671
+ // execFileSync no shell, so tmpMsgPath with special chars cannot inject (#754).
4672
+ execFileSyncLocal('git', ['commit', '-F', tmpMsgPath], { cwd: subrepoPath, stdio: 'pipe' });
4267
4673
  } finally {
4268
4674
  try { fs.unlinkSync(tmpMsgPath); } catch {}
4269
4675
  }
4270
4676
 
4271
- const sha = execSync('git rev-parse HEAD', { cwd: subrepoPath, encoding: 'utf8' }).trim();
4677
+ const sha = execFileSyncLocal('git', ['rev-parse', 'HEAD'], { cwd: subrepoPath, encoding: 'utf8' }).trim();
4272
4678
  return {
4273
4679
  ok: true,
4274
4680
  subrepo,
@@ -5908,7 +6314,7 @@ function cmdBrain(args) {
5908
6314
  // #170 — global brain cache at ~/.rcode/brain-cache/<sha1(repo+branch+paths)>/.
5909
6315
  // Same source pulled from N projects = N clones today, 1 clone + N copies
5910
6316
  // after this change. Cache TTL is configurable per source (defaults to 6h).
5911
- const { execSync } = require('child_process');
6317
+ const { execSync, execFileSync: execFileSyncBrain } = require('child_process');
5912
6318
  const crypto = require('crypto');
5913
6319
  const os = require('os');
5914
6320
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rcode-brain-'));
@@ -5978,13 +6384,15 @@ function cmdBrain(args) {
5978
6384
  // Use --no-checkout + explicit sparse-checkout init + set + checkout
5979
6385
  // because `git clone --sparse` combined with --filter=blob:none has
5980
6386
  // an intermittent failure mode where git misreads the URL as a path.
5981
- execSync(
5982
- `git clone --depth=1 --filter=blob:none --no-checkout --branch="${branch}" "${repo}" "${tmp}"`,
5983
- { stdio: 'pipe' }
5984
- );
5985
- execSync(`git -C "${tmp}" sparse-checkout init --no-cone`, { stdio: 'pipe' });
5986
- execSync(`git -C "${tmp}" sparse-checkout set ${sparsePaths.map(p => `"${p}"`).join(' ')}`, { stdio: 'pipe' });
5987
- execSync(`git -C "${tmp}" checkout`, { stdio: 'pipe' });
6387
+ // execFileSync — repo/branch/tmp/sparsePaths from user config; no shell so
6388
+ // values with spaces, quotes, or semicolons cannot inject commands (#754).
6389
+ execFileSyncBrain('git', [
6390
+ 'clone', '--depth=1', '--filter=blob:none', '--no-checkout',
6391
+ `--branch=${branch}`, repo, tmp,
6392
+ ], { stdio: 'pipe' });
6393
+ execFileSyncBrain('git', ['-C', tmp, 'sparse-checkout', 'init', '--no-cone'], { stdio: 'pipe' });
6394
+ execFileSyncBrain('git', ['-C', tmp, 'sparse-checkout', 'set', ...sparsePaths], { stdio: 'pipe' });
6395
+ execFileSyncBrain('git', ['-C', tmp, 'checkout'], { stdio: 'pipe' });
5988
6396
 
5989
6397
  // Warm cache before destination copy so a copy failure to dest still
5990
6398
  // saves the next pull. Replace any stale slot atomically.
@@ -5993,7 +6401,7 @@ function cmdBrain(args) {
5993
6401
  fs.mkdirSync(cacheDir, { recursive: true });
5994
6402
  copyTree(tmp, cacheDir);
5995
6403
  const commitSha = (() => {
5996
- try { return execSync(`git -C "${tmp}" rev-parse HEAD`, { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); }
6404
+ try { return execFileSyncBrain('git', ['-C', tmp, 'rev-parse', 'HEAD'], { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim(); }
5997
6405
  catch { return null; }
5998
6406
  })();
5999
6407
  fs.writeFileSync(cacheManifest, JSON.stringify({
@@ -6690,6 +7098,10 @@ function cmdRoadmapDetectStructure() {
6690
7098
  * Thresholds (kept conservative — bump in config later if needed):
6691
7099
  * - "consider closing" when >= 8 open phases under one milestone
6692
7100
  * - "should close" when >= 12 open phases (hard nudge)
7101
+ *
7102
+ * Fix #893 — open-phase count is now scoped to the CURRENT milestone only.
7103
+ * Phases that belong to a prior milestone (different ## MN heading in
7104
+ * ROADMAP.md) are excluded so M2 phases never inflate M3's count.
6693
7105
  */
6694
7106
  function cmdMilestoneHealth() {
6695
7107
  const statePath = path.join(RCODE_DIR, 'state.json');
@@ -6698,12 +7110,69 @@ function cmdMilestoneHealth() {
6698
7110
  try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); }
6699
7111
  catch (e) { return { ok: false, error: `invalid state.json: ${e.message}` }; }
6700
7112
 
6701
- const milestone = state.milestone || null;
6702
- const phases = Array.isArray(state.phases) ? state.phases : [];
7113
+ // Prefer state.milestone; fall back to state.current_milestone for compat.
7114
+ const milestone = state.milestone || state.current_milestone || null;
7115
+ const allPhases = Array.isArray(state.phases) ? state.phases : [];
7116
+
7117
+ // --- milestone-scoped phase filtering (#893) ---
7118
+ // Parse ROADMAP.md to find which phase numbers belong to the current
7119
+ // milestone heading (## M1, ## Milestone 2, etc.). When ROADMAP is absent
7120
+ // or the milestone can't be matched, fall back to ALL phases so the health
7121
+ // check still works on unstructured projects.
7122
+ let milestonePhasesNumbers = null; // null = no filtering
7123
+ const roadmapPath = path.join(PROJECT_ROOT, '.planning', 'ROADMAP.md');
7124
+ if (milestone && fs.existsSync(roadmapPath)) {
7125
+ try {
7126
+ const text = fs.readFileSync(roadmapPath, 'utf8');
7127
+ const lines = text.split('\n');
7128
+ // Find the heading that matches the current milestone label.
7129
+ // Accepted forms: "## M3", "## Milestone 3", "## M3 — Some Title", etc.
7130
+ const milestoneId = String(milestone).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7131
+ const milestoneHeadRe = new RegExp(`^#{1,3}\\s+${milestoneId}[\\s—:\\-]`, 'i');
7132
+ const milestoneHeadBareRe = new RegExp(`^#{1,3}\\s+${milestoneId}\\s*$`, 'i');
7133
+ // Any milestone-level heading that is NOT a Phase heading
7134
+ const anyMilestoneHeadRe = /^#{1,3}\s+(?!Phase\s)/i;
7135
+
7136
+ let inCurrentMilestone = false;
7137
+ const phaseNums = new Set();
7138
+ const phaseRowRe = /^\|\s*(\d+(?:\.\d+)?)\s*\|/;
7139
+ const phaseHeadRe = /^#{2,4}\s*Phase\s+(\d+(?:\.\d+)?)/i;
7140
+
7141
+ for (const line of lines) {
7142
+ if (milestoneHeadRe.test(line) || milestoneHeadBareRe.test(line)) {
7143
+ inCurrentMilestone = true;
7144
+ continue;
7145
+ }
7146
+ // A different milestone-level heading ends this section
7147
+ if (inCurrentMilestone && anyMilestoneHeadRe.test(line) &&
7148
+ !milestoneHeadRe.test(line) && !milestoneHeadBareRe.test(line)) {
7149
+ break;
7150
+ }
7151
+ if (inCurrentMilestone) {
7152
+ const rm = phaseRowRe.exec(line);
7153
+ if (rm) { phaseNums.add(rm[1]); continue; }
7154
+ const rh = phaseHeadRe.exec(line);
7155
+ if (rh) { phaseNums.add(rh[1]); }
7156
+ }
7157
+ }
7158
+ if (phaseNums.size > 0) milestonePhasesNumbers = phaseNums;
7159
+ } catch { /* ROADMAP unreadable — skip filtering */ }
7160
+ }
7161
+
7162
+ // Select only phases that belong to the current milestone, or all if
7163
+ // ROADMAP filtering wasn't possible.
7164
+ const phases = milestonePhasesNumbers
7165
+ ? allPhases.filter(p => {
7166
+ const num = String(p.number ?? p.id ?? '').trim();
7167
+ return milestonePhasesNumbers.has(num);
7168
+ })
7169
+ : allPhases;
7170
+
6703
7171
  // "Open" = not done. State schema uses status: 'planned' | 'in_progress' |
6704
- // 'completed' | 'verified' | 'shipped'. Treat anything not in
6705
- // {completed, verified, shipped} as open.
6706
- const doneStatuses = new Set(['completed', 'verified', 'shipped']);
7172
+ // 'complete' | 'completed' | 'verified' | 'shipped'. Treat anything not in
7173
+ // that set as open. Fix #897 — 'complete' was excluded, causing
7174
+ // milestone-health to report done phases as open.
7175
+ const doneStatuses = new Set(['complete', 'completed', 'verified', 'shipped']);
6707
7176
  const open = phases.filter(p => !doneStatuses.has(p.status));
6708
7177
  const done = phases.filter(p => doneStatuses.has(p.status));
6709
7178
 
@@ -6720,6 +7189,7 @@ function cmdMilestoneHealth() {
6720
7189
  recommendation,
6721
7190
  threshold_consider: 8,
6722
7191
  threshold_should: 12,
7192
+ milestone_scoped: milestonePhasesNumbers !== null,
6723
7193
  };
6724
7194
  }
6725
7195
 
@@ -6901,11 +7371,27 @@ function cmdFindFiles(rawArgs) {
6901
7371
  return { ok: true, type, matches };
6902
7372
  }
6903
7373
 
7374
+ // Closes #739 — parse human duration strings into milliseconds.
7375
+ // Supports: '30m' → 1800000, '2h' → 7200000, '1d' → 86400000.
7376
+ function parseDuration(str) {
7377
+ if (!str || typeof str !== 'string') return 3600000; // default 1h
7378
+ const m = str.trim().match(/^(\d+(?:\.\d+)?)([smhd]?)$/i);
7379
+ if (!m) return 3600000;
7380
+ const n = parseFloat(m[1]);
7381
+ switch ((m[2] || 'h').toLowerCase()) {
7382
+ case 'd': return Math.round(n * 86400000);
7383
+ case 'h': return Math.round(n * 3600000);
7384
+ case 'm': return Math.round(n * 60000);
7385
+ case 's': return Math.round(n * 1000);
7386
+ default: return Math.round(n * 3600000);
7387
+ }
7388
+ }
7389
+
6904
7390
  async function main() {
6905
7391
  const [, , subcommand, ...args] = process.argv;
6906
7392
  // #473 guard runs before any subcommand. Skipped for read-only inspection
6907
7393
  // so 'rcode-tools version' / 'help' / 'list-agents' work outside the project.
6908
- const READ_ONLY_SUBCOMMANDS = new Set(['version', 'help', '--help', '-h', undefined, 'list-agents', 'agent-info', 'agent-skills']);
7394
+ const READ_ONLY_SUBCOMMANDS = new Set(['version', 'help', '--help', '-h', undefined, 'list-agents', 'agent-info', 'agent-skills', 'validate']);
6909
7395
  if (!READ_ONLY_SUBCOMMANDS.has(subcommand)) {
6910
7396
  assertCwdMatchesProjectRoot();
6911
7397
  }
@@ -7099,6 +7585,42 @@ async function main() {
7099
7585
  console.log(JSON.stringify(result, null, 2));
7100
7586
  return;
7101
7587
  }
7588
+ case 'validate': {
7589
+ // #747 — schema checks for state.json and config.yaml.
7590
+ // Usage: rcode-tools validate state|config|all
7591
+ const target = args[0] || 'all';
7592
+ if (!['state', 'config', 'all'].includes(target)) {
7593
+ console.error(`Unknown validate target: '${target}'. Valid: state, config, all`);
7594
+ process.exit(1);
7595
+ }
7596
+ const validateErrors = [];
7597
+
7598
+ if (target === 'state' || target === 'all') {
7599
+ const state = readState();
7600
+ if (!state) {
7601
+ validateErrors.push('state: state.json not found or empty');
7602
+ } else {
7603
+ if (!state.current_phase) validateErrors.push('state: missing current_phase');
7604
+ if (!state.current_milestone) validateErrors.push('state: missing current_milestone');
7605
+ if (state.schema_version !== 2) validateErrors.push(`state: schema_version should be 2, got ${state.schema_version}`);
7606
+ if (!Array.isArray(state.phases) && typeof state.phases !== 'object') validateErrors.push('state: phases must be array or object');
7607
+ }
7608
+ }
7609
+
7610
+ if (target === 'config' || target === 'all') {
7611
+ const config = readConfig();
7612
+ if (!config.project_name) validateErrors.push('config: missing project_name');
7613
+ if (!config.current_phase && !config.phase) validateErrors.push('config: missing current_phase or phase');
7614
+ }
7615
+
7616
+ if (validateErrors.length) {
7617
+ validateErrors.forEach(e => console.error('❌ ' + e));
7618
+ process.exit(1);
7619
+ } else {
7620
+ console.log('✅ All artifacts valid');
7621
+ }
7622
+ return;
7623
+ }
7102
7624
  case 'roadmap': {
7103
7625
  const roadmap = require(path.join(__dirname, 'lib', 'roadmap.cjs'));
7104
7626
  const r = roadmap.dispatch(PROJECT_ROOT, args);
@@ -7117,7 +7639,19 @@ async function main() {
7117
7639
  }
7118
7640
  case 'config-set': {
7119
7641
  const cfg = require(path.join(__dirname, 'lib', 'config.cjs'));
7120
- result = cfg.cmdSet(PROJECT_ROOT, args[0], args.slice(1).join(' '));
7642
+ const csKey = args[0];
7643
+ const csVal = args.slice(1).join(' ');
7644
+ result = cfg.cmdSet(PROJECT_ROOT, csKey, csVal);
7645
+ // Fix #855 — keep state.json in sync when current_phase is updated via config-set.
7646
+ // One-way: config-set → state.json. The set-phase path writes config.yaml separately (below).
7647
+ if (csKey === 'current_phase' && csVal) {
7648
+ const stJson = readState() || defaultState();
7649
+ if (stJson.current_phase !== csVal) {
7650
+ stJson.current_phase = csVal;
7651
+ stJson.current_plan = 0;
7652
+ writeState(stJson);
7653
+ }
7654
+ }
7121
7655
  break;
7122
7656
  }
7123
7657
  case 'config-check-yolo': {
@@ -7165,6 +7699,79 @@ async function main() {
7165
7699
  result = { active: true, mode, scope: scopeRaw || 'global', expires_at: expiresAt, reason: 'yolo active' };
7166
7700
  break;
7167
7701
  }
7702
+ case 'yolo': {
7703
+ // Closes #739 — scoped yolo mode with TTL and milestone/phase scope.
7704
+ // Usage:
7705
+ // yolo scoped --ttl 2h --scope milestone → enable for current milestone
7706
+ // yolo scoped --ttl 30m --scope phase → enable for current phase
7707
+ // yolo off → disable immediately
7708
+ // yolo status → show current yolo state
7709
+ const sub = args[0];
7710
+ const cfg = require(path.join(__dirname, 'lib', 'config.cjs'));
7711
+ switch (sub) {
7712
+ case 'scoped': {
7713
+ const ttlArg = (args.find(a => a.startsWith('--ttl='))?.slice(6))
7714
+ || (args.indexOf('--ttl') !== -1 ? args[args.indexOf('--ttl') + 1] : null)
7715
+ || '1h';
7716
+ const scope = (args.find(a => a.startsWith('--scope='))?.slice(8))
7717
+ || (args.indexOf('--scope') !== -1 ? args[args.indexOf('--scope') + 1] : null)
7718
+ || 'phase';
7719
+ const ms = parseDuration(ttlArg);
7720
+ const expires = Date.now() + ms;
7721
+ const st = readState() || {};
7722
+ st.yolo = {
7723
+ enabled: true,
7724
+ expires,
7725
+ scope,
7726
+ milestone: st.current_milestone || null,
7727
+ phase: st.current_phase || null,
7728
+ };
7729
+ writeState(st);
7730
+ // Also persist to config.yaml so config-check-yolo can read it.
7731
+ cfg.cmdSet(PROJECT_ROOT, 'mode', 'yolo');
7732
+ cfg.cmdSet(PROJECT_ROOT, 'yolo_scope', scope === 'milestone'
7733
+ ? `milestone:${st.current_milestone || 'unknown'}`
7734
+ : `phase:${st.current_phase || 'unknown'}`);
7735
+ cfg.cmdSet(PROJECT_ROOT, 'yolo_ttl', new Date(expires).toISOString());
7736
+ result = { ok: true, enabled: true, scope, ttl: ttlArg, expires: new Date(expires).toISOString() };
7737
+ console.log(`Yolo mode: enabled (${scope} scope, expires in ${ttlArg})`);
7738
+ break;
7739
+ }
7740
+ case 'off': {
7741
+ const st = readState() || {};
7742
+ st.yolo = { enabled: false };
7743
+ writeState(st);
7744
+ cfg.cmdSet(PROJECT_ROOT, 'mode', 'guided');
7745
+ cfg.cmdSet(PROJECT_ROOT, 'yolo_scope', '');
7746
+ cfg.cmdSet(PROJECT_ROOT, 'yolo_ttl', '');
7747
+ result = { ok: true, enabled: false };
7748
+ console.log('Yolo mode: off');
7749
+ break;
7750
+ }
7751
+ case 'status': {
7752
+ const st = readState() || {};
7753
+ const y = st.yolo;
7754
+ if (!y?.enabled || Date.now() > (y.expires || 0)) {
7755
+ result = { active: false };
7756
+ console.log('Yolo mode: off');
7757
+ } else {
7758
+ const remainingMs = y.expires - Date.now();
7759
+ const remainingMin = Math.round(remainingMs / 60000);
7760
+ result = { active: true, scope: y.scope, expires: new Date(y.expires).toISOString(), remaining_min: remainingMin };
7761
+ console.log(`Yolo mode: on (${y.scope} scope, ${remainingMin}m remaining)`);
7762
+ }
7763
+ break;
7764
+ }
7765
+ default: {
7766
+ console.error('Usage: yolo <scoped|off|status>');
7767
+ console.error(' yolo scoped --ttl <2h|30m|1d> --scope <milestone|phase>');
7768
+ console.error(' yolo off');
7769
+ console.error(' yolo status');
7770
+ process.exit(1);
7771
+ }
7772
+ }
7773
+ break;
7774
+ }
7168
7775
  case 'verify': {
7169
7776
  const verify = require(path.join(__dirname, 'lib', 'verify.cjs'));
7170
7777
  result = verify.dispatch(PROJECT_ROOT, args);
@@ -7242,6 +7849,7 @@ async function main() {
7242
7849
  console.log(' phase add <name> [--decimal <parent>] → add phase (integer to current milestone, or --decimal slots under parent as parent.M)');
7243
7850
  console.log(' phase next-range [count] → return next N contiguous free phase numbers (#730)');
7244
7851
  console.log(' phase scaffold-milestone --names "n1|n2|..." → bulk-create phase folders for a milestone (#731)');
7852
+ console.log(' phase scaffold-all → create missing phase folders for all phases in ROADMAP.md (#731)');
7245
7853
  console.log(' workflow-config-audit → find workflows still referencing .planning/config.json (#733)');
7246
7854
  console.log(' commit "<msg>" [--files p1 p2 ...] → atomic git commit with conventional-commits validation (no AI attribution, no --no-verify, no auto-push)');
7247
7855
  console.log(' commit-to-subrepo --subrepo <p> "<msg>" → atomic commit inside a git subrepo (same validation as commit)');
@@ -7266,10 +7874,15 @@ async function main() {
7266
7874
  console.log(' notes <subcommand> [args] → manage project notes');
7267
7875
  console.log(' config <subcommand> [args] → read/write project config');
7268
7876
  console.log(' notify send --title "<t>" [--body "<b>"] [--event <e>] [--only slack|discord|teams] → post to configured webhooks');
7269
- console.log(' roadmap <get-phase|list-phases|update-plan-progress|clear> → .planning/ROADMAP.md operations');
7877
+ console.log(' roadmap <get-phase|list-phases|update-plan-progress|clear|detect> → .planning/ROADMAP.md operations');
7878
+ console.log(' roadmap detect → report ROADMAP convention in use (single vs per-milestone) (#734)');
7879
+ console.log(' validate <state|config|all> → schema checks for state.json and config.yaml (#747)');
7270
7880
  console.log(' config-get <dotted.key> → read scalar from .rcode/config.yaml');
7271
7881
  console.log(' config-set <dotted.key> <value> → atomically set a value in .rcode/config.yaml');
7272
7882
  console.log(' config-check-yolo [--phase N] [--workflow W] → check if yolo mode is active for scope (#739)');
7883
+ console.log(' yolo scoped --ttl <2h|30m|1d> --scope <milestone|phase> → enable scoped yolo with TTL (#739)');
7884
+ console.log(' yolo off → disable yolo mode immediately');
7885
+ console.log(' yolo status → show current yolo state and remaining TTL');
7273
7886
  console.log(' handoff write --from <skill> --to <skill> --phase N [--context "..."] → write cross-skill handoff token (#741)');
7274
7887
  console.log(' handoff read [--from <skill>] → read most recent handoff for this skill (#741)');
7275
7888
  console.log(' handoff clear → consume (clear) the latest handoff token (#741)');
@@ -7296,6 +7909,7 @@ async function main() {
7296
7909
  console.log(' state add-blocker "<description>" → append to blockers[]');
7297
7910
  console.log(' state resolve-blocker <index>|--all|--phase <N> --issue <N>|--commit <sha>|--noref → mark blocker(s) resolved (#654, #656)');
7298
7911
  console.log(' state record-session → update last_session timestamp');
7912
+ console.log(' state sync-from-git → recover phase/sprint state from git commit history (#915)');
7299
7913
  console.log(' state record-council --slug <s> --panel <csv> --artifact <path>');
7300
7914
  console.log(' state record-chain --slug <s> --agents <csv> --artifacts <path>');
7301
7915
  console.log(' state insert-phase --number <N.M> --name <slug>');