@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.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/descriptions/index.d.ts +1 -0
- package/dist/descriptions/index.js +1 -0
- package/dist/descriptions/loader.d.ts +1 -0
- package/dist/descriptions/loader.js +17 -0
- package/dist/errors/loader.d.ts +2 -0
- package/dist/errors/loader.js +24 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/descriptions/index.d.ts +1 -0
- package/dist/src/descriptions/index.js +1 -0
- package/dist/src/descriptions/loader.d.ts +1 -0
- package/dist/src/descriptions/loader.js +17 -0
- package/dist/src/errors/loader.d.ts +2 -0
- package/dist/src/errors/loader.js +24 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +13 -0
- package/dist/src/storage.d.ts +25 -0
- package/dist/src/storage.js +214 -0
- package/dist/src/tools/createroadmap.d.ts +2 -0
- package/dist/src/tools/createroadmap.js +135 -0
- package/dist/src/tools/readroadmap.d.ts +2 -0
- package/dist/src/tools/readroadmap.js +90 -0
- package/dist/src/tools/updateroadmap.d.ts +2 -0
- package/dist/src/tools/updateroadmap.js +107 -0
- package/dist/src/types.d.ts +151 -0
- package/dist/src/types.js +18 -0
- package/dist/storage.d.ts +25 -0
- package/dist/storage.js +214 -0
- package/dist/tools/createroadmap.d.ts +2 -0
- package/dist/tools/createroadmap.js +135 -0
- package/dist/tools/readroadmap.d.ts +2 -0
- package/dist/tools/readroadmap.js +90 -0
- package/dist/tools/updateroadmap.d.ts +2 -0
- package/dist/tools/updateroadmap.js +107 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.js +18 -0
- 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,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
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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,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,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,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
|
+
}
|