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/CHANGELOG.md +16 -0
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/coordinator.ts +430 -41
- package/src/index.ts +42 -13
- package/src/plan_revision.ts +471 -0
- package/src/plan_revision_generation.ts +376 -0
- package/src/plan_store.ts +281 -0
- package/src/render.ts +2 -0
- package/src/steering.ts +234 -0
- package/src/todo_parser.ts +46 -7
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(
|
|
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
|
+
}
|