* feat(sdk): add queued_phases to init.manager (closes #2497) Surfaces the milestone immediately AFTER the active one so the /gsd-manager dashboard can preview upcoming phases without mixing them into the active phases grid. Changes: - roadmap.ts: exports two new helpers - extractPhasesFromSection(section): parses phase number / name / goal / depends_on using the same pattern initManager uses for the active milestone, so queued phases have identical shape. - extractNextMilestoneSection(content, projectDir): resolves the current milestone via the STATE-first path (matching upstream PR #2508) then scans for the next ## milestone heading. Shipped milestones are stripped first so they can't shadow the real next. Returns null when the active milestone is the last one. - init-complex.ts: initManager now exposes - queued_phases: Array<{ number, name, display_name, goal, depends_on, dep_phases, deps_display }> - queued_milestone_version: string | null - queued_milestone_name: string | null Existing phases array is unchanged — callers that only care about the active milestone see no behavior difference. Scope note: PR #2508 (merged upstream 2026-04-21) superseded the #2495 + #2496 portions of this branch's original submission. This commit is the rebased remainder contributing only #2497 on top of upstream's new helpers. Test coverage (7 new tests, all passing): - roadmap.test.ts: +5 tests - extractPhasesFromSection parses multiple phases with goal + deps - extractPhasesFromSection returns [] when no phase headings - extractNextMilestoneSection returns the milestone after the STATE-resolved active one - extractNextMilestoneSection returns null when active is last - extractNextMilestoneSection returns null when no version found - init-complex.test.ts: +4 tests under `queued_phases (#2497)` - surfaces next milestone with version + name metadata - queued entries carry name / deps_display / display_name - queued phases are NOT mixed into active phases list - returns [] + nulls when active is the last milestone All 51 tests in roadmap.test.ts + init-complex.test.ts pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(workflows): render queued_phases section in /gsd-manager dashboard Surfaces the new `queued_phases` / `queued_milestone_version` / `queued_milestone_name` fields from init.manager (SDK #2497) in a compact preview section directly below the main active-milestone table. Changes to workflows/manager.md: - Initialize step: parse the optional trio (queued_milestone_version, queued_milestone_name, queued_phases) alongside the existing init.manager fields. Treat missing as empty for backward compatibility with older SDK versions. - Dashboard step: new "Queued section (next milestone preview)" rendered between the main active-milestone grid and the Recommendations section. Renders only when queued_phases is non-empty; skipped entirely when absent or empty (e.g. active milestone is the last one). - Queued rows render without D/P/E columns since the phases haven't been discussed yet — just number, display_name, deps_display, and a fixed "· Queued" status. - Success criterion added: queued section renders when non-empty and is skipped when absent. Queued phases are deliberately NOT eligible for the Continue action menu; they live in a future milestone. The preview exists for situational awareness only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ INIT=$(gsd-sdk query init.manager)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
```
|
||||
|
||||
Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_count`, `in_progress_count`, `phases`, `recommended_actions`, `all_complete`, `waiting_signal`, `manager_flags`.
|
||||
Parse JSON for: `milestone_version`, `milestone_name`, `phase_count`, `completed_count`, `in_progress_count`, `phases`, `recommended_actions`, `all_complete`, `waiting_signal`, `manager_flags`, and the optional trio `queued_milestone_version`, `queued_milestone_name`, `queued_phases` (added in SDK fix `2495-2496-2497` — may be absent on older SDK versions, treat missing as empty).
|
||||
|
||||
`manager_flags` contains per-step passthrough flags from config:
|
||||
- `manager_flags.discuss` — appended to `/gsd-discuss-phase` args (e.g. `"--auto --analyze"`)
|
||||
@@ -103,6 +103,28 @@ Example output:
|
||||
| 6 | Polish & Final Mail… | 1-5 | · | · | · | · Up next |
|
||||
```
|
||||
|
||||
**Queued section (next milestone preview):**
|
||||
|
||||
If `queued_phases` is present and non-empty, render a compact preview of the next milestone's phases directly below the main table. This surfaces upcoming work without cluttering the active-milestone grid. Skip this section entirely when `queued_phases` is empty or missing (e.g. the active milestone is the last one in the roadmap).
|
||||
|
||||
Use `queued_milestone_version` and `queued_milestone_name` for the header. Phases render without D/P/E columns since they aren't discussed yet — just number, name (pre-truncated `display_name`), dependencies (`deps_display`), and a fixed `· Queued` status. Phase-name padding should match the active-table column width for visual alignment.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
───────────────────────────────────────────────────────────────
|
||||
◆ Queued — {queued_milestone_version} {queued_milestone_name} ({queued_phases.length} phases)
|
||||
───────────────────────────────────────────────────────────────
|
||||
| # | Phase | Deps | Status |
|
||||
|---|----------------------|------|--------------|
|
||||
| 31| Email Logs | — | · Queued |
|
||||
| 32| Today's Sheets | 31 | · Queued |
|
||||
| 33| Resend Backfill | 31 | · Queued |
|
||||
| 34| Business Day Audit | 31 | · Queued |
|
||||
```
|
||||
|
||||
Queued phases are NOT eligible for the Continue action menu — they live in a future milestone and must wait for the current milestone to ship. The preview exists purely for situational awareness.
|
||||
|
||||
**Recommendations section:**
|
||||
|
||||
If `all_complete` is true:
|
||||
@@ -362,4 +384,5 @@ Display final status with progress bar:
|
||||
- [ ] Exit shows final status with resume instructions
|
||||
- [ ] "Other" free-text input parsed for phase number and action
|
||||
- [ ] Manager loop continues until user exits or milestone completes
|
||||
- [ ] Queued section renders when `queued_phases` is non-empty; skipped when absent or empty
|
||||
</success_criteria>
|
||||
|
||||
@@ -229,4 +229,92 @@ describe('initManager', () => {
|
||||
expect(typeof flags.plan).toBe('string');
|
||||
expect(typeof flags.execute).toBe('string');
|
||||
});
|
||||
|
||||
// ── queued_phases (#2497) ─────────────────────────────────────────────
|
||||
describe('queued_phases (#2497)', () => {
|
||||
const MULTI_MILESTONE = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Milestone v1.0: Old — ✅ SHIPPED 2026-01-01',
|
||||
'',
|
||||
'Shipped.',
|
||||
'',
|
||||
'## Milestone v2.0.5: Current',
|
||||
'',
|
||||
'### Phase 35: Audit',
|
||||
'**Goal**: Audit schemas.',
|
||||
'**Depends on**: None',
|
||||
'',
|
||||
'## Milestone v2.1: Daily Emails',
|
||||
'',
|
||||
'### Phase 31: Schema',
|
||||
'**Goal**: Build schema.',
|
||||
'**Depends on**: None',
|
||||
'',
|
||||
'### Phase 32: Sending',
|
||||
'**Goal**: Send emails.',
|
||||
'**Depends on**: Phase 31',
|
||||
'',
|
||||
'## Milestone v2.2: Later',
|
||||
'',
|
||||
'### Phase 99: Future',
|
||||
'**Goal**: Later work.',
|
||||
].join('\n');
|
||||
|
||||
beforeEach(async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), MULTI_MILESTONE);
|
||||
await writeFile(join(tmpDir, '.planning', 'STATE.md'), [
|
||||
'---',
|
||||
'milestone: v2.0.5',
|
||||
'milestone_name: Current',
|
||||
'---',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
it('surfaces the next milestone in queued_phases with metadata', async () => {
|
||||
const result = await initManager([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.queued_milestone_version).toBe('v2.1');
|
||||
expect(data.queued_milestone_name).toBe('Daily Emails');
|
||||
const queued = data.queued_phases as Record<string, unknown>[];
|
||||
expect(queued.map(p => p.number)).toEqual(['31', '32']);
|
||||
// Only the NEXT milestone's phases appear — not v2.2's Phase 99.
|
||||
expect(queued.find(p => p.number === '99')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('queued_phases entries carry name, deps_display, and display_name', async () => {
|
||||
const result = await initManager([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const queued = data.queued_phases as Record<string, unknown>[];
|
||||
const p32 = queued.find(p => p.number === '32');
|
||||
expect(p32).toBeDefined();
|
||||
expect(p32!.name).toBe('Sending');
|
||||
expect(p32!.deps_display).toBe('31');
|
||||
expect(typeof p32!.display_name).toBe('string');
|
||||
});
|
||||
|
||||
it('does NOT mix queued phases into the active phases list', async () => {
|
||||
const result = await initManager([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const active = (data.phases as Record<string, unknown>[]).map(p => p.number);
|
||||
// Active milestone is v2.0.5 → only Phase 35 belongs here.
|
||||
expect(active).toContain('35');
|
||||
expect(active).not.toContain('31');
|
||||
expect(active).not.toContain('32');
|
||||
});
|
||||
|
||||
it('returns empty queued_phases and null metadata when active is last milestone', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), [
|
||||
'## Milestone v2.0.5: Only Milestone',
|
||||
'',
|
||||
'### Phase 35: Audit',
|
||||
'**Goal**: Final.',
|
||||
].join('\n'));
|
||||
const result = await initManager([], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(data.queued_phases).toEqual([]);
|
||||
expect(data.queued_milestone_version).toBeNull();
|
||||
expect(data.queued_milestone_name).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,12 @@ import { homedir } from 'node:os';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { resolveModel } from './config-query.js';
|
||||
import { planningPaths, normalizePhaseName, phaseTokenMatches, toPosixPath } from './helpers.js';
|
||||
import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js';
|
||||
import {
|
||||
getMilestoneInfo,
|
||||
extractCurrentMilestone,
|
||||
extractNextMilestoneSection,
|
||||
extractPhasesFromSection,
|
||||
} from './roadmap.js';
|
||||
import { withProjectRoot } from './init.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
@@ -543,6 +548,40 @@ export const initManager: QueryHandler = async (_args, projectDir, _workstream)
|
||||
|
||||
const completedCount = phases.filter(p => p.disk_status === 'complete').length;
|
||||
|
||||
// ── Next-milestone surface (issue #2497) ───────────────────────────────
|
||||
// Populate queued_phases + metadata with the milestone immediately after
|
||||
// the active one, so the /gsd-manager dashboard can preview what's coming
|
||||
// next without mixing it into the active phases grid. Empty/null when the
|
||||
// active milestone is the last one in ROADMAP.
|
||||
let queuedPhases: Record<string, unknown>[] = [];
|
||||
let queuedMilestoneVersion: string | null = null;
|
||||
let queuedMilestoneName: string | null = null;
|
||||
try {
|
||||
const next = await extractNextMilestoneSection(rawContent, projectDir);
|
||||
if (next) {
|
||||
queuedMilestoneVersion = next.version;
|
||||
queuedMilestoneName = next.name;
|
||||
queuedPhases = extractPhasesFromSection(next.section).map(p => {
|
||||
const MAX_NAME_WIDTH = 20;
|
||||
const display_name = p.name.length > MAX_NAME_WIDTH
|
||||
? p.name.slice(0, MAX_NAME_WIDTH - 1) + '…'
|
||||
: p.name;
|
||||
const depNums = p.depends_on && !/^none$/i.test(p.depends_on.trim())
|
||||
? (p.depends_on.match(/\d+(?:\.\d+)*/g) || [])
|
||||
: [];
|
||||
return {
|
||||
number: p.number,
|
||||
name: p.name,
|
||||
display_name,
|
||||
goal: p.goal,
|
||||
depends_on: p.depends_on,
|
||||
dep_phases: depNums,
|
||||
deps_display: depNums.length > 0 ? depNums.join(',') : '—',
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch { /* queued_phases is a non-critical enhancement */ }
|
||||
|
||||
// Read manager flags from config
|
||||
const managerConfig = (config as Record<string, unknown>).manager as Record<string, Record<string, string>> | undefined;
|
||||
const sanitizeFlags = (raw: unknown): string => {
|
||||
@@ -568,6 +607,9 @@ export const initManager: QueryHandler = async (_args, projectDir, _workstream)
|
||||
recommended_actions: recommendedActions,
|
||||
waiting_signal: waitingSignal,
|
||||
all_complete: completedCount === phases.length && phases.length > 0,
|
||||
queued_phases: queuedPhases,
|
||||
queued_milestone_version: queuedMilestoneVersion,
|
||||
queued_milestone_name: queuedMilestoneName,
|
||||
project_exists: pathExists(projectDir, '.planning/PROJECT.md'),
|
||||
roadmap_exists: true,
|
||||
state_exists: true,
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
roadmapGetPhase,
|
||||
getMilestoneInfo,
|
||||
extractCurrentMilestone,
|
||||
extractNextMilestoneSection,
|
||||
extractPhasesFromSection,
|
||||
stripShippedMilestones,
|
||||
} from './roadmap.js';
|
||||
|
||||
@@ -474,3 +476,99 @@ describe('roadmapAnalyze', () => {
|
||||
expect((data1.phases as unknown[]).length).toBe((data2.phases as unknown[]).length);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractPhasesFromSection + extractNextMilestoneSection (#2497) ──────
|
||||
|
||||
describe('extractPhasesFromSection', () => {
|
||||
it('parses phase number, name, goal, and depends_on from a milestone section', () => {
|
||||
const section = [
|
||||
'',
|
||||
'### Phase 31: Email Schema',
|
||||
'**Goal**: Set up Prisma models.',
|
||||
'**Depends on**: None',
|
||||
'',
|
||||
'### Phase 32: Today\'s Sheets',
|
||||
'**Goal**: Port the GAS sender.',
|
||||
'**Depends on**: Phase 31',
|
||||
'',
|
||||
].join('\n');
|
||||
const phases = extractPhasesFromSection(section);
|
||||
expect(phases).toEqual([
|
||||
{ number: '31', name: 'Email Schema', goal: 'Set up Prisma models.', depends_on: 'None' },
|
||||
{ number: '32', name: "Today's Sheets", goal: 'Port the GAS sender.', depends_on: 'Phase 31' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when section has no phase headings', () => {
|
||||
expect(extractPhasesFromSection('no phases here\njust prose.')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNextMilestoneSection', () => {
|
||||
const MULTI = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Milestone v1.0: Old — ✅ SHIPPED 2026-01-01',
|
||||
'',
|
||||
'Shipped stuff.',
|
||||
'',
|
||||
'## Milestone v2.0.5: Current Milestone',
|
||||
'',
|
||||
'### Phase 35: Audit',
|
||||
'**Goal**: Audit schemas.',
|
||||
'',
|
||||
'## Milestone v2.1: Daily Emails',
|
||||
'',
|
||||
'### Phase 31: Schema',
|
||||
'**Goal**: Build schema.',
|
||||
'**Depends on**: None',
|
||||
'',
|
||||
'### Phase 32: Sending',
|
||||
'**Goal**: Send emails.',
|
||||
'**Depends on**: Phase 31',
|
||||
'',
|
||||
'## Milestone v2.2: Later',
|
||||
'',
|
||||
'### Phase 99: Future',
|
||||
'**Goal**: Later work.',
|
||||
].join('\n');
|
||||
|
||||
it('returns the milestone immediately after the active one (STATE-driven)', async () => {
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), MULTI);
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'STATE.md'),
|
||||
'---\nmilestone: v2.0.5\nmilestone_name: Current Milestone\n---\n',
|
||||
);
|
||||
const next = await extractNextMilestoneSection(MULTI, tmpDir);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.version).toBe('v2.1');
|
||||
expect(next!.name).toBe('Daily Emails');
|
||||
// Phases parse correctly from the returned section — only v2.1 phases,
|
||||
// not v2.2's Phase 99.
|
||||
const phases = extractPhasesFromSection(next!.section).map(p => p.number);
|
||||
expect(phases).toEqual(['31', '32']);
|
||||
});
|
||||
|
||||
it('returns null when the active milestone is the last one in ROADMAP', async () => {
|
||||
const roadmap = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Milestone v2.0.5: Last One',
|
||||
'',
|
||||
'### Phase 35: Final',
|
||||
'**Goal**: Final work.',
|
||||
].join('\n');
|
||||
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
|
||||
await writeFile(
|
||||
join(tmpDir, '.planning', 'STATE.md'),
|
||||
'---\nmilestone: v2.0.5\n---\n',
|
||||
);
|
||||
const next = await extractNextMilestoneSection(roadmap, tmpDir);
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no current milestone can be resolved', async () => {
|
||||
const next = await extractNextMilestoneSection('# Roadmap\nno milestones\n', tmpDir);
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,6 +215,121 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
|
||||
return content.slice(sectionStart, sectionEnd);
|
||||
}
|
||||
|
||||
// ─── Next-milestone helpers (issue #2497) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Phase shape returned by extractPhasesFromSection — mirrors the fields used
|
||||
* by the current-milestone phases array in initManager so consumers can
|
||||
* render queued phases uniformly.
|
||||
*/
|
||||
export interface QueuedPhase {
|
||||
number: string;
|
||||
name: string;
|
||||
goal: string | null;
|
||||
depends_on: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract phase entries from an arbitrary ROADMAP milestone section.
|
||||
*
|
||||
* Parses `#### Phase N: Name` / `### Phase N: Name` / `## Phase N: Name`
|
||||
* headings and, for each, captures goal + depends_on via the same patterns
|
||||
* used by initManager's current-milestone phase parsing. Used by
|
||||
* `initManager` to populate `queued_phases` (#2497).
|
||||
*/
|
||||
export function extractPhasesFromSection(section: string): QueuedPhase[] {
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
||||
const phases: QueuedPhase[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(section)) !== null) {
|
||||
const phaseNum = m[1];
|
||||
const phaseName = m[2].replace(/\(INSERTED\)/i, '').trim();
|
||||
const sectionStart = m.index;
|
||||
const rest = section.slice(sectionStart);
|
||||
const nextHeader = rest.match(/\n#{2,4}\s+Phase\s+\d/i);
|
||||
const end = nextHeader ? sectionStart + (nextHeader.index ?? 0) : section.length;
|
||||
const body = section.slice(sectionStart, end);
|
||||
const goalMatch = body.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
const dependsMatch = body.match(/\*\*Depends on(?::\*\*|\*\*:)\s*([^\n]+)/i);
|
||||
phases.push({
|
||||
number: phaseNum,
|
||||
name: phaseName,
|
||||
goal: goalMatch ? goalMatch[1].trim() : null,
|
||||
depends_on: dependsMatch ? dependsMatch[1].trim() : null,
|
||||
});
|
||||
}
|
||||
return phases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the milestone section that comes immediately AFTER the active one.
|
||||
*
|
||||
* Used by initManager to surface `queued_phases` without conflating the
|
||||
* active milestone's phase list with the next one (#2497). Returns null
|
||||
* when no subsequent milestone section exists (active is the last one).
|
||||
*
|
||||
* Reuses the same current-version resolution path as `getMilestoneInfo`:
|
||||
* STATE.md frontmatter first, then in-flight emoji markers in ROADMAP.
|
||||
* Shipped milestones are stripped first so they can't shadow the real
|
||||
* "next" one.
|
||||
*/
|
||||
export async function extractNextMilestoneSection(
|
||||
content: string,
|
||||
projectDir: string,
|
||||
): Promise<{ version: string; name: string; section: string } | null> {
|
||||
const cleaned = stripShippedMilestones(content);
|
||||
|
||||
// Resolve current version via STATE.md (priority) then in-flight markers.
|
||||
let currentVersion: string | null = null;
|
||||
const fromState = await parseMilestoneFromState(projectDir);
|
||||
if (fromState?.version) {
|
||||
const raw = fromState.version.trim();
|
||||
currentVersion = /^v\d/i.test(raw) ? raw : `v${raw}`;
|
||||
}
|
||||
if (!currentVersion) {
|
||||
const inProgressMatch = cleaned.match(/(?:🚧|🟡)\s*\*\*v(\d+(?:\.\d+)+)\s/);
|
||||
if (inProgressMatch) currentVersion = 'v' + inProgressMatch[1];
|
||||
}
|
||||
if (!currentVersion) return null;
|
||||
|
||||
// Find the current milestone ## heading.
|
||||
const escaped = escapeRegex(currentVersion);
|
||||
const currentHeadingPattern = new RegExp(
|
||||
`^##\\s+[^\\n]*${escaped}[^\\n]*$`,
|
||||
'mi',
|
||||
);
|
||||
const currentMatch = cleaned.match(currentHeadingPattern);
|
||||
if (!currentMatch || currentMatch.index === undefined) return null;
|
||||
|
||||
// Look for the next ## milestone heading after the current one.
|
||||
const tail = cleaned.slice(currentMatch.index + currentMatch[0].length);
|
||||
const nextMilestonePattern = /^##\s+([^\n]*(?:v(\d+(?:\.\d+)+)|✅|🚧|🟡|📋)[^\n]*)$/gim;
|
||||
let nextMatch: RegExpExecArray | null;
|
||||
while ((nextMatch = nextMilestonePattern.exec(tail)) !== null) {
|
||||
const heading = nextMatch[1];
|
||||
const versionMatch = heading.match(/v(\d+(?:\.\d+)+)/);
|
||||
if (!versionMatch) continue;
|
||||
const nextVersion = 'v' + versionMatch[1];
|
||||
if (nextVersion === currentVersion) continue;
|
||||
|
||||
// Derive a display name: trim through "vX.Y:" or "vX.Y —" prefix.
|
||||
const nameMatch = heading.match(/v\d+(?:\.\d+)+:?\s*[—–-]?\s*([^\n(]+)/);
|
||||
const name = nameMatch ? nameMatch[1].trim() : heading.trim();
|
||||
|
||||
const sectionStart = (nextMatch.index ?? 0) + nextMatch[0].length;
|
||||
const afterStart = tail.slice(sectionStart);
|
||||
const followingHeader = afterStart.match(/^##\s/m);
|
||||
const sectionEnd = followingHeader && followingHeader.index !== undefined
|
||||
? sectionStart + followingHeader.index
|
||||
: tail.length;
|
||||
const section = tail.slice(sectionStart, sectionEnd);
|
||||
|
||||
return { version: nextVersion, name, section };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user