pi-long-task 0.1.0 → 0.1.2
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 +1 -1
- package/package.json +1 -1
- package/src/coordinator.ts +51 -2
- package/src/git.ts +97 -3
- package/src/render.ts +77 -1
- package/src/todo_parser.ts +16 -5
package/README.md
CHANGED
package/package.json
CHANGED
package/src/coordinator.ts
CHANGED
|
@@ -49,6 +49,19 @@ export type CoordinatorProgressPhase =
|
|
|
49
49
|
| "task_failed"
|
|
50
50
|
| "complete";
|
|
51
51
|
|
|
52
|
+
export type CoordinatorProgressItemStatus = "empty" | "in_progress" | "done";
|
|
53
|
+
|
|
54
|
+
export interface CoordinatorProgressTask {
|
|
55
|
+
taskId: string;
|
|
56
|
+
title: string;
|
|
57
|
+
status: CoordinatorProgressItemStatus;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CoordinatorProgressSubtask {
|
|
61
|
+
text: string;
|
|
62
|
+
status: CoordinatorProgressItemStatus;
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
export interface CoordinatorProgressUpdate {
|
|
53
66
|
message: string;
|
|
54
67
|
phase: CoordinatorProgressPhase;
|
|
@@ -66,6 +79,8 @@ export interface CoordinatorProgressUpdate {
|
|
|
66
79
|
workerEventType?: string;
|
|
67
80
|
isError?: boolean;
|
|
68
81
|
totalTasks?: number;
|
|
82
|
+
currentTask?: CoordinatorProgressTask;
|
|
83
|
+
subtasks?: CoordinatorProgressSubtask[];
|
|
69
84
|
}
|
|
70
85
|
|
|
71
86
|
export type CoordinatorProgressHandler = (update: CoordinatorProgressUpdate) => void;
|
|
@@ -189,6 +204,7 @@ export async function runCoordinator(options: RunCoordinatorOptions): Promise<Co
|
|
|
189
204
|
taskId: nextTask.taskId,
|
|
190
205
|
title: nextTask.title,
|
|
191
206
|
attempt,
|
|
207
|
+
...currentTaskProgress(nextTask, "in_progress"),
|
|
192
208
|
},
|
|
193
209
|
);
|
|
194
210
|
const preExistingDirtyPaths = options.commit
|
|
@@ -441,9 +457,40 @@ function emitProgress(
|
|
|
441
457
|
});
|
|
442
458
|
}
|
|
443
459
|
|
|
460
|
+
function currentTaskProgress(
|
|
461
|
+
task: Pick<Task, "taskId" | "title" | "statusItems">,
|
|
462
|
+
status: CoordinatorProgressItemStatus,
|
|
463
|
+
): Pick<CoordinatorProgressUpdate, "currentTask" | "subtasks"> {
|
|
464
|
+
return {
|
|
465
|
+
currentTask: {
|
|
466
|
+
taskId: task.taskId,
|
|
467
|
+
title: task.title,
|
|
468
|
+
status,
|
|
469
|
+
},
|
|
470
|
+
subtasks: subtaskProgress(task, status),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function subtaskProgress(
|
|
475
|
+
task: Pick<Task, "statusItems">,
|
|
476
|
+
taskStatus: CoordinatorProgressItemStatus,
|
|
477
|
+
): CoordinatorProgressSubtask[] {
|
|
478
|
+
let markedInProgress = false;
|
|
479
|
+
return task.statusItems.map((item) => {
|
|
480
|
+
if (item.done || taskStatus === "done") {
|
|
481
|
+
return { text: item.text, status: "done" };
|
|
482
|
+
}
|
|
483
|
+
if (taskStatus === "in_progress" && !markedInProgress) {
|
|
484
|
+
markedInProgress = true;
|
|
485
|
+
return { text: item.text, status: "in_progress" };
|
|
486
|
+
}
|
|
487
|
+
return { text: item.text, status: "empty" };
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
444
491
|
function emitWorkerEventProgress(
|
|
445
492
|
runtime: RuntimeOptions,
|
|
446
|
-
task: Pick<Task, "taskId" | "title">,
|
|
493
|
+
task: Pick<Task, "taskId" | "title" | "statusItems">,
|
|
447
494
|
attempt: number,
|
|
448
495
|
event: { type: string; toolName?: string; isError?: boolean },
|
|
449
496
|
): void {
|
|
@@ -460,6 +507,7 @@ function emitWorkerEventProgress(
|
|
|
460
507
|
toolName: event.toolName,
|
|
461
508
|
workerEventType: event.type,
|
|
462
509
|
isError: event.isError,
|
|
510
|
+
...currentTaskProgress(task, "in_progress"),
|
|
463
511
|
};
|
|
464
512
|
if (event.isError) {
|
|
465
513
|
update.status = "failed";
|
|
@@ -469,7 +517,7 @@ function emitWorkerEventProgress(
|
|
|
469
517
|
|
|
470
518
|
function emitTaskOutcomeProgress(
|
|
471
519
|
runtime: RuntimeOptions,
|
|
472
|
-
task: Pick<Task, "taskId" | "title">,
|
|
520
|
+
task: Pick<Task, "taskId" | "title" | "statusItems">,
|
|
473
521
|
outcome: SessionOutcome,
|
|
474
522
|
commitHash: string | undefined,
|
|
475
523
|
commitError: string | undefined,
|
|
@@ -494,6 +542,7 @@ function emitTaskOutcomeProgress(
|
|
|
494
542
|
title: task.title,
|
|
495
543
|
attempt: outcome.attempt,
|
|
496
544
|
status: outcome.reportedStatus,
|
|
545
|
+
...currentTaskProgress(task, outcome.done ? "done" : "in_progress"),
|
|
497
546
|
};
|
|
498
547
|
if (commitHash) {
|
|
499
548
|
update.commitHash = commitHash;
|
package/src/git.ts
CHANGED
|
@@ -3,10 +3,16 @@ import { realpathSync } from "node:fs";
|
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { SessionOutcome } from "./worker_session.ts";
|
|
7
7
|
|
|
8
8
|
const execFileAsync = promisify(execFile);
|
|
9
9
|
|
|
10
|
+
const GENERATED_TODO_COMMIT_PREFIX_RE = /^(?:Complete|Progress)\s+TODO\s+\d+(?:\s+[—-]\s*)?/i;
|
|
11
|
+
const TODO_LABEL_PREFIX_RE = /^TODO\s+\d+(?:\s+[—-]\s*)?/i;
|
|
12
|
+
const CONVENTIONAL_SUBJECT_RE = /^([a-z][a-z0-9-]*)(\([^)]*\))?(!)?:\s+(.+)$/;
|
|
13
|
+
const DEFAULT_COMMIT_SUBJECT = "Update project files";
|
|
14
|
+
const RECENT_COMMIT_SUBJECT_LIMIT = 20;
|
|
15
|
+
|
|
10
16
|
export interface GitRunResult {
|
|
11
17
|
stdout: string;
|
|
12
18
|
stderr: string;
|
|
@@ -104,8 +110,7 @@ export async function commitAfterSession(options: CommitAfterSessionOptions): Pr
|
|
|
104
110
|
return { error: "not inside a git repository" };
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
const
|
|
108
|
-
const commitMessage = `${messagePrefix} ${taskLabel(options.outcome.task)}`;
|
|
113
|
+
const commitMessage = await commitMessageForOutcome(root, options.outcome);
|
|
109
114
|
|
|
110
115
|
try {
|
|
111
116
|
const add = await runGit(root, ["add", "-A"]);
|
|
@@ -154,6 +159,95 @@ export async function commitAfterSession(options: CommitAfterSessionOptions): Pr
|
|
|
154
159
|
}
|
|
155
160
|
}
|
|
156
161
|
|
|
162
|
+
async function commitMessageForOutcome(
|
|
163
|
+
root: string,
|
|
164
|
+
outcome: Pick<SessionOutcome, "task" | "reportedStatus" | "done" | "error" | "timedOut" | "aborted">,
|
|
165
|
+
): Promise<string> {
|
|
166
|
+
const subject = normalizedTaskSubject(outcome.task.title);
|
|
167
|
+
const recentSubjects = await recentCommitSubjects(root);
|
|
168
|
+
return formatSubjectLikeRecentCommits(subject, recentSubjects);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function recentCommitSubjects(root: string): Promise<string[]> {
|
|
172
|
+
const result = await runGit(root, ["log", `-${RECENT_COMMIT_SUBJECT_LIMIT}`, "--format=%s"]);
|
|
173
|
+
if (result.code !== 0) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return result.stdout
|
|
178
|
+
.split(/\r?\n/g)
|
|
179
|
+
.map((subject) => subject.trim())
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.filter((subject) => !GENERATED_TODO_COMMIT_PREFIX_RE.test(subject))
|
|
182
|
+
.filter((subject) => !/^Merge\b/.test(subject) && !/^Revert\b/.test(subject));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatSubjectLikeRecentCommits(subject: string, recentSubjects: readonly string[]): string {
|
|
186
|
+
const sample = recentSubjects[0];
|
|
187
|
+
if (!sample) {
|
|
188
|
+
return ensureSafeCommitSubject(subject);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const conventional = CONVENTIONAL_SUBJECT_RE.exec(sample);
|
|
192
|
+
if (conventional) {
|
|
193
|
+
const prefix = `${conventional[1]}${conventional[2] ?? ""}${conventional[3] ?? ""}: `;
|
|
194
|
+
return ensureSafeCommitSubject(`${prefix}${formatSubjectBody(subject, conventional[4])}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return ensureSafeCommitSubject(formatSubjectBody(subject, sample));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatSubjectBody(subject: string, sampleBody: string): string {
|
|
201
|
+
let formatted = normalizedTaskSubject(subject);
|
|
202
|
+
const sampleFirstLetter = sampleBody.match(/[A-Za-z]/)?.[0];
|
|
203
|
+
if (sampleFirstLetter && sampleFirstLetter === sampleFirstLetter.toLowerCase()) {
|
|
204
|
+
formatted = lowercaseFirstLetter(formatted);
|
|
205
|
+
} else if (sampleFirstLetter && sampleFirstLetter === sampleFirstLetter.toUpperCase()) {
|
|
206
|
+
formatted = uppercaseFirstLetter(formatted);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
formatted = formatted.replace(/[.!?]+$/g, "");
|
|
210
|
+
if (/\.$/.test(sampleBody.trim())) {
|
|
211
|
+
formatted = `${formatted}.`;
|
|
212
|
+
}
|
|
213
|
+
return formatted;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizedTaskSubject(title: string): string {
|
|
217
|
+
const normalized = stripGeneratedTodoPrefix(title)
|
|
218
|
+
.replace(/\s+/g, " ")
|
|
219
|
+
.replace(/[.!?]+$/g, "")
|
|
220
|
+
.trim();
|
|
221
|
+
return normalized || DEFAULT_COMMIT_SUBJECT;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function ensureSafeCommitSubject(subject: string): string {
|
|
225
|
+
const normalized = stripGeneratedTodoPrefix(subject).replace(/\s+/g, " ").trim();
|
|
226
|
+
return normalized || DEFAULT_COMMIT_SUBJECT;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function stripGeneratedTodoPrefix(subject: string): string {
|
|
230
|
+
let cleaned = subject.trim();
|
|
231
|
+
let previous = "";
|
|
232
|
+
while (cleaned && cleaned !== previous) {
|
|
233
|
+
previous = cleaned;
|
|
234
|
+
cleaned = cleaned
|
|
235
|
+
.replace(GENERATED_TODO_COMMIT_PREFIX_RE, "")
|
|
236
|
+
.replace(TODO_LABEL_PREFIX_RE, "")
|
|
237
|
+
.replace(/^[:\s—-]+/g, "")
|
|
238
|
+
.trim();
|
|
239
|
+
}
|
|
240
|
+
return cleaned;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function lowercaseFirstLetter(value: string): string {
|
|
244
|
+
return value.replace(/[A-Za-z]/, (letter) => letter.toLowerCase());
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function uppercaseFirstLetter(value: string): string {
|
|
248
|
+
return value.replace(/[A-Za-z]/, (letter) => letter.toUpperCase());
|
|
249
|
+
}
|
|
250
|
+
|
|
157
251
|
async function stagedArtifactPaths(root: string, runDir?: string): Promise<Set<string>> {
|
|
158
252
|
const artifacts = new Set<string>();
|
|
159
253
|
const runDirRel = runDir ? relToRoot(runDir, root) : "";
|
package/src/render.ts
CHANGED
|
@@ -22,6 +22,19 @@ export interface CoordinatorToolRenderDetails extends CoordinatorResultForRender
|
|
|
22
22
|
runId?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
type ProgressItemStatus = "empty" | "in_progress" | "done";
|
|
26
|
+
|
|
27
|
+
interface ProgressTaskRenderDetails {
|
|
28
|
+
taskId: string;
|
|
29
|
+
title: string;
|
|
30
|
+
status: ProgressItemStatus;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ProgressSubtaskRenderDetails {
|
|
34
|
+
text: string;
|
|
35
|
+
status: ProgressItemStatus;
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
export function formatCoordinatorResultMessage(result: CoordinatorResultForRendering): string {
|
|
26
39
|
const resultPath = result.resultPath ?? result.taskResultPath ?? "unknown";
|
|
27
40
|
const remaining = result.remainingTasks ?? [];
|
|
@@ -90,7 +103,26 @@ function renderLongTaskProgress(details: Record<string, unknown> | undefined, fa
|
|
|
90
103
|
const phase = stringValue(details?.phase);
|
|
91
104
|
const toolName = stringValue(details?.toolName);
|
|
92
105
|
const prefix = phase === "worker_tool" && toolName ? `worker ${toolName}` : phase || "progress";
|
|
93
|
-
|
|
106
|
+
const currentTask = progressTaskDetails(details?.currentTask);
|
|
107
|
+
if (!currentTask) {
|
|
108
|
+
return `${theme.fg("accent", "●")} ${theme.fg("muted", prefix)} ${message}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const taskLabel = `TODO ${currentTask.taskId} — ${currentTask.title}`;
|
|
112
|
+
const lines = [
|
|
113
|
+
`${progressBubble(currentTask.status, theme)} ${theme.fg("muted", prefix)} ${theme.fg(progressTextColor(currentTask.status), taskLabel)}`,
|
|
114
|
+
];
|
|
115
|
+
if (message && !message.includes(taskLabel)) {
|
|
116
|
+
lines.push(` ${theme.fg("dim", message)}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const subtask of progressSubtaskDetails(details?.subtasks)) {
|
|
120
|
+
lines.push(
|
|
121
|
+
` ${progressBubble(subtask.status, theme)} ${theme.fg(progressTextColor(subtask.status), subtask.text)}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return lines.join("\n");
|
|
94
126
|
}
|
|
95
127
|
|
|
96
128
|
function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded: boolean, theme: Theme): string {
|
|
@@ -141,6 +173,50 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
|
|
|
141
173
|
return lines.join("\n");
|
|
142
174
|
}
|
|
143
175
|
|
|
176
|
+
function progressTaskDetails(value: unknown): ProgressTaskRenderDetails | undefined {
|
|
177
|
+
const record = recordOrUndefined(value);
|
|
178
|
+
const taskId = stringValue(record?.taskId);
|
|
179
|
+
const title = stringValue(record?.title);
|
|
180
|
+
const status = progressItemStatus(record?.status);
|
|
181
|
+
if (!taskId || !title || !status) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
return { taskId, title, status };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[] {
|
|
188
|
+
if (!Array.isArray(value)) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
return value.flatMap((item) => {
|
|
192
|
+
const record = recordOrUndefined(item);
|
|
193
|
+
const text = stringValue(record?.text);
|
|
194
|
+
const status = progressItemStatus(record?.status);
|
|
195
|
+
if (!text || !status) {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
return [{ text, status }];
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function progressItemStatus(value: unknown): ProgressItemStatus | undefined {
|
|
203
|
+
return value === "empty" || value === "in_progress" || value === "done" ? value : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function progressBubble(status: ProgressItemStatus, theme: Theme): string {
|
|
207
|
+
return status === "empty" ? theme.fg("dim", "○") : theme.fg(progressTextColor(status), "●");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function progressTextColor(status: ProgressItemStatus): "success" | "warning" | "dim" {
|
|
211
|
+
if (status === "done") {
|
|
212
|
+
return "success";
|
|
213
|
+
}
|
|
214
|
+
if (status === "in_progress") {
|
|
215
|
+
return "warning";
|
|
216
|
+
}
|
|
217
|
+
return "dim";
|
|
218
|
+
}
|
|
219
|
+
|
|
144
220
|
function longTaskDetails(details: Record<string, unknown> | undefined): CoordinatorToolRenderDetails | undefined {
|
|
145
221
|
if (!details) {
|
|
146
222
|
return undefined;
|
package/src/todo_parser.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export interface TaskStatusItem {
|
|
2
|
+
text: string;
|
|
3
|
+
done: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
1
6
|
export interface Task {
|
|
2
7
|
taskId: string;
|
|
3
8
|
title: string;
|
|
@@ -7,6 +12,7 @@ export interface Task {
|
|
|
7
12
|
done: boolean;
|
|
8
13
|
progressDone?: boolean;
|
|
9
14
|
statusCheckboxes: boolean[];
|
|
15
|
+
statusItems: TaskStatusItem[];
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
export class TodoParseError extends Error {
|
|
@@ -78,10 +84,10 @@ function findProgressDone(lines: string[], taskId: string): boolean | undefined
|
|
|
78
84
|
return undefined;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
|
-
function
|
|
87
|
+
function findStatusItems(lines: string[], startIdx: number, endIdx: number): TaskStatusItem[] {
|
|
82
88
|
let inStatus = false;
|
|
83
89
|
let seenCheckbox = false;
|
|
84
|
-
const
|
|
90
|
+
const items: TaskStatusItem[] = [];
|
|
85
91
|
|
|
86
92
|
for (let idx = startIdx; idx < endIdx; idx += 1) {
|
|
87
93
|
const stripped = lines[idx].trim();
|
|
@@ -97,7 +103,10 @@ function findStatusCheckboxes(lines: string[], startIdx: number, endIdx: number)
|
|
|
97
103
|
const checkbox = CHECKBOX_RE.exec(stripLineBreaks(lines[idx]));
|
|
98
104
|
if (checkbox) {
|
|
99
105
|
seenCheckbox = true;
|
|
100
|
-
|
|
106
|
+
items.push({
|
|
107
|
+
text: checkbox[3].replace(/^\]\s*/, "").trim(),
|
|
108
|
+
done: checkbox[2].toLowerCase() === "x",
|
|
109
|
+
});
|
|
101
110
|
continue;
|
|
102
111
|
}
|
|
103
112
|
|
|
@@ -110,7 +119,7 @@ function findStatusCheckboxes(lines: string[], startIdx: number, endIdx: number)
|
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
121
|
|
|
113
|
-
return
|
|
122
|
+
return items;
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
function markStatusBlockDone(lines: string[], startIdx: number, endIdx: number): void {
|
|
@@ -161,7 +170,8 @@ export function parseTasks(markdown: string): Task[] {
|
|
|
161
170
|
const endIdx = pos + 1 < headings.length ? headings[pos + 1].startIdx : lines.length;
|
|
162
171
|
const section = `${lines.slice(heading.startIdx, endIdx).join("").trimEnd()}\n`;
|
|
163
172
|
const progressDone = findProgressDone(lines, heading.taskId);
|
|
164
|
-
const
|
|
173
|
+
const statusItems = findStatusItems(lines, heading.startIdx, endIdx);
|
|
174
|
+
const statusCheckboxes = statusItems.map((item) => item.done);
|
|
165
175
|
const done = progressDone ?? (statusCheckboxes.length > 0 ? statusCheckboxes.every(Boolean) : false);
|
|
166
176
|
|
|
167
177
|
const task: Task = {
|
|
@@ -172,6 +182,7 @@ export function parseTasks(markdown: string): Task[] {
|
|
|
172
182
|
endLine: endIdx,
|
|
173
183
|
done,
|
|
174
184
|
statusCheckboxes,
|
|
185
|
+
statusItems,
|
|
175
186
|
};
|
|
176
187
|
if (progressDone !== undefined) {
|
|
177
188
|
task.progressDone = progressDone;
|