wile 1.0.0 → 1.1.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/dist/agent/scripts/mock-claude.sh +85 -10
- package/dist/agent/scripts/mock-codex.sh +85 -10
- package/dist/agent/scripts/mock-gemini.sh +85 -10
- package/dist/agent/scripts/prompt-compact.md +3 -2
- package/dist/agent/scripts/validate-prd.ts +111 -21
- package/dist/cli.js +87 -18
- package/package.json +10 -1
|
@@ -11,6 +11,79 @@ const prd = JSON.parse(fs.readFileSync(prdPath, "utf8"));
|
|
|
11
11
|
const stories = Array.isArray(prd.stories) ? prd.stories : [];
|
|
12
12
|
const pendingStories = stories.filter((story) => story.status === "pending");
|
|
13
13
|
const doneStories = stories.filter((story) => story.status === "done");
|
|
14
|
+
const parseCompactedFromRanges = (value) => {
|
|
15
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ranges = [];
|
|
20
|
+
const tokens = value.split(",").map((token) => token.trim()).filter(Boolean);
|
|
21
|
+
for (const token of tokens) {
|
|
22
|
+
const match = token.match(/^(-?\d+)(?:\.\.(-?\d+))?$/);
|
|
23
|
+
if (!match) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const start = Number(match[1]);
|
|
28
|
+
const end = match[2] === undefined ? start : Number(match[2]);
|
|
29
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ranges.push({ start, end });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
ranges.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start));
|
|
37
|
+
const merged = [];
|
|
38
|
+
for (const range of ranges) {
|
|
39
|
+
const last = merged[merged.length - 1];
|
|
40
|
+
if (!last || range.start > last.end + 1) {
|
|
41
|
+
merged.push({ ...range });
|
|
42
|
+
} else if (range.end > last.end) {
|
|
43
|
+
last.end = range.end;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return merged;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const compactedIdsFromStory = (story) => {
|
|
51
|
+
const ids = [];
|
|
52
|
+
for (const range of parseCompactedFromRanges(story.compactedFrom)) {
|
|
53
|
+
for (let id = range.start; id <= range.end; id += 1) {
|
|
54
|
+
ids.push(id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return ids;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const compactedFromStringFromIds = (ids) => {
|
|
61
|
+
const sorted = [...new Set(ids)].sort((a, b) => a - b);
|
|
62
|
+
if (sorted.length === 0) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ranges = [];
|
|
67
|
+
let start = sorted[0];
|
|
68
|
+
let end = sorted[0];
|
|
69
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
70
|
+
const id = sorted[i];
|
|
71
|
+
if (id === end + 1) {
|
|
72
|
+
end = id;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ranges.push({ start, end });
|
|
77
|
+
start = id;
|
|
78
|
+
end = id;
|
|
79
|
+
}
|
|
80
|
+
ranges.push({ start, end });
|
|
81
|
+
|
|
82
|
+
return ranges
|
|
83
|
+
.map((range) => (range.start === range.end ? `${range.start}` : `${range.start}..${range.end}`))
|
|
84
|
+
.join(",");
|
|
85
|
+
};
|
|
86
|
+
|
|
14
87
|
const requiredDoneIds = new Set();
|
|
15
88
|
for (const story of pendingStories) {
|
|
16
89
|
const deps = Array.isArray(story.dependsOn) ? story.dependsOn : [];
|
|
@@ -20,25 +93,27 @@ for (const story of pendingStories) {
|
|
|
20
93
|
}
|
|
21
94
|
const retainedDoneStories = doneStories.filter((story) => requiredDoneIds.has(story.id));
|
|
22
95
|
const compactableDoneStories = doneStories.filter((story) => !requiredDoneIds.has(story.id));
|
|
23
|
-
|
|
96
|
+
|
|
97
|
+
let maxReservedId = Math.max(0, ...stories.map((story) => (Number.isInteger(story.id) ? story.id : 0)));
|
|
24
98
|
for (const story of stories) {
|
|
25
|
-
const
|
|
26
|
-
for (const
|
|
27
|
-
|
|
99
|
+
const ranges = parseCompactedFromRanges(story.compactedFrom);
|
|
100
|
+
for (const range of ranges) {
|
|
101
|
+
if (range.end > maxReservedId) {
|
|
102
|
+
maxReservedId = range.end;
|
|
103
|
+
}
|
|
28
104
|
}
|
|
29
105
|
}
|
|
30
|
-
const summaryId =
|
|
106
|
+
const summaryId = maxReservedId + 1;
|
|
31
107
|
|
|
32
108
|
const nextStories = [...pendingStories, ...retainedDoneStories];
|
|
33
109
|
if (compactableDoneStories.length > 0) {
|
|
34
|
-
const
|
|
110
|
+
const compactedIds = [
|
|
35
111
|
...new Set(
|
|
36
112
|
compactableDoneStories.flatMap((story) => {
|
|
37
|
-
|
|
38
|
-
return [story.id, ...priorCompacted];
|
|
113
|
+
return [story.id, ...compactedIdsFromStory(story)];
|
|
39
114
|
})
|
|
40
115
|
)
|
|
41
|
-
]
|
|
116
|
+
];
|
|
42
117
|
|
|
43
118
|
nextStories.push({
|
|
44
119
|
id: summaryId,
|
|
@@ -49,7 +124,7 @@ if (compactableDoneStories.length > 0) {
|
|
|
49
124
|
"Pending stories were preserved unchanged."
|
|
50
125
|
],
|
|
51
126
|
dependsOn: [],
|
|
52
|
-
compactedFrom,
|
|
127
|
+
compactedFrom: compactedFromStringFromIds(compactedIds),
|
|
53
128
|
status: "done"
|
|
54
129
|
});
|
|
55
130
|
}
|
|
@@ -11,6 +11,79 @@ const prd = JSON.parse(fs.readFileSync(prdPath, "utf8"));
|
|
|
11
11
|
const stories = Array.isArray(prd.stories) ? prd.stories : [];
|
|
12
12
|
const pendingStories = stories.filter((story) => story.status === "pending");
|
|
13
13
|
const doneStories = stories.filter((story) => story.status === "done");
|
|
14
|
+
const parseCompactedFromRanges = (value) => {
|
|
15
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ranges = [];
|
|
20
|
+
const tokens = value.split(",").map((token) => token.trim()).filter(Boolean);
|
|
21
|
+
for (const token of tokens) {
|
|
22
|
+
const match = token.match(/^(-?\d+)(?:\.\.(-?\d+))?$/);
|
|
23
|
+
if (!match) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const start = Number(match[1]);
|
|
28
|
+
const end = match[2] === undefined ? start : Number(match[2]);
|
|
29
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ranges.push({ start, end });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
ranges.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start));
|
|
37
|
+
const merged = [];
|
|
38
|
+
for (const range of ranges) {
|
|
39
|
+
const last = merged[merged.length - 1];
|
|
40
|
+
if (!last || range.start > last.end + 1) {
|
|
41
|
+
merged.push({ ...range });
|
|
42
|
+
} else if (range.end > last.end) {
|
|
43
|
+
last.end = range.end;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return merged;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const compactedIdsFromStory = (story) => {
|
|
51
|
+
const ids = [];
|
|
52
|
+
for (const range of parseCompactedFromRanges(story.compactedFrom)) {
|
|
53
|
+
for (let id = range.start; id <= range.end; id += 1) {
|
|
54
|
+
ids.push(id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return ids;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const compactedFromStringFromIds = (ids) => {
|
|
61
|
+
const sorted = [...new Set(ids)].sort((a, b) => a - b);
|
|
62
|
+
if (sorted.length === 0) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ranges = [];
|
|
67
|
+
let start = sorted[0];
|
|
68
|
+
let end = sorted[0];
|
|
69
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
70
|
+
const id = sorted[i];
|
|
71
|
+
if (id === end + 1) {
|
|
72
|
+
end = id;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ranges.push({ start, end });
|
|
77
|
+
start = id;
|
|
78
|
+
end = id;
|
|
79
|
+
}
|
|
80
|
+
ranges.push({ start, end });
|
|
81
|
+
|
|
82
|
+
return ranges
|
|
83
|
+
.map((range) => (range.start === range.end ? `${range.start}` : `${range.start}..${range.end}`))
|
|
84
|
+
.join(",");
|
|
85
|
+
};
|
|
86
|
+
|
|
14
87
|
const requiredDoneIds = new Set();
|
|
15
88
|
for (const story of pendingStories) {
|
|
16
89
|
const deps = Array.isArray(story.dependsOn) ? story.dependsOn : [];
|
|
@@ -20,25 +93,27 @@ for (const story of pendingStories) {
|
|
|
20
93
|
}
|
|
21
94
|
const retainedDoneStories = doneStories.filter((story) => requiredDoneIds.has(story.id));
|
|
22
95
|
const compactableDoneStories = doneStories.filter((story) => !requiredDoneIds.has(story.id));
|
|
23
|
-
|
|
96
|
+
|
|
97
|
+
let maxReservedId = Math.max(0, ...stories.map((story) => (Number.isInteger(story.id) ? story.id : 0)));
|
|
24
98
|
for (const story of stories) {
|
|
25
|
-
const
|
|
26
|
-
for (const
|
|
27
|
-
|
|
99
|
+
const ranges = parseCompactedFromRanges(story.compactedFrom);
|
|
100
|
+
for (const range of ranges) {
|
|
101
|
+
if (range.end > maxReservedId) {
|
|
102
|
+
maxReservedId = range.end;
|
|
103
|
+
}
|
|
28
104
|
}
|
|
29
105
|
}
|
|
30
|
-
const summaryId =
|
|
106
|
+
const summaryId = maxReservedId + 1;
|
|
31
107
|
|
|
32
108
|
const nextStories = [...pendingStories, ...retainedDoneStories];
|
|
33
109
|
if (compactableDoneStories.length > 0) {
|
|
34
|
-
const
|
|
110
|
+
const compactedIds = [
|
|
35
111
|
...new Set(
|
|
36
112
|
compactableDoneStories.flatMap((story) => {
|
|
37
|
-
|
|
38
|
-
return [story.id, ...priorCompacted];
|
|
113
|
+
return [story.id, ...compactedIdsFromStory(story)];
|
|
39
114
|
})
|
|
40
115
|
)
|
|
41
|
-
]
|
|
116
|
+
];
|
|
42
117
|
|
|
43
118
|
nextStories.push({
|
|
44
119
|
id: summaryId,
|
|
@@ -49,7 +124,7 @@ if (compactableDoneStories.length > 0) {
|
|
|
49
124
|
"Pending stories were preserved unchanged."
|
|
50
125
|
],
|
|
51
126
|
dependsOn: [],
|
|
52
|
-
compactedFrom,
|
|
127
|
+
compactedFrom: compactedFromStringFromIds(compactedIds),
|
|
53
128
|
status: "done"
|
|
54
129
|
});
|
|
55
130
|
}
|
|
@@ -11,6 +11,79 @@ const prd = JSON.parse(fs.readFileSync(prdPath, "utf8"));
|
|
|
11
11
|
const stories = Array.isArray(prd.stories) ? prd.stories : [];
|
|
12
12
|
const pendingStories = stories.filter((story) => story.status === "pending");
|
|
13
13
|
const doneStories = stories.filter((story) => story.status === "done");
|
|
14
|
+
const parseCompactedFromRanges = (value) => {
|
|
15
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ranges = [];
|
|
20
|
+
const tokens = value.split(",").map((token) => token.trim()).filter(Boolean);
|
|
21
|
+
for (const token of tokens) {
|
|
22
|
+
const match = token.match(/^(-?\d+)(?:\.\.(-?\d+))?$/);
|
|
23
|
+
if (!match) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const start = Number(match[1]);
|
|
28
|
+
const end = match[2] === undefined ? start : Number(match[2]);
|
|
29
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
ranges.push({ start, end });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
ranges.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start));
|
|
37
|
+
const merged = [];
|
|
38
|
+
for (const range of ranges) {
|
|
39
|
+
const last = merged[merged.length - 1];
|
|
40
|
+
if (!last || range.start > last.end + 1) {
|
|
41
|
+
merged.push({ ...range });
|
|
42
|
+
} else if (range.end > last.end) {
|
|
43
|
+
last.end = range.end;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return merged;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const compactedIdsFromStory = (story) => {
|
|
51
|
+
const ids = [];
|
|
52
|
+
for (const range of parseCompactedFromRanges(story.compactedFrom)) {
|
|
53
|
+
for (let id = range.start; id <= range.end; id += 1) {
|
|
54
|
+
ids.push(id);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return ids;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const compactedFromStringFromIds = (ids) => {
|
|
61
|
+
const sorted = [...new Set(ids)].sort((a, b) => a - b);
|
|
62
|
+
if (sorted.length === 0) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ranges = [];
|
|
67
|
+
let start = sorted[0];
|
|
68
|
+
let end = sorted[0];
|
|
69
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
70
|
+
const id = sorted[i];
|
|
71
|
+
if (id === end + 1) {
|
|
72
|
+
end = id;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ranges.push({ start, end });
|
|
77
|
+
start = id;
|
|
78
|
+
end = id;
|
|
79
|
+
}
|
|
80
|
+
ranges.push({ start, end });
|
|
81
|
+
|
|
82
|
+
return ranges
|
|
83
|
+
.map((range) => (range.start === range.end ? `${range.start}` : `${range.start}..${range.end}`))
|
|
84
|
+
.join(",");
|
|
85
|
+
};
|
|
86
|
+
|
|
14
87
|
const requiredDoneIds = new Set();
|
|
15
88
|
for (const story of pendingStories) {
|
|
16
89
|
const deps = Array.isArray(story.dependsOn) ? story.dependsOn : [];
|
|
@@ -20,25 +93,27 @@ for (const story of pendingStories) {
|
|
|
20
93
|
}
|
|
21
94
|
const retainedDoneStories = doneStories.filter((story) => requiredDoneIds.has(story.id));
|
|
22
95
|
const compactableDoneStories = doneStories.filter((story) => !requiredDoneIds.has(story.id));
|
|
23
|
-
|
|
96
|
+
|
|
97
|
+
let maxReservedId = Math.max(0, ...stories.map((story) => (Number.isInteger(story.id) ? story.id : 0)));
|
|
24
98
|
for (const story of stories) {
|
|
25
|
-
const
|
|
26
|
-
for (const
|
|
27
|
-
|
|
99
|
+
const ranges = parseCompactedFromRanges(story.compactedFrom);
|
|
100
|
+
for (const range of ranges) {
|
|
101
|
+
if (range.end > maxReservedId) {
|
|
102
|
+
maxReservedId = range.end;
|
|
103
|
+
}
|
|
28
104
|
}
|
|
29
105
|
}
|
|
30
|
-
const summaryId =
|
|
106
|
+
const summaryId = maxReservedId + 1;
|
|
31
107
|
|
|
32
108
|
const nextStories = [...pendingStories, ...retainedDoneStories];
|
|
33
109
|
if (compactableDoneStories.length > 0) {
|
|
34
|
-
const
|
|
110
|
+
const compactedIds = [
|
|
35
111
|
...new Set(
|
|
36
112
|
compactableDoneStories.flatMap((story) => {
|
|
37
|
-
|
|
38
|
-
return [story.id, ...priorCompacted];
|
|
113
|
+
return [story.id, ...compactedIdsFromStory(story)];
|
|
39
114
|
})
|
|
40
115
|
)
|
|
41
|
-
]
|
|
116
|
+
];
|
|
42
117
|
|
|
43
118
|
nextStories.push({
|
|
44
119
|
id: summaryId,
|
|
@@ -49,7 +124,7 @@ if (compactableDoneStories.length > 0) {
|
|
|
49
124
|
"Pending stories were preserved unchanged."
|
|
50
125
|
],
|
|
51
126
|
dependsOn: [],
|
|
52
|
-
compactedFrom,
|
|
127
|
+
compactedFrom: compactedFromStringFromIds(compactedIds),
|
|
53
128
|
status: "done"
|
|
54
129
|
});
|
|
55
130
|
}
|
|
@@ -5,6 +5,7 @@ Goal: compact both `.wile/prd.json` and `.wile/progress.txt` while preserving co
|
|
|
5
5
|
Rules:
|
|
6
6
|
- Final `.wile/prd.json` must stay valid under the current schema (`stories`, numeric `id`, `dependsOn`, `status`).
|
|
7
7
|
- Never reuse a compacted story ID. Any ID listed in any `compactedFrom` is permanently reserved.
|
|
8
|
+
- `compactedFrom` must use canonical range-string syntax like `1..3,5` (sorted, non-overlapping, merged).
|
|
8
9
|
- Keep every pending story exactly as-is.
|
|
9
10
|
- Do not create missing dependencies.
|
|
10
11
|
|
|
@@ -18,12 +19,12 @@ Steps:
|
|
|
18
19
|
- Keep all `pendingStories` exactly as-is.
|
|
19
20
|
- Keep all done stories whose id is in `requiredDoneIds` exactly as-is.
|
|
20
21
|
- For remaining done stories, replace them with one summary done story:
|
|
21
|
-
- `id`: next available integer id greater than every story `id` and every
|
|
22
|
+
- `id`: next available integer id greater than every story `id` and every id covered by any `compactedFrom` ranges
|
|
22
23
|
- `title`: `[COMPACT] Completed stories summary`
|
|
23
24
|
- `description`: concise summary of shipped work
|
|
24
25
|
- `acceptanceCriteria`: a short list (1-3 bullets) describing what was completed
|
|
25
26
|
- `dependsOn`: []
|
|
26
|
-
- `compactedFrom`:
|
|
27
|
+
- `compactedFrom`: canonical range-string of all compacted story IDs (example: `1..3,5`)
|
|
27
28
|
- Include each replaced done story `id`
|
|
28
29
|
- If a replaced story already has `compactedFrom`, include those IDs too (preserve tombstones transitively)
|
|
29
30
|
- `status`: `"done"`
|
|
@@ -5,16 +5,26 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
5
5
|
type JsonObject = Record<string, unknown>;
|
|
6
6
|
type StoryStatus = "pending" | "done";
|
|
7
7
|
|
|
8
|
+
type IdRange = {
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
8
13
|
interface Story {
|
|
9
14
|
id: number;
|
|
10
15
|
title: string;
|
|
11
16
|
description: string;
|
|
12
17
|
acceptanceCriteria: string[];
|
|
13
18
|
dependsOn: number[];
|
|
14
|
-
compactedFrom?:
|
|
19
|
+
compactedFrom?: string;
|
|
20
|
+
compactedFromRanges?: IdRange[];
|
|
15
21
|
status: StoryStatus;
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
type ReservedRange = IdRange & {
|
|
25
|
+
ownerStoryId: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
18
28
|
const fail = (message: string): never => {
|
|
19
29
|
console.error(message);
|
|
20
30
|
process.exit(1);
|
|
@@ -76,6 +86,70 @@ const toIntegerArray = (value: unknown, message: string): number[] => {
|
|
|
76
86
|
return items;
|
|
77
87
|
};
|
|
78
88
|
|
|
89
|
+
const compactedFromFormatError =
|
|
90
|
+
'must use canonical range syntax like "1..3,5" (sorted, non-overlapping).';
|
|
91
|
+
|
|
92
|
+
const parseCompactedFrom = (value: unknown, label: string): { value: string; ranges: IdRange[] } => {
|
|
93
|
+
const raw = toNonEmptyString(
|
|
94
|
+
value,
|
|
95
|
+
`${label}.compactedFrom ${compactedFromFormatError}`
|
|
96
|
+
).trim();
|
|
97
|
+
const tokens = raw.split(",").map((token) => token.trim());
|
|
98
|
+
if (tokens.length === 0 || tokens.some((token) => token.length === 0)) {
|
|
99
|
+
fail(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const parsed: IdRange[] = [];
|
|
103
|
+
for (const token of tokens) {
|
|
104
|
+
const match = token.match(/^(-?\d+)(?:\.\.(-?\d+))?$/);
|
|
105
|
+
if (!match) {
|
|
106
|
+
fail(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const start = Number(match[1]);
|
|
110
|
+
const end = match[2] === undefined ? start : Number(match[2]);
|
|
111
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
|
|
112
|
+
fail(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
parsed.push({ start, end });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
parsed.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start));
|
|
119
|
+
|
|
120
|
+
for (let i = 1; i < parsed.length; i += 1) {
|
|
121
|
+
if (parsed[i].start <= parsed[i - 1].end) {
|
|
122
|
+
fail(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const merged: IdRange[] = [];
|
|
127
|
+
for (const range of parsed) {
|
|
128
|
+
const last = merged[merged.length - 1];
|
|
129
|
+
if (!last || range.start > last.end + 1) {
|
|
130
|
+
merged.push({ ...range });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (range.end > last.end) {
|
|
135
|
+
last.end = range.end;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const canonical = merged
|
|
140
|
+
.map((range) => (range.start === range.end ? `${range.start}` : `${range.start}..${range.end}`))
|
|
141
|
+
.join(",");
|
|
142
|
+
const normalizedInput = tokens.join(",");
|
|
143
|
+
if (normalizedInput !== canonical) {
|
|
144
|
+
fail(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
value: canonical,
|
|
149
|
+
ranges: merged
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
79
153
|
const parseStory = (raw: unknown, idx: number): Story => {
|
|
80
154
|
const label = `stories[${idx}]`;
|
|
81
155
|
const storyObj = toObject(raw, `${label} must be an object.`);
|
|
@@ -85,18 +159,16 @@ const parseStory = (raw: unknown, idx: number): Story => {
|
|
|
85
159
|
fail(`${label}.status must be "pending" or "done".`);
|
|
86
160
|
}
|
|
87
161
|
|
|
88
|
-
let compactedFrom:
|
|
162
|
+
let compactedFrom: string | undefined;
|
|
163
|
+
let compactedFromRanges: IdRange[] | undefined;
|
|
89
164
|
if (storyObj.compactedFrom !== undefined) {
|
|
90
|
-
compactedFrom = toIntegerArray(
|
|
91
|
-
storyObj.compactedFrom,
|
|
92
|
-
`${label}.compactedFrom must be an array of integer story IDs.`
|
|
93
|
-
);
|
|
94
|
-
if (new Set(compactedFrom).size !== compactedFrom.length) {
|
|
95
|
-
fail(`${label}.compactedFrom must not contain duplicate IDs.`);
|
|
96
|
-
}
|
|
97
165
|
if (statusRaw !== "done") {
|
|
98
166
|
fail(`${label}.compactedFrom is only allowed when status is "done".`);
|
|
99
167
|
}
|
|
168
|
+
|
|
169
|
+
const parsedCompactedFrom = parseCompactedFrom(storyObj.compactedFrom, label);
|
|
170
|
+
compactedFrom = parsedCompactedFrom.value;
|
|
171
|
+
compactedFromRanges = parsedCompactedFrom.ranges;
|
|
100
172
|
}
|
|
101
173
|
|
|
102
174
|
return {
|
|
@@ -115,6 +187,7 @@ const parseStory = (raw: unknown, idx: number): Story => {
|
|
|
115
187
|
`${label}.dependsOn must be an array of integer story IDs.`
|
|
116
188
|
),
|
|
117
189
|
compactedFrom,
|
|
190
|
+
compactedFromRanges,
|
|
118
191
|
status: statusRaw as StoryStatus
|
|
119
192
|
};
|
|
120
193
|
};
|
|
@@ -165,6 +238,15 @@ const findCycle = (stories: Story[]): number[] | null => {
|
|
|
165
238
|
return null;
|
|
166
239
|
};
|
|
167
240
|
|
|
241
|
+
const findReservedOwner = (id: number, reservedRanges: ReservedRange[]): number | undefined => {
|
|
242
|
+
for (const range of reservedRanges) {
|
|
243
|
+
if (id >= range.start && id <= range.end) {
|
|
244
|
+
return range.ownerStoryId;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
};
|
|
249
|
+
|
|
168
250
|
const args = process.argv.slice(2);
|
|
169
251
|
let prdPath = ".wile/prd.json";
|
|
170
252
|
let summaryOnly = false;
|
|
@@ -217,28 +299,36 @@ for (const story of stories) {
|
|
|
217
299
|
storyById.set(story.id, story);
|
|
218
300
|
}
|
|
219
301
|
|
|
220
|
-
const
|
|
302
|
+
const reservedRanges: ReservedRange[] = [];
|
|
221
303
|
for (const story of stories) {
|
|
222
|
-
for (const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
304
|
+
for (const range of story.compactedFromRanges ?? []) {
|
|
305
|
+
for (const existingRange of reservedRanges) {
|
|
306
|
+
if (range.start <= existingRange.end && existingRange.start <= range.end) {
|
|
307
|
+
const overlapId = Math.max(range.start, existingRange.start);
|
|
308
|
+
fail(
|
|
309
|
+
`Compacted story id ${overlapId} is listed multiple times (stories ${existingRange.ownerStoryId} and ${story.id}).`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
228
312
|
}
|
|
229
|
-
|
|
313
|
+
|
|
314
|
+
reservedRanges.push({
|
|
315
|
+
start: range.start,
|
|
316
|
+
end: range.end,
|
|
317
|
+
ownerStoryId: story.id
|
|
318
|
+
});
|
|
230
319
|
}
|
|
231
320
|
}
|
|
232
321
|
|
|
233
|
-
for (const
|
|
234
|
-
|
|
235
|
-
|
|
322
|
+
for (const story of stories) {
|
|
323
|
+
const ownerStoryId = findReservedOwner(story.id, reservedRanges);
|
|
324
|
+
if (ownerStoryId !== undefined) {
|
|
325
|
+
fail(`Story id ${story.id} is reserved by compactedFrom in story ${ownerStoryId}.`);
|
|
236
326
|
}
|
|
237
327
|
}
|
|
238
328
|
|
|
239
329
|
for (const story of stories) {
|
|
240
330
|
for (const depId of story.dependsOn) {
|
|
241
|
-
const compactedOwner =
|
|
331
|
+
const compactedOwner = findReservedOwner(depId, reservedRanges);
|
|
242
332
|
if (compactedOwner !== undefined) {
|
|
243
333
|
fail(
|
|
244
334
|
`Story ${story.id} depends on compacted story id ${depId} (compacted in story ${compactedOwner}).`
|
package/dist/cli.js
CHANGED
|
@@ -8019,13 +8019,13 @@ Use bullet points for preflight checks, e.g.
|
|
|
8019
8019
|
"- If verification is a command, state the expected result of that command.",
|
|
8020
8020
|
"- Use one behavior per bullet.",
|
|
8021
8021
|
"- Keep IDs stable, unique, and numeric (e.g., 1, 2, 3).",
|
|
8022
|
-
"- Never reuse IDs listed in any story's `compactedFrom
|
|
8022
|
+
"- Never reuse IDs listed in any story's `compactedFrom` ranges.",
|
|
8023
8023
|
'- Avoid vague terms like "should" or "nice".',
|
|
8024
8024
|
"- Keep stories small enough to finish in one iteration.",
|
|
8025
8025
|
'- Use `status: "pending"` for work not done yet and `status: "done"` only after all acceptance criteria are verified.',
|
|
8026
8026
|
"- Use `dependsOn` to model prerequisites by story ID.",
|
|
8027
8027
|
"- `dependsOn` must reference active story IDs only; never reference compacted IDs.",
|
|
8028
|
-
|
|
8028
|
+
'- When compacting completed stories, add a canonical range string like `compactedFrom: "1..3,5"` to the summary done story.',
|
|
8029
8029
|
"- Prefer concrete files/commands only when they reflect the real outcome.",
|
|
8030
8030
|
"",
|
|
8031
8031
|
"Environment notes:",
|
|
@@ -8195,6 +8195,53 @@ var toIntegerArray = (value, message) => {
|
|
|
8195
8195
|
}
|
|
8196
8196
|
return value;
|
|
8197
8197
|
};
|
|
8198
|
+
var compactedFromFormatError = 'must use canonical range syntax like "1..3,5" (sorted, non-overlapping).';
|
|
8199
|
+
var parseCompactedFrom = (value, label) => {
|
|
8200
|
+
const raw = toNonEmptyString(value, `${label}.compactedFrom ${compactedFromFormatError}`).trim();
|
|
8201
|
+
const tokens = raw.split(",").map((token) => token.trim());
|
|
8202
|
+
if (tokens.length === 0 || tokens.some((token) => token.length === 0)) {
|
|
8203
|
+
throw new Error(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
8204
|
+
}
|
|
8205
|
+
const parsed = [];
|
|
8206
|
+
for (const token of tokens) {
|
|
8207
|
+
const match = token.match(/^(-?\d+)(?:\.\.(-?\d+))?$/);
|
|
8208
|
+
if (!match) {
|
|
8209
|
+
throw new Error(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
8210
|
+
}
|
|
8211
|
+
const start = Number(match[1]);
|
|
8212
|
+
const end = match[2] === undefined ? start : Number(match[2]);
|
|
8213
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
|
|
8214
|
+
throw new Error(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
8215
|
+
}
|
|
8216
|
+
parsed.push({ start, end });
|
|
8217
|
+
}
|
|
8218
|
+
parsed.sort((a, b) => a.start === b.start ? a.end - b.end : a.start - b.start);
|
|
8219
|
+
for (let i = 1;i < parsed.length; i += 1) {
|
|
8220
|
+
if (parsed[i].start <= parsed[i - 1].end) {
|
|
8221
|
+
throw new Error(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
8222
|
+
}
|
|
8223
|
+
}
|
|
8224
|
+
const merged = [];
|
|
8225
|
+
for (const range of parsed) {
|
|
8226
|
+
const last = merged[merged.length - 1];
|
|
8227
|
+
if (!last || range.start > last.end + 1) {
|
|
8228
|
+
merged.push({ ...range });
|
|
8229
|
+
continue;
|
|
8230
|
+
}
|
|
8231
|
+
if (range.end > last.end) {
|
|
8232
|
+
last.end = range.end;
|
|
8233
|
+
}
|
|
8234
|
+
}
|
|
8235
|
+
const canonical = merged.map((range) => range.start === range.end ? `${range.start}` : `${range.start}..${range.end}`).join(",");
|
|
8236
|
+
const normalizedInput = tokens.join(",");
|
|
8237
|
+
if (normalizedInput !== canonical) {
|
|
8238
|
+
throw new Error(`${label}.compactedFrom ${compactedFromFormatError}`);
|
|
8239
|
+
}
|
|
8240
|
+
return {
|
|
8241
|
+
value: canonical,
|
|
8242
|
+
ranges: merged
|
|
8243
|
+
};
|
|
8244
|
+
};
|
|
8198
8245
|
var parsePrdStory = (storyRaw, index) => {
|
|
8199
8246
|
const label = `stories[${index}]`;
|
|
8200
8247
|
const story = toObject(storyRaw, `${label} must be an object.`);
|
|
@@ -8208,14 +8255,14 @@ var parsePrdStory = (storyRaw, index) => {
|
|
|
8208
8255
|
throw new Error(`${label}.status must be "pending" or "done".`);
|
|
8209
8256
|
}
|
|
8210
8257
|
let compactedFrom;
|
|
8258
|
+
let compactedFromRanges;
|
|
8211
8259
|
if (story.compactedFrom !== undefined) {
|
|
8212
|
-
compactedFrom = toIntegerArray(story.compactedFrom, `${label}.compactedFrom must be an array of integer story IDs.`);
|
|
8213
|
-
if (new Set(compactedFrom).size !== compactedFrom.length) {
|
|
8214
|
-
throw new Error(`${label}.compactedFrom must not contain duplicate IDs.`);
|
|
8215
|
-
}
|
|
8216
8260
|
if (status !== "done") {
|
|
8217
8261
|
throw new Error(`${label}.compactedFrom is only allowed when status is "done".`);
|
|
8218
8262
|
}
|
|
8263
|
+
const parsedCompactedFrom = parseCompactedFrom(story.compactedFrom, label);
|
|
8264
|
+
compactedFrom = parsedCompactedFrom.value;
|
|
8265
|
+
compactedFromRanges = parsedCompactedFrom.ranges;
|
|
8219
8266
|
}
|
|
8220
8267
|
return {
|
|
8221
8268
|
id,
|
|
@@ -8224,6 +8271,7 @@ var parsePrdStory = (storyRaw, index) => {
|
|
|
8224
8271
|
acceptanceCriteria,
|
|
8225
8272
|
dependsOn,
|
|
8226
8273
|
compactedFrom,
|
|
8274
|
+
compactedFromRanges,
|
|
8227
8275
|
status
|
|
8228
8276
|
};
|
|
8229
8277
|
};
|
|
@@ -8265,6 +8313,19 @@ var findDependencyCycle = (stories) => {
|
|
|
8265
8313
|
}
|
|
8266
8314
|
return null;
|
|
8267
8315
|
};
|
|
8316
|
+
var findReservedOwner = (id, reservedRanges) => {
|
|
8317
|
+
for (const range of reservedRanges) {
|
|
8318
|
+
if (id >= range.start && id <= range.end) {
|
|
8319
|
+
return range.ownerStoryId;
|
|
8320
|
+
}
|
|
8321
|
+
}
|
|
8322
|
+
return;
|
|
8323
|
+
};
|
|
8324
|
+
var toPublicStory = (story) => {
|
|
8325
|
+
const publicStory = { ...story };
|
|
8326
|
+
delete publicStory.compactedFromRanges;
|
|
8327
|
+
return publicStory;
|
|
8328
|
+
};
|
|
8268
8329
|
var validatePrd = (raw) => {
|
|
8269
8330
|
const payload = toObject(raw, "prd.json must be a JSON object.");
|
|
8270
8331
|
if (!Array.isArray(payload.stories)) {
|
|
@@ -8278,23 +8339,31 @@ var validatePrd = (raw) => {
|
|
|
8278
8339
|
}
|
|
8279
8340
|
storyById.set(story.id, story);
|
|
8280
8341
|
}
|
|
8281
|
-
const
|
|
8342
|
+
const reservedRanges = [];
|
|
8282
8343
|
for (const story of stories) {
|
|
8283
|
-
for (const
|
|
8284
|
-
|
|
8285
|
-
|
|
8344
|
+
for (const range of story.compactedFromRanges ?? []) {
|
|
8345
|
+
for (const existingRange of reservedRanges) {
|
|
8346
|
+
if (range.start <= existingRange.end && existingRange.start <= range.end) {
|
|
8347
|
+
const overlapId = Math.max(range.start, existingRange.start);
|
|
8348
|
+
throw new Error(`Compacted story id ${overlapId} is listed multiple times (stories ${existingRange.ownerStoryId} and ${story.id}).`);
|
|
8349
|
+
}
|
|
8286
8350
|
}
|
|
8287
|
-
|
|
8351
|
+
reservedRanges.push({
|
|
8352
|
+
start: range.start,
|
|
8353
|
+
end: range.end,
|
|
8354
|
+
ownerStoryId: story.id
|
|
8355
|
+
});
|
|
8288
8356
|
}
|
|
8289
8357
|
}
|
|
8290
|
-
for (const
|
|
8291
|
-
|
|
8292
|
-
|
|
8358
|
+
for (const story of stories) {
|
|
8359
|
+
const ownerStoryId = findReservedOwner(story.id, reservedRanges);
|
|
8360
|
+
if (ownerStoryId !== undefined) {
|
|
8361
|
+
throw new Error(`Story id ${story.id} is reserved by compactedFrom in story ${ownerStoryId}.`);
|
|
8293
8362
|
}
|
|
8294
8363
|
}
|
|
8295
8364
|
for (const story of stories) {
|
|
8296
8365
|
for (const depId of story.dependsOn) {
|
|
8297
|
-
const compactedOwner =
|
|
8366
|
+
const compactedOwner = findReservedOwner(depId, reservedRanges);
|
|
8298
8367
|
if (compactedOwner !== undefined) {
|
|
8299
8368
|
throw new Error(`Story ${story.id} depends on compacted story id ${depId} (compacted in story ${compactedOwner}).`);
|
|
8300
8369
|
}
|
|
@@ -8314,9 +8383,9 @@ var validatePrd = (raw) => {
|
|
|
8314
8383
|
throw new Error(`No runnable pending stories in prd.json. Pending stories are blocked: ${blockedIds}.`);
|
|
8315
8384
|
}
|
|
8316
8385
|
return {
|
|
8317
|
-
prd: { stories },
|
|
8318
|
-
pendingStories,
|
|
8319
|
-
runnableStory,
|
|
8386
|
+
prd: { stories: stories.map(toPublicStory) },
|
|
8387
|
+
pendingStories: pendingStories.map(toPublicStory),
|
|
8388
|
+
runnableStory: runnableStory ? toPublicStory(runnableStory) : null,
|
|
8320
8389
|
allDone: pendingStories.length === 0
|
|
8321
8390
|
};
|
|
8322
8391
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wile",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Autonomous AI coding agent that ships features while you sleep",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,6 +31,15 @@
|
|
|
31
31
|
"agent",
|
|
32
32
|
"claude"
|
|
33
33
|
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/dooart/wile.git",
|
|
37
|
+
"directory": "packages/cli"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/dooart/wile/tree/main/packages/cli#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/dooart/wile/issues"
|
|
42
|
+
},
|
|
34
43
|
"license": "MIT",
|
|
35
44
|
"devDependencies": {
|
|
36
45
|
"@types/prompts": "^2.4.9",
|