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
@@ -1,4 +1,5 @@
1
- import { createChange, resolveChangeRoot, updateChangeStatus } from "../changes";
1
+ import { archiveChange, createChange, listChanges, resolveChangeRoot, updateChangeStatus } from "../changes";
2
+ import { archiveTasksForChange } from "../tasks";
2
3
 
3
4
  type ChangeCreateOptions = {
4
5
  changeId?: string;
@@ -12,6 +13,11 @@ type ChangeStatusOptions = {
12
13
  status: string;
13
14
  };
14
15
 
16
+ type ChangeArchiveOptions = {
17
+ changeId: string;
18
+ skipTasks: boolean;
19
+ };
20
+
15
21
  const parseChangeCreateOptions = (args: string[]) => {
16
22
  let changeId: string | undefined;
17
23
  let title: string | undefined;
@@ -148,6 +154,82 @@ const parseChangeStatusOptions = (args: string[]) => {
148
154
  return options;
149
155
  };
150
156
 
157
+ const parseChangeArchiveOptions = (args: string[]) => {
158
+ let changeId: string | undefined;
159
+ let skipTasks = false;
160
+ const unknown: string[] = [];
161
+
162
+ const rejectUnsupported = (flag: string) => {
163
+ throw new Error(`Unsupported option: ${flag}.`);
164
+ };
165
+
166
+ for (let index = 0; index < args.length; index += 1) {
167
+ const arg = args[index];
168
+ if (arg === "--skip-tasks") {
169
+ skipTasks = true;
170
+ continue;
171
+ }
172
+ if (arg === "--change-id") {
173
+ changeId = args[index + 1];
174
+ if (changeId === undefined) {
175
+ throw new Error("Missing value for --change-id.");
176
+ }
177
+ index += 1;
178
+ continue;
179
+ }
180
+ if (arg.startsWith("--change-id=")) {
181
+ changeId = arg.slice("--change-id=".length);
182
+ continue;
183
+ }
184
+ if (arg === "--schub-root" || arg === "--agent-root") {
185
+ rejectUnsupported(arg);
186
+ }
187
+ if (arg.startsWith("--schub-root=")) {
188
+ rejectUnsupported("--schub-root");
189
+ }
190
+ if (arg.startsWith("--agent-root=")) {
191
+ rejectUnsupported("--agent-root");
192
+ }
193
+ unknown.push(arg);
194
+ }
195
+
196
+ if (unknown.length > 0) {
197
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
198
+ }
199
+
200
+ if (!changeId) {
201
+ throw new Error("Provide --change-id.");
202
+ }
203
+
204
+ const options: ChangeArchiveOptions = { changeId, skipTasks };
205
+ return options;
206
+ };
207
+
208
+ const parseChangeListOptions = (args: string[]) => {
209
+ const unknown: string[] = [];
210
+
211
+ const rejectUnsupported = (flag: string) => {
212
+ throw new Error(`Unsupported option: ${flag}.`);
213
+ };
214
+
215
+ for (const arg of args) {
216
+ if (arg === "--schub-root" || arg === "--agent-root") {
217
+ rejectUnsupported(arg);
218
+ }
219
+ if (arg.startsWith("--schub-root=")) {
220
+ rejectUnsupported("--schub-root");
221
+ }
222
+ if (arg.startsWith("--agent-root=")) {
223
+ rejectUnsupported("--agent-root");
224
+ }
225
+ unknown.push(arg);
226
+ }
227
+
228
+ if (unknown.length > 0) {
229
+ throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
230
+ }
231
+ };
232
+
151
233
  export const runChangesCreate = (args: string[], startDir: string) => {
152
234
  const options = parseChangeCreateOptions(args);
153
235
  const schubDir = resolveChangeRoot(startDir);
@@ -161,3 +243,22 @@ export const runChangesStatus = (args: string[], startDir: string) => {
161
243
  const updated = updateChangeStatus(schubDir, options.changeId, options.status);
162
244
  process.stdout.write(`[OK] Updated status for ${updated.changeId}: ${updated.previousStatus} -> ${updated.status}\n`);
163
245
  };
246
+
247
+ export const runChangesArchive = (args: string[], startDir: string) => {
248
+ const options = parseChangeArchiveOptions(args);
249
+ const schubDir = resolveChangeRoot(startDir);
250
+ const archived = archiveChange(schubDir, options.changeId);
251
+ const archivedTasks = options.skipTasks ? [] : archiveTasksForChange(schubDir, archived.changeId);
252
+ const taskLabel = options.skipTasks
253
+ ? "tasks kept"
254
+ : `${archivedTasks.length} task${archivedTasks.length === 1 ? "" : "s"} archived`;
255
+ process.stdout.write(`[OK] Archived change ${archived.changeId} (${taskLabel})\n`);
256
+ };
257
+
258
+ export const runChangesList = (args: string[], startDir: string) => {
259
+ parseChangeListOptions(args);
260
+ const schubDir = resolveChangeRoot(startDir);
261
+ const changes = listChanges(schubDir);
262
+ const lines = changes.map((change) => `${change.id} ${change.title} (${change.status})`);
263
+ process.stdout.write(`${lines.join("\n")}\n`);
264
+ };
@@ -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("cookbook create scaffolds cookbook file", () => {
51
52
  const { cwd, schubRoot } = createRepo();
52
- const changeId = "C004_new-cookbook";
53
+ const changeId = "C0004_new-cookbook";
53
54
  const changeTitle = "New Cookbook";
54
55
  seedChange(schubRoot, changeId, changeTitle);
55
56
 
@@ -3,7 +3,13 @@ import { spawnSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { installCodexSkills, resolveCodexSkillsRoot, selectProviders } from "./init";
6
+ import {
7
+ installCodexSkills,
8
+ installOpencodeSkills,
9
+ resolveCodexSkillsRoot,
10
+ resolveOpencodeSkillsRoot,
11
+ selectProviders,
12
+ } from "./init";
7
13
 
8
14
  const createRepoFixture = () => {
9
15
  const base = mkdtempSync(join(tmpdir(), "schub-init-codex-"));
@@ -15,7 +21,9 @@ const createRepoFixture = () => {
15
21
 
16
22
  test("selectProviders handles numeric and named inputs", () => {
17
23
  expect(selectProviders("1")).toEqual(["codex"]);
18
- expect(selectProviders("codex, 1")).toEqual(["codex"]);
24
+ expect(selectProviders("2")).toEqual(["opencode"]);
25
+ expect(selectProviders("codex, opencode")).toEqual(["codex", "opencode"]);
26
+ expect(selectProviders("Opencode, 1")).toEqual(["opencode", "codex"]);
19
27
  });
20
28
 
21
29
  test("resolveCodexSkillsRoot prefers worktree .codex when present", () => {
@@ -63,6 +71,51 @@ test("resolveCodexSkillsRoot uses cwd when git is unavailable", () => {
63
71
  }
64
72
  });
65
73
 
74
+ test("resolveOpencodeSkillsRoot prefers worktree .opencode when present", () => {
75
+ const { repoRoot, startDir } = createRepoFixture();
76
+ const gitInit = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
77
+
78
+ expect(gitInit.status).toBe(0);
79
+
80
+ mkdirSync(join(repoRoot, ".opencode"), { recursive: true });
81
+
82
+ const destination = resolveOpencodeSkillsRoot(startDir);
83
+ const expectedRoot = realpathSync(repoRoot);
84
+
85
+ expect(destination).toBe(join(expectedRoot, ".opencode", "skills"));
86
+ });
87
+
88
+ test("resolveOpencodeSkillsRoot falls back to home when worktree .opencode is missing", () => {
89
+ const { repoRoot, startDir } = createRepoFixture();
90
+ const gitInit = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
91
+
92
+ expect(gitInit.status).toBe(0);
93
+
94
+ const originalHome = process.env.HOME;
95
+ const homeDir = mkdtempSync(join(tmpdir(), "schub-home-"));
96
+ process.env.HOME = homeDir;
97
+
98
+ try {
99
+ const destination = resolveOpencodeSkillsRoot(startDir);
100
+ expect(destination).toBe(join(homeDir, ".opencode", "skills"));
101
+ } finally {
102
+ process.env.HOME = originalHome;
103
+ }
104
+ });
105
+
106
+ test("resolveOpencodeSkillsRoot uses cwd when git is unavailable", () => {
107
+ const { startDir } = createRepoFixture();
108
+ const originalPath = process.env.PATH;
109
+ process.env.PATH = "";
110
+
111
+ try {
112
+ const destination = resolveOpencodeSkillsRoot(startDir);
113
+ expect(destination).toBe(join(startDir, ".opencode", "skills"));
114
+ } finally {
115
+ process.env.PATH = originalPath;
116
+ }
117
+ });
118
+
66
119
  test("installCodexSkills skips existing skill directories", () => {
67
120
  const base = mkdtempSync(join(tmpdir(), "schub-init-install-"));
68
121
  const destination = join(base, "skills");
@@ -76,3 +129,17 @@ test("installCodexSkills skips existing skill directories", () => {
76
129
  expect(installed.length).toBeGreaterThan(0);
77
130
  expect(existsSync(join(destination, "create-tasks", "SKILL.md"))).toBe(true);
78
131
  });
132
+
133
+ test("installOpencodeSkills skips existing skill directories", () => {
134
+ const base = mkdtempSync(join(tmpdir(), "schub-init-opencode-install-"));
135
+ const destination = join(base, "skills");
136
+ const existingSkill = join(destination, "review-proposal");
137
+ mkdirSync(existingSkill, { recursive: true });
138
+ writeFileSync(join(existingSkill, "SKILL.md"), "existing", "utf8");
139
+
140
+ const { installed, skipped } = installOpencodeSkills(destination);
141
+
142
+ expect(skipped).toContain("review-proposal");
143
+ expect(installed.length).toBeGreaterThan(0);
144
+ expect(existsSync(join(destination, "create-tasks", "SKILL.md"))).toBe(true);
145
+ });
@@ -3,16 +3,20 @@ import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { createInterface } from "node:readline";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { ensureRoadmapFile } from "../features/roadmap";
6
7
  import { initSchubRoot, resolveGitRoot } from "../init";
7
8
 
8
- type ProviderId = "codex";
9
+ type ProviderId = "codex" | "opencode";
9
10
 
10
11
  type ProviderOption = {
11
12
  id: ProviderId;
12
13
  label: string;
13
14
  };
14
15
 
15
- const PROVIDERS: ProviderOption[] = [{ id: "codex", label: "Codex" }];
16
+ const PROVIDERS: ProviderOption[] = [
17
+ { id: "codex", label: "Codex" },
18
+ { id: "opencode", label: "Opencode" },
19
+ ];
16
20
  const BUNDLED_SKILLS_ROOT = fileURLToPath(new URL("../../skills", import.meta.url));
17
21
 
18
22
  const isDirectory = (path: string) => {
@@ -82,7 +86,24 @@ export const resolveCodexSkillsRoot = (startDir: string) => {
82
86
  return join(home, ".codex", "skills");
83
87
  };
84
88
 
85
- export const installCodexSkills = (destination: string) => {
89
+ export const resolveOpencodeSkillsRoot = (startDir: string) => {
90
+ const resolvedStart = resolve(startDir);
91
+ const gitRoot = resolveGitRoot(resolvedStart);
92
+
93
+ if (!gitRoot) {
94
+ return join(resolvedStart, ".opencode", "skills");
95
+ }
96
+
97
+ const localOpencodeRoot = join(gitRoot, ".opencode");
98
+ if (isDirectory(localOpencodeRoot)) {
99
+ return join(localOpencodeRoot, "skills");
100
+ }
101
+
102
+ const home = process.env.HOME ?? homedir();
103
+ return join(home, ".opencode", "skills");
104
+ };
105
+
106
+ const installBundledSkills = (destination: string) => {
86
107
  mkdirSync(destination, { recursive: true });
87
108
 
88
109
  const entries = readdirSync(BUNDLED_SKILLS_ROOT, { withFileTypes: true });
@@ -109,8 +130,12 @@ export const installCodexSkills = (destination: string) => {
109
130
  return { installed, skipped };
110
131
  };
111
132
 
112
- const reportCodexInstall = (destination: string, installed: string[], skipped: string[]) => {
113
- process.stdout.write(`[OK] Codex skills: ${destination}\n`);
133
+ export const installCodexSkills = (destination: string) => installBundledSkills(destination);
134
+
135
+ export const installOpencodeSkills = (destination: string) => installBundledSkills(destination);
136
+
137
+ const reportSkillsInstall = (label: string, destination: string, installed: string[], skipped: string[]) => {
138
+ process.stdout.write(`[OK] ${label} skills: ${destination}\n`);
114
139
 
115
140
  for (const skill of installed) {
116
141
  process.stdout.write(`[OK] Installed ${skill}\n`);
@@ -121,6 +146,14 @@ const reportCodexInstall = (destination: string, installed: string[], skipped: s
121
146
  }
122
147
  };
123
148
 
149
+ const reportCodexInstall = (destination: string, installed: string[], skipped: string[]) => {
150
+ reportSkillsInstall("Codex", destination, installed, skipped);
151
+ };
152
+
153
+ const reportOpencodeInstall = (destination: string, installed: string[], skipped: string[]) => {
154
+ reportSkillsInstall("Opencode", destination, installed, skipped);
155
+ };
156
+
124
157
  const parseInitOptions = (args: string[]) => {
125
158
  if (args.length === 0) {
126
159
  return;
@@ -135,10 +168,20 @@ export const runInit = async (args: string[], startDir: string) => {
135
168
  const schubRoot = initSchubRoot(startDir);
136
169
  process.stdout.write(`[OK] Wrote ${schubRoot}\n`);
137
170
 
171
+ const roadmapResult = ensureRoadmapFile(schubRoot);
172
+ const roadmapStatus = roadmapResult.created ? "OK" : "SKIP";
173
+ process.stdout.write(`[${roadmapStatus}] Roadmap ${roadmapResult.path}\n`);
174
+
138
175
  const providers = await promptForProviders();
139
176
  if (providers.includes("codex")) {
140
177
  const destination = resolveCodexSkillsRoot(startDir);
141
178
  const { installed, skipped } = installCodexSkills(destination);
142
179
  reportCodexInstall(destination, installed, skipped);
143
180
  }
181
+
182
+ if (providers.includes("opencode")) {
183
+ const destination = resolveOpencodeSkillsRoot(startDir);
184
+ const { installed, skipped } = installOpencodeSkills(destination);
185
+ reportOpencodeInstall(destination, installed, skipped);
186
+ }
144
187
  };
@@ -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("review create scaffolds REVIEW_ME from template", () => {
51
52
  const { cwd, schubRoot } = createRepo();
52
- const changeId = "C001_sample-change";
53
+ const changeId = "C0001_sample-change";
53
54
  const changeTitle = "Sample Change";
54
55
  seedChange(schubRoot, changeId, changeTitle);
55
56
 
@@ -72,7 +73,7 @@ test("review create scaffolds REVIEW_ME from template", () => {
72
73
 
73
74
  test("review complete creates Q&A with review content", () => {
74
75
  const { cwd, schubRoot } = createRepo();
75
- const changeId = "C002_review-complete";
76
+ const changeId = "C0002_review-complete";
76
77
  const changeTitle = "Review Complete";
77
78
  seedChange(schubRoot, changeId, changeTitle);
78
79
 
@@ -171,7 +171,7 @@ export const runReviewCreate = (args: string[], startDir: string) => {
171
171
 
172
172
  if (!isValidChangeId(trimmedId)) {
173
173
  throw new Error(
174
- `Invalid change-id '${options.changeId}'. Use kebab-case or a C-prefixed id (e.g., C001_add-user-auth).`,
174
+ `Invalid change-id '${options.changeId}'. Use kebab-case or a C-prefixed id (e.g., C1_add-user-auth).`,
175
175
  );
176
176
  }
177
177
 
@@ -0,0 +1,84 @@
1
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawnSync } from "bun";
6
+
7
+ const testDir = dirname(fileURLToPath(import.meta.url));
8
+ const cliDir = resolve(testDir, "..", "..");
9
+ const decoder = new TextDecoder();
10
+
11
+ const runRoadmap = (schubCwd: string, args: string[] = []) => {
12
+ const result = spawnSync({
13
+ cmd: ["bun", "run", "schub", "roadmap", ...args],
14
+ cwd: cliDir,
15
+ env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
16
+ });
17
+
18
+ return {
19
+ result,
20
+ stdout: decoder.decode(result.stdout ?? new Uint8Array()),
21
+ stderr: decoder.decode(result.stderr ?? new Uint8Array()),
22
+ };
23
+ };
24
+
25
+ const createRoadmapRepo = (items: string[] = []) => {
26
+ const base = mkdtempSync(join(tmpdir(), "schub-roadmap-cli-"));
27
+ const repoRoot = join(base, "repo");
28
+ const cwd = join(repoRoot, "nested", "dir");
29
+ mkdirSync(cwd, { recursive: true });
30
+
31
+ const schubDir = join(repoRoot, ".schub");
32
+ mkdirSync(schubDir, { recursive: true });
33
+ const roadmapPath = join(schubDir, "roadmap.md");
34
+ const content = ["## Roadmap", "", ...items, ""].join("\n");
35
+ writeFileSync(roadmapPath, content, "utf8");
36
+
37
+ return { cwd, roadmapPath, schubDir };
38
+ };
39
+
40
+ test("roadmap list prints index and proposal refs", () => {
41
+ const { cwd } = createRoadmapRepo([
42
+ "- **<PROPOSAL_REF>**: As a user, I want to sign in.",
43
+ "- **C0001**: As an admin, I want to manage accounts.",
44
+ ]);
45
+
46
+ const { result, stdout } = runRoadmap(cwd, ["list"]);
47
+
48
+ expect(result.exitCode).toBe(0);
49
+ const lines = stdout.trim().split("\n").filter(Boolean);
50
+ expect(lines).toEqual(["1 [--] As a user, I want to sign in.", "2 [C0001] As an admin, I want to manage accounts."]);
51
+ });
52
+
53
+ test("roadmap add appends a new story", () => {
54
+ const { cwd, roadmapPath } = createRoadmapRepo(["- **<PROPOSAL_REF>**: As a user, I want to sign in."]);
55
+
56
+ const { result } = runRoadmap(cwd, ["add", "As a guest, I want to browse."]);
57
+
58
+ expect(result.exitCode).toBe(0);
59
+ const content = readFileSync(roadmapPath, "utf8");
60
+ expect(content).toContain("- **<PROPOSAL_REF>**: As a guest, I want to browse.");
61
+ });
62
+
63
+ test("roadmap propose creates proposal and updates roadmap", () => {
64
+ const { cwd, roadmapPath, schubDir } = createRoadmapRepo(["- **<PROPOSAL_REF>**: As a user, I want to sign in."]);
65
+
66
+ const { result } = runRoadmap(cwd, ["propose", "1"]);
67
+
68
+ expect(result.exitCode).toBe(0);
69
+ const content = readFileSync(roadmapPath, "utf8");
70
+ expect(content).toContain("- **C0001**: As a user, I want to sign in.");
71
+
72
+ const changeDirs = readdirSync(join(schubDir, "changes"));
73
+ expect(changeDirs).toHaveLength(1);
74
+ expect(changeDirs[0]).toMatch(/^C0001_/);
75
+ });
76
+
77
+ test("roadmap propose errors when item already linked", () => {
78
+ const { cwd } = createRoadmapRepo(["- **C0003**: As a user, I want to sign in."]);
79
+
80
+ const { result, stderr } = runRoadmap(cwd, ["propose", "1"]);
81
+
82
+ expect(result.exitCode).not.toBe(0);
83
+ expect(stderr).toContain("already has a proposal ref");
84
+ });
@@ -0,0 +1,84 @@
1
+ import { addRoadmapItem, listRoadmapItems, proposeRoadmapItem } from "../features/roadmap";
2
+ import { findSchubRoot } from "../features/tasks";
3
+
4
+ const resolveRoadmapRoot = (startDir: string) => {
5
+ const schubDir = findSchubRoot(startDir);
6
+ if (!schubDir) {
7
+ throw new Error("No .schub directory found.");
8
+ }
9
+ return schubDir;
10
+ };
11
+
12
+ const parseRoadmapAddOptions = (args: string[]) => {
13
+ const storyParts: string[] = [];
14
+ for (const arg of args) {
15
+ if (arg.startsWith("--")) {
16
+ throw new Error(`Unknown option: ${arg}`);
17
+ }
18
+ storyParts.push(arg);
19
+ }
20
+
21
+ const story = storyParts.join(" ").trim();
22
+ if (!story) {
23
+ throw new Error("Provide a roadmap story.");
24
+ }
25
+ return { story };
26
+ };
27
+
28
+ const parseRoadmapListOptions = (args: string[]) => {
29
+ if (args.length > 0) {
30
+ throw new Error(`Unknown option(s): ${args.join(", ")}`);
31
+ }
32
+ };
33
+
34
+ const parseRoadmapProposeOptions = (args: string[]) => {
35
+ if (args.length === 0) {
36
+ throw new Error("Provide a roadmap item index.");
37
+ }
38
+ if (args.length > 1) {
39
+ throw new Error(`Unknown option(s): ${args.slice(1).join(", ")}`);
40
+ }
41
+ const rawIndex = args[0];
42
+ if (!rawIndex || rawIndex.startsWith("--")) {
43
+ throw new Error("Provide a roadmap item index.");
44
+ }
45
+ const index = Number.parseInt(rawIndex, 10);
46
+ if (Number.isNaN(index) || index <= 0) {
47
+ throw new Error(`Invalid roadmap index '${rawIndex}'. Use a positive number.`);
48
+ }
49
+ return { index };
50
+ };
51
+
52
+ const formatRoadmapLine = (index: number, proposalRef: string | null, story: string) => {
53
+ const ref = proposalRef ?? "--";
54
+ return `${index} [${ref}] ${story}`;
55
+ };
56
+
57
+ export const runRoadmapList = (startDir: string, args: string[]) => {
58
+ parseRoadmapListOptions(args);
59
+ const schubDir = resolveRoadmapRoot(startDir);
60
+ const items = listRoadmapItems(schubDir);
61
+
62
+ if (items.length === 0) {
63
+ process.stdout.write("No roadmap items found.\n");
64
+ return;
65
+ }
66
+
67
+ const lines = items.map((item) => formatRoadmapLine(item.index, item.proposalRef, item.story));
68
+ process.stdout.write(`${lines.join("\n")}\n`);
69
+ };
70
+
71
+ export const runRoadmapAdd = (startDir: string, args: string[]) => {
72
+ const options = parseRoadmapAddOptions(args);
73
+ const schubDir = resolveRoadmapRoot(startDir);
74
+ addRoadmapItem(schubDir, options.story);
75
+ process.stdout.write("[OK] Added roadmap item.\n");
76
+ };
77
+
78
+ export const runRoadmapPropose = (startDir: string, args: string[]) => {
79
+ const options = parseRoadmapProposeOptions(args);
80
+ const schubDir = resolveRoadmapRoot(startDir);
81
+ const result = proposeRoadmapItem(schubDir, options.index);
82
+ process.stdout.write(`[OK] Wrote proposal: ${result.proposalPath}\n`);
83
+ process.stdout.write(`[OK] Updated roadmap item ${options.index} -> ${result.proposalRef}\n`);
84
+ };