pi-long-task 0.3.11 → 0.3.13
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/CHANGELOG.md +19 -0
- package/README.md +14 -6
- package/package.json +9 -5
- package/src/coordinator.ts +68 -20
- package/src/goal_loop.ts +30 -0
- package/src/goal_orchestrator.ts +219 -128
- package/src/goal_review.ts +91 -73
- package/src/goal_state.ts +25 -0
- package/src/goal_todo_execution.ts +25 -8
- package/src/goal_todo_generation.ts +15 -8
- package/src/result_writer.ts +90 -24
- package/src/todo_generator.ts +74 -23
- package/src/todo_parser.ts +35 -11
- package/src/worker_session.ts +163 -44
|
@@ -120,6 +120,23 @@ export async function runGoalTodoExecutionLongTask(
|
|
|
120
120
|
|
|
121
121
|
const progressEvents: CoordinatorProgressUpdate[] = [];
|
|
122
122
|
const workerStartedAt = now();
|
|
123
|
+
const childTimeoutMs = timeoutForIteration(iteration, state, workerStartedAt);
|
|
124
|
+
if (childTimeoutMs <= 0) {
|
|
125
|
+
const failure = await recordExecutionFailure({
|
|
126
|
+
state,
|
|
127
|
+
iteration,
|
|
128
|
+
store,
|
|
129
|
+
previousTraceLength,
|
|
130
|
+
progressLogPath,
|
|
131
|
+
message: `Goal iteration ${iteration.iteration} has no time remaining for TODO execution.`,
|
|
132
|
+
error: new Error("iteration deadline exceeded"),
|
|
133
|
+
now,
|
|
134
|
+
});
|
|
135
|
+
throw new GoalTodoExecutionError(failure.workerResult.summary, {
|
|
136
|
+
state: failure.state,
|
|
137
|
+
workerResult: failure.workerResult,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
123
140
|
let childResult: CoordinatorResult;
|
|
124
141
|
try {
|
|
125
142
|
childResult = await (options.longTaskRunner ?? runCoordinator)({
|
|
@@ -132,7 +149,7 @@ export async function runGoalTodoExecutionLongTask(
|
|
|
132
149
|
workerModel: options.model,
|
|
133
150
|
workerModelName: options.modelName,
|
|
134
151
|
taskThinking: options.thinkingLevel,
|
|
135
|
-
taskTimeoutMs:
|
|
152
|
+
taskTimeoutMs: childTimeoutMs,
|
|
136
153
|
maxBashTimeoutMs: options.maxBashTimeoutMs,
|
|
137
154
|
maxAttemptsPerTask: options.maxAttemptsPerTask,
|
|
138
155
|
onProgress: (update) => {
|
|
@@ -294,14 +311,14 @@ async function writeProgressLog(progressLogPath: string, events: CoordinatorProg
|
|
|
294
311
|
}
|
|
295
312
|
|
|
296
313
|
function timeoutForIteration(iteration: GoalIterationState, state: GoalLoopState, now: Date): number {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
if (!Number.isFinite(
|
|
302
|
-
return
|
|
314
|
+
const iterationRemaining = iteration.deadlineAt
|
|
315
|
+
? Date.parse(iteration.deadlineAt) - now.getTime()
|
|
316
|
+
: state.limits.iterationTimeoutMs;
|
|
317
|
+
const overallRemaining = state.deadlineAt ? Date.parse(state.deadlineAt) - now.getTime() : state.limits.timeoutMs;
|
|
318
|
+
if (!Number.isFinite(iterationRemaining) || !Number.isFinite(overallRemaining)) {
|
|
319
|
+
return 0;
|
|
303
320
|
}
|
|
304
|
-
return Math.min(state.limits.iterationTimeoutMs,
|
|
321
|
+
return Math.floor(Math.min(state.limits.iterationTimeoutMs, iterationRemaining, overallRemaining));
|
|
305
322
|
}
|
|
306
323
|
|
|
307
324
|
function errorMessage(error: unknown): string {
|
|
@@ -85,6 +85,12 @@ export async function runGoalTodoGenerationLongTask(
|
|
|
85
85
|
});
|
|
86
86
|
const payloadPath = path.join(iterationDir, GOAL_TODO_GENERATION_PAYLOAD_FILE);
|
|
87
87
|
await writeFile(payloadPath, payload, "utf8");
|
|
88
|
+
const childTimeoutMs = timeoutForIteration(iteration, state, now());
|
|
89
|
+
if (childTimeoutMs <= 0) {
|
|
90
|
+
throw new GoalTodoGenerationError(
|
|
91
|
+
`Goal iteration ${iteration.iteration} has no time remaining for TODO generation.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
88
94
|
|
|
89
95
|
const childResult = await (options.longTaskRunner ?? runCoordinator)({
|
|
90
96
|
inputText: payload,
|
|
@@ -96,7 +102,7 @@ export async function runGoalTodoGenerationLongTask(
|
|
|
96
102
|
workerModel: options.model,
|
|
97
103
|
workerModelName: options.modelName,
|
|
98
104
|
taskThinking: options.thinkingLevel,
|
|
99
|
-
taskTimeoutMs:
|
|
105
|
+
taskTimeoutMs: childTimeoutMs,
|
|
100
106
|
maxBashTimeoutMs: options.maxBashTimeoutMs,
|
|
101
107
|
});
|
|
102
108
|
|
|
@@ -125,6 +131,7 @@ export async function runGoalTodoGenerationLongTask(
|
|
|
125
131
|
generatorRunDir: childResult.runDir,
|
|
126
132
|
generatorResultPath: childResult.resultPath,
|
|
127
133
|
generatorTaskResultPath: childResult.taskResultPath,
|
|
134
|
+
generatorWorkerCostTotal: childResult.workerCostTotal,
|
|
128
135
|
},
|
|
129
136
|
{ now: now() },
|
|
130
137
|
);
|
|
@@ -467,14 +474,14 @@ function ensureTrailingNewline(value: string): string {
|
|
|
467
474
|
}
|
|
468
475
|
|
|
469
476
|
function timeoutForIteration(iteration: GoalIterationState, state: GoalLoopState, now: Date): number {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
if (!Number.isFinite(
|
|
475
|
-
return
|
|
477
|
+
const iterationRemaining = iteration.deadlineAt
|
|
478
|
+
? Date.parse(iteration.deadlineAt) - now.getTime()
|
|
479
|
+
: state.limits.iterationTimeoutMs;
|
|
480
|
+
const overallRemaining = state.deadlineAt ? Date.parse(state.deadlineAt) - now.getTime() : state.limits.timeoutMs;
|
|
481
|
+
if (!Number.isFinite(iterationRemaining) || !Number.isFinite(overallRemaining)) {
|
|
482
|
+
return 0;
|
|
476
483
|
}
|
|
477
|
-
return Math.min(state.limits.iterationTimeoutMs,
|
|
484
|
+
return Math.floor(Math.min(state.limits.iterationTimeoutMs, iterationRemaining, overallRemaining));
|
|
478
485
|
}
|
|
479
486
|
|
|
480
487
|
function buildPreviousIterationContext(state: GoalLoopState): string {
|
package/src/result_writer.ts
CHANGED
|
@@ -3,7 +3,7 @@ export const PARTIAL_STATUSES = new Set(["partial", "incomplete", "blocked", "fa
|
|
|
3
3
|
|
|
4
4
|
const TASK_RESULT_MARKER_RE = /TASK_RESULT\s*:/gi;
|
|
5
5
|
const STATUS_LINE_RE = /^\s*status\s*:\s*([A-Za-z_-]+)\s*$/im;
|
|
6
|
-
const
|
|
6
|
+
const REQUIRED_LIST_FIELDS = ["changes", "verification", "remaining"] as const;
|
|
7
7
|
|
|
8
8
|
export interface TaskResultBlock {
|
|
9
9
|
marker: "TASK_RESULT";
|
|
@@ -11,6 +11,14 @@ export interface TaskResultBlock {
|
|
|
11
11
|
fenced: boolean;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export interface ParsedTaskResult {
|
|
15
|
+
status: string;
|
|
16
|
+
summary: string;
|
|
17
|
+
changes: string[];
|
|
18
|
+
verification: string[];
|
|
19
|
+
remaining: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
export function isDoneStatus(status: string): boolean {
|
|
15
23
|
return DONE_STATUSES.has(status.trim().toLowerCase());
|
|
16
24
|
}
|
|
@@ -28,6 +36,33 @@ export function hasTaskResultStatus(assistantText: string): boolean {
|
|
|
28
36
|
return Boolean(block && STATUS_LINE_RE.test(block.body));
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
export function parseCompleteTaskResult(assistantText: string): ParsedTaskResult | undefined {
|
|
40
|
+
const block = extractTaskResultBlock(assistantText);
|
|
41
|
+
if (!block) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fields = parseResultFields(block.body);
|
|
46
|
+
const status = fields.scalars.get("status")?.trim().toLowerCase() ?? "";
|
|
47
|
+
const summary = fields.scalars.get("summary")?.trim() ?? "";
|
|
48
|
+
if (!status || !summary || (!isDoneStatus(status) && !isPartialStatus(status))) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lists = Object.fromEntries(
|
|
53
|
+
REQUIRED_LIST_FIELDS.map((field) => [field, fields.lists.get(field) ?? []]),
|
|
54
|
+
) as Record<(typeof REQUIRED_LIST_FIELDS)[number], string[]>;
|
|
55
|
+
if (REQUIRED_LIST_FIELDS.some((field) => lists[field].length === 0)) {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { status, summary, ...lists };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function hasCompleteTaskResult(assistantText: string): boolean {
|
|
63
|
+
return parseCompleteTaskResult(assistantText) !== undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
31
66
|
export function parseReportedStatus(assistantText: string): string {
|
|
32
67
|
const block = extractTaskResultBlock(assistantText);
|
|
33
68
|
const searchText = block ? block.body : assistantText || "";
|
|
@@ -56,41 +91,72 @@ export const summarizeAssistantResult = extractResultSummary;
|
|
|
56
91
|
|
|
57
92
|
export function extractTaskResultBlock(assistantText: string): TaskResultBlock | undefined {
|
|
58
93
|
const text = assistantText || "";
|
|
59
|
-
const fencedBlocks = fencedCodeBlocks(text);
|
|
60
|
-
for (let idx = fencedBlocks.length - 1; idx >= 0; idx -= 1) {
|
|
61
|
-
const body = taskResultBodyFromText(fencedBlocks[idx]);
|
|
62
|
-
if (body !== undefined) {
|
|
63
|
-
return { marker: "TASK_RESULT", body, fenced: true };
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const body = taskResultBodyFromText(text);
|
|
68
|
-
if (body === undefined) {
|
|
69
|
-
return undefined;
|
|
70
|
-
}
|
|
71
|
-
return { marker: "TASK_RESULT", body: stripTrailingFence(body), fenced: false };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function taskResultBodyFromText(text: string): string | undefined {
|
|
75
94
|
TASK_RESULT_MARKER_RE.lastIndex = 0;
|
|
76
95
|
let match: RegExpExecArray | null;
|
|
77
96
|
let lastMatch: RegExpExecArray | undefined;
|
|
78
97
|
while ((match = TASK_RESULT_MARKER_RE.exec(text)) !== null) {
|
|
79
98
|
lastMatch = match;
|
|
80
99
|
}
|
|
81
|
-
|
|
82
100
|
if (!lastMatch) {
|
|
83
101
|
return undefined;
|
|
84
102
|
}
|
|
85
103
|
|
|
86
|
-
|
|
104
|
+
const markerEnd = lastMatch.index + lastMatch[0].length;
|
|
105
|
+
const fence = enclosingFence(text, lastMatch.index);
|
|
106
|
+
const bodyEnd = fence?.end ?? text.length;
|
|
107
|
+
return {
|
|
108
|
+
marker: "TASK_RESULT",
|
|
109
|
+
body: text.slice(markerEnd, bodyEnd).trim(),
|
|
110
|
+
fenced: Boolean(fence),
|
|
111
|
+
};
|
|
87
112
|
}
|
|
88
113
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
114
|
+
function enclosingFence(text: string, position: number): { end: number } | undefined {
|
|
115
|
+
const fenceRe = /^(`{3,})[^\r\n`]*\r?\n/gm;
|
|
116
|
+
let match: RegExpExecArray | null;
|
|
117
|
+
while ((match = fenceRe.exec(text)) !== null) {
|
|
118
|
+
const contentStart = match.index + match[0].length;
|
|
119
|
+
const closeRe = new RegExp(`^${match[1]}\\s*$`, "gm");
|
|
120
|
+
closeRe.lastIndex = contentStart;
|
|
121
|
+
const close = closeRe.exec(text);
|
|
122
|
+
if (!close) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (position >= contentStart && position < close.index) {
|
|
126
|
+
return { end: close.index };
|
|
127
|
+
}
|
|
128
|
+
fenceRe.lastIndex = close.index + close[0].length;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
92
131
|
}
|
|
93
132
|
|
|
94
|
-
function
|
|
95
|
-
|
|
133
|
+
function parseResultFields(body: string): {
|
|
134
|
+
scalars: Map<string, string>;
|
|
135
|
+
lists: Map<string, string[]>;
|
|
136
|
+
} {
|
|
137
|
+
const scalars = new Map<string, string>();
|
|
138
|
+
const lists = new Map<string, string[]>();
|
|
139
|
+
let currentList: string | undefined;
|
|
140
|
+
|
|
141
|
+
for (const line of body.replace(/\r\n?/g, "\n").split("\n")) {
|
|
142
|
+
const field = /^\s*([A-Za-z_-]+)\s*:\s*(.*?)\s*$/.exec(line);
|
|
143
|
+
if (field) {
|
|
144
|
+
const name = field[1].toLowerCase();
|
|
145
|
+
currentList = REQUIRED_LIST_FIELDS.includes(name as (typeof REQUIRED_LIST_FIELDS)[number]) ? name : undefined;
|
|
146
|
+
if (currentList) {
|
|
147
|
+
lists.set(currentList, []);
|
|
148
|
+
} else {
|
|
149
|
+
scalars.set(name, field[2]);
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (currentList) {
|
|
155
|
+
const bullet = /^\s*[-*+]\s+(.+?)\s*$/.exec(line);
|
|
156
|
+
if (bullet?.[1]) {
|
|
157
|
+
lists.get(currentList)?.push(bullet[1]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { scalars, lists };
|
|
96
162
|
}
|
package/src/todo_generator.ts
CHANGED
|
@@ -24,6 +24,7 @@ interface ExistingTask {
|
|
|
24
24
|
taskId: string;
|
|
25
25
|
title: string;
|
|
26
26
|
body: string;
|
|
27
|
+
done: boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export function todoMarkdownFromString(rawInput: string, goal?: string): string | undefined {
|
|
@@ -73,9 +74,6 @@ export function validateTodoMarkdown(markdown: string): void {
|
|
|
73
74
|
if (!PROGRESS_HEADING_RE.test(trimmed)) {
|
|
74
75
|
throw new TodoGenerationError("TODO markdown must include a `## Progress` section.");
|
|
75
76
|
}
|
|
76
|
-
if (!/^---\s*$/m.test(trimmed)) {
|
|
77
|
-
throw new TodoGenerationError("TODO markdown must include a `---` separator before task sections.");
|
|
78
|
-
}
|
|
79
77
|
|
|
80
78
|
let tasks;
|
|
81
79
|
try {
|
|
@@ -91,6 +89,11 @@ export function validateTodoMarkdown(markdown: string): void {
|
|
|
91
89
|
throw new TodoGenerationError("TODO markdown must include at least one task section.");
|
|
92
90
|
}
|
|
93
91
|
|
|
92
|
+
const progressEntries = validatedProgressEntries(markdown, tasks[0].startLine - 1);
|
|
93
|
+
if (progressEntries.length !== tasks.length) {
|
|
94
|
+
throw new TodoGenerationError("Progress section must contain exactly one line for every task section.");
|
|
95
|
+
}
|
|
96
|
+
|
|
94
97
|
tasks.forEach((task, idx) => {
|
|
95
98
|
const expectedId = String(idx + 1);
|
|
96
99
|
if (task.taskId !== expectedId) {
|
|
@@ -99,9 +102,11 @@ export function validateTodoMarkdown(markdown: string): void {
|
|
|
99
102
|
);
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
throw new TodoGenerationError(
|
|
105
|
+
const progressEntry = progressEntries[idx];
|
|
106
|
+
if (progressEntry?.taskId !== task.taskId || progressEntry.title !== task.title) {
|
|
107
|
+
throw new TodoGenerationError(
|
|
108
|
+
`Progress section entry ${idx + 1} must match TODO ${task.taskId} — ${task.title}.`,
|
|
109
|
+
);
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
if (!/\*\*Goal:\*\*/.test(task.section)) {
|
|
@@ -310,7 +315,7 @@ function normalizeExistingTodoMarkdown(input: string): string {
|
|
|
310
315
|
}
|
|
311
316
|
|
|
312
317
|
const globalInstructions = extractGlobalInstructions(input);
|
|
313
|
-
const progress = tasks.map((task, idx) => `- [ ] TODO ${idx + 1} — ${task.title}`).join("\n");
|
|
318
|
+
const progress = tasks.map((task, idx) => `- [${task.done ? "x" : " "}] TODO ${idx + 1} — ${task.title}`).join("\n");
|
|
314
319
|
const sections = tasks.map((task, idx) => normalizeTaskSection({ ...task, taskId: String(idx + 1) })).join("\n\n");
|
|
315
320
|
|
|
316
321
|
const globalBlock = globalInstructions ? `\n\n${globalInstructions}` : "";
|
|
@@ -318,17 +323,18 @@ function normalizeExistingTodoMarkdown(input: string): string {
|
|
|
318
323
|
}
|
|
319
324
|
|
|
320
325
|
function extractExistingTasks(input: string): ExistingTask[] {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
326
|
+
let tasks;
|
|
327
|
+
try {
|
|
328
|
+
tasks = parseTasks(input);
|
|
329
|
+
} catch {
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
return tasks.map((task) => ({
|
|
333
|
+
taskId: task.taskId,
|
|
334
|
+
title: cleanTitle(task.title),
|
|
335
|
+
body: task.section.replace(/^##\s+TODO\s+\d+\s+[—-]\s+.*(?:\r?\n)?/, "").trim(),
|
|
336
|
+
done: task.done,
|
|
337
|
+
}));
|
|
332
338
|
}
|
|
333
339
|
|
|
334
340
|
function extractGlobalInstructions(input: string): string {
|
|
@@ -424,12 +430,57 @@ function lowercaseFirst(value: string): string {
|
|
|
424
430
|
return `${value[0].toLocaleLowerCase()}${value.slice(1)}`;
|
|
425
431
|
}
|
|
426
432
|
|
|
427
|
-
function
|
|
428
|
-
|
|
429
|
-
|
|
433
|
+
function validatedProgressEntries(
|
|
434
|
+
markdown: string,
|
|
435
|
+
firstTaskIndex: number,
|
|
436
|
+
): Array<{ taskId: string; title: string; done: boolean }> {
|
|
437
|
+
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
|
|
438
|
+
const visible = visibleLineIndexes(lines);
|
|
439
|
+
const progressIndex = visible.find((idx) => idx < firstTaskIndex && /^##\s+Progress\s*$/i.test(lines[idx].trim()));
|
|
440
|
+
if (progressIndex === undefined) {
|
|
441
|
+
throw new TodoGenerationError("TODO markdown must include a `## Progress` section before task sections.");
|
|
442
|
+
}
|
|
443
|
+
const separatorIndex = visible.find(
|
|
444
|
+
(idx) => idx > progressIndex && idx < firstTaskIndex && /^---\s*$/.test(lines[idx].trim()),
|
|
445
|
+
);
|
|
446
|
+
if (separatorIndex === undefined) {
|
|
447
|
+
throw new TodoGenerationError("TODO markdown must include a `---` separator before task sections.");
|
|
448
|
+
}
|
|
430
449
|
|
|
431
|
-
|
|
432
|
-
|
|
450
|
+
const entries: Array<{ taskId: string; title: string; done: boolean }> = [];
|
|
451
|
+
for (let idx = progressIndex + 1; idx < separatorIndex; idx += 1) {
|
|
452
|
+
if (!visible.includes(idx)) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const match = /^\s*-\s+\[([ xX])\]\s+TODO\s+(\d+)\s+[—-]\s+(.+?)\s*$/.exec(lines[idx]);
|
|
456
|
+
if (match) {
|
|
457
|
+
entries.push({ taskId: match[2], title: match[3].trim(), done: match[1].toLowerCase() === "x" });
|
|
458
|
+
} else if (/\bTODO\s+\d+\b/i.test(lines[idx])) {
|
|
459
|
+
throw new TodoGenerationError(`Malformed progress entry: ${lines[idx].trim()}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return entries;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function visibleLineIndexes(lines: readonly string[]): number[] {
|
|
466
|
+
const indexes: number[] = [];
|
|
467
|
+
let fence: string | undefined;
|
|
468
|
+
for (let idx = 0; idx < lines.length; idx += 1) {
|
|
469
|
+
const match = /^\s*(`{3,}|~{3,})/.exec(lines[idx]);
|
|
470
|
+
if (match) {
|
|
471
|
+
const marker = match[1];
|
|
472
|
+
if (!fence) {
|
|
473
|
+
fence = marker;
|
|
474
|
+
} else if (marker[0] === fence[0] && marker.length >= fence.length) {
|
|
475
|
+
fence = undefined;
|
|
476
|
+
}
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (!fence) {
|
|
480
|
+
indexes.push(idx);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return indexes;
|
|
433
484
|
}
|
|
434
485
|
|
|
435
486
|
function fencedMarkdownBlocks(text: string): string[] {
|
package/src/todo_parser.ts
CHANGED
|
@@ -25,6 +25,8 @@ export class TodoParseError extends Error {
|
|
|
25
25
|
const TASK_HEADING_RE = /^##\s+TODO\s+(\d+)\s+[—-]\s+(.+?)\s*$/;
|
|
26
26
|
const CHECKBOX_RE = /^(\s*-\s+\[)([ xX])(\].*)$/;
|
|
27
27
|
const GLOBAL_PROGRESS_HEADING_RE = /^##\s+Progress\s*$/i;
|
|
28
|
+
const FIELD_HEADING_RE = /^\*\*[^*\r\n]+:\*\*\s*$/;
|
|
29
|
+
const FENCE_LINE_RE = /^\s*(`{3,}|~{3,})/;
|
|
28
30
|
|
|
29
31
|
function progressRegexForTask(taskId: string): RegExp {
|
|
30
32
|
return new RegExp(`^(\\s*-\\s+\\[)([ xX])(\\]\\s+TODO\\s+${escapeRegExp(taskId)}\\b.*)$`);
|
|
@@ -54,18 +56,32 @@ interface TaskHeading {
|
|
|
54
56
|
|
|
55
57
|
function parseTaskHeadings(lines: string[]): TaskHeading[] {
|
|
56
58
|
const headings: TaskHeading[] = [];
|
|
59
|
+
let fence: string | undefined;
|
|
57
60
|
|
|
58
61
|
lines.forEach((line, idx) => {
|
|
59
|
-
const
|
|
60
|
-
|
|
62
|
+
const stripped = stripLineBreaks(line);
|
|
63
|
+
const fenceMatch = FENCE_LINE_RE.exec(stripped);
|
|
64
|
+
if (fenceMatch) {
|
|
65
|
+
const marker = fenceMatch[1];
|
|
66
|
+
if (!fence) {
|
|
67
|
+
fence = marker;
|
|
68
|
+
} else if (marker[0] === fence[0] && marker.length >= fence.length) {
|
|
69
|
+
fence = undefined;
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (fence) {
|
|
61
74
|
return;
|
|
62
75
|
}
|
|
63
76
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
const match = TASK_HEADING_RE.exec(stripped);
|
|
78
|
+
if (match) {
|
|
79
|
+
headings.push({
|
|
80
|
+
startIdx: idx,
|
|
81
|
+
taskId: match[1],
|
|
82
|
+
title: match[2].trim(),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
69
85
|
});
|
|
70
86
|
|
|
71
87
|
return headings;
|
|
@@ -73,9 +89,17 @@ function parseTaskHeadings(lines: string[]): TaskHeading[] {
|
|
|
73
89
|
|
|
74
90
|
function findProgressDone(lines: string[], taskId: string): boolean | undefined {
|
|
75
91
|
const regex = progressRegexForTask(taskId);
|
|
92
|
+
const progressStart = lines.findIndex((line) => GLOBAL_PROGRESS_HEADING_RE.test(stripLineBreaks(line).trim()));
|
|
93
|
+
if (progressStart < 0) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
76
96
|
|
|
77
|
-
for (
|
|
78
|
-
const
|
|
97
|
+
for (let idx = progressStart + 1; idx < lines.length; idx += 1) {
|
|
98
|
+
const stripped = stripLineBreaks(lines[idx]);
|
|
99
|
+
if (/^\s*---\s*$/.test(stripped) || TASK_HEADING_RE.test(stripped)) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const match = regex.exec(stripped);
|
|
79
103
|
if (match) {
|
|
80
104
|
return match[2].toLowerCase() === "x";
|
|
81
105
|
}
|
|
@@ -114,7 +138,7 @@ function findStatusItems(lines: string[], startIdx: number, endIdx: number): Tas
|
|
|
114
138
|
continue;
|
|
115
139
|
}
|
|
116
140
|
|
|
117
|
-
if (seenCheckbox) {
|
|
141
|
+
if (FIELD_HEADING_RE.test(stripped) || seenCheckbox) {
|
|
118
142
|
break;
|
|
119
143
|
}
|
|
120
144
|
}
|
|
@@ -152,7 +176,7 @@ function markStatusBlockDone(lines: string[], startIdx: number, endIdx: number):
|
|
|
152
176
|
continue;
|
|
153
177
|
}
|
|
154
178
|
|
|
155
|
-
if (seenCheckbox) {
|
|
179
|
+
if (FIELD_HEADING_RE.test(stripped) || seenCheckbox) {
|
|
156
180
|
break;
|
|
157
181
|
}
|
|
158
182
|
}
|