@howaboua/opencode-roadmap-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/descriptions/index.d.ts +1 -0
  4. package/dist/descriptions/index.js +1 -0
  5. package/dist/descriptions/loader.d.ts +1 -0
  6. package/dist/descriptions/loader.js +17 -0
  7. package/dist/errors/loader.d.ts +2 -0
  8. package/dist/errors/loader.js +24 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.js +3 -0
  11. package/dist/src/descriptions/index.d.ts +1 -0
  12. package/dist/src/descriptions/index.js +1 -0
  13. package/dist/src/descriptions/loader.d.ts +1 -0
  14. package/dist/src/descriptions/loader.js +17 -0
  15. package/dist/src/errors/loader.d.ts +2 -0
  16. package/dist/src/errors/loader.js +24 -0
  17. package/dist/src/index.d.ts +3 -0
  18. package/dist/src/index.js +13 -0
  19. package/dist/src/storage.d.ts +25 -0
  20. package/dist/src/storage.js +214 -0
  21. package/dist/src/tools/createroadmap.d.ts +2 -0
  22. package/dist/src/tools/createroadmap.js +135 -0
  23. package/dist/src/tools/readroadmap.d.ts +2 -0
  24. package/dist/src/tools/readroadmap.js +90 -0
  25. package/dist/src/tools/updateroadmap.d.ts +2 -0
  26. package/dist/src/tools/updateroadmap.js +107 -0
  27. package/dist/src/types.d.ts +151 -0
  28. package/dist/src/types.js +18 -0
  29. package/dist/storage.d.ts +25 -0
  30. package/dist/storage.js +214 -0
  31. package/dist/tools/createroadmap.d.ts +2 -0
  32. package/dist/tools/createroadmap.js +135 -0
  33. package/dist/tools/readroadmap.d.ts +2 -0
  34. package/dist/tools/readroadmap.js +90 -0
  35. package/dist/tools/updateroadmap.d.ts +2 -0
  36. package/dist/tools/updateroadmap.js +107 -0
  37. package/dist/types.d.ts +151 -0
  38. package/dist/types.js +18 -0
  39. package/package.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 howaboua
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @howaboua/opencode-roadmap-plugin
2
+
3
+ Strategic roadmap planning and multi-agent coordination for OpenCode.
4
+
5
+ ## Installation
6
+
7
+ Add to your repository `opencode.json` or user-level `~/.config/opencode/opencode.json`:
8
+
9
+ ```json
10
+ {
11
+ "plugin": ["@howaboua/opencode-roadmap-plugin"]
12
+ }
13
+ ```
14
+
15
+ ## How It Works
16
+
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.
22
+
23
+ ## Development
24
+
25
+ ```bash
26
+ npm run build # Emit dist/ (ESM + d.ts)
27
+ ```
28
+
29
+ See `AGENTS.md` for coding standards.
@@ -0,0 +1 @@
1
+ export { loadDescription } from "./loader";
@@ -0,0 +1 @@
1
+ export { loadDescription } from "./loader.js";
@@ -0,0 +1 @@
1
+ export declare function loadDescription(filename: string): Promise<string>;
@@ -0,0 +1,17 @@
1
+ import { promises as fs } from "fs";
2
+ import { join } from "path";
3
+ export async function loadDescription(filename) {
4
+ // In the compiled output, __dirname is .../dist/descriptions, but the assets are in .../src/descriptions.
5
+ // This path adjustment ensures the assets are found regardless of the build process.
6
+ const descriptionsDir = join(__dirname, "..", "..", "src", "descriptions");
7
+ const filePath = join(descriptionsDir, filename);
8
+ try {
9
+ return await fs.readFile(filePath, "utf-8");
10
+ }
11
+ catch (error) {
12
+ if (error.code === "ENOENT") {
13
+ throw new Error(`Description file not found: ${filename}. Looked in: ${filePath}. Please ensure asset files are correctly located.`);
14
+ }
15
+ throw error;
16
+ }
17
+ }
@@ -0,0 +1,2 @@
1
+ export declare function loadErrorTemplate(filename: string): Promise<string>;
2
+ export declare function getErrorMessage(filename: string, params?: Record<string, string>): Promise<string>;
@@ -0,0 +1,24 @@
1
+ import { promises as fs } from "fs";
2
+ import { join } from "path";
3
+ const ERROR_CACHE = {};
4
+ export async function loadErrorTemplate(filename) {
5
+ if (ERROR_CACHE[filename])
6
+ return ERROR_CACHE[filename];
7
+ const errorsDir = join(__dirname, "..", "..", "src", "errors");
8
+ const filePath = join(errorsDir, filename + ".txt");
9
+ try {
10
+ const content = await fs.readFile(filePath, "utf-8");
11
+ ERROR_CACHE[filename] = content.trim();
12
+ return ERROR_CACHE[filename];
13
+ }
14
+ catch (error) {
15
+ if (error.code === "ENOENT") {
16
+ throw new Error(`Error template not found: ${filename} at ${filePath}`);
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+ export async function getErrorMessage(filename, params = {}) {
22
+ const template = await loadErrorTemplate(filename);
23
+ return template.replace(/\{(\w+)\}/g, (_, key) => params[key] || `{${key}}`);
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const RoadmapPlugin: Plugin;
3
+ export default RoadmapPlugin;
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { RoadmapPlugin as impl } from "./src/index.js";
2
+ export const RoadmapPlugin = impl;
3
+ export default RoadmapPlugin;
@@ -0,0 +1 @@
1
+ export { loadDescription } from "./loader.js";
@@ -0,0 +1 @@
1
+ export { loadDescription } from "./loader.js";
@@ -0,0 +1 @@
1
+ export declare function loadDescription(filename: string): Promise<string>;
@@ -0,0 +1,17 @@
1
+ import { promises as fs } from "fs";
2
+ import { join } from "path";
3
+ export async function loadDescription(filename) {
4
+ // In the compiled output, __dirname is .../dist/descriptions, but the assets are in .../src/descriptions.
5
+ // This path adjustment ensures the assets are found regardless of the build process.
6
+ const descriptionsDir = join(__dirname, "..", "..", "src", "descriptions");
7
+ const filePath = join(descriptionsDir, filename);
8
+ try {
9
+ return await fs.readFile(filePath, "utf-8");
10
+ }
11
+ catch (error) {
12
+ if (error.code === "ENOENT") {
13
+ throw new Error(`Description file not found: ${filename}. Looked in: ${filePath}. Please ensure asset files are correctly located.`);
14
+ }
15
+ throw error;
16
+ }
17
+ }
@@ -0,0 +1,2 @@
1
+ export declare function loadErrorTemplate(filename: string): Promise<string>;
2
+ export declare function getErrorMessage(filename: string, params?: Record<string, string>): Promise<string>;
@@ -0,0 +1,24 @@
1
+ import { promises as fs } from "fs";
2
+ import { join } from "path";
3
+ const ERROR_CACHE = {};
4
+ export async function loadErrorTemplate(filename) {
5
+ if (ERROR_CACHE[filename])
6
+ return ERROR_CACHE[filename];
7
+ const errorsDir = join(__dirname, "..", "..", "src", "errors");
8
+ const filePath = join(errorsDir, filename + ".txt");
9
+ try {
10
+ const content = await fs.readFile(filePath, "utf-8");
11
+ ERROR_CACHE[filename] = content.trim();
12
+ return ERROR_CACHE[filename];
13
+ }
14
+ catch (error) {
15
+ if (error.code === "ENOENT") {
16
+ throw new Error(`Error template not found: ${filename} at ${filePath}`);
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+ export async function getErrorMessage(filename, params = {}) {
22
+ const template = await loadErrorTemplate(filename);
23
+ return template.replace(/\{(\w+)\}/g, (_, key) => params[key] || `{${key}}`);
24
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const RoadmapPlugin: Plugin;
3
+ export default RoadmapPlugin;
@@ -0,0 +1,13 @@
1
+ import { createCreateRoadmapTool } from "./tools/createroadmap.js";
2
+ import { createUpdateRoadmapTool } from "./tools/updateroadmap.js";
3
+ import { createReadRoadmapTool } from "./tools/readroadmap.js";
4
+ export const RoadmapPlugin = async ({ project, directory, worktree, $ }) => {
5
+ return {
6
+ tool: {
7
+ createroadmap: await createCreateRoadmapTool(directory),
8
+ updateroadmap: await createUpdateRoadmapTool(directory),
9
+ readroadmap: await createReadRoadmapTool(directory),
10
+ },
11
+ };
12
+ };
13
+ export default RoadmapPlugin;
@@ -0,0 +1,25 @@
1
+ import type { Roadmap, RoadmapStorage, ValidationError } from "./types.js";
2
+ export declare class FileStorage implements RoadmapStorage {
3
+ private directory;
4
+ constructor(directory: string);
5
+ exists(): Promise<boolean>;
6
+ read(): Promise<Roadmap | null>;
7
+ write(roadmap: Roadmap): Promise<void>;
8
+ archive(): Promise<string>;
9
+ }
10
+ 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<{
14
+ number: string;
15
+ }>, globalSeenNumbers?: Set<string>, featureNumber?: string): Promise<ValidationError[]>;
16
+ static validateFeatureSequence(features: Array<{
17
+ number: string;
18
+ actions: Array<{
19
+ 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>;
25
+ }
@@ -0,0 +1,214 @@
1
+ import { promises as fs } from "fs";
2
+ import { join } from "path";
3
+ import { getErrorMessage } from "./errors/loader.js";
4
+ const ROADMAP_FILE = "roadmap.json";
5
+ export class FileStorage {
6
+ directory;
7
+ constructor(directory) {
8
+ this.directory = directory;
9
+ }
10
+ async exists() {
11
+ try {
12
+ await fs.access(join(this.directory, ROADMAP_FILE));
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ async read() {
20
+ try {
21
+ const filePath = join(this.directory, ROADMAP_FILE);
22
+ const data = await fs.readFile(filePath, "utf-8");
23
+ if (!data.trim()) {
24
+ return null;
25
+ }
26
+ 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;
34
+ }
35
+ catch (error) {
36
+ if (error instanceof SyntaxError) {
37
+ throw new Error("Roadmap file contains invalid JSON. File may be corrupted.");
38
+ }
39
+ if (error instanceof Error && error.message.includes("ENOENT")) {
40
+ return null;
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+ async write(roadmap) {
46
+ const filePath = join(this.directory, ROADMAP_FILE);
47
+ const tempPath = join(this.directory, `${ROADMAP_FILE}.tmp.${Date.now()}`);
48
+ 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) {
54
+ try {
55
+ await fs.unlink(tempPath);
56
+ }
57
+ catch {
58
+ // Ignore cleanup errors
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+ async archive() {
64
+ const filePath = join(this.directory, ROADMAP_FILE);
65
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
66
+ const archiveFilename = `roadmap.archive.${timestamp}.json`;
67
+ const archivePath = join(this.directory, archiveFilename);
68
+ await fs.rename(filePath, archivePath);
69
+ return archiveFilename;
70
+ }
71
+ }
72
+ export class RoadmapValidator {
73
+ static async validateFeatureNumber(number) {
74
+ if (!number || typeof number !== "string") {
75
+ return {
76
+ code: "INVALID_FEATURE_NUMBER",
77
+ message: await getErrorMessage("invalid_feature_id", { id: "undefined" }),
78
+ };
79
+ }
80
+ if (!/^\d+$/.test(number)) {
81
+ return {
82
+ code: "INVALID_FEATURE_NUMBER_FORMAT",
83
+ message: await getErrorMessage("invalid_feature_id", { id: number }),
84
+ };
85
+ }
86
+ return null;
87
+ }
88
+ static async validateActionNumber(number) {
89
+ if (!number || typeof number !== "string") {
90
+ return {
91
+ code: "INVALID_ACTION_NUMBER",
92
+ message: await getErrorMessage("invalid_action_id", { id: "undefined" }),
93
+ };
94
+ }
95
+ if (!/^\d+\.\d{2}$/.test(number)) {
96
+ return {
97
+ code: "INVALID_ACTION_NUMBER_FORMAT",
98
+ message: await getErrorMessage("invalid_action_id", { id: number }),
99
+ };
100
+ }
101
+ return null;
102
+ }
103
+ static async validateActionSequence(actions, globalSeenNumbers, featureNumber) {
104
+ const errors = [];
105
+ 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);
109
+ if (numberError) {
110
+ errors.push(numberError);
111
+ continue;
112
+ }
113
+ // Check action-feature mismatch
114
+ if (featureNumber) {
115
+ const actionFeaturePrefix = action.number.split('.')[0];
116
+ if (actionFeaturePrefix !== featureNumber) {
117
+ errors.push({
118
+ code: "ACTION_FEATURE_MISMATCH",
119
+ message: await getErrorMessage("action_mismatch", { action: action.number, feature: featureNumber }),
120
+ });
121
+ }
122
+ }
123
+ // Check for duplicates within this feature
124
+ if (seenNumbers.has(action.number)) {
125
+ errors.push({
126
+ code: "DUPLICATE_ACTION_NUMBER",
127
+ message: `Duplicate action ID "${action.number}".`,
128
+ });
129
+ }
130
+ // Check for global duplicates
131
+ if (globalSeenNumbers?.has(action.number)) {
132
+ errors.push({
133
+ code: "DUPLICATE_ACTION_NUMBER_GLOBAL",
134
+ message: `Duplicate action ID "${action.number}" (exists in another feature).`,
135
+ });
136
+ }
137
+ seenNumbers.add(action.number);
138
+ globalSeenNumbers?.add(action.number);
139
+ }
140
+ return errors;
141
+ }
142
+ static async validateFeatureSequence(features) {
143
+ const errors = [];
144
+ const seenNumbers = new Set();
145
+ 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);
149
+ if (numberError) {
150
+ errors.push(numberError);
151
+ continue;
152
+ }
153
+ if (seenNumbers.has(feature.number)) {
154
+ errors.push({
155
+ code: "DUPLICATE_FEATURE_NUMBER",
156
+ message: `Duplicate feature ID "${feature.number}".`,
157
+ });
158
+ }
159
+ seenNumbers.add(feature.number);
160
+ const actionErrors = await this.validateActionSequence(feature.actions, seenActionNumbers, feature.number);
161
+ errors.push(...actionErrors);
162
+ }
163
+ return errors;
164
+ }
165
+ static async validateTitle(title, fieldType) {
166
+ if (!title || typeof title !== "string") {
167
+ return {
168
+ code: "INVALID_TITLE",
169
+ message: `Invalid ${fieldType} title. Must be non-empty string.`,
170
+ };
171
+ }
172
+ if (title.trim() === "") {
173
+ return {
174
+ code: "EMPTY_TITLE",
175
+ message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} title cannot be empty.`,
176
+ };
177
+ }
178
+ return null;
179
+ }
180
+ static async validateDescription(description, fieldType) {
181
+ if (!description || typeof description !== "string") {
182
+ return {
183
+ code: "INVALID_DESCRIPTION",
184
+ message: `Invalid ${fieldType} description. Must be non-empty string.`,
185
+ };
186
+ }
187
+ if (description.trim() === "") {
188
+ return {
189
+ code: "EMPTY_DESCRIPTION",
190
+ message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} description cannot be empty.`,
191
+ };
192
+ }
193
+ return null;
194
+ }
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)) {
203
+ return {
204
+ 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
+ }),
210
+ };
211
+ }
212
+ return null;
213
+ }
214
+ }
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin";
2
+ export declare function createCreateRoadmapTool(directory: string): Promise<ToolDefinition>;
@@ -0,0 +1,135 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { FileStorage, RoadmapValidator } from "../storage.js";
3
+ import { loadDescription } from "../descriptions/index.js";
4
+ import { getErrorMessage } from "../errors/loader.js";
5
+ export async function createCreateRoadmapTool(directory) {
6
+ const description = await loadDescription("createroadmap.txt");
7
+ return tool({
8
+ description,
9
+ args: {
10
+ features: tool.schema
11
+ .array(tool.schema.object({
12
+ number: tool.schema.string().describe('Feature number as string ("1", "2", "3...")'),
13
+ title: tool.schema.string().describe("Feature title"),
14
+ description: tool.schema.string().describe("Brief description of what this feature accomplishes"),
15
+ actions: tool.schema
16
+ .array(tool.schema.object({
17
+ number: tool.schema
18
+ .string()
19
+ .describe('Action number as string with two decimals ("1.01", "1.02", etc.)'),
20
+ description: tool.schema.string().describe("Action description"),
21
+ status: tool.schema.enum(["pending"]).describe('Initial action status (must be "pending")'),
22
+ }))
23
+ .describe("List of actions for this feature in order"),
24
+ }))
25
+ .describe("Array of features for roadmap"),
26
+ },
27
+ async execute(args) {
28
+ const storage = new FileStorage(directory);
29
+ let roadmap;
30
+ let isUpdate = false;
31
+ if (await storage.exists()) {
32
+ const existing = await storage.read();
33
+ if (!existing) {
34
+ throw new Error("Existing roadmap file is corrupted. Please fix manually.");
35
+ }
36
+ roadmap = existing;
37
+ isUpdate = true;
38
+ }
39
+ else {
40
+ roadmap = { features: [] };
41
+ }
42
+ if (!args.features || args.features.length === 0) {
43
+ throw new Error('Roadmap must have at least one feature with at least one action. Example: {"features": [{"number": "1", "title": "Feature 1", "description": "Description", "actions": [{"number": "1.01", "description": "Action 1", "status": "pending"}]}]}');
44
+ }
45
+ const validationErrors = [];
46
+ // First pass: structural validation of input
47
+ for (const feature of args.features) {
48
+ if (!feature.actions || feature.actions.length === 0) {
49
+ throw new Error(`Feature "${feature.number}" must have at least one action. Each feature needs at least one action to be valid.`);
50
+ }
51
+ const titleError = await RoadmapValidator.validateTitle(feature.title, "feature");
52
+ if (titleError)
53
+ validationErrors.push(titleError);
54
+ const descError = await RoadmapValidator.validateDescription(feature.description, "feature");
55
+ if (descError)
56
+ validationErrors.push(descError);
57
+ for (const action of feature.actions) {
58
+ const actionTitleError = await RoadmapValidator.validateTitle(action.description, "action");
59
+ if (actionTitleError)
60
+ validationErrors.push(actionTitleError);
61
+ }
62
+ }
63
+ // Validate sequence consistency of input (internal consistency)
64
+ const sequenceErrors = await RoadmapValidator.validateFeatureSequence(args.features);
65
+ validationErrors.push(...sequenceErrors);
66
+ if (validationErrors.length > 0) {
67
+ const errorMessages = validationErrors.map((err) => err.message).join("\n");
68
+ throw new Error(`Validation errors:\n${errorMessages}\n\nPlease fix these issues and try again.`);
69
+ }
70
+ // Merge Logic
71
+ for (const inputFeature of args.features) {
72
+ const existingFeature = roadmap.features.find((f) => f.number === inputFeature.number);
73
+ if (existingFeature) {
74
+ // Feature exists: Validate Immutability
75
+ if (existingFeature.title !== inputFeature.title || existingFeature.description !== inputFeature.description) {
76
+ const msg = await getErrorMessage("immutable_feature", {
77
+ id: inputFeature.number,
78
+ oldTitle: existingFeature.title,
79
+ oldDesc: existingFeature.description,
80
+ newTitle: inputFeature.title,
81
+ newDesc: inputFeature.description
82
+ });
83
+ throw new Error(msg);
84
+ }
85
+ // Process Actions
86
+ for (const inputAction of inputFeature.actions) {
87
+ const existingAction = existingFeature.actions.find((a) => a.number === inputAction.number);
88
+ if (existingAction) {
89
+ // Action exists: skip (immutable)
90
+ continue;
91
+ }
92
+ else {
93
+ // New Action: Append
94
+ existingFeature.actions.push({
95
+ number: inputAction.number,
96
+ description: inputAction.description,
97
+ status: inputAction.status,
98
+ });
99
+ // Sort actions to ensure order
100
+ existingFeature.actions.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
101
+ }
102
+ }
103
+ }
104
+ else {
105
+ // New Feature: Append
106
+ roadmap.features.push({
107
+ number: inputFeature.number,
108
+ title: inputFeature.title,
109
+ description: inputFeature.description,
110
+ actions: inputFeature.actions.map((a) => ({
111
+ number: a.number,
112
+ description: a.description,
113
+ status: a.status,
114
+ })),
115
+ });
116
+ }
117
+ }
118
+ // Final Sort of Features
119
+ roadmap.features.sort((a, b) => parseInt(a.number) - parseInt(b.number));
120
+ // Final Validation of the Merged Roadmap
121
+ const finalErrors = await RoadmapValidator.validateFeatureSequence(roadmap.features);
122
+ if (finalErrors.length > 0) {
123
+ throw new Error(`Resulting roadmap would be invalid:\n${finalErrors.map(e => e.message).join("\n")}`);
124
+ }
125
+ await storage.write(roadmap);
126
+ const totalActions = roadmap.features.reduce((sum, feature) => sum + feature.actions.length, 0);
127
+ const action = isUpdate ? "Updated" : "Created";
128
+ const summary = `${action} roadmap with ${roadmap.features.length} features and ${totalActions} actions:\n` +
129
+ roadmap.features
130
+ .map((feature) => ` Feature ${feature.number}: ${feature.title} (${feature.actions.length} actions)`)
131
+ .join("\n");
132
+ return summary;
133
+ },
134
+ });
135
+ }
@@ -0,0 +1,2 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin";
2
+ export declare function createReadRoadmapTool(directory: string): Promise<ToolDefinition>;