@howaboua/opencode-roadmap-plugin 0.1.9 → 0.2.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/README.md
CHANGED
|
@@ -30,14 +30,14 @@ OpenCode installs it automatically on next launch.
|
|
|
30
30
|
|
|
31
31
|
### `createroadmap`
|
|
32
32
|
|
|
33
|
-
Create or extend a project roadmap.
|
|
33
|
+
Create or extend a project roadmap. Requires a feature list and a short spec for each feature.
|
|
34
34
|
|
|
35
35
|
```
|
|
36
|
-
"Create a roadmap
|
|
36
|
+
"Create a roadmap with features: 1) Auth, 2) Profiles. Specs: Auth uses OAuth and must support password reset; Profiles needs avatar uploads and privacy settings"
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
- Features group related work (`"1"`, `"2"`, `"3"`)
|
|
40
|
-
- Actions are
|
|
39
|
+
- Features group related work (`"1"`, `"2"`, `"3"`) and include a brief spec
|
|
40
|
+
- Actions are markdown task list items (`- [ ] 1.01 ...`) within features
|
|
41
41
|
- New actions always start as `pending`
|
|
42
42
|
- Append-only: existing IDs never change
|
|
43
43
|
|
|
@@ -54,11 +54,11 @@ Before delegating work to Task tool subagents, instruct them to read the roadmap
|
|
|
54
54
|
|
|
55
55
|
### `updateroadmap`
|
|
56
56
|
|
|
57
|
-
Change action status or description.
|
|
57
|
+
Change action status or description. Each update includes a brief note appended to the updates section.
|
|
58
58
|
|
|
59
59
|
```
|
|
60
|
-
"Mark action 1.01 as in_progress"
|
|
61
|
-
"Action 2.03 is completed"
|
|
60
|
+
"Mark action 1.01 as in_progress — Drafted schema notes"
|
|
61
|
+
"Action 2.03 is completed — Added tests for edge cases"
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
**Statuses:** `pending` → `in_progress` → `completed` | `cancelled`
|
|
@@ -92,8 +92,8 @@ AI: Reads roadmap → sees Feature 1 has 4 actions → uses todowrite for immedi
|
|
|
92
92
|
|
|
93
93
|
## Storage
|
|
94
94
|
|
|
95
|
-
- **Active:**
|
|
96
|
-
- **Archived:**
|
|
95
|
+
- **Active:** Stored as a markdown roadmap alongside the project
|
|
96
|
+
- **Archived:** Snapshot archived when complete
|
|
97
97
|
|
|
98
98
|
## License
|
|
99
99
|
|
|
@@ -12,8 +12,8 @@ Structure: Features contain Actions. IDs are immutable once created.
|
|
|
12
12
|
|
|
13
13
|
Inputs:
|
|
14
14
|
- feature: short label for the overall roadmap
|
|
15
|
-
- spec: natural-language spec for the overall direction
|
|
16
|
-
- features/actions: structured tasks with numbered IDs
|
|
15
|
+
- spec: natural-language spec for the overall direction (scope, constraints, success criteria)
|
|
16
|
+
- features/actions: structured tasks with numbered IDs and clear, detailed descriptions
|
|
17
17
|
|
|
18
18
|
Example:
|
|
19
19
|
{
|
|
@@ -7,4 +7,4 @@ Use after completing work on an action. When delegating to Task tool subagents,
|
|
|
7
7
|
|
|
8
8
|
Archives roadmap automatically when all actions reach completed.
|
|
9
9
|
|
|
10
|
-
Input: actionNumber (required), status (optional), description (optional).
|
|
10
|
+
Input: actionNumber (required), note (required), status (optional), description (optional).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parses and renders the roadmap markdown document.
|
|
3
|
-
* Keeps frontmatter simple and the task
|
|
3
|
+
* Keeps frontmatter simple and the task list machine-readable.
|
|
4
4
|
* Exposes a narrow API for the storage layer.
|
|
5
5
|
*/
|
|
6
6
|
import type { RoadmapDocument } from "../types.js";
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { Roadmap as RoadmapSchema } from "../types.js";
|
|
2
2
|
const FRONTMATTER_START = "---\n";
|
|
3
3
|
const FRONTMATTER_END = "\n---\n";
|
|
4
|
-
const
|
|
5
|
-
const
|
|
4
|
+
const TASK_LIST_HEADER = "## Task List";
|
|
5
|
+
const STATUS_MAP = {
|
|
6
|
+
" ": "pending",
|
|
7
|
+
x: "completed",
|
|
8
|
+
X: "completed",
|
|
9
|
+
"~": "in_progress",
|
|
10
|
+
"-": "cancelled",
|
|
11
|
+
};
|
|
6
12
|
export const parseDocument = (data) => {
|
|
7
13
|
const { frontmatter, body } = splitFrontmatter(data);
|
|
8
14
|
const { feature, spec } = parseFrontmatter(frontmatter);
|
|
9
|
-
const roadmap =
|
|
15
|
+
const roadmap = parseTaskList(body);
|
|
10
16
|
return { feature, spec, roadmap };
|
|
11
17
|
};
|
|
12
18
|
export const buildDocument = (document) => {
|
|
13
19
|
const specValue = document.spec.trimEnd();
|
|
14
20
|
const specLines = specValue === "" ? [""] : specValue.split("\n");
|
|
15
21
|
const specBlock = specLines.map((line) => ` ${line}`).join("\n");
|
|
16
|
-
const
|
|
22
|
+
const taskList = buildTaskList(document.roadmap);
|
|
17
23
|
return [
|
|
18
24
|
"---",
|
|
19
25
|
`feature: ${JSON.stringify(document.feature)}`,
|
|
@@ -21,12 +27,19 @@ export const buildDocument = (document) => {
|
|
|
21
27
|
specBlock,
|
|
22
28
|
"---",
|
|
23
29
|
"",
|
|
24
|
-
|
|
25
|
-
tasks,
|
|
26
|
-
"```",
|
|
30
|
+
TASK_LIST_HEADER,
|
|
27
31
|
"",
|
|
32
|
+
taskList,
|
|
28
33
|
].join("\n");
|
|
29
34
|
};
|
|
35
|
+
export const ensureDocument = (document) => {
|
|
36
|
+
const validated = RoadmapSchema.parse(document.roadmap);
|
|
37
|
+
return {
|
|
38
|
+
feature: document.feature,
|
|
39
|
+
spec: document.spec,
|
|
40
|
+
roadmap: validated,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
30
43
|
const splitFrontmatter = (data) => {
|
|
31
44
|
if (!data.startsWith(FRONTMATTER_START)) {
|
|
32
45
|
throw new Error("Roadmap format is invalid. Missing frontmatter.");
|
|
@@ -90,28 +103,97 @@ const normalizeSpec = (lines) => {
|
|
|
90
103
|
const normalized = lines.map((line) => line.slice(indent));
|
|
91
104
|
return normalized.join("\n").trimEnd();
|
|
92
105
|
};
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
106
|
+
const parseTaskList = (body) => {
|
|
107
|
+
const lines = body.split("\n");
|
|
108
|
+
const features = [];
|
|
109
|
+
let currentFeature = null;
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const trimmed = line.trim();
|
|
112
|
+
if (trimmed === "" || trimmed === TASK_LIST_HEADER) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const featureMatch = trimmed.match(/^#+\s*Feature\s+(\d+)\s*:\s*(.+)$/);
|
|
116
|
+
if (featureMatch) {
|
|
117
|
+
if (currentFeature) {
|
|
118
|
+
features.push(currentFeature);
|
|
119
|
+
}
|
|
120
|
+
currentFeature = {
|
|
121
|
+
number: featureMatch[1],
|
|
122
|
+
title: featureMatch[2].trim(),
|
|
123
|
+
description: "",
|
|
124
|
+
actions: [],
|
|
125
|
+
};
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const descriptionMatch = trimmed.match(/^Description:\s*(.+)$/);
|
|
129
|
+
if (descriptionMatch) {
|
|
130
|
+
if (!currentFeature) {
|
|
131
|
+
throw new Error("Roadmap format is invalid. Description must follow a feature header.");
|
|
132
|
+
}
|
|
133
|
+
currentFeature.description = descriptionMatch[1].trim();
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const actionMatch = trimmed.match(/^-\s*\[([ xX~-])\]\s+(\d+\.\d{2})\s+(.+)$/);
|
|
137
|
+
if (actionMatch) {
|
|
138
|
+
if (!currentFeature) {
|
|
139
|
+
throw new Error("Roadmap format is invalid. Action must follow a feature header.");
|
|
140
|
+
}
|
|
141
|
+
const statusToken = actionMatch[1];
|
|
142
|
+
const status = STATUS_MAP[statusToken];
|
|
143
|
+
if (!status) {
|
|
144
|
+
throw new Error("Roadmap format is invalid. Unsupported status marker.");
|
|
145
|
+
}
|
|
146
|
+
const number = actionMatch[2];
|
|
147
|
+
const numberPrefix = number.split(".")[0];
|
|
148
|
+
if (numberPrefix !== currentFeature.number) {
|
|
149
|
+
throw new Error(`Action "${number}" does not belong to feature "${currentFeature.number}".`);
|
|
150
|
+
}
|
|
151
|
+
currentFeature.actions.push({
|
|
152
|
+
number,
|
|
153
|
+
description: actionMatch[3].trim(),
|
|
154
|
+
status,
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
97
158
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
throw new Error("Roadmap format is invalid. Task block is incomplete.");
|
|
159
|
+
if (currentFeature) {
|
|
160
|
+
features.push(currentFeature);
|
|
101
161
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
162
|
+
if (features.length === 0) {
|
|
163
|
+
throw new Error("Roadmap format is invalid. Missing task list.");
|
|
164
|
+
}
|
|
165
|
+
for (const feature of features) {
|
|
166
|
+
if (!feature.description) {
|
|
167
|
+
throw new Error(`Feature "${feature.number}" is missing a description.`);
|
|
168
|
+
}
|
|
169
|
+
if (feature.actions.length === 0) {
|
|
170
|
+
throw new Error(`Feature "${feature.number}" must include at least one action.`);
|
|
171
|
+
}
|
|
105
172
|
}
|
|
106
|
-
|
|
107
|
-
const parsed = JSON.parse(jsonText);
|
|
108
|
-
return RoadmapSchema.parse(parsed);
|
|
173
|
+
return RoadmapSchema.parse({ features });
|
|
109
174
|
};
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
feature:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
175
|
+
const buildTaskList = (roadmap) => {
|
|
176
|
+
const lines = [];
|
|
177
|
+
for (const feature of roadmap.features) {
|
|
178
|
+
lines.push(`### Feature ${feature.number}: ${feature.title}`);
|
|
179
|
+
lines.push(`Description: ${feature.description}`);
|
|
180
|
+
for (const action of feature.actions) {
|
|
181
|
+
lines.push(`${renderAction(action.status)} ${action.number} ${action.description}`);
|
|
182
|
+
}
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
return lines.join("\n");
|
|
186
|
+
};
|
|
187
|
+
const renderAction = (status) => {
|
|
188
|
+
switch (status) {
|
|
189
|
+
case "completed":
|
|
190
|
+
return "- [x]";
|
|
191
|
+
case "in_progress":
|
|
192
|
+
return "- [~]";
|
|
193
|
+
case "cancelled":
|
|
194
|
+
return "- [-]";
|
|
195
|
+
case "pending":
|
|
196
|
+
default:
|
|
197
|
+
return "- [ ]";
|
|
198
|
+
}
|
|
117
199
|
};
|
|
@@ -20,6 +20,7 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
20
20
|
.enum(["pending", "in_progress", "completed", "cancelled"])
|
|
21
21
|
.optional()
|
|
22
22
|
.describe("New action status. Flexible transitions allowed except from cancelled."),
|
|
23
|
+
note: tool.schema.string().describe("Required update note to append to the action."),
|
|
23
24
|
},
|
|
24
25
|
async execute(args) {
|
|
25
26
|
const storage = new FileStorage(directory);
|
|
@@ -54,6 +55,10 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
54
55
|
if (args.description === undefined && args.status === undefined) {
|
|
55
56
|
throw new Error("No changes specified. Please provide description and/or status.");
|
|
56
57
|
}
|
|
58
|
+
const noteError = RoadmapValidator.validateDescription(args.note, "action");
|
|
59
|
+
if (noteError) {
|
|
60
|
+
throw new Error(`${noteError.message}`);
|
|
61
|
+
}
|
|
57
62
|
const oldStatus = targetAction.status;
|
|
58
63
|
const oldDescription = targetAction.description;
|
|
59
64
|
// Validate description if provided
|
|
@@ -64,6 +69,7 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
64
69
|
}
|
|
65
70
|
targetAction.description = args.description;
|
|
66
71
|
}
|
|
72
|
+
targetAction.description = `${targetAction.description} (note: ${args.note})`;
|
|
67
73
|
// Validate and update status if provided
|
|
68
74
|
if (args.status !== undefined) {
|
|
69
75
|
const statusTransitionError = RoadmapValidator.validateStatusProgression(targetAction.status, args.status);
|
|
@@ -79,6 +85,9 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
79
85
|
if (args.status !== undefined && oldStatus !== args.status) {
|
|
80
86
|
changes.push(`status: "${oldStatus}" → "${args.status}"`);
|
|
81
87
|
}
|
|
88
|
+
if (oldDescription !== targetAction.description) {
|
|
89
|
+
changes.push("note added");
|
|
90
|
+
}
|
|
82
91
|
if (changes.length === 0) {
|
|
83
92
|
return {
|
|
84
93
|
document: {
|