schub 0.1.2 → 0.1.4

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.
Files changed (80) hide show
  1. package/README.md +27 -0
  2. package/dist/index.js +12830 -3057
  3. package/package.json +5 -2
  4. package/skills/create-proposal/SKILL.md +5 -1
  5. package/skills/create-tasks/SKILL.md +5 -4
  6. package/skills/implement-task/SKILL.md +6 -1
  7. package/skills/review-proposal/SKILL.md +3 -2
  8. package/skills/update-roadmap/SKILL.md +23 -0
  9. package/src/changes.test.ts +166 -0
  10. package/src/changes.ts +159 -54
  11. package/src/commands/adr.test.ts +6 -5
  12. package/src/commands/changes.test.ts +136 -14
  13. package/src/commands/changes.ts +102 -1
  14. package/src/commands/cookbook.test.ts +6 -5
  15. package/src/commands/init.test.ts +69 -2
  16. package/src/commands/init.ts +48 -5
  17. package/src/commands/review.test.ts +7 -6
  18. package/src/commands/review.ts +1 -1
  19. package/src/commands/roadmap.test.ts +84 -0
  20. package/src/commands/roadmap.ts +84 -0
  21. package/src/commands/tasks-create.test.ts +22 -22
  22. package/src/commands/tasks-implement.test.ts +253 -0
  23. package/src/commands/tasks-implement.ts +121 -0
  24. package/src/commands/tasks-list.test.ts +27 -27
  25. package/src/commands/tasks-update.test.ts +92 -0
  26. package/src/commands/tasks.ts +98 -1
  27. package/src/features/roadmap/index.ts +230 -0
  28. package/src/features/roadmap/roadmap.test.ts +77 -0
  29. package/src/features/tasks/constants.ts +1 -0
  30. package/src/features/tasks/create.ts +10 -8
  31. package/src/features/tasks/filesystem.test.ts +285 -18
  32. package/src/features/tasks/filesystem.ts +152 -39
  33. package/src/features/tasks/graph.ts +18 -3
  34. package/src/features/tasks/index.ts +10 -1
  35. package/src/features/tasks/worktree.ts +48 -0
  36. package/src/frontmatter.ts +115 -0
  37. package/src/index.test.ts +42 -6
  38. package/src/index.ts +226 -109
  39. package/src/opencode.test.ts +53 -0
  40. package/src/opencode.ts +74 -0
  41. package/src/tasks.ts +2 -0
  42. package/src/tui/App.test.tsx +418 -0
  43. package/src/tui/App.tsx +343 -0
  44. package/src/tui/components/PlanView.test.tsx +101 -0
  45. package/src/tui/components/PlanView.tsx +89 -0
  46. package/src/tui/components/PreviewPage.test.tsx +69 -0
  47. package/src/tui/components/PreviewPage.tsx +87 -0
  48. package/src/tui/components/ProposalDetailView.test.tsx +169 -0
  49. package/src/tui/components/ProposalDetailView.tsx +166 -0
  50. package/src/tui/components/RoadmapView.test.tsx +85 -0
  51. package/src/tui/components/RoadmapView.tsx +369 -0
  52. package/src/tui/components/StatusView.test.tsx +1351 -0
  53. package/src/tui/components/StatusView.tsx +519 -0
  54. package/src/tui/components/markdown-renderer.test.ts +46 -0
  55. package/src/tui/components/markdown-renderer.ts +89 -0
  56. package/src/tui/components/status-view-data.ts +322 -0
  57. package/src/tui/components/status-view-render.tsx +329 -0
  58. package/src/tui/index.ts +16 -0
  59. package/templates/create-proposal/adr-template.md +6 -4
  60. package/templates/create-proposal/cookbook-template.md +5 -3
  61. package/templates/create-proposal/proposal-template.md +8 -6
  62. package/templates/create-roadmap/roadmap.md +5 -0
  63. package/templates/create-tasks/task-template.md +9 -4
  64. package/templates/review-proposal/q&a-template.md +8 -3
  65. package/templates/review-proposal/review-me-template.md +6 -4
  66. package/templates/setup-project/project-overview-template.md +5 -0
  67. package/templates/setup-project/project-setup-template.md +5 -0
  68. package/templates/setup-project/project-wow-template.md +5 -0
  69. package/src/App.test.tsx +0 -93
  70. package/src/App.tsx +0 -155
  71. package/src/components/PlanView.test.tsx +0 -113
  72. package/src/components/PlanView.tsx +0 -160
  73. package/src/components/StatusView.test.tsx +0 -380
  74. package/src/components/StatusView.tsx +0 -367
  75. package/src/ide.ts +0 -7
  76. package/templates/templates-parity.test.ts +0 -45
  77. /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
  78. /package/src/{components → tui/components}/statusColor.ts +0 -0
  79. /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
  80. /package/src/{terminal.ts → tui/terminal.ts} +0 -0
