@howaboua/opencode-roadmap-plugin 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -1,29 +1,100 @@
1
- # @howaboua/opencode-roadmap-plugin
1
+ # Opencode Roadmap Plugin
2
2
 
3
- Strategic roadmap planning and multi-agent coordination for OpenCode.
3
+ Persistent project roadmaps for OpenCode. Coordinates work across sessions and parallel Task tool subagents.
4
+
5
+ ## Why Use This?
6
+
7
+ OpenCode's built-in todo is session-scoped—it disappears when you restart. Task tool subagents are stateless—they can't see each other's work.
8
+
9
+ This plugin solves both:
10
+
11
+ - **Persists to disk** — survives restarts, available across sessions
12
+ - **Shared context** — subagents read the same roadmap to understand the bigger picture
13
+ - **Concurrent awareness** — agents see what's `in_progress` and avoid conflicts
14
+
15
+ ![opencode-roadmap](https://github.com/user-attachments/assets/e2479a72-ec65-457f-9503-bf2d01580c70)
4
16
 
5
17
  ## Installation
6
18
 
7
- Add to your repository `opencode.json` or user-level `~/.config/opencode/opencode.json`:
19
+ Add to your `opencode.json`:
8
20
 
9
21
  ```json
10
22
  {
11
- "plugin": ["@howaboua/opencode-roadmap-plugin"]
23
+ "plugin": ["@howaboua/opencode-roadmap-plugin@latest"]
12
24
  }
13
25
  ```
14
26
 
15
- ## How It Works
27
+ OpenCode installs it automatically on next launch.
28
+
29
+ ## Tools
30
+
31
+ ### `createroadmap`
32
+
33
+ Create or extend a project roadmap.
34
+
35
+ ```
36
+ "Create a roadmap for building user auth with login, signup, and password reset"
37
+ ```
38
+
39
+ - Features group related work (`"1"`, `"2"`, `"3"`)
40
+ - Actions are concrete tasks (`"1.01"`, `"1.02"`) within features
41
+ - New actions always start as `pending`
42
+ - Append-only: existing IDs never change
43
+
44
+ ### `readroadmap`
45
+
46
+ View current state and progress.
47
+
48
+ ```
49
+ "Show me the roadmap"
50
+ "What's the status of feature 2?"
51
+ ```
52
+
53
+ Before delegating work to Task tool subagents, instruct them to read the roadmap first so they understand their assigned action within the broader plan.
16
54
 
17
- - `createroadmap`: create or append features/actions while keeping IDs immutable.
18
- - `updateroadmap`: advance action status forward only (`pending` → `in_progress` → `completed`), optional description update.
19
- - `readroadmap`: summarize roadmap, optionally filtered by feature/action.
20
- - Storage: JSON on disk with auto-archive when all actions complete.
21
- - Validation: Zod schemas enforce shape; errors surface readable templates.
55
+ ### `updateroadmap`
22
56
 
23
- ## Development
57
+ Change action status or description.
24
58
 
25
- ```bash
26
- npm run build # Emit dist/ (ESM + d.ts)
27
59
  ```
60
+ "Mark action 1.01 as in_progress"
61
+ "Action 2.03 is completed"
62
+ ```
63
+
64
+ **Statuses:** `pending` → `in_progress` → `completed` | `cancelled`
65
+
66
+ Transitions are flexible—you can revert if plans change. Only `cancelled` is terminal.
67
+
68
+ Auto-archives the roadmap when all actions reach `completed`.
69
+
70
+ ## Coordinating Parallel Work
71
+
72
+ When multiple subagents work simultaneously:
73
+
74
+ 1. Each reads the roadmap to see what's `in_progress`
75
+ 2. Agents stay focused on their assigned action only
76
+ 3. They avoid modifying files that belong to another `in_progress` action
77
+ 4. Errors outside their scope get noted, not fixed
78
+
79
+ This prevents conflicts when subagents run in parallel.
80
+
81
+ ## Workflow Example
82
+
83
+ ```
84
+ You: "Plan out building a REST API with auth, users, and posts endpoints"
85
+
86
+ AI: Creates roadmap with 3 features, ~12 actions
87
+
88
+ You: "Implement feature 1"
89
+
90
+ AI: Reads roadmap → sees Feature 1 has 4 actions → uses todowrite for immediate steps → delegates to subagents → each subagent reads roadmap first → updates status when done
91
+ ```
92
+
93
+ ## Storage
94
+
95
+ - **Active:** `roadmap.json` in project root
96
+ - **Archived:** `roadmap.archive.<timestamp>.json` when complete
97
+
98
+ ## License
28
99
 
29
- See `AGENTS.md` for coding standards.
100
+ MIT
@@ -1,20 +1,24 @@
1
- Initialize or append to project roadmap. Supports adding new features and actions.
1
+ Establish a durable project roadmap saved to disk. Provides shared context across sessions and Task tool subagents.
2
2
 
3
- Constraints:
4
- 1. Descriptions: MUST be detailed, clear, and actionable (unlike the minimal example below).
5
- 2. Append-only: New IDs must follow sequence (Feature 1->2, Action 1.01->1.02).
6
- 3. ID Format: Feature "1", Action "1.01".
7
- 4. Validation: Action prefix MUST match Feature (Action "1.01" belongs to Feature "1").
8
- 5. Status: Must be "pending".
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
6
+ - Deliverables group naturally into distinct features
7
+
8
+ When launching Task tool subagents, instruct them to call readroadmap first to understand their work within the broader plan.
9
+
10
+ Structure: Features contain Actions. IDs are immutable once created.
11
+
12
+ Format:
13
+ - Feature: number "1", "2", title, description
14
+ - Action: number "1.01", "1.02" (prefix matches parent feature), description, status "pending"
9
15
 
10
16
  Example:
11
17
  {
12
- "features": [
13
- {
14
- "number": "1", "title": "Auth", "description": "User authentication system setup",
15
- "actions": [
16
- { "number": "1.01", "description": "Initialize PostgreSQL database schema with users table and indexes", "status": "pending" }
17
- ]
18
- }
19
- ]
20
- }
18
+ "features": [{
19
+ "number": "1", "title": "Auth", "description": "User authentication system",
20
+ "actions": [
21
+ { "number": "1.01", "description": "Design JWT token flow and refresh strategy", "status": "pending" }
22
+ ]
23
+ }]
24
+ }
@@ -1,15 +1,22 @@
1
+ /**
2
+ * Loads tool description text files from the descriptions directory.
3
+ * ESM-compatible using import.meta.url for path resolution.
4
+ */
1
5
  import { promises as fs } from "fs";
2
- import { join } from "path";
6
+ import { dirname, join } from "path";
7
+ import { fileURLToPath } from "url";
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
3
10
  export async function loadDescription(filename) {
4
- // Assets are colocated with the compiled loader (copied into dist/src/descriptions).
5
11
  const filePath = join(__dirname, filename);
6
12
  try {
7
13
  return await fs.readFile(filePath, "utf-8");
8
14
  }
9
15
  catch (error) {
10
- if (error.code === "ENOENT") {
16
+ const err = error;
17
+ if (err?.code === "ENOENT") {
11
18
  throw new Error(`Description file not found: ${filename}. Looked in: ${filePath}. Please ensure asset files are correctly located.`);
12
19
  }
13
- throw error;
20
+ throw err ?? new Error("Unknown error while loading description");
14
21
  }
15
22
  }
@@ -1,2 +1,19 @@
1
- Retrieve current roadmap state, progress, and details.
2
- Filter: Provide 'featureNumber' or 'actionNumber' for specific details, or omit to read entire roadmap.
1
+ Load the persisted roadmap from disk to understand project state.
2
+
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
6
+ 3. To check overall progress across features
7
+
8
+ Concurrent work awareness:
9
+ - Actions marked "in_progress" may have another agent actively working on them
10
+ - Stay focused on YOUR assigned action only - do not fix unrelated codebase errors
11
+ - Avoid modifying files that belong to another in_progress action
12
+ - If you encounter errors in code outside your scope, note them but do not fix
13
+
14
+ Returns: feature list, action statuses, completion percentages.
15
+
16
+ Filter options:
17
+ - featureNumber: show one feature and its actions
18
+ - actionNumber: show one action's details
19
+ - (omit both): full roadmap overview
@@ -1,8 +1,10 @@
1
- Update action status or description.
1
+ Advance action state within the persisted roadmap.
2
2
 
3
- Constraints:
4
- 1. Forward-only status: pending in_progress completed.
5
- 2. Immutable IDs: Cannot change action numbers or move actions.
6
- 3. Side Effect: Automatically archives roadmap file when ALL actions are completed.
3
+ Statuses: pending, in_progress, completed, cancelled
4
+ Transitions: Flexible (can revert if needed), except cancelled is terminal.
7
5
 
8
- Input: actionNumber (required), status (optional), description (optional).
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.
7
+
8
+ Archives roadmap automatically when all actions reach completed.
9
+
10
+ Input: actionNumber (required), status (optional), description (optional).
@@ -1,5 +1,12 @@
1
+ /**
2
+ * Loads error message templates from the errors directory.
3
+ * Supports template variable substitution for dynamic error messages.
4
+ */
1
5
  import { promises as fs } from "fs";
2
- import { join } from "path";
6
+ import { dirname, join } from "path";
7
+ import { fileURLToPath } from "url";
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
3
10
  const ERROR_CACHE = {};
4
11
  export async function loadErrorTemplate(filename) {
5
12
  if (ERROR_CACHE[filename])
@@ -11,10 +18,11 @@ export async function loadErrorTemplate(filename) {
11
18
  return ERROR_CACHE[filename];
12
19
  }
13
20
  catch (error) {
14
- if (error.code === "ENOENT") {
21
+ const err = error;
22
+ if (err?.code === "ENOENT") {
15
23
  throw new Error(`Error template not found: ${filename} at ${filePath}`);
16
24
  }
17
- throw error;
25
+ throw err ?? new Error("Unknown error loading error template");
18
26
  }
19
27
  }
20
28
  export async function getErrorMessage(filename, params = {}) {
package/dist/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createCreateRoadmapTool } from "./tools/createroadmap.js";
2
2
  import { createUpdateRoadmapTool } from "./tools/updateroadmap.js";
3
3
  import { createReadRoadmapTool } from "./tools/readroadmap.js";
4
- export const RoadmapPlugin = async ({ project, directory, worktree, $ }) => {
4
+ export const RoadmapPlugin = async ({ directory }) => {
5
5
  return {
6
6
  tool: {
7
7
  createroadmap: await createCreateRoadmapTool(directory),
@@ -1,25 +1,26 @@
1
1
  import type { Roadmap, RoadmapStorage, ValidationError } from "./types.js";
2
2
  export declare class FileStorage implements RoadmapStorage {
3
- private directory;
3
+ private readonly directory;
4
4
  constructor(directory: string);
5
5
  exists(): Promise<boolean>;
6
6
  read(): Promise<Roadmap | null>;
7
+ private acquireLock;
7
8
  write(roadmap: Roadmap): Promise<void>;
8
9
  archive(): Promise<string>;
9
10
  }
10
11
  export declare class RoadmapValidator {
11
- static validateFeatureNumber(number: string): Promise<ValidationError | null>;
12
- static validateActionNumber(number: string): Promise<ValidationError | null>;
13
- static validateActionSequence(actions: Array<{
12
+ static validateFeatureNumber(number: string): ValidationError | null;
13
+ static validateActionNumber(number: string): ValidationError | null;
14
+ static validateActionSequence(actions: {
14
15
  number: string;
15
- }>, globalSeenNumbers?: Set<string>, featureNumber?: string): Promise<ValidationError[]>;
16
- static validateFeatureSequence(features: Array<{
16
+ }[], globalSeenNumbers?: Set<string>, featureNumber?: string): ValidationError[];
17
+ static validateFeatureSequence(features: {
17
18
  number: string;
18
- actions: Array<{
19
+ actions: {
19
20
  number: string;
20
- }>;
21
- }>): Promise<ValidationError[]>;
22
- static validateTitle(title: string, fieldType: "feature" | "action"): Promise<ValidationError | null>;
23
- static validateDescription(description: string, fieldType: "feature" | "action"): Promise<ValidationError | null>;
24
- static validateStatusProgression(currentStatus: string, newStatus: string): Promise<ValidationError | null>;
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;
25
26
  }
@@ -1,7 +1,15 @@
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.
4
+ */
1
5
  import { promises as fs } from "fs";
2
6
  import { join } from "path";
3
- import { getErrorMessage } from "./errors/loader.js";
7
+ import { z } from "zod";
8
+ import { Roadmap as RoadmapSchema } from "./types.js";
4
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;
5
13
  export class FileStorage {
6
14
  directory;
7
15
  constructor(directory) {
@@ -24,43 +32,70 @@ export class FileStorage {
24
32
  return null;
25
33
  }
26
34
  const parsed = JSON.parse(data);
27
- if (!parsed || typeof parsed !== "object") {
28
- throw new Error("Invalid roadmap format: not an object");
29
- }
30
- if (!Array.isArray(parsed.features)) {
31
- throw new Error("Invalid roadmap format: missing or invalid features array");
32
- }
33
- return parsed;
35
+ const validated = RoadmapSchema.parse(parsed);
36
+ return validated;
34
37
  }
35
38
  catch (error) {
36
39
  if (error instanceof SyntaxError) {
37
40
  throw new Error("Roadmap file contains invalid JSON. File may be corrupted.");
38
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
+ }
39
46
  if (error instanceof Error && error.message.includes("ENOENT")) {
40
47
  return null;
41
48
  }
42
- throw error;
49
+ if (error instanceof Error) {
50
+ throw error;
51
+ }
52
+ throw new Error("Unknown error while reading roadmap");
43
53
  }
44
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
+ }
68
+ }
69
+ throw new Error("Could not acquire lock on roadmap file. Another operation may be in progress.");
70
+ }
45
71
  async write(roadmap) {
46
- const filePath = join(this.directory, ROADMAP_FILE);
47
- const tempPath = join(this.directory, `${ROADMAP_FILE}.tmp.${Date.now()}`);
72
+ await fs.mkdir(this.directory, { recursive: true }).catch(() => { });
73
+ const unlock = await this.acquireLock();
48
74
  try {
49
- const data = JSON.stringify(roadmap, null, 2);
50
- await fs.writeFile(tempPath, data, "utf-8");
51
- await fs.rename(tempPath, filePath);
52
- }
53
- catch (error) {
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}`);
54
78
  try {
55
- await fs.unlink(tempPath);
79
+ const data = JSON.stringify(roadmap, null, 2);
80
+ await fs.writeFile(tempPath, data, "utf-8");
81
+ await fs.rename(tempPath, filePath);
56
82
  }
