@howaboua/opencode-roadmap-plugin 0.1.7 → 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.
@@ -1,20 +1,24 @@
1
- Establish a durable project roadmap saved to disk. Provides shared context across sessions and Task tool subagents.
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
- - User says "roadmap", "plan the project", "phases", or "milestones"
5
- - Work will be delegated to Task tool subagents that need shared context
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 understand their work within the broader plan.
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
- Format:
13
- - Feature: number "1", "2", title, description
14
- - Action: number "1.01", "1.02" (prefix matches parent feature), description, status "pending"
13
+ Inputs:
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
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 from disk to understand project state.
1
+ Load the persisted roadmap to understand project state.
2
2
 
3
3
  When to use:
4
- 1. Before starting a major feature - read roadmap first, then plan immediate steps with todowrite
5
- 2. When launching Task tool subagents - instruct them to call readroadmap to understand their assigned work
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,7 +3,7 @@ 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
- Update after completing work on an action. When delegating to Task tool subagents, instruct them to call updateroadmap when they finish their assigned action.
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
 
@@ -1 +1 @@
1
- Roadmap file is corrupted or unreadable. Ask user to check roadmap.json file.
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 block 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,117 @@
1
+ import { Roadmap as RoadmapSchema } from "../types.js";
2
+ const FRONTMATTER_START = "---\n";
3
+ const FRONTMATTER_END = "\n---\n";
4
+ const TASK_FENCE = "```json";
5
+ const TASK_FENCE_END = "\n```";
6
+ export const parseDocument = (data) => {
7
+ const { frontmatter, body } = splitFrontmatter(data);
8
+ const { feature, spec } = parseFrontmatter(frontmatter);
9
+ const roadmap = parseRoadmapBody(body);
10
+ return { feature, spec, roadmap };
11
+ };
12
+ export const buildDocument = (document) => {
13
+ const specValue = document.spec.trimEnd();
14
+ const specLines = specValue === "" ? [""] : specValue.split("\n");
15
+ const specBlock = specLines.map((line) => ` ${line}`).join("\n");
16
+ const tasks = JSON.stringify(document.roadmap, null, 2);
17
+ return [
18
+ "---",
19
+ `feature: ${JSON.stringify(document.feature)}`,
20
+ "spec: |",
21
+ specBlock,
22
+ "---",
23
+ "",
24
+ TASK_FENCE,
25
+ tasks,
26
+ "```",
27
+ "",
28
+ ].join("\n");
29
+ };
30
+ const splitFrontmatter = (data) => {
31
+ if (!data.startsWith(FRONTMATTER_START)) {
32
+ throw new Error("Roadmap format is invalid. Missing frontmatter.");
33
+ }
34
+ const endIndex = data.indexOf(FRONTMATTER_END, FRONTMATTER_START.length);
35
+ if (endIndex === -1) {
36
+ throw new Error("Roadmap format is invalid. Frontmatter is not closed.");
37
+ }
38
+ const frontmatter = data.slice(FRONTMATTER_START.length, endIndex);
39
+ const body = data.slice(endIndex + FRONTMATTER_END.length);
40
+ return { frontmatter, body };
41
+ };
42
+ const parseFrontmatter = (frontmatter) => {
43
+ const lines = frontmatter.split("\n");
44
+ let feature = null;
45
+ let specStart = -1;
46
+ for (let i = 0; i < lines.length; i += 1) {
47
+ const line = lines[i];
48
+ if (line.startsWith("feature:")) {
49
+ feature = parseScalar(line.slice("feature:".length));
50
+ }
51
+ if (line.startsWith("spec:")) {
52
+ if (line.trim() !== "spec: |") {
53
+ throw new Error("Roadmap format is invalid. Spec must use a block value.");
54
+ }
55
+ specStart = i;
56
+ }
57
+ }
58
+ if (!feature) {
59
+ throw new Error("Roadmap format is invalid. Missing feature.");
60
+ }
61
+ if (specStart === -1) {
62
+ throw new Error("Roadmap format is invalid. Missing spec.");
63
+ }
64
+ const specLines = lines.slice(specStart + 1);
65
+ const spec = normalizeSpec(specLines);
66
+ return { feature, spec };
67
+ };
68
+ const parseScalar = (value) => {
69
+ const trimmed = value.trim();
70
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
71
+ return trimmed.slice(1, -1);
72
+ }
73
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
74
+ return trimmed.slice(1, -1);
75
+ }
76
+ return trimmed;
77
+ };
78
+ const normalizeSpec = (lines) => {
79
+ if (lines.length === 0) {
80
+ return "";
81
+ }
82
+ const nonEmpty = lines.filter((line) => line.trim() !== "");
83
+ const indent = nonEmpty.length === 0
84
+ ? 0
85
+ : nonEmpty.reduce((min, line) => {
86
+ const match = line.match(/^\s+/);
87
+ const count = match ? match[0].length : 0;
88
+ return Math.min(min, count);
89
+ }, Number.MAX_SAFE_INTEGER);
90
+ const normalized = lines.map((line) => line.slice(indent));
91
+ return normalized.join("\n").trimEnd();
92
+ };
93
+ const parseRoadmapBody = (body) => {
94
+ const fenceStart = body.indexOf(TASK_FENCE);
95
+ if (fenceStart === -1) {
96
+ throw new Error("Roadmap format is invalid. Missing task block.");
97
+ }
98
+ const jsonStart = body.indexOf("\n", fenceStart + TASK_FENCE.length);
99
+ if (jsonStart === -1) {
100
+ throw new Error("Roadmap format is invalid. Task block is incomplete.");
101
+ }
102
+ const fenceEnd = body.indexOf(TASK_FENCE_END, jsonStart + 1);
103
+ if (fenceEnd === -1) {
104
+ throw new Error("Roadmap format is invalid. Task block is not closed.");
105
+ }
106
+ const jsonText = body.slice(jsonStart + 1, fenceEnd).trim();
107
+ const parsed = JSON.parse(jsonText);
108
+ return RoadmapSchema.parse(parsed);
109
+ };
110
+ export const ensureDocument = (document) => {
111
+ const validated = RoadmapSchema.parse(document.roadmap);
112
+ return {
113
+ feature: document.feature,
114
+ spec: document.spec,
115
+ roadmap: validated,
116
+ };
117
+ };
@@ -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}`);
@@ -1,6 +1,6 @@
1
- import type { Roadmap, RoadmapStorage, ValidationError } from "./types.js";
1
+ import type { RoadmapDocument, RoadmapStorage } from "./types.js";
2
2
  type UpdateResult<T> = {
3
- roadmap: Roadmap;
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<Roadmap | null>;
12
- private readFromDisk;
13
- private acquireLock;
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 {};
@@ -1,16 +1,14 @@
1
1
  /**
2
- * File-based storage for roadmap data with atomic writes and validation.
3
- * Handles concurrent access via file locking and provides safe read/write operations.
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 { Roadmap as RoadmapSchema } from "./types.js";
9
- const ROADMAP_FILE = "roadmap.json";
10
- const LOCK_FILE = `${ROADMAP_FILE}.lock`;
11
- const LOCK_TIMEOUT_MS = 5000;
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(join(this.directory, ROADMAP_FILE));
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 readFromDisk() {
32
- try {
33
- const filePath = join(this.directory, ROADMAP_FILE);
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 = JSON.stringify(roadmap, null, 2);
126
- const filePath = join(this.directory, ROADMAP_FILE);
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 fs.mkdir(this.directory, { recursive: true }).catch(() => { });
135
- const unlock = await this.acquireLock();
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 = JSON.stringify(outcome.roadmap, null, 2);
140
- const filePath = join(this.directory, ROADMAP_FILE);
141
- await this.writeAtomic(filePath, data);
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("Cannot archive: roadmap file does not exist");
57
+ throw new Error("Roadmap not found.");
152
58
  }
153
- const unlock = await this.acquireLock();
59
+ const unlock = await acquireLock(this.directory);
154
60
  try {
155
- return await this.archiveUnlocked();
61
+ return await archiveRoadmapFile(this.directory);
156
62
  }
157
63
  finally {
158
64
  await unlock();
159
65
  }
160
66
  }
161
- }
162
- export class RoadmapValidator {
163
- static validateFeatureNumber(number) {
164
- if (!number || typeof number !== "string") {
165
- return {
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
- // Check for duplicates within this feature
213
- if (seenNumbers.has(action.number)) {
214
- errors.push({
215
- code: "DUPLICATE_ACTION_NUMBER",
216
- message: `Duplicate action ID "${action.number}".`,
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
- // Check for global duplicates
220
- if (globalSeenNumbers?.has(action.number)) {
221
- errors.push({
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
- seenNumbers.add(action.number);
227
- globalSeenNumbers?.add(action.number);
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 (seenNumbers.has(feature.number)) {
242
- errors.push({
243
- code: "DUPLICATE_FEATURE_NUMBER",
244
- message: `Duplicate feature ID "${feature.number}".`,
245
- });
86
+ if (error instanceof Error) {
87
+ throw error;
246
88
  }
247
- seenNumbers.add(feature.number);
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, 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,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 ?? { features: [] };
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
- roadmap,
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, 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");
@@ -26,10 +27,11 @@ export async function createUpdateRoadmapTool(directory) {
26
27
  if (actionNumberError) {
27
28
  throw new Error(`${actionNumberError.message} Use ReadRoadmap to see valid action numbers.`);
28
29
  }
29
- return await storage.update((roadmap) => {
30
- if (!roadmap) {
30
+ return await storage.update((document) => {
31
+ if (!document) {
31
32
  throw new Error("Roadmap not found. Use CreateRoadmap to create one.");
32
33
  }
34
+ const roadmap = document.roadmap;
33
35
  let targetAction = null;
34
36
  let targetFeature = null;
35
37
  let actionFound = false;
@@ -79,7 +81,11 @@ export async function createUpdateRoadmapTool(directory) {
79
81
  }
80
82
  if (changes.length === 0) {
81
83
  return {
82
- roadmap,
84
+ document: {
85
+ feature: document.feature,
86
+ spec: document.spec,
87
+ roadmap,
88
+ },
83
89
  buildResult: () => `Action ${args.actionNumber} unchanged. Provided values match current state.`,
84
90
  };
85
91
  }
@@ -105,7 +111,11 @@ export async function createUpdateRoadmapTool(directory) {
105
111
  featureContext += `${action.number} ${statusIcon} ${action.description} [${action.status}]\n`;
106
112
  }
107
113
  return {
108
- roadmap,
114
+ document: {
115
+ feature: document.feature,
116
+ spec: document.spec,
117
+ roadmap,
118
+ },
109
119
  archive: allCompleted,
110
120
  buildResult: (archiveName) => {
111
121
  const archiveMsg = archiveName ? `\n\nAll actions completed! Roadmap archived to "${archiveName}".` : "";
@@ -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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/opencode-roadmap-plugin",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Strategic roadmap planning and multi-agent coordination for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",