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
@@ -0,0 +1,169 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { render as inkRender } from "ink-testing-library";
6
+ import ProposalDetailView from "./ProposalDetailView";
7
+
8
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}[[0-9;]*m`, "g");
9
+ const stripAnsi = (value: string) => value.replace(ansiPattern, "");
10
+
11
+ const renders: Array<() => void> = [];
12
+ const render = (...args: Parameters<typeof inkRender>) => {
13
+ const rendered = inkRender(...args);
14
+ renders.push(rendered.unmount);
15
+ return rendered;
16
+ };
17
+
18
+ afterEach(() => {
19
+ for (const unmount of renders) {
20
+ unmount();
21
+ }
22
+ renders.length = 0;
23
+ });
24
+
25
+ const writeProposal = (changesRoot: string, changeId: string, content: string) => {
26
+ const changeDir = join(changesRoot, changeId);
27
+ mkdirSync(changeDir, { recursive: true });
28
+ writeFileSync(join(changeDir, "proposal.md"), content, "utf8");
29
+ };
30
+
31
+ const writeTask = (schubDir: string, status: string, id: string, slug: string, body: string) => {
32
+ const tasksRoot = join(schubDir, "tasks", status);
33
+ mkdirSync(tasksRoot, { recursive: true });
34
+ writeFileSync(join(tasksRoot, `${id}_${slug}.md`), body, "utf8");
35
+ };
36
+
37
+ const findLine = (output: string, id: string) => output.split("\n").find((line) => line.includes(id)) ?? "";
38
+
39
+ test("proposal detail view renders metadata and summary", () => {
40
+ const originalCwd = process.cwd();
41
+ const base = mkdtempSync(join(tmpdir(), "schub-proposal-detail-"));
42
+ const schubDir = join(base, ".schub");
43
+ const changesRoot = join(schubDir, "changes");
44
+ const changeId = "C1200_detail-view";
45
+ const proposal = `---\nchange_id: ${changeId}\ncreated: 2026-01-10\nstatus: Draft\ninput: TUI detail view\n---\n# Proposal - Detail View\n\n## Summary\n\nAdd a detail view in the TUI.\n\n## Why\n\nBecause.\n`;
46
+
47
+ mkdirSync(changesRoot, { recursive: true });
48
+ writeProposal(changesRoot, changeId, proposal);
49
+
50
+ try {
51
+ process.chdir(base);
52
+ const { lastFrame } = render(<ProposalDetailView changeId={changeId} />);
53
+ const output = stripAnsi(lastFrame() ?? "");
54
+
55
+ expect(output).toContain("Change ID:");
56
+ expect(output).toContain(changeId);
57
+ expect(output).toContain("Created:");
58
+ expect(output).toContain("2026-01-10");
59
+ expect(output).toContain("Status:");
60
+ expect(output).toContain("Draft");
61
+ expect(output).toContain("Input:");
62
+ expect(output).toContain("TUI detail view");
63
+ expect(output).toContain("Summary");
64
+ expect(output).toContain("Add a detail view in the TUI.");
65
+ } finally {
66
+ process.chdir(originalCwd);
67
+ }
68
+ });
69
+
70
+ test("proposal detail view shows summary fallback when missing", () => {
71
+ const originalCwd = process.cwd();
72
+ const base = mkdtempSync(join(tmpdir(), "schub-proposal-summary-"));
73
+ const schubDir = join(base, ".schub");
74
+ const changesRoot = join(schubDir, "changes");
75
+ const changeId = "C1201_missing-summary";
76
+ const proposal = `---\nchange_id: ${changeId}\ncreated: 2026-01-11\nstatus: Draft\ninput: None\n---\n# Proposal - Missing Summary\n\n## Why\n\nBecause.\n`;
77
+
78
+ mkdirSync(changesRoot, { recursive: true });
79
+ writeProposal(changesRoot, changeId, proposal);
80
+
81
+ try {
82
+ process.chdir(base);
83
+ const { lastFrame } = render(<ProposalDetailView changeId={changeId} />);
84
+ const output = stripAnsi(lastFrame() ?? "");
85
+
86
+ expect(output).toContain("No summary provided.");
87
+ } finally {
88
+ process.chdir(originalCwd);
89
+ }
90
+ });
91
+
92
+ test("proposal detail view shows empty state when tasks are missing", () => {
93
+ const originalCwd = process.cwd();
94
+ const base = mkdtempSync(join(tmpdir(), "schub-proposal-empty-tasks-"));
95
+ const schubDir = join(base, ".schub");
96
+ const changesRoot = join(schubDir, "changes");
97
+ const changeId = "C1202_no-tasks";
98
+ const proposal = `---\nchange_id: ${changeId}\ncreated: 2026-01-12\nstatus: Draft\ninput: Nothing\n---\n# Proposal - Empty Tasks\n\n## Summary\n\nStill working.\n`;
99
+
100
+ mkdirSync(changesRoot, { recursive: true });
101
+ writeProposal(changesRoot, changeId, proposal);
102
+
103
+ try {
104
+ process.chdir(base);
105
+ const { lastFrame } = render(<ProposalDetailView changeId={changeId} />);
106
+ const output = stripAnsi(lastFrame() ?? "");
107
+
108
+ expect(output).toContain("No tasks found.");
109
+ } finally {
110
+ process.chdir(originalCwd);
111
+ }
112
+ });
113
+
114
+ test("proposal detail view highlights the selected task", () => {
115
+ const originalCwd = process.cwd();
116
+ const base = mkdtempSync(join(tmpdir(), "schub-proposal-selection-"));
117
+ const schubDir = join(base, ".schub");
118
+ const changesRoot = join(schubDir, "changes");
119
+ const changeId = "C1203_task-selection";
120
+ const proposal = `---\nchange_id: ${changeId}\ncreated: 2026-01-13\nstatus: Accepted\ninput: Selection test\n---\n# Proposal - Task Selection\n\n## Summary\n\nFocus on tasks.\n`;
121
+
122
+ mkdirSync(changesRoot, { recursive: true });
123
+ writeProposal(changesRoot, changeId, proposal);
124
+ writeTask(schubDir, "backlog", "T0100", "first-task", `---\nchange_id: ${changeId}\n---\n# Task: T0100 First Task\n`);
125
+ writeTask(schubDir, "ready", "T0101", "second-task", `---\nchange_id: ${changeId}\n---\n# Task: T0101 Second Task\n`);
126
+
127
+ try {
128
+ process.chdir(base);
129
+ const { lastFrame } = render(<ProposalDetailView changeId={changeId} selection={1} />);
130
+ const output = stripAnsi(lastFrame() ?? "");
131
+ const backlogLine = findLine(output, "T0100");
132
+ const readyLine = findLine(output, "T0101");
133
+
134
+ expect(output).toContain("Backlog");
135
+ expect(output).toContain("Ready");
136
+ expect(backlogLine).not.toContain("›");
137
+ expect(readyLine).toContain("›");
138
+ } finally {
139
+ process.chdir(originalCwd);
140
+ }
141
+ });
142
+
143
+ test("proposal detail view sorts tasks alphabetically", () => {
144
+ const originalCwd = process.cwd();
145
+ const base = mkdtempSync(join(tmpdir(), "schub-proposal-sorted-"));
146
+ const schubDir = join(base, ".schub");
147
+ const changesRoot = join(schubDir, "changes");
148
+ const changeId = "C1204_task-sorted";
149
+ const proposal = `---\nchange_id: ${changeId}\ncreated: 2026-01-13\nstatus: Accepted\ninput: Sorting test\n---\n# Proposal - Task Sorting\n\n## Summary\n\nSort tasks alphabetically.\n`;
150
+
151
+ mkdirSync(changesRoot, { recursive: true });
152
+ writeProposal(changesRoot, changeId, proposal);
153
+ writeTask(schubDir, "backlog", "T0100", "zulu-task", `---\nchange_id: ${changeId}\n---\n# Task: T0100 Zulu Task\n`);
154
+ writeTask(schubDir, "backlog", "T0200", "alpha-task", `---\nchange_id: ${changeId}\n---\n# Task: T0200 Alpha Task\n`);
155
+
156
+ try {
157
+ process.chdir(base);
158
+ const { lastFrame } = render(<ProposalDetailView changeId={changeId} />);
159
+ const output = stripAnsi(lastFrame() ?? "");
160
+ const alphaIndex = output.indexOf("T0200");
161
+ const zuluIndex = output.indexOf("T0100");
162
+
163
+ expect(alphaIndex).toBeGreaterThanOrEqual(0);
164
+ expect(zuluIndex).toBeGreaterThanOrEqual(0);
165
+ expect(alphaIndex).toBeLessThan(zuluIndex);
166
+ } finally {
167
+ process.chdir(originalCwd);
168
+ }
169
+ });
@@ -0,0 +1,166 @@
1
+ import { Box, Text } from "ink";
2
+ import { readChangeDetail } from "../../changes";
3
+ import {
4
+ findSchubRoot,
5
+ listTasksForChange,
6
+ TASK_STATUSES,
7
+ type TaskInfo,
8
+ type TaskStatus,
9
+ trimTaskTitle,
10
+ } from "../../features/tasks";
11
+
12
+ type ProposalDetailViewProps = {
13
+ changeId: string;
14
+ selection?: number;
15
+ startDir?: string;
16
+ };
17
+
18
+ type TaskGroup = {
19
+ label: string;
20
+ status: TaskStatus;
21
+ items: TaskInfo[];
22
+ };
23
+
24
+ const SUMMARY_FALLBACK = "No summary provided.";
25
+
26
+ const compareText = (left: string, right: string) => left.localeCompare(right, undefined, { sensitivity: "base" });
27
+
28
+ const sortTaskItems = (tasks: TaskInfo[]) =>
29
+ tasks.sort((left, right) => {
30
+ const leftTitle = trimTaskTitle(left.title) || left.id;
31
+ const rightTitle = trimTaskTitle(right.title) || right.id;
32
+ const titleCompare = compareText(leftTitle, rightTitle);
33
+ if (titleCompare !== 0) {
34
+ return titleCompare;
35
+ }
36
+ return compareText(left.id, right.id);
37
+ });
38
+
39
+ const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
40
+ backlog: "Backlog",
41
+ ready: "Ready",
42
+ wip: "WIP",
43
+ blocked: "Blocked",
44
+ done: "Done",
45
+ archived: "Archived",
46
+ };
47
+
48
+ const buildTaskGroups = (tasks: TaskInfo[]): TaskGroup[] => {
49
+ const groups = new Map<TaskStatus, TaskInfo[]>();
50
+ for (const status of TASK_STATUSES) {
51
+ groups.set(status, []);
52
+ }
53
+
54
+ for (const task of tasks) {
55
+ const group = groups.get(task.status);
56
+ if (group) {
57
+ group.push(task);
58
+ }
59
+ }
60
+
61
+ for (const group of groups.values()) {
62
+ sortTaskItems(group);
63
+ }
64
+
65
+ return TASK_STATUSES.map((status) => ({
66
+ status,
67
+ label: TASK_STATUS_LABELS[status],
68
+ items: groups.get(status) ?? [],
69
+ })).filter((group) => group.items.length > 0);
70
+ };
71
+
72
+ export default function ProposalDetailView({ changeId, selection = 0, startDir }: ProposalDetailViewProps) {
73
+ const resolvedStartDir = startDir ?? process.env.SCHUB_CWD ?? process.cwd();
74
+ const schubDir = findSchubRoot(resolvedStartDir);
75
+
76
+ if (!schubDir) {
77
+ return (
78
+ <Box flexDirection="column">
79
+ <Text color="red">No .schub directory found.</Text>
80
+ </Box>
81
+ );
82
+ }
83
+
84
+ const detail = readChangeDetail(schubDir, changeId);
85
+ const tasks = listTasksForChange(schubDir, detail.changeId);
86
+ const summary = detail.summary.trim() || SUMMARY_FALLBACK;
87
+ const taskGroups = buildTaskGroups(tasks);
88
+
89
+ const renderMetadataRow = (label: string, value: string) => (
90
+ <Box>
91
+ <Text color="white">{label}:</Text>
92
+ <Text color="gray"> {value}</Text>
93
+ </Box>
94
+ );
95
+
96
+ const renderTaskRow = (task: TaskInfo, index: number) => {
97
+ const selected = index === selection;
98
+ const title = trimTaskTitle(task.title);
99
+ const displayTitle = title ? ` ${title}` : "";
100
+
101
+ return (
102
+ <Box key={task.id} marginLeft={1}>
103
+ <Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
104
+ <Box marginLeft={1}>
105
+ <Text color="white" bold={selected}>
106
+ {task.id}
107
+ </Text>
108
+ {displayTitle ? <Text color="gray">{displayTitle}</Text> : null}
109
+ </Box>
110
+ </Box>
111
+ );
112
+ };
113
+
114
+ let currentIndex = 0;
115
+
116
+ const renderTaskGroup = (group: TaskGroup) => {
117
+ const groupStartIndex = currentIndex;
118
+ const rows = group.items.map((task, index) => renderTaskRow(task, groupStartIndex + index));
119
+ currentIndex += group.items.length;
120
+
121
+ return (
122
+ <Box key={group.status} flexDirection="column" marginBottom={1}>
123
+ <Box marginBottom={0}>
124
+ <Text color="white">{group.label}</Text>
125
+ </Box>
126
+ {rows}
127
+ </Box>
128
+ );
129
+ };
130
+
131
+ return (
132
+ <Box flexDirection="column">
133
+ <Box marginBottom={1}>
134
+ <Text bold color="white">
135
+ Proposal Detail
136
+ </Text>
137
+ </Box>
138
+ <Box flexDirection="column" marginBottom={1}>
139
+ {renderMetadataRow("Change ID", detail.changeId)}
140
+ {renderMetadataRow("Created", detail.created)}
141
+ {renderMetadataRow("Status", detail.status)}
142
+ {renderMetadataRow("Input", detail.input)}
143
+ </Box>
144
+ <Box flexDirection="column" marginBottom={1}>
145
+ <Text color="white">Summary</Text>
146
+ <Box marginLeft={1}>
147
+ <Text color="gray">{summary}</Text>
148
+ </Box>
149
+ </Box>
150
+ <Box flexDirection="column">
151
+ <Box marginBottom={1}>
152
+ <Text bold color="white">
153
+ Tasks
154
+ </Text>
155
+ </Box>
156
+ {taskGroups.length === 0 ? (
157
+ <Box marginLeft={1}>
158
+ <Text color="gray">No tasks found.</Text>
159
+ </Box>
160
+ ) : (
161
+ taskGroups.map((group) => renderTaskGroup(group))
162
+ )}
163
+ </Box>
164
+ </Box>
165
+ );
166
+ }
@@ -0,0 +1,85 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { render as inkRender } from "ink-testing-library";
6
+ import RoadmapView from "./RoadmapView";
7
+
8
+ const ansiPattern = new RegExp(`${String.fromCharCode(27)}[[0-9;]*m`, "g");
9
+ const stripAnsi = (value: string) => value.replace(ansiPattern, "");
10
+
11
+ const renders: Array<() => void> = [];
12
+ const render = (...args: Parameters<typeof inkRender>) => {
13
+ const rendered = inkRender(...args);
14
+ renders.push(rendered.unmount);
15
+ return rendered;
16
+ };
17
+
18
+ afterEach(() => {
19
+ for (const unmount of renders) {
20
+ unmount();
21
+ }
22
+ renders.length = 0;
23
+ });
24
+
25
+ const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
26
+
27
+ const createRoadmapWorkspace = () => {
28
+ const base = mkdtempSync(join(tmpdir(), "schub-roadmap-view-"));
29
+ const schubDir = join(base, ".schub");
30
+ mkdirSync(schubDir, { recursive: true });
31
+ const roadmapPath = join(schubDir, "roadmap.md");
32
+ writeFileSync(roadmapPath, ["## Roadmap", "", "- **C0001**: As a user, I want to sign in.", ""].join("\n"), "utf8");
33
+ return { base, roadmapPath };
34
+ };
35
+
36
+ test("roadmap view renders items with proposal refs", async () => {
37
+ const { base } = createRoadmapWorkspace();
38
+ const { lastFrame } = render(<RoadmapView startDir={base} />);
39
+ await nextTick();
40
+ const output = stripAnsi(lastFrame() ?? "");
41
+
42
+ expect(output).toContain("Roadmap");
43
+ expect(output).toContain("C0001");
44
+ expect(output).toContain("As a user, I want to sign in.");
45
+ });
46
+
47
+ test("roadmap view sorts stories alphabetically", async () => {
48
+ const base = mkdtempSync(join(tmpdir(), "schub-roadmap-sorted-"));
49
+ const schubDir = join(base, ".schub");
50
+ mkdirSync(schubDir, { recursive: true });
51
+ const roadmapPath = join(schubDir, "roadmap.md");
52
+ writeFileSync(
53
+ roadmapPath,
54
+ ["## Roadmap", "", "- **C0001**: Zulu story.", "- **C0002**: Alpha story.", ""].join("\n"),
55
+ "utf8",
56
+ );
57
+
58
+ const { lastFrame } = render(<RoadmapView startDir={base} />);
59
+ await nextTick();
60
+ const output = stripAnsi(lastFrame() ?? "");
61
+ const alphaIndex = output.indexOf("Alpha story.");
62
+ const zuluIndex = output.indexOf("Zulu story.");
63
+
64
+ expect(alphaIndex).toBeGreaterThanOrEqual(0);
65
+ expect(zuluIndex).toBeGreaterThanOrEqual(0);
66
+ expect(alphaIndex).toBeLessThan(zuluIndex);
67
+ });
68
+
69
+ test("roadmap view adds a story from direct input", async () => {
70
+ const { base, roadmapPath } = createRoadmapWorkspace();
71
+ const rendered = render(<RoadmapView refreshIntervalMs={50} startDir={base} />);
72
+ await nextTick();
73
+ await nextTick();
74
+
75
+ const story = "As a tester, I want to explore.";
76
+ for (const char of story) {
77
+ rendered.stdin.write(char);
78
+ await nextTick();
79
+ }
80
+ rendered.stdin.write("\n");
81
+ await new Promise((resolve) => setTimeout(resolve, 20));
82
+
83
+ const content = readFileSync(roadmapPath, "utf8");
84
+ expect(content).toContain("- **<PROPOSAL_REF>**: As a tester, I want to explore.");
85
+ });