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.
- package/dist/__tests__/task-manager.test.d.ts +2 -0
- package/dist/__tests__/task-manager.test.d.ts.map +1 -0
- package/dist/__tests__/task-manager.test.js +95 -0
- package/dist/__tests__/task-manager.test.js.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +147 -0
- package/dist/cli.js.map +1 -0
- package/dist/git.d.ts +60 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +138 -0
- package/dist/git.js.map +1 -0
- package/dist/harnesses/agent-harness.d.ts +19 -0
- package/dist/harnesses/agent-harness.d.ts.map +1 -0
- package/dist/harnesses/agent-harness.js +2 -0
- package/dist/harnesses/agent-harness.js.map +1 -0
- package/dist/harnesses/index.d.ts +3 -0
- package/dist/harnesses/index.d.ts.map +1 -0
- package/dist/harnesses/index.js +2 -0
- package/dist/harnesses/index.js.map +1 -0
- package/dist/harnesses/pi.d.ts +23 -0
- package/dist/harnesses/pi.d.ts.map +1 -0
- package/dist/harnesses/pi.js +63 -0
- package/dist/harnesses/pi.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/orchestrator.d.ts +44 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +241 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/prompts.d.ts +12 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +112 -0
- package/dist/prompts.js.map +1 -0
- package/dist/providers/github.d.ts +34 -0
- package/dist/providers/github.d.ts.map +1 -0
- package/dist/providers/github.js +135 -0
- package/dist/providers/github.js.map +1 -0
- package/dist/providers/index.d.ts +3 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +2 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/issue-provider.d.ts +59 -0
- package/dist/providers/issue-provider.d.ts.map +1 -0
- package/dist/providers/issue-provider.js +2 -0
- package/dist/providers/issue-provider.js.map +1 -0
- package/dist/task-manager.d.ts +57 -0
- package/dist/task-manager.d.ts.map +1 -0
- package/dist/task-manager.js +158 -0
- package/dist/task-manager.js.map +1 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/package.json +46 -0
- package/src/cli.ts +172 -0
- package/src/git.ts +148 -0
- package/src/harnesses/agent-harness.ts +15 -0
- package/src/harnesses/index.ts +2 -0
- package/src/harnesses/pi.ts +82 -0
- package/src/index.ts +13 -0
- package/src/orchestrator.ts +294 -0
- package/src/prompts.ts +114 -0
- package/src/providers/github.ts +180 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/issue-provider.ts +59 -0
- package/src/task-manager.ts +190 -0
- 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,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'};
|