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/CHANGELOG.md +25 -0
- package/README.md +74 -8
- package/package.json +2 -2
- package/src/coordinator.ts +1102 -50
- package/src/index.ts +60 -18
- 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/worker_config.ts +63 -14
- package/src/worker_reuse_policy.ts +389 -0
- package/src/worker_session.ts +261 -33
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
|
|
279
|
-
|
|
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
|
|
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(
|
|
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
|
+
}
|