@howaboua/opencode-roadmap-plugin 0.1.6 ā 0.1.7
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/dist/src/storage.d.ts +11 -0
- package/dist/src/storage.js +77 -22
- package/dist/src/tools/createroadmap.js +93 -100
- package/dist/src/tools/updateroadmap.js +82 -81
- package/package.json +1 -1
package/dist/src/storage.d.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { Roadmap, RoadmapStorage, ValidationError } from "./types.js";
|
|
2
|
+
type UpdateResult<T> = {
|
|
3
|
+
roadmap: Roadmap;
|
|
4
|
+
buildResult: (archiveName: string | null) => T;
|
|
5
|
+
archive?: boolean;
|
|
6
|
+
};
|
|
2
7
|
export declare class FileStorage implements RoadmapStorage {
|
|
3
8
|
private readonly directory;
|
|
4
9
|
constructor(directory: string);
|
|
5
10
|
exists(): Promise<boolean>;
|
|
6
11
|
read(): Promise<Roadmap | null>;
|
|
12
|
+
private readFromDisk;
|
|
7
13
|
private acquireLock;
|
|
14
|
+
private fsyncDir;
|
|
15
|
+
private writeAtomic;
|
|
16
|
+
private archiveUnlocked;
|
|
8
17
|
write(roadmap: Roadmap): Promise<void>;
|
|
18
|
+
update<T>(fn: (current: Roadmap | null) => Promise<UpdateResult<T>> | UpdateResult<T>): Promise<T>;
|
|
9
19
|
archive(): Promise<string>;
|
|
10
20
|
}
|
|
11
21
|
export declare class RoadmapValidator {
|
|
@@ -24,3 +34,4 @@ export declare class RoadmapValidator {
|
|
|
24
34
|
static validateDescription(description: string, fieldType: "feature" | "action"): ValidationError | null;
|
|
25
35
|
static validateStatusProgression(currentStatus: string, newStatus: string): ValidationError | null;
|
|
26
36
|
}
|
|
37
|
+
export {};
|
package/dist/src/storage.js
CHANGED
|
@@ -10,6 +10,7 @@ const ROADMAP_FILE = "roadmap.json";
|
|
|
10
10
|
const LOCK_FILE = `${ROADMAP_FILE}.lock`;
|
|
11
11
|
const LOCK_TIMEOUT_MS = 5000;
|
|
12
12
|
const LOCK_RETRY_MS = 50;
|
|
13
|
+
const LOCK_STALE_MS = 30000;
|
|
13
14
|
export class FileStorage {
|
|
14
15
|
directory;
|
|
15
16
|
constructor(directory) {
|
|
@@ -25,6 +26,9 @@ export class FileStorage {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
async read() {
|
|
29
|
+
return this.readFromDisk();
|
|
30
|
+
}
|
|
31
|
+
async readFromDisk() {
|
|
28
32
|
try {
|
|
29
33
|
const filePath = join(this.directory, ROADMAP_FILE);
|
|
30
34
|
const data = await fs.readFile(filePath, "utf-8");
|
|
@@ -63,30 +67,80 @@ export class FileStorage {
|
|
|
63
67
|
};
|
|
64
68
|
}
|
|
65
69
|
catch {
|
|
66
|
-
|
|
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));
|
|
67
79
|
}
|
|
68
80
|
}
|
|
69
81
|
throw new Error("Could not acquire lock on roadmap file. Another operation may be in progress.");
|
|
70
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
|
+
}
|
|
71
121
|
async write(roadmap) {
|
|
72
122
|
await fs.mkdir(this.directory, { recursive: true }).catch(() => { });
|
|
73
123
|
const unlock = await this.acquireLock();
|
|
74
124
|
try {
|
|
125
|
+
const data = JSON.stringify(roadmap, null, 2);
|
|
75
126
|
const filePath = join(this.directory, ROADMAP_FILE);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
127
|
+
await this.writeAtomic(filePath, data);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await unlock();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async update(fn) {
|
|
134
|
+
await fs.mkdir(this.directory, { recursive: true }).catch(() => { });
|
|
135
|
+
const unlock = await this.acquireLock();
|
|
136
|
+
try {
|
|
137
|
+
const current = await this.readFromDisk();
|
|
138
|
+
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;
|
|
143
|
+
return outcome.buildResult(archiveName);
|
|
90
144
|
}
|
|
91
145
|
finally {
|
|
92
146
|
await unlock();
|
|
@@ -96,12 +150,13 @@ export class FileStorage {
|
|
|
96
150
|
if (!(await this.exists())) {
|
|
97
151
|
throw new Error("Cannot archive: roadmap file does not exist");
|
|
98
152
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
153
|
+
const unlock = await this.acquireLock();
|
|
154
|
+
try {
|
|
155
|
+
return await this.archiveUnlocked();
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await unlock();
|
|
159
|
+
}
|
|
105
160
|
}
|
|
106
161
|
}
|
|
107
162
|
export class RoadmapValidator {
|
|
@@ -237,7 +292,7 @@ export class RoadmapValidator {
|
|
|
237
292
|
if (currentStatus === "cancelled") {
|
|
238
293
|
return {
|
|
239
294
|
code: "INVALID_STATUS_TRANSITION",
|
|
240
|
-
message:
|
|
295
|
+
message: "Cannot change status of cancelled action. Create a new action instead.",
|
|
241
296
|
};
|
|
242
297
|
}
|
|
243
298
|
return null;
|
|
@@ -30,117 +30,110 @@ export async function createCreateRoadmapTool(directory) {
|
|
|
30
30
|
},
|
|
31
31
|
async execute(args) {
|
|
32
32
|
const storage = new FileStorage(directory);
|
|
33
|
-
let roadmap;
|
|
34
|
-
let isUpdate = false;
|
|
35
|
-
if (await storage.exists()) {
|
|
36
|
-
const existing = await storage.read();
|
|
37
|
-
if (!existing) {
|
|
38
|
-
throw new Error("Existing roadmap file is corrupted. Please fix manually.");
|
|
39
|
-
}
|
|
40
|
-
roadmap = existing;
|
|
41
|
-
isUpdate = true;
|
|
42
|
-
}
|
|
43
|
-
else {
|
|
44
|
-
roadmap = { features: [] };
|
|
45
|
-
}
|
|
46
33
|
if (!args.features || args.features.length === 0) {
|
|
47
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"}]}]}');
|
|
48
35
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
36
|
+
return await storage.update(async (current) => {
|
|
37
|
+
const roadmap = current ?? { features: [] };
|
|
38
|
+
const isUpdate = current !== null;
|
|
39
|
+
const validationErrors = [];
|
|
40
|
+
// First pass: structural validation of input
|
|
41
|
+
for (const feature of args.features) {
|
|
42
|
+
if (!feature.actions || feature.actions.length === 0) {
|
|
43
|
+
throw new Error(`Feature "${feature.number}" must have at least one action. Each feature needs at least one action to be valid.`);
|
|
44
|
+
}
|
|
45
|
+
const titleError = RoadmapValidator.validateTitle(feature.title, "feature");
|
|
46
|
+
if (titleError)
|
|
47
|
+
validationErrors.push(titleError);
|
|
48
|
+
const descError = RoadmapValidator.validateDescription(feature.description, "feature");
|
|
49
|
+
if (descError)
|
|
50
|
+
validationErrors.push(descError);
|
|
51
|
+
for (const action of feature.actions) {
|
|
52
|
+
const actionTitleError = RoadmapValidator.validateTitle(action.description, "action");
|
|
53
|
+
if (actionTitleError)
|
|
54
|
+
validationErrors.push(actionTitleError);
|
|
55
|
+
}
|
|
54
56
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
for (const action of feature.actions) {
|
|
62
|
-
const actionTitleError = RoadmapValidator.validateTitle(action.description, "action");
|
|
63
|
-
if (actionTitleError)
|
|
64
|
-
validationErrors.push(actionTitleError);
|
|
57
|
+
// Validate sequence consistency of input (internal consistency)
|
|
58
|
+
const sequenceErrors = RoadmapValidator.validateFeatureSequence(args.features);
|
|
59
|
+
validationErrors.push(...sequenceErrors);
|
|
60
|
+
if (validationErrors.length > 0) {
|
|
61
|
+
const errorMessages = validationErrors.map((err) => err.message).join("\n");
|
|
62
|
+
throw new Error(`Validation errors:\n${errorMessages}\n\nPlease fix these issues and try again.`);
|
|
65
63
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (existingFeature.title !== inputFeature.title ||
|
|
80
|
-
existingFeature.description !== inputFeature.description) {
|
|
81
|
-
const msg = await getErrorMessage("immutable_feature", {
|
|
82
|
-
id: inputFeature.number,
|
|
83
|
-
oldTitle: existingFeature.title,
|
|
84
|
-
oldDesc: existingFeature.description,
|
|
85
|
-
newTitle: inputFeature.title,
|
|
86
|
-
newDesc: inputFeature.description,
|
|
87
|
-
});
|
|
88
|
-
throw new Error(msg);
|
|
89
|
-
}
|
|
90
|
-
// Process Actions
|
|
91
|
-
for (const inputAction of inputFeature.actions) {
|
|
92
|
-
const existingAction = existingFeature.actions.find((a) => a.number === inputAction.number);
|
|
93
|
-
if (existingAction) {
|
|
94
|
-
// Action exists: skip (immutable)
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
// New Action: Append
|
|
99
|
-
existingFeature.actions.push({
|
|
100
|
-
number: inputAction.number,
|
|
101
|
-
description: inputAction.description,
|
|
102
|
-
status: inputAction.status,
|
|
64
|
+
// Merge Logic
|
|
65
|
+
for (const inputFeature of args.features) {
|
|
66
|
+
const existingFeature = roadmap.features.find((f) => f.number === inputFeature.number);
|
|
67
|
+
if (existingFeature) {
|
|
68
|
+
// Feature exists: Validate Immutability
|
|
69
|
+
if (existingFeature.title !== inputFeature.title ||
|
|
70
|
+
existingFeature.description !== inputFeature.description) {
|
|
71
|
+
const msg = await getErrorMessage("immutable_feature", {
|
|
72
|
+
id: inputFeature.number,
|
|
73
|
+
oldTitle: existingFeature.title,
|
|
74
|
+
oldDesc: existingFeature.description,
|
|
75
|
+
newTitle: inputFeature.title,
|
|
76
|
+
newDesc: inputFeature.description,
|
|
103
77
|
});
|
|
104
|
-
|
|
105
|
-
|
|
78
|
+
throw new Error(msg);
|
|
79
|
+
}
|
|
80
|
+
// Process Actions
|
|
81
|
+
for (const inputAction of inputFeature.actions) {
|
|
82
|
+
const existingAction = existingFeature.actions.find((a) => a.number === inputAction.number);
|
|
83
|
+
if (existingAction) {
|
|
84
|
+
// Action exists: skip (immutable)
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// New Action: Append
|
|
89
|
+
existingFeature.actions.push({
|
|
90
|
+
number: inputAction.number,
|
|
91
|
+
description: inputAction.description,
|
|
92
|
+
status: inputAction.status,
|
|
93
|
+
});
|
|
94
|
+
// Sort actions to ensure order
|
|
95
|
+
existingFeature.actions.sort((a, b) => parseFloat(a.number) - parseFloat(b.number));
|
|
96
|
+
}
|
|
106
97
|
}
|
|
107
98
|
}
|
|
99
|
+
else {
|
|
100
|
+
// New Feature: Append
|
|
101
|
+
roadmap.features.push({
|
|
102
|
+
number: inputFeature.number,
|
|
103
|
+
title: inputFeature.title,
|
|
104
|
+
description: inputFeature.description,
|
|
105
|
+
actions: inputFeature.actions.map((a) => ({
|
|
106
|
+
number: a.number,
|
|
107
|
+
description: a.description,
|
|
108
|
+
status: a.status,
|
|
109
|
+
})),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
108
112
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
number: a.number,
|
|
117
|
-
description: a.description,
|
|
118
|
-
status: a.status,
|
|
119
|
-
})),
|
|
120
|
-
});
|
|
113
|
+
// Final Sort of Features
|
|
114
|
+
roadmap.features.sort((a, b) => parseInt(a.number) - parseInt(b.number));
|
|
115
|
+
// Safety check: ensure no feature ended up with zero actions after merge
|
|
116
|
+
for (const feature of roadmap.features) {
|
|
117
|
+
if (feature.actions.length === 0) {
|
|
118
|
+
throw new Error(`Feature "${feature.number}" has no actions. This indicates a merge error.`);
|
|
119
|
+
}
|
|
121
120
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
for (const feature of roadmap.features) {
|
|
127
|
-
if (feature.actions.length === 0) {
|
|
128
|
-
throw new Error(`Feature "${feature.number}" has no actions. This indicates a merge error.`);
|
|
121
|
+
// Final Validation of the Merged Roadmap
|
|
122
|
+
const finalErrors = RoadmapValidator.validateFeatureSequence(roadmap.features);
|
|
123
|
+
if (finalErrors.length > 0) {
|
|
124
|
+
throw new Error(`Resulting roadmap would be invalid:\n${finalErrors.map((e) => e.message).join("\n")}`);
|
|
129
125
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
.map((feature) => ` Feature ${feature.number}: ${feature.title} (${feature.actions.length} actions)`)
|
|
142
|
-
.join("\n");
|
|
143
|
-
return summary;
|
|
126
|
+
const totalActions = roadmap.features.reduce((sum, feature) => sum + feature.actions.length, 0);
|
|
127
|
+
const action = isUpdate ? "Updated" : "Created";
|
|
128
|
+
const summary = `${action} roadmap with ${roadmap.features.length} features and ${totalActions} actions:\n` +
|
|
129
|
+
roadmap.features
|
|
130
|
+
.map((feature) => ` Feature ${feature.number}: ${feature.title} (${feature.actions.length} actions)`)
|
|
131
|
+
.join("\n");
|
|
132
|
+
return {
|
|
133
|
+
roadmap,
|
|
134
|
+
buildResult: () => summary,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
144
137
|
},
|
|
145
138
|
});
|
|
146
139
|
}
|
|
@@ -22,96 +22,97 @@ export async function createUpdateRoadmapTool(directory) {
|
|
|
22
22
|
},
|
|
23
23
|
async execute(args) {
|
|
24
24
|
const storage = new FileStorage(directory);
|
|
25
|
-
if (!(await storage.exists())) {
|
|
26
|
-
throw new Error("Roadmap not found. Use CreateRoadmap to create one.");
|
|
27
|
-
}
|
|
28
|
-
const roadmap = await storage.read();
|
|
29
|
-
if (!roadmap) {
|
|
30
|
-
throw new Error("Roadmap file is corrupted. Please fix manually.");
|
|
31
|
-
}
|
|
32
25
|
const actionNumberError = RoadmapValidator.validateActionNumber(args.actionNumber);
|
|
33
26
|
if (actionNumberError) {
|
|
34
27
|
throw new Error(`${actionNumberError.message} Use ReadRoadmap to see valid action numbers.`);
|
|
35
28
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
for (const feature of roadmap.features) {
|
|
40
|
-
const action = feature.actions.find((a) => a.number === args.actionNumber);
|
|
41
|
-
if (action) {
|
|
42
|
-
targetAction = action;
|
|
43
|
-
targetFeature = feature;
|
|
44
|
-
actionFound = true;
|
|
45
|
-
break;
|
|
29
|
+
return await storage.update((roadmap) => {
|
|
30
|
+
if (!roadmap) {
|
|
31
|
+
throw new Error("Roadmap not found. Use CreateRoadmap to create one.");
|
|
46
32
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
const oldStatus = targetAction.status;
|
|
60
|
-
const oldDescription = targetAction.description;
|
|
61
|
-
// Validate description if provided
|
|
62
|
-
if (args.description !== undefined) {
|
|
63
|
-
const descError = RoadmapValidator.validateDescription(args.description, "action");
|
|
64
|
-
if (descError) {
|
|
65
|
-
throw new Error(`${descError.message}`);
|
|
33
|
+
let targetAction = null;
|
|
34
|
+
let targetFeature = null;
|
|
35
|
+
let actionFound = false;
|
|
36
|
+
for (const feature of roadmap.features) {
|
|
37
|
+
const action = feature.actions.find((a) => a.number === args.actionNumber);
|
|
38
|
+
if (action) {
|
|
39
|
+
targetAction = action;
|
|
40
|
+
targetFeature = feature;
|
|
41
|
+
actionFound = true;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
66
44
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// Validate and update status if provided
|
|
70
|
-
if (args.status !== undefined) {
|
|
71
|
-
const statusTransitionError = RoadmapValidator.validateStatusProgression(targetAction.status, args.status);
|
|
72
|
-
if (statusTransitionError) {
|
|
73
|
-
throw new Error(`${statusTransitionError.message} Current status: "${targetAction.status}", requested: "${args.status}"`);
|
|
45
|
+
if (!actionFound) {
|
|
46
|
+
throw new Error(`Action "${args.actionNumber}" not found. Use ReadRoadmap to see valid action numbers.`);
|
|
74
47
|
}
|
|
75
|
-
targetAction
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
let allCompleted = true;
|
|
90
|
-
for (const feature of roadmap.features) {
|
|
91
|
-
for (const action of feature.actions) {
|
|
92
|
-
if (action.status !== "completed") {
|
|
93
|
-
allCompleted = false;
|
|
94
|
-
break;
|
|
48
|
+
if (!targetAction || !targetFeature) {
|
|
49
|
+
throw new Error("Internal error: target action not found.");
|
|
50
|
+
}
|
|
51
|
+
// Validate that at least one field is being updated
|
|
52
|
+
if (args.description === undefined && args.status === undefined) {
|
|
53
|
+
throw new Error("No changes specified. Please provide description and/or status.");
|
|
54
|
+
}
|
|
55
|
+
const oldStatus = targetAction.status;
|
|
56
|
+
const oldDescription = targetAction.description;
|
|
57
|
+
// Validate description if provided
|
|
58
|
+
if (args.description !== undefined) {
|
|
59
|
+
const descError = RoadmapValidator.validateDescription(args.description, "action");
|
|
60
|
+
if (descError) {
|
|
61
|
+
throw new Error(`${descError.message}`);
|
|
95
62
|
}
|
|
63
|
+
targetAction.description = args.description;
|
|
96
64
|
}
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
65
|
+
// Validate and update status if provided
|
|
66
|
+
if (args.status !== undefined) {
|
|
67
|
+
const statusTransitionError = RoadmapValidator.validateStatusProgression(targetAction.status, args.status);
|
|
68
|
+
if (statusTransitionError) {
|
|
69
|
+
throw new Error(`${statusTransitionError.message} Current status: "${targetAction.status}", requested: "${args.status}"`);
|
|
70
|
+
}
|
|
71
|
+
targetAction.status = args.status;
|
|
72
|
+
}
|
|
73
|
+
const changes = [];
|
|
74
|
+
if (args.description !== undefined && oldDescription !== args.description) {
|
|
75
|
+
changes.push("description updated");
|
|
76
|
+
}
|
|
77
|
+
if (args.status !== undefined && oldStatus !== args.status) {
|
|
78
|
+
changes.push(`status: "${oldStatus}" ā "${args.status}"`);
|
|
79
|
+
}
|
|
80
|
+
if (changes.length === 0) {
|
|
81
|
+
return {
|
|
82
|
+
roadmap,
|
|
83
|
+
buildResult: () => `Action ${args.actionNumber} unchanged. Provided values match current state.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Check if all actions are completed
|
|
87
|
+
let allCompleted = true;
|
|
88
|
+
for (const feature of roadmap.features) {
|
|
89
|
+
for (const action of feature.actions) {
|
|
90
|
+
if (action.status !== "completed") {
|
|
91
|
+
allCompleted = false;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!allCompleted)
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
// Format feature context
|
|
99
|
+
const featureCompleted = targetFeature.actions.filter((a) => a.status === "completed").length;
|
|
100
|
+
const featureTotal = targetFeature.actions.length;
|
|
101
|
+
let featureContext = `\n\nFeature ${targetFeature.number}: ${targetFeature.title} (${featureCompleted}/${featureTotal} complete)\n`;
|
|
102
|
+
featureContext += `Description: ${targetFeature.description}\n`;
|
|
103
|
+
for (const action of targetFeature.actions) {
|
|
104
|
+
const statusIcon = action.status === "completed" ? "ā" : action.status === "in_progress" ? "ā" : "ā";
|
|
105
|
+
featureContext += `${action.number} ${statusIcon} ${action.description} [${action.status}]\n`;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
roadmap,
|
|
109
|
+
archive: allCompleted,
|
|
110
|
+
buildResult: (archiveName) => {
|
|
111
|
+
const archiveMsg = archiveName ? `\n\nAll actions completed! Roadmap archived to "${archiveName}".` : "";
|
|
112
|
+
return `Updated action ${args.actionNumber} in feature "${targetFeature.title}": ${changes.join(", ")}${featureContext}${archiveMsg}`;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
});
|
|
115
116
|
},
|
|
116
117
|
});
|
|
117
118
|
}
|