@howaboua/opencode-roadmap-plugin 0.1.6 → 0.1.9

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.
@@ -1,20 +1,24 @@
1
- Establish a durable project roadmap saved to disk. Provides shared context across sessions and Task tool subagents.
1
+ Establish a durable project roadmap with a high-level spec and task list. Provides shared context across sessions and Task tool subagents.
2
2
 
3
3
  Use when:
4
- - User says "roadmap", "plan the project", "phases", or "milestones"
5
- - Work will be delegated to Task tool subagents that need shared context
4
+ - After a heavy planning session to lock in the spec and task list
5
+ - When launching Task tool subagents that need shared context
6
+ - User says "roadmap", "plan the project", "phases", or "milestones"
6
7
  - Deliverables group naturally into distinct features
7
8
 
8
- When launching Task tool subagents, instruct them to call readroadmap first to understand their work within the broader plan.
9
+ When launching Task tool subagents, explicitly instruct them to: (1) call readroadmap first, and (2) call updateroadmap to mark their action in_progress at start and completed at finish with a short note.
9
10
 
10
11
  Structure: Features contain Actions. IDs are immutable once created.
11
12
 
12
- Format:
13
- - Feature: number "1", "2", title, description
14
- - Action: number "1.01", "1.02" (prefix matches parent feature), description, status "pending"
13
+ Inputs:
14
+ - feature: short label for the overall roadmap
15
+ - spec: natural-language spec for the overall direction
16
+ - features/actions: structured tasks with numbered IDs
15
17
 
16
18
  Example:
