wile 1.0.1 → 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.
@@ -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
- const reservedIds = new Set(stories.map((story) => story.id));
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 priorCompacted = Array.isArray(story.compactedFrom) ? story.compactedFrom : [];
26
- for (const compactedId of priorCompacted) {
27
- reservedIds.add(compactedId);
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 = Math.max(0, ...reservedIds) + 1;
106
+ const summaryId = maxReservedId + 1;
31
107
 
32
108
  const nextStories = [...pendingStories, ...retainedDoneStories];
33
109
  if (compactableDoneStories.length > 0) {
34
- const compactedFrom = [
110
+ const compactedIds = [
35
111
  ...new Set(
36
112
  compactableDoneStories.flatMap((story) => {
37
- const priorCompacted = Array.isArray(story.compactedFrom) ? story.compactedFrom : [];
38
- return [story.id, ...priorCompacted];
113
+ return [story.id, ...compactedIdsFromStory(story)];
39
114
  })
40
115
  )
41
- ].sort((a, b) => a - b);
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
- const reservedIds = new Set(stories.map((story) => story.id));
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 priorCompacted = Array.isArray(story.compactedFrom) ? story.compactedFrom : [];
26
- for (const compactedId of priorCompacted) {
27
- reservedIds.add(compactedId);
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 = Math.max(0, ...reservedIds) + 1;
106
+ const summaryId = maxReservedId + 1;
31
107
 
32
108
  const nextStories = [...pendingStories, ...retainedDoneStories];
33
109
  if (compactableDoneStories.length > 0) {
34
- const compactedFrom = [
110
+ const compactedIds = [
35
111
  ...new Set(
36
112
  compactableDoneStories.flatMap((story) => {
37
- const priorCompacted = Array.isArray(story.compactedFrom) ? story.compactedFrom : [];
38
- return [story.id, ...priorCompacted];
113
+ return [story.id, ...compactedIdsFromStory(story)];
39
114
  })
40
115
  )
41
- ].sort((a, b) => a - b);
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
- const reservedIds = new Set(stories.map((story) => story.id));
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 priorCompacted = Array.isArray(story.compactedFrom) ? story.compactedFrom : [];
26
- for (const compactedId of priorCompacted) {
27
- reservedIds.add(compactedId);
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 = Math.max(0, ...reservedIds) + 1;
106
+ const summaryId = maxReservedId + 1;
31
107
 
32
108
  const nextStories = [...pendingStories, ...retainedDoneStories];
33
109
  if (compactableDoneStories.length > 0) {
34
- const compactedFrom = [
110
+ const compactedIds = [
35
111
  ...new Set(
36
112
  compactableDoneStories.flatMap((story) => {
37
- const priorCompacted = Array.isArray(story.compactedFrom) ? story.compactedFrom : [];
38
- return [story.id, ...priorCompacted];
113
+ return [story.id, ...compactedIdsFromStory(story)];
39
114
  })
40
115
  )
41
- ].sort((a, b) => a - b);
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 value in every `compactedFrom` array
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`: sorted unique list of all compacted story IDs
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?: number[];
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: number[] | undefined;
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 compactedById = new Map<number, number>();
302
+ const reservedRanges: ReservedRange[] = [];
221
303
  for (const story of stories) {
222
- for (const compactedId of story.compactedFrom ?? []) {
223
- const existingOwner = compactedById.get(compactedId);
224
- if (existingOwner !== undefined) {
225
- fail(
226
- `Compacted story id ${compactedId} is listed multiple times (stories ${existingOwner} and ${story.id}).`
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
- compactedById.set(compactedId, story.id);
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 [compactedId, ownerStoryId] of compactedById) {
234
- if (storyById.has(compactedId)) {
235
- fail(`Story id ${compactedId} is reserved by compactedFrom in story ${ownerStoryId}.`);
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 = compactedById.get(depId);
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
- "- When compacting completed stories, add `compactedFrom: [oldId1, oldId2, ...]` to the summary done story.",
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 compactedById = new Map;
8342
+ const reservedRanges = [];
8282
8343
  for (const story of stories) {
8283
- for (const compactedId of story.compactedFrom ?? []) {
8284
- if (compactedById.has(compactedId)) {
8285
- throw new Error(`Compacted story id ${compactedId} is listed multiple times (stories ${compactedById.get(compactedId)} and ${story.id}).`);
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
- compactedById.set(compactedId, story.id);
8351
+ reservedRanges.push({
8352
+ start: range.start,
8353
+ end: range.end,
8354
+ ownerStoryId: story.id
8355
+ });
8288
8356
  }
8289
8357
  }
8290
- for (const [compactedId, ownerStoryId] of compactedById) {
8291
- if (storyById.has(compactedId)) {
8292
- throw new Error(`Story id ${compactedId} is reserved by compactedFrom in story ${ownerStoryId}.`);
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 = compactedById.get(depId);
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.0.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": {