package/src/changes.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, relative } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { type FrontmatterData, readFrontmatter, updateFrontmatterValue } from "./frontmatter";
4
5
  import { resolveSchubRoot } from "./schub-root";
5
6
  import { resolveTemplatePath } from "./templates";
6
7
 
@@ -11,8 +12,19 @@ export type ChangeInfo = {
11
12
  path: string;
12
13
  };
13
14
 
14
- const CHANGE_ID_PATTERN = /^(?:[Cc]\d{3}_)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
-
15
+ export type ChangeOverviewInfo = ChangeInfo & {
16
+ statusLabel: string;
17
+ statusOrder: number;
18
+ };
19
+ export type ChangeDetail = {
20
+ changeId: string;
21
+ created: string;
22
+ status: string;
23
+ input: string;
24
+ summary: string;
25
+ };
26
+ const CHANGE_ID_PATTERN = /^(?:[Cc]\d+_)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
27
+ const SUMMARY_FALLBACK = "No summary provided.";
16
28
  const isDirectory = (path: string) => {
17
29
  try {
18
30
  return statSync(path).isDirectory();
@@ -20,28 +32,52 @@ const isDirectory = (path: string) => {
20
32
  return false;
21
33
  }
22
34
  };
23
-
35
+ const readFrontmatterString = (data: FrontmatterData, key: string) => {
36
+ const value = data[key];
37
+ if (typeof value === "string") {
38
+ return value.trim();
39
+ }
40
+ return "";
41
+ };
24
42
  const parseProposal = (content: string, changeId: string) => {
25
43
  const titleMatch = content.match(/^#\s+Proposal\s+-\s+(.*)$/m);
26
- const statusMatch = content.match(/^\*\*Status\*\*:\s*(.+)$/m);
44
+ const { data } = readFrontmatter(content);
45
+ const status = readFrontmatterString(data, "status");
27
46
 
28
47
  return {
29
48
  title: titleMatch?.[1]?.trim() ?? changeId,
30
- status: statusMatch?.[1]?.trim() ?? "unknown",
49
+ status: status || "unknown",
31
50
  };
32
51
  };
52
+ const parseProposalSummary = (content: string) => {
53
+ const summaryMatch = content.match(/(?:^|\n)##\s+Summary[^\S\r\n]*\n([\s\S]*?)(?:\n##\s|\n#\s|$)/);
54
+ const summary = summaryMatch?.[1]?.trim() ?? "";
55
+ return summary || SUMMARY_FALLBACK;
56
+ };
57
+ const parseProposalDetail = (content: string, changeId: string) => {
58
+ const { data, body } = readFrontmatter(content);
59
+ const status = readFrontmatterString(data, "status");
60
+ const storedChangeId = readFrontmatterString(data, "change_id");
61
+ const created = readFrontmatterString(data, "created");
62
+ const input = readFrontmatterString(data, "input");
33
63
 
64
+ return {
65
+ changeId: storedChangeId || changeId,
66
+ created,
67
+ status: status || "unknown",
68
+ input,
69
+ summary: parseProposalSummary(body),
70
+ };
71
+ };
34
72
  export const normalizeChangeId = (value: string) => {
35
73
  const trimmed = value.trim();
36
- const match = trimmed.match(/^([Cc])(\d{3})_(.+)$/);
74
+ const match = trimmed.match(/^([Cc])(\d+)_(.+)$/);
37
75
  if (match) {
38
76
  return `C${match[2]}_${match[3]}`;
39
77
  }
40
78
  return trimmed;
41
79
  };
42
-
43
80
  export const isValidChangeId = (value: string) => CHANGE_ID_PATTERN.test(value.trim());
44
-
45
81
  export const readChangeSummary = (schubDir: string, changeId: string) => {
46
82
  const trimmed = changeId.trim();
47
83
  if (!trimmed) {
@@ -49,7 +85,7 @@ export const readChangeSummary = (schubDir: string, changeId: string) => {
49
85
  }
50
86
 
51
87
  if (!isValidChangeId(trimmed)) {
52
- throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C001_add-user-auth).`);
88
+ throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C1_add-user-auth).`);
53
89
  }
54
90
 
55
91
  const normalized = normalizeChangeId(trimmed);
@@ -72,7 +108,19 @@ export const readChangeSummary = (schubDir: string, changeId: string) => {
72
108
  proposalPath,
73
109
  };
74
110
  };
111
+ export const readChangeDetail = (schubDir: string, changeId: string) => {
112
+ const summary = readChangeSummary(schubDir, changeId);
113
+ const content = readFileSync(summary.proposalPath, "utf8");
114
+ const detail = parseProposalDetail(content, summary.changeId);
75
115
 
116
+ return {
117
+ changeId: detail.changeId,
118
+ created: detail.created,
119
+ status: detail.status,
120
+ input: detail.input,
121
+ summary: detail.summary,
122
+ };
123
+ };
76
124
  export const updateChangeStatus = (schubDir: string, changeId: string, status: string) => {
77
125
  const nextStatus = status.trim();
78
126
  if (!nextStatus) {
@@ -81,16 +129,16 @@ export const updateChangeStatus = (schubDir: string, changeId: string, status: s
81
129
 
82
130
  const summary = readChangeSummary(schubDir, changeId);
83
131
  const content = readFileSync(summary.proposalPath, "utf8");
84
- const statusMatch = content.match(/^\*\*Status\*\*:\s*(.+)$/m);
85
- const previousStatus = statusMatch?.[1]?.trim();
132
+ const { data } = readFrontmatter(content);
133
+ const previousStatus = readFrontmatterString(data, "status");
86
134
 
87
135
  if (!previousStatus) {
88
136
  throw new Error(
89
- `Proposal status not found in ${summary.proposalPath}.\nAdd a '**Status**: <value>' line before updating status.`,
137
+ `Proposal status not found in ${summary.proposalPath}.\nAdd a 'status' field in frontmatter before updating status.`,
90
138
  );
91
139
  }
92
140
 
93
- const updated = content.replace(/^\*\*Status\*\*:\s*(.+)$/m, `**Status**: ${nextStatus}`);
141
+ const updated = updateFrontmatterValue(content, "status", nextStatus);
94
142
  writeFileSync(summary.proposalPath, updated, "utf8");
95
143
 
96
144
  return {
@@ -100,32 +148,46 @@ export const updateChangeStatus = (schubDir: string, changeId: string, status: s
100
148
  status: nextStatus,
101
149
  };
102
150
  };
151
+ export const archiveChange = (schubDir: string, changeId: string) => {
152
+ const summary = readChangeSummary(schubDir, changeId);
153
+ const archiveRoot = join(schubDir, "archive", "changes");
154
+ const archivePath = join(archiveRoot, summary.changeId);
103
155
 
156
+ if (existsSync(archivePath)) {
157
+ throw new Error(`Archive already exists: ${archivePath}`);
158
+ }
159
+
160
+ mkdirSync(archiveRoot, { recursive: true });
161
+
162
+ const updated = updateChangeStatus(schubDir, summary.changeId, "Archived");
163
+ renameSync(summary.changeDir, archivePath);
164
+
165
+ return {
166
+ changeId: updated.changeId,
167
+ previousStatus: updated.previousStatus,
168
+ status: updated.status,
169
+ proposalPath: join(archivePath, "proposal.md"),
170
+ archivePath,
171
+ };
172
+ };
104
173
  const changeNumber = (id: string) => {
105
174
  const match = id.match(/\d+/);
106
175
  return match ? Number(match[0]) : Number.POSITIVE_INFINITY;
107
176
  };
108
-
109
- export const listChanges = (schubDir: string) => {
110
- const changesRoot = join(schubDir, "changes");
177
+ const readChangesFromRoot = (changesRoot: string, repoRoot: string) => {
111
178
  if (!existsSync(changesRoot) || !isDirectory(changesRoot)) {
112
179
  return [];
113
180
  }
114
-
115
- const repoRoot = dirname(schubDir);
116
181
  const entries = readdirSync(changesRoot, { withFileTypes: true });
117
182
  const changes: ChangeInfo[] = [];
118
-
119
183
  for (const entry of entries) {
120
184
  if (!entry.isDirectory()) {
121
185
  continue;
122
186
  }
123
-
124
187
  const proposalPath = join(changesRoot, entry.name, "proposal.md");
125
188
  if (!existsSync(proposalPath)) {
126
189
  continue;
127
190
  }
128
-
129
191
  const content = readFileSync(proposalPath, "utf8");
130
192
  const parsed = parseProposal(content, entry.name);
131
193
  changes.push({
@@ -135,58 +197,96 @@ export const listChanges = (schubDir: string) => {
135
197
  path: relative(repoRoot, proposalPath),
136
198
  });
137
199
  }
138
-
139
- return changes.sort((a, b) => {
140
- const numberDiff = changeNumber(a.id) - changeNumber(b.id);
200
+ return changes;
201
+ };
202
+ export const listChanges = (schubDir: string) => {
203
+ const repoRoot = dirname(schubDir);
204
+ const changes = readChangesFromRoot(join(schubDir, "changes"), repoRoot);
205
+ return changes.sort((left, right) => {
206
+ const numberDiff = changeNumber(left.id) - changeNumber(right.id);
141
207
  if (numberDiff !== 0) {
142
208
  return numberDiff;
143
209
  }
144
- return a.id.localeCompare(b.id);
210
+ return left.id.localeCompare(right.id);
211
+ });
212
+ };
213
+ const STATUS_GROUPS = [
214
+ { label: "Draft", match: "draft" },
215
+ { label: "Pending Review", match: "review" },
216
+ { label: "Accepted", match: "accepted" },
217
+ { label: "Implementing", match: "implement" },
218
+ { label: "Done", match: "done" },
219
+ { label: "Archived", match: "archiv" },
220
+ ];
221
+
222
+ const normalizeStatusLabel = (status: string) => {
223
+ const trimmed = status.trim();
224
+ const normalized = trimmed.toLowerCase();
225
+ for (const [index, group] of STATUS_GROUPS.entries()) {
226
+ if (normalized.includes(group.match)) {
227
+ return { label: group.label, order: index };
228
+ }
229
+ }
230
+ const label = trimmed
231
+ .split(/\s+/)
232
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
233
+ .join(" ");
234
+ return { label: label || "Unknown", order: STATUS_GROUPS.length };
235
+ };
236
+ export const listChangeOverview = (schubDir: string) => {
237
+ const repoRoot = dirname(schubDir);
238
+ const combined = new Map<string, ChangeInfo>();
239
+ for (const root of [join(schubDir, "changes"), join(schubDir, "archive", "changes")]) {
240
+ for (const change of readChangesFromRoot(root, repoRoot)) {
241
+ if (!combined.has(change.id)) {
242
+ combined.set(change.id, change);
243
+ }
244
+ }
245
+ }
246
+ return Array.from(combined.values()).map((change) => {
247
+ const normalized = normalizeStatusLabel(change.status);
248
+ return {
249
+ ...change,
250
+ statusLabel: normalized.label,
251
+ statusOrder: normalized.order,
252
+ };
145
253
  });
146
254
  };
147
-
148
255
  const slugify = (value: string) => {
149
256
  let slug = value.trim().toLowerCase();
150
257
  slug = slug.replace(/[^a-z0-9]+/g, "-");
151
258
  slug = slug.replace(/-{2,}/g, "-");
152
259
  return slug.replace(/^-|-$/g, "");
153
260
  };
154
-
155
261
  const splitPrefixedChangeId = (changeId: string) => {
156
- const match = changeId.match(/^([Cc])(\d{3})_([a-z0-9]+(?:-[a-z0-9]+)*)$/);
262
+ const match = changeId.match(/^([Cc])(\d+)_([a-z0-9]+(?:-[a-z0-9]+)*)$/);
157
263
  if (match) {
158
264
  return { prefix: match[2], slug: match[3] };
159
265
  }
160
266
  return { prefix: null, slug: changeId };
161
267
  };
162
-
163
268
  const nextChangePrefix = (schubDir: string) => {
164
269
  const changesRoot = join(schubDir, "changes");
165
270
  const archiveRoot = join(schubDir, "archive", "changes");
166
271
  const prefixes: number[] = [];
167
-
168
272
  const scan = (root: string) => {
169
273
  if (!existsSync(root) || !isDirectory(root)) return;
170
274
  for (const entry of readdirSync(root, { withFileTypes: true })) {
171
275
  if (!entry.isDirectory()) {
172
276
  continue;
173
277
  }
174
- const match = entry.name.match(/^[Cc](\d{3})_/);
278
+ const match = entry.name.match(/^[Cc](\d+)_/);
175
279
  if (match) {
176
280
  prefixes.push(Number.parseInt(match[1], 10));
177
281
  }
178
282
  }
179
283
  };
180
-
181
284
  scan(changesRoot);
182
285
  scan(archiveRoot);
183
-
184
286
  const next = prefixes.length > 0 ? Math.max(...prefixes) + 1 : 1;
185
- return next.toString().padStart(3, "0");
287
+ return next.toString().padStart(4, "0");
186
288
  };
187
-
188
289
  const CHANGE_PREFIX = "C";
189
-
190
290
  const BUNDLED_PROPOSAL_TEMPLATE_PATH = fileURLToPath(
191
291
  new URL("../templates/create-proposal/proposal-template.md", import.meta.url),
192
292
  );
@@ -203,31 +303,36 @@ const readProposalTemplate = (schubDir: string) => {
203
303
  throw new Error(`[ERROR] Template not found: ${templatePath}`);
204
304
  }
205
305
  };
206
-
207
306
  const isValidSlug = (value: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
208
-
209
307
  const changeExists = (schubDir: string, changeId: string) => {
210
308
  const active = join(schubDir, "changes", changeId);
211
309
  if (existsSync(active)) {
212
310
  return true;
213
311
  }
214
-
215
312
  const archiveRoot = join(schubDir, "archive", "changes");
216
313
  if (!existsSync(archiveRoot) || !isDirectory(archiveRoot)) {
217
314
  return false;
218
315
  }
219
-
220
316
  for (const entry of readdirSync(archiveRoot, { withFileTypes: true })) {
221
317
  if (entry.isDirectory() && entry.name.includes(changeId)) {
222
318
  return true;
223
319
  }
224
320
  }
225
-
226
321
  return false;
227
322
  };
228
-
323
+ const findChangeByPrefix = (schubDir: string, prefix: string) => {
324
+ const normalizedPrefix = prefix.toUpperCase();
325
+ for (const root of [join(schubDir, "changes"), join(schubDir, "archive", "changes")]) {
326
+ if (!existsSync(root) || !isDirectory(root)) continue;
327
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
328
+ if (entry.isDirectory() && entry.name.toUpperCase().startsWith(`${normalizedPrefix}_`)) {
329
+ return entry.name;
330
+ }
331
+ }
332
+ }
333
+ return null;
334
+ };
229
335
  export const resolveChangeRoot = resolveSchubRoot;
230
-
231
336
  export const createChange = (
232
337
  schubDir: string,
233
338
  options: { changeId?: string; title?: string; input?: string; overwrite?: boolean },
@@ -235,7 +340,6 @@ export const createChange = (
235
340
  let changeId = (options.changeId || "").trim();
236
341
  let title = (options.title || "").trim();
237
342
  const input = (options.input || "").trim();
238
-
239
343
  if (!changeId) {
240
344
  if (!title) {
241
345
  throw new Error("Provide --change-id or --title.");
@@ -246,13 +350,11 @@ export const createChange = (
246
350
  }
247
351
  console.warn(`[WARN] Derived change-id '${changeId}' from --title. Prefer verb-led ids.`);
248
352
  }
249
-
250
353
  const originalChangeId = changeId;
251
354
  const { prefix: existingPrefix, slug } = splitPrefixedChangeId(changeId);
252
355
  if (!isValidSlug(slug)) {
253
356
  throw new Error(`Invalid change-id '${changeId}'. Use kebab-case (lowercase letters/digits and hyphens).`);
254
357
  }
255
-
256
358
  if (!existingPrefix) {
257
359
  const prefix = nextChangePrefix(schubDir);
258
360
  changeId = `${CHANGE_PREFIX}${prefix}_${slug}`;
@@ -263,18 +365,24 @@ export const createChange = (
263
365
  console.error(`[INFO] Normalized change-id to '${changeId}'.`);
264
366
  }
265
367
  }
266
-
267
368
  if (!title) {
268
369
  title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
269
370
  }
270
-
371
+ const prefixMatch = changeId.match(/^([Cc]\d+)_/);
372
+ if (prefixMatch) {
373
+ const prefix = prefixMatch[1].toUpperCase();
374
+ const existing = findChangeByPrefix(schubDir, prefix);
375
+ if (existing && existing !== changeId) {
376
+ throw new Error(
377
+ `Change prefix '${prefix}' already exists as '${existing}'. Choose a new prefix or omit it to auto-generate.`,
378
+ );
379
+ }
380
+ }
271
381
  const changeDir = join(schubDir, "changes", changeId);
272
382
  const proposalPath = join(changeDir, "proposal.md");
273
-
274
383
  if (changeExists(schubDir, changeId) && !options.overwrite) {
275
384
  throw new Error(`Change '${changeId}' already exists under ${schubDir}. Choose a unique id or pass --overwrite.`);
276
385
  }
277
-
278
386
  const template = readProposalTemplate(schubDir);
279
387
  const today = new Date().toISOString().split("T")[0];
280
388
  const rendered = template
@@ -283,13 +391,10 @@ export const createChange = (
283
391
  .replace("{{DATE}}", today)
284
392
  .replace("{{INPUT}}", input || "[no input provided]")
285
393
  .replace("{{AGENT_ROOT}}", schubDir);
286
-
287
394
  if (existsSync(proposalPath) && !options.overwrite) {
288
395
  throw new Error(`Refusing to overwrite existing file: ${proposalPath}`);
289
396
  }
290
-
291
397
  mkdirSync(changeDir, { recursive: true });
292
398
  writeFileSync(proposalPath, rendered, "utf8");
293
-
294
399
  return proposalPath;
295
400
  };
@@ -37,19 +37,20 @@ const seedChange = (schubRoot: string, changeId: string, title: string) => {
37
37
  const changeDir = join(schubRoot, "changes", changeId);
38
38
  mkdirSync(changeDir, { recursive: true });
39
39
  const proposal = [
40
+ "---",
41
+ `change_id: ${changeId}`,
42
+ "created: 2024-01-01",
43
+ "status: Draft",
44
+ "---",
40
45
  `# Proposal - ${title}`,
41
46
  "",
42
- `**Change ID**: \`${changeId}\``,
43
- "**Created**: 2024-01-01",
44
- "**Status**: Draft",
45
- "",
46
47
  ].join("\n");
47
48
  writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
48
49
  };
49
50
 
50
51
  test("adr create scaffolds ADR file", () => {
51
52
  const { cwd, schubRoot } = createRepo();
52
- const changeId = "C003_new-adr";
53
+ const changeId = "C0003_new-adr";
53
54
  const changeTitle = "New ADR";
54
55
  seedChange(schubRoot, changeId, changeTitle);
55
56
 
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { spawnSync } from "bun";
7
7
 
@@ -38,6 +38,34 @@ const runChangesStatus = (schubCwd: string, args: string[] = []) => {
38
38
  };
39
39
  };
40
40
 
41
+ const runChangesArchive = (schubCwd: string, args: string[] = []) => {
42
+ const result = spawnSync({
43
+ cmd: ["bun", "run", "schub", "changes", "archive", ...args],
44
+ cwd: cliDir,
45
+ env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
46
+ });
47
+
48
+ return {
49
+ result,
50
+ stdout: decoder.decode(result.stdout ?? new Uint8Array()),
51
+ stderr: decoder.decode(result.stderr ?? new Uint8Array()),
52
+ };
53
+ };
54
+
55
+ const runChangesList = (schubCwd: string, args: string[] = []) => {
56
+ const result = spawnSync({
57
+ cmd: ["bun", "run", "schub", "changes", "list", ...args],
58
+ cwd: cliDir,
59
+ env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
60
+ });
61
+
62
+ return {
63
+ result,
64
+ stdout: decoder.decode(result.stdout ?? new Uint8Array()),
65
+ stderr: decoder.decode(result.stderr ?? new Uint8Array()),
66
+ };
67
+ };
68
+
41
69
  const createRepo = () => {
42
70
  const base = mkdtempSync(join(tmpdir(), "schub-changes-"));
43
71
  const repoRoot = join(base, "repo");
@@ -52,17 +80,27 @@ const seedChange = (schubRoot: string, changeId: string) => {
52
80
  writeFileSync(join(changeDir, "proposal.md"), "# Proposal - Seed\n", "utf8");
53
81
  };
54
82
 
55
- const seedProposal = (schubRoot: string, changeId: string, status: string) => {
83
+ const seedProposal = (schubRoot: string, changeId: string, status: string, title = "Seed") => {
56
84
  const changeDir = join(schubRoot, "changes", changeId);
57
85
  mkdirSync(changeDir, { recursive: true });
58
- const proposal = `# Proposal - Seed\n**Status**: ${status}\n`;
86
+ const proposal = `---\nstatus: ${status}\n---\n# Proposal - ${title}\n`;
59
87
  writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
60
88
  };
61
89
 
90
+ const seedTask = (schubRoot: string, status: string, taskId: string, changeId: string, titleSlug = "seed-task") => {
91
+ const taskDir = join(schubRoot, "tasks", status);
92
+ mkdirSync(taskDir, { recursive: true });
93
+ const fileName = `${taskId}_${titleSlug}.md`;
94
+ const taskPath = join(taskDir, fileName);
95
+ const taskBody = `---\nchange_id: ${changeId}\n---\n# Task: ${taskId} Seed\n`;
96
+ writeFileSync(taskPath, taskBody, "utf8");
97
+ return taskPath;
98
+ };
99
+
62
100
  test("changes create scaffolds proposal with prefixed id", () => {
63
101
  const { repoRoot, cwd } = createRepo();
64
102
  const schubRoot = join(repoRoot, ".schub");
65
- seedChange(schubRoot, "C002_existing-change");
103
+ seedChange(schubRoot, "C0002_existing-change");
66
104
 
67
105
  const title = "Update CLI scaffolding";
68
106
  const input = "user prompt";
@@ -77,7 +115,7 @@ test("changes create scaffolds proposal with prefixed id", () => {
77
115
 
78
116
  expect(result.exitCode).toBe(0);
79
117
 
80
- const changeId = "C003_update-cli-scaffolding";
118
+ const changeId = "C0003_update-cli-scaffolding";
81
119
  const proposalPath = join(schubRoot, "changes", changeId, "proposal.md");
82
120
  expect(existsSync(proposalPath)).toBe(true);
83
121
 
@@ -115,7 +153,7 @@ test("changes create requires change id or title", () => {
115
153
  test("changes create respects overwrite", () => {
116
154
  const { cwd } = createRepo();
117
155
  const schubRoot = join(cwd, ".schub");
118
- const changeId = "C001_repeatable-change";
156
+ const changeId = "C0001_repeatable-change";
119
157
 
120
158
  const first = runChangesCreate(cwd, ["--change-id", changeId, "--title", "First"]);
121
159
  expect(first.result.exitCode).toBe(0);
@@ -143,29 +181,113 @@ test("changes create rejects schub root flags", () => {
143
181
  test("changes status updates accepted proposals", () => {
144
182
  const { repoRoot, cwd } = createRepo();
145
183
  const schubRoot = join(repoRoot, ".schub");
146
- seedProposal(schubRoot, "C001_update-cli", "Accepted");
184
+ seedProposal(schubRoot, "C0001_update-cli", "Accepted");
147
185
 
148
- const { result, stdout } = runChangesStatus(cwd, ["--change-id", "C001_update-cli", "--status", "Done"]);
186
+ const { result, stdout } = runChangesStatus(cwd, ["--change-id", "C0001_update-cli", "--status", "Done"]);
149
187
 
150
188
  expect(result.exitCode).toBe(0);
151
189
 
152
- const proposalPath = join(schubRoot, "changes", "C001_update-cli", "proposal.md");
190
+ const proposalPath = join(schubRoot, "changes", "C0001_update-cli", "proposal.md");
153
191
  const updated = readFileSync(proposalPath, "utf8");
154
- expect(updated).toContain("**Status**: Done");
192
+ expect(updated).toContain("status: Done");
155
193
  expect(stdout).toContain("[OK] Updated status");
156
194
  });
157
195
 
158
196
  test("changes status updates WIP proposals", () => {
159
197
  const { repoRoot, cwd } = createRepo();
160
198
  const schubRoot = join(repoRoot, ".schub");
161
- seedProposal(schubRoot, "C001_update-cli", "WIP");
199
+ seedProposal(schubRoot, "C0001_update-cli", "WIP");
162
200
 
163
- const { result, stdout } = runChangesStatus(cwd, ["--change-id", "C001_update-cli", "--status", "Done"]);
201
+ const { result, stdout } = runChangesStatus(cwd, ["--change-id", "C0001_update-cli", "--status", "Done"]);
164
202
 
165
203
  expect(result.exitCode).toBe(0);
166
204
 
167
- const proposalPath = join(schubRoot, "changes", "C001_update-cli", "proposal.md");
205
+ const proposalPath = join(schubRoot, "changes", "C0001_update-cli", "proposal.md");
168
206
  const updated = readFileSync(proposalPath, "utf8");
169
- expect(updated).toContain("**Status**: Done");
207
+ expect(updated).toContain("status: Done");
170
208
  expect(stdout).toContain("[OK] Updated status");
171
209
  });
210
+
211
+ test("changes list prints change summaries", () => {
212
+ const { repoRoot, cwd } = createRepo();
213
+ const schubRoot = join(repoRoot, ".schub");
214
+ seedProposal(schubRoot, "C0002_second-change", "Done", "Second Change");
215
+ seedProposal(schubRoot, "C0001_first-change", "Accepted", "First Change");
216
+
217
+ const { result, stdout } = runChangesList(cwd);
218
+
219
+ expect(result.exitCode).toBe(0);
220
+ expect(stdout.trim().split("\n")).toEqual([
221
+ "C0001_first-change First Change (Accepted)",
222
+ "C0002_second-change Second Change (Done)",
223
+ ]);
224
+ });
225
+
226
+ test("changes archive moves change and tasks by default", () => {
227
+ const { repoRoot, cwd } = createRepo();
228
+ const schubRoot = join(repoRoot, ".schub");
229
+ const changeId = "C0001_archive-change";
230
+ seedProposal(schubRoot, changeId, "Accepted");
231
+
232
+ const readyTask = seedTask(schubRoot, "ready", "T0001_archive-change", changeId, "ready-task");
233
+ const doneTask = seedTask(schubRoot, "done", "T0002_archive-change", changeId, "done-task");
234
+ const otherTask = seedTask(schubRoot, "backlog", "T0003_other-change", "C0002_other-change", "other-task");
235
+
236
+ const { result, stdout } = runChangesArchive(cwd, ["--change-id", changeId]);
237
+
238
+ expect(result.exitCode).toBe(0);
239
+ expect(stdout).toContain(`[OK] Archived change ${changeId}`);
240
+
241
+ const archivedChangePath = join(schubRoot, "archive", "changes", changeId);
242
+ expect(existsSync(archivedChangePath)).toBe(true);
243
+ expect(existsSync(join(schubRoot, "changes", changeId))).toBe(false);
244
+
245
+ const archivedReadyTask = join(schubRoot, "tasks", "archived", basename(readyTask));
246
+ const archivedDoneTask = join(schubRoot, "tasks", "archived", basename(doneTask));
247
+ expect(existsSync(archivedReadyTask)).toBe(true);
248
+ expect(existsSync(archivedDoneTask)).toBe(true);
249
+ expect(existsSync(readyTask)).toBe(false);
250
+ expect(existsSync(doneTask)).toBe(false);
251
+ expect(existsSync(otherTask)).toBe(true);
252
+
253
+ const proposalPath = join(archivedChangePath, "proposal.md");
254
+ const updated = readFileSync(proposalPath, "utf8");
255
+ expect(updated).toContain("status: Archived");
256
+ });
257
+
258
+ test("changes archive skips tasks when requested", () => {
259
+ const { repoRoot, cwd } = createRepo();
260
+ const schubRoot = join(repoRoot, ".schub");
261
+ const changeId = "C0001_archive-change";
262
+ seedProposal(schubRoot, changeId, "Accepted");
263
+
264
+ const readyTask = seedTask(schubRoot, "ready", "T0001_archive-change", changeId, "ready-task");
265
+
266
+ const { result } = runChangesArchive(cwd, ["--change-id", changeId, "--skip-tasks"]);
267
+
268
+ expect(result.exitCode).toBe(0);
269
+ expect(existsSync(readyTask)).toBe(true);
270
+ expect(existsSync(join(schubRoot, "tasks", "archived", basename(readyTask)))).toBe(false);
271
+ expect(existsSync(join(schubRoot, "archive", "changes", changeId))).toBe(true);
272
+ });
273
+
274
+ test("changes archive reports collisions and leaves tasks alone", () => {
275
+ const { repoRoot, cwd } = createRepo();
276
+ const schubRoot = join(repoRoot, ".schub");
277
+ const changeId = "C0001_archive-change";
278
+ seedProposal(schubRoot, changeId, "Accepted");
279
+
280
+ const readyTask = seedTask(schubRoot, "ready", "T0001_archive-change", changeId, "ready-task");
281
+
282
+ const archiveRoot = join(schubRoot, "archive", "changes", changeId);
283
+ mkdirSync(archiveRoot, { recursive: true });
284
+ writeFileSync(join(archiveRoot, "proposal.md"), "---\nstatus: Archived\n---\n# Proposal - Existing\n", "utf8");
285
+
286
+ const { result, stderr } = runChangesArchive(cwd, ["--change-id", changeId]);
287
+
288
+ expect(result.exitCode).not.toBe(0);
289
+ expect(stderr).toContain("Archive already exists");
290
+ expect(existsSync(readyTask)).toBe(true);
291
+ expect(existsSync(join(schubRoot, "tasks", "archived", basename(readyTask)))).toBe(false);
292
+ expect(existsSync(join(schubRoot, "changes", changeId))).toBe(true);
293
+ });