pi-long-task 0.3.17 → 0.5.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/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { truncateToWidth, type Component, type OverlayHandle, type TUI } from "@
5
5
  import { runCoordinator, type CoordinatorProgressUpdate, type CoordinatorResult } from "./coordinator.ts";
6
6
  import { runGoalLoop, type GoalLoopProgressUpdate, type GoalLoopRunResult } from "./goal_orchestrator.ts";
7
7
  import { longTaskInputTransform } from "./input_router.ts";
8
+ import { ActiveLongTaskSteeringRouter, SerializedSteeringQueue, type SteeringInput } from "./steering.ts";
8
9
  import {
9
10
  formatGoalLoopResultMessage,
10
11
  goalTaskDetailsFromResult,
@@ -275,10 +276,8 @@ function renderSidebarWidgetLines(update: CoordinatorProgressUpdate): string[] {
275
276
  const progress = update.taskProgress;
276
277
  const summary = progress?.summary;
277
278
  const statusDetails = sidebarUpdateStateDetails(update);
278
- const lines = [
279
- "Pi Long Task",
280
- `${statusDetails.icon} ${statusDetails.label} · ${update.activeStatus ?? update.message}`,
281
- ];
279
+ const statusText = normalizeActiveStatus(update.activeStatus ?? update.message);
280
+ const lines = ["Pi Long Task", `${statusDetails.icon} ${statusDetails.label} · ${statusText}`];
282
281
  if (summary) {
283
282
  lines.push(
284
283
  `Tasks: ${summary.completedTasks}/${summary.totalTasks} · ${summary.completedPercent}%` +
@@ -395,7 +394,8 @@ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme:
395
394
  rows.push(theme.fg("success", "No active task"));
396
395
  }
397
396
 
398
- const activeStatus = update.activeStatus ?? normalizeMessageForSidebar(update.message, update);
397
+ const rawActiveStatus = update.activeStatus ?? normalizeMessageForSidebar(update.message, update);
398
+ const activeStatus = rawActiveStatus ? normalizeActiveStatus(rawActiveStatus) : undefined;
399
399
  if (currentTask && activeStatus) {
400
400
  rows.push("", sidebarHeading("Active status", theme));
401
401
  rows.push(...wrapPlainText(activeStatus, width, 6).map((line) => theme.fg("accent", line)));
@@ -572,6 +572,8 @@ function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
572
572
  return { icon: "✓", label: "Plan ready", color: "success" };
573
573
  case "task_start":
574
574
  return { icon: "▢", label: "Running task", color: "accent" };
575
+ case "worker_session":
576
+ return { icon: "↻", label: "Worker session", color: "accent" };
575
577
  case "worker_tool":
576
578
  return { icon: "+", label: "Worker tool", color: "warning" };
577
579
  case "task_done":
@@ -580,6 +582,8 @@ function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
580
582
  return { icon: "!", label: "Task blocked", color: "warning" };
581
583
  case "task_failed":
582
584
  return { icon: "×", label: "Task failed", color: "error" };
585
+ case "task_obsolete":
586
+ return { icon: "↻", label: "Task replaced", color: "warning" };
583
587
  case "complete":
584
588
  return { icon: "✓", label: "Complete", color: "success" };
585
589
  }
@@ -678,6 +682,18 @@ function normalizeMessageForSidebar(updateMessage: string, update: CoordinatorPr
678
682
  return title && message.includes(title) && message.length <= title.length + 16 ? undefined : message;
679
683
  }
680
684
 
685
+ function normalizeActiveStatus(status: string): string {
686
+ const normalized = status.trim();
687
+ const firstOutcome = /^(Finished|Failed):\s*/i.exec(normalized)?.[1];
688
+ if (!firstOutcome) {
689
+ return normalized;
690
+ }
691
+
692
+ const activity = normalized.replace(/^(?:(?:Finished|Failed):\s*)+/i, "");
693
+ const outcome = firstOutcome.toLowerCase() === "failed" ? "Failed" : "Finished";
694
+ return activity ? `${outcome}: ${activity}` : `${outcome}:`;
695
+ }
696
+
681
697
  function wrapPlainText(text: string, width: number, limit?: number): string[] {
682
698
  const safeWidth = Math.max(8, width);
683
699
  const words = text.trim().split(/\s+/).filter(Boolean);
@@ -727,8 +743,40 @@ function formatCost(value: number): string {
727
743
  return `$${value.toFixed(2)}`;
728
744
  }
729
745
 
746
+ interface SteeringInputContext {
747
+ ui: {
748
+ notify(message: string, level?: "info" | "warning" | "error"): void;
749
+ };
750
+ }
751
+
752
+ export function handleLongTaskInput(
753
+ event: SteeringInput,
754
+ ctx: SteeringInputContext,
755
+ steeringRouter: ActiveLongTaskSteeringRouter,
756
+ ): { action: "continue" } | { action: "transform"; text: string } | { action: "handled" } {
757
+ if (event.source === "extension") {
758
+ return { action: "continue" };
759
+ }
760
+
761
+ const steering = steeringRouter.route(event);
762
+ if (steering.routed) {
763
+ ctx.ui.notify(
764
+ `Guidance received and queued for incorporation into the active Pi Long Task (#${steering.message.sequence}).`,
765
+ "info",
766
+ );
767
+ return { action: "handled" };
768
+ }
769
+
770
+ const transformed = longTaskInputTransform(event.text);
771
+ if (!transformed) {
772
+ return { action: "continue" };
773
+ }
774
+ return { action: "transform", text: transformed };
775
+ }
776
+
730
777
  export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
731
778
  const workerCostAccumulator = createWorkerCostAccumulator();
779
+ const steeringRouter = new ActiveLongTaskSteeringRouter();
732
780
 
733
781
  pi.on("message_end", (event) => {
734
782
  if (event.message.role !== "assistant") {
@@ -739,18 +787,7 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
739
787
  return message ? { message } : undefined;
740
788
  });
741
789
 
742
- pi.on("input", (event) => {
743
- if (event.source === "extension") {
744
- return { action: "continue" as const };
745
- }
746
-
747
- const transformed = longTaskInputTransform(event.text);
748
- if (!transformed) {
749
- return { action: "continue" as const };
750
- }
751
-
752
- return { action: "transform" as const, text: transformed };
753
- });
790
+ pi.on("input", (event, ctx) => handleLongTaskInput(event, ctx, steeringRouter));
754
791
 
755
792
  pi.registerTool({
756
793
  name: "pi_long_task",
@@ -760,8 +797,10 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
760
797
  parameters: PiLongTaskParams,
761
798
  renderCall: renderLongTaskToolCall,
762
799
  renderResult: renderLongTaskToolResult,
763
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
800
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
764
801
  const sidebar = createLongTaskSidebarController(ctx);
802
+ const steeringQueue = new SerializedSteeringQueue({ queueId: toolCallId });
803
+ const deactivateSteering = steeringRouter.activate(steeringQueue);
765
804
  const publishProgress = (update: CoordinatorProgressUpdate) => {
766
805
  sidebar?.update(update);
767
806
  onUpdate?.({
@@ -782,6 +821,7 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
782
821
  workerModel: ctx?.model,
783
822
  abortSignal: signal,
784
823
  onProgress: publishProgress,
824
+ steeringQueue,
785
825
  });
786
826
  workerCostAccumulator.add(result.workerCostTotal);
787
827
 
@@ -795,6 +835,8 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
795
835
  details: toolDetails(result),
796
836
  };
797
837
  } finally {
838
+ deactivateSteering();
839
+ steeringQueue.close();
798
840
  sidebar?.close();
799
841
  }
800
842
  },
@@ -0,0 +1,471 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { markTaskDone, markTaskPending, type Task } from "./todo_parser.ts";
4
+
5
+ /**
6
+ * State owned by the coordinator for a task. Revised planner markdown is never
7
+ * trusted to supply this state.
8
+ */
9
+ export const PLAN_TASK_STATE_VALUES = ["pending", "running", "completed", "failed", "blocked"] as const;
10
+ export type PlanTaskState = (typeof PLAN_TASK_STATE_VALUES)[number];
11
+
12
+ export type PlanTaskMatchKind = "stable_id" | "exact_content" | "unique_title";
13
+ export type PlanTaskRevisionKind = "unchanged" | "modified" | "inserted" | "follow_up";
14
+ export type RetiredPlanTaskReason = "removed" | "superseded" | "invalidated";
15
+
16
+ export interface PlanTaskMatch {
17
+ previousTaskId: string;
18
+ revisedTaskId: string;
19
+ kind: PlanTaskMatchKind;
20
+ /** True only when relative order changed, not merely when insertion shifted an index. */
21
+ reordered: boolean;
22
+ contentChanged: boolean;
23
+ }
24
+
25
+ /** An item in the accepted, schedulable plan, including preserved completed items. */
26
+ export interface ReconciledPlanTask {
27
+ identity: string;
28
+ task: Task;
29
+ state: PlanTaskState;
30
+ revisionKind: PlanTaskRevisionKind;
31
+ previousTaskId?: string;
32
+ revisedTaskId?: string;
33
+ matchKind?: PlanTaskMatchKind;
34
+ reordered: boolean;
35
+ /** Existing result/commit references may be attached to this identity. */
36
+ preserveOutputs: boolean;
37
+ /** Set when changed requirements must be executed without erasing prior completed work. */
38
+ followUpForIdentity?: string;
39
+ }
40
+
41
+ /** Historical work excluded from scheduling but retained for results/audit rendering. */
42
+ export interface RetiredPlanTask {
43
+ identity: string;
44
+ task: Task;
45
+ state: PlanTaskState;
46
+ reason: RetiredPlanTaskReason;
47
+ preserveOutputs: boolean;
48
+ }
49
+
50
+ export interface ReconcilePlanRevisionOptions {
51
+ /** Coordinator-owned states keyed by IDs in the previous plan. Parsed `done` still wins. */
52
+ previousStates?: Readonly<Record<string, PlanTaskState>>;
53
+ /** The task whose worker is currently in flight, if any. */
54
+ runningTaskId?: string;
55
+ /**
56
+ * Explicitly invalidated IDs from the previous plan. Their history is retained and
57
+ * replacement/follow-up work is made pending, even if proposed content is unchanged.
58
+ */
59
+ invalidatedTaskIds?: readonly string[];
60
+ /** Used to make generated follow-up identities stable within a persisted revision. */
61
+ revisionId?: string;
62
+ }
63
+
64
+ export interface PlanRevisionResult {
65
+ activeTasks: ReconciledPlanTask[];
66
+ retiredTasks: RetiredPlanTask[];
67
+ matches: PlanTaskMatch[];
68
+ /** In-flight results for these previous task IDs must not mutate the accepted plan. */
69
+ staleRunningTaskIds: string[];
70
+ }
71
+
72
+ export class PlanRevisionError extends Error {
73
+ constructor(message: string) {
74
+ super(message);
75
+ this.name = "PlanRevisionError";
76
+ }
77
+ }
78
+
79
+ interface InternalMatch {
80
+ previousIndex: number;
81
+ revisedIndex: number;
82
+ kind: PlanTaskMatchKind;
83
+ }
84
+
85
+ /**
86
+ * Deterministically reconciles a validated revised plan with coordinator-owned state.
87
+ *
88
+ * Match precedence is: explicit stable ID, exact semantic content, then a title that
89
+ * is unique among the remaining tasks on both sides. Ambiguous tasks are intentionally
90
+ * treated as remove+insert rather than risking progress transfer to the wrong work.
91
+ */
92
+ export function reconcilePlanRevision(
93
+ previousTasks: readonly Task[],
94
+ revisedTasks: readonly Task[],
95
+ options: ReconcilePlanRevisionOptions = {},
96
+ ): PlanRevisionResult {
97
+ assertUniqueStableIds(previousTasks, "previous");
98
+ assertUniqueStableIds(revisedTasks, "revised");
99
+ assertUniqueTaskIds(previousTasks, "previous");
100
+ assertUniqueTaskIds(revisedTasks, "revised");
101
+
102
+ const previousIndexByTaskId = new Map(previousTasks.map((task, index) => [task.taskId, index]));
103
+ if (options.runningTaskId && !previousIndexByTaskId.has(options.runningTaskId)) {
104
+ throw new PlanRevisionError(`Running task TODO ${options.runningTaskId} is not present in the previous plan.`);
105
+ }
106
+
107
+ const invalidated = new Set(options.invalidatedTaskIds ?? []);
108
+ for (const taskId of invalidated) {
109
+ if (!previousIndexByTaskId.has(taskId)) {
110
+ throw new PlanRevisionError(`Invalidated task TODO ${taskId} is not present in the previous plan.`);
111
+ }
112
+ }
113
+
114
+ const internalMatches = matchTasks(previousTasks, revisedTasks);
115
+ const matchByRevisedIndex = new Map(internalMatches.map((match) => [match.revisedIndex, match]));
116
+ const matchByPreviousIndex = new Map(internalMatches.map((match) => [match.previousIndex, match]));
117
+ const reorderedMatches = reorderedMatchKeys(internalMatches);
118
+ const revisionId = normalizedRevisionId(options.revisionId);
119
+ const previousIdentities = taskIdentities(previousTasks, "legacy");
120
+ const revisedIdentities = taskIdentities(revisedTasks, "inserted");
121
+ const previousStates = previousTasks.map((task) => previousTaskState(task, options));
122
+
123
+ const activeTasks: ReconciledPlanTask[] = [];
124
+ const retiredTasks: RetiredPlanTask[] = [];
125
+ const staleRunningTaskIds: string[] = [];
126
+
127
+ for (let revisedIndex = 0; revisedIndex < revisedTasks.length; revisedIndex += 1) {
128
+ const revisedTask = revisedTasks[revisedIndex];
129
+ const match = matchByRevisedIndex.get(revisedIndex);
130
+ if (!match) {
131
+ activeTasks.push({
132
+ identity: revisedIdentities[revisedIndex],
133
+ task: taskForState(revisedTask, "pending", revisedIdentities[revisedIndex]),
134
+ state: "pending",
135
+ revisionKind: "inserted",
136
+ revisedTaskId: revisedTask.taskId,
137
+ reordered: false,
138
+ preserveOutputs: false,
139
+ });
140
+ continue;
141
+ }
142
+
143
+ const previousTask = previousTasks[match.previousIndex];
144
+ const previousState = previousStates[match.previousIndex];
145
+ const previousIdentity = previousIdentities[match.previousIndex];
146
+ const contentChanged = taskSemanticFingerprint(previousTask) !== taskSemanticFingerprint(revisedTask);
147
+ const explicitlyInvalidated = invalidated.has(previousTask.taskId);
148
+ const changed = contentChanged || explicitlyInvalidated;
149
+ const reordered = reorderedMatches.has(matchKey(match));
150
+
151
+ if (!changed) {
152
+ activeTasks.push({
153
+ identity: previousIdentity,
154
+ task: taskForState(revisedTask, previousState, previousIdentity),
155
+ state: previousState,
156
+ revisionKind: "unchanged",
157
+ previousTaskId: previousTask.taskId,
158
+ revisedTaskId: revisedTask.taskId,
159
+ matchKind: match.kind,
160
+ reordered,
161
+ preserveOutputs: previousState === "completed",
162
+ });
163
+ continue;
164
+ }
165
+
166
+ const mustRetainPrior = previousState === "completed" || previousState === "running";
167
+ if (mustRetainPrior) {
168
+ retiredTasks.push({
169
+ identity: previousIdentity,
170
+ task: taskForState(previousTask, previousState, previousIdentity),
171
+ state: previousState,
172
+ reason: explicitlyInvalidated ? "invalidated" : "superseded",
173
+ preserveOutputs: previousState === "completed",
174
+ });
175
+ }
176
+ if (previousState === "running") {
177
+ staleRunningTaskIds.push(previousTask.taskId);
178
+ }
179
+
180
+ const requiresFollowUp = previousState === "completed";
181
+ const nextIdentity = requiresFollowUp
182
+ ? followUpIdentity(previousIdentity, revisedTask, revisionId)
183
+ : previousIdentity;
184
+ activeTasks.push({
185
+ identity: nextIdentity,
186
+ task: taskForState(revisedTask, "pending", nextIdentity),
187
+ state: "pending",
188
+ revisionKind: requiresFollowUp ? "follow_up" : "modified",
189
+ previousTaskId: previousTask.taskId,
190
+ revisedTaskId: revisedTask.taskId,
191
+ matchKind: match.kind,
192
+ reordered,
193
+ preserveOutputs: false,
194
+ followUpForIdentity: requiresFollowUp ? previousIdentity : undefined,
195
+ });
196
+ }
197
+
198
+ for (let previousIndex = 0; previousIndex < previousTasks.length; previousIndex += 1) {
199
+ if (matchByPreviousIndex.has(previousIndex)) {
200
+ continue;
201
+ }
202
+ const previousTask = previousTasks[previousIndex];
203
+ const previousState = previousStates[previousIndex];
204
+ const previousIdentity = previousIdentities[previousIndex];
205
+ const explicitlyInvalidated = invalidated.has(previousTask.taskId);
206
+
207
+ retiredTasks.push({
208
+ identity: previousIdentity,
209
+ task: taskForState(previousTask, previousState, previousIdentity),
210
+ state: previousState,
211
+ reason: explicitlyInvalidated ? "invalidated" : "removed",
212
+ preserveOutputs: previousState === "completed",
213
+ });
214
+
215
+ if (previousState === "running") {
216
+ staleRunningTaskIds.push(previousTask.taskId);
217
+ }
218
+ if (explicitlyInvalidated) {
219
+ const nextIdentity = followUpIdentity(previousIdentity, previousTask, revisionId);
220
+ activeTasks.push({
221
+ identity: nextIdentity,
222
+ task: taskForState(previousTask, "pending", nextIdentity),
223
+ state: "pending",
224
+ revisionKind: previousState === "completed" ? "follow_up" : "modified",
225
+ previousTaskId: previousTask.taskId,
226
+ reordered: false,
227
+ preserveOutputs: false,
228
+ followUpForIdentity: previousState === "completed" ? previousIdentity : undefined,
229
+ });
230
+ }
231
+ }
232
+
233
+ return {
234
+ activeTasks,
235
+ retiredTasks,
236
+ matches: internalMatches.map((match) => {
237
+ const previousTask = previousTasks[match.previousIndex];
238
+ const revisedTask = revisedTasks[match.revisedIndex];
239
+ return {
240
+ previousTaskId: previousTask.taskId,
241
+ revisedTaskId: revisedTask.taskId,
242
+ kind: match.kind,
243
+ reordered: reorderedMatches.has(matchKey(match)),
244
+ contentChanged: taskSemanticFingerprint(previousTask) !== taskSemanticFingerprint(revisedTask),
245
+ };
246
+ }),
247
+ staleRunningTaskIds: [...new Set(staleRunningTaskIds)],
248
+ };
249
+ }
250
+
251
+ /** Semantic content excludes numbering, stable-ID markers, checkbox state, and formatting-only whitespace. */
252
+ export function taskSemanticFingerprint(task: Task): string {
253
+ const lines = task.section.replace(/\r\n?/g, "\n").split("\n");
254
+ const body = lines
255
+ .slice(1)
256
+ .filter((line) => !/^\s*<!--\s*pi-long-task-id:.*-->\s*$/i.test(line))
257
+ .map((line) =>
258
+ line
259
+ .replace(/^(\s*-\s+\[)[ xX](\])/, "$1 $2")
260
+ .replace(/[\t ]+/g, " ")
261
+ .trim(),
262
+ )
263
+ .join("\n")
264
+ .replace(/\n{3,}/g, "\n\n")
265
+ .trim();
266
+ return `${normalizedTitle(task.title)}\n${body}`;
267
+ }
268
+
269
+ function matchTasks(previousTasks: readonly Task[], revisedTasks: readonly Task[]): InternalMatch[] {
270
+ const matches: InternalMatch[] = [];
271
+ const usedPrevious = new Set<number>();
272
+ const usedRevised = new Set<number>();
273
+
274
+ const previousStable = indexByStableId(previousTasks);
275
+ revisedTasks.forEach((task, revisedIndex) => {
276
+ if (!task.stableId) {
277
+ return;
278
+ }
279
+ const previousIndex = previousStable.get(task.stableId);
280
+ if (previousIndex !== undefined) {
281
+ addMatch(matches, usedPrevious, usedRevised, previousIndex, revisedIndex, "stable_id");
282
+ }
283
+ });
284
+
285
+ const previousByFingerprint = unmatchedGroups(previousTasks, usedPrevious, (task) =>
286
+ task.stableId ? undefined : taskSemanticFingerprint(task),
287
+ );
288
+ const revisedByFingerprint = unmatchedGroups(revisedTasks, usedRevised, (task) =>
289
+ task.stableId ? undefined : taskSemanticFingerprint(task),
290
+ );
291
+ for (const [fingerprint, previousIndexes] of previousByFingerprint) {
292
+ const revisedIndexes = revisedByFingerprint.get(fingerprint) ?? [];
293
+ const count = Math.min(previousIndexes.length, revisedIndexes.length);
294
+ for (let index = 0; index < count; index += 1) {
295
+ addMatch(matches, usedPrevious, usedRevised, previousIndexes[index], revisedIndexes[index], "exact_content");
296
+ }
297
+ }
298
+
299
+ const previousByTitle = unmatchedGroups(previousTasks, usedPrevious, (task) =>
300
+ task.stableId ? undefined : normalizedTitle(task.title),
301
+ );
302
+ const revisedByTitle = unmatchedGroups(revisedTasks, usedRevised, (task) =>
303
+ task.stableId ? undefined : normalizedTitle(task.title),
304
+ );
305
+ for (const [title, previousIndexes] of previousByTitle) {
306
+ const revisedIndexes = revisedByTitle.get(title) ?? [];
307
+ if (previousIndexes.length === 1 && revisedIndexes.length === 1) {
308
+ addMatch(matches, usedPrevious, usedRevised, previousIndexes[0], revisedIndexes[0], "unique_title");
309
+ }
310
+ }
311
+
312
+ return matches.sort((left, right) => left.revisedIndex - right.revisedIndex);
313
+ }
314
+
315
+ function addMatch(
316
+ matches: InternalMatch[],
317
+ usedPrevious: Set<number>,
318
+ usedRevised: Set<number>,
319
+ previousIndex: number,
320
+ revisedIndex: number,
321
+ kind: PlanTaskMatchKind,
322
+ ): void {
323
+ if (usedPrevious.has(previousIndex) || usedRevised.has(revisedIndex)) {
324
+ return;
325
+ }
326
+ usedPrevious.add(previousIndex);
327
+ usedRevised.add(revisedIndex);
328
+ matches.push({ previousIndex, revisedIndex, kind });
329
+ }
330
+
331
+ function unmatchedGroups(
332
+ tasks: readonly Task[],
333
+ used: ReadonlySet<number>,
334
+ keyForTask: (task: Task) => string | undefined,
335
+ ): Map<string, number[]> {
336
+ const groups = new Map<string, number[]>();
337
+ tasks.forEach((task, index) => {
338
+ if (used.has(index)) {
339
+ return;
340
+ }
341
+ const key = keyForTask(task);
342
+ if (!key) {
343
+ return;
344
+ }
345
+ groups.set(key, [...(groups.get(key) ?? []), index]);
346
+ });
347
+ return groups;
348
+ }
349
+
350
+ function reorderedMatchKeys(matches: readonly InternalMatch[]): Set<string> {
351
+ const reordered = new Set<string>();
352
+ for (let leftIndex = 0; leftIndex < matches.length; leftIndex += 1) {
353
+ const left = matches[leftIndex];
354
+ for (let rightIndex = leftIndex + 1; rightIndex < matches.length; rightIndex += 1) {
355
+ const right = matches[rightIndex];
356
+ const previousOrder = Math.sign(left.previousIndex - right.previousIndex);
357
+ const revisedOrder = Math.sign(left.revisedIndex - right.revisedIndex);
358
+ if (previousOrder !== revisedOrder) {
359
+ reordered.add(matchKey(left));
360
+ reordered.add(matchKey(right));
361
+ }
362
+ }
363
+ }
364
+ return reordered;
365
+ }
366
+
367
+ function matchKey(match: InternalMatch): string {
368
+ return `${match.previousIndex}:${match.revisedIndex}`;
369
+ }
370
+
371
+ function previousTaskState(task: Task, options: ReconcilePlanRevisionOptions): PlanTaskState {
372
+ if (task.done) {
373
+ return "completed";
374
+ }
375
+ if (options.runningTaskId === task.taskId) {
376
+ return "running";
377
+ }
378
+ return options.previousStates?.[task.taskId] ?? "pending";
379
+ }
380
+
381
+ function taskForState(task: Task, state: PlanTaskState, identity: string): Task {
382
+ const done = state === "completed";
383
+ return {
384
+ ...task,
385
+ stableId: identity,
386
+ section:
387
+ state === "completed"
388
+ ? markTaskDone(sectionWithStableId(task.section, identity), task.taskId)
389
+ : markTaskPending(sectionWithStableId(task.section, identity), task.taskId),
390
+ done,
391
+ progressDone: done,
392
+ statusCheckboxes: task.statusCheckboxes.map(() => done),
393
+ statusItems: task.statusItems.map((item) => ({ ...item, done })),
394
+ };
395
+ }
396
+
397
+ function sectionWithStableId(section: string, identity: string): string {
398
+ const marker = `<!-- pi-long-task-id: ${identity} -->`;
399
+ if (/^[\t ]*<!--[\t ]*pi-long-task-id:.*-->[\t ]*$/im.test(section)) {
400
+ return section.replace(/^[\t ]*<!--[\t ]*pi-long-task-id:.*-->[\t ]*$/im, marker);
401
+ }
402
+ const headingEnd = section.search(/\r?\n/);
403
+ if (headingEnd < 0) {
404
+ return `${section}\n\n${marker}\n`;
405
+ }
406
+ const newline = section.startsWith("\r\n", headingEnd) ? "\r\n" : "\n";
407
+ const insertAt = headingEnd + newline.length;
408
+ return `${section.slice(0, insertAt)}${marker}${newline}${section.slice(insertAt)}`;
409
+ }
410
+
411
+ function taskIdentities(tasks: readonly Task[], prefix: string): string[] {
412
+ const occurrences = new Map<string, number>();
413
+ return tasks.map((task) => {
414
+ if (task.stableId) {
415
+ return task.stableId;
416
+ }
417
+ const digest = digestText(taskSemanticFingerprint(task));
418
+ const occurrence = (occurrences.get(digest) ?? 0) + 1;
419
+ occurrences.set(digest, occurrence);
420
+ return `${prefix}:${digest}:${occurrence}`;
421
+ });
422
+ }
423
+
424
+ function followUpIdentity(previousIdentity: string, task: Task, revisionId: string): string {
425
+ return `follow-up:${digestText(`${previousIdentity}\n${taskSemanticFingerprint(task)}\n${revisionId}`)}`;
426
+ }
427
+
428
+ function normalizedRevisionId(value: string | undefined): string {
429
+ return value?.trim() || "revision";
430
+ }
431
+
432
+ function digestText(value: string): string {
433
+ return createHash("sha256").update(value).digest("hex").slice(0, 20);
434
+ }
435
+
436
+ function normalizedTitle(value: string): string {
437
+ return value.normalize("NFKC").replace(/\s+/g, " ").trim().toLocaleLowerCase("en-US");
438
+ }
439
+
440
+ function indexByStableId(tasks: readonly Task[]): Map<string, number> {
441
+ const indexes = new Map<string, number>();
442
+ tasks.forEach((task, index) => {
443
+ if (task.stableId) {
444
+ indexes.set(task.stableId, index);
445
+ }
446
+ });
447
+ return indexes;
448
+ }
449
+
450
+ function assertUniqueStableIds(tasks: readonly Task[], label: string): void {
451
+ const seen = new Set<string>();
452
+ for (const task of tasks) {
453
+ if (!task.stableId) {
454
+ continue;
455
+ }
456
+ if (seen.has(task.stableId)) {
457
+ throw new PlanRevisionError(`Duplicate pi-long-task-id ${JSON.stringify(task.stableId)} in ${label} plan.`);
458
+ }
459
+ seen.add(task.stableId);
460
+ }
461
+ }
462
+
463
+ function assertUniqueTaskIds(tasks: readonly Task[], label: string): void {
464
+ const seen = new Set<string>();
465
+ for (const task of tasks) {
466
+ if (seen.has(task.taskId)) {
467
+ throw new PlanRevisionError(`Duplicate TODO ${task.taskId} in ${label} plan.`);
468
+ }
469
+ seen.add(task.taskId);
470
+ }
471
+ }