@howaboua/opencode-roadmap-plugin 0.1.7 → 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 +9 -9
- package/dist/src/descriptions/createroadmap.txt +11 -7
- package/dist/src/descriptions/readroadmap.txt +3 -3
- package/dist/src/descriptions/updateroadmap.txt +2 -2
- package/dist/src/errors/roadmap_corrupted.txt +1 -1
- package/dist/src/roadmap/document.d.ts +9 -0
- package/dist/src/roadmap/document.js +199 -0
- package/dist/src/roadmap/files.d.ts +4 -0
- package/dist/src/roadmap/files.js +49 -0
- package/dist/src/roadmap/lock.d.ts +1 -0
- package/dist/src/roadmap/lock.js +33 -0
- package/dist/src/roadmap/paths.d.ts +7 -0
- package/dist/src/roadmap/paths.js +12 -0
- package/dist/src/storage.d.ts +6 -26
- package/dist/src/storage.js +39 -247
- package/dist/src/tools/createroadmap.js +25 -4
- package/dist/src/tools/readroadmap.js +8 -4
- package/dist/src/tools/updateroadmap.js +24 -5
- package/dist/src/types.d.ts +9 -2
- package/dist/src/validators.d.ts +21 -0
- package/dist/src/validators.js +135 -0
- package/package.json +1 -1
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
|
|
|
@@ -1,20 +1,24 @@
|
|
|
1
|
-
Establish a durable project roadmap
|
|
1
|
+
Establish a durable project roadmap with a high-level spec and task list. Provides shared context across sessions and Task tool subagents.
|
|
2
2
|
|
|
3
3
|
Use when:
|
|
4
|
-
-
|
|
5
|
-
-
|
|
4
|
+
- After a heavy planning session to lock in the spec and task list
|
|
5
|
+
- When launching Task tool subagents that need shared context
|
|
6
|
+
- User says "roadmap", "plan the project", "phases", or "milestones"
|
|
6
7
|
- Deliverables group naturally into distinct features
|
|
7
8
|
|
|
8
|
-
When launching Task tool subagents, instruct them to call readroadmap first to
|
|
9
|
+
When launching Task tool subagents, explicitly instruct them to: (1) call readroadmap first, and (2) call updateroadmap to mark their action in_progress at start and completed at finish with a short note.
|
|
9
10
|
|
|
10
11
|
Structure: Features contain Actions. IDs are immutable once created.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
13
|
+
Inputs:
|
|
14
|
+
- feature: short label for the overall roadmap
|
|
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
|
|
15
17
|
|
|
16
18
|
Example:
|
|
17
19
|
{
|
|
20
|
+
"feature": "Core",
|
|
21
|
+
"spec": "Build the core workflow and key integrations.",
|
|
18
22
|
"features": [{
|
|
19
23
|
"number": "1", "title": "Auth", "description": "User authentication system",
|
|
20
24
|
"actions": [
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
Load the persisted roadmap
|
|
1
|
+
Load the persisted roadmap to understand project state.
|
|
2
2
|
|
|
3
3
|
When to use:
|
|
4
|
-
1. Before starting a major feature -
|
|
5
|
-
2. When launching Task tool subagents - instruct them to call readroadmap
|
|
4
|
+
1. Before starting a major feature - call readroadmap first, then plan immediate steps with todowrite
|
|
5
|
+
2. When launching Task tool subagents - explicitly instruct them to call readroadmap before doing any work, then updateroadmap when they start and finish
|
|
6
6
|
3. To check overall progress across features
|
|
7
7
|
|
|
8
8
|
Concurrent work awareness:
|
|
@@ -3,8 +3,8 @@ Advance action state within the persisted roadmap.
|
|
|
3
3
|
Statuses: pending, in_progress, completed, cancelled
|
|
4
4
|
Transitions: Flexible (can revert if needed), except cancelled is terminal.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Use after completing work on an action. When delegating to Task tool subagents, explicitly instruct them to call updateroadmap at start (set in_progress) and at finish (set completed with a short note).
|
|
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 +1 @@
|
|
|
1
|
-
Roadmap
|
|
1
|
+
Roadmap data is corrupted or unreadable. Ask user to recreate the roadmap.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses and renders the roadmap markdown document.
|
|
3
|
+
* Keeps frontmatter simple and the task list machine-readable.
|
|
4
|
+
* Exposes a narrow API for the storage layer.
|
|
5
|
+
*/
|
|
6
|
+
import type { RoadmapDocument } from "../types.js";
|
|
7
|
+
export declare const parseDocument: (data: string) => RoadmapDocument;
|
|
8
|
+
export declare const buildDocument: (document: RoadmapDocument) => string;
|
|
9
|
+
export declare const ensureDocument: (document: RoadmapDocument) => RoadmapDocument;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Roadmap as RoadmapSchema } from "../types.js";
|
|
2
|
+
const FRONTMATTER_START = "---\n";
|
|
3
|
+
const FRONTMATTER_END = "\n---\n";
|
|
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
|
+
};
|
|
12
|
+
export const parseDocument = (data) => {
|
|
13
|
+
const { frontmatter, body } = splitFrontmatter(data);
|
|
14
|
+
const { feature, spec } = parseFrontmatter(frontmatter);
|
|
15
|
+
const roadmap = parseTaskList(body);
|
|
16
|
+
return { feature, spec, roadmap };
|
|
17
|
+
};
|
|
18
|
+
export const buildDocument = (document) => {
|
|
19
|
+
const specValue = document.spec.trimEnd();
|
|
20
|
+
const specLines = specValue === "" ? [""] : specValue.split("\n");
|
|
21
|
+
const specBlock = specLines.map((line) => ` ${line}`).join("\n");
|
|
22
|
+
const taskList = buildTaskList(document.roadmap);
|
|
23
|
+
return [
|
|
24
|
+
"---",
|
|
25
|
+
`feature: ${JSON.stringify(document.feature)}`,
|
|
26
|
+
"spec: |",
|
|
27
|
+
specBlock,
|
|
28
|
+
"---",
|
|
29
|
+
"",
|
|
30
|
+
TASK_LIST_HEADER,
|
|
31
|
+
"",
|
|
32
|
+
taskList,
|
|
33
|
+
].join("\n");
|
|
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
|
+
};
|
|
43
|
+
const splitFrontmatter = (data) => {
|
|
44
|
+
if (!data.startsWith(FRONTMATTER_START)) {
|
|
45
|
+
throw new Error("Roadmap format is invalid. Missing frontmatter.");
|
|
46
|
+
}
|
|
47
|
+
const endIndex = data.indexOf(FRONTMATTER_END, FRONTMATTER_START.length);
|
|
48
|
+
if (endIndex === -1) {
|
|
49
|
+
throw new Error("Roadmap format is invalid. Frontmatter is not closed.");
|
|
50
|
+
}
|
|
51
|
+
const frontmatter = data.slice(FRONTMATTER_START.length, endIndex);
|
|
52
|
+
const body = data.slice(endIndex + FRONTMATTER_END.length);
|
|
53
|
+
return { frontmatter, body };
|
|
54
|
+
};
|
|
55
|
+
const parseFrontmatter = (frontmatter) => {
|
|
56
|
+
const lines = frontmatter.split("\n");
|
|
57
|
+
let feature = null;
|
|
58
|
+
let specStart = -1;
|
|
59
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
60
|
+
const line = lines[i];
|
|
61
|
+
if (line.startsWith("feature:")) {
|
|
62
|
+
feature = parseScalar(line.slice("feature:".length));
|
|
63
|
+
}
|
|
64
|
+
if (line.startsWith("spec:")) {
|
|
65
|
+
if (line.trim() !== "spec: |") {
|
|
66
|
+
throw new Error("Roadmap format is invalid. Spec must use a block value.");
|
|
67
|
+
}
|
|
68
|
+
specStart = i;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!feature) {
|
|
72
|
+
throw new Error("Roadmap format is invalid. Missing feature.");
|
|
73
|
+
}
|
|
74
|
+
if (specStart === -1) {
|
|
75
|
+
throw new Error("Roadmap format is invalid. Missing spec.");
|
|
76
|
+
}
|
|
77
|
+
const specLines = lines.slice(specStart + 1);
|
|
78
|
+
const spec = normalizeSpec(specLines);
|
|
79
|
+
return { feature, spec };
|
|
80
|
+
};
|
|
81
|
+
const parseScalar = (value) => {
|
|
82
|
+
const trimmed = value.trim();
|
|
83
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
84
|
+
return trimmed.slice(1, -1);
|
|
85
|
+
}
|
|
86
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
87
|
+
return trimmed.slice(1, -1);
|
|
88
|
+
}
|
|
89
|
+
return trimmed;
|
|
90
|
+
};
|
|
91
|
+
const normalizeSpec = (lines) => {
|
|
92
|
+
if (lines.length === 0) {
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
const nonEmpty = lines.filter((line) => line.trim() !== "");
|
|
96
|
+
const indent = nonEmpty.length === 0
|
|
97
|
+
? 0
|
|
98
|
+
: nonEmpty.reduce((min, line) => {
|
|
99
|
+
const match = line.match(/^\s+/);
|
|
100
|
+
const count = match ? match[0].length : 0;
|
|
101
|
+
return Math.min(min, count);
|
|
102
|
+
}, Number.MAX_SAFE_INTEGER);
|
|
103
|
+
const normalized = lines.map((line) => line.slice(indent));
|
|
104
|
+
return normalized.join("\n").trimEnd();
|
|
105
|
+
};
|
|
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
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (currentFeature) {
|
|
160
|
+
features.push(currentFeature);
|
|
161
|
+
}
|
|
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
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return RoadmapSchema.parse({ features });
|
|
174
|
+
};
|
|
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
|
+
}
|
|
199
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const ensureRoadmapDir: (base: string) => Promise<void>;
|
|
2
|
+
export declare const readRoadmapFile: (base: string) => Promise<string>;
|
|
3
|
+
export declare const writeRoadmapFile: (base: string, data: string) => Promise<void>;
|
|
4
|
+
export declare const archiveRoadmapFile: (base: string) => Promise<string>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handles roadmap file IO with atomic writes.
|
|
3
|
+
* Keeps filesystem concerns separate from document parsing.
|
|
4
|
+
*/
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import { roadmapDir, roadmapPath, tempPath } from "./paths.js";
|
|
7
|
+
export const ensureRoadmapDir = async (base) => {
|
|
8
|
+
await fs.mkdir(roadmapDir(base), { recursive: true }).catch(() => { });
|
|
9
|
+
};
|
|
10
|
+
export const readRoadmapFile = async (base) => {
|
|
11
|
+
return await fs.readFile(roadmapPath(base), "utf-8");
|
|
12
|
+
};
|
|
13
|
+
const fsyncDir = async (dir) => {
|
|
14
|
+
const handle = await fs.open(dir, "r");
|
|
15
|
+
try {
|
|
16
|
+
await handle.sync();
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
await handle.close().catch(() => { });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
export const writeRoadmapFile = async (base, data) => {
|
|
23
|
+
const suffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
24
|
+
const temp = tempPath(base, suffix);
|
|
25
|
+
const handle = await fs.open(temp, "w");
|
|
26
|
+
try {
|
|
27
|
+
await handle.writeFile(data, "utf-8");
|
|
28
|
+
await handle.sync();
|
|
29
|
+
await handle.close();
|
|
30
|
+
await fs.rename(temp, roadmapPath(base));
|
|
31
|
+
await fsyncDir(roadmapDir(base));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
await handle.close().catch(() => { });
|
|
35
|
+
await fs.unlink(temp).catch(() => { });
|
|
36
|
+
if (error instanceof Error) {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
throw new Error("Unknown error while persisting roadmap");
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
export const archiveRoadmapFile = async (base) => {
|
|
43
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
44
|
+
const archiveFilename = `roadmap.archive.${timestamp}.md`;
|
|
45
|
+
const archivePath = `${roadmapDir(base)}/${archiveFilename}`;
|
|
46
|
+
await fs.rename(roadmapPath(base), archivePath);
|
|
47
|
+
await fsyncDir(roadmapDir(base));
|
|
48
|
+
return archiveFilename;
|
|
49
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const acquireLock: (base: string) => Promise<() => Promise<void>>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provides exclusive access for roadmap operations.
|
|
3
|
+
* Uses a lock file with stale detection to avoid deadlocks.
|
|
4
|
+
*/
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import { lockPath } from "./paths.js";
|
|
7
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
8
|
+
const LOCK_RETRY_MS = 50;
|
|
9
|
+
const LOCK_STALE_MS = 30000;
|
|
10
|
+
export const acquireLock = async (base) => {
|
|
11
|
+
const path = lockPath(base);
|
|
12
|
+
const start = Date.now();
|
|
13
|
+
while (Date.now() - start < LOCK_TIMEOUT_MS) {
|
|
14
|
+
try {
|
|
15
|
+
await fs.writeFile(path, String(process.pid), { flag: "wx" });
|
|
16
|
+
return async () => {
|
|
17
|
+
await fs.unlink(path).catch(() => { });
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
const isStale = await fs
|
|
22
|
+
.stat(path)
|
|
23
|
+
.then((stat) => Date.now() - stat.mtimeMs > LOCK_STALE_MS)
|
|
24
|
+
.catch(() => false);
|
|
25
|
+
if (isStale) {
|
|
26
|
+
await fs.unlink(path).catch(() => { });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error("Could not acquire lock on roadmap data. Another operation may be in progress.");
|
|
33
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const ROADMAP_DIR = "roadmap";
|
|
2
|
+
export declare const ROADMAP_FILE = "roadmap.md";
|
|
3
|
+
export declare const LOCK_FILE = "roadmap.md.lock";
|
|
4
|
+
export declare const roadmapDir: (base: string) => string;
|
|
5
|
+
export declare const roadmapPath: (base: string) => string;
|
|
6
|
+
export declare const lockPath: (base: string) => string;
|
|
7
|
+
export declare const tempPath: (base: string, suffix: string) => string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralizes file naming for the roadmap document.
|
|
3
|
+
* Keeps path logic consistent across storage helpers.
|
|
4
|
+
*/
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
export const ROADMAP_DIR = "roadmap";
|
|
7
|
+
export const ROADMAP_FILE = "roadmap.md";
|
|
8
|
+
export const LOCK_FILE = `${ROADMAP_FILE}.lock`;
|
|
9
|
+
export const roadmapDir = (base) => join(base, ROADMAP_DIR);
|
|
10
|
+
export const roadmapPath = (base) => join(roadmapDir(base), ROADMAP_FILE);
|
|
11
|
+
export const lockPath = (base) => join(roadmapDir(base), LOCK_FILE);
|
|
12
|
+
export const tempPath = (base, suffix) => join(roadmapDir(base), `${ROADMAP_FILE}.tmp.${suffix}`);
|
package/dist/src/storage.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RoadmapDocument, RoadmapStorage } from "./types.js";
|
|
2
2
|
type UpdateResult<T> = {
|
|
3
|
-
|
|
3
|
+
document: RoadmapDocument;
|
|
4
4
|
buildResult: (archiveName: string | null) => T;
|
|
5
5
|
archive?: boolean;
|
|
6
6
|
};
|
|
@@ -8,30 +8,10 @@ export declare class FileStorage implements RoadmapStorage {
|
|
|
8
8
|
private readonly directory;
|
|
9
9
|
constructor(directory: string);
|
|
10
10
|
exists(): Promise<boolean>;
|
|
11
|
-
read(): Promise<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
private fsyncDir;
|
|
15
|
-
private writeAtomic;
|
|
16
|
-
private archiveUnlocked;
|
|
17
|
-
write(roadmap: Roadmap): Promise<void>;
|
|
18
|
-
update<T>(fn: (current: Roadmap | null) => Promise<UpdateResult<T>> | UpdateResult<T>): Promise<T>;
|
|
11
|
+
read(): Promise<RoadmapDocument | null>;
|
|
12
|
+
write(document: RoadmapDocument): Promise<void>;
|
|
13
|
+
update<T>(fn: (current: RoadmapDocument | null) => Promise<UpdateResult<T>> | UpdateResult<T>): Promise<T>;
|
|
19
14
|
archive(): Promise<string>;
|
|
20
|
-
|
|
21
|
-
export declare class RoadmapValidator {
|
|
22
|
-
static validateFeatureNumber(number: string): ValidationError | null;
|
|
23
|
-
static validateActionNumber(number: string): ValidationError | null;
|
|
24
|
-
static validateActionSequence(actions: {
|
|
25
|
-
number: string;
|
|
26
|
-
}[], globalSeenNumbers?: Set<string>, featureNumber?: string): ValidationError[];
|
|
27
|
-
static validateFeatureSequence(features: {
|
|
28
|
-
number: string;
|
|
29
|
-
actions: {
|
|
30
|
-
number: string;
|
|
31
|
-
}[];
|
|
32
|
-
}[]): ValidationError[];
|
|
33
|
-
static validateTitle(title: string, fieldType: "feature" | "action"): ValidationError | null;
|
|
34
|
-
static validateDescription(description: string, fieldType: "feature" | "action"): ValidationError | null;
|
|
35
|
-
static validateStatusProgression(currentStatus: string, newStatus: string): ValidationError | null;
|
|
15
|
+
private readFromDisk;
|
|
36
16
|
}
|
|
37
17
|
export {};
|
package/dist/src/storage.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Persists the roadmap document with locking and atomic writes.
|
|
3
|
+
* Delegates parsing and formatting to the document helpers.
|
|
4
|
+
* Keeps IO logic focused on concurrency and filesystem safety.
|
|
4
5
|
*/
|
|
5
6
|
import { promises as fs } from "fs";
|
|
6
|
-
import { join } from "path";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const LOCK_RETRY_MS = 50;
|
|
13
|
-
const LOCK_STALE_MS = 30000;
|
|
8
|
+
import { parseDocument, buildDocument, ensureDocument } from "./roadmap/document.js";
|
|
9
|
+
import { acquireLock } from "./roadmap/lock.js";
|
|
10
|
+
import { ensureRoadmapDir, readRoadmapFile, writeRoadmapFile, archiveRoadmapFile } from "./roadmap/files.js";
|
|
11
|
+
import { roadmapPath } from "./roadmap/paths.js";
|
|
14
12
|
export class FileStorage {
|
|
15
13
|
directory;
|
|
16
14
|
constructor(directory) {
|
|
@@ -18,7 +16,7 @@ export class FileStorage {
|
|
|
18
16
|
}
|
|
19
17
|
async exists() {
|
|
20
18
|
try {
|
|
21
|
-
await fs.access(
|
|
19
|
+
await fs.access(roadmapPath(this.directory));
|
|
22
20
|
return true;
|
|
23
21
|
}
|
|
24
22
|
catch {
|
|
@@ -28,118 +26,26 @@ export class FileStorage {
|
|
|
28
26
|
async read() {
|
|
29
27
|
return this.readFromDisk();
|
|
30
28
|
}
|
|
31
|
-
async
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const data = await fs.readFile(filePath, "utf-8");
|
|
35
|
-
if (!data.trim()) {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
const parsed = JSON.parse(data);
|
|
39
|
-
const validated = RoadmapSchema.parse(parsed);
|
|
40
|
-
return validated;
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
if (error instanceof SyntaxError) {
|
|
44
|
-
throw new Error("Roadmap file contains invalid JSON. File may be corrupted.");
|
|
45
|
-
}
|
|
46
|
-
if (error instanceof z.ZodError) {
|
|
47
|
-
const issues = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
|
|
48
|
-
throw new Error(`Roadmap file has invalid structure: ${issues}`);
|
|
49
|
-
}
|
|
50
|
-
if (error instanceof Error && error.message.includes("ENOENT")) {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
if (error instanceof Error) {
|
|
54
|
-
throw error;
|
|
55
|
-
}
|
|
56
|
-
throw new Error("Unknown error while reading roadmap");
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
async acquireLock() {
|
|
60
|
-
const lockPath = join(this.directory, LOCK_FILE);
|
|
61
|
-
const start = Date.now();
|
|
62
|
-
while (Date.now() - start < LOCK_TIMEOUT_MS) {
|
|
63
|
-
try {
|
|
64
|
-
await fs.writeFile(lockPath, String(process.pid), { flag: "wx" });
|
|
65
|
-
return async () => {
|
|
66
|
-
await fs.unlink(lockPath).catch(() => { });
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
const isStale = await fs
|
|
71
|
-
.stat(lockPath)
|
|
72
|
-
.then((stat) => Date.now() - stat.mtimeMs > LOCK_STALE_MS)
|
|
73
|
-
.catch(() => false);
|
|
74
|
-
if (isStale) {
|
|
75
|
-
await fs.unlink(lockPath).catch(() => { });
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS));
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
throw new Error("Could not acquire lock on roadmap file. Another operation may be in progress.");
|
|
82
|
-
}
|
|
83
|
-
async fsyncDir() {
|
|
84
|
-
const handle = await fs.open(this.directory, "r");
|
|
85
|
-
try {
|
|
86
|
-
await handle.sync();
|
|
87
|
-
}
|
|
88
|
-
finally {
|
|
89
|
-
await handle.close().catch(() => { });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
async writeAtomic(filePath, data) {
|
|
93
|
-
const randomSuffix = Math.random().toString(36).slice(2, 8);
|
|
94
|
-
const tempPath = join(this.directory, `${ROADMAP_FILE}.tmp.${Date.now()}.${randomSuffix}`);
|
|
95
|
-
const handle = await fs.open(tempPath, "w");
|
|
96
|
-
try {
|
|
97
|
-
await handle.writeFile(data, "utf-8");
|
|
98
|
-
await handle.sync();
|
|
99
|
-
await handle.close();
|
|
100
|
-
await fs.rename(tempPath, filePath);
|
|
101
|
-
await this.fsyncDir();
|
|
102
|
-
}
|
|
103
|
-
catch (error) {
|
|
104
|
-
await handle.close().catch(() => { });
|
|
105
|
-
await fs.unlink(tempPath).catch(() => { });
|
|
106
|
-
if (error instanceof Error) {
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
109
|
-
throw new Error("Unknown error while writing roadmap");
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
async archiveUnlocked() {
|
|
113
|
-
const filePath = join(this.directory, ROADMAP_FILE);
|
|
114
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
115
|
-
const archiveFilename = `roadmap.archive.${timestamp}.json`;
|
|
116
|
-
const archivePath = join(this.directory, archiveFilename);
|
|
117
|
-
await fs.rename(filePath, archivePath);
|
|
118
|
-
await this.fsyncDir();
|
|
119
|
-
return archiveFilename;
|
|
120
|
-
}
|
|
121
|
-
async write(roadmap) {
|
|
122
|
-
await fs.mkdir(this.directory, { recursive: true }).catch(() => { });
|
|
123
|
-
const unlock = await this.acquireLock();
|
|
29
|
+
async write(document) {
|
|
30
|
+
await ensureRoadmapDir(this.directory);
|
|
31
|
+
const unlock = await acquireLock(this.directory);
|
|
124
32
|
try {
|
|
125
|
-
const data =
|
|
126
|
-
|
|
127
|
-
await this.writeAtomic(filePath, data);
|
|
33
|
+
const data = buildDocument(ensureDocument(document));
|
|
34
|
+
await writeRoadmapFile(this.directory, data);
|
|
128
35
|
}
|
|
129
36
|
finally {
|
|
130
37
|
await unlock();
|
|
131
38
|
}
|
|
132
39
|
}
|
|
133
40
|
async update(fn) {
|
|
134
|
-
await
|
|
135
|
-
const unlock = await this.
|
|
41
|
+
await ensureRoadmapDir(this.directory);
|
|
42
|
+
const unlock = await acquireLock(this.directory);
|
|
136
43
|
try {
|
|
137
44
|
const current = await this.readFromDisk();
|
|
138
45
|
const outcome = await fn(current);
|
|
139
|
-
const data =
|
|
140
|
-
|
|
141
|
-
await this.
|
|
142
|
-
const archiveName = outcome.archive ? await this.archiveUnlocked() : null;
|
|
46
|
+
const data = buildDocument(ensureDocument(outcome.document));
|
|
47
|
+
await writeRoadmapFile(this.directory, data);
|
|
48
|
+
const archiveName = outcome.archive ? await archiveRoadmapFile(this.directory) : null;
|
|
143
49
|
return outcome.buildResult(archiveName);
|
|
144
50
|
}
|
|
145
51
|
finally {
|
|
@@ -148,153 +54,39 @@ export class FileStorage {
|
|
|
148
54
|
}
|
|
149
55
|
async archive() {
|
|
150
56
|
if (!(await this.exists())) {
|
|
151
|
-
throw new Error("
|
|
57
|
+
throw new Error("Roadmap not found.");
|
|
152
58
|
}
|
|
153
|
-
const unlock = await this.
|
|
59
|
+
const unlock = await acquireLock(this.directory);
|
|
154
60
|
try {
|
|
155
|
-
return await this.
|
|
61
|
+
return await archiveRoadmapFile(this.directory);
|
|
156
62
|
}
|
|
157
63
|
finally {
|
|
158
64
|
await unlock();
|
|
159
65
|
}
|
|
160
66
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
code: "INVALID_FEATURE_NUMBER",
|
|
167
|
-
message: "Invalid feature ID: must be a string.",
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
if (!/^\d+$/.test(number)) {
|
|
171
|
-
return {
|
|
172
|
-
code: "INVALID_FEATURE_NUMBER_FORMAT",
|
|
173
|
-
message: "Invalid feature ID format: must be a simple number.",
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
return null;
|
|
177
|
-
}
|
|
178
|
-
static validateActionNumber(number) {
|
|
179
|
-
if (!number || typeof number !== "string") {
|
|
180
|
-
return {
|
|
181
|
-
code: "INVALID_ACTION_NUMBER",
|
|
182
|
-
message: "Invalid action ID: must be a string.",
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
if (!/^\d+\.\d{2}$/.test(number)) {
|
|
186
|
-
return {
|
|
187
|
-
code: "INVALID_ACTION_NUMBER_FORMAT",
|
|
188
|
-
message: "Invalid action ID format: must be X.YY (e.g., 1.01).",
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
return null;
|
|
192
|
-
}
|
|
193
|
-
static validateActionSequence(actions, globalSeenNumbers, featureNumber) {
|
|
194
|
-
const errors = [];
|
|
195
|
-
const seenNumbers = new Set();
|
|
196
|
-
for (const action of actions) {
|
|
197
|
-
const numberError = this.validateActionNumber(action.number);
|
|
198
|
-
if (numberError) {
|
|
199
|
-
errors.push(numberError);
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
// Check action-feature mismatch
|
|
203
|
-
if (featureNumber) {
|
|
204
|
-
const actionFeaturePrefix = action.number.split(".")[0];
|
|
205
|
-
if (actionFeaturePrefix !== featureNumber) {
|
|
206
|
-
errors.push({
|
|
207
|
-
code: "ACTION_FEATURE_MISMATCH",
|
|
208
|
-
message: `Action "${action.number}" does not belong to feature "${featureNumber}".`,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
67
|
+
async readFromDisk() {
|
|
68
|
+
try {
|
|
69
|
+
const data = await readRoadmapFile(this.directory);
|
|
70
|
+
if (!data.trim()) {
|
|
71
|
+
return null;
|
|
211
72
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
});
|
|
73
|
+
return ensureDocument(parseDocument(data));
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof SyntaxError) {
|
|
77
|
+
throw new Error("Roadmap data is invalid. Unable to parse tasks.");
|
|
218
78
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
code: "DUPLICATE_ACTION_NUMBER_GLOBAL",
|
|
223
|
-
message: `Duplicate action ID "${action.number}" (exists in another feature).`,
|
|
224
|
-
});
|
|
79
|
+
if (error instanceof z.ZodError) {
|
|
80
|
+
const issues = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
|
|
81
|
+
throw new Error(`Roadmap data is invalid: ${issues}`);
|
|
225
82
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
return errors;
|
|
230
|
-
}
|
|
231
|
-
static validateFeatureSequence(features) {
|
|
232
|
-
const errors = [];
|
|
233
|
-
const seenNumbers = new Set();
|
|
234
|
-
const seenActionNumbers = new Set();
|
|
235
|
-
for (const feature of features) {
|
|
236
|
-
const numberError = this.validateFeatureNumber(feature.number);
|
|
237
|
-
if (numberError) {
|
|
238
|
-
errors.push(numberError);
|
|
239
|
-
continue;
|
|
83
|
+
if (error instanceof Error && error.message.includes("ENOENT")) {
|
|
84
|
+
return null;
|
|
240
85
|
}
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
code: "DUPLICATE_FEATURE_NUMBER",
|
|
244
|
-
message: `Duplicate feature ID "${feature.number}".`,
|
|
245
|
-
});
|
|
86
|
+
if (error instanceof Error) {
|
|
87
|
+
throw error;
|
|
246
88
|
}
|
|
247
|
-
|
|
248
|
-
const actionErrors = this.validateActionSequence(feature.actions, seenActionNumbers, feature.number);
|
|
249
|
-
errors.push(...actionErrors);
|
|
250
|
-
}
|
|
251
|
-
return errors;
|
|
252
|
-
}
|
|
253
|
-
static validateTitle(title, fieldType) {
|
|
254
|
-
if (!title || typeof title !== "string") {
|
|
255
|
-
return {
|
|
256
|
-
code: "INVALID_TITLE",
|
|
257
|
-
message: `Invalid ${fieldType} title. Must be non-empty string.`,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
if (title.trim() === "") {
|
|
261
|
-
return {
|
|
262
|
-
code: "EMPTY_TITLE",
|
|
263
|
-
message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} title cannot be empty.`,
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
return null;
|
|
267
|
-
}
|
|
268
|
-
static validateDescription(description, fieldType) {
|
|
269
|
-
if (!description || typeof description !== "string") {
|
|
270
|
-
return {
|
|
271
|
-
code: "INVALID_DESCRIPTION",
|
|
272
|
-
message: `Invalid ${fieldType} description. Must be non-empty string.`,
|
|
273
|
-
};
|
|
274
|
-
}
|
|
275
|
-
if (description.trim() === "") {
|
|
276
|
-
return {
|
|
277
|
-
code: "EMPTY_DESCRIPTION",
|
|
278
|
-
message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} description cannot be empty.`,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
return null;
|
|
282
|
-
}
|
|
283
|
-
static validateStatusProgression(currentStatus, newStatus) {
|
|
284
|
-
const validStatuses = ["pending", "in_progress", "completed", "cancelled"];
|
|
285
|
-
if (!validStatuses.includes(newStatus)) {
|
|
286
|
-
return {
|
|
287
|
-
code: "INVALID_STATUS",
|
|
288
|
-
message: `Invalid status "${newStatus}". Valid: ${validStatuses.join(", ")}`,
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
// Allow any transition except from cancelled (terminal state for abandoned work)
|
|
292
|
-
if (currentStatus === "cancelled") {
|
|
293
|
-
return {
|
|
294
|
-
code: "INVALID_STATUS_TRANSITION",
|
|
295
|
-
message: "Cannot change status of cancelled action. Create a new action instead.",
|
|
296
|
-
};
|
|
89
|
+
throw new Error("Unknown error while loading roadmap");
|
|
297
90
|
}
|
|
298
|
-
return null;
|
|
299
91
|
}
|
|
300
92
|
}
|
|
@@ -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
|
|
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,13 +33,27 @@ export async function createCreateRoadmapTool(directory) {
|
|
|
30
33
|
},
|
|
31
34
|
async execute(args) {
|
|
32
35
|
const storage = new FileStorage(directory);
|
|
36
|
+
const featureError = RoadmapValidator.validateTitle(args.feature, "feature");
|
|
37
|
+
if (featureError) {
|
|
38
|
+
throw new Error(featureError.message);
|
|
39
|
+
}
|
|
40
|
+
const specError = RoadmapValidator.validateDescription(args.spec, "feature");
|
|
41
|
+
if (specError) {
|
|
42
|
+
throw new Error(specError.message);
|
|
43
|
+
}
|
|
33
44
|
if (!args.features || args.features.length === 0) {
|
|
34
|
-
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"}]}]}');
|
|
35
46
|
}
|
|
36
47
|
return await storage.update(async (current) => {
|
|
37
|
-
const roadmap = current
|
|
48
|
+
const roadmap = current ? current.roadmap : { features: [] };
|
|
38
49
|
const isUpdate = current !== null;
|
|
39
50
|
const validationErrors = [];
|
|
51
|
+
if (current && current.feature !== args.feature) {
|
|
52
|
+
throw new Error("Feature label does not match existing roadmap.");
|
|
53
|
+
}
|
|
54
|
+
if (current && current.spec !== args.spec) {
|
|
55
|
+
throw new Error("Spec does not match existing roadmap.");
|
|
56
|
+
}
|
|
40
57
|
// First pass: structural validation of input
|
|
41
58
|
for (const feature of args.features) {
|
|
42
59
|
if (!feature.actions || feature.actions.length === 0) {
|
|
@@ -130,7 +147,11 @@ export async function createCreateRoadmapTool(directory) {
|
|
|
130
147
|
.map((feature) => ` Feature ${feature.number}: ${feature.title} (${feature.actions.length} actions)`)
|
|
131
148
|
.join("\n");
|
|
132
149
|
return {
|
|
133
|
-
|
|
150
|
+
document: {
|
|
151
|
+
feature: args.feature,
|
|
152
|
+
spec: args.spec,
|
|
153
|
+
roadmap,
|
|
154
|
+
},
|
|
134
155
|
buildResult: () => summary,
|
|
135
156
|
};
|
|
136
157
|
});
|
|
@@ -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
|
|
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
|
|
28
|
-
if (!
|
|
29
|
-
throw new Error("Roadmap
|
|
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
|
|
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");
|
|
@@ -19,6 +20,7 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
19
20
|
.enum(["pending", "in_progress", "completed", "cancelled"])
|
|
20
21
|
.optional()
|
|
21
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."),
|
|
22
24
|
},
|
|
23
25
|
async execute(args) {
|
|
24
26
|
const storage = new FileStorage(directory);
|
|
@@ -26,10 +28,11 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
26
28
|
if (actionNumberError) {
|
|
27
29
|
throw new Error(`${actionNumberError.message} Use ReadRoadmap to see valid action numbers.`);
|
|
28
30
|
}
|
|
29
|
-
return await storage.update((
|
|
30
|
-
if (!
|
|
31
|
+
return await storage.update((document) => {
|
|
32
|
+
if (!document) {
|
|
31
33
|
throw new Error("Roadmap not found. Use CreateRoadmap to create one.");
|
|
32
34
|
}
|
|
35
|
+
const roadmap = document.roadmap;
|
|
33
36
|
let targetAction = null;
|
|
34
37
|
let targetFeature = null;
|
|
35
38
|
let actionFound = false;
|
|
@@ -52,6 +55,10 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
52
55
|
if (args.description === undefined && args.status === undefined) {
|
|
53
56
|
throw new Error("No changes specified. Please provide description and/or status.");
|
|
54
57
|
}
|
|
58
|
+
const noteError = RoadmapValidator.validateDescription(args.note, "action");
|
|
59
|
+
if (noteError) {
|
|
60
|
+
throw new Error(`${noteError.message}`);
|
|
61
|
+
}
|
|
55
62
|
const oldStatus = targetAction.status;
|
|
56
63
|
const oldDescription = targetAction.description;
|
|
57
64
|
// Validate description if provided
|
|
@@ -62,6 +69,7 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
62
69
|
}
|
|
63
70
|
targetAction.description = args.description;
|
|
64
71
|
}
|
|
72
|
+
targetAction.description = `${targetAction.description} (note: ${args.note})`;
|
|
65
73
|
// Validate and update status if provided
|
|
66
74
|
if (args.status !== undefined) {
|
|
67
75
|
const statusTransitionError = RoadmapValidator.validateStatusProgression(targetAction.status, args.status);
|
|
@@ -77,9 +85,16 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
77
85
|
if (args.status !== undefined && oldStatus !== args.status) {
|
|
78
86
|
changes.push(`status: "${oldStatus}" → "${args.status}"`);
|
|
79
87
|
}
|
|
88
|
+
if (oldDescription !== targetAction.description) {
|
|
89
|
+
changes.push("note added");
|
|
90
|
+
}
|
|
80
91
|
if (changes.length === 0) {
|
|
81
92
|
return {
|
|
82
|
-
|
|
93
|
+
document: {
|
|
94
|
+
feature: document.feature,
|
|
95
|
+
spec: document.spec,
|
|
96
|
+
roadmap,
|
|
97
|
+
},
|
|
83
98
|
buildResult: () => `Action ${args.actionNumber} unchanged. Provided values match current state.`,
|
|
84
99
|
};
|
|
85
100
|
}
|
|
@@ -105,7 +120,11 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
105
120
|
featureContext += `${action.number} ${statusIcon} ${action.description} [${action.status}]\n`;
|
|
106
121
|
}
|
|
107
122
|
return {
|
|
108
|
-
|
|
123
|
+
document: {
|
|
124
|
+
feature: document.feature,
|
|
125
|
+
spec: document.spec,
|
|
126
|
+
roadmap,
|
|
127
|
+
},
|
|
109
128
|
archive: allCompleted,
|
|
110
129
|
buildResult: (archiveName) => {
|
|
111
130
|
const archiveMsg = archiveName ? `\n\nAll actions completed! Roadmap archived to "${archiveName}".` : "";
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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<
|
|
118
|
-
write(
|
|
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
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
export class RoadmapValidator {
|
|
2
|
+
static validateFeatureNumber(number) {
|
|
3
|
+
if (!number || typeof number !== "string") {
|
|
4
|
+
return {
|
|
5
|
+
code: "INVALID_FEATURE_NUMBER",
|
|
6
|
+
message: "Invalid feature ID: must be a string.",
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
if (!/^\d+$/.test(number)) {
|
|
10
|
+
return {
|
|
11
|
+
code: "INVALID_FEATURE_NUMBER_FORMAT",
|
|
12
|
+
message: "Invalid feature ID format: must be a simple number.",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
static validateActionNumber(number) {
|
|
18
|
+
if (!number || typeof number !== "string") {
|
|
19
|
+
return {
|
|
20
|
+
code: "INVALID_ACTION_NUMBER",
|
|
21
|
+
message: "Invalid action ID: must be a string.",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (!/^\d+\.\d{2}$/.test(number)) {
|
|
25
|
+
return {
|
|
26
|
+
code: "INVALID_ACTION_NUMBER_FORMAT",
|
|
27
|
+
message: "Invalid action ID format: must be X.YY (e.g., 1.01).",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
static validateActionSequence(actions, globalSeenNumbers, featureNumber) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
const seenNumbers = new Set();
|
|
35
|
+
for (const action of actions) {
|
|
36
|
+
const numberError = this.validateActionNumber(action.number);
|
|
37
|
+
if (numberError) {
|
|
38
|
+
errors.push(numberError);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (featureNumber) {
|
|
42
|
+
const actionFeaturePrefix = action.number.split(".")[0];
|
|
43
|
+
if (actionFeaturePrefix !== featureNumber) {
|
|
44
|
+
errors.push({
|
|
45
|
+
code: "ACTION_FEATURE_MISMATCH",
|
|
46
|
+
message: `Action "${action.number}" does not belong to feature "${featureNumber}".`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (seenNumbers.has(action.number)) {
|
|
51
|
+
errors.push({
|
|
52
|
+
code: "DUPLICATE_ACTION_NUMBER",
|
|
53
|
+
message: `Duplicate action ID "${action.number}".`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (globalSeenNumbers?.has(action.number)) {
|
|
57
|
+
errors.push({
|
|
58
|
+
code: "DUPLICATE_ACTION_NUMBER_GLOBAL",
|
|
59
|
+
message: `Duplicate action ID "${action.number}" (exists in another feature).`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
seenNumbers.add(action.number);
|
|
63
|
+
globalSeenNumbers?.add(action.number);
|
|
64
|
+
}
|
|
65
|
+
return errors;
|
|
66
|
+
}
|
|
67
|
+
static validateFeatureSequence(features) {
|
|
68
|
+
const errors = [];
|
|
69
|
+
const seenNumbers = new Set();
|
|
70
|
+
const seenActionNumbers = new Set();
|
|
71
|
+
for (const feature of features) {
|
|
72
|
+
const numberError = this.validateFeatureNumber(feature.number);
|
|
73
|
+
if (numberError) {
|
|
74
|
+
errors.push(numberError);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (seenNumbers.has(feature.number)) {
|
|
78
|
+
errors.push({
|
|
79
|
+
code: "DUPLICATE_FEATURE_NUMBER",
|
|
80
|
+
message: `Duplicate feature ID "${feature.number}".`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
seenNumbers.add(feature.number);
|
|
84
|
+
const actionErrors = this.validateActionSequence(feature.actions, seenActionNumbers, feature.number);
|
|
85
|
+
errors.push(...actionErrors);
|
|
86
|
+
}
|
|
87
|
+
return errors;
|
|
88
|
+
}
|
|
89
|
+
static validateTitle(title, fieldType) {
|
|
90
|
+
if (!title || typeof title !== "string") {
|
|
91
|
+
return {
|
|
92
|
+
code: "INVALID_TITLE",
|
|
93
|
+
message: `Invalid ${fieldType} title. Must be non-empty string.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (title.trim() === "") {
|
|
97
|
+
return {
|
|
98
|
+
code: "EMPTY_TITLE",
|
|
99
|
+
message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} title cannot be empty.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
static validateDescription(description, fieldType) {
|
|
105
|
+
if (!description || typeof description !== "string") {
|
|
106
|
+
return {
|
|
107
|
+
code: "INVALID_DESCRIPTION",
|
|
108
|
+
message: `Invalid ${fieldType} description. Must be non-empty string.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (description.trim() === "") {
|
|
112
|
+
return {
|
|
113
|
+
code: "EMPTY_DESCRIPTION",
|
|
114
|
+
message: `${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)} description cannot be empty.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
static validateStatusProgression(currentStatus, newStatus) {
|
|
120
|
+
const validStatuses = ["pending", "in_progress", "completed", "cancelled"];
|
|
121
|
+
if (!validStatuses.includes(newStatus)) {
|
|
122
|
+
return {
|
|
123
|
+
code: "INVALID_STATUS",
|
|
124
|
+
message: `Invalid status "${newStatus}". Valid: ${validStatuses.join(", ")}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (currentStatus === "cancelled") {
|
|
128
|
+
return {
|
|
129
|
+
code: "INVALID_STATUS_TRANSITION",
|
|
130
|
+
message: "Cannot change status of cancelled action. Create a new action instead.",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|