57
- catch {
58
- // Ignore cleanup errors
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");
59
89
  }
60
- throw error;
90
+ }
91
+ finally {
92
+ await unlock();
61
93
  }
62
94
  }
63
95
  async archive() {
96
+ if (!(await this.exists())) {
97
+ throw new Error("Cannot archive: roadmap file does not exist");
98
+ }
64
99
  const filePath = join(this.directory, ROADMAP_FILE);
65
100
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
66
101
  const archiveFilename = `roadmap.archive.${timestamp}.json`;
@@ -70,53 +105,52 @@ export class FileStorage {
70
105
  }
71
106
  }
72
107
  export class RoadmapValidator {
73
- static async validateFeatureNumber(number) {
108
+ static validateFeatureNumber(number) {
74
109
  if (!number || typeof number !== "string") {
75
110
  return {
76
111
  code: "INVALID_FEATURE_NUMBER",
77
- message: await getErrorMessage("invalid_feature_id", { id: "undefined" }),
112
+ message: "Invalid feature ID: must be a string.",
78
113
  };
79
114
  }
80
115
  if (!/^\d+$/.test(number)) {
81
116
  return {
82
117
  code: "INVALID_FEATURE_NUMBER_FORMAT",
83
- message: await getErrorMessage("invalid_feature_id", { id: number }),
118
+ message: "Invalid feature ID format: must be a simple number.",
84
119
  };
85
120
  }
86
121
  return null;
87
122
  }
88
- static async validateActionNumber(number) {
123
+ static validateActionNumber(number) {
89
124
  if (!number || typeof number !== "string") {
90
125
  return {
91
126
  code: "INVALID_ACTION_NUMBER",
92
- message: await getErrorMessage("invalid_action_id", { id: "undefined" }),
127
+ message: "Invalid action ID: must be a string.",
93
128
  };
94
129
  }
95
130
  if (!/^\d+\.\d{2}$/.test(number)) {
96
131
  return {
97
132
  code: "INVALID_ACTION_NUMBER_FORMAT",
98
- message: await getErrorMessage("invalid_action_id", { id: number }),
133
+ message: "Invalid action ID format: must be X.YY (e.g., 1.01).",
99
134
  };
100
135
  }
101
136
  return null;
102
137
  }
103
- static async validateActionSequence(actions, globalSeenNumbers, featureNumber) {
138
+ static validateActionSequence(actions, globalSeenNumbers, featureNumber) {
104
139
  const errors = [];
105
140
  const seenNumbers = new Set();
106
- for (let i = 0; i < actions.length; i++) {
107
- const action = actions[i];
108
- const numberError = await this.validateActionNumber(action.number);
141
+ for (const action of actions) {
142
+ const numberError = this.validateActionNumber(action.number);
109
143
  if (numberError) {
110
144
  errors.push(numberError);
111
145
  continue;
112
146
  }
113
147
  // Check action-feature mismatch
114
148
  if (featureNumber) {
115
- const actionFeaturePrefix = action.number.split('.')[0];
149
+ const actionFeaturePrefix = action.number.split(".")[0];
116
150
  if (actionFeaturePrefix !== featureNumber) {
117
151
  errors.push({
118
152
  code: "ACTION_FEATURE_MISMATCH",
119
- message: await getErrorMessage("action_mismatch", { action: action.number, feature: featureNumber }),
153
+ message: `Action "${action.number}" does not belong to feature "${featureNumber}".`,
120
154
  });
121
155
  }
122
156
  }
@@ -139,13 +173,12 @@ export class RoadmapValidator {
139
173
  }
140
174
  return errors;
141
175
  }
142
- static async validateFeatureSequence(features) {
176
+ static validateFeatureSequence(features) {
143
177
  const errors = [];
144
178
  const seenNumbers = new Set();
145
179
  const seenActionNumbers = new Set();
146
- for (let i = 0; i < features.length; i++) {
147
- const feature = features[i];
148
- const numberError = await this.validateFeatureNumber(feature.number);
180
+ for (const feature of features) {
181
+ const numberError = this.validateFeatureNumber(feature.number);
149
182
  if (numberError) {
150
183
  errors.push(numberError);
151
184
  continue;
@@ -157,12 +190,12 @@ export class RoadmapValidator {
157
190
  });
158
191
  }
159
192
  seenNumbers.add(feature.number);
160
- const actionErrors = await this.validateActionSequence(feature.actions, seenActionNumbers, feature.number);
193
+ const actionErrors = this.validateActionSequence(feature.actions, seenActionNumbers, feature.number);
161
194
  errors.push(...actionErrors);
162
195
  }
163
196
  return errors;
164
197
  }
165
- static async validateTitle(title, fieldType) {
198
+ static validateTitle(title, fieldType) {
166
199
  if (!title || typeof title !== "string") {
167
200
  return {
168
201
  code: "INVALID_TITLE",
@@ -177,7 +210,7 @@ export class RoadmapValidator {
177
210
  }
178
211
  return null;
179
212
  }
180
- static async validateDescription(description, fieldType) {
213
+ static validateDescription(description, fieldType) {
181
214
  if (!description || typeof description !== "string") {
182
215
  return {
183
216
  code: "INVALID_DESCRIPTION",
@@ -192,21 +225,19 @@ export class RoadmapValidator {
192
225
  }
193
226
  return null;
194
227
  }
195
- static async validateStatusProgression(currentStatus, newStatus) {
196
- const statusFlow = {
197
- pending: ["in_progress", "completed"],
198
- in_progress: ["completed"],
199
- completed: [],
200
- };
201
- const allowedTransitions = statusFlow[currentStatus] || [];
202
- if (!allowedTransitions.includes(newStatus)) {
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") {
203
238
  return {
204
239
  code: "INVALID_STATUS_TRANSITION",
205
- message: await getErrorMessage("invalid_transition", {
206
- from: currentStatus,
207
- to: newStatus,
208
- allowed: allowedTransitions.length > 0 ? allowedTransitions.join(", ") : "None (terminal state)"
209
- }),
240
+ message: `Cannot change status of cancelled action. Create a new action instead.`,
210
241
  };
211
242
  }
212
243
  return null;
@@ -1,2 +1,6 @@
1
+ /**
2
+ * Tool for creating and appending to project roadmaps.
3
+ * Supports merge logic for adding new features/actions to existing roadmaps.
4
+ */
1
5
  import { type ToolDefinition } from "@opencode-ai/plugin";
2
6
  export declare function createCreateRoadmapTool(directory: string): Promise<ToolDefinition>;
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Tool for creating and appending to project roadmaps.
3
+ * Supports merge logic for adding new features/actions to existing roadmaps.
4
+ */
1
5
  import { tool } from "@opencode-ai/plugin";
2
6
  import { FileStorage, RoadmapValidator } from "../storage.js";
3
7
  import { loadDescription } from "../descriptions/index.js";
@@ -48,20 +52,20 @@ export async function createCreateRoadmapTool(directory) {
48
52
  if (!feature.actions || feature.actions.length === 0) {
49
53
  throw new Error(`Feature "${feature.number}" must have at least one action. Each feature needs at least one action to be valid.`);
50
54
  }
51
- const titleError = await RoadmapValidator.validateTitle(feature.title, "feature");
55
+ const titleError = RoadmapValidator.validateTitle(feature.title, "feature");
52
56
  if (titleError)
53
57
  validationErrors.push(titleError);
54
- const descError = await RoadmapValidator.validateDescription(feature.description, "feature");
58
+ const descError = RoadmapValidator.validateDescription(feature.description, "feature");
55
59
  if (descError)
56
60
  validationErrors.push(descError);
57
61
  for (const action of feature.actions) {
58
- const actionTitleError = await RoadmapValidator.validateTitle(action.description, "action");
62
+ const actionTitleError = RoadmapValidator.validateTitle(action.description, "action");
59
63
  if (actionTitleError)
60
64
  validationErrors.push(actionTitleError);
61
65
  }
62
66
  }
63
67
  // Validate sequence consistency of input (internal consistency)
64
- const sequenceErrors = await RoadmapValidator.validateFeatureSequence(args.features);
68
+ const sequenceErrors = RoadmapValidator.validateFeatureSequence(args.features);
65
69
  validationErrors.push(...sequenceErrors);
66
70
  if (validationErrors.length > 0) {
67
71
  const errorMessages = validationErrors.map((err) => err.message).join("\n");
@@ -72,13 +76,14 @@ export async function createCreateRoadmapTool(directory) {
72
76
  const existingFeature = roadmap.features.find((f) => f.number === inputFeature.number);
73
77
  if (existingFeature) {
74
78
  // Feature exists: Validate Immutability
75
- if (existingFeature.title !== inputFeature.title || existingFeature.description !== inputFeature.description) {
79
+ if (existingFeature.title !== inputFeature.title ||
80
+ existingFeature.description !== inputFeature.description) {
76
81
  const msg = await getErrorMessage("immutable_feature", {
77
82
  id: inputFeature.number,
78
83
  oldTitle: existingFeature.title,
79
84
  oldDesc: existingFeature.description,
80
85
  newTitle: inputFeature.title,
81
- newDesc: inputFeature.description
86
+ newDesc: inputFeature.description,
82
87
  });
83
88
  throw new Error(msg);
84
89
  }
@@ -117,10 +122,16 @@ export async function createCreateRoadmapTool(directory) {
117
122
  }
118
123
  // Final Sort of Features
119
124
  roadmap.features.sort((a, b) => parseInt(a.number) - parseInt(b.number));
125
+ // Safety check: ensure no feature ended up with zero actions after merge
126
+ for (const feature of roadmap.features) {
127
+ if (feature.actions.length === 0) {
128
+ throw new Error(`Feature "${feature.number}" has no actions. This indicates a merge error.`);
129
+ }
130
+ }
120
131
  // Final Validation of the Merged Roadmap
121
- const finalErrors = await RoadmapValidator.validateFeatureSequence(roadmap.features);
132
+ const finalErrors = RoadmapValidator.validateFeatureSequence(roadmap.features);
122
133
  if (finalErrors.length > 0) {
123
- throw new Error(`Resulting roadmap would be invalid:\n${finalErrors.map(e => e.message).join("\n")}`);
134
+ throw new Error(`Resulting roadmap would be invalid:\n${finalErrors.map((e) => e.message).join("\n")}`);
124
135
  }
125
136
  await storage.write(roadmap);
126
137
  const totalActions = roadmap.features.reduce((sum, feature) => sum + feature.actions.length, 0);
@@ -1,2 +1,6 @@
1
+ /**
2
+ * Tool for reading roadmap state, progress, and details.
3
+ * Supports filtering by feature or action number.
4
+ */
1
5
  import { type ToolDefinition } from "@opencode-ai/plugin";
2
6
  export declare function createReadRoadmapTool(directory: string): Promise<ToolDefinition>;