pi-long-task 0.1.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/LICENSE +21 -0
- package/README.md +126 -0
- package/package.json +63 -0
- package/scripts/native_smoke.mjs +323 -0
- package/src/coordinator.ts +633 -0
- package/src/git.ts +262 -0
- package/src/index.ts +63 -0
- package/src/render.ts +270 -0
- package/src/result_writer.ts +96 -0
- package/src/todo_generator.ts +304 -0
- package/src/todo_parser.ts +229 -0
- package/src/types.ts +60 -0
- package/src/worker_session.ts +836 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { parseTasks, TodoParseError } from "./todo_parser.ts";
|
|
2
|
+
|
|
3
|
+
const TODO_HEADING_RE = /^##\s+TODO\s+(\d+)\s+[—-]\s+(.+?)\s*$/gm;
|
|
4
|
+
const TODO_HEADING_LINE_RE = /^##\s+TODO\s+(\d+)\s+[—-]\s+(.+?)\s*$/;
|
|
5
|
+
const PROGRESS_HEADING_RE = /^##\s+Progress\s*$/im;
|
|
6
|
+
const BULLET_ITEM_RE = /^\s*[-*+]\s+(?!-{2,}\s*$)(.+?)\s*$/;
|
|
7
|
+
const NUMBERED_ITEM_RE = /^\s*\d+[.)]\s+(.+?)\s*$/;
|
|
8
|
+
const FENCE_RE = /```(?:markdown|md)?\s*\n([\s\S]*?)\n```/gi;
|
|
9
|
+
|
|
10
|
+
export class TodoGenerationError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "TodoGenerationError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ExistingTask {
|
|
18
|
+
taskId: string;
|
|
19
|
+
title: string;
|
|
20
|
+
body: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function todoMarkdownFromString(rawInput: string): string | undefined {
|
|
24
|
+
const input = rawInput.trim();
|
|
25
|
+
if (!input) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (hasTodoHeadings(input)) {
|
|
30
|
+
const markdown = normalizeExistingTodoMarkdown(input);
|
|
31
|
+
validateTodoMarkdown(markdown);
|
|
32
|
+
return markdown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const listItems = simpleListItems(input);
|
|
36
|
+
if (listItems.length >= 2) {
|
|
37
|
+
const markdown = generatedTodoMarkdown(listItems);
|
|
38
|
+
validateTodoMarkdown(markdown);
|
|
39
|
+
return markdown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function generatedTodoMarkdown(items: string[]): string {
|
|
46
|
+
const titles = items.map(cleanTitle).filter((item) => item.length > 0);
|
|
47
|
+
if (titles.length === 0) {
|
|
48
|
+
throw new TodoGenerationError("Cannot generate TODO markdown without at least one task item.");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const progress = titles.map((title, idx) => `- [ ] TODO ${idx + 1} — ${title}`).join("\n");
|
|
52
|
+
const sections = titles
|
|
53
|
+
.map((title, idx) => {
|
|
54
|
+
const taskId = idx + 1;
|
|
55
|
+
return `## TODO ${taskId} — ${title}\n\n**Goal:** ${goalForTitle(title)}\n\n**Status:**\n- [ ] Complete ${lowercaseFirst(title)}\n\n**Verify:**\n- Run focused checks relevant to this task.\n\n**Done when:**\n- The task is implemented and verified.`;
|
|
56
|
+
})
|
|
57
|
+
.join("\n\n");
|
|
58
|
+
|
|
59
|
+
return `# Pi Long Task TODO\n\n## Progress\n\n${progress}\n\n---\n\n${sections}\n`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function validateTodoMarkdown(markdown: string): void {
|
|
63
|
+
const trimmed = markdown.trim();
|
|
64
|
+
if (!trimmed.startsWith("# Pi Long Task TODO")) {
|
|
65
|
+
throw new TodoGenerationError("TODO markdown must start with `# Pi Long Task TODO`.");
|
|
66
|
+
}
|
|
67
|
+
if (!PROGRESS_HEADING_RE.test(trimmed)) {
|
|
68
|
+
throw new TodoGenerationError("TODO markdown must include a `## Progress` section.");
|
|
69
|
+
}
|
|
70
|
+
if (!/^---\s*$/m.test(trimmed)) {
|
|
71
|
+
throw new TodoGenerationError("TODO markdown must include a `---` separator before task sections.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let tasks;
|
|
75
|
+
try {
|
|
76
|
+
tasks = parseTasks(markdown);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof TodoParseError) {
|
|
79
|
+
throw new TodoGenerationError(error.message);
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (tasks.length === 0) {
|
|
85
|
+
throw new TodoGenerationError("TODO markdown must include at least one task section.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
tasks.forEach((task, idx) => {
|
|
89
|
+
const expectedId = String(idx + 1);
|
|
90
|
+
if (task.taskId !== expectedId) {
|
|
91
|
+
throw new TodoGenerationError(
|
|
92
|
+
`Task IDs must be sequential. Expected TODO ${expectedId}, found TODO ${task.taskId}.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const progressLine = progressLineRegex(task.taskId, task.title);
|
|
97
|
+
if (!progressLine.test(markdown)) {
|
|
98
|
+
throw new TodoGenerationError(`Progress section must include an unchecked line for TODO ${task.taskId}.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!/\*\*Goal:\*\*/.test(task.section)) {
|
|
102
|
+
throw new TodoGenerationError(`TODO ${task.taskId} must include \`**Goal:**\`.`);
|
|
103
|
+
}
|
|
104
|
+
if (!/\*\*Status:\*\*/.test(task.section)) {
|
|
105
|
+
throw new TodoGenerationError(`TODO ${task.taskId} must include \`**Status:**\`.`);
|
|
106
|
+
}
|
|
107
|
+
if (task.statusCheckboxes.length === 0) {
|
|
108
|
+
throw new TodoGenerationError(`TODO ${task.taskId} must include status checkboxes.`);
|
|
109
|
+
}
|
|
110
|
+
if (!/\*\*Verify:\*\*/.test(task.section) && !/verification|verify|checks?/i.test(task.section)) {
|
|
111
|
+
throw new TodoGenerationError(`TODO ${task.taskId} must include \`**Verify:**\` or verification guidance.`);
|
|
112
|
+
}
|
|
113
|
+
if (!/\*\*Done when:\*\*/.test(task.section)) {
|
|
114
|
+
throw new TodoGenerationError(`TODO ${task.taskId} must include \`**Done when:**\`.`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildTodoCreationPrompt(rawInput: string): string {
|
|
120
|
+
return `Convert the following raw project request into Pi Long Task-compatible TODO markdown.\n\nRequirements:\n- Output only markdown, with no commentary and no code fence.\n- Start with exactly: # Pi Long Task TODO\n- Include a ## Progress section with one unchecked line per task: - [ ] TODO N — Title\n- Include a --- separator before task sections.\n- Create sequential sections named ## TODO N — Title.\n- Each task section must include **Goal:**, **Status:** with unchecked checkbox items, **Verify:** with concrete verification guidance, and **Done when:**.\n- Preserve any global instructions or constraints that apply to all tasks above ## Progress.\n- Keep tasks focused and independently assignable to worker sessions.\n\nRaw input:\n\n${rawInput.trim()}\n`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function extractTodoMarkdown(assistantText: string): string {
|
|
124
|
+
for (const block of fencedMarkdownBlocks(assistantText)) {
|
|
125
|
+
const candidate = normalizeCandidate(block);
|
|
126
|
+
if (candidate) {
|
|
127
|
+
return candidate;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const headerIndex = assistantText.indexOf("# Pi Long Task TODO");
|
|
132
|
+
if (headerIndex >= 0) {
|
|
133
|
+
const candidate = normalizeCandidate(assistantText.slice(headerIndex));
|
|
134
|
+
if (candidate) {
|
|
135
|
+
return candidate;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const todoHeading = assistantText.search(/^##\s+TODO\s+\d+\s+[—-]\s+/m);
|
|
140
|
+
if (todoHeading >= 0) {
|
|
141
|
+
const candidate = normalizeCandidate(assistantText.slice(todoHeading));
|
|
142
|
+
if (candidate) {
|
|
143
|
+
return candidate;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
throw new TodoGenerationError("Could not extract valid Pi Long Task TODO markdown from assistant text.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeCandidate(candidate: string): string | undefined {
|
|
151
|
+
try {
|
|
152
|
+
const markdown = todoMarkdownFromString(candidate.trim());
|
|
153
|
+
if (markdown) {
|
|
154
|
+
return markdown;
|
|
155
|
+
}
|
|
156
|
+
validateTodoMarkdown(candidate);
|
|
157
|
+
return ensureTrailingNewline(candidate.trim());
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function hasTodoHeadings(input: string): boolean {
|
|
164
|
+
TODO_HEADING_RE.lastIndex = 0;
|
|
165
|
+
return TODO_HEADING_RE.test(input);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeExistingTodoMarkdown(input: string): string {
|
|
169
|
+
const tasks = extractExistingTasks(input);
|
|
170
|
+
if (tasks.length === 0) {
|
|
171
|
+
throw new TodoGenerationError("No task headings found to normalize.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const globalInstructions = extractGlobalInstructions(input);
|
|
175
|
+
const progress = tasks.map((task, idx) => `- [ ] TODO ${idx + 1} — ${task.title}`).join("\n");
|
|
176
|
+
const sections = tasks.map((task, idx) => normalizeTaskSection({ ...task, taskId: String(idx + 1) })).join("\n\n");
|
|
177
|
+
|
|
178
|
+
const globalBlock = globalInstructions ? `\n\n${globalInstructions}` : "";
|
|
179
|
+
return `# Pi Long Task TODO${globalBlock}\n\n## Progress\n\n${progress}\n\n---\n\n${sections}\n`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function extractExistingTasks(input: string): ExistingTask[] {
|
|
183
|
+
TODO_HEADING_RE.lastIndex = 0;
|
|
184
|
+
const matches = [...input.matchAll(TODO_HEADING_RE)];
|
|
185
|
+
return matches.map((match, idx) => {
|
|
186
|
+
const bodyStart = (match.index ?? 0) + match[0].length;
|
|
187
|
+
const bodyEnd = idx + 1 < matches.length ? (matches[idx + 1].index ?? input.length) : input.length;
|
|
188
|
+
return {
|
|
189
|
+
taskId: match[1],
|
|
190
|
+
title: cleanTitle(match[2]),
|
|
191
|
+
body: input.slice(bodyStart, bodyEnd).trim(),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function extractGlobalInstructions(input: string): string {
|
|
197
|
+
const lines = input.replace(/\r\n?/g, "\n").split("\n");
|
|
198
|
+
const selected: string[] = [];
|
|
199
|
+
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
if (TODO_HEADING_LINE_RE.test(line.trim()) || /^##\s+Progress\s*$/i.test(line.trim())) {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
if (/^#\s+Pi Long Task TODO\s*$/i.test(line.trim())) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
selected.push(line);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return selected.join("\n").trim();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function normalizeTaskSection(task: ExistingTask): string {
|
|
214
|
+
const body = task.body.trim();
|
|
215
|
+
const blocks: string[] = [];
|
|
216
|
+
if (body) {
|
|
217
|
+
blocks.push(body);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const sectionProbe = `## TODO ${task.taskId} — ${task.title}\n\n${body}`;
|
|
221
|
+
if (!/\*\*Goal:\*\*/.test(sectionProbe)) {
|
|
222
|
+
blocks.push(`**Goal:** ${goalForTitle(task.title)}`);
|
|
223
|
+
}
|
|
224
|
+
if (!/\*\*Status:\*\*/.test(sectionProbe)) {
|
|
225
|
+
blocks.push(`**Status:**\n- [ ] Complete ${lowercaseFirst(task.title)}`);
|
|
226
|
+
} else if (!/^\s*-\s+\[[ xX]\]\s+/m.test(body)) {
|
|
227
|
+
blocks.push(`- [ ] Complete ${lowercaseFirst(task.title)}`);
|
|
228
|
+
}
|
|
229
|
+
if (!/\*\*Verify:\*\*/.test(sectionProbe) && !/verification|verify|checks?/i.test(sectionProbe)) {
|
|
230
|
+
blocks.push("**Verify:**\n- Run focused checks relevant to this task.");
|
|
231
|
+
}
|
|
232
|
+
if (!/\*\*Done when:\*\*/.test(sectionProbe)) {
|
|
233
|
+
blocks.push("**Done when:**\n- The task is implemented and verified.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return `## TODO ${task.taskId} — ${task.title}\n\n${blocks.join("\n\n")}`.trimEnd();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function simpleListItems(input: string): string[] {
|
|
240
|
+
const lines = input.replace(/\r\n?/g, "\n").split("\n");
|
|
241
|
+
const nonBlank = lines.map((line) => line.trim()).filter(Boolean);
|
|
242
|
+
if (nonBlank.length < 2) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const items: string[] = [];
|
|
247
|
+
|
|
248
|
+
for (const line of nonBlank) {
|
|
249
|
+
const bullet = BULLET_ITEM_RE.exec(line);
|
|
250
|
+
if (bullet) {
|
|
251
|
+
items.push(stripCheckboxMarker(bullet[1]));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const numbered = NUMBERED_ITEM_RE.exec(line);
|
|
256
|
+
if (numbered) {
|
|
257
|
+
items.push(stripCheckboxMarker(numbered[1]));
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return items.map(cleanTitle).filter(Boolean);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stripCheckboxMarker(value: string): string {
|
|
268
|
+
return value.replace(/^\[[ xX]\]\s+/, "");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function cleanTitle(value: string): string {
|
|
272
|
+
return value
|
|
273
|
+
.replace(/\s+/g, " ")
|
|
274
|
+
.trim()
|
|
275
|
+
.replace(/[.。]\s*$/, "");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function goalForTitle(title: string): string {
|
|
279
|
+
return `Complete ${lowercaseFirst(title)}.`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function lowercaseFirst(value: string): string {
|
|
283
|
+
if (!value) {
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
return `${value[0].toLocaleLowerCase()}${value.slice(1)}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function progressLineRegex(taskId: string, title: string): RegExp {
|
|
290
|
+
return new RegExp(`^\\s*-\\s+\\[ \\]\\s+TODO\\s+${escapeRegExp(taskId)}\\s+[—-]\\s+${escapeRegExp(title)}\\s*$`, "m");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function escapeRegExp(value: string): string {
|
|
294
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function fencedMarkdownBlocks(text: string): string[] {
|
|
298
|
+
FENCE_RE.lastIndex = 0;
|
|
299
|
+
return [...text.matchAll(FENCE_RE)].map((match) => match[1]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function ensureTrailingNewline(value: string): string {
|
|
303
|
+
return value.endsWith("\n") ? value : `${value}\n`;
|
|
304
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
export interface Task {
|
|
2
|
+
taskId: string;
|
|
3
|
+
title: string;
|
|
4
|
+
section: string;
|
|
5
|
+
startLine: number;
|
|
6
|
+
endLine: number;
|
|
7
|
+
done: boolean;
|
|
8
|
+
progressDone?: boolean;
|
|
9
|
+
statusCheckboxes: boolean[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class TodoParseError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "TodoParseError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TASK_HEADING_RE = /^##\s+TODO\s+(\d+)\s+[—-]\s+(.+?)\s*$/;
|
|
20
|
+
const CHECKBOX_RE = /^(\s*-\s+\[)([ xX])(\].*)$/;
|
|
21
|
+
const GLOBAL_PROGRESS_HEADING_RE = /^##\s+Progress\s*$/i;
|
|
22
|
+
|
|
23
|
+
function progressRegexForTask(taskId: string): RegExp {
|
|
24
|
+
return new RegExp(`^(\\s*-\\s+\\[)([ xX])(\\]\\s+TODO\\s+${escapeRegExp(taskId)}\\b.*)$`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function escapeRegExp(value: string): string {
|
|
28
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function splitLinesKeepEnds(text: string): string[] {
|
|
32
|
+
return text.match(/[^\r\n]*(?:\r\n|\n|\r)|[^\r\n]+/g) ?? [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function splitLines(text: string): string[] {
|
|
36
|
+
return splitLinesKeepEnds(text).map((line) => line.replace(/[\r\n]+$/g, ""));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stripLineBreaks(line: string): string {
|
|
40
|
+
return line.replace(/[\r\n]+$/g, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TaskHeading {
|
|
44
|
+
startIdx: number;
|
|
45
|
+
taskId: string;
|
|
46
|
+
title: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseTaskHeadings(lines: string[]): TaskHeading[] {
|
|
50
|
+
const headings: TaskHeading[] = [];
|
|
51
|
+
|
|
52
|
+
lines.forEach((line, idx) => {
|
|
53
|
+
const match = TASK_HEADING_RE.exec(stripLineBreaks(line));
|
|
54
|
+
if (!match) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
headings.push({
|
|
59
|
+
startIdx: idx,
|
|
60
|
+
taskId: match[1],
|
|
61
|
+
title: match[2].trim(),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return headings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findProgressDone(lines: string[], taskId: string): boolean | undefined {
|
|
69
|
+
const regex = progressRegexForTask(taskId);
|
|
70
|
+
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const match = regex.exec(stripLineBreaks(line));
|
|
73
|
+
if (match) {
|
|
74
|
+
return match[2].toLowerCase() === "x";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function findStatusCheckboxes(lines: string[], startIdx: number, endIdx: number): boolean[] {
|
|
82
|
+
let inStatus = false;
|
|
83
|
+
let seenCheckbox = false;
|
|
84
|
+
const checkboxes: boolean[] = [];
|
|
85
|
+
|
|
86
|
+
for (let idx = startIdx; idx < endIdx; idx += 1) {
|
|
87
|
+
const stripped = lines[idx].trim();
|
|
88
|
+
if (stripped === "**Status:**") {
|
|
89
|
+
inStatus = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!inStatus) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const checkbox = CHECKBOX_RE.exec(stripLineBreaks(lines[idx]));
|
|
98
|
+
if (checkbox) {
|
|
99
|
+
seenCheckbox = true;
|
|
100
|
+
checkboxes.push(checkbox[2].toLowerCase() === "x");
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (stripped === "") {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (seenCheckbox) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return checkboxes;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function markStatusBlockDone(lines: string[], startIdx: number, endIdx: number): void {
|
|
117
|
+
let inStatus = false;
|
|
118
|
+
let seenCheckbox = false;
|
|
119
|
+
|
|
120
|
+
for (let idx = startIdx; idx < endIdx; idx += 1) {
|
|
121
|
+
const stripped = lines[idx].trim();
|
|
122
|
+
if (stripped === "**Status:**") {
|
|
123
|
+
inStatus = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!inStatus) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const raw = stripLineBreaks(lines[idx]);
|
|
132
|
+
const newline = lines[idx].endsWith("\n") ? "\n" : "";
|
|
133
|
+
const checkbox = CHECKBOX_RE.exec(raw);
|
|
134
|
+
if (checkbox) {
|
|
135
|
+
seenCheckbox = true;
|
|
136
|
+
if (checkbox[2].toLowerCase() !== "x") {
|
|
137
|
+
lines[idx] = `${checkbox[1]}x${checkbox[3]}${newline}`;
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (stripped === "") {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (seenCheckbox) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function parseTasks(markdown: string): Task[] {
|
|
153
|
+
const lines = splitLinesKeepEnds(markdown);
|
|
154
|
+
const headings = parseTaskHeadings(lines);
|
|
155
|
+
|
|
156
|
+
if (headings.length === 0) {
|
|
157
|
+
throw new TodoParseError("No task sections found. Expected headings like `## TODO 1 — Task title`.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return headings.map((heading, pos) => {
|
|
161
|
+
const endIdx = pos + 1 < headings.length ? headings[pos + 1].startIdx : lines.length;
|
|
162
|
+
const section = `${lines.slice(heading.startIdx, endIdx).join("").trimEnd()}\n`;
|
|
163
|
+
const progressDone = findProgressDone(lines, heading.taskId);
|
|
164
|
+
const statusCheckboxes = findStatusCheckboxes(lines, heading.startIdx, endIdx);
|
|
165
|
+
const done = progressDone ?? (statusCheckboxes.length > 0 ? statusCheckboxes.every(Boolean) : false);
|
|
166
|
+
|
|
167
|
+
const task: Task = {
|
|
168
|
+
taskId: heading.taskId,
|
|
169
|
+
title: heading.title,
|
|
170
|
+
section,
|
|
171
|
+
startLine: heading.startIdx + 1,
|
|
172
|
+
endLine: endIdx,
|
|
173
|
+
done,
|
|
174
|
+
statusCheckboxes,
|
|
175
|
+
};
|
|
176
|
+
if (progressDone !== undefined) {
|
|
177
|
+
task.progressDone = progressDone;
|
|
178
|
+
}
|
|
179
|
+
return task;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function incompleteTasks(markdown: string): Task[] {
|
|
184
|
+
return parseTasks(markdown).filter((task) => !task.done);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function markTaskDone(markdown: string, taskId: string): string {
|
|
188
|
+
const lines = splitLinesKeepEnds(markdown);
|
|
189
|
+
const progressRegex = progressRegexForTask(taskId);
|
|
190
|
+
|
|
191
|
+
lines.forEach((line, idx) => {
|
|
192
|
+
const raw = stripLineBreaks(line);
|
|
193
|
+
const newline = line.endsWith("\n") ? "\n" : "";
|
|
194
|
+
const match = progressRegex.exec(raw);
|
|
195
|
+
if (match && match[2].toLowerCase() !== "x") {
|
|
196
|
+
lines[idx] = `${match[1]}x${match[3]}${newline}`;
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const headings = parseTaskHeadings(lines);
|
|
201
|
+
const headingPos = headings.findIndex((heading) => heading.taskId === taskId);
|
|
202
|
+
if (headingPos >= 0) {
|
|
203
|
+
const endIdx = headingPos + 1 < headings.length ? headings[headingPos + 1].startIdx : lines.length;
|
|
204
|
+
markStatusBlockDone(lines, headings[headingPos].startIdx, endIdx);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return lines.join("");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function todoGlobalInstructions(markdown: string, limit = 6000): string {
|
|
211
|
+
const selected: string[] = [];
|
|
212
|
+
|
|
213
|
+
for (const line of splitLines(markdown)) {
|
|
214
|
+
const stripped = line.trim();
|
|
215
|
+
if (GLOBAL_PROGRESS_HEADING_RE.test(stripped)) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
if (TASK_HEADING_RE.test(stripped)) {
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
selected.push(line);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let text = selected.join("\n").trim();
|
|
225
|
+
if (text.length > limit) {
|
|
226
|
+
text = `${text.slice(0, limit).trimEnd()}\n\n[truncated by Pi Long Task]`;
|
|
227
|
+
}
|
|
228
|
+
return text;
|
|
229
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Static } from "typebox";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import type { SessionOutcome } from "./worker_session.ts";
|
|
5
|
+
|
|
6
|
+
export const PiLongTaskParams = Type.Object(
|
|
7
|
+
{
|
|
8
|
+
inputText: Type.String({ description: "TODO file content or long-task instructions to process." }),
|
|
9
|
+
commit: Type.Boolean({ description: "Whether Pi Long Task may commit completed worker changes." }),
|
|
10
|
+
},
|
|
11
|
+
{ additionalProperties: false },
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export type PiLongTaskInput = Static<typeof PiLongTaskParams>;
|
|
15
|
+
|
|
16
|
+
export type CoordinatorStatus = "done" | "partial" | "blocked" | "failed";
|
|
17
|
+
|
|
18
|
+
export interface CoordinatorCommitSummary {
|
|
19
|
+
taskId: string;
|
|
20
|
+
hash?: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CoordinatorRemainingTask {
|
|
25
|
+
taskId: string;
|
|
26
|
+
title: string;
|
|
27
|
+
status: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PiLongTaskResult {
|
|
31
|
+
status: CoordinatorStatus;
|
|
32
|
+
message: string;
|
|
33
|
+
summary: string;
|
|
34
|
+
runId: string;
|
|
35
|
+
runDir: string;
|
|
36
|
+
todoPath: string;
|
|
37
|
+
resultPath: string;
|
|
38
|
+
taskResultPath: string;
|
|
39
|
+
totalTasks: number;
|
|
40
|
+
completedTasks: number;
|
|
41
|
+
failedTasks: number;
|
|
42
|
+
blockedTasks: number;
|
|
43
|
+
attemptedTasks: number;
|
|
44
|
+
remainingTasks: CoordinatorRemainingTask[];
|
|
45
|
+
outcomes: SessionOutcome[];
|
|
46
|
+
commits: CoordinatorCommitSummary[];
|
|
47
|
+
attempts: Array<{
|
|
48
|
+
taskId: string;
|
|
49
|
+
title: string;
|
|
50
|
+
attempt: number;
|
|
51
|
+
reportedStatus: string;
|
|
52
|
+
done: boolean;
|
|
53
|
+
error?: string;
|
|
54
|
+
commitHash?: string;
|
|
55
|
+
commitError?: string;
|
|
56
|
+
commitSkipped?: string;
|
|
57
|
+
}>;
|
|
58
|
+
commit: boolean;
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|