17
19
  {
20
+ "feature": "Core",
21
+ "spec": "Build the core workflow and key integrations.",
18
22
  "features": [{
19
23
  "number": "1", "title": "Auth", "description": "User authentication system",
20
24
  "actions": [
@@ -1,8 +1,8 @@
1
- Load the persisted roadmap from disk to understand project state.
1
+ Load the persisted roadmap to understand project state.
2
2
 
3
3
  When to use:
4
- 1. Before starting a major feature - read roadmap first, then plan immediate steps with todowrite
5
- 2. When launching Task tool subagents - instruct them to call readroadmap to understand their assigned work
4
+ 1. Before starting a major feature - call readroadmap first, then plan immediate steps with todowrite
5
+ 2. When launching Task tool subagents - explicitly instruct them to call readroadmap before doing any work, then updateroadmap when they start and finish
6
6
  3. To check overall progress across features
7
7
 
8
8
  Concurrent work awareness:
@@ -3,7 +3,7 @@ Advance action state within the persisted roadmap.
3
3
  Statuses: pending, in_progress, completed, cancelled
4
4
  Transitions: Flexible (can revert if needed), except cancelled is terminal.
5
5
 
6
- Update after completing work on an action. When delegating to Task tool subagents, instruct them to call updateroadmap when they finish their assigned action.
6
+ Use after completing work on an action. When delegating to Task tool subagents, explicitly instruct them to call updateroadmap at start (set in_progress) and at finish (set completed with a short note).
7
7
 
8
8
  Archives roadmap automatically when all actions reach completed.
9
9
 
@@ -1 +1 @@
1
- Roadmap file is corrupted or unreadable. Ask user to check roadmap.json file.
1
+ Roadmap data is corrupted or unreadable. Ask user to recreate the roadmap.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Parses and renders the roadmap markdown document.
3
+ * Keeps frontmatter simple and the task block machine-readable.
4
+ * Exposes a narrow API for the storage layer.
5
+ */
6
+ import type { RoadmapDocument } from "../types.js";
7
+ export declare const parseDocument: (data: string) => RoadmapDocument;
8
+ export declare const buildDocument: (document: RoadmapDocument) => string;
9
+ export declare const ensureDocument: (document: RoadmapDocument) => RoadmapDocument;
@@ -0,0 +1,117 @@
1
+ import { Roadmap as RoadmapSchema } from "../types.js";
2
+ const FRONTMATTER_START = "---\n";
3
+ const FRONTMATTER_END = "\n---\n";
4
+ const TASK_FENCE = "```json";
5
+ const TASK_FENCE_END = "\n```";
6
+ export const parseDocument = (data) => {
7
+ const { frontmatter, body } = splitFrontmatter(data);
8
+ const { feature, spec } = parseFrontmatter(frontmatter);
9
+ const roadmap = parseRoadmapBody(body);
10
+ return { feature, spec, roadmap };
11
+ };
12
+ export const buildDocument = (document) => {
13
+ const specValue = document.spec.trimEnd();
14
+ const specLines = specValue === "" ? [""] : specValue.split("\n");
15
+ const specBlock = specLines.map((line) => ` ${line}`).join("\n");
16
+ const tasks = JSON.stringify(document.roadmap, null, 2);
17
+ return [
18
+ "---",
19
+ `feature: ${JSON.stringify(document.feature)}`,
20
+ "spec: |",
21
+ specBlock,
22
+ "---",
23
+ "",
24
+ TASK_FENCE,
25
+ tasks,
26
+ "```",
27
+ "",
28
+ ].join("\n");
29
+ };
30
+ const splitFrontmatter = (data) => {
31
+ if (!data.startsWith(FRONTMATTER_START)) {
32
+ throw new Error("Roadmap format is invalid. Missing frontmatter.");
33
+ }
34
+ const endIndex = data.indexOf(FRONTMATTER_END, FRONTMATTER_START.length);
35
+ if (endIndex === -1) {
36
+ throw new Error("Roadmap format is invalid. Frontmatter is not closed.");
37
+ }
38
+ const frontmatter = data.slice(FRONTMATTER_START.length, endIndex);
39
+ const body = data.slice(endIndex + FRONTMATTER_END.length);
40
+ return { frontmatter, body };
41
+ };
42
+ const parseFrontmatter = (frontmatter) => {
43
+ const lines = frontmatter.split("\n");
44
+ let feature = null;
45
+ let specStart = -1;
46
+ for (let i = 0; i < lines.length; i += 1) {
47
+ const line = lines[i];
48
+ if (line.startsWith("feature:")) {
49
+ feature = parseScalar(line.slice("feature:".length));
50
+ }
51
+ if (line.startsWith("spec:")) {
52
+ if (line.trim() !== "spec: |") {
53
+ throw new Error("Roadmap format is invalid. Spec must use a block value.");
54
+ }
55
+ specStart = i;
56
+ }
57
+ }
58
+ if (!feature) {
59
+ throw new Error("Roadmap format is invalid. Missing feature.");
60
+ }
61
+ if (specStart === -1) {
62
+ throw new Error("Roadmap format is invalid. Missing spec.");
63
+ }
64
+ const specLines = lines.slice(specStart + 1);
65
+ const spec = normalizeSpec(specLines);
66
+ return { feature, spec };
67
+ };
68
+ const parseScalar = (value) => {
69
+ const trimmed = value.trim();
70
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
71
+ return trimmed.slice(1, -1);
72
+ }
73
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
74
+ return trimmed.slice(1, -1);
75
+ }
76
+ return trimmed;
77
+ };
78
+ const normalizeSpec = (lines) => {
79
+ if (lines.length === 0) {
80
+ return "";
81
+ }
82
+ const nonEmpty = lines.filter((line) => line.trim() !== "");
83
+ const indent = nonEmpty.length === 0
84
+ ? 0
85
+ : nonEmpty.reduce((min, line) => {
86
+ const match = line.match(/^\s+/);
87
+ const count = match ? match[0].length : 0;
88
+ return Math.min(min, count);
89
+ }, Number.MAX_SAFE_INTEGER);
90
+ const normalized = lines.map((line) => line.slice(indent));
91
+ return normalized.join("\n").trimEnd();
92
+ };
93
+ const parseRoadmapBody = (body) => {
94
+ const fenceStart = body.indexOf(TASK_FENCE);
95
+ if (fenceStart === -1) {
96
+ throw new Error("Roadmap format is invalid. Missing task block.");
97
+ }
98
+ const jsonStart = body.indexOf("\n", fenceStart + TASK_FENCE.length);
99
+ if (jsonStart === -1) {
100
+ throw new Error("Roadmap format is invalid. Task block is incomplete.");
101
+ }
102
+ const fenceEnd = body.indexOf(TASK_FENCE_END, jsonStart + 1);
103
+ if (fenceEnd === -1) {
104
+ throw new Error("Roadmap format is invalid. Task block is not closed.");
105
+ }
106
+ const jsonText = body.slice(jsonStart + 1, fenceEnd).trim();
107
+ const parsed = JSON.parse(jsonText);
108
+ return RoadmapSchema.parse(parsed);
109
+ };
110
+ export const ensureDocument = (document) => {
111
+ const validated = RoadmapSchema.parse(document.roadmap);
112
+ return {
113
+ feature: document.feature,
114
+ spec: document.spec,
115
+ roadmap: validated,
116
+ };
117
+ };
@@ -0,0 +1,4 @@
1
+ export declare const ensureRoadmapDir: (base: string) => Promise<void>;
2
+ export declare const readRoadmapFile: (base: string) => Promise<string>;
3
+ export declare const writeRoadmapFile: (base: string, data: string) => Promise<void>;
4
+ export declare const archiveRoadmapFile: (base: string) => Promise<string>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Handles roadmap file IO with atomic writes.
3
+ * Keeps filesystem concerns separate from document parsing.
4
+ */
5
+ import { promises as fs } from "fs";
6
+ import { roadmapDir, roadmapPath, tempPath } from "./paths.js";
7
+ export const ensureRoadmapDir = async (base) => {
8
+ await fs.mkdir(roadmapDir(base), { recursive: true }).catch(() => { });
9
+ };
10
+ export const readRoadmapFile = async (base) => {
11
+ return await fs.readFile(roadmapPath(base), "utf-8");
12
+ };
13
+ const fsyncDir = async (dir) => {
14
+ const handle = await fs.open(dir, "r");
15
+ try {
16
+ await handle.sync();
17
+ }
18
+ finally {
19
+ await handle.close().catch(() => { });
20
+ }
21
+ };
22
+ export const writeRoadmapFile = async (base, data) => {
23
+ const suffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
24
+ const temp = tempPath(base, suffix);
25
+ const handle = await fs.open(temp, "w");
26
+ try {
27
+ await handle.writeFile(data, "utf-8");
28
+ await handle.sync();
29
+ await handle.close();
30
+ await fs.rename(temp, roadmapPath(base));
31
+ await fsyncDir(roadmapDir(base));
32
+ }
33
+ catch (error) {
34
+ await handle.close().catch(() => { });
35
+ await fs.unlink(temp).catch(() => { });
36
+ if (error instanceof Error) {
37
+ throw error;
38
+ }
39
+ throw new Error("Unknown error while persisting roadmap");
40
+ }
41
+ };
42
+ export const archiveRoadmapFile = async (base) => {
43
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
44
+ const archiveFilename = `roadmap.archive.${timestamp}.md`;
45
+ const archivePath = `${roadmapDir(base)}/${archiveFilename}`;
46
+ await fs.rename(roadmapPath(base), archivePath);
47
+ await fsyncDir(roadmapDir(base));
48
+ return archiveFilename;
49
+ };
@@ -0,0 +1 @@
1
+ export declare const acquireLock: (base: string) => Promise<() => Promise<void>>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Provides exclusive access for roadmap operations.
3
+ * Uses a lock file with stale detection to avoid deadlocks.
4
+ */
5
+ import { promises as fs } from "fs";
6
+ import { lockPath } from "./paths.js";
7
+ const LOCK_TIMEOUT_MS = 5000;
8
+ const LOCK_RETRY_MS = 50;
9
+ const LOCK_STALE_MS = 30000;
10
+ export const acquireLock = async (base) => {
11
+ const path = lockPath(base);
12
+ const start = Date.now();
13
+ while (Date.now() - start < LOCK_TIMEOUT_MS) {
14
+ try {
15
+ await fs.writeFile(path, String(process.pid), { flag: "wx" });
16
+ return async () => {
17
+ await fs.unlink(path).catch(() => { });
18
+ };
19
+ }
20
+ catch {
21
+ const isStale = await fs
22
+ .stat(path)
23
+ .then((stat) => Date.now() - stat.mtimeMs > LOCK_STALE_MS)
24
+ .catch(() => false);
25
+ if (isStale) {
26
+ await fs.unlink(path).catch(() => { });
27
+ continue;
28
+ }
29
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
30
+ }
31
+ }
32
+ throw new Error("Could not acquire lock on roadmap data. Another operation may be in progress.");
33
+ };
@@ -0,0 +1,7 @@
1
+ export declare const ROADMAP_DIR = "roadmap";
2
+ export declare const ROADMAP_FILE = "roadmap.md";
3
+ export declare const LOCK_FILE = "roadmap.md.lock";
4
+ export declare const roadmapDir: (base: string) => string;
5
+ export declare const roadmapPath: (base: string) => string;
6
+ export declare const lockPath: (base: string) => string;
7
+ export declare const tempPath: (base: string, suffix: string) => string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Centralizes file naming for the roadmap document.
3
+ * Keeps path logic consistent across storage helpers.
4
+ */
5
+ import { join } from "path";
6
+ export const ROADMAP_DIR = "roadmap";
7
+ export const ROADMAP_FILE = "roadmap.md";
8
+ export const LOCK_FILE = `${ROADMAP_FILE}.lock`;
9
+ export const roadmapDir = (base) => join(base, ROADMAP_DIR);
10
+ export const roadmapPath = (base) => join(roadmapDir(base), ROADMAP_FILE);
11
+ export const lockPath = (base) => join(roadmapDir(base), LOCK_FILE);
12
+ export const tempPath = (base, suffix) => join(roadmapDir(base), `${ROADMAP_FILE}.tmp.${suffix}`);
@@ -1,26 +1,17 @@
1
- import type { Roadmap, RoadmapStorage, ValidationError } from "./types.js";
1
+ import type { RoadmapDocument, RoadmapStorage } from "./types.js";
2
+ type UpdateResult<T> = {
3
+ document: RoadmapDocument;
4
+ buildResult: (archiveName: string | null) => T;
5
+ archive?: boolean;
6
+ };
2
7
  export declare class FileStorage implements RoadmapStorage {
3
8
  private readonly directory;
4
9
  constructor(directory: string);
5
10
  exists(): Promise<boolean>;
6
- read(): Promise<Roadmap | null>;
7
- private acquireLock;
8
- write(roadmap: Roadmap): Promise<void>;
11
+ read(): Promise<RoadmapDocument | null>;
12
+ write(document: RoadmapDocument): Promise<void>;
13
+ update<T>(fn: (current: RoadmapDocument | null) => Promise<UpdateResult<T>> | UpdateResult<T>): Promise<T>;
9
14
  archive(): Promise<string>;
15
+ private readFromDisk;
10
16
  }
11
- export declare class RoadmapValidator {
12
- static validateFeatureNumber(number: string): ValidationError | null;
13
- static validateActionNumber(number: string): ValidationError | null;
14
- static validateActionSequence(actions: {
15
- number: string;
16
- }[], globalSeenNumbers?: Set<string>, featureNumber?: string): ValidationError[];
17
- static validateFeatureSequence(features: {
18
- number: string;
19
- actions: {
20
- number: string;
21
- }[];
22
- }[]): ValidationError[];
23
- static validateTitle(title: string, fieldType: "feature" | "action"): ValidationError | null;
24
- static validateDescription(description: string, fieldType: "feature" | "action"): ValidationError | null;
25
- static validateStatusProgression(currentStatus: string, newStatus: string): ValidationError | null;
26
- }
17
+ export {};
@@ -1,15 +1,14 @@
1
1
  /**
2
- * File-based storage for roadmap data with atomic writes and validation.
3
- * Handles concurrent access via file locking and provides safe read/write operations.
2
+ * Persists the roadmap document with locking and atomic writes.
3
+ * Delegates parsing and formatting to the document helpers.
4
+ * Keeps IO logic focused on concurrency and filesystem safety.
4
5
  */
5
6
  import { promises as fs } from "fs";
6
- import { join } from "path";
7
7
  import { z } from "zod";
8
- import { Roadmap as RoadmapSchema } from "./types.js";
9
- const ROADMAP_FILE = "roadmap.json";
10
- const LOCK_FILE = `${ROADMAP_FILE}.lock`;
11
- const LOCK_TIMEOUT_MS = 5000;
12
- const LOCK_RETRY_MS = 50;
8
+ import { parseDocument, buildDocument, ensureDocument } from "./roadmap/document.js";
9
+ import { acquireLock } from "./roadmap/lock.js";
10
+ import { ensureRoadmapDir, readRoadmapFile, writeRoadmapFile, archiveRoadmapFile } from "./roadmap/files.js";
11
+ import { roadmapPath } from "./roadmap/paths.js";
13
12
  export class FileStorage {
14
13
  directory;
15
14
  constructor(directory) {
@@ -17,7 +16,7 @@ export class FileStorage {
17
16
  }
18
17
  async exists() {
19
18
  try {
20
- await fs.access(join(this.directory, ROADMAP_FILE));
19
+ await fs.access(roadmapPath(this.directory));
21
20
  return true;
22
21
  }
23
22
  catch {
@@ -25,68 +24,29 @@ export class FileStorage {
25
24
  }
26
25
  }
27
26
  async read() {
27
+ return this.readFromDisk();
28
+ }
29
+ async write(document) {
30
+ await ensureRoadmapDir(this.directory);
31
+ const unlock = await acquireLock(this.directory);
28
32
  try {
29
- const filePath = join(this.directory, ROADMAP_FILE);
30
- const data = await fs.readFile(filePath, "utf-8");
31
- if (!data.trim()) {
32
- return null;
33
- }
34
- const parsed = JSON.parse(data);
35
- const validated = RoadmapSchema.parse(parsed);
36
- return validated;
37
- }
38
- catch (error) {
39
- if (error instanceof SyntaxError) {
40
- throw new Error("Roadmap file contains invalid JSON. File may be corrupted.");
41
- }
42
- if (error instanceof z.ZodError) {
43
- const issues = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
44
- throw new Error(`Roadmap file has invalid structure: ${issues}`);
45
- }
46
- if (error instanceof Error && error.message.includes("ENOENT")) {
47
- return null;
48
- }
49
- if (error instanceof Error) {
50
- throw error;
51
- }
52
- throw new Error("Unknown error while reading roadmap");
33
+ const data = buildDocument(ensureDocument(document));
34
+ await writeRoadmapFile(this.directory, data);
53
35
  }
54
- }
55
- async acquireLock() {
56
- const lockPath = join(this.directory, LOCK_FILE);
57
- const start = Date.now();
58
- while (Date.now() - start < LOCK_TIMEOUT_MS) {
59
- try {
60
- await fs.writeFile(lockPath, String(process.pid), { flag: "wx" });
61
- return async () => {
62
- await fs.unlink(lockPath).catch(() => { });
63
- };
64
- }
65
- catch {
66
- await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
67
- }
36
+ finally {
37
+ await unlock();
68
38
  }
69
- throw new Error("Could not acquire lock on roadmap file. Another operation may be in progress.");
70
39
  }
71
- async write(roadmap) {
72
- await fs.mkdir(this.directory, { recursive: true }).catch(() => { });
73
- const unlock = await this.acquireLock();
40
+ async update(fn) {
41
+ await ensureRoadmapDir(this.directory);
42
+ const unlock = await acquireLock(this.directory);
74
43
  try {
75
- const filePath = join(this.directory, ROADMAP_FILE);
76
- const randomSuffix = Math.random().toString(36).slice(2, 8);
77
- const tempPath = join(this.directory, `${ROADMAP_FILE}.tmp.${Date.now()}.${randomSuffix}`);
78
- try {
79
- const data = JSON.stringify(roadmap, null, 2);
80
- await fs.writeFile(tempPath, data, "utf-8");
81
- await fs.rename(tempPath, filePath);
82
- }
83
- catch (error) {
84
- await fs.unlink(tempPath).catch(() => { });
85
- if (error instanceof Error) {
86
- throw error;
87
- }
88
- throw new Error("Unknown error while writing roadmap");
89
- }
44
+ const current = await this.readFromDisk();
45
+ const outcome = await fn(current);
46
+ const data = buildDocument(ensureDocument(outcome.document));
47
+ await writeRoadmapFile(this.directory, data);
48
+ const archiveName = outcome.archive ? await archiveRoadmapFile(this.directory) : null;
49
+ return outcome.buildResult(archiveName);
90
50
  }
91
51
  finally {
92
52
  await unlock();
@@ -94,152 +54,39 @@ export class FileStorage {
94
54
  }
95
55
  async archive() {
96
56
  if (!(await this.exists())) {
97
- throw new Error("Cannot archive: roadmap file does not exist");
98
- }
99
- const filePath = join(this.directory, ROADMAP_FILE);
100
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
101
- const archiveFilename = `roadmap.archive.${timestamp}.json`;
102
- const archivePath = join(this.directory, archiveFilename);
103
- await fs.rename(filePath, archivePath);
104
- return archiveFilename;
105
- }
106
- }
107
- export class RoadmapValidator {
108
- static validateFeatureNumber(number) {
109
- if (!number || typeof number !== "string") {
110
- return {
111
- code: "INVALID_FEATURE_NUMBER",
112
- message: "Invalid feature ID: must be a string.",
113
- };
114
- }
115
- if (!/^\d+$/.test(number)) {
116
- return {
117
- code: "INVALID_FEATURE_NUMBER_FORMAT",
118
- message: "Invalid feature ID format: must be a simple number.",
119
- };
57
+ throw new Error("Roadmap not found.");
120
58
  }
121
- return null;
122
- }
123
- static validateActionNumber(number) {
124
- if (!number || typeof number !== "string") {
125
- return {
126
- code: "INVALID_ACTION_NUMBER",
127
- message: "Invalid action ID: must be a string.",
128
- };
59
+ const unlock = await acquireLock(this.directory);
60
+ try {
61
+ return await archiveRoadmapFile(this.directory);
129
62
  }
130
- if (!/^\d+\.\d{2}$/.test(number)) {
131
- return {
132
- code: "INVALID_ACTION_NUMBER_FORMAT",
133
- message: "Invalid action ID format: must be X.YY (e.g., 1.01).",
134
- };
63
+ finally {
64
+ await unlock();
135
65
  }
136
- return null;
137
66
  }
138
- static validateActionSequence(actions, globalSeenNumbers, featureNumber) {
139
- const errors = [];
140
- const seenNumbers = new Set();
141
- for (const action of actions) {
142
- const numberError = this.validateActionNumber(action.number);
143
- if (numberError) {
144
- errors.push(numberError);
145
- continue;
146
- }
147
- // Check action-feature mismatch
148
- if (featureNumber) {
149
- const actionFeaturePrefix = action.number.split(".")[0];
150
- if (actionFeaturePrefix !== featureNumber) {
151
- errors.push({
152
- code: "ACTION_FEATURE_MISMATCH",
153
- message: `Action "${action.number}" does not belong to feature "${featureNumber}".`,
154
- });
155
- }
67
+ async readFromDisk() {
68
+ try {
69
+ const data = await readRoadmapFile(this.directory);
70
+ if (!data.trim()) {
71
+ return null;
156
72
  }
157
- // Check for duplicates within this feature
158
- if (seenNumbers.has(action.number)) {
159
- errors.push({
160
- code: "DUPLICATE_ACTION_NUMBER",
161
- message: `Duplicate action ID "${action.number}".`,
162
- });
73
+ return ensureDocument(parseDocument(data));
74
+ }
75
+ catch (error) {
76
+ if (error instanceof SyntaxError) {
77
+ throw new Error("Roadmap data is invalid. Unable to parse tasks.");
163
78
  }
164
- // Check for global duplicates
165
- if (globalSeenNumbers?.has(action.number)) {
166
- errors.push({
167
- code: "DUPLICATE_ACTION_NUMBER_GLOBAL",
168
- message: `Duplicate action ID "${action.number}" (exists in another feature).`,
169
- });
79
+ if (error instanceof z.ZodError) {
80
+ const issues = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
81
+ throw new Error(`Roadmap data is invalid: ${issues}`);
170
82
  }
171
- seenNumbers.add(action.number);
172
- globalSeenNumbers?.add(action.number);
173
- }
174
- return errors;
175
- }
176
- static validateFeatureSequence(features) {
177
- const errors = [];
178
- const seenNumbers = new Set();
179
- const seenActionNumbers = new Set();
180
- for (const feature of features) {
181
- const numberError = this.validateFeatureNumber(feature.number);
182
- if (numberError) {
183
- errors.push(numberError);
184
- continue;
83
+ if (error instanceof Error && error.message.includes("ENOENT")) {
84
+ return null;
185
85
  }
186
- if (seenNumbers.has(feature.number)) {
187
- errors.push({
188
- code: "DUPLICATE_FEATURE_NUMBER",
189
- message: `Duplicate feature ID "${feature.number}".`,
190
- });
86
+ if (error instanceof Error) {
87
+ throw error;
191
88
  }
192
- seenNumbers.add(feature.number);
193
- const actionErrors = this.validateActionSequence(feature.actions, seenActionNumbers, feature.number);
194
- errors.push(...actionErrors);
195
- }
196
- return errors;
197
- }
198
- static validateTitle(title, fieldType) {
199
- if (!title || typeof title !== "string") {
200
- return {
201
- code: "INVALID_TITLE",
202
- message: `Invalid ${fieldType} title. Must be non-empty string.`,
203
- };
204
- }
205
- if (title.trim() === "") {
206
- return {
207
- code: "EMPTY_TITLE",
208
- message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} title cannot be empty.`,
209
- };
210
- }
211
- return null;
212
- }
213
- static validateDescription(description, fieldType) {
214
- if (!description || typeof description !== "string") {
215
- return {
216
- code: "INVALID_DESCRIPTION",
217
- message: `Invalid ${fieldType} description. Must be non-empty string.`,
218
- };
219
- }
220
- if (description.trim() === "") {
221
- return {
222
- code: "EMPTY_DESCRIPTION",
223
- message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} description cannot be empty.`,
224
- };
225
- }
226
- return null;
227
- }
228
- static validateStatusProgression(currentStatus, newStatus) {
229
- const validStatuses = ["pending", "in_progress", "completed", "cancelled"];
230
- if (!validStatuses.includes(newStatus)) {
231
- return {
232
- code: "INVALID_STATUS",
233
- message: `Invalid status "${newStatus}". Valid: ${validStatuses.join(", ")}`,
234
- };
235
- }
236
- // Allow any transition except from cancelled (terminal state for abandoned work)
237
- if (currentStatus === "cancelled") {
238
- return {
239
- code: "INVALID_STATUS_TRANSITION",
240
- message: `Cannot change status of cancelled action. Create a new action instead.`,
241
- };
89
+ throw new Error("Unknown error while loading roadmap");
242
90
  }
243
- return null;
244
91
  }
245
92
  }