pi-long-task 0.3.16 → 0.4.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,
@@ -580,6 +581,8 @@ function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
580
581
  return { icon: "!", label: "Task blocked", color: "warning" };
581
582
  case "task_failed":
582
583
  return { icon: "×", label: "Task failed", color: "error" };
584
+ case "task_obsolete":
585
+ return { icon: "↻", label: "Task replaced", color: "warning" };
583
586
  case "complete":
584
587
  return { icon: "✓", label: "Complete", color: "success" };
585
588
  }
@@ -727,8 +730,40 @@ function formatCost(value: number): string {
727
730
  return `$${value.toFixed(2)}`;
728
731
  }
729
732
 
733
+ interface SteeringInputContext {
734
+ ui: {
735
+ notify(message: string, level?: "info" | "warning" | "error"): void;
736
+ };
737
+ }
738
+
739
+ export function handleLongTaskInput(
740
+ event: SteeringInput,
741
+ ctx: SteeringInputContext,
742
+ steeringRouter: ActiveLongTaskSteeringRouter,
743
+ ): { action: "continue" } | { action: "transform"; text: string } | { action: "handled" } {
744
+ if (event.source === "extension") {
745
+ return { action: "continue" };
746
+ }
747
+
748
+ const steering = steeringRouter.route(event);
749
+ if (steering.routed) {
750
+ ctx.ui.notify(
751
+ `Guidance received and queued for incorporation into the active Pi Long Task (#${steering.message.sequence}).`,
752
+ "info",
753
+ );
754
+ return { action: "handled" };
755
+ }
756
+
757
+ const transformed = longTaskInputTransform(event.text);
758
+ if (!transformed) {
759
+ return { action: "continue" };
760
+ }
761
+ return { action: "transform", text: transformed };
762
+ }
763
+
730
764
  export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
731
765
  const workerCostAccumulator = createWorkerCostAccumulator();
766
+ const steeringRouter = new ActiveLongTaskSteeringRouter();
732
767
 
733
768
  pi.on("message_end", (event) => {
734
769
  if (event.message.role !== "assistant") {
@@ -739,18 +774,7 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
739
774
  return message ? { message } : undefined;
740
775
  });
741
776
 
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
- });
777
+ pi.on("input", (event, ctx) => handleLongTaskInput(event, ctx, steeringRouter));
754
778
 
755
779
  pi.registerTool({
756
780
  name: "pi_long_task",
@@ -760,8 +784,10 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
760
784
  parameters: PiLongTaskParams,
761
785
  renderCall: renderLongTaskToolCall,
762
786
  renderResult: renderLongTaskToolResult,
763
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
787
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
764
788
  const sidebar = createLongTaskSidebarController(ctx);
789
+ const steeringQueue = new SerializedSteeringQueue({ queueId: toolCallId });
790
+ const deactivateSteering = steeringRouter.activate(steeringQueue);
765
791
  const publishProgress = (update: CoordinatorProgressUpdate) => {
766
792
  sidebar?.update(update);
767
793
  onUpdate?.({
@@ -782,6 +808,7 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
782
808
  workerModel: ctx?.model,
783
809
  abortSignal: signal,
784
810
  onProgress: publishProgress,
811
+ steeringQueue,
785
812
  });
786
813
  workerCostAccumulator.add(result.workerCostTotal);
787
814
 
@@ -795,6 +822,8 @@ export default function registerPiLongTaskExtension(pi: ExtensionAPI) {
795
822
  details: toolDetails(result),
796
823
  };
797
824
  } finally {
825
+ deactivateSteering();
826
+ steeringQueue.close();
798
827
  sidebar?.close();
799
828
  }
800
829
  },
@@ -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
+ }