@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.
@@ -3,7 +3,8 @@
3
3
  * Supports merge logic for adding new features/actions to existing roadmaps.
4
4
  */
5
5
  import { tool } from "@opencode-ai/plugin";
6
- import { FileStorage, RoadmapValidator } from "../storage.js";
6
+ import { FileStorage } from "../storage.js";
7
+ import { RoadmapValidator } from "../validators.js";
7
8
  import { loadDescription } from "../descriptions/index.js";
8
9
  import { getErrorMessage } from "../errors/loader.js";
9
10
  export async function createCreateRoadmapTool(directory) {
@@ -11,6 +12,8 @@ export async function createCreateRoadmapTool(directory) {
11
12
  return tool({
12
13
  description,
13
14
  args: {
15
+ feature: tool.schema.string().describe("Short feature label for the roadmap"),
16
+ spec: tool.schema.string().describe("Overall spec and goals in natural language"),
14
17
  features: tool.schema
15
18
  .array(tool.schema.object({
16
19
  number: tool.schema.string().describe('Feature number as string ("1", "2", "3...")'),
@@ -30,117 +33,128 @@ export async function createCreateRoadmapTool(directory) {
30
33
  },
31
34
  async execute(args) {
32
35
  const storage = new FileStorage(directory);
33
- let roadmap;
34
- let isUpdate = false;
35
- if (await storage.exists()) {
36
- const existing = await storage.read();
37
- if (!existing) {
38
- throw new Error("Existing roadmap file is corrupted. Please fix manually.");
39
- }
40
- roadmap = existing;
41
- isUpdate = true;
36
+ const featureError = RoadmapValidator.validateTitle(args.feature, "feature");
37
+ if (featureError) {
38
+ throw new Error(featureError.message);
42
39
  }
43
- else {
44
- roadmap = { features: [] };
40
+ const specError = RoadmapValidator.validateDescription(args.spec, "feature");
41
+ if (specError) {
42
+ throw new Error(specError.message);
45
43
  }
46
44
  if (!args.features || args.features.length === 0) {
47
- 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"}]}]}');
45
+ throw new Error('Roadmap must have at least one feature with at least one action. Example: {"feature":"Core","spec":"Project goals...","features": [{"number": "1", "title": "Feature 1", "description": "Description", "actions": [{"number": "1.01", "description": "Action 1", "status": "pending"}]}]}');
48
46
  }
49
- const validationErrors = [];
50
- // First pass: structural validation of input
51
- for (const feature of args.features) {
52
- if (!feature.actions || feature.actions.length === 0) {
53
- throw new Error(`Feature "${feature.number}" must have at least one action. Each feature needs at least one action to be valid.`);
47
+ return await storage.update(async (current) => {
48
+ const roadmap = current ? current.roadmap : { features: [] };
49
+ const isUpdate = current !== null;
50
+ const validationErrors = [];
51
+ if (current && current.feature !== args.feature) {
52
+ throw new Error("Feature label does not match existing roadmap.");
54
53
  }
55
- const titleError = RoadmapValidator.validateTitle(feature.title, "feature");
56
- if (titleError)
57
- validationErrors.push(titleError);
58
- const descError = RoadmapValidator.validateDescription(feature.description, "feature");
59
- if (descError)
60
- validationErrors.push(descError);
61
- for (const action of feature.actions) {
62
- const actionTitleError = RoadmapValidator.validateTitle(action.description, "action");
63
- if (actionTitleError)
64
- validationErrors.push(actionTitleError);
54
+ if (current && current.spec !== args.spec) {
55
+ throw new Error("Spec does not match existing roadmap.");
65
56
  }
66
- }
67
- // Validate sequence consistency of input (internal consistency)
68
- const sequenceErrors = RoadmapValidator.validateFeatureSequence(args.features);
69
- validationErrors.push(...sequenceErrors);
70
- if (validationErrors.length > 0) {
71
- const errorMessages = validationErrors.map((err) => err.message).join("\n");
72
- throw new Error(`Validation errors:\n${errorMessages}\n\nPlease fix these issues and try again.`);
73
- }
74
- // Merge Logic
75
- for (const inputFeature of args.features) {
76
- const existingFeature = roadmap.features.find((f) => f.number === inputFeature.number);
77
- if (existingFeature) {
78
- // Feature exists: Validate Immutability
79
- if (existingFeature.title !== inputFeature.title ||
80
- existingFeature.description !== inputFeature.description) {
81
- const msg = await getErrorMessage("immutable_feature", {
82
- id: inputFeature.number,
83
- oldTitle: existingFeature.title,
84
- oldDesc: existingFeature.description,
85
- newTitle: inputFeature.title,
86
- newDesc: inputFeature.description,
87
- });
88
- throw new Error(msg);
57
+ // First pass: structural validation of input
58
+ for (const feature of args.features) {
59
+ if (!feature.actions || feature.actions.length === 0) {
60
+ throw new Error(`Feature "${feature.number}" must have at least one action. Each feature needs at least one action to be valid.`);
89
61
  }
90
- // Process Actions
91
- for (const inputAction of inputFeature.actions) {
92
- const existingAction = existingFeature.actions.find((a) => a.number === inputAction.number);
93
- if (existingAction) {
94
- // Action exists: skip (immutable)
95
- continue;
96
- }
97
- else {
98
- // New Action: Append
99
- existingFeature.actions.push({
100
- number: inputAction.number,
101
- description: inputAction.description,
102
- status: inputAction.status,
62
+ const titleError = RoadmapValidator.validateTitle(feature.title, "feature");
63
+ if (titleError)
64
+ validationErrors.push(titleError);
65
+ const descError = RoadmapValidator.validateDescription(feature.description, "feature");
66
+ if (descError)
67
+ validationErrors.push(descError);
68
+ for (const action of feature.actions) {
69
+ const actionTitleError = RoadmapValidator.validateTitle(action.description, "action");
70
+ if (actionTitleError)
71
+ validationErrors.push(actionTitleError);
72
+ }
73
+ }
74
+ // Validate sequence consistency of input (internal consistency)
75
+ const sequenceErrors = RoadmapValidator.validateFeatureSequence(args.features);
76
+ validationErrors.push(...sequenceErrors);
77
+ if (validationErrors.length > 0) {
78
+ const errorMessages = validationErrors.map((err) => err.message).join("\n");
79
+ throw new Error(`Validation errors:\n${errorMessages}\n\nPlease fix these issues and try again.`);
80
+ }
81
+ // Merge Logic
82
+ for (const inputFeature of args.features) {
83
+ const existingFeature = roadmap.features.find((f) => f.number === inputFeature.number);
84
+ if (existingFeature) {
85
+ // Feature exists: Validate Immutability
86
+ if (existingFeature.title !== inputFeature.title ||
87
+ existingFeature.description !== inputFeature.description) {
88
+ const msg = await getErrorMessage("immutable_feature", {
89
+ id: inputFeature.number,
90
+ oldTitle: existingFeature.title,
91
+ oldDesc: existingFeature.description,
92
+ newTitle: inputFeature.title,
93
+ newDesc: inputFeature.description,
103
94
  });
104
- // Sort actions to ensure order
105
- existingFeature.actions.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
95
+ throw new Error(msg);
106
96
  }
97
+ // Process Actions
98
+ for (const inputAction of inputFeature.actions) {
99
+ const existingAction = existingFeature.actions.find((a) => a.number === inputAction.number);
100
+ if (existingAction) {
101
+ // Action exists: skip (immutable)
102
+ continue;
103
+ }
104
+ else {
105
+ // New Action: Append
106
+ existingFeature.actions.push({
107
+ number: inputAction.number,
108
+ description: inputAction.description,
109
+ status: inputAction.status,
110
+ });
111
+ // Sort actions to ensure order
112
+ existingFeature.actions.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
113
+ }
114
+ }
115
+ }
116
+ else {
117
+ // New Feature: Append
118
+ roadmap.features.push({
119
+ number: inputFeature.number,
120
+ title: inputFeature.title,
121
+ description: inputFeature.description,
122
+ actions: inputFeature.actions.map((a) => ({
123
+ number: a.number,
124
+ description: a.description,
125
+ status: a.status,
126
+ })),
127
+ });
107
128
  }
108
129
  }
109
- else {
110
- // New Feature: Append
111
- roadmap.features.push({
112
- number: inputFeature.number,
113
- title: inputFeature.title,
114
- description: inputFeature.description,
115
- actions: inputFeature.actions.map((a) => ({
116
- number: a.number,
117
- description: a.description,
118
- status: a.status,
119
- })),
120
- });
130
+ // Final Sort of Features
131
+ roadmap.features.sort((a, b) => parseInt(a.number) - parseInt(b.number));
132
+ // Safety check: ensure no feature ended up with zero actions after merge
133
+ for (const feature of roadmap.features) {
134
+ if (feature.actions.length === 0) {
135
+ throw new Error(`Feature "${feature.number}" has no actions. This indicates a merge error.`);
136
+ }
121
137
  }
122
- }
123
- // Final Sort of Features
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.`);
138
+ // Final Validation of the Merged Roadmap
139
+ const finalErrors = RoadmapValidator.validateFeatureSequence(roadmap.features);
140
+ if (finalErrors.length > 0) {
141
+ throw new Error(`Resulting roadmap would be invalid:\n${finalErrors.map((e) => e.message).join("\n")}`);
129
142
  }
130
- }
131
- // Final Validation of the Merged Roadmap
132
- const finalErrors = RoadmapValidator.validateFeatureSequence(roadmap.features);
133
- if (finalErrors.length > 0) {
134
- throw new Error(`Resulting roadmap would be invalid:\n${finalErrors.map((e) => e.message).join("\n")}`);
135
- }
136
- await storage.write(roadmap);
137
- const totalActions = roadmap.features.reduce((sum, feature) => sum + feature.actions.length, 0);
138
- const action = isUpdate ? "Updated" : "Created";
139
- const summary = `${action} roadmap with ${roadmap.features.length} features and ${totalActions} actions:\n` +
140
- roadmap.features
141
- .map((feature) => ` Feature ${feature.number}: ${feature.title} (${feature.actions.length} actions)`)
142
- .join("\n");
143
- return summary;
143
+ const totalActions = roadmap.features.reduce((sum, feature) => sum + feature.actions.length, 0);
144
+ const action = isUpdate ? "Updated" : "Created";
145
+ const summary = `${action} roadmap with ${roadmap.features.length} features and ${totalActions} actions:\n` +
146
+ roadmap.features
147
+ .map((feature) => ` Feature ${feature.number}: ${feature.title} (${feature.actions.length} actions)`)
148
+ .join("\n");
149
+ return {
150
+ document: {
151
+ feature: args.feature,
152
+ spec: args.spec,
153
+ roadmap,
154
+ },
155
+ buildResult: () => summary,
156
+ };
157
+ });
144
158
  },
145
159
  });
146
160
  }
@@ -3,7 +3,8 @@
3
3
  * Supports filtering by feature or action number.
4
4
  */
5
5
  import { tool } from "@opencode-ai/plugin";
6
- import { FileStorage, RoadmapValidator } from "../storage.js";
6
+ import { FileStorage } from "../storage.js";
7
+ import { RoadmapValidator } from "../validators.js";
7
8
  import { loadDescription } from "../descriptions/index.js";
8
9
  export async function createReadRoadmapTool(directory) {
9
10
  const description = await loadDescription("readroadmap.txt");
@@ -24,10 +25,11 @@ export async function createReadRoadmapTool(directory) {
24
25
  if (!(await storage.exists())) {
25
26
  throw new Error("Roadmap not found. Use CreateRoadmap to create one.");
26
27
  }
27
- const roadmap = await storage.read();
28
- if (!roadmap) {
29
- throw new Error("Roadmap file is corrupted. Please fix manually.");
28
+ const document = await storage.read();
29
+ if (!document) {
30
+ throw new Error("Roadmap data is corrupted. Please fix manually.");
30
31
  }
32
+ const roadmap = document.roadmap;
31
33
  if (args.actionNumber && args.featureNumber) {
32
34
  throw new Error("Cannot specify both actionNumber and featureNumber. Use one or the other, or neither for full roadmap.");
33
35
  }
@@ -73,6 +75,8 @@ export async function createReadRoadmapTool(directory) {
73
75
  const pendingActions = totalActions - completedActions - inProgressActions;
74
76
  let output = `Project Roadmap Overview\n` +
75
77
  `========================\n` +
78
+ `Feature: ${document.feature}\n` +
79
+ `Spec:\n${document.spec}\n\n` +
76
80
  `Features: ${roadmap.features.length}\n` +
77
81
  `Total Actions: ${totalActions}\n` +
78
82
  `Progress: ${completedActions} completed, ${inProgressActions} in progress, ${pendingActions} pending\n\n`;
@@ -3,7 +3,8 @@
3
3
  * Enforces forward-only status progression and archives when complete.
4
4
  */
5
5
  import { tool } from "@opencode-ai/plugin";
6
- import { FileStorage, RoadmapValidator } from "../storage.js";
6
+ import { FileStorage } from "../storage.js";
7
+ import { RoadmapValidator } from "../validators.js";
7
8
  import { loadDescription } from "../descriptions/index.js";
8
9
  export async function createUpdateRoadmapTool(directory) {
9
10
  const description = await loadDescription("updateroadmap.txt");
@@ -22,96 +23,106 @@ export async function createUpdateRoadmapTool(directory) {
22
23
  },
23
24
  async execute(args) {
24
25
  const storage = new FileStorage(directory);
25
- if (!(await storage.exists())) {
26
- throw new Error("Roadmap not found. Use CreateRoadmap to create one.");
27
- }
28
- const roadmap = await storage.read();
29
- if (!roadmap) {
30
- throw new Error("Roadmap file is corrupted. Please fix manually.");
31
- }
32
26
  const actionNumberError = RoadmapValidator.validateActionNumber(args.actionNumber);
33
27
  if (actionNumberError) {
34
28
  throw new Error(`${actionNumberError.message} Use ReadRoadmap to see valid action numbers.`);
35
29
  }
36
- let targetAction = null;
37
- let targetFeature = null;
38
- let actionFound = false;
39
- for (const feature of roadmap.features) {
40
- const action = feature.actions.find((a) => a.number === args.actionNumber);
41
- if (action) {
42
- targetAction = action;
43
- targetFeature = feature;
44
- actionFound = true;
45
- break;
30
+ return await storage.update((document) => {
31
+ if (!document) {
32
+ throw new Error("Roadmap not found. Use CreateRoadmap to create one.");
46
33
  }
47
- }
48
- if (!actionFound) {
49
- throw new Error(`Action "${args.actionNumber}" not found. Use ReadRoadmap to see valid action numbers.`);
50
- }
51
- // TypeScript: we know targetAction and targetFeature are not null here
52
- if (!targetAction || !targetFeature) {
53
- throw new Error("Internal error: target action not found.");
54
- }
55
- // Validate that at least one field is being updated
56
- if (args.description === undefined && args.status === undefined) {
57
- throw new Error("No changes specified. Please provide description and/or status.");
58
- }
59
- const oldStatus = targetAction.status;
60
- const oldDescription = targetAction.description;
61
- // Validate description if provided
62
- if (args.description !== undefined) {
63
- const descError = RoadmapValidator.validateDescription(args.description, "action");
64
- if (descError) {
65
- throw new Error(`${descError.message}`);
34
+ const roadmap = document.roadmap;
35
+ let targetAction = null;
36
+ let targetFeature = null;
37
+ let actionFound = false;
38
+ for (const feature of roadmap.features) {
39
+ const action = feature.actions.find((a) => a.number === args.actionNumber);
40
+ if (action) {
41
+ targetAction = action;
42
+ targetFeature = feature;
43
+ actionFound = true;
44
+ break;
45
+ }
66
46
  }
67
- targetAction.description = args.description;
68
- }
69
- // Validate and update status if provided
70
- if (args.status !== undefined) {
71
- const statusTransitionError = RoadmapValidator.validateStatusProgression(targetAction.status, args.status);
72
- if (statusTransitionError) {
73
- throw new Error(`${statusTransitionError.message} Current status: "${targetAction.status}", requested: "${args.status}"`);
47
+ if (!actionFound) {
48
+ throw new Error(`Action "${args.actionNumber}" not found. Use ReadRoadmap to see valid action numbers.`);
74
49
  }
75
- targetAction.status = args.status;
76
- }
77
- await storage.write(roadmap);
78
- const changes = [];
79
- if (args.description !== undefined && oldDescription !== args.description) {
80
- changes.push(`description updated`);
81
- }
82
- if (args.status !== undefined && oldStatus !== args.status) {
83
- changes.push(`status: "${oldStatus}" → "${args.status}"`);
84
- }
85
- if (changes.length === 0) {
86
- return `Action ${args.actionNumber} unchanged. Provided values match current state.`;
87
- }
88
- // Check if all actions are completed
89
- let allCompleted = true;
90
- for (const feature of roadmap.features) {
91
- for (const action of feature.actions) {
92
- if (action.status !== "completed") {
93
- allCompleted = false;
94
- break;
50
+ if (!targetAction || !targetFeature) {
51
+ throw new Error("Internal error: target action not found.");
52
+ }
53
+ // Validate that at least one field is being updated
54
+ if (args.description === undefined && args.status === undefined) {
55
+ throw new Error("No changes specified. Please provide description and/or status.");
56
+ }
57
+ const oldStatus = targetAction.status;
58
+ const oldDescription = targetAction.description;
59
+ // Validate description if provided
60
+ if (args.description !== undefined) {
61
+ const descError = RoadmapValidator.validateDescription(args.description, "action");
62
+ if (descError) {
63
+ throw new Error(`${descError.message}`);
95
64
  }
65
+ targetAction.description = args.description;
96
66
  }
97
- if (!allCompleted)
98
- break;
99
- }
100
- let archiveMsg = "";
101
- if (allCompleted) {
102
- const archiveName = await storage.archive();
103
- archiveMsg = `\n\nšŸŽ‰ All actions completed! Roadmap archived to "${archiveName}".`;
104
- }
105
- // Format feature context
106
- const featureCompleted = targetFeature.actions.filter((a) => a.status === "completed").length;
107
- const featureTotal = targetFeature.actions.length;
108
- let featureContext = `\n\nFeature ${targetFeature.number}: ${targetFeature.title} (${featureCompleted}/${featureTotal} complete)\n`;
109
- featureContext += `Description: ${targetFeature.description}\n`;
110
- for (const action of targetFeature.actions) {
111
- const statusIcon = action.status === "completed" ? "āœ“" : action.status === "in_progress" ? "→" : "ā—‹";
112
- featureContext += `${action.number} ${statusIcon} ${action.description} [${action.status}]\n`;
113
- }
114
- return `Updated action ${args.actionNumber} in feature "${targetFeature.title}": ${changes.join(", ")}${featureContext}${archiveMsg}`;
67
+ // Validate and update status if provided
68
+ if (args.status !== undefined) {
69
+ const statusTransitionError = RoadmapValidator.validateStatusProgression(targetAction.status, args.status);
70
+ if (statusTransitionError) {
71
+ throw new Error(`${statusTransitionError.message} Current status: "${targetAction.status}", requested: "${args.status}"`);
72
+ }
73
+ targetAction.status = args.status;
74
+ }
75
+ const changes = [];
76
+ if (args.description !== undefined && oldDescription !== args.description) {
77
+ changes.push("description updated");
78
+ }
79
+ if (args.status !== undefined && oldStatus !== args.status) {
80
+ changes.push(`status: "${oldStatus}" → "${args.status}"`);
81
+ }
82
+ if (changes.length === 0) {
83
+ return {
84
+ document: {
85
+ feature: document.feature,
86
+ spec: document.spec,
87
+ roadmap,
88
+ },
89
+ buildResult: () => `Action ${args.actionNumber} unchanged. Provided values match current state.`,
90
+ };
91
+ }
92
+ // Check if all actions are completed
93
+ let allCompleted = true;
94
+ for (const feature of roadmap.features) {
95
+ for (const action of feature.actions) {
96
+ if (action.status !== "completed") {
97
+ allCompleted = false;
98
+ break;
99
+ }
100
+ }
101
+ if (!allCompleted)
102
+ break;
103
+ }
104
+ // Format feature context
105
+ const featureCompleted = targetFeature.actions.filter((a) => a.status === "completed").length;
106
+ const featureTotal = targetFeature.actions.length;
107
+ let featureContext = `\n\nFeature ${targetFeature.number}: ${targetFeature.title} (${featureCompleted}/${featureTotal} complete)\n`;
108
+ featureContext += `Description: ${targetFeature.description}\n`;
109
+ for (const action of targetFeature.actions) {
110
+ const statusIcon = action.status === "completed" ? "āœ“" : action.status === "in_progress" ? "→" : "ā—‹";
111
+ featureContext += `${action.number} ${statusIcon} ${action.description} [${action.status}]\n`;
112
+ }
113
+ return {
114
+ document: {
115
+ feature: document.feature,
116
+ spec: document.spec,
117
+ roadmap,
118
+ },
119
+ archive: allCompleted,
120
+ buildResult: (archiveName) => {
121
+ const archiveMsg = archiveName ? `\n\nAll actions completed! Roadmap archived to "${archiveName}".` : "";
122
+ return `Updated action ${args.actionNumber} in feature "${targetFeature.title}": ${changes.join(", ")}${featureContext}${archiveMsg}`;
123
+ },
124
+ };
125
+ });
115
126
  },
116
127
  });
117
128
  }
@@ -113,9 +113,14 @@ export declare const Roadmap: z.ZodObject<{
113
113
  }[];
114
114
  }>;
115
115
  export type Roadmap = z.infer<typeof Roadmap>;
116
+ export type RoadmapDocument = {
117
+ feature: string;
118
+ spec: string;
119
+ roadmap: Roadmap;
120
+ };
116
121
  export interface RoadmapStorage {
117
- read(): Promise<Roadmap | null>;
118
- write(roadmap: Roadmap): Promise<void>;
122
+ read(): Promise<RoadmapDocument | null>;
123
+ write(document: RoadmapDocument): Promise<void>;
119
124
  exists(): Promise<boolean>;
120
125
  archive(): Promise<string>;
121
126
  }
@@ -129,6 +134,8 @@ export interface ValidationResult {
129
134
  errors: ValidationError[];
130
135
  }
131
136
  export interface CreateRoadmapInput {
137
+ feature: string;
138
+ spec: string;
132
139
  features: {
133
140
  number: string;
134
141
  title: string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Validates roadmap identifiers and text fields.
3
+ * Keeps validation concerns separate from storage and tools.
4
+ */
5
+ import type { ValidationError } from "./types.js";
6
+ export declare class RoadmapValidator {
7
+ static validateFeatureNumber(number: string): ValidationError | null;
8
+ static validateActionNumber(number: string): ValidationError | null;
9
+ static validateActionSequence(actions: {
10
+ number: string;
11
+ }[], globalSeenNumbers?: Set<string>, featureNumber?: string): ValidationError[];
12
+ static validateFeatureSequence(features: {
13
+ number: string;
14
+ actions: {
15
+ number: string;
16
+ }[];
17
+ }[]): ValidationError[];
18
+ static validateTitle(title: string, fieldType: "feature" | "action"): ValidationError | null;
19
+ static validateDescription(description: string, fieldType: "feature" | "action"): ValidationError | null;
20
+ static validateStatusProgression(currentStatus: string, newStatus: string): ValidationError | null;
21
+ }