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 CHANGED
@@ -24,7 +24,7 @@ A finished run gives you:
24
24
 
25
25
  ## Install
26
26
 
27
- After this package is published to npm, install it with:
27
+ Install it from npm with:
28
28
 
29
29
  ```bash
30
30
  pi install npm:pi-long-task
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-long-task",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Pi extension for breaking down and running long coding tasks safely.",
6
6
  "keywords": [
@@ -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 { taskLabel, type SessionOutcome } from "./worker_session.ts";
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 messagePrefix = options.outcome.done ? "Complete" : "Progress";
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
- return `${theme.fg("accent", "●")} ${theme.fg("muted", prefix)} ${message}`;
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;
@@ -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 findStatusCheckboxes(lines: string[], startIdx: number, endIdx: number): boolean[] {
87
+ function findStatusItems(lines: string[], startIdx: number, endIdx: number): TaskStatusItem[] {
82
88
  let inStatus = false;
83
89
  let seenCheckbox = false;
84
- const checkboxes: boolean[] = [];
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
- checkboxes.push(checkbox[2].toLowerCase() === "x");
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 checkboxes;
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 statusCheckboxes = findStatusCheckboxes(lines, heading.startIdx, endIdx);
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;