roadmapsmith 0.9.16 → 0.9.23

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.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: roadmap-validate
3
+ description: Inspect evidence-backed roadmap validation through the RoadmapSmith CLI.
4
+ ---
5
+
6
+ # RoadmapSmith Validate
7
+
8
+ Use this command when the user wants per-task evidence status without mutating the roadmap.
9
+
10
+ ## Required behavior
11
+
12
+ 1. Prefer the local engine inside this repository:
13
+ - `node roadmap-skill/bin/cli.js validate --json --project-root .`
14
+ - on this Windows machine, prefer `C:\Program Files\nodejs\node.exe roadmap-skill/bin/cli.js validate --json --project-root .` if `node` is not in PATH
15
+ 2. Otherwise prefer `roadmapsmith validate --json --project-root .`.
16
+ 3. Treat this command as CLI-backed and non-mutating.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: roadmap-zero
3
+ description: Run the one-command Zero Mode workflow through the RoadmapSmith CLI.
4
+ ---
5
+
6
+ # RoadmapSmith Zero
7
+
8
+ Use this command when the repository is empty or low-context and the user needs the discovery interview plus first roadmap generation.
9
+
10
+ ## Required behavior
11
+
12
+ 1. Prefer the local engine inside this repository:
13
+ - `node roadmap-skill/bin/cli.js zero --project-root .`
14
+ - on this Windows machine, prefer `C:\Program Files\nodejs\node.exe roadmap-skill/bin/cli.js zero --project-root .` if `node` is not in PATH
15
+ 2. Otherwise prefer `roadmapsmith zero --project-root .`.
16
+ 3. Treat this command as CLI-backed and interactive.
17
+ 4. If the CLI is missing, explain the install path instead of improvising the workflow manually.
package/skills.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "roadmapsmith",
3
+ "description": "One-command roadmap generation and maintenance for coding agents, with a shared native slash bundle for Claude and Codex plus the RoadmapSmith CLI.",
4
+ "triggers": [
5
+ "roadmap",
6
+ "sync roadmap",
7
+ "validate task completion",
8
+ "project milestones",
9
+ "agent roadmap"
10
+ ],
11
+ "usageExamples": [
12
+ "npx skills add PapiScholz/roadmapsmith --skill '*' -a claude-code",
13
+ "npx skills add PapiScholz/roadmapsmith --skill roadmap-sync",
14
+ "roadmapsmith setup",
15
+ "roadmapsmith zero",
16
+ "roadmapsmith maintain",
17
+ "roadmapsmith /roadmap",
18
+ "roadmapsmith /roadmap-update",
19
+ "roadmapsmith validate --json"
20
+ ],
21
+ "install": {
22
+ "command": "npx skills add PapiScholz/roadmapsmith --skill '*' -a claude-code",
23
+ "source": "PapiScholz/roadmapsmith",
24
+ "skill": "*",
25
+ "notes": "Recommended Claude Code install path for native GUI slash commands like /roadmap, /roadmap-zero, /roadmap-maintain, /roadmap-status, /roadmap-init, /roadmap-generate, /roadmap-validate, /roadmap-update, /roadmap-audit, and /roadmap-setup. The legacy /roadmap-sync root remains available for compatibility, especially as /roadmap-sync <action>. Install the roadmapsmith CLI separately for actual command execution, then run /reload-skills and, if applicable, /reload-plugins. Codex native plugin installs use the repo/package .codex-plugin surface instead of this Claude-specific skills CLI path."
26
+ },
27
+ "skills": [
28
+ {
29
+ "name": "roadmap",
30
+ "path": "skills/roadmap",
31
+ "description": "Native slash palette for RoadmapSmith commands and recommended entrypoints across supported hosts.",
32
+ "version": "0.9.23"
33
+ },
34
+ {
35
+ "name": "roadmap-zero",
36
+ "path": "skills/roadmap-zero",
37
+ "description": "Native slash entrypoint for the one-command Zero Mode CLI workflow.",
38
+ "version": "0.9.23"
39
+ },
40
+ {
41
+ "name": "roadmap-maintain",
42
+ "path": "skills/roadmap-maintain",
43
+ "description": "Native slash entrypoint for the preserve-first generate + sync + audit flow.",
44
+ "version": "0.9.23"
45
+ },
46
+ {
47
+ "name": "roadmap-status",
48
+ "path": "skills/roadmap-status",
49
+ "description": "Native slash readiness check grounded in roadmapsmith doctor JSON.",
50
+ "version": "0.9.23"
51
+ },
52
+ {
53
+ "name": "roadmap-init",
54
+ "path": "skills/roadmap-init",
55
+ "description": "Native slash entrypoint for creating ROADMAP.md and AGENTS.md.",
56
+ "version": "0.9.23"
57
+ },
58
+ {
59
+ "name": "roadmap-generate",
60
+ "path": "skills/roadmap-generate",
61
+ "description": "Native slash entrypoint for managed roadmap updates that require --full-regen before destructive replacement.",
62
+ "version": "0.9.23"
63
+ },
64
+ {
65
+ "name": "roadmap-validate",
66
+ "path": "skills/roadmap-validate",
67
+ "description": "Native slash entrypoint for evidence-backed roadmap validation.",
68
+ "version": "0.9.23"
69
+ },
70
+ {
71
+ "name": "roadmap-update",
72
+ "path": "skills/roadmap-update",
73
+ "description": "Native slash entrypoint for applying evidence-backed checklist sync.",
74
+ "version": "0.9.23"
75
+ },
76
+ {
77
+ "name": "roadmap-sync",
78
+ "path": "skills/roadmap-sync",
79
+ "description": "Legacy namespaced root plus policy guidance for RoadmapSmith slash workflows.",
80
+ "version": "0.9.23"
81
+ },
82
+ {
83
+ "name": "roadmap-audit",
84
+ "path": "skills/roadmap-audit",
85
+ "description": "Native slash entrypoint for the current sync-plus-audit workflow.",
86
+ "version": "0.9.23"
87
+ },
88
+ {
89
+ "name": "roadmap-setup",
90
+ "path": "skills/roadmap-setup",
91
+ "description": "Native slash entrypoint for generating RoadmapSmith host integration files.",
92
+ "version": "0.9.23"
93
+ }
94
+ ]
95
+ }
@@ -12,7 +12,16 @@ const WEB_CONFIGS = [
12
12
  ];
13
13
  const STYLE_CONFIGS = ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs'];
14
14
  const WEB_DEPS = new Set(['next', 'react', 'vue', 'svelte', 'astro', 'vite', 'nuxt', 'gatsby', 'remix', '@remix-run/react']);
15
+ const ELECTRON_DEPS = new Set(['electron', 'electron-builder', 'electron-forge', '@electron-forge/cli', 'electron-updater']);
16
+ const ELECTRON_CONFIGS = [
17
+ 'electron-builder.json',
18
+ 'electron-builder.yml',
19
+ 'electron-builder.yaml',
20
+ 'forge.config.js',
21
+ 'forge.config.ts'
22
+ ];
15
23
  const LANDING_ROUTE_RE = /(?:^|\/)(?:contact|services|about|pricing|hero|cta|landing)(?:\/|\.)/i;
24
+ const FIXTURE_PATH_RE = /(^|\/)(?:test|tests)\/fixtures\//i;
16
25
 
17
26
  function readPackageDeps(projectRoot) {
18
27
  if (!projectRoot) return [];
@@ -44,6 +53,9 @@ function hasWorkspaces(projectRoot) {
44
53
  }
45
54
 
46
55
  function classifyProject({ projectRoot, files }) {
56
+ const candidateFiles = Array.isArray(files)
57
+ ? files.filter((file) => !FIXTURE_PATH_RE.test(String(file || '')))
58
+ : [];
47
59
  const signals = [];
48
60
 
49
61
  if (hasWorkspaces(projectRoot)) {
@@ -51,17 +63,22 @@ function classifyProject({ projectRoot, files }) {
51
63
  return { type: 'monorepo', confidence: 'high', signals };
52
64
  }
53
65
 
54
- const hasPy = hasFilename(files, 'pyproject.toml') || hasFilename(files, 'setup.py');
55
- if (hasPy && !files.some((f) => /\.[jt]sx?$/.test(f))) {
66
+ const hasPy = hasFilename(candidateFiles, 'pyproject.toml') || hasFilename(candidateFiles, 'setup.py');
67
+ if (hasPy && !candidateFiles.some((f) => /\.[jt]sx?$/.test(f))) {
56
68
  signals.push('pyproject.toml / setup.py, no JS files');
57
69
  return { type: 'python-package', confidence: 'high', signals };
58
70
  }
59
71
 
60
72
  let webScore = 0;
61
73
  let landingScore = 0;
74
+ let electronScore = 0;
62
75
  const deps = readPackageDeps(projectRoot);
63
76
 
64
77
  for (const dep of deps) {
78
+ if (ELECTRON_DEPS.has(dep)) {
79
+ electronScore += 3;
80
+ signals.push(`dependency: ${dep}`);
81
+ }
65
82
  if (WEB_DEPS.has(dep)) {
66
83
  webScore += 2;
67
84
  signals.push(`dependency: ${dep}`);
@@ -69,59 +86,81 @@ function classifyProject({ projectRoot, files }) {
69
86
  }
70
87
 
71
88
  for (const dir of WEB_DIRS) {
72
- if (hasDir(files, dir)) {
89
+ if (hasDir(candidateFiles, dir)) {
73
90
  webScore += 2;
74
91
  signals.push(`directory: ${dir.replace(/\/$/, '')}`);
75
92
  }
76
93
  }
77
94
 
78
95
  for (const dir of ASSET_DIRS) {
79
- if (hasDir(files, dir)) {
96
+ if (hasDir(candidateFiles, dir)) {
80
97
  webScore += 1;
81
98
  signals.push(`directory: ${dir.replace(/\/$/, '')}`);
82
99
  }
83
100
  }
84
101
 
102
+ if (hasDir(candidateFiles, 'electron/')) {
103
+ electronScore += 3;
104
+ signals.push('directory: electron');
105
+ }
106
+
107
+ if (candidateFiles.some((file) => /^electron\/.+\.(js|ts|cjs|mjs)$/.test(file))) {
108
+ electronScore += 2;
109
+ signals.push('electron main/preload sources');
110
+ }
111
+
112
+ for (const cfg of ELECTRON_CONFIGS) {
113
+ if (hasFilename(candidateFiles, cfg)) {
114
+ electronScore += 2;
115
+ signals.push(`config: ${cfg}`);
116
+ }
117
+ }
118
+
85
119
  for (const cfg of WEB_CONFIGS) {
86
- if (hasFilename(files, cfg)) {
120
+ if (hasFilename(candidateFiles, cfg)) {
87
121
  webScore += 3;
88
122
  signals.push(`config: ${cfg}`);
89
123
  }
90
124
  }
91
125
 
92
126
  for (const cfg of STYLE_CONFIGS) {
93
- if (hasFilename(files, cfg)) {
127
+ if (hasFilename(candidateFiles, cfg)) {
94
128
  webScore += 1;
95
129
  signals.push(`config: ${cfg}`);
96
130
  }
97
131
  }
98
132
 
99
- if (files.some((f) => /\.css$/.test(f))) {
133
+ if (candidateFiles.some((f) => /\.css$/.test(f))) {
100
134
  webScore += 1;
101
135
  signals.push('CSS files present');
102
136
  }
103
137
 
104
- const landingRoutes = files.filter((f) => LANDING_ROUTE_RE.test(f));
138
+ const landingRoutes = candidateFiles.filter((f) => LANDING_ROUTE_RE.test(f));
105
139
  if (landingRoutes.length > 0) {
106
140
  landingScore += landingRoutes.length * 2;
107
141
  signals.push(`landing/service routes: ${landingRoutes.length}`);
108
142
  }
109
143
 
110
- if (hasFilename(files, 'favicon.ico') || hasFilename(files, 'logo.png') || hasFilename(files, 'logo.svg')) {
144
+ if (hasFilename(candidateFiles, 'favicon.ico') || hasFilename(candidateFiles, 'logo.png') || hasFilename(candidateFiles, 'logo.svg')) {
111
145
  landingScore += 1;
112
146
  signals.push('branding asset in public/');
113
147
  }
114
148
 
115
- if (webScore === 0 && (files.some((f) => f.startsWith('bin/')) || hasFilename(files, 'cli.js'))) {
149
+ if (webScore === 0 && (candidateFiles.some((f) => f.startsWith('bin/')) || hasFilename(candidateFiles, 'cli.js'))) {
116
150
  signals.push('bin/ directory or cli.js');
117
151
  return { type: 'cli-tool', confidence: 'medium', signals };
118
152
  }
119
153
 
120
- if (webScore === 0 && hasFilename(files, 'package.json')) {
154
+ if (webScore === 0 && hasFilename(candidateFiles, 'package.json')) {
121
155
  signals.push('package.json, no web signals');
122
156
  return { type: 'npm-package', confidence: 'low', signals };
123
157
  }
124
158
 
159
+ if (electronScore >= 3) {
160
+ const confidence = electronScore >= 6 ? 'high' : 'medium';
161
+ return { type: 'electron-app', confidence, signals };
162
+ }
163
+
125
164
  if (webScore >= 3) {
126
165
  const type = landingScore >= 3 ? 'landing-site' : 'frontend-web';
127
166
  const confidence = webScore >= 7 ? 'high' : 'medium';
@@ -4,8 +4,8 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { walkFiles, detectLanguages, detectTestFrameworks, detectWorkspaces } = require('../io');
6
6
  const { createRoadmapModel, PHASE_ORDER } = require('../model');
7
- const { slugify, ensureTrailingNewline } = require('../utils');
8
- const { parseRoadmap, upsertManagedBlock } = require('../parser');
7
+ const { slugify } = require('../utils');
8
+ const { parseRoadmap, tasksInManagedBlock, upsertManagedBlock } = require('../parser');
9
9
  const { findBestTaskMatch, dedupeTasks } = require('../match');
10
10
  const { collectPluginContributions } = require('../config');
11
11
  const { renderBody } = require('../renderer');
@@ -13,6 +13,8 @@ const { classifyProject } = require('../classifier');
13
13
 
14
14
  const IMPL_PATTERN_RE = /[/|]TODO|TODO[|/]|[/|]FIXME|FIXME[|/]/;
15
15
  const COMMENT_TODO_RE = /(?:\/\/|#|\*\s*).*\b(?:TODO|FIXME)\b/;
16
+ const ADDITIONS_SECTION_TITLE = 'RoadmapSmith Additions';
17
+ const PHASE_LABEL_RE = /`?\[(P[0-2])\]`?/i;
16
18
 
17
19
  function isTodoMarker(line) {
18
20
  return COMMENT_TODO_RE.test(line) && !IMPL_PATTERN_RE.test(line);
@@ -181,6 +183,58 @@ function toCandidate(text, phase, priority, source = 'default') {
181
183
  };
182
184
  }
183
185
 
186
+ function hasSubstantiveManagedBlock(parsedRoadmap) {
187
+ if (!parsedRoadmap || !parsedRoadmap.managedRange) {
188
+ return false;
189
+ }
190
+
191
+ const managedLines = parsedRoadmap.lines.slice(
192
+ parsedRoadmap.managedRange.start + 1,
193
+ parsedRoadmap.managedRange.end
194
+ );
195
+ return managedLines.some((line) => line.trim().length > 0);
196
+ }
197
+
198
+ function stripTrailingBlankLines(lines) {
199
+ const next = Array.isArray(lines) ? lines.slice() : [];
200
+ while (next.length > 0 && !next[next.length - 1].trim()) {
201
+ next.pop();
202
+ }
203
+ return next;
204
+ }
205
+
206
+ function renderAdditionTask(task) {
207
+ return `- [ ] ${task.text} <!-- rs:task=${task.id} -->`;
208
+ }
209
+
210
+ function isGenericPreserveModeCandidate(candidate) {
211
+ return candidate && ['default', 'classifier', 'todo-hint'].includes(candidate.source);
212
+ }
213
+
214
+ function buildManagedAdditionsLines(tasks, options = {}) {
215
+ const groups = groupByPhase(tasks);
216
+ const lines = [];
217
+ const includeSectionHeading = options.includeSectionHeading !== false;
218
+
219
+ if (includeSectionHeading) {
220
+ lines.push(`## ${ADDITIONS_SECTION_TITLE}`);
221
+ lines.push('');
222
+ }
223
+
224
+ for (const phase of PHASE_ORDER) {
225
+ if (!groups[phase] || groups[phase].length === 0) {
226
+ continue;
227
+ }
228
+ lines.push(`### Phase ${phase}`);
229
+ for (const task of groups[phase]) {
230
+ lines.push(renderAdditionTask(task));
231
+ }
232
+ lines.push('');
233
+ }
234
+
235
+ return stripTrailingBlankLines(lines);
236
+ }
237
+
184
238
  const WEB_CANDIDATES_COMMON = [
185
239
  { text: 'Add SEO metadata: title, description, and canonical URL for all pages', phase: 'P0' },
186
240
  { text: 'Implement responsive and mobile-first layout across all breakpoints', phase: 'P0' },
@@ -285,6 +339,9 @@ function applyTaskMatchers(scan, config) {
285
339
  }
286
340
 
287
341
  function inferPhase(existingTask) {
342
+ const textPhaseMatch = String(existingTask.text || '').match(PHASE_LABEL_RE);
343
+ if (textPhaseMatch) return textPhaseMatch[1].toUpperCase();
344
+
288
345
  const section = String(existingTask.section || '').toUpperCase();
289
346
  if (section.includes('P0')) return 'P0';
290
347
  if (section.includes('P1')) return 'P1';
@@ -292,12 +349,138 @@ function inferPhase(existingTask) {
292
349
  return 'P1';
293
350
  }
294
351
 
295
- function mergeWithExisting(candidates, existingTasks) {
352
+ function sortTasksByPhaseAndText(tasks) {
353
+ return tasks.slice().sort((left, right) => {
354
+ const leftPhaseIndex = PHASE_ORDER.indexOf(left.phase);
355
+ const rightPhaseIndex = PHASE_ORDER.indexOf(right.phase);
356
+ if (leftPhaseIndex !== rightPhaseIndex) {
357
+ return leftPhaseIndex - rightPhaseIndex;
358
+ }
359
+ return left.text.localeCompare(right.text);
360
+ });
361
+ }
362
+
363
+ function findPhaseSectionRange(lines, managedRange, phase) {
364
+ const headingPattern = /^(#{2,4})\s+(.*)$/;
365
+ const phasePattern = new RegExp(`\\b${phase}\\b`, 'i');
366
+ let sectionStart = -1;
367
+ let sectionLevel = 0;
368
+
369
+ for (let index = managedRange.start + 1; index < managedRange.end; index += 1) {
370
+ const match = lines[index].trim().match(headingPattern);
371
+ if (!match) {
372
+ continue;
373
+ }
374
+ if (!phasePattern.test(match[2])) {
375
+ continue;
376
+ }
377
+ sectionStart = index;
378
+ sectionLevel = match[1].length;
379
+ }
380
+
381
+ if (sectionStart < 0) {
382
+ return null;
383
+ }
384
+
385
+ let sectionEnd = managedRange.end;
386
+ for (let index = sectionStart + 1; index < managedRange.end; index += 1) {
387
+ const match = lines[index].trim().match(headingPattern);
388
+ if (!match) {
389
+ continue;
390
+ }
391
+ if (match[1].length <= sectionLevel) {
392
+ sectionEnd = index;
393
+ break;
394
+ }
395
+ }
396
+
397
+ return {
398
+ start: sectionStart,
399
+ end: sectionEnd
400
+ };
401
+ }
402
+
403
+ function buildPreserveModeInsertions(parsedRoadmap, tasks) {
404
+ const managedTasks = sortTasksByPhaseAndText(tasksInManagedBlock(parsedRoadmap));
405
+ const groups = groupByPhase(tasks);
406
+ const lines = parsedRoadmap.lines;
407
+ const insertions = [];
408
+ const fallbackTasks = [];
409
+
410
+ for (const phase of PHASE_ORDER) {
411
+ const phaseTasks = sortTasksByPhaseAndText(groups[phase] || []);
412
+ if (phaseTasks.length === 0) {
413
+ continue;
414
+ }
415
+
416
+ const samePhaseTasks = managedTasks.filter((task) => inferPhase(task) === phase);
417
+ if (samePhaseTasks.length > 0) {
418
+ const anchor = samePhaseTasks.reduce((latest, task) => {
419
+ if (!latest || task.lastChildLineIndex > latest.lastChildLineIndex) {
420
+ return task;
421
+ }
422
+ return latest;
423
+ }, null);
424
+ insertions.push({
425
+ index: anchor.lastChildLineIndex + 1,
426
+ lines: phaseTasks.map(renderAdditionTask)
427
+ });
428
+ continue;
429
+ }
430
+
431
+ const phaseSection = findPhaseSectionRange(lines, parsedRoadmap.managedRange, phase);
432
+ if (phaseSection) {
433
+ let insertionIndex = phaseSection.end;
434
+ while (insertionIndex > phaseSection.start + 1 && !lines[insertionIndex - 1].trim()) {
435
+ insertionIndex -= 1;
436
+ }
437
+ insertions.push({
438
+ index: insertionIndex,
439
+ lines: phaseTasks.map(renderAdditionTask)
440
+ });
441
+ continue;
442
+ }
443
+
444
+ fallbackTasks.push(...phaseTasks);
445
+ }
446
+
447
+ if (fallbackTasks.length > 0) {
448
+ const fallbackLines = buildManagedAdditionsLines(fallbackTasks, { includeSectionHeading: true });
449
+ insertions.push({
450
+ index: parsedRoadmap.managedRange.end,
451
+ lines: ['', ...fallbackLines]
452
+ });
453
+ }
454
+
455
+ return insertions.sort((left, right) => right.index - left.index);
456
+ }
457
+
458
+ function insertPreserveModeTasks(existingContent, parsedRoadmap, tasks) {
459
+ if (!parsedRoadmap || !parsedRoadmap.managedRange || tasks.length === 0) {
460
+ return existingContent;
461
+ }
462
+
463
+ const nextLines = parsedRoadmap.lines.slice();
464
+ const insertions = buildPreserveModeInsertions(parsedRoadmap, tasks);
465
+ for (const insertion of insertions) {
466
+ nextLines.splice(insertion.index, 0, ...insertion.lines);
467
+ }
468
+
469
+ return nextLines.join('\n');
470
+ }
471
+
472
+ function filterPreserveModeCandidates(candidates) {
473
+ return candidates.filter((candidate) => !isGenericPreserveModeCandidate(candidate));
474
+ }
475
+
476
+ function mergeWithExisting(candidates, existingTasks, options = {}) {
296
477
  const matchedExistingIds = new Set();
297
478
  const merged = [];
298
479
 
299
480
  for (const candidate of candidates) {
300
- const match = findBestTaskMatch(candidate, existingTasks);
481
+ const match = findBestTaskMatch(candidate, existingTasks, {
482
+ allowFuzzy: options.allowFuzzy !== false
483
+ });
301
484
  if (match) {
302
485
  matchedExistingIds.add(match.task.id);
303
486
  merged.push({
@@ -311,20 +494,22 @@ function mergeWithExisting(candidates, existingTasks) {
311
494
  merged.push(candidate);
312
495
  }
313
496
 
314
- for (const existing of existingTasks) {
315
- if (matchedExistingIds.has(existing.id)) {
316
- continue;
317
- }
497
+ if (options.includeUnmatchedExisting) {
498
+ for (const existing of existingTasks) {
499
+ if (matchedExistingIds.has(existing.id)) {
500
+ continue;
501
+ }
318
502
 
319
- const phase = inferPhase(existing);
320
- merged.push({
321
- id: existing.id,
322
- text: existing.text,
323
- phase,
324
- priority: phase,
325
- checked: existing.checked,
326
- source: 'existing'
327
- });
503
+ const phase = inferPhase(existing);
504
+ merged.push({
505
+ id: existing.id,
506
+ text: existing.text,
507
+ phase,
508
+ priority: phase,
509
+ checked: existing.checked,
510
+ source: 'existing'
511
+ });
512
+ }
328
513
  }
329
514
 
330
515
  return dedupeTasks(merged);
@@ -566,6 +751,8 @@ function generateRoadmapDocument(options) {
566
751
  const zeroModeConfig = config.zeroMode || {};
567
752
  const plugins = options.plugins || [];
568
753
  const existingContent = options.existingContent || '';
754
+ const preserveManagedBlock = options.preserveManagedBlock === true;
755
+ const forceFullRegenerate = options.forceFullRegenerate === true;
569
756
 
570
757
  const scan = scanProject(projectRoot);
571
758
  const existing = parseRoadmap(existingContent);
@@ -573,7 +760,7 @@ function generateRoadmapDocument(options) {
573
760
  for (const task of existing.tasks) {
574
761
  existingCheckedById[task.id] = task.checked;
575
762
  }
576
- const existingPhaseTasks = existing.tasks.filter((task) => /^Phase P[0-2]/i.test(String(task.section || '')));
763
+ const existingManagedTasks = tasksInManagedBlock(existing);
577
764
 
578
765
  const pluginTaskCandidates = collectPluginContributions(plugins, 'registerTaskDetectors', {
579
766
  projectRoot,
@@ -627,7 +814,32 @@ function generateRoadmapDocument(options) {
627
814
 
628
815
  const baseCandidates = buildDefaultCandidates(scan, config);
629
816
  const matcherCandidates = applyTaskMatchers(scan, config);
630
- const merged = mergeWithExisting([...baseCandidates, ...matcherCandidates, ...pluginTaskCandidates], existingPhaseTasks);
817
+ const allCandidates = dedupeTasks([...baseCandidates, ...matcherCandidates, ...pluginTaskCandidates]);
818
+
819
+ if (hasSubstantiveManagedBlock(existing) && preserveManagedBlock && !forceFullRegenerate) {
820
+ const unmatchedCandidates = allCandidates.filter((candidate) => {
821
+ return !findBestTaskMatch(candidate, existingManagedTasks, {
822
+ allowFuzzy: true,
823
+ minScore: 0.72
824
+ });
825
+ });
826
+ const preserveModeCandidates = filterPreserveModeCandidates(unmatchedCandidates);
827
+
828
+ if (preserveModeCandidates.length === 0) {
829
+ return existingContent;
830
+ }
831
+
832
+ return insertPreserveModeTasks(existingContent, existing, preserveModeCandidates);
833
+ }
834
+
835
+ if (hasSubstantiveManagedBlock(existing) && !forceFullRegenerate) {
836
+ throw new Error('Refusing to regenerate a substantive managed roadmap block. Rerun with --full-regen to replace it explicitly.');
837
+ }
838
+
839
+ const merged = mergeWithExisting(allCandidates, existingManagedTasks, {
840
+ allowFuzzy: true,
841
+ includeUnmatchedExisting: false
842
+ });
631
843
  const model = createModel(scan, merged, config, [profileSection, ...generatedZeroModeSection, ...configSections, ...pluginSections], existingCheckedById);
632
844
  const profile = config.roadmapProfile || 'compact';
633
845
  const managedBody = renderBody(model, profile);