schub 0.1.2 → 0.1.3

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,7 +11,7 @@ $ARGUMENTS
11
11
 
12
12
  ## Workflow
13
13
 
14
- 1. Derive a concise, verb-led `change-id` suffix from the request (kebab-case: `add-`, `update-`, `remove-`, `refactor-`, `fix-`) and prefix it with `C` + the next 3-digit sequence + `_` (e.g., `C001_add-user-auth`).
14
+ 1. Derive a concise, verb-led `change-id` suffix from the request (kebab-case: `add-`, `update-`, `remove-`, `refactor-`, `fix-`) and prefix it with `C` + the next 4-digit sequence + `_` (e.g., `C0001_add-user-auth`).
15
15
  2. Run `npx schub changes create --change-id "<change-id>" --title "<title>" --input "<user prompt verbatim>"` to scaffold the proposal template.
16
16
  - Supported flags: `--change-id`, `--title`, `--input`, `--overwrite`.
17
17
  3. Update the generated proposal with concrete, testable statements.
@@ -3,7 +3,7 @@ name: create-tasks
3
3
  description: "Break an accepted proposal into actionable task files under `.schub/tasks/` using the task template. Use when asked to create tasks for a change-id."
4
4
  ---
5
5
 
6
- ## User Input (should contain change-id or shorthand `C###`)
6
+ ## User Input (should contain change-id or shorthand `C####`)
7
7
 
8
8
  ```text
9
9
  $ARGUMENTS
@@ -11,13 +11,13 @@ $ARGUMENTS
11
11
 
12
12
  ## Workflow
13
13
 
14
- 1. If the user provides shorthand `C###`, resolve it to the matching `C###_<suffix>` change folder (error if ambiguous). Confirm `.schub/changes/<change-id>/proposal.md` exists and that it is marked accepted. If it is missing, ask the user to run the create-proposal skill first. If it is still a draft, ask the user to review it first.
14
+ 1. If the user provides shorthand `C####`, resolve it to the matching `C####_<suffix>` change folder (error if ambiguous). Confirm `.schub/changes/<change-id>/proposal.md` exists and that it is marked accepted. If it is missing, ask the user to run the create-proposal skill first. If it is still a draft, ask the user to review it first.
15
15
  2. Read the proposal and other files in the <change-id> folder.
16
16
  3. Derive tasks, each task should be:
17
17
  - Small enough to implement in one sitting.
18
18
  - Independently testable.
19
19
  - Explicit about file paths and affected components.
20
- 4. Assign a unique task id using `T###` (ex: `T001`) and avoid collisions with existing tasks.
20
+ 4. Assign a unique task id using `T####` (ex: `T0001`) and avoid collisions with existing tasks.
21
21
  5. Run `npx schub tasks create --change-id "<change-id>" --title "<task title>"` to scaffold tasks.
22
22
  - Repeat `--title` for multiple tasks.
23
23
  - Optional flags: `--status "<status>"` (default: `backlog`), `--overwrite`.
@@ -3,7 +3,7 @@ name: review-proposal
3
3
  description: "Run a review session for a proposal by creating a list of open questions, updating the proposal, adding a Q&A section, and deleting REVIEW_ME.md on completion. Use when asked to review proposals."
4
4
  ---
5
5
 
6
- ## User Input (change-id or shorthand `C###`)
6
+ ## User Input (change-id or shorthand `C####`)
7
7
 
8
8
  ```text
9
9
  $ARGUMENTS
@@ -11,7 +11,7 @@ $ARGUMENTS
11
11
 
12
12
  ## Workflow
13
13
 
14
- 0. If the user provides shorthand `C###`, resolve it to the matching `C###_<suffix>` change folder (error if ambiguous). Mark the proposal Status as "In Review"
14
+ 0. If the user provides shorthand `C####`, resolve it to the matching `C####_<suffix>` change folder (error if ambiguous). Mark the proposal Status as "In Review"
15
15
  1. Review the proposal by checking the [MISSING INFORMATION] tags and the potential issues listed.
16
16
  2. If there are issues, or missing information, run `npx schub review create --change-id "<change-id>"` to scaffold `.schub/changes/<change-id>/REVIEW_ME.md`.
17
17
  3. Triage each item:
package/src/App.test.tsx CHANGED
@@ -15,6 +15,12 @@ const displaySchubDir =
15
15
  const ansiPattern = new RegExp(`${String.fromCharCode(27)}[[0-9;]*m`, "g");
16
16
  const stripAnsi = (value: string) => value.replace(ansiPattern, "");
17
17
 
18
+ const writeProposal = (changesRoot: string, changeId: string, status: string, title = changeId) => {
19
+ const changeDir = join(changesRoot, changeId);
20
+ mkdirSync(changeDir, { recursive: true });
21
+ writeFileSync(join(changeDir, "proposal.md"), `# Proposal - ${title}\n**Status**: ${status}\n`, "utf8");
22
+ };
23
+
18
24
  test("renders tabs with header and footer details", () => {
19
25
  const { lastFrame } = render(<App />);
20
26
  const output = stripAnsi(lastFrame() || "");
@@ -27,6 +33,51 @@ test("renders tabs with header and footer details", () => {
27
33
  expect(output).toContain(packageJson.version);
28
34
  });
29
35
 
36
+ test("shows review shortcut when pending review proposal is selected", async () => {
37
+ const originalCwd = process.cwd();
38
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-review-shortcut-"));
39
+ const changesRoot = join(baseDir, ".schub", "changes");
40
+
41
+ mkdirSync(changesRoot, { recursive: true });
42
+ writeProposal(changesRoot, "C001_pending-review", "Pending Review");
43
+
44
+ try {
45
+ process.chdir(baseDir);
46
+ const rendered = render(<App />);
47
+ await new Promise((resolve) => setTimeout(resolve, 0));
48
+ const output = stripAnsi(rendered.lastFrame() ?? "");
49
+ expect(output).toContain("[r review]");
50
+ } finally {
51
+ process.chdir(originalCwd);
52
+ }
53
+ });
54
+
55
+ test("hides review shortcut when selection moves off pending review", async () => {
56
+ const originalCwd = process.cwd();
57
+ const baseDir = mkdtempSync(join(tmpdir(), "schub-app-review-hide-"));
58
+ const changesRoot = join(baseDir, ".schub", "changes");
59
+
60
+ mkdirSync(changesRoot, { recursive: true });
61
+ writeProposal(changesRoot, "C001_pending-review", "Pending Review");
62
+ writeProposal(changesRoot, "C002_draft", "Draft");
63
+
64
+ try {
65
+ process.chdir(baseDir);
66
+ const rendered = render(<App />);
67
+ await new Promise((resolve) => setTimeout(resolve, 0));
68
+ const initial = stripAnsi(rendered.lastFrame() ?? "");
69
+ expect(initial).toContain("[r review]");
70
+
71
+ rendered.stdin.write("\u001B[B");
72
+ await new Promise((resolve) => setTimeout(resolve, 0));
73
+ await new Promise((resolve) => setTimeout(resolve, 0));
74
+ const moved = stripAnsi(rendered.lastFrame() ?? "");
75
+ expect(moved).not.toContain("[r review]");
76
+ } finally {
77
+ process.chdir(originalCwd);
78
+ }
79
+ });
80
+
30
81
  test("selected tab shows a blue left indicator", () => {
31
82
  const originalForceColor = process.env.FORCE_COLOR;
32
83
  process.env.FORCE_COLOR = "1";
@@ -56,15 +107,16 @@ test("shows a copy banner after copying an item", async () => {
56
107
  };
57
108
 
58
109
  mkdirSync(readyRoot, { recursive: true });
59
- writeFileSync(join(readyRoot, "T900_copy-task.md"), "# Task: T900 Copy Task\n", "utf8");
110
+ writeFileSync(join(readyRoot, "T0900_copy-task.md"), "# Task: T0900 Copy Task\n", "utf8");
60
111
 
61
112
  try {
62
113
  process.chdir(baseDir);
63
114
  const rendered = render(<App copyToClipboard={recordCopy} />);
64
115
  rendered.stdin.write("c");
65
116
  await new Promise((resolve) => setTimeout(resolve, 0));
117
+ await new Promise((resolve) => setTimeout(resolve, 0));
66
118
  const output = rendered.lastFrame() ?? "";
67
- expect(copied).toBe("T900");
119
+ expect(copied).toBe("T0900");
68
120
  expect(output).toContain("Copied to clipboard !");
69
121
  } finally {
70
122
  process.chdir(originalCwd);
package/src/App.tsx CHANGED
@@ -14,6 +14,11 @@ type TabDefinition = {
14
14
  label: string;
15
15
  };
16
16
 
17
+ type Shortcut = {
18
+ keyLabel: string;
19
+ label: string;
20
+ };
21
+
17
22
  const tabs: TabDefinition[] = [
18
23
  { id: "status", label: "Status" },
19
24
  { id: "plan", label: "Plan" },
@@ -34,6 +39,7 @@ export default function App({ copyToClipboard = copyToClipboardDefault }: AppPro
34
39
  rows: stdout.rows,
35
40
  }));
36
41
  const [copyBanner, setCopyBanner] = React.useState<string | null>(null);
42
+ const [statusShortcuts, setStatusShortcuts] = React.useState<Shortcut[]>([]);
37
43
  const versionLabel = `${packageJson.version}`;
38
44
  const homeDir = homedir();
39
45
  const startDir = process.env.SCHUB_CWD ?? process.cwd();
@@ -81,15 +87,22 @@ export default function App({ copyToClipboard = copyToClipboardDefault }: AppPro
81
87
  }
82
88
  });
83
89
 
90
+ React.useEffect(() => {
91
+ if (mode !== "status") {
92
+ setStatusShortcuts([]);
93
+ }
94
+ }, [mode]);
95
+
84
96
  const handleCopyId = (value: string) => {
85
97
  copyToClipboard(value);
86
98
  setCopyBanner(COPY_BANNER_TEXT);
87
99
  };
88
100
 
89
- const shortcuts = [
101
+ const baseShortcuts: Shortcut[] = [
90
102
  { keyLabel: "o", label: "open file" },
91
103
  { keyLabel: "c", label: "copy" },
92
104
  ];
105
+ const shortcuts = mode === "status" ? [...baseShortcuts, ...statusShortcuts] : baseShortcuts;
93
106
 
94
107
  return (
95
108
  <Box backgroundColor="black" flexDirection="column" width={dimensions.columns} height={dimensions.rows}>
@@ -127,7 +140,11 @@ export default function App({ copyToClipboard = copyToClipboardDefault }: AppPro
127
140
  </Box>
128
141
  </Box>
129
142
  <Box flexDirection="column" paddingX={2} paddingY={1} flexGrow={1} flexShrink={1}>
130
- {mode === "status" ? <StatusView onCopyId={handleCopyId} /> : <PlanView onCopyId={handleCopyId} />}
143
+ {mode === "status" ? (
144
+ <StatusView onCopyId={handleCopyId} onShortcutsChange={setStatusShortcuts} />
145
+ ) : (
146
+ <PlanView onCopyId={handleCopyId} />
147
+ )}
131
148
  </Box>
132
149
  <Box flexDirection="column" paddingX={2} paddingBottom={1} flexShrink={0}>
133
150
  <Box justifyContent="space-between">
@@ -0,0 +1,52 @@
1
+ import { expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { archiveChange } from "./changes";
6
+
7
+ const createSchubRoot = () => {
8
+ const base = mkdtempSync(join(tmpdir(), "schub-change-archive-"));
9
+ const schubDir = join(base, ".schub");
10
+ mkdirSync(schubDir, { recursive: true });
11
+ return { schubDir };
12
+ };
13
+
14
+ const seedProposal = (schubDir: string, changeId: string, status: string) => {
15
+ const changeDir = join(schubDir, "changes", changeId);
16
+ mkdirSync(changeDir, { recursive: true });
17
+ const proposal = `# Proposal - Seed\n**Status**: ${status}\n`;
18
+ writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
19
+ };
20
+
21
+ test("archiveChange updates proposal and moves directory", () => {
22
+ const { schubDir } = createSchubRoot();
23
+ const changeId = "C0001_archive-change";
24
+ seedProposal(schubDir, changeId, "Accepted");
25
+
26
+ archiveChange(schubDir, changeId);
27
+
28
+ const archivePath = join(schubDir, "archive", "changes", changeId);
29
+ expect(existsSync(join(schubDir, "changes", changeId))).toBe(false);
30
+ expect(existsSync(archivePath)).toBe(true);
31
+
32
+ const proposalPath = join(archivePath, "proposal.md");
33
+ const updated = readFileSync(proposalPath, "utf8");
34
+ expect(updated).toContain("**Status**: Archived");
35
+ });
36
+
37
+ test("archiveChange rejects archive collisions", () => {
38
+ const { schubDir } = createSchubRoot();
39
+ const changeId = "C0001_archive-change";
40
+ seedProposal(schubDir, changeId, "Accepted");
41
+
42
+ const archivePath = join(schubDir, "archive", "changes", changeId);
43
+ mkdirSync(archivePath, { recursive: true });
44
+ const archivedProposal = join(archivePath, "proposal.md");
45
+ writeFileSync(archivedProposal, "# Proposal - Existing\n**Status**: Archived\n", "utf8");
46
+
47
+ expect(() => archiveChange(schubDir, changeId)).toThrow(`Archive already exists: ${archivePath}`);
48
+
49
+ const activeProposal = readFileSync(join(schubDir, "changes", changeId, "proposal.md"), "utf8");
50
+ expect(activeProposal).toContain("**Status**: Accepted");
51
+ expect(readFileSync(archivedProposal, "utf8")).toContain("# Proposal - Existing");
52
+ });
package/src/changes.ts CHANGED
@@ -1,4 +1,4 @@
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
4
  import { resolveSchubRoot } from "./schub-root";
@@ -11,7 +11,7 @@ export type ChangeInfo = {
11
11
  path: string;
12
12
  };
13
13
 
14
- const CHANGE_ID_PATTERN = /^(?:[Cc]\d{3}_)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
14
+ const CHANGE_ID_PATTERN = /^(?:[Cc]\d{4}_)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
15
 
16
16
  const isDirectory = (path: string) => {
17
17
  try {
@@ -33,7 +33,7 @@ const parseProposal = (content: string, changeId: string) => {
33
33
 
34
34
  export const normalizeChangeId = (value: string) => {
35
35
  const trimmed = value.trim();
36
- const match = trimmed.match(/^([Cc])(\d{3})_(.+)$/);
36
+ const match = trimmed.match(/^([Cc])(\d{4})_(.+)$/);
37
37
  if (match) {
38
38
  return `C${match[2]}_${match[3]}`;
39
39
  }
@@ -49,7 +49,7 @@ export const readChangeSummary = (schubDir: string, changeId: string) => {
49
49
  }
50
50
 
51
51
  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).`);
52
+ throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C0001_add-user-auth).`);
53
53
  }
54
54
 
55
55
  const normalized = normalizeChangeId(trimmed);
@@ -101,6 +101,29 @@ export const updateChangeStatus = (schubDir: string, changeId: string, status: s
101
101
  };
102
102
  };
103
103
 
104
+ export const archiveChange = (schubDir: string, changeId: string) => {
105
+ const summary = readChangeSummary(schubDir, changeId);
106
+ const archiveRoot = join(schubDir, "archive", "changes");
107
+ const archivePath = join(archiveRoot, summary.changeId);
108
+
109
+ if (existsSync(archivePath)) {
110
+ throw new Error(`Archive already exists: ${archivePath}`);
111
+ }
112
+
113
+ mkdirSync(archiveRoot, { recursive: true });
114
+
115
+ const updated = updateChangeStatus(schubDir, summary.changeId, "Archived");
116
+ renameSync(summary.changeDir, archivePath);
117
+
118
+ return {
119
+ changeId: updated.changeId,
120
+ previousStatus: updated.previousStatus,
121
+ status: updated.status,
122
+ proposalPath: join(archivePath, "proposal.md"),
123
+ archivePath,
124
+ };
125
+ };
126
+
104
127
  const changeNumber = (id: string) => {
105
128
  const match = id.match(/\d+/);
106
129
  return match ? Number(match[0]) : Number.POSITIVE_INFINITY;
@@ -153,7 +176,7 @@ const slugify = (value: string) => {
153
176
  };
154
177
 
155
178
  const splitPrefixedChangeId = (changeId: string) => {
156
- const match = changeId.match(/^([Cc])(\d{3})_([a-z0-9]+(?:-[a-z0-9]+)*)$/);
179
+ const match = changeId.match(/^([Cc])(\d{4})_([a-z0-9]+(?:-[a-z0-9]+)*)$/);
157
180
  if (match) {
158
181
  return { prefix: match[2], slug: match[3] };
159
182
  }
@@ -171,7 +194,7 @@ const nextChangePrefix = (schubDir: string) => {
171
194
  if (!entry.isDirectory()) {
172
195
  continue;
173
196
  }
174
- const match = entry.name.match(/^[Cc](\d{3})_/);
197
+ const match = entry.name.match(/^[Cc](\d{4})_/);
175
198
  if (match) {
176
199
  prefixes.push(Number.parseInt(match[1], 10));
177
200
  }
@@ -182,7 +205,7 @@ const nextChangePrefix = (schubDir: string) => {
182
205
  scan(archiveRoot);
183
206
 
184
207
  const next = prefixes.length > 0 ? Math.max(...prefixes) + 1 : 1;
185
- return next.toString().padStart(3, "0");
208
+ return next.toString().padStart(4, "0");
186
209
  };
187
210
 
188
211
  const CHANGE_PREFIX = "C";
@@ -49,7 +49,7 @@ const seedChange = (schubRoot: string, changeId: string, title: string) => {
49
49
 
50
50
  test("adr create scaffolds ADR file", () => {
51
51
  const { cwd, schubRoot } = createRepo();
52
- const changeId = "C003_new-adr";
52
+ const changeId = "C0003_new-adr";
53
53
  const changeTitle = "New ADR";
54
54
  seedChange(schubRoot, changeId, changeTitle);
55
55
 
@@ -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 = `# Proposal - ${title}\n**Status**: ${status}\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 = `# Task: ${taskId} Seed\n\n**Change ID**: ${changeId}\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,13 +181,13 @@ 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
192
  expect(updated).toContain("**Status**: Done");
155
193
  expect(stdout).toContain("[OK] Updated status");
@@ -158,14 +196,98 @@ test("changes status updates accepted proposals", () => {
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
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"), "# Proposal - Existing\n**Status**: Archived\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
+ });