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.
@@ -0,0 +1,376 @@
1
+ import type { PlanRevisionResult, PlanTaskState, ReconciledPlanTask } from "./plan_revision.ts";
2
+ import { reconcilePlanRevision } from "./plan_revision.ts";
3
+ import { parseTasks, type Task } from "./todo_parser.ts";
4
+ import { extractAndValidateTodoMarkdown, validateTodoMarkdown } from "./todo_generator.ts";
5
+
6
+ export interface PlanRevisionRelevantResult {
7
+ /** Task ID in the current (pre-revision) plan. */
8
+ taskId: string;
9
+ status: string;
10
+ summary: string;
11
+ outputReferences?: readonly string[];
12
+ }
13
+
14
+ export interface PlanRevisionActiveTaskContext {
15
+ taskId: string;
16
+ title: string;
17
+ attempt?: number;
18
+ startedAt?: string;
19
+ activity?: string;
20
+ }
21
+
22
+ export interface PlanRevisionRequest {
23
+ revisionId: string;
24
+ guidance: string;
25
+ currentTodoMarkdown: string;
26
+ taskStates: Readonly<Record<string, PlanTaskState>>;
27
+ relevantResults: readonly PlanRevisionRelevantResult[];
28
+ activeTask?: PlanRevisionActiveTaskContext;
29
+ invalidatedTaskIds: readonly string[];
30
+ }
31
+
32
+ export interface PlanRevisionPlannerInput {
33
+ /** Ready-to-send planner prompt containing the complete revision context. */
34
+ prompt: string;
35
+ /** Structured form of the same context for planner adapters that do not consume a text prompt. */
36
+ request: Readonly<PlanRevisionRequest>;
37
+ }
38
+
39
+ export type PlanRevisionPlanner = (input: PlanRevisionPlannerInput) => Promise<string>;
40
+
41
+ export interface PreservedPlanRevisionResult {
42
+ previousTaskId: string;
43
+ identity: string;
44
+ retainedAs: "active" | "history";
45
+ result: PlanRevisionRelevantResult;
46
+ }
47
+
48
+ export interface GeneratedPlanRevision {
49
+ revisionId: string;
50
+ guidance: string;
51
+ /** Valid planner proposal before coordinator-owned state is applied. */
52
+ proposalMarkdown: string;
53
+ /** Complete, valid TODO markdown with reconciled task identity and completion state. */
54
+ todoMarkdown: string;
55
+ reconciliation: PlanRevisionResult;
56
+ preservedResults: PreservedPlanRevisionResult[];
57
+ }
58
+
59
+ export interface GeneratePlanRevisionOptions {
60
+ currentTodoMarkdown: string;
61
+ guidance: string;
62
+ revisionId: string;
63
+ planner: PlanRevisionPlanner;
64
+ taskStates?: Readonly<Record<string, PlanTaskState>>;
65
+ relevantResults?: readonly PlanRevisionRelevantResult[];
66
+ activeTask?: PlanRevisionActiveTaskContext;
67
+ invalidatedTaskIds?: readonly string[];
68
+ }
69
+
70
+ /**
71
+ * A failed revision is recoverable: callers must keep priorTodoMarkdown as the
72
+ * authoritative plan and may retry the same queued guidance.
73
+ */
74
+ export class PlanRevisionGenerationError extends Error {
75
+ readonly recoverable = true;
76
+ readonly priorTodoMarkdown: string;
77
+ readonly revisionId: string;
78
+
79
+ constructor(message: string, options: { priorTodoMarkdown: string; revisionId: string; cause?: unknown }) {
80
+ super(message, { cause: options.cause });
81
+ this.name = "PlanRevisionGenerationError";
82
+ this.priorTodoMarkdown = options.priorTodoMarkdown;
83
+ this.revisionId = options.revisionId;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Requests a complete revised plan, validates it, and reconciles it without
89
+ * mutating or persisting the current plan. A caller should replace its active
90
+ * snapshot only after this function resolves.
91
+ */
92
+ export async function generatePlanRevision(options: GeneratePlanRevisionOptions): Promise<GeneratedPlanRevision> {
93
+ const priorTodoMarkdown = options.currentTodoMarkdown;
94
+ const revisionId = options.revisionId.trim();
95
+ const guidance = options.guidance.trim();
96
+
97
+ try {
98
+ if (!revisionId) {
99
+ throw new Error("A plan revision requires a non-empty revisionId.");
100
+ }
101
+ if (!guidance) {
102
+ throw new Error("Plan revision guidance must not be empty.");
103
+ }
104
+
105
+ validateTodoMarkdown(priorTodoMarkdown);
106
+ const previousTasks = parseTasks(priorTodoMarkdown);
107
+ const taskStates = effectiveTaskStates(previousTasks, options.taskStates, options.activeTask);
108
+ const request: PlanRevisionRequest = {
109
+ revisionId,
110
+ guidance,
111
+ currentTodoMarkdown: priorTodoMarkdown,
112
+ taskStates,
113
+ relevantResults: (options.relevantResults ?? []).map(copyRelevantResult),
114
+ activeTask: options.activeTask ? { ...options.activeTask } : undefined,
115
+ invalidatedTaskIds: [...(options.invalidatedTaskIds ?? [])],
116
+ };
117
+ const prompt = buildPlanRevisionPrompt(request);
118
+ const rawProposal = await options.planner({ prompt, request });
119
+ const proposalMarkdown = extractAndValidateTodoMarkdown(rawProposal);
120
+ // Keep this explicit even though extraction validates. It guards future
121
+ // extractor changes before any accepted state can be returned.
122
+ validateTodoMarkdown(proposalMarkdown);
123
+ const revisedTasks = parseTasks(proposalMarkdown);
124
+ const reconciliation = reconcilePlanRevision(previousTasks, revisedTasks, {
125
+ previousStates: taskStates,
126
+ runningTaskId: options.activeTask?.taskId,
127
+ invalidatedTaskIds: request.invalidatedTaskIds,
128
+ revisionId,
129
+ });
130
+ const todoMarkdown = renderReconciledTodoMarkdown(proposalMarkdown, reconciliation.activeTasks);
131
+ validateTodoMarkdown(todoMarkdown);
132
+ const renderedTasks = parseTasks(todoMarkdown);
133
+ const renderedReconciliation = reconciliationWithRenderedTasks(reconciliation, renderedTasks);
134
+
135
+ return {
136
+ revisionId,
137
+ guidance,
138
+ proposalMarkdown,
139
+ todoMarkdown,
140
+ reconciliation: renderedReconciliation,
141
+ preservedResults: preservedRevisionResults(previousTasks, renderedReconciliation, request.relevantResults),
142
+ };
143
+ } catch (error) {
144
+ if (error instanceof PlanRevisionGenerationError) {
145
+ throw error;
146
+ }
147
+ throw new PlanRevisionGenerationError(
148
+ `Plan revision ${revisionId || "<unknown>"} was not accepted: ${errorMessage(error)}`,
149
+ {
150
+ priorTodoMarkdown,
151
+ revisionId,
152
+ cause: error,
153
+ },
154
+ );
155
+ }
156
+ }
157
+
158
+ /** Builds the complete planner context required to place steering guidance. */
159
+ export function buildPlanRevisionPrompt(request: Readonly<PlanRevisionRequest>): string {
160
+ const taskContext = parseTasks(request.currentTodoMarkdown).map((task) => ({
161
+ taskId: task.taskId,
162
+ stableId: task.stableId,
163
+ title: task.title,
164
+ state: request.taskStates[task.taskId] ?? (task.done ? "completed" : "pending"),
165
+ }));
166
+ const activeTask = request.activeTask ?? null;
167
+
168
+ return `Revise an active Pi Long Task TODO plan using new steering guidance.
169
+
170
+ Return one complete, valid revised plan. Output only markdown, without commentary or a code fence.
171
+
172
+ Required format and revision rules:
173
+ - Start with exactly \`# Pi Long Task TODO\`.
174
+ - Include a \`## Progress\` section with exactly one \`- [ ] TODO N — Title\` or \`- [x] TODO N — Title\` line per task.
175
+ - Include a \`---\` separator, then sequential \`## TODO N — Title\` sections.
176
+ - Every task must include \`**Goal:**\`, \`**Status:**\` with checkbox items, \`**Verify:**\`, and \`**Done when:**\`.
177
+ - Apply the guidance where it belongs and revise only affected plan content. Preserve unrelated tasks and global constraints.
178
+ - Preserve the relative order of unaffected tasks unless the guidance requests a reorder.
179
+ - Preserve each \`<!-- pi-long-task-id: ... -->\` marker on equivalent existing work. New tasks may omit the marker.
180
+ - Do not use checkbox changes to discard coordinator-owned status. The coordinator will reconcile status after validation.
181
+ - Never silently rewrite or erase completed history. If guidance corrects completed work, retain its valid result and express the new requirement as explicit follow-up work.
182
+ - Return the full plan, not a patch, diff, explanation, or partial task section.
183
+
184
+ Revision ID: ${oneLine(request.revisionId)}
185
+
186
+ Coordinator-owned task state:
187
+
188
+ ${markdownFence(JSON.stringify(taskContext, null, 2), "json")}
189
+
190
+ Active-task context:
191
+
192
+ ${markdownFence(JSON.stringify(activeTask, null, 2), "json")}
193
+
194
+ Relevant completed/attempt results and output references:
195
+
196
+ ${markdownFence(JSON.stringify(request.relevantResults, null, 2), "json")}
197
+
198
+ Explicitly invalidated current task IDs:
199
+
200
+ ${markdownFence(JSON.stringify(request.invalidatedTaskIds), "json")}
201
+
202
+ New steering guidance:
203
+
204
+ ${markdownFence(request.guidance, "text")}
205
+
206
+ Current complete TODO plan:
207
+
208
+ ${markdownFence(request.currentTodoMarkdown, "markdown")}
209
+ `;
210
+ }
211
+
212
+ /**
213
+ * Produces a normal Pi Long Task TODO document from reconciled active items.
214
+ * Persistence and atomic replacement are intentionally left to the caller.
215
+ */
216
+ export function renderReconciledTodoMarkdown(
217
+ proposedMarkdown: string,
218
+ activeTasks: readonly ReconciledPlanTask[],
219
+ ): string {
220
+ if (activeTasks.length === 0) {
221
+ throw new Error("A revised TODO plan must retain at least one active or historical-completion task.");
222
+ }
223
+
224
+ const normalized = proposedMarkdown.replace(/\r\n?/g, "\n");
225
+ const lines = normalized.split("\n");
226
+ const progressIndex = lines.findIndex((line) => /^##\s+Progress\s*$/i.test(line.trim()));
227
+ if (progressIndex < 0) {
228
+ throw new Error("The revised TODO plan is missing its Progress section.");
229
+ }
230
+ const prefix = lines.slice(0, progressIndex).join("\n").trimEnd();
231
+ const progress = activeTasks
232
+ .map((item, index) => `- [${item.state === "completed" ? "x" : " "}] TODO ${index + 1} — ${item.task.title}`)
233
+ .join("\n");
234
+ const sections = activeTasks
235
+ .map((item, index) => renumberTaskSection(item.task.section, index + 1, item.task.title))
236
+ .join("\n\n");
237
+ const rendered = `${prefix}\n\n## Progress\n\n${progress}\n\n---\n\n${sections.trimEnd()}\n`;
238
+ validateTodoMarkdown(rendered);
239
+ return rendered;
240
+ }
241
+
242
+ function effectiveTaskStates(
243
+ tasks: readonly Task[],
244
+ supplied: Readonly<Record<string, PlanTaskState>> | undefined,
245
+ activeTask: PlanRevisionActiveTaskContext | undefined,
246
+ ): Readonly<Record<string, PlanTaskState>> {
247
+ const knownIds = new Set(tasks.map((task) => task.taskId));
248
+ for (const taskId of Object.keys(supplied ?? {})) {
249
+ if (!knownIds.has(taskId)) {
250
+ throw new Error(`Task state refers to unknown current TODO ${taskId}.`);
251
+ }
252
+ }
253
+ if (activeTask && !knownIds.has(activeTask.taskId)) {
254
+ throw new Error(`Active task TODO ${activeTask.taskId} is not present in the current plan.`);
255
+ }
256
+
257
+ return Object.fromEntries(
258
+ tasks.map((task) => {
259
+ const state = task.done
260
+ ? "completed"
261
+ : activeTask?.taskId === task.taskId
262
+ ? "running"
263
+ : (supplied?.[task.taskId] ?? "pending");
264
+ return [task.taskId, state];
265
+ }),
266
+ );
267
+ }
268
+
269
+ function reconciliationWithRenderedTasks(
270
+ reconciliation: PlanRevisionResult,
271
+ renderedTasks: readonly Task[],
272
+ ): PlanRevisionResult {
273
+ if (renderedTasks.length !== reconciliation.activeTasks.length) {
274
+ throw new Error("Rendered revised plan task count does not match reconciliation output.");
275
+ }
276
+ return {
277
+ ...reconciliation,
278
+ activeTasks: reconciliation.activeTasks.map((item, index) => ({
279
+ ...item,
280
+ task: renderedTasks[index],
281
+ })),
282
+ };
283
+ }
284
+
285
+ function preservedRevisionResults(
286
+ previousTasks: readonly Task[],
287
+ reconciliation: PlanRevisionResult,
288
+ results: readonly PlanRevisionRelevantResult[],
289
+ ): PreservedPlanRevisionResult[] {
290
+ const previousByTaskId = new Map(previousTasks.map((task) => [task.taskId, task]));
291
+ const activeByPreviousTaskId = new Map(
292
+ reconciliation.activeTasks
293
+ .filter((item) => item.previousTaskId && item.preserveOutputs)
294
+ .map((item) => [item.previousTaskId as string, item]),
295
+ );
296
+ const retiredByPreviousTaskId = new Map(
297
+ reconciliation.retiredTasks
298
+ .filter((item) => item.preserveOutputs)
299
+ .map((item) => {
300
+ const task = previousTasks.find(
301
+ (candidate) => candidate.stableId === item.identity || candidate.taskId === item.task.taskId,
302
+ );
303
+ return task ? [task.taskId, item] : undefined;
304
+ })
305
+ .filter((entry): entry is [string, PlanRevisionResult["retiredTasks"][number]] => Boolean(entry)),
306
+ );
307
+
308
+ const preserved: PreservedPlanRevisionResult[] = [];
309
+ for (const result of results) {
310
+ if (!previousByTaskId.has(result.taskId)) {
311
+ throw new Error(`Relevant result refers to unknown current TODO ${result.taskId}.`);
312
+ }
313
+ const active = activeByPreviousTaskId.get(result.taskId);
314
+ if (active) {
315
+ preserved.push({
316
+ previousTaskId: result.taskId,
317
+ identity: active.identity,
318
+ retainedAs: "active",
319
+ result: copyRelevantResult(result),
320
+ });
321
+ continue;
322
+ }
323
+ const retired = retiredByPreviousTaskId.get(result.taskId);
324
+ if (retired) {
325
+ preserved.push({
326
+ previousTaskId: result.taskId,
327
+ identity: retired.identity,
328
+ retainedAs: "history",
329
+ result: copyRelevantResult(result),
330
+ });
331
+ }
332
+ }
333
+ return preserved;
334
+ }
335
+
336
+ function copyRelevantResult(result: PlanRevisionRelevantResult): PlanRevisionRelevantResult {
337
+ return {
338
+ ...result,
339
+ outputReferences: result.outputReferences ? [...result.outputReferences] : undefined,
340
+ };
341
+ }
342
+
343
+ function renumberTaskSection(section: string, taskNumber: number, title: string): string {
344
+ const heading = `## TODO ${taskNumber} — ${title}`;
345
+ if (!/^##\s+TODO\s+\d+\s+[—-]\s+.+$/m.test(section)) {
346
+ throw new Error(`Cannot render revised task ${taskNumber}; its TODO heading is missing.`);
347
+ }
348
+ return section.replace(/^##\s+TODO\s+\d+\s+[—-]\s+.+$/m, heading).trimEnd();
349
+ }
350
+
351
+ function markdownFence(value: string, language: string): string {
352
+ const fence = "`".repeat(Math.max(3, longestBacktickRun(value) + 1));
353
+ return `${fence}${language}\n${value.trim()}\n${fence}`;
354
+ }
355
+
356
+ function longestBacktickRun(value: string): number {
357
+ let longest = 0;
358
+ let current = 0;
359
+ for (const character of value) {
360
+ if (character === "`") {
361
+ current += 1;
362
+ longest = Math.max(longest, current);
363
+ } else {
364
+ current = 0;
365
+ }
366
+ }
367
+ return longest;
368
+ }
369
+
370
+ function oneLine(value: string): string {
371
+ return value.replace(/\s+/g, " ").trim();
372
+ }
373
+
374
+ function errorMessage(error: unknown): string {
375
+ return error instanceof Error ? error.message : String(error);
376
+ }
@@ -0,0 +1,281 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { reconcilePlanRevision, taskSemanticFingerprint, type PlanTaskState } from "./plan_revision.ts";
6
+ import { renderReconciledTodoMarkdown, type GeneratedPlanRevision } from "./plan_revision_generation.ts";
7
+ import { markTaskDone, parseTasks, type Task } from "./todo_parser.ts";
8
+ import { validateTodoMarkdown } from "./todo_generator.ts";
9
+
10
+ export interface PersistedPlanSnapshot {
11
+ markdown: string;
12
+ /** Changes only when plan structure/content changes, not when checkboxes change. */
13
+ authorityToken: string;
14
+ }
15
+
16
+ export interface PlanTaskReference {
17
+ taskId: string;
18
+ stableId?: string;
19
+ semanticFingerprint: string;
20
+ authorityToken: string;
21
+ }
22
+
23
+ export interface ApplyPersistedPlanRevisionOptions {
24
+ expectedAuthorityToken: string;
25
+ taskStates?: Readonly<Record<string, PlanTaskState>>;
26
+ runningTask?: PlanTaskReference;
27
+ /** Runs while writes are serialized and before the authoritative file is replaced. */
28
+ beforeCommit?: (revision: GeneratedPlanRevision) => void | Promise<void>;
29
+ }
30
+
31
+ export interface ResolvePersistedTaskResult {
32
+ task?: Task;
33
+ stale: boolean;
34
+ snapshot: PersistedPlanSnapshot;
35
+ }
36
+
37
+ export interface CompletePersistedTaskResult extends ResolvePersistedTaskResult {
38
+ applied: boolean;
39
+ }
40
+
41
+ export class StalePlanRevisionError extends Error {
42
+ readonly recoverable = true;
43
+
44
+ constructor(message: string) {
45
+ super(message);
46
+ this.name = "StalePlanRevisionError";
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Serialized, atomic persistence for the authoritative TODO document.
52
+ *
53
+ * Every mutation is derived from the latest in-memory snapshot and reaches disk
54
+ * through a same-directory temporary file + rename. Structural authority tokens
55
+ * let status-only writes rebase safely while rejecting planner output generated
56
+ * from an older accepted plan.
57
+ */
58
+ export class PersistentTodoPlanStore {
59
+ readonly todoPath: string;
60
+ private markdown: string;
61
+ private authorityToken: string;
62
+ private writeTail: Promise<void> = Promise.resolve();
63
+
64
+ private constructor(todoPath: string, markdown: string) {
65
+ validateTodoMarkdown(markdown);
66
+ this.todoPath = todoPath;
67
+ this.markdown = markdown;
68
+ this.authorityToken = planAuthorityToken(markdown);
69
+ }
70
+
71
+ static async create(todoPath: string, markdown: string): Promise<PersistentTodoPlanStore> {
72
+ const store = new PersistentTodoPlanStore(todoPath, markdown);
73
+ await atomicWrite(todoPath, markdown);
74
+ return store;
75
+ }
76
+
77
+ /** Restores the exact rendered plan state from the authoritative TODO file. */
78
+ static async load(todoPath: string): Promise<PersistentTodoPlanStore> {
79
+ return new PersistentTodoPlanStore(todoPath, await readFile(todoPath, "utf8"));
80
+ }
81
+
82
+ /** Restores from content already read by a caller. */
83
+ static fromPersistedMarkdown(todoPath: string, markdown: string): PersistentTodoPlanStore {
84
+ return new PersistentTodoPlanStore(todoPath, markdown);
85
+ }
86
+
87
+ snapshot(): PersistedPlanSnapshot {
88
+ return { markdown: this.markdown, authorityToken: this.authorityToken };
89
+ }
90
+
91
+ async applyRevision(
92
+ revision: GeneratedPlanRevision,
93
+ options: ApplyPersistedPlanRevisionOptions,
94
+ ): Promise<GeneratedPlanRevision> {
95
+ return this.withWriteLock(async () => {
96
+ if (options.expectedAuthorityToken !== this.authorityToken) {
97
+ throw new StalePlanRevisionError(
98
+ `Plan revision ${revision.revisionId} was generated from a stale plan and cannot replace the latest revision.`,
99
+ );
100
+ }
101
+
102
+ const currentTasks = parseTasks(this.markdown);
103
+ const renderedTargetTasks = parseTasks(revision.todoMarkdown);
104
+ if (renderedTargetTasks.length !== revision.reconciliation.activeTasks.length) {
105
+ throw new Error("Persisted revision task count does not match its reconciliation state.");
106
+ }
107
+ const currentByTaskId = new Map(currentTasks.map((task) => [task.taskId, task]));
108
+ const targetTasks = renderedTargetTasks.map((task, index) => {
109
+ const item = revision.reconciliation.activeTasks[index];
110
+ const previous = item?.previousTaskId ? currentByTaskId.get(item.previousTaskId) : undefined;
111
+ const shouldMatchPrevious = previous && item.revisionKind !== "follow_up" && item.revisedTaskId !== undefined;
112
+ return shouldMatchPrevious ? taskWithStableId(task, previous.stableId) : task;
113
+ });
114
+ const runningTaskId = options.runningTask
115
+ ? resolvePlanTaskReference(currentTasks, options.runningTask, this.authorityToken)?.taskId
116
+ : undefined;
117
+ const taskStates = Object.fromEntries(
118
+ currentTasks.map((task) => [
119
+ task.taskId,
120
+ task.done ? "completed" : (options.taskStates?.[task.taskId] ?? "pending"),
121
+ ]),
122
+ ) as Record<string, PlanTaskState>;
123
+ const reconciliation = reconcilePlanRevision(currentTasks, targetTasks, {
124
+ previousStates: taskStates,
125
+ runningTaskId,
126
+ revisionId: revision.revisionId,
127
+ });
128
+ const todoMarkdown = renderReconciledTodoMarkdown(revision.todoMarkdown, reconciliation.activeTasks);
129
+ validateTodoMarkdown(todoMarkdown);
130
+ const renderedTasks = parseTasks(todoMarkdown);
131
+ const appliedRevision: GeneratedPlanRevision = {
132
+ ...revision,
133
+ todoMarkdown,
134
+ reconciliation: {
135
+ ...reconciliation,
136
+ activeTasks: reconciliation.activeTasks.map((item, index) => ({
137
+ ...item,
138
+ task: renderedTasks[index],
139
+ })),
140
+ },
141
+ };
142
+
143
+ await options.beforeCommit?.(appliedRevision);
144
+ await atomicWrite(this.todoPath, todoMarkdown);
145
+ this.markdown = todoMarkdown;
146
+ this.authorityToken = planAuthorityToken(todoMarkdown);
147
+ return appliedRevision;
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Marks the equivalent task in the latest plan complete. If an accepted
153
+ * revision replaced that work, the stale worker update becomes a no-op.
154
+ */
155
+ async completeTask(reference: PlanTaskReference): Promise<CompletePersistedTaskResult> {
156
+ return this.withWriteLock(async () => {
157
+ const tasks = parseTasks(this.markdown);
158
+ const task = resolvePlanTaskReference(tasks, reference, this.authorityToken);
159
+ if (!task) {
160
+ return { applied: false, stale: true, snapshot: this.snapshot() };
161
+ }
162
+ if (task.done) {
163
+ return { applied: false, stale: false, task, snapshot: this.snapshot() };
164
+ }
165
+
166
+ const nextMarkdown = markTaskDone(this.markdown, task.taskId);
167
+ validateTodoMarkdown(nextMarkdown);
168
+ await atomicWrite(this.todoPath, nextMarkdown);
169
+ this.markdown = nextMarkdown;
170
+ // Completion updates only checkboxes, so the structural token normally
171
+ // remains unchanged. Recompute it to keep this invariant verified.
172
+ this.authorityToken = planAuthorityToken(nextMarkdown);
173
+ const snapshot = this.snapshot();
174
+ const completedTask = resolvePlanTaskReference(parseTasks(snapshot.markdown), reference, snapshot.authorityToken);
175
+ return { applied: true, stale: false, task: completedTask, snapshot };
176
+ });
177
+ }
178
+
179
+ /** Resolves an in-flight worker identity against the latest accepted plan. */
180
+ async resolveTask(reference: PlanTaskReference): Promise<ResolvePersistedTaskResult> {
181
+ return this.withWriteLock(async () => {
182
+ const snapshot = this.snapshot();
183
+ const task = resolvePlanTaskReference(parseTasks(snapshot.markdown), reference, snapshot.authorityToken);
184
+ return { task, stale: !task, snapshot };
185
+ });
186
+ }
187
+
188
+ private async withWriteLock<T>(operation: () => Promise<T>): Promise<T> {
189
+ const previous = this.writeTail;
190
+ let release: (() => void) | undefined;
191
+ this.writeTail = new Promise<void>((resolve) => {
192
+ release = resolve;
193
+ });
194
+ await previous;
195
+ try {
196
+ return await operation();
197
+ } finally {
198
+ release?.();
199
+ }
200
+ }
201
+ }
202
+
203
+ function taskWithStableId(task: Task, stableId: string | undefined): Task {
204
+ const markerPattern = /^[\t ]*<!--[\t ]*pi-long-task-id:.*-->[\t ]*(?:\r?\n)?/im;
205
+ const withoutMarker = task.section.replace(markerPattern, "");
206
+ if (!stableId) {
207
+ return { ...task, stableId: undefined, section: withoutMarker };
208
+ }
209
+ const headingEnd = withoutMarker.search(/\r?\n/);
210
+ const marker = `<!-- pi-long-task-id: ${stableId} -->`;
211
+ const section =
212
+ headingEnd < 0
213
+ ? `${withoutMarker}\n\n${marker}\n`
214
+ : `${withoutMarker.slice(0, headingEnd + 1)}\n${marker}\n${withoutMarker.slice(headingEnd + 1).replace(/^\n/, "")}`;
215
+ return { ...task, stableId, section };
216
+ }
217
+
218
+ export function planTaskReference(task: Task, authorityToken: string): PlanTaskReference {
219
+ return {
220
+ taskId: task.taskId,
221
+ stableId: task.stableId,
222
+ semanticFingerprint: taskSemanticFingerprint(task),
223
+ authorityToken,
224
+ };
225
+ }
226
+
227
+ /** Stable across checkbox-only task completion writes. */
228
+ export function planAuthorityToken(markdown: string): string {
229
+ const normalized = markdown
230
+ .replace(/\r\n?/g, "\n")
231
+ .replace(/^(\s*-\s+\[)[ xX](\])/gm, "$1 $2")
232
+ .trimEnd();
233
+ // A deterministic content token avoids persisting a second source of truth.
234
+ return `plan:${createHash("sha256").update(normalized).digest("hex")}`;
235
+ }
236
+
237
+ export function resolvePlanTaskReference(
238
+ tasks: readonly Task[],
239
+ reference: PlanTaskReference,
240
+ currentAuthorityToken: string,
241
+ ): Task | undefined {
242
+ if (reference.stableId) {
243
+ const stableMatches = tasks.filter(
244
+ (task) => task.stableId === reference.stableId && taskSemanticFingerprint(task) === reference.semanticFingerprint,
245
+ );
246
+ if (stableMatches.length === 1) {
247
+ return stableMatches[0];
248
+ }
249
+ // Once a worker has a persisted identity, a newer revision must not let it
250
+ // fall through to semantically similar work under a different identity.
251
+ // Fingerprint fallback is reserved for legacy workers launched before IDs
252
+ // were first added to their plan.
253
+ if (reference.authorityToken !== currentAuthorityToken) {
254
+ return undefined;
255
+ }
256
+ }
257
+
258
+ const semanticMatches = tasks.filter((task) => taskSemanticFingerprint(task) === reference.semanticFingerprint);
259
+ if (semanticMatches.length === 1) {
260
+ return semanticMatches[0];
261
+ }
262
+
263
+ if (reference.authorityToken === currentAuthorityToken) {
264
+ return tasks.find((task) => task.taskId === reference.taskId);
265
+ }
266
+ return undefined;
267
+ }
268
+
269
+ async function atomicWrite(pathname: string, content: string): Promise<void> {
270
+ const temporaryPath = path.join(
271
+ path.dirname(pathname),
272
+ `.${path.basename(pathname)}.${process.pid}.${randomUUID()}.tmp`,
273
+ );
274
+ await writeFile(temporaryPath, content, "utf8");
275
+ try {
276
+ await rename(temporaryPath, pathname);
277
+ } catch (error) {
278
+ await unlink(temporaryPath).catch(() => undefined);
279
+ throw error;
280
+ }
281
+ }
package/src/render.ts CHANGED
@@ -462,6 +462,8 @@ function progressPhaseLabel(phase: string): string {
462
462
  return "Failed";
463
463
  case "task_blocked":
464
464
  return "Blocked";
465
+ case "task_obsolete":
466
+ return "Revised";
465
467
  case "complete":
466
468
  return "Complete";
467
469
  default: