@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.
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/CONTRIBUTING.md +4 -0
- package/cli/github-sync.js +17 -201
- package/cli/lib/config.cjs +1 -1
- package/cli/lib/github-sync-discover.cjs +304 -0
- package/cli/lib/namespace-migrate.cjs +16 -1
- package/cli/uninstall.js +7 -2
- package/dist/rcode.js +194 -194
- package/package.json +1 -1
- package/rcode/agents/rcode-haitham.md +1 -1
- package/rcode/agents/rcode-hanzla.md +1 -1
- package/rcode/agents/rcode-hussain-pm.md +1 -1
- package/rcode/agents/rcode-nyquist-auditor.md +1 -1
- package/rcode/agents/rcode-omar.md +1 -1
- package/rcode/agents/rcode-remediation-planner.md +1 -1
- package/rcode/agents/rcode-waleed.md +1 -1
- package/rcode/agents/rcode-yousef.md +1 -1
- package/rcode/agents/rules/planner/task-templates.md +24 -22
- package/rcode/agents/rules/verifier/verification-report.md +2 -2
- package/rcode/bin/lib/roadmap.cjs +80 -4
- package/rcode/bin/rcode-tools.cjs +18 -1
- package/rcode/brain/best-practices/state-sync-rule.md +1 -1
- package/rcode/references/council-protocol.md +9 -4
- package/rcode/references/execute-auto-copy-learnings.md +15 -0
- package/rcode/references/execute-close-parent-artifacts.md +49 -0
- package/rcode/references/execute-interactive-mode.md +44 -0
- package/rcode/references/execute-notify-webhooks.md +22 -0
- package/rcode/references/execution-protocol.md +11 -11
- package/rcode/references/git-preflight.md +2 -2
- package/rcode/references/plan-gaps-mode.md +89 -0
- package/rcode/references/plan-thinking-partner.md +20 -0
- package/rcode/references/plan-windows-troubleshooting.md +25 -0
- package/rcode/references/planner-playbook.md +23 -29
- package/rcode/skills/_shared/state-sync-rule.md +1 -1
- package/rcode/skills/actions/1-analysis/rcode-document-project/SKILL.md +2 -0
- package/rcode/skills/actions/1-analysis/rcode-prfaq/SKILL.md +2 -0
- package/rcode/skills/actions/2-plan/rcode-create-epics-and-stories/SKILL.md +11 -0
- package/rcode/skills/actions/2-plan/rcode-create-prd/SKILL.md +2 -0
- package/rcode/skills/actions/2-plan/rcode-create-story/SKILL.md +11 -0
- package/rcode/skills/actions/2-plan/rcode-edit-prd/SKILL.md +2 -0
- package/rcode/skills/actions/2-plan/rcode-validate-prd/SKILL.md +2 -0
- package/rcode/skills/actions/3-solutioning/rcode-check-implementation-readiness/SKILL.md +2 -0
- package/rcode/skills/actions/3-solutioning/rcode-create-architecture/SKILL.md +2 -0
- package/rcode/skills/actions/4-implementation/rcode-checkpoint-preview/SKILL.md +2 -0
- package/rcode/skills/actions/4-implementation/rcode-code-review/SKILL.md +2 -0
- package/rcode/skills/actions/4-implementation/rcode-correct-course/SKILL.md +2 -0
- package/rcode/skills/actions/4-implementation/rcode-debug/SKILL.md +1 -1
- package/rcode/skills/actions/4-implementation/rcode-dev-story/SKILL.md +2 -0
- package/rcode/skills/actions/4-implementation/rcode-retrospective/SKILL.md +2 -0
- package/rcode/skills/actions/4-implementation/rcode-scaffold-project/SKILL.md +2 -0
- package/rcode/skills/actions/4-implementation/rcode-sprint-planning/SKILL.md +3 -2
- package/rcode/skills/actions/4-implementation/rcode-sprint-status/SKILL.md +2 -0
- package/rcode/skills/agents/haitham-frontend/SKILL.md +6 -0
- package/rcode/skills/agents/hanzla-engineer/SKILL.md +6 -0
- package/rcode/skills/agents/hussain-pm/SKILL.md +1 -1
- package/rcode/skills/agents/hussain-sm/SKILL.md +3 -3
- package/rcode/skills/agents/majlis-council/references.md +17 -7
- package/rcode/skills/agents/raees-orchestrator/SKILL.md +13 -11
- package/rcode/skills/agents/yousef-backend/SKILL.md +6 -0
- package/rcode/templates/github/epic-template.md +1 -1
- package/rcode/templates/github/feature-template.md +1 -1
- package/rcode/templates/github/task-template.md +1 -1
- package/rcode/templates/sprint.md +14 -3
- package/rcode/templates/verification-report.md +7 -4
- package/rcode/workflows/code-review-fix.md +5 -5
- package/rcode/workflows/code-review.md +1 -1
- package/rcode/workflows/council.md +1 -0
- package/rcode/workflows/create-epics-and-stories.md +12 -6
- package/rcode/workflows/dev-story.md +12 -14
- package/rcode/workflows/do.md +12 -9
- package/rcode/workflows/execute-sprint.md +4 -0
- package/rcode/workflows/execute-waves.md +44 -1
- package/rcode/workflows/execute.md +20 -119
- package/rcode/workflows/lens-audit.md +7 -7
- package/rcode/workflows/plan-spawn-planner.md +0 -1
- package/rcode/workflows/plan.md +25 -136
- package/rcode/workflows/quick.md +1 -1
- package/rcode/workflows/sprint-planning.md +14 -37
- package/server/lib/html/client/components/shared.js +21 -1
- package/server/lib/html/client/views/SprintsView.js +3 -3
- 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
|
package/cli/github-sync.js
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* rcode github-sync --execute # actually create issues
|
|
12
12
|
*
|
|
13
13
|
* Granular targeting (push specific items):
|
|
14
|
-
* --phase=
|
|
15
|
-
* --sprint=
|
|
16
|
-
* --epic=
|
|
17
|
-
* --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
|
-
|
|
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 .
|
|
485
|
-
console.log(` Run
|
|
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:**
|
|
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:**
|
|
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
|
|
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:**
|
|
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
|
|
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:**
|
|
767
|
+
`- **Source:** \`${story.sourcePath}\``,
|
|
952
768
|
`- **Last synced:** ${new Date().toISOString()}`,
|
|
953
769
|
].join('\n');
|
|
954
770
|
|
package/cli/lib/config.cjs
CHANGED
|
@@ -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
|
+
};
|