whitesmith 0.0.0

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 (70) hide show
  1. package/dist/__tests__/task-manager.test.d.ts +2 -0
  2. package/dist/__tests__/task-manager.test.d.ts.map +1 -0
  3. package/dist/__tests__/task-manager.test.js +95 -0
  4. package/dist/__tests__/task-manager.test.js.map +1 -0
  5. package/dist/cli.d.ts +5 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +147 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/git.d.ts +60 -0
  10. package/dist/git.d.ts.map +1 -0
  11. package/dist/git.js +138 -0
  12. package/dist/git.js.map +1 -0
  13. package/dist/harnesses/agent-harness.d.ts +19 -0
  14. package/dist/harnesses/agent-harness.d.ts.map +1 -0
  15. package/dist/harnesses/agent-harness.js +2 -0
  16. package/dist/harnesses/agent-harness.js.map +1 -0
  17. package/dist/harnesses/index.d.ts +3 -0
  18. package/dist/harnesses/index.d.ts.map +1 -0
  19. package/dist/harnesses/index.js +2 -0
  20. package/dist/harnesses/index.js.map +1 -0
  21. package/dist/harnesses/pi.d.ts +23 -0
  22. package/dist/harnesses/pi.d.ts.map +1 -0
  23. package/dist/harnesses/pi.js +63 -0
  24. package/dist/harnesses/pi.js.map +1 -0
  25. package/dist/index.d.ts +11 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +8 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/orchestrator.d.ts +44 -0
  30. package/dist/orchestrator.d.ts.map +1 -0
  31. package/dist/orchestrator.js +241 -0
  32. package/dist/orchestrator.js.map +1 -0
  33. package/dist/prompts.d.ts +12 -0
  34. package/dist/prompts.d.ts.map +1 -0
  35. package/dist/prompts.js +112 -0
  36. package/dist/prompts.js.map +1 -0
  37. package/dist/providers/github.d.ts +34 -0
  38. package/dist/providers/github.d.ts.map +1 -0
  39. package/dist/providers/github.js +135 -0
  40. package/dist/providers/github.js.map +1 -0
  41. package/dist/providers/index.d.ts +3 -0
  42. package/dist/providers/index.d.ts.map +1 -0
  43. package/dist/providers/index.js +2 -0
  44. package/dist/providers/index.js.map +1 -0
  45. package/dist/providers/issue-provider.d.ts +59 -0
  46. package/dist/providers/issue-provider.d.ts.map +1 -0
  47. package/dist/providers/issue-provider.js +2 -0
  48. package/dist/providers/issue-provider.js.map +1 -0
  49. package/dist/task-manager.d.ts +57 -0
  50. package/dist/task-manager.d.ts.map +1 -0
  51. package/dist/task-manager.js +158 -0
  52. package/dist/task-manager.js.map +1 -0
  53. package/dist/types.d.ts +92 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +14 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +46 -0
  58. package/src/cli.ts +172 -0
  59. package/src/git.ts +148 -0
  60. package/src/harnesses/agent-harness.ts +15 -0
  61. package/src/harnesses/index.ts +2 -0
  62. package/src/harnesses/pi.ts +82 -0
  63. package/src/index.ts +13 -0
  64. package/src/orchestrator.ts +294 -0
  65. package/src/prompts.ts +114 -0
  66. package/src/providers/github.ts +180 -0
  67. package/src/providers/index.ts +2 -0
  68. package/src/providers/issue-provider.ts +59 -0
  69. package/src/task-manager.ts +190 -0
  70. package/src/types.ts +88 -0
@@ -0,0 +1,180 @@
1
+ import {exec} from 'node:child_process';
2
+ import {promisify} from 'node:util';
3
+ import type {IssueProvider} from './issue-provider.js';
4
+ import type {Issue} from '../types.js';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ /**
9
+ * GitHub Issues provider using the `gh` CLI.
10
+ */
11
+ export class GitHubProvider implements IssueProvider {
12
+ private workDir: string;
13
+ private repo?: string;
14
+
15
+ constructor(workDir: string, repo?: string) {
16
+ this.workDir = workDir;
17
+ this.repo = repo;
18
+ }
19
+
20
+ private repoFlag(): string {
21
+ return this.repo ? ` --repo ${this.repo}` : '';
22
+ }
23
+
24
+ private async gh(args: string): Promise<string> {
25
+ try {
26
+ const {stdout} = await execAsync(`gh ${args}${this.repoFlag()}`, {
27
+ cwd: this.workDir,
28
+ maxBuffer: 10 * 1024 * 1024,
29
+ });
30
+ return stdout.trim();
31
+ } catch (error) {
32
+ const e = error as {stdout?: string; stderr?: string; message?: string};
33
+ // Some gh commands return exit code 1 for "not found" results
34
+ if (e.stdout !== undefined) {
35
+ return e.stdout.trim();
36
+ }
37
+ throw new Error(`gh ${args} failed: ${e.stderr || e.message}`);
38
+ }
39
+ }
40
+
41
+ async listIssues(options?: {labels?: string[]; noLabels?: string[]}): Promise<Issue[]> {
42
+ let cmd = 'issue list --state open --json number,title,body,labels,url --limit 100';
43
+
44
+ if (options?.labels && options.labels.length > 0) {
45
+ cmd += ` --label "${options.labels.join(',')}"`;
46
+ }
47
+
48
+ const raw = await this.gh(cmd);
49
+ if (!raw || raw === '[]') return [];
50
+
51
+ const issues: Array<{
52
+ number: number;
53
+ title: string;
54
+ body: string;
55
+ labels: Array<{name: string}>;
56
+ url: string;
57
+ }> = JSON.parse(raw);
58
+
59
+ let result: Issue[] = issues.map((i) => ({
60
+ number: i.number,
61
+ title: i.title,
62
+ body: i.body || '',
63
+ labels: i.labels.map((l) => l.name),
64
+ url: i.url,
65
+ }));
66
+
67
+ // Client-side filter for "no labels" (gh CLI doesn't support negation)
68
+ if (options?.noLabels && options.noLabels.length > 0) {
69
+ result = result.filter((issue) => !issue.labels.some((l) => options.noLabels!.includes(l)));
70
+ }
71
+
72
+ return result;
73
+ }
74
+
75
+ async getIssue(number: number): Promise<Issue> {
76
+ const raw = await this.gh(`issue view ${number} --json number,title,body,labels,url`);
77
+ const i = JSON.parse(raw) as {
78
+ number: number;
79
+ title: string;
80
+ body: string;
81
+ labels: Array<{name: string}>;
82
+ url: string;
83
+ };
84
+ return {
85
+ number: i.number,
86
+ title: i.title,
87
+ body: i.body || '',
88
+ labels: i.labels.map((l) => l.name),
89
+ url: i.url,
90
+ };
91
+ }
92
+
93
+ async addLabel(number: number, label: string): Promise<void> {
94
+ await this.gh(`issue edit ${number} --add-label "${label}"`);
95
+ }
96
+
97
+ async removeLabel(number: number, label: string): Promise<void> {
98
+ try {
99
+ await this.gh(`issue edit ${number} --remove-label "${label}"`);
100
+ } catch {
101
+ // Ignore if label wasn't present
102
+ }
103
+ }
104
+
105
+ async comment(number: number, body: string): Promise<void> {
106
+ // Use stdin to avoid shell escaping issues
107
+ await new Promise<void>((resolve, reject) => {
108
+ const child = exec(
109
+ `gh issue comment ${number} --body-file -${this.repoFlag()}`,
110
+ {cwd: this.workDir},
111
+ (err) => (err ? reject(err) : resolve()),
112
+ );
113
+ child.stdin!.write(body);
114
+ child.stdin!.end();
115
+ });
116
+ }
117
+
118
+ async closeIssue(number: number): Promise<void> {
119
+ await this.gh(`issue close ${number}`);
120
+ }
121
+
122
+ async createPR(options: {
123
+ head: string;
124
+ base: string;
125
+ title: string;
126
+ body: string;
127
+ }): Promise<string> {
128
+ const url = await new Promise<string>((resolve, reject) => {
129
+ const child = exec(
130
+ `gh pr create --head "${options.head}" --base "${options.base}" --title "${options.title}" --body-file -${this.repoFlag()}`,
131
+ {cwd: this.workDir},
132
+ (err, stdout) => (err ? reject(err) : resolve(stdout.trim())),
133
+ );
134
+ child.stdin!.write(options.body);
135
+ child.stdin!.end();
136
+ });
137
+ return url;
138
+ }
139
+
140
+ async remoteBranchExists(branch: string): Promise<boolean> {
141
+ try {
142
+ const {stdout} = await execAsync(`git ls-remote --heads origin ${branch}`, {
143
+ cwd: this.workDir,
144
+ });
145
+ return stdout.trim().length > 0;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ async getPRForBranch(
152
+ branch: string,
153
+ ): Promise<{state: 'open' | 'merged' | 'closed'; url: string} | null> {
154
+ try {
155
+ const raw = await this.gh(
156
+ `pr list --head "${branch}" --state all --json state,url --limit 1`,
157
+ );
158
+ if (!raw || raw === '[]') return null;
159
+ const prs = JSON.parse(raw) as Array<{state: string; url: string}>;
160
+ if (prs.length === 0) return null;
161
+ const pr = prs[0];
162
+ const state = pr.state.toLowerCase() as 'open' | 'merged' | 'closed';
163
+ return {state, url: pr.url};
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ async ensureLabels(labels: string[]): Promise<void> {
170
+ for (const label of labels) {
171
+ try {
172
+ await this.gh(
173
+ `label create "${label}" --force --color 7B68EE --description "whitesmith automation"`,
174
+ );
175
+ } catch {
176
+ // Label might already exist
177
+ }
178
+ }
179
+ }
180
+ }
@@ -0,0 +1,2 @@
1
+ export type {IssueProvider} from './issue-provider.js';
2
+ export {GitHubProvider} from './github.js';
@@ -0,0 +1,59 @@
1
+ import type {Issue} from '../types.js';
2
+
3
+ /**
4
+ * Interface for issue sources.
5
+ * Implementations can back this with GitHub Issues, GitLab, Jira, etc.
6
+ */
7
+ export interface IssueProvider {
8
+ /**
9
+ * List open issues, optionally filtered by labels
10
+ */
11
+ listIssues(options?: {labels?: string[]; noLabels?: string[]}): Promise<Issue[]>;
12
+
13
+ /**
14
+ * Get a single issue by number
15
+ */
16
+ getIssue(number: number): Promise<Issue>;
17
+
18
+ /**
19
+ * Add a label to an issue
20
+ */
21
+ addLabel(number: number, label: string): Promise<void>;
22
+
23
+ /**
24
+ * Remove a label from an issue
25
+ */
26
+ removeLabel(number: number, label: string): Promise<void>;
27
+
28
+ /**
29
+ * Post a comment on an issue
30
+ */
31
+ comment(number: number, body: string): Promise<void>;
32
+
33
+ /**
34
+ * Close an issue
35
+ */
36
+ closeIssue(number: number): Promise<void>;
37
+
38
+ /**
39
+ * Create a pull request and return its URL
40
+ */
41
+ createPR(options: {head: string; base: string; title: string; body: string}): Promise<string>;
42
+
43
+ /**
44
+ * Check if a remote branch exists
45
+ */
46
+ remoteBranchExists(branch: string): Promise<boolean>;
47
+
48
+ /**
49
+ * Check if there's an open or merged PR for a given head branch
50
+ */
51
+ getPRForBranch(
52
+ branch: string,
53
+ ): Promise<{state: 'open' | 'merged' | 'closed'; url: string} | null>;
54
+
55
+ /**
56
+ * Ensure required labels exist in the repo (create if missing)
57
+ */
58
+ ensureLabels(labels: string[]): Promise<void>;
59
+ }
@@ -0,0 +1,190 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import {parse as parseYaml, stringify as stringifyYaml} from 'yaml';
4
+ import type {Task, TaskFrontmatter} from './types.js';
5
+
6
+ const TASKS_DIR = 'tasks';
7
+
8
+ /**
9
+ * Manages task files in the tasks/ directory.
10
+ *
11
+ * Task files live at: tasks/<issue-number>/<seq>-<slug>.md
12
+ * Each file has YAML frontmatter with id, issue, title, depends_on.
13
+ */
14
+ export class TaskManager {
15
+ private workDir: string;
16
+
17
+ constructor(workDir: string) {
18
+ this.workDir = workDir;
19
+ }
20
+
21
+ /**
22
+ * Get the tasks directory path
23
+ */
24
+ getTasksDir(): string {
25
+ return path.join(this.workDir, TASKS_DIR);
26
+ }
27
+
28
+ /**
29
+ * Get the directory for a specific issue's tasks
30
+ */
31
+ getIssueTasksDir(issueNumber: number): string {
32
+ return path.join(this.workDir, TASKS_DIR, String(issueNumber));
33
+ }
34
+
35
+ /**
36
+ * List all pending tasks for an issue (files in tasks/<issue>/)
37
+ */
38
+ listTasks(issueNumber: number): Task[] {
39
+ const dir = this.getIssueTasksDir(issueNumber);
40
+ if (!fs.existsSync(dir)) return [];
41
+
42
+ const files = fs
43
+ .readdirSync(dir)
44
+ .filter((f) => f.endsWith('.md'))
45
+ .sort();
46
+
47
+ return files.map((f) => this.readTask(path.join(dir, f)));
48
+ }
49
+
50
+ /**
51
+ * List all pending tasks across all issues
52
+ */
53
+ listAllTasks(): Task[] {
54
+ const tasksDir = this.getTasksDir();
55
+ if (!fs.existsSync(tasksDir)) return [];
56
+
57
+ const issueDirs = fs
58
+ .readdirSync(tasksDir)
59
+ .filter((d) => {
60
+ const fullPath = path.join(tasksDir, d);
61
+ return fs.statSync(fullPath).isDirectory() && /^\d+$/.test(d);
62
+ })
63
+ .sort();
64
+
65
+ const tasks: Task[] = [];
66
+ for (const issueDir of issueDirs) {
67
+ const issueNumber = parseInt(issueDir, 10);
68
+ tasks.push(...this.listTasks(issueNumber));
69
+ }
70
+ return tasks;
71
+ }
72
+
73
+ /**
74
+ * Check if an issue has any remaining (pending) tasks
75
+ */
76
+ hasRemainingTasks(issueNumber: number): boolean {
77
+ return this.listTasks(issueNumber).length > 0;
78
+ }
79
+
80
+ /**
81
+ * Get issue numbers that have task files
82
+ */
83
+ getIssuesWithTasks(): number[] {
84
+ const tasksDir = this.getTasksDir();
85
+ if (!fs.existsSync(tasksDir)) return [];
86
+
87
+ return fs
88
+ .readdirSync(tasksDir)
89
+ .filter((d) => {
90
+ const fullPath = path.join(tasksDir, d);
91
+ return fs.statSync(fullPath).isDirectory() && /^\d+$/.test(d);
92
+ })
93
+ .map((d) => parseInt(d, 10))
94
+ .sort((a, b) => a - b);
95
+ }
96
+
97
+ /**
98
+ * Read and parse a single task file
99
+ */
100
+ readTask(filePath: string): Task {
101
+ const content = fs.readFileSync(filePath, 'utf-8');
102
+ const frontmatter = this.parseFrontmatter(content);
103
+ const relativePath = path.relative(this.workDir, filePath);
104
+
105
+ return {
106
+ id: frontmatter.id,
107
+ issue: frontmatter.issue,
108
+ title: frontmatter.title,
109
+ dependsOn: frontmatter.depends_on || [],
110
+ content,
111
+ filePath: relativePath,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Write a task file
117
+ */
118
+ writeTask(
119
+ issueNumber: number,
120
+ seq: number,
121
+ slug: string,
122
+ frontmatter: TaskFrontmatter,
123
+ body: string,
124
+ ): string {
125
+ const dir = this.getIssueTasksDir(issueNumber);
126
+ fs.mkdirSync(dir, {recursive: true});
127
+
128
+ const seqStr = String(seq).padStart(3, '0');
129
+ const fileName = `${seqStr}-${slug}.md`;
130
+ const filePath = path.join(dir, fileName);
131
+
132
+ const content = `---\n${stringifyYaml(frontmatter).trim()}\n---\n\n${body}`;
133
+ fs.writeFileSync(filePath, content, 'utf-8');
134
+
135
+ return path.relative(this.workDir, filePath);
136
+ }
137
+
138
+ /**
139
+ * Delete a task file (called when task is implemented)
140
+ */
141
+ deleteTask(taskFilePath: string): void {
142
+ const fullPath = path.resolve(this.workDir, taskFilePath);
143
+ if (fs.existsSync(fullPath)) {
144
+ fs.unlinkSync(fullPath);
145
+ }
146
+
147
+ // Clean up empty issue directory
148
+ const dir = path.dirname(fullPath);
149
+ if (fs.existsSync(dir)) {
150
+ const remaining = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
151
+ if (remaining.length === 0) {
152
+ // Remove directory if no more task files
153
+ fs.rmSync(dir, {recursive: true, force: true});
154
+ }
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Check if all dependencies of a task are satisfied
160
+ * (i.e. their task files have been deleted from the tasks directory)
161
+ */
162
+ areDependenciesSatisfied(task: Task): boolean {
163
+ if (task.dependsOn.length === 0) return true;
164
+
165
+ const allTasks = this.listAllTasks();
166
+ const pendingIds = new Set(allTasks.map((t) => t.id));
167
+
168
+ // A dependency is satisfied if its task file no longer exists (deleted = completed)
169
+ return task.dependsOn.every((depId) => !pendingIds.has(depId));
170
+ }
171
+
172
+ /**
173
+ * Parse YAML frontmatter from a markdown file
174
+ */
175
+ private parseFrontmatter(content: string): TaskFrontmatter {
176
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
177
+ if (!match) {
178
+ throw new Error('Task file is missing YAML frontmatter');
179
+ }
180
+
181
+ const parsed = parseYaml(match[1]) as TaskFrontmatter;
182
+ if (!parsed.id || parsed.issue === undefined || !parsed.title) {
183
+ throw new Error(
184
+ `Task frontmatter is missing required fields (id, issue, title). Got: ${JSON.stringify(parsed)}`,
185
+ );
186
+ }
187
+
188
+ return parsed;
189
+ }
190
+ }
package/src/types.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * A GitHub issue (or equivalent) created by a human.
3
+ * Represents a problem, feature request, or improvement.
4
+ */
5
+ export interface Issue {
6
+ /** Issue number (e.g. 42) */
7
+ number: number;
8
+ /** Issue title */
9
+ title: string;
10
+ /** Issue body/description (markdown) */
11
+ body: string;
12
+ /** Current labels on the issue */
13
+ labels: string[];
14
+ /** Issue URL */
15
+ url: string;
16
+ }
17
+
18
+ /**
19
+ * A task generated from an issue.
20
+ * Each task represents a single PR's worth of work.
21
+ */
22
+ export interface Task {
23
+ /** Unique task ID: "<issue>-<seq>" e.g. "42-001" */
24
+ id: string;
25
+ /** Parent issue number */
26
+ issue: number;
27
+ /** Human-readable title */
28
+ title: string;
29
+ /** Task IDs this depends on (must be completed first) */
30
+ dependsOn: string[];
31
+ /** Full markdown content of the task file (including frontmatter) */
32
+ content: string;
33
+ /** File path relative to repo root */
34
+ filePath: string;
35
+ }
36
+
37
+ /**
38
+ * Parsed frontmatter from a task file
39
+ */
40
+ export interface TaskFrontmatter {
41
+ id: string;
42
+ issue: number;
43
+ title: string;
44
+ depends_on?: string[];
45
+ }
46
+
47
+ /**
48
+ * Labels used by whitesmith to track issue state
49
+ */
50
+ export const LABELS = {
51
+ /** Agent is generating tasks for this issue */
52
+ INVESTIGATING: 'whitesmith:investigating',
53
+ /** A PR with generated tasks has been opened */
54
+ TASKS_PROPOSED: 'whitesmith:tasks-proposed',
55
+ /** Task PR has been merged — tasks are on main */
56
+ TASKS_ACCEPTED: 'whitesmith:tasks-accepted',
57
+ /** All tasks for this issue have been completed */
58
+ COMPLETED: 'whitesmith:completed',
59
+ } as const;
60
+
61
+ /**
62
+ * Configuration for whitesmith
63
+ */
64
+ export interface DevPulseConfig {
65
+ /** Command to run the agent harness */
66
+ agentCmd: string;
67
+ /** Maximum iterations per run */
68
+ maxIterations: number;
69
+ /** Working directory (the repo) */
70
+ workDir: string;
71
+ /** Skip pushing branches and creating PRs */
72
+ noPush: boolean;
73
+ /** Skip sleep between iterations (for testing) */
74
+ noSleep: boolean;
75
+ /** Log file path */
76
+ logFile?: string;
77
+ /** GitHub repo in "owner/repo" format (auto-detected if not set) */
78
+ repo?: string;
79
+ }
80
+
81
+ /**
82
+ * What the orchestrator should do next
83
+ */
84
+ export type Action =
85
+ | {type: 'reconcile'; issue: Issue}
86
+ | {type: 'investigate'; issue: Issue}
87
+ | {type: 'implement'; task: Task; issue: Issue}
88
+ | {type: 'idle'};