schub 0.1.3 → 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.
- package/README.md +27 -0
- package/dist/index.js +17231 -7669
- package/package.json +5 -2
- package/skills/create-proposal/SKILL.md +4 -0
- package/skills/create-tasks/SKILL.md +2 -1
- package/skills/implement-task/SKILL.md +6 -1
- package/skills/review-proposal/SKILL.md +1 -0
- package/skills/update-roadmap/SKILL.md +23 -0
- package/src/changes.test.ts +119 -5
- package/src/changes.ts +136 -54
- package/src/commands/adr.test.ts +5 -4
- package/src/commands/changes.test.ts +6 -6
- package/src/commands/cookbook.test.ts +5 -4
- package/src/commands/init.ts +5 -0
- package/src/commands/review.test.ts +5 -4
- package/src/commands/review.ts +1 -1
- package/src/commands/roadmap.test.ts +84 -0
- package/src/commands/roadmap.ts +84 -0
- package/src/commands/tasks-create.test.ts +1 -1
- package/src/commands/tasks-implement.test.ts +253 -0
- package/src/commands/tasks-implement.ts +121 -0
- package/src/commands/tasks-update.test.ts +92 -0
- package/src/commands/tasks.ts +98 -1
- package/src/features/roadmap/index.ts +230 -0
- package/src/features/roadmap/roadmap.test.ts +77 -0
- package/src/features/tasks/constants.ts +1 -0
- package/src/features/tasks/create.ts +9 -7
- package/src/features/tasks/filesystem.test.ts +221 -4
- package/src/features/tasks/filesystem.ts +124 -40
- package/src/features/tasks/graph.ts +18 -3
- package/src/features/tasks/index.ts +10 -1
- package/src/features/tasks/worktree.ts +48 -0
- package/src/frontmatter.ts +115 -0
- package/src/index.test.ts +42 -6
- package/src/index.ts +225 -118
- package/src/opencode.test.ts +53 -0
- package/src/opencode.ts +71 -3
- package/src/tasks.ts +1 -0
- package/src/tui/App.test.tsx +418 -0
- package/src/tui/App.tsx +343 -0
- package/src/{components → tui/components}/PlanView.test.tsx +26 -38
- package/src/tui/components/PlanView.tsx +89 -0
- package/src/tui/components/PreviewPage.test.tsx +69 -0
- package/src/tui/components/PreviewPage.tsx +87 -0
- package/src/tui/components/ProposalDetailView.test.tsx +169 -0
- package/src/tui/components/ProposalDetailView.tsx +166 -0
- package/src/tui/components/RoadmapView.test.tsx +85 -0
- package/src/tui/components/RoadmapView.tsx +369 -0
- package/src/tui/components/StatusView.test.tsx +1351 -0
- package/src/tui/components/StatusView.tsx +519 -0
- package/src/tui/components/markdown-renderer.test.ts +46 -0
- package/src/tui/components/markdown-renderer.ts +89 -0
- package/src/tui/components/status-view-data.ts +322 -0
- package/src/tui/components/status-view-render.tsx +329 -0
- package/src/tui/index.ts +16 -0
- package/templates/create-proposal/adr-template.md +6 -4
- package/templates/create-proposal/cookbook-template.md +5 -3
- package/templates/create-proposal/proposal-template.md +8 -6
- package/templates/create-roadmap/roadmap.md +5 -0
- package/templates/create-tasks/task-template.md +9 -4
- package/templates/review-proposal/q&a-template.md +8 -3
- package/templates/review-proposal/review-me-template.md +6 -4
- package/templates/setup-project/project-overview-template.md +5 -0
- package/templates/setup-project/project-setup-template.md +5 -0
- package/templates/setup-project/project-wow-template.md +5 -0
- package/src/App.test.tsx +0 -145
- package/src/App.tsx +0 -172
- package/src/components/PlanView.tsx +0 -160
- package/src/components/StatusView.test.tsx +0 -432
- package/src/components/StatusView.tsx +0 -420
- package/src/ide.ts +0 -7
- package/templates/templates-parity.test.ts +0 -45
- /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
- /package/src/{components → tui/components}/statusColor.ts +0 -0
- /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
- /package/src/{terminal.ts → tui/terminal.ts} +0 -0
|
@@ -83,7 +83,7 @@ const seedChange = (schubRoot: string, changeId: string) => {
|
|
|
83
83
|
const seedProposal = (schubRoot: string, changeId: string, status: string, title = "Seed") => {
|
|
84
84
|
const changeDir = join(schubRoot, "changes", changeId);
|
|
85
85
|
mkdirSync(changeDir, { recursive: true });
|
|
86
|
-
const proposal =
|
|
86
|
+
const proposal = `---\nstatus: ${status}\n---\n# Proposal - ${title}\n`;
|
|
87
87
|
writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
|
|
88
88
|
};
|
|
89
89
|
|
|
@@ -92,7 +92,7 @@ const seedTask = (schubRoot: string, status: string, taskId: string, changeId: s
|
|
|
92
92
|
mkdirSync(taskDir, { recursive: true });
|
|
93
93
|
const fileName = `${taskId}_${titleSlug}.md`;
|
|
94
94
|
const taskPath = join(taskDir, fileName);
|
|
95
|
-
const taskBody =
|
|
95
|
+
const taskBody = `---\nchange_id: ${changeId}\n---\n# Task: ${taskId} Seed\n`;
|
|
96
96
|
writeFileSync(taskPath, taskBody, "utf8");
|
|
97
97
|
return taskPath;
|
|
98
98
|
};
|
|
@@ -189,7 +189,7 @@ test("changes status updates accepted proposals", () => {
|
|
|
189
189
|
|
|
190
190
|
const proposalPath = join(schubRoot, "changes", "C0001_update-cli", "proposal.md");
|
|
191
191
|
const updated = readFileSync(proposalPath, "utf8");
|
|
192
|
-
expect(updated).toContain("
|
|
192
|
+
expect(updated).toContain("status: Done");
|
|
193
193
|
expect(stdout).toContain("[OK] Updated status");
|
|
194
194
|
});
|
|
195
195
|
|
|
@@ -204,7 +204,7 @@ test("changes status updates WIP proposals", () => {
|
|
|
204
204
|
|
|
205
205
|
const proposalPath = join(schubRoot, "changes", "C0001_update-cli", "proposal.md");
|
|
206
206
|
const updated = readFileSync(proposalPath, "utf8");
|
|
207
|
-
expect(updated).toContain("
|
|
207
|
+
expect(updated).toContain("status: Done");
|
|
208
208
|
expect(stdout).toContain("[OK] Updated status");
|
|
209
209
|
});
|
|
210
210
|
|
|
@@ -252,7 +252,7 @@ test("changes archive moves change and tasks by default", () => {
|
|
|
252
252
|
|
|
253
253
|
const proposalPath = join(archivedChangePath, "proposal.md");
|
|
254
254
|
const updated = readFileSync(proposalPath, "utf8");
|
|
255
|
-
expect(updated).toContain("
|
|
255
|
+
expect(updated).toContain("status: Archived");
|
|
256
256
|
});
|
|
257
257
|
|
|
258
258
|
test("changes archive skips tasks when requested", () => {
|
|
@@ -281,7 +281,7 @@ test("changes archive reports collisions and leaves tasks alone", () => {
|
|
|
281
281
|
|
|
282
282
|
const archiveRoot = join(schubRoot, "archive", "changes", changeId);
|
|
283
283
|
mkdirSync(archiveRoot, { recursive: true });
|
|
284
|
-
writeFileSync(join(archiveRoot, "proposal.md"), "# Proposal - Existing\n
|
|
284
|
+
writeFileSync(join(archiveRoot, "proposal.md"), "---\nstatus: Archived\n---\n# Proposal - Existing\n", "utf8");
|
|
285
285
|
|
|
286
286
|
const { result, stderr } = runChangesArchive(cwd, ["--change-id", changeId]);
|
|
287
287
|
|
|
@@ -37,12 +37,13 @@ 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
|
};
|
package/src/commands/init.ts
CHANGED
|
@@ -3,6 +3,7 @@ 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
9
|
type ProviderId = "codex" | "opencode";
|
|
@@ -167,6 +168,10 @@ export const runInit = async (args: string[], startDir: string) => {
|
|
|
167
168
|
const schubRoot = initSchubRoot(startDir);
|
|
168
169
|
process.stdout.write(`[OK] Wrote ${schubRoot}\n`);
|
|
169
170
|
|
|
171
|
+
const roadmapResult = ensureRoadmapFile(schubRoot);
|
|
172
|
+
const roadmapStatus = roadmapResult.created ? "OK" : "SKIP";
|
|
173
|
+
process.stdout.write(`[${roadmapStatus}] Roadmap ${roadmapResult.path}\n`);
|
|
174
|
+
|
|
170
175
|
const providers = await promptForProviders();
|
|
171
176
|
if (providers.includes("codex")) {
|
|
172
177
|
const destination = resolveCodexSkillsRoot(startDir);
|
|
@@ -37,12 +37,13 @@ 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
|
};
|
package/src/commands/review.ts
CHANGED
|
@@ -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.,
|
|
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
|
+
};
|
|
@@ -35,7 +35,7 @@ const createRepo = () => {
|
|
|
35
35
|
const seedProposal = (schubRoot: string, changeId: string, status = "Accepted") => {
|
|
36
36
|
const changeDir = join(schubRoot, "changes", changeId);
|
|
37
37
|
mkdirSync(changeDir, { recursive: true });
|
|
38
|
-
writeFileSync(join(changeDir, "proposal.md"),
|
|
38
|
+
writeFileSync(join(changeDir, "proposal.md"), `---\nstatus: ${status}\n---\n# Proposal - Seed\n`, "utf8");
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
const writeExistingTask = (schubRoot: string, status: string, id: string, slug: string) => {
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawnSync } from "bun";
|
|
7
|
+
|
|
8
|
+
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const cliDir = resolve(testDir, "..", "..");
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
|
|
12
|
+
const runTasksImplement = (schubCwd: string, args: string[], envOverrides: NodeJS.ProcessEnv = {}) => {
|
|
13
|
+
const result = spawnSync({
|
|
14
|
+
cmd: ["bun", "run", "schub", "tasks", "implement", ...args],
|
|
15
|
+
cwd: cliDir,
|
|
16
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd, ...envOverrides },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
result,
|
|
21
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
22
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const createTaskRepo = () => {
|
|
27
|
+
const base = mkdtempSync(join(tmpdir(), "schub-tasks-implement-"));
|
|
28
|
+
const repoRoot = join(base, "repo");
|
|
29
|
+
const cwd = join(repoRoot, "nested", "dir");
|
|
30
|
+
mkdirSync(cwd, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const tasksRoot = join(repoRoot, ".schub", "tasks");
|
|
33
|
+
const statuses = ["backlog", "ready", "wip", "blocked", "done", "archived"];
|
|
34
|
+
|
|
35
|
+
for (const status of statuses) {
|
|
36
|
+
mkdirSync(join(tasksRoot, status), { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { base, cwd, repoRoot, tasksRoot };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const writeTask = (tasksRoot: string, status: string, id: string, slug: string) => {
|
|
43
|
+
const filePath = join(tasksRoot, status, `${id}_${slug}.md`);
|
|
44
|
+
writeFileSync(filePath, `# Task: ${id} ${slug}\n`, "utf8");
|
|
45
|
+
return filePath;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const writeOpencodeStub = (baseDir: string) => {
|
|
49
|
+
const binDir = join(baseDir, "bin");
|
|
50
|
+
mkdirSync(binDir, { recursive: true });
|
|
51
|
+
const stubPath = join(binDir, "opencode");
|
|
52
|
+
writeFileSync(
|
|
53
|
+
stubPath,
|
|
54
|
+
[
|
|
55
|
+
"#!/usr/bin/env bash",
|
|
56
|
+
'if [ -n "$OPENCODE_LOG_FILE" ]; then',
|
|
57
|
+
' printf "%s" "$SCHUB_CWD" > "$OPENCODE_LOG_FILE"',
|
|
58
|
+
"fi",
|
|
59
|
+
"",
|
|
60
|
+
].join("\n"),
|
|
61
|
+
"utf8",
|
|
62
|
+
);
|
|
63
|
+
chmodSync(stubPath, 0o755);
|
|
64
|
+
return binDir;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const writeGitStub = (baseDir: string) => {
|
|
68
|
+
const binDir = join(baseDir, "bin");
|
|
69
|
+
mkdirSync(binDir, { recursive: true });
|
|
70
|
+
const stubPath = join(binDir, "git");
|
|
71
|
+
writeFileSync(stubPath, ["#!/usr/bin/env bash", "exit 1", ""].join("\n"), "utf8");
|
|
72
|
+
chmodSync(stubPath, 0o755);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const runGit = (repoRoot: string, args: string[]) => {
|
|
76
|
+
return spawnSync({
|
|
77
|
+
cmd: ["git", ...args],
|
|
78
|
+
cwd: repoRoot,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const runGitChecked = (repoRoot: string, args: string[]) => {
|
|
83
|
+
const result = runGit(repoRoot, args);
|
|
84
|
+
if (result.exitCode !== 0) {
|
|
85
|
+
const stderr = decoder.decode(result.stderr ?? new Uint8Array());
|
|
86
|
+
throw new Error(`Git command failed: git ${args.join(" ")}\n${stderr}`);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const initGitRepo = (repoRoot: string) => {
|
|
92
|
+
runGitChecked(repoRoot, ["init"]);
|
|
93
|
+
writeFileSync(join(repoRoot, "README.md"), "test", "utf8");
|
|
94
|
+
runGitChecked(repoRoot, ["add", "."]);
|
|
95
|
+
runGitChecked(repoRoot, ["-c", "user.email=schub@example.com", "-c", "user.name=Schub", "commit", "-m", "init"]);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const waitForFile = async (filePath: string) => {
|
|
99
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
100
|
+
if (existsSync(filePath)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
throw new Error(`Timed out waiting for ${filePath}`);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
test("tasks implement --mode none moves task to wip and launches opencode from repo root", async () => {
|
|
110
|
+
const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
111
|
+
writeTask(tasksRoot, "ready", "T0008", "none-mode");
|
|
112
|
+
|
|
113
|
+
const logPath = join(base, "opencode-log.txt");
|
|
114
|
+
const binDir = writeOpencodeStub(base);
|
|
115
|
+
const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
|
|
116
|
+
|
|
117
|
+
const { result } = runTasksImplement(
|
|
118
|
+
cwd,
|
|
119
|
+
["--id", "T0008", "--mode", "none", "--worktree-root", join(repoRoot, ".schub", "worktrees")],
|
|
120
|
+
{
|
|
121
|
+
PATH: path,
|
|
122
|
+
OPENCODE_LOG_FILE: logPath,
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(result.exitCode).toBe(0);
|
|
127
|
+
|
|
128
|
+
const wipPath = join(tasksRoot, "wip", "T0008_none-mode.md");
|
|
129
|
+
expect(existsSync(wipPath)).toBe(true);
|
|
130
|
+
expect(existsSync(join(tasksRoot, "ready", "T0008_none-mode.md"))).toBe(false);
|
|
131
|
+
|
|
132
|
+
await waitForFile(logPath);
|
|
133
|
+
const loggedCwd = readFileSync(logPath, "utf8");
|
|
134
|
+
expect(realpathSync(loggedCwd)).toBe(realpathSync(repoRoot));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("tasks implement --mode worktree creates a worktree and launches opencode from worktree root", async () => {
|
|
138
|
+
const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
139
|
+
initGitRepo(repoRoot);
|
|
140
|
+
writeTask(tasksRoot, "ready", "T0009", "worktree-mode");
|
|
141
|
+
|
|
142
|
+
const logPath = join(base, "opencode-log.txt");
|
|
143
|
+
const binDir = writeOpencodeStub(base);
|
|
144
|
+
const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
|
|
145
|
+
|
|
146
|
+
const { result } = runTasksImplement(cwd, ["--id", "T0009", "--mode", "worktree"], {
|
|
147
|
+
PATH: path,
|
|
148
|
+
OPENCODE_LOG_FILE: logPath,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.exitCode).toBe(0);
|
|
152
|
+
|
|
153
|
+
const wipPath = join(tasksRoot, "wip", "T0009_worktree-mode.md");
|
|
154
|
+
expect(existsSync(wipPath)).toBe(true);
|
|
155
|
+
|
|
156
|
+
const worktreePath = join(repoRoot, ".schub", "worktrees", "T0009");
|
|
157
|
+
expect(existsSync(worktreePath)).toBe(true);
|
|
158
|
+
|
|
159
|
+
const branchResult = runGit(repoRoot, ["rev-parse", "--verify", "task/T0009"]);
|
|
160
|
+
expect(branchResult.exitCode).toBe(0);
|
|
161
|
+
|
|
162
|
+
await waitForFile(logPath);
|
|
163
|
+
const loggedCwd = readFileSync(logPath, "utf8");
|
|
164
|
+
expect(realpathSync(loggedCwd)).toBe(realpathSync(worktreePath));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("tasks implement --mode worktree respects --worktree-root override", async () => {
|
|
168
|
+
const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
169
|
+
initGitRepo(repoRoot);
|
|
170
|
+
writeTask(tasksRoot, "ready", "T0010", "worktree-root");
|
|
171
|
+
|
|
172
|
+
const logPath = join(base, "opencode-log.txt");
|
|
173
|
+
const binDir = writeOpencodeStub(base);
|
|
174
|
+
const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
|
|
175
|
+
|
|
176
|
+
const { result } = runTasksImplement(
|
|
177
|
+
cwd,
|
|
178
|
+
["--id", "T0010", "--mode", "worktree", "--worktree-root", ".schub/custom-worktrees"],
|
|
179
|
+
{
|
|
180
|
+
PATH: path,
|
|
181
|
+
OPENCODE_LOG_FILE: logPath,
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(result.exitCode).toBe(0);
|
|
186
|
+
|
|
187
|
+
const worktreePath = join(repoRoot, ".schub", "custom-worktrees", "T0010");
|
|
188
|
+
expect(existsSync(worktreePath)).toBe(true);
|
|
189
|
+
|
|
190
|
+
await waitForFile(logPath);
|
|
191
|
+
const loggedCwd = readFileSync(logPath, "utf8");
|
|
192
|
+
expect(realpathSync(loggedCwd)).toBe(realpathSync(worktreePath));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("tasks implement --mode worktree errors when branch exists", () => {
|
|
196
|
+
const { cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
197
|
+
initGitRepo(repoRoot);
|
|
198
|
+
writeTask(tasksRoot, "ready", "T0011", "branch-exists");
|
|
199
|
+
runGitChecked(repoRoot, ["branch", "task/T0011"]);
|
|
200
|
+
|
|
201
|
+
const { result, stderr } = runTasksImplement(cwd, ["--id", "T0011", "--mode", "worktree"]);
|
|
202
|
+
|
|
203
|
+
expect(result.exitCode).not.toBe(0);
|
|
204
|
+
expect(stderr).toContain("Branch task/T0011 already exists");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("tasks implement --mode worktree errors when worktree path exists", () => {
|
|
208
|
+
const { cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
209
|
+
initGitRepo(repoRoot);
|
|
210
|
+
writeTask(tasksRoot, "ready", "T0012", "worktree-exists");
|
|
211
|
+
|
|
212
|
+
const worktreePath = join(repoRoot, ".schub", "worktrees", "T0012");
|
|
213
|
+
mkdirSync(worktreePath, { recursive: true });
|
|
214
|
+
|
|
215
|
+
const { result, stderr } = runTasksImplement(cwd, ["--id", "T0012", "--mode", "worktree"]);
|
|
216
|
+
|
|
217
|
+
expect(result.exitCode).not.toBe(0);
|
|
218
|
+
expect(stderr).toContain("Worktree path already exists");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("tasks implement --mode worktree falls back to none when git is unavailable", async () => {
|
|
222
|
+
const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
223
|
+
initGitRepo(repoRoot);
|
|
224
|
+
writeTask(tasksRoot, "ready", "T0013", "git-missing");
|
|
225
|
+
|
|
226
|
+
const logPath = join(base, "opencode-log.txt");
|
|
227
|
+
const binDir = writeOpencodeStub(base);
|
|
228
|
+
writeGitStub(base);
|
|
229
|
+
const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
|
|
230
|
+
|
|
231
|
+
const { result } = runTasksImplement(cwd, ["--id", "T0013", "--mode", "worktree"], {
|
|
232
|
+
PATH: path,
|
|
233
|
+
OPENCODE_LOG_FILE: logPath,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.exitCode).toBe(0);
|
|
237
|
+
|
|
238
|
+
const worktreePath = join(repoRoot, ".schub", "worktrees", "T0013");
|
|
239
|
+
expect(existsSync(worktreePath)).toBe(false);
|
|
240
|
+
|
|
241
|
+
await waitForFile(logPath);
|
|
242
|
+
const loggedCwd = readFileSync(logPath, "utf8");
|
|
243
|
+
expect(realpathSync(loggedCwd)).toBe(realpathSync(repoRoot));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("tasks implement errors when task id is not found", () => {
|
|
247
|
+
const { cwd } = createTaskRepo();
|
|
248
|
+
|
|
249
|
+
const { result, stderr } = runTasksImplement(cwd, ["--id", "T9999", "--mode", "none"]);
|
|
250
|
+
|
|
251
|
+
expect(result.exitCode).not.toBe(0);
|
|
252
|
+
expect(stderr).toContain("Task T9999 not found");
|
|
253
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { assignTaskToWip, createTaskWorktree } from "../features/tasks";
|
|
3
|
+
import { launchOpencodeImplement } from "../opencode";
|
|
4
|
+
|
|
5
|
+
type TaskImplementMode = "none" | "worktree";
|
|
6
|
+
|
|
7
|
+
type TaskImplementOptions = {
|
|
8
|
+
taskId: string;
|
|
9
|
+
mode: TaskImplementMode;
|
|
10
|
+
worktreeRoot?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const parseTaskImplementOptions = (args: string[]) => {
|
|
14
|
+
let taskId: string | undefined;
|
|
15
|
+
let mode: string | undefined;
|
|
16
|
+
let worktreeRoot: string | undefined;
|
|
17
|
+
const unknown: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
20
|
+
const arg = args[index];
|
|
21
|
+
if (arg === "--id") {
|
|
22
|
+
const value = args[index + 1];
|
|
23
|
+
if (value === undefined) {
|
|
24
|
+
throw new Error("Missing value for --id.");
|
|
25
|
+
}
|
|
26
|
+
taskId = value;
|
|
27
|
+
index += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg.startsWith("--id=")) {
|
|
31
|
+
taskId = arg.slice("--id=".length);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === "--mode") {
|
|
35
|
+
const value = args[index + 1];
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
throw new Error("Missing value for --mode.");
|
|
38
|
+
}
|
|
39
|
+
mode = value;
|
|
40
|
+
index += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (arg.startsWith("--mode=")) {
|
|
44
|
+
mode = arg.slice("--mode=".length);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (arg === "--worktree-root") {
|
|
48
|
+
const value = args[index + 1];
|
|
49
|
+
if (value === undefined) {
|
|
50
|
+
throw new Error("Missing value for --worktree-root.");
|
|
51
|
+
}
|
|
52
|
+
worktreeRoot = value;
|
|
53
|
+
index += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg.startsWith("--worktree-root=")) {
|
|
57
|
+
worktreeRoot = arg.slice("--worktree-root=".length);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
unknown.push(arg);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (unknown.length > 0) {
|
|
64
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!taskId || taskId.trim() === "") {
|
|
68
|
+
throw new Error("Provide --id.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (mode !== undefined && mode.trim() === "") {
|
|
72
|
+
throw new Error("Missing value for --mode.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (worktreeRoot !== undefined && worktreeRoot.trim() === "") {
|
|
76
|
+
throw new Error("Missing value for --worktree-root.");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const normalizedMode = (mode ?? "none").trim().toLowerCase();
|
|
80
|
+
const allowedModes: TaskImplementMode[] = ["none", "worktree"];
|
|
81
|
+
if (!allowedModes.includes(normalizedMode as TaskImplementMode)) {
|
|
82
|
+
throw new Error(`Invalid mode '${mode}'. Use none or worktree.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const options: TaskImplementOptions = {
|
|
86
|
+
taskId: taskId.trim().toUpperCase(),
|
|
87
|
+
mode: normalizedMode as TaskImplementMode,
|
|
88
|
+
worktreeRoot: worktreeRoot?.trim() || undefined,
|
|
89
|
+
};
|
|
90
|
+
return options;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const runTasksImplement = (schubDir: string | null, args: string[]) => {
|
|
94
|
+
if (!schubDir) {
|
|
95
|
+
throw new Error("No .schub directory found.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const options = parseTaskImplementOptions(args);
|
|
99
|
+
const assigned = assignTaskToWip(schubDir, options.taskId);
|
|
100
|
+
const repoRoot = dirname(schubDir);
|
|
101
|
+
|
|
102
|
+
let launchRoot = repoRoot;
|
|
103
|
+
|
|
104
|
+
if (options.mode === "worktree") {
|
|
105
|
+
const worktree = createTaskWorktree({
|
|
106
|
+
repoRoot,
|
|
107
|
+
taskId: options.taskId,
|
|
108
|
+
worktreeRoot: options.worktreeRoot,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (worktree) {
|
|
112
|
+
launchRoot = worktree.worktreePath;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
launchOpencodeImplement(options.taskId, repoRoot, assigned.title, {
|
|
117
|
+
env: { ...process.env, SCHUB_CWD: launchRoot },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
process.stdout.write(`[OK] Assigned task ${assigned.id}: ${assigned.status}\n`);
|
|
121
|
+
};
|