@hanzlaa/rcode 4.7.2 → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +1 -1
  2. package/CLAUDE.md +1 -1
  3. package/CONTRIBUTING.md +4 -0
  4. package/cli/github-sync.js +17 -201
  5. package/cli/lib/config.cjs +1 -1
  6. package/cli/lib/github-sync-discover.cjs +304 -0
  7. package/cli/lib/namespace-migrate.cjs +16 -1
  8. package/cli/uninstall.js +7 -2
  9. package/dist/rcode.js +194 -194
  10. package/package.json +1 -1
  11. package/rcode/agents/rcode-haitham.md +1 -1
  12. package/rcode/agents/rcode-hanzla.md +1 -1
  13. package/rcode/agents/rcode-hussain-pm.md +1 -1
  14. package/rcode/agents/rcode-nyquist-auditor.md +1 -1
  15. package/rcode/agents/rcode-omar.md +1 -1
  16. package/rcode/agents/rcode-remediation-planner.md +1 -1
  17. package/rcode/agents/rcode-waleed.md +1 -1
  18. package/rcode/agents/rcode-yousef.md +1 -1
  19. package/rcode/agents/rules/planner/task-templates.md +24 -22
  20. package/rcode/agents/rules/verifier/verification-report.md +2 -2
  21. package/rcode/bin/lib/roadmap.cjs +80 -4
  22. package/rcode/bin/rcode-tools.cjs +18 -1
  23. package/rcode/brain/best-practices/state-sync-rule.md +1 -1
  24. package/rcode/references/council-protocol.md +9 -4
  25. package/rcode/references/execute-auto-copy-learnings.md +15 -0
  26. package/rcode/references/execute-close-parent-artifacts.md +49 -0
  27. package/rcode/references/execute-interactive-mode.md +44 -0
  28. package/rcode/references/execute-notify-webhooks.md +22 -0
  29. package/rcode/references/execution-protocol.md +11 -11
  30. package/rcode/references/git-preflight.md +2 -2
  31. package/rcode/references/plan-gaps-mode.md +89 -0
  32. package/rcode/references/plan-thinking-partner.md +20 -0
  33. package/rcode/references/plan-windows-troubleshooting.md +25 -0
  34. package/rcode/references/planner-playbook.md +23 -29
  35. package/rcode/skills/_shared/state-sync-rule.md +1 -1
  36. package/rcode/skills/actions/1-analysis/rcode-document-project/SKILL.md +2 -0
  37. package/rcode/skills/actions/1-analysis/rcode-prfaq/SKILL.md +2 -0
  38. package/rcode/skills/actions/2-plan/rcode-create-epics-and-stories/SKILL.md +11 -0
  39. package/rcode/skills/actions/2-plan/rcode-create-prd/SKILL.md +2 -0
  40. package/rcode/skills/actions/2-plan/rcode-create-story/SKILL.md +11 -0
  41. package/rcode/skills/actions/2-plan/rcode-edit-prd/SKILL.md +2 -0
  42. package/rcode/skills/actions/2-plan/rcode-validate-prd/SKILL.md +2 -0
  43. package/rcode/skills/actions/3-solutioning/rcode-check-implementation-readiness/SKILL.md +2 -0
  44. package/rcode/skills/actions/3-solutioning/rcode-create-architecture/SKILL.md +2 -0
  45. package/rcode/skills/actions/4-implementation/rcode-checkpoint-preview/SKILL.md +2 -0
  46. package/rcode/skills/actions/4-implementation/rcode-code-review/SKILL.md +2 -0
  47. package/rcode/skills/actions/4-implementation/rcode-correct-course/SKILL.md +2 -0
  48. package/rcode/skills/actions/4-implementation/rcode-debug/SKILL.md +1 -1
  49. package/rcode/skills/actions/4-implementation/rcode-dev-story/SKILL.md +2 -0
  50. package/rcode/skills/actions/4-implementation/rcode-retrospective/SKILL.md +2 -0
  51. package/rcode/skills/actions/4-implementation/rcode-scaffold-project/SKILL.md +2 -0
  52. package/rcode/skills/actions/4-implementation/rcode-sprint-planning/SKILL.md +3 -2
  53. package/rcode/skills/actions/4-implementation/rcode-sprint-status/SKILL.md +2 -0
  54. package/rcode/skills/agents/haitham-frontend/SKILL.md +6 -0
  55. package/rcode/skills/agents/hanzla-engineer/SKILL.md +6 -0
  56. package/rcode/skills/agents/hussain-pm/SKILL.md +1 -1
  57. package/rcode/skills/agents/hussain-sm/SKILL.md +3 -3
  58. package/rcode/skills/agents/majlis-council/references.md +17 -7
  59. package/rcode/skills/agents/raees-orchestrator/SKILL.md +13 -11
  60. package/rcode/skills/agents/yousef-backend/SKILL.md +6 -0
  61. package/rcode/templates/github/epic-template.md +1 -1
  62. package/rcode/templates/github/feature-template.md +1 -1
  63. package/rcode/templates/github/task-template.md +1 -1
  64. package/rcode/templates/sprint.md +14 -3
  65. package/rcode/templates/verification-report.md +7 -4
  66. package/rcode/workflows/code-review-fix.md +5 -5
  67. package/rcode/workflows/code-review.md +1 -1
  68. package/rcode/workflows/council.md +1 -0
  69. package/rcode/workflows/create-epics-and-stories.md +12 -6
  70. package/rcode/workflows/dev-story.md +12 -14
  71. package/rcode/workflows/do.md +12 -9
  72. package/rcode/workflows/execute-sprint.md +4 -0
  73. package/rcode/workflows/execute-waves.md +44 -1
  74. package/rcode/workflows/execute.md +20 -119
  75. package/rcode/workflows/lens-audit.md +7 -7
  76. package/rcode/workflows/plan-spawn-planner.md +0 -1
  77. package/rcode/workflows/plan.md +25 -136
  78. package/rcode/workflows/quick.md +1 -1
  79. package/rcode/workflows/sprint-planning.md +14 -37
  80. package/server/lib/html/client/components/shared.js +21 -1
  81. package/server/lib/html/client/views/SprintsView.js +3 -3
  82. package/server/lib/scanner.js +7 -4
package/AGENTS.md CHANGED
@@ -24,7 +24,7 @@ If a user says "just keep going" or "don't stop until done", that authorization
24
24
 
25
25
  - Follow [Conventional Commits](https://www.conventionalcommits.org/) format: `type(scope): subject`
26
26
  - Types allowed: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `revert`
27
- - Scopes allowed: `agents`, `skills`, `workflows`, `templates`, `dashboard`, `docs`, `config`, `github`, `commands`, `memory`, `brand`, `cli`, `ci`, `release`, `meta`, `tasks`, `migrations`, `refs`, `state`, `hooks`, `install`, `parity`, `triggers`, `dogfood`, `namespace`, `planning`, `insights`, `help`, `roadmap`, `session`, `audits`, `execute`, `executor`, `plan`, `planner`, `readme`, `sync`, `sprint`, `agent-exp`, `extensibility`, `lens-audit`, `tiers`, `build`, `council`, `doctor`, `postinstall`, `progress`, `security`, `tools`, `uninstall`, `update`, `test`, `changelog`, `scopes`, `phases`, `references`, `kanban`, `orchestrator`, `orchpanel`, `status`, `bin`, `brain`, `dogfeed`, `new-project`, `package`, `rcode-tools`, `rihal-tools`, `team`, `usp`, `v4`, `observability`, `audit`, `agent-rules`, `cursor`, `i18n`, `phase`, `scaffold`, `campaign`, `ship`, `getting-started`, `do-router`, `milestone-health`, `modules`, `project-types`, `roadmapper`, `token`, plus numeric phase/sprint scopes (e.g. `docs(15)`, `feat(8.3)`)
27
+ - Scopes allowed: `agents`, `skills`, `workflows`, `templates`, `dashboard`, `docs`, `config`, `github`, `github-sync`, `commands`, `memory`, `brand`, `cli`, `ci`, `release`, `meta`, `tasks`, `migrations`, `refs`, `state`, `hooks`, `init`, `install`, `parity`, `triggers`, `dogfood`, `namespace`, `planning`, `insights`, `help`, `roadmap`, `session`, `audits`, `execute`, `executor`, `plan`, `planner`, `readme`, `rcode`, `review`, `sync`, `sprint`, `agent-exp`, `extensibility`, `lens-audit`, `tiers`, `build`, `council`, `doctor`, `postinstall`, `progress`, `security`, `tools`, `uninstall`, `update`, `test`, `changelog`, `scopes`, `phases`, `references`, `kanban`, `orchestrator`, `orchpanel`, `status`, `bin`, `brain`, `dogfeed`, `new-project`, `package`, `rcode-tools`, `rihal-tools`, `team`, `usp`, `v4`, `observability`, `audit`, `agent-rules`, `cursor`, `i18n`, `phase`, `scaffold`, `campaign`, `ship`, `getting-started`, `do-router`, `milestone-health`, `modules`, `project-types`, `roadmapper`, `token`, plus numeric phase/sprint scopes (e.g. `docs(15)`, `feat(8.3)`)
28
28
  - Subject: lowercase first letter, imperative mood, no trailing period, under 72 chars
29
29
  - **NEVER add Claude/AI attribution to commit messages.** No "Generated with Claude Code", no "Co-Authored-By: Claude", no "🤖 Generated". The user does not want this.
30
30
  - **NEVER use `--no-verify`** to bypass hooks. If hooks fail, fix the underlying issue.
package/CLAUDE.md CHANGED
@@ -24,7 +24,7 @@ If a user says "just keep going" or "don't stop until done", that authorization
24
24
 
25
25
  - Follow [Conventional Commits](https://www.conventionalcommits.org/) format: `type(scope): subject`
26
26
  - Types allowed: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `revert`
27
- - Scopes allowed: `agents`, `skills`, `workflows`, `templates`, `dashboard`, `docs`, `config`, `github`, `commands`, `memory`, `brand`, `cli`, `ci`, `release`, `meta`, `tasks`, `migrations`, `refs`, `state`, `hooks`, `install`, `parity`, `triggers`, `dogfood`, `namespace`, `planning`, `insights`, `help`, `roadmap`, `session`, `audits`, `execute`, `executor`, `plan`, `planner`, `readme`, `sync`, `sprint`, `agent-exp`, `extensibility`, `lens-audit`, `tiers`, `build`, `council`, `doctor`, `postinstall`, `progress`, `security`, `tools`, `uninstall`, `update`, `test`, `changelog`, `scopes`, `phases`, `references`, `kanban`, `orchestrator`, `orchpanel`, `status`, `bin`, `brain`, `dogfeed`, `new-project`, `package`, `rcode-tools`, `rihal-tools`, `team`, `usp`, `v4`, `observability`, `audit`, plus numeric phase/sprint scopes (e.g. `docs(15)`, `feat(8.3)`)
27
+ - Scopes allowed: `agents`, `skills`, `workflows`, `templates`, `dashboard`, `docs`, `config`, `github`, `github-sync`, `commands`, `memory`, `brand`, `cli`, `ci`, `release`, `meta`, `tasks`, `migrations`, `refs`, `state`, `hooks`, `install`, `parity`, `triggers`, `dogfood`, `namespace`, `planning`, `insights`, `help`, `roadmap`, `session`, `audits`, `execute`, `executor`, `plan`, `planner`, `readme`, `rcode`, `review`, `sync`, `sprint`, `agent-exp`, `extensibility`, `lens-audit`, `tiers`, `build`, `council`, `doctor`, `postinstall`, `progress`, `security`, `tools`, `uninstall`, `update`, `test`, `changelog`, `scopes`, `phases`, `references`, `kanban`, `orchestrator`, `orchpanel`, `status`, `bin`, `brain`, `dogfeed`, `new-project`, `package`, `rcode-tools`, `rihal-tools`, `team`, `usp`, `v4`, `observability`, `audit`, `init`, `agent-rules`, `cursor`, `i18n`, `phase`, `scaffold`, `campaign`, `ship`, `getting-started`, `do-router`, `milestone-health`, `modules`, `project-types`, `roadmapper`, `token`, plus numeric phase/sprint scopes (e.g. `docs(15)`, `feat(8.3)`)
28
28
  - Subject: lowercase first letter, imperative mood, no trailing period, under 72 chars
29
29
  - **NEVER add Claude/AI attribution to commit messages.** No "Generated with Claude Code", no "Co-Authored-By: Claude", no "🤖 Generated". The user does not want this.
30
30
  - **NEVER use `--no-verify`** to bypass hooks. If hooks fail, fix the underlying issue.
package/CONTRIBUTING.md CHANGED
@@ -282,10 +282,12 @@ We use [Conventional Commits](https://www.conventionalcommits.org/) format. The
282
282
  - `getting-started` — the `docs/getting-started.md` onboarding guide
283
283
  - `config` — team.yaml, config.yaml
284
284
  - `github` — CI/CD, issue templates, PR templates
285
+ - `github-sync` — `cli/github-sync.js` and its discovery/parsing logic
285
286
  - `cli` — `cli/*.js` and `rcode/bin/rcode-tools.cjs`
286
287
  - `state` — `.rcode/state.json` and state-manipulation code paths
287
288
  - `refs` — files inside `rcode/references/`
288
289
  - `hooks` — `.claude/hooks/*` and install-time hook wiring
290
+ - `init` — `/rcode-init` command and init-time project bootstrap
289
291
  - `install` — installer flow, manifest, side-effects
290
292
  - `memory` — Memory Bank distillates and templates
291
293
  - `brand` — branding/voice across surfaces
@@ -310,6 +312,8 @@ We use [Conventional Commits](https://www.conventionalcommits.org/) format. The
310
312
  - `plan` — plan workflow
311
313
  - `planner` — planner agent
312
314
  - `readme` — README.md
315
+ - `review` — code review reports and review-gate fixes (e.g. `NN-REVIEW.md` resolutions)
316
+ - `rcode` — cross-cutting rcode planning/methodology commits not tied to one narrower scope
313
317
  - `sync` — install/state sync flows
314
318
  - `sprint` — sprint-level workflow additions and fixes
315
319
  - `agent-exp` — agent experience improvements
@@ -11,10 +11,10 @@
11
11
  * rcode github-sync --execute # actually create issues
12
12
  *
13
13
  * Granular targeting (push specific items):
14
- * --phase=phase-02 push one phase (all its epics + stories)
15
- * --sprint=sprint-01 push stories belonging to one sprint
16
- * --epic=epic-1-auth push one epic and its child stories
17
- * --story=story-1-1-login push one story
14
+ * --phase=44 push one phase (all its epics + stories)
15
+ * --sprint=44.1 push stories belonging to one sprint
16
+ * --epic=EPIC-01 push one epic and its child stories
17
+ * --story=44.1.1 push one story (sprint-track task id or epic-track story id, e.g. 1.1)
18
18
  *
19
19
  * Options:
20
20
  * --repo=owner/name target a specific repo (otherwise auto-detect)
@@ -45,6 +45,7 @@ const gh = require('./lib/github.cjs');
45
45
  const { askText, PromptAbortError } = require('./lib/prompts.cjs');
46
46
  const { writeJsonAtomic } = require('./lib/fsutil.cjs');
47
47
  const { loadConfig } = require('./lib/config.cjs');
48
+ const { discoverPhases, applyGranularFilters } = require('./lib/github-sync-discover.cjs');
48
49
 
49
50
  /**
50
51
  * Hash a string — used to detect content changes between syncs.
@@ -140,51 +141,6 @@ function parseArgs(args) {
140
141
  return opts;
141
142
  }
142
143
 
143
- /**
144
- * Parse YAML-ish frontmatter from the top of a markdown file.
145
- * Used to extract explicit epic/sprint linking from story files.
146
- * Returns an object of key/value strings, or {} if no frontmatter block.
147
- */
148
- function extractFrontmatter(content) {
149
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
150
- if (!match) return {};
151
- const fm = {};
152
- for (const line of match[1].split(/\r?\n/)) {
153
- const m = line.match(/^([\w_-]+)\s*:\s*(.*)$/);
154
- if (m) fm[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
155
- }
156
- return fm;
157
- }
158
-
159
- /**
160
- * Parse .rcode/phases/{phase}/sprints.md if present and return a map of
161
- * sprint-id → story-ids (based on "- [ ] story-X-Y" list entries under
162
- * each sprint section). Used by --sprint=ID filtering.
163
- *
164
- * Sprint section convention:
165
- * ## Sprint 1 — {goal}
166
- * - [ ] story-1-1-login
167
- * - [ ] story-1-2-signup
168
- */
169
- function parseSprintsFile(sprintsContent) {
170
- if (!sprintsContent) return {};
171
- const sprintMap = {};
172
- let currentSprint = null;
173
- for (const line of sprintsContent.split(/\r?\n/)) {
174
- const header = line.match(/^##\s+Sprint\s+(\S+)/i);
175
- if (header) {
176
- currentSprint = `sprint-${header[1].replace(/[^\w-]/g, '')}`;
177
- sprintMap[currentSprint] = [];
178
- continue;
179
- }
180
- if (currentSprint) {
181
- const item = line.match(/^\s*-\s*\[[ xX]\]\s+(\S+)/);
182
- if (item) sprintMap[currentSprint].push(item[1]);
183
- }
184
- }
185
- return sprintMap;
186
- }
187
-
188
144
  // ---------- Discover .rcode/ content ----------
189
145
 
190
146
  function loadState(cwd) {
@@ -196,148 +152,6 @@ function loadState(cwd) {
196
152
  try { return JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch { return null; }
197
153
  }
198
154
 
199
- function discoverPhases(cwd) {
200
- const phasesDir = path.join(cwd, '.rcode/phases');
201
- if (!fs.existsSync(phasesDir)) return [];
202
-
203
- const phases = [];
204
- for (const entry of fs.readdirSync(phasesDir, { withFileTypes: true })) {
205
- if (!entry.isDirectory()) continue;
206
- const phaseDir = path.join(phasesDir, entry.name);
207
- const briefPath = path.join(phaseDir, 'brief.md');
208
- const sprintsPath = path.join(phaseDir, 'sprints.md');
209
- const storiesDir = path.join(phaseDir, 'stories');
210
- const tasksDir = path.join(phaseDir, 'tasks');
211
-
212
- const sprintsContent = fs.existsSync(sprintsPath)
213
- ? fs.readFileSync(sprintsPath, 'utf8')
214
- : null;
215
-
216
- const phase = {
217
- id: entry.name,
218
- brief: fs.existsSync(briefPath) ? fs.readFileSync(briefPath, 'utf8') : null,
219
- sprints: sprintsContent,
220
- sprintMap: parseSprintsFile(sprintsContent),
221
- stories: [],
222
- epics: [],
223
- };
224
-
225
- if (fs.existsSync(storiesDir)) {
226
- for (const file of fs.readdirSync(storiesDir)) {
227
- if (!file.endsWith('.md')) continue;
228
- const content = fs.readFileSync(path.join(storiesDir, file), 'utf8');
229
- const fm = extractFrontmatter(content);
230
- const id = file.replace('.md', '');
231
- // Figure out which epic this story belongs to: prefer explicit
232
- // frontmatter, fall back to "story-X-..." → "epic-X-..." naming
233
- // convention so existing projects without frontmatter still link.
234
- let parentEpic = fm.epic || null;
235
- if (!parentEpic) {
236
- const m = id.match(/^story-(\d+)/);
237
- if (m) parentEpic = `epic-${m[1]}`;
238
- }
239
- // Figure out which sprint this story belongs to: explicit
240
- // frontmatter wins, else look it up in the sprint map.
241
- let sprintId = fm.sprint || null;
242
- if (!sprintId) {
243
- for (const [sid, storyList] of Object.entries(phase.sprintMap)) {
244
- if (storyList.some((s) => s === id || id.startsWith(s))) {
245
- sprintId = sid;
246
- break;
247
- }
248
- }
249
- }
250
- phase.stories.push({
251
- id,
252
- file,
253
- content,
254
- title: extractTitle(content) || id,
255
- parentEpic,
256
- sprintId,
257
- frontmatter: fm,
258
- });
259
- }
260
- }
261
-
262
- if (fs.existsSync(tasksDir)) {
263
- for (const file of fs.readdirSync(tasksDir)) {
264
- if (!file.endsWith('.md')) continue;
265
- const content = fs.readFileSync(path.join(tasksDir, file), 'utf8');
266
- const fm = extractFrontmatter(content);
267
- phase.epics.push({
268
- id: file.replace('.md', ''),
269
- file,
270
- content,
271
- title: extractTitle(content) || file.replace('.md', ''),
272
- frontmatter: fm,
273
- });
274
- }
275
- }
276
-
277
- phases.push(phase);
278
- }
279
-
280
- return phases;
281
- }
282
-
283
- /**
284
- * Apply granular --sprint/--epic/--story filters to the discovered phases.
285
- * Mutates a shallow copy — original discovery result is not touched.
286
- * Returns a new phases array where only the requested items remain.
287
- */
288
- function applyGranularFilters(phases, opts) {
289
- // No filters → return as-is
290
- if (!opts.sprint && !opts.epic && !opts.story) return phases;
291
-
292
- const filtered = phases.map((p) => ({
293
- ...p,
294
- epics: [...p.epics],
295
- stories: [...p.stories],
296
- }));
297
-
298
- if (opts.sprint) {
299
- for (const p of filtered) {
300
- // Keep only stories whose sprintId matches
301
- p.stories = p.stories.filter((s) => s.sprintId === opts.sprint);
302
- // Keep only epics that have at least one remaining story or whose id
303
- // the user might also be interested in (conservative: keep all epics
304
- // in this phase so child stories have a visible parent reference)
305
- // Actually: if filtering by sprint, user wants the sprint's work —
306
- // stories only. Drop epics unless they're referenced by a remaining
307
- // story.
308
- const liveEpicIds = new Set(p.stories.map((s) => s.parentEpic).filter(Boolean));
309
- p.epics = p.epics.filter((e) => liveEpicIds.has(e.id));
310
- }
311
- }
312
-
313
- if (opts.epic) {
314
- for (const p of filtered) {
315
- p.epics = p.epics.filter((e) => e.id === opts.epic);
316
- // Keep stories whose parentEpic matches
317
- p.stories = p.stories.filter((s) => s.parentEpic === opts.epic);
318
- }
319
- }
320
-
321
- if (opts.story) {
322
- for (const p of filtered) {
323
- p.stories = p.stories.filter((s) => s.id === opts.story);
324
- // Keep any epic a surviving story points at (so link target exists)
325
- const parents = new Set(p.stories.map((s) => s.parentEpic).filter(Boolean));
326
- p.epics = p.epics.filter((e) => parents.has(e.id));
327
- }
328
- }
329
-
330
- // Drop phases that ended up completely empty after filtering
331
- return filtered.filter(
332
- (p) => p.epics.length > 0 || p.stories.length > 0,
333
- );
334
- }
335
-
336
- function extractTitle(markdown) {
337
- const match = markdown.match(/^#\s+(.+)$/m);
338
- return match ? match[1].trim() : null;
339
- }
340
-
341
155
  // ---------- Load/save sync map (for idempotency) ----------
342
156
 
343
157
  function loadSyncMap(cwd) {
@@ -460,7 +274,9 @@ async function main(args) {
460
274
  // ------ Discover phases ------
461
275
  let phases = discoverPhases(cwd);
462
276
  if (opts.phase) {
463
- phases = phases.filter((p) => p.id === opts.phase);
277
+ // Match either the full directory id or the bare numeric prefix so
278
+ // --phase=44 and --phase=44-github-sync-... both work.
279
+ phases = phases.filter((p) => p.id === opts.phase || p.numericId === opts.phase);
464
280
  if (phases.length === 0) {
465
281
  console.error(`❌ Phase '${opts.phase}' not found.`);
466
282
  process.exit(1);
@@ -481,8 +297,8 @@ async function main(args) {
481
297
  console.error(` Check the filter value or run without filters to see available ids.`);
482
298
  process.exit(1);
483
299
  }
484
- console.log(`ℹ No phases found in .rcode/phases/ — nothing to sync.`);
485
- console.log(` Run 'rcode init' or create a phase to get started.`);
300
+ console.log(`ℹ No phases found in .planning/phases/ or epics in .planning/epics/ — nothing to sync.`);
301
+ console.log(` Run /rcode-plan or /rcode-create-epics-and-stories to get started.`);
486
302
  process.exit(0);
487
303
  }
488
304
 
@@ -551,7 +367,7 @@ async function main(args) {
551
367
  { name: 'QA', color: 'd73a4a', description: 'Quality assurance' },
552
368
  { name: 'Docs', color: 'c5def5', description: 'Documentation' },
553
369
  ] : [],
554
- milestones: phases.filter((p) => !syncMap.phases[p.id]),
370
+ milestones: phases.filter((p) => !p.noMilestone && !syncMap.phases[p.id]),
555
371
  epics: phases.flatMap((p) =>
556
372
  p.epics.filter((e) => !syncMap.epics[e.id]).map((e) => ({ ...e, phase: p.id })),
557
373
  ),
@@ -702,7 +518,7 @@ async function main(args) {
702
518
  `## 📊 Meta`,
703
519
  ``,
704
520
  `- **Phase:** \`${epic.phase}\``,
705
- `- **Source:** \`.rcode/phases/${epic.phase}/tasks/${epic.file}\``,
521
+ `- **Source:** \`${epic.sourcePath}\``,
706
522
  `- **Synced by:** rcode github-sync`,
707
523
  ``,
708
524
  `## 📝 Child Stories`,
@@ -785,7 +601,7 @@ async function main(args) {
785
601
  parentRefLine,
786
602
  sprintRefLine,
787
603
  `- **Phase:** \`${story.phase}\``,
788
- `- **Source:** \`.rcode/phases/${story.phase}/stories/${story.file}\``,
604
+ `- **Source:** \`${story.sourcePath}\``,
789
605
  `- **Synced by:** rcode github-sync`,
790
606
  ``,
791
607
  `> **Linking:** Reference this story in commits with \`refs #${'{issue}'}\``,
@@ -892,7 +708,7 @@ async function main(args) {
892
708
  const body = [
893
709
  `## 🎯 Epic Vision`,
894
710
  ``,
895
- `_Strategic goal this Epic contributes to. Fill in from \`.rcode/phases/${epic.phase}/tasks/${epic.file}\`._`,
711
+ `_Strategic goal this Epic contributes to. Fill in from \`${epic.sourcePath}\`._`,
896
712
  ``,
897
713
  `## 📋 Source Content`,
898
714
  ``,
@@ -903,7 +719,7 @@ async function main(args) {
903
719
  `## 📊 Meta`,
904
720
  ``,
905
721
  `- **Phase:** \`${epic.phase}\``,
906
- `- **Source:** \`.rcode/phases/${epic.phase}/tasks/${epic.file}\``,
722
+ `- **Source:** \`${epic.sourcePath}\``,
907
723
  `- **Last synced:** ${new Date().toISOString()}`,
908
724
  ].join('\n');
909
725
 
@@ -937,7 +753,7 @@ async function main(args) {
937
753
  const body = [
938
754
  `## 🎯 Problem Statement`,
939
755
  ``,
940
- `_From \`.rcode/phases/${story.phase}/stories/${story.file}\`._`,
756
+ `_From \`${story.sourcePath}\`._`,
941
757
  ``,
942
758
  `## 📋 Source Content`,
943
759
  ``,
@@ -948,7 +764,7 @@ async function main(args) {
948
764
  `## 📊 Meta`,
949
765
  ``,
950
766
  `- **Phase:** \`${story.phase}\``,
951
- `- **Source:** \`.rcode/phases/${story.phase}/stories/${story.file}\``,
767
+ `- **Source:** \`${story.sourcePath}\``,
952
768
  `- **Last synced:** ${new Date().toISOString()}`,
953
769
  ].join('\n');
954
770
 
@@ -49,7 +49,7 @@ const HARDCODED_DEFAULTS = {
49
49
 
50
50
  // Paths
51
51
  output_folder: '.rcode',
52
- planning_artifacts: '.rcode/phases',
52
+ planning_artifacts: '.planning/phases',
53
53
  project_knowledge: '.rcode/context',
54
54
 
55
55
  // Model profile — matches model-profiles.json names
@@ -0,0 +1,304 @@
1
+ /**
2
+ * cli/lib/github-sync-discover.cjs — phase/epic/story discovery for github-sync
3
+ *
4
+ * Owns discovery for BOTH artifact tracks this project produces:
5
+ * - sprint-track: .planning/phases/{slug}/{phase}-{plan}-SPRINT.md
6
+ * (`<task id="" title="">` XML blocks, current planner output)
7
+ * - epic-track: .planning/epics/EPIC-{NN}.md + .planning/epics/stories/{N}.{M}.md
8
+ * (PRD → epics → stories chain, phase-agnostic per docs/adr/0001)
9
+ *
10
+ * Required by both cli/github-sync.js (the CLI entry point) and
11
+ * test/github-sync.test.cjs (so discovery logic is exercised directly instead
12
+ * of being duplicated inline in tests).
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ /**
19
+ * Parse YAML-ish frontmatter from the top of a markdown file.
20
+ * Used to extract explicit epic/sprint linking from story files.
21
+ * Returns an object of key/value strings, or {} if no frontmatter block.
22
+ */
23
+ function extractFrontmatter(content) {
24
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
25
+ if (!match) return {};
26
+ const fm = {};
27
+ for (const line of match[1].split(/\r?\n/)) {
28
+ const m = line.match(/^([\w_-]+)\s*:\s*(.*)$/);
29
+ if (m) fm[m[1].trim()] = m[2].trim().replace(/^["']|["']$/g, '');
30
+ }
31
+ return fm;
32
+ }
33
+
34
+ function extractTitle(markdown) {
35
+ const match = markdown.match(/^#\s+(.+)$/m);
36
+ return match ? match[1].trim() : null;
37
+ }
38
+
39
+ /**
40
+ * Parse `<task id="" title="">` blocks out of a SPRINT.md's text, mirroring
41
+ * server/lib/scanner.js's buildPhaseTree parser (the proven-working reference
42
+ * implementation for the current planner output format). Falls back to the
43
+ * legacy `### Story|Task N — title` heading format when no `<task>` blocks
44
+ * are present (pre-<task> SPRINT.md era).
45
+ *
46
+ * Each returned item becomes a story-equivalent GitHub issue — the
47
+ * sprint-track has no separate epic level; sprint grouping is surfaced via
48
+ * the `sprintId` field in the issue body's "Sprint:" line.
49
+ */
50
+ function parseSprintTasks(text, sprintId, file, sourcePathPrefix) {
51
+ const tasks = [];
52
+ const taskRe = /<task\b([^>]*)>([\s\S]*?)<\/task>/g;
53
+ let tm;
54
+ let n = 0;
55
+ while ((tm = taskRe.exec(text))) {
56
+ n += 1;
57
+ const idM = tm[1].match(/id="([^"]+)"/);
58
+ const titleAttrM = tm[1].match(/title="([^"]*)"/);
59
+ const titleTagM = tm[2].match(/<title>([\s\S]*?)<\/title>/);
60
+ tasks.push({
61
+ id: idM ? idM[1] : `${sprintId}-task-${n}`,
62
+ file,
63
+ content: tm[0],
64
+ title: (titleAttrM && titleAttrM[1].trim()) || (titleTagM && titleTagM[1].trim()) || `Task ${n}`,
65
+ parentEpic: null,
66
+ sprintId,
67
+ frontmatter: {},
68
+ sourcePath: `${sourcePathPrefix}/${file}`,
69
+ });
70
+ }
71
+
72
+ // Fallback for pre-<task> SPRINT.md format: "### Story N — title" / "### Task X — title".
73
+ if (tasks.length === 0) {
74
+ const headRe = /^#{2,4}\s+(?:Story|Task)\s+([^\s—–-]+)\s*[—–-]\s*(.+?)\s*$/gm;
75
+ let hm;
76
+ while ((hm = headRe.exec(text))) {
77
+ tasks.push({
78
+ id: hm[1].trim(),
79
+ file,
80
+ content: hm[0],
81
+ title: hm[2].trim(),
82
+ parentEpic: null,
83
+ sprintId,
84
+ frontmatter: {},
85
+ sourcePath: `${sourcePathPrefix}/${file}`,
86
+ });
87
+ }
88
+ }
89
+
90
+ return tasks;
91
+ }
92
+
93
+ /**
94
+ * Discover sprint-track phases from .planning/phases/*-SPRINT.md files.
95
+ * Returns every discovered phase directory, even ones with zero SPRINT.md
96
+ * files (parity with the dead legacy directory-tree discovery behavior).
97
+ */
98
+ function discoverSprintTrackPhases(cwd) {
99
+ const phasesDir = path.join(cwd, '.planning', 'phases');
100
+ if (!fs.existsSync(phasesDir)) return [];
101
+
102
+ const phases = [];
103
+ for (const entry of fs.readdirSync(phasesDir, { withFileTypes: true })) {
104
+ if (!entry.isDirectory()) continue;
105
+
106
+ const numericIdM = entry.name.match(/^(\d+)-/);
107
+ const numericId = numericIdM ? numericIdM[1] : null;
108
+ const phaseDir = path.join(phasesDir, entry.name);
109
+ const sourcePathPrefix = `.planning/phases/${entry.name}`;
110
+
111
+ const sprintFiles = fs
112
+ .readdirSync(phaseDir)
113
+ .filter((f) => /-SPRINT\.md$/i.test(f))
114
+ .sort((a, b) => {
115
+ const na = parseInt((a.match(/-(\d+)-SPRINT\.md$/i) || [])[1] || '0', 10);
116
+ const nb = parseInt((b.match(/-(\d+)-SPRINT\.md$/i) || [])[1] || '0', 10);
117
+ return na - nb;
118
+ });
119
+
120
+ const stories = [];
121
+ for (const file of sprintFiles) {
122
+ const text = fs.readFileSync(path.join(phaseDir, file), 'utf8');
123
+ const fileM = file.match(/^(\d+)-(\d+)-SPRINT\.md$/i);
124
+ let sprintId;
125
+ if (fileM) {
126
+ sprintId = `${parseInt(fileM[1], 10)}.${parseInt(fileM[2], 10)}`;
127
+ } else {
128
+ const fm = extractFrontmatter(text);
129
+ sprintId = fm.sprint || file.replace(/-SPRINT\.md$/i, '');
130
+ }
131
+ stories.push(...parseSprintTasks(text, sprintId, file, sourcePathPrefix));
132
+ }
133
+
134
+ phases.push({
135
+ id: entry.name,
136
+ numericId,
137
+ brief: null,
138
+ sprints: null,
139
+ sprintMap: {},
140
+ stories,
141
+ epics: [],
142
+ noMilestone: false,
143
+ });
144
+ }
145
+
146
+ return phases;
147
+ }
148
+
149
+ /**
150
+ * Discover the epic-track from .planning/epics/EPIC-{NN}.md +
151
+ * .planning/epics/stories/{N}.{M}.md, per the layout in
152
+ * rcode/workflows/create-epics-and-stories.md. Story files use bold
153
+ * Markdown fields (`**Epic:** EPIC-{N} — {title}`), NOT YAML frontmatter —
154
+ * extractFrontmatter correctly returns {} for these and is not used here.
155
+ *
156
+ * Epic file names are zero-padded (EPIC-01.md) but a story's `**Epic:**`
157
+ * field is unpadded (EPIC-1) — match by NUMERIC value so padding
158
+ * differences never break the parent link.
159
+ *
160
+ * Returns null when .planning/epics/ doesn't exist, or when it exists but
161
+ * has no epics and no stories (nothing to sync — avoid a spurious empty
162
+ * synthetic phase).
163
+ */
164
+ function discoverEpicTrackPhase(cwd) {
165
+ const epicsDir = path.join(cwd, '.planning', 'epics');
166
+ if (!fs.existsSync(epicsDir)) return null;
167
+
168
+ const epics = [];
169
+ const epicFiles = fs.readdirSync(epicsDir).filter((f) => /^EPIC-\d+\.md$/i.test(f));
170
+ for (const file of epicFiles) {
171
+ const content = fs.readFileSync(path.join(epicsDir, file), 'utf8');
172
+ const numM = file.match(/(\d+)/);
173
+ const epicNumber = numM ? parseInt(numM[1], 10) : null;
174
+ const id = file.replace(/\.md$/i, '');
175
+ epics.push({
176
+ id,
177
+ file,
178
+ epicNumber,
179
+ content,
180
+ title: extractTitle(content) || id,
181
+ frontmatter: {},
182
+ sourcePath: `.planning/epics/${file}`,
183
+ });
184
+ }
185
+
186
+ const stories = [];
187
+ const storiesDir = path.join(epicsDir, 'stories');
188
+ if (fs.existsSync(storiesDir)) {
189
+ const storyFiles = fs.readdirSync(storiesDir).filter((f) => /^[\d.]+\.md$/.test(f));
190
+ for (const file of storyFiles) {
191
+ const content = fs.readFileSync(path.join(storiesDir, file), 'utf8');
192
+ const id = file.replace(/\.md$/i, '');
193
+ const epicRefM = content.match(/\*\*Epic:\*\*\s*EPIC-(\d+)/i);
194
+ let parentEpic = null;
195
+ if (epicRefM) {
196
+ const refNum = parseInt(epicRefM[1], 10);
197
+ const matchedEpic = epics.find((e) => e.epicNumber === refNum);
198
+ if (matchedEpic) parentEpic = matchedEpic.id;
199
+ }
200
+ stories.push({
201
+ id,
202
+ file,
203
+ content,
204
+ title: extractTitle(content) || id,
205
+ parentEpic,
206
+ sprintId: null,
207
+ frontmatter: {},
208
+ sourcePath: `.planning/epics/stories/${file}`,
209
+ });
210
+ }
211
+ }
212
+
213
+ if (epics.length === 0 && stories.length === 0) return null;
214
+
215
+ return {
216
+ id: 'epics',
217
+ numericId: null,
218
+ brief: null,
219
+ sprints: null,
220
+ sprintMap: {},
221
+ stories,
222
+ epics,
223
+ // Signals this synthetic phase has no numbered-phase milestone to
224
+ // attach to on GitHub — epic-track is a deliberately phase-agnostic
225
+ // PRD→epics→stories chain per docs/adr/0001, distinct from the numbered
226
+ // .planning/phases/ milestones.
227
+ noMilestone: true,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Combine sprint-track and epic-track discovery into one phases array.
233
+ */
234
+ function discoverPhases(cwd) {
235
+ const phases = discoverSprintTrackPhases(cwd);
236
+ const epicPhase = discoverEpicTrackPhase(cwd);
237
+ if (epicPhase) phases.push(epicPhase);
238
+ return phases;
239
+ }
240
+
241
+ /**
242
+ * Apply granular --sprint/--epic/--story filters to the discovered phases.
243
+ * Mutates a shallow copy — original discovery result is not touched.
244
+ * Returns a new phases array where only the requested items remain.
245
+ */
246
+ function applyGranularFilters(phases, opts) {
247
+ // No filters → return as-is
248
+ if (!opts.sprint && !opts.epic && !opts.story) return phases;
249
+
250
+ const filtered = phases.map((p) => ({
251
+ ...p,
252
+ epics: [...p.epics],
253
+ stories: [...p.stories],
254
+ }));
255
+
256
+ if (opts.sprint) {
257
+ // Normalize both dash and dot sprint-id forms so --sprint=44-1 and
258
+ // --sprint=44.1 both match a stored sprintId of "44.1".
259
+ const sprintNorm = opts.sprint.replace(/-/g, '.');
260
+ for (const p of filtered) {
261
+ // Keep only stories whose sprintId matches
262
+ p.stories = p.stories.filter((s) => s.sprintId === opts.sprint || s.sprintId === sprintNorm);
263
+ // Keep only epics that have at least one remaining story or whose id
264
+ // the user might also be interested in (conservative: keep all epics
265
+ // in this phase so child stories have a visible parent reference)
266
+ // Actually: if filtering by sprint, user wants the sprint's work —
267
+ // stories only. Drop epics unless they're referenced by a remaining
268
+ // story.
269
+ const liveEpicIds = new Set(p.stories.map((s) => s.parentEpic).filter(Boolean));
270
+ p.epics = p.epics.filter((e) => liveEpicIds.has(e.id));
271
+ }
272
+ }
273
+
274
+ if (opts.epic) {
275
+ for (const p of filtered) {
276
+ p.epics = p.epics.filter((e) => e.id === opts.epic);
277
+ // Keep stories whose parentEpic matches
278
+ p.stories = p.stories.filter((s) => s.parentEpic === opts.epic);
279
+ }
280
+ }
281
+
282
+ if (opts.story) {
283
+ for (const p of filtered) {
284
+ p.stories = p.stories.filter((s) => s.id === opts.story);
285
+ // Keep any epic a surviving story points at (so link target exists)
286
+ const parents = new Set(p.stories.map((s) => s.parentEpic).filter(Boolean));
287
+ p.epics = p.epics.filter((e) => parents.has(e.id));
288
+ }
289
+ }
290
+
291
+ // Drop phases that ended up completely empty after filtering
292
+ return filtered.filter(
293
+ (p) => p.epics.length > 0 || p.stories.length > 0,
294
+ );
295
+ }
296
+
297
+ module.exports = {
298
+ extractFrontmatter,
299
+ extractTitle,
300
+ discoverSprintTrackPhases,
301
+ discoverEpicTrackPhase,
302
+ discoverPhases,
303
+ applyGranularFilters,
304
+ };