@ilikexiaoni/pi-task-list 0.4.0 → 0.4.2
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/README.md +26 -7
- package/extensions/task-context-bridge.ts +21 -40
- package/extensions/task-list.ts +286 -126
- package/lib/task-list-core.ts +284 -92
- package/lib/task-list-persistence.ts +116 -0
- package/package.json +1 -1
package/lib/task-list-core.ts
CHANGED
|
@@ -49,6 +49,16 @@ export interface TaskState {
|
|
|
49
49
|
tasks: Task[];
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export interface TaskSummary {
|
|
53
|
+
total: number;
|
|
54
|
+
active: number;
|
|
55
|
+
blocked: number;
|
|
56
|
+
pending: number;
|
|
57
|
+
completed: number;
|
|
58
|
+
cancelled: number;
|
|
59
|
+
ready: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
export interface TaskCommand {
|
|
53
63
|
action: TaskAction;
|
|
54
64
|
id?: number;
|
|
@@ -76,6 +86,13 @@ export interface TaskCommand {
|
|
|
76
86
|
confirm?: boolean;
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
/** A mutation record that can be replayed after compaction or session restore. */
|
|
90
|
+
export interface TaskOperation {
|
|
91
|
+
version: 1;
|
|
92
|
+
command: TaskCommand;
|
|
93
|
+
at: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
export interface ApplyResult {
|
|
80
97
|
action: TaskAction;
|
|
81
98
|
state: TaskState;
|
|
@@ -96,15 +113,45 @@ export class TaskListError extends Error {
|
|
|
96
113
|
}
|
|
97
114
|
}
|
|
98
115
|
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
116
|
+
export const MAX_TASKS = 200;
|
|
117
|
+
export const MAX_TASK_ID = Number.MAX_SAFE_INTEGER;
|
|
118
|
+
export const MAX_SUBJECT_LENGTH = 300;
|
|
119
|
+
export const MAX_FIELD_LENGTH = 4000;
|
|
120
|
+
export const MAX_LIST_ITEMS = 50;
|
|
121
|
+
export const MAX_NOTES = 100;
|
|
103
122
|
|
|
104
123
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
105
124
|
return typeof value === "object" && value !== null;
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
/** Identify a persisted task snapshot without silently treating malformed data as an empty list. */
|
|
128
|
+
export function isPersistedTaskState(value: unknown): value is { tasks: unknown[] } {
|
|
129
|
+
if (!isRecord(value) || !Array.isArray(value.tasks)) return false;
|
|
130
|
+
const seenIds = new Set<number>();
|
|
131
|
+
return value.tasks.every((task) => {
|
|
132
|
+
if (!isRecord(task)) return false;
|
|
133
|
+
const subject = typeof task.subject === "string" ? sanitizeTaskText(task.subject) : "";
|
|
134
|
+
if (!subject) return false;
|
|
135
|
+
|
|
136
|
+
const hasPersistedId = task.id !== undefined;
|
|
137
|
+
const id = positiveInteger(task.id);
|
|
138
|
+
// Missing IDs can be migrated by normalizeState; malformed explicit IDs cannot.
|
|
139
|
+
if (hasPersistedId && id === undefined) return false;
|
|
140
|
+
if (id !== undefined) {
|
|
141
|
+
if (seenIds.has(id)) return false;
|
|
142
|
+
seenIds.add(id);
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function isPersistedTaskOperation(value: unknown): value is TaskOperation {
|
|
149
|
+
if (!isRecord(value) || (value.version !== undefined && value.version !== 1)) return false;
|
|
150
|
+
if (typeof value.at !== "string" || !value.at.trim() || value.at.length > 64 || !isRecord(value.command)) return false;
|
|
151
|
+
const action = value.command.action;
|
|
152
|
+
return typeof action === "string" && ["create", "update", "clear"].includes(action);
|
|
153
|
+
}
|
|
154
|
+
|
|
108
155
|
function isTaskStatus(value: unknown): value is TaskStatus {
|
|
109
156
|
return typeof value === "string" && (TASK_STATUSES as readonly string[]).includes(value);
|
|
110
157
|
}
|
|
@@ -118,14 +165,21 @@ function isTaskBlockSource(value: unknown): value is TaskBlockSource {
|
|
|
118
165
|
}
|
|
119
166
|
|
|
120
167
|
function positiveInteger(value: unknown): number | undefined {
|
|
121
|
-
return typeof value === "number" && Number.
|
|
168
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= MAX_TASK_ID ? value : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const ANSI_ESCAPE_PATTERN = /\u001b(?:\][^\u0007]*(?:\u0007|\u001b\\)|\[[0-?]*[ -/]*[@-~])/g;
|
|
172
|
+
|
|
173
|
+
/** Keep task data safe for TUI rendering and compact enough for session context. */
|
|
174
|
+
export function sanitizeTaskText(value: string): string {
|
|
175
|
+
return value.replace(ANSI_ESCAPE_PATTERN, "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
122
176
|
}
|
|
123
177
|
|
|
124
178
|
function boundedText(value: unknown, field: string, max = MAX_FIELD_LENGTH): string {
|
|
125
179
|
if (typeof value !== "string") {
|
|
126
180
|
throw new TaskListError("invalid_argument", `${field} 必须是字符串`);
|
|
127
181
|
}
|
|
128
|
-
const text = value
|
|
182
|
+
const text = sanitizeTaskText(value);
|
|
129
183
|
if (text.length > max) {
|
|
130
184
|
throw new TaskListError("invalid_argument", `${field} 不能超过 ${max} 个字符`);
|
|
131
185
|
}
|
|
@@ -177,7 +231,7 @@ function safeStringList(value: unknown): string[] {
|
|
|
177
231
|
if (!Array.isArray(value)) return [];
|
|
178
232
|
return value
|
|
179
233
|
.filter((item): item is string => typeof item === "string")
|
|
180
|
-
.map((item) => item
|
|
234
|
+
.map((item) => sanitizeTaskText(item).slice(0, MAX_FIELD_LENGTH))
|
|
181
235
|
.filter(Boolean)
|
|
182
236
|
.filter((item, index, list) => list.indexOf(item) === index)
|
|
183
237
|
.slice(0, MAX_LIST_ITEMS);
|
|
@@ -192,13 +246,43 @@ function safeIdList(value: unknown): number[] {
|
|
|
192
246
|
.slice(0, MAX_LIST_ITEMS);
|
|
193
247
|
}
|
|
194
248
|
|
|
249
|
+
function nextTaskId(id: number): number {
|
|
250
|
+
return id === MAX_TASK_ID ? 1 : id + 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function findAvailableTaskId(preferred: number, reserved: ReadonlySet<number>): number {
|
|
254
|
+
let candidate = Math.min(Math.max(preferred, 1), MAX_TASK_ID);
|
|
255
|
+
for (let attempts = 0; attempts <= reserved.size; attempts += 1) {
|
|
256
|
+
if (!reserved.has(candidate)) return candidate;
|
|
257
|
+
candidate = nextTaskId(candidate);
|
|
258
|
+
}
|
|
259
|
+
throw new TaskListError("task_limit", "没有可用的安全任务 ID");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function reservedTaskIds(tasks: readonly Task[]): Set<number> {
|
|
263
|
+
const reserved = new Set<number>();
|
|
264
|
+
for (const task of tasks) {
|
|
265
|
+
reserved.add(task.id);
|
|
266
|
+
for (const dependency of task.blockedBy) reserved.add(dependency);
|
|
267
|
+
}
|
|
268
|
+
return reserved;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function allocateTaskId(state: TaskState): number {
|
|
272
|
+
const reserved = reservedTaskIds(state.tasks);
|
|
273
|
+
const id = findAvailableTaskId(state.nextId, reserved);
|
|
274
|
+
reserved.add(id);
|
|
275
|
+
state.nextId = findAvailableTaskId(nextTaskId(id), reserved);
|
|
276
|
+
return id;
|
|
277
|
+
}
|
|
278
|
+
|
|
195
279
|
function safeNotes(value: unknown): TaskNote[] {
|
|
196
280
|
if (!Array.isArray(value)) return [];
|
|
197
281
|
return value
|
|
198
282
|
.filter(isRecord)
|
|
199
283
|
.map((item) => ({
|
|
200
|
-
at: typeof item.at === "string" ? item.at : "",
|
|
201
|
-
text: typeof item.text === "string" ? item.text
|
|
284
|
+
at: typeof item.at === "string" ? sanitizeTaskText(item.at).slice(0, 64) : "",
|
|
285
|
+
text: typeof item.text === "string" ? sanitizeTaskText(item.text).slice(0, MAX_FIELD_LENGTH) : "",
|
|
202
286
|
}))
|
|
203
287
|
.filter((item) => item.text.length > 0)
|
|
204
288
|
.slice(-MAX_NOTES);
|
|
@@ -222,6 +306,41 @@ export function cloneState(state: TaskState): TaskState {
|
|
|
222
306
|
};
|
|
223
307
|
}
|
|
224
308
|
|
|
309
|
+
export function cloneTaskCommand(command: TaskCommand): TaskCommand {
|
|
310
|
+
const cloned: TaskCommand = { action: command.action };
|
|
311
|
+
if (command.id !== undefined) cloned.id = command.id;
|
|
312
|
+
if (command.subject !== undefined) cloned.subject = command.subject;
|
|
313
|
+
if (command.description !== undefined) cloned.description = command.description;
|
|
314
|
+
if (command.activeForm !== undefined) cloned.activeForm = command.activeForm;
|
|
315
|
+
if (command.acceptanceCriteria !== undefined) cloned.acceptanceCriteria = [...command.acceptanceCriteria];
|
|
316
|
+
if (command.evidence !== undefined) cloned.evidence = [...command.evidence];
|
|
317
|
+
if (command.addEvidence !== undefined) cloned.addEvidence = [...command.addEvidence];
|
|
318
|
+
if (command.status !== undefined) cloned.status = command.status;
|
|
319
|
+
if (command.priority !== undefined) cloned.priority = command.priority;
|
|
320
|
+
if (command.blockedBy !== undefined) cloned.blockedBy = [...command.blockedBy];
|
|
321
|
+
if (command.addBlockedBy !== undefined) cloned.addBlockedBy = [...command.addBlockedBy];
|
|
322
|
+
if (command.removeBlockedBy !== undefined) cloned.removeBlockedBy = [...command.removeBlockedBy];
|
|
323
|
+
if (command.blockReason !== undefined) cloned.blockReason = command.blockReason;
|
|
324
|
+
if (command.tags !== undefined) cloned.tags = [...command.tags];
|
|
325
|
+
if (command.addTags !== undefined) cloned.addTags = [...command.addTags];
|
|
326
|
+
if (command.removeTags !== undefined) cloned.removeTags = [...command.removeTags];
|
|
327
|
+
if (command.owner !== undefined) cloned.owner = command.owner;
|
|
328
|
+
if (command.completionNote !== undefined) cloned.completionNote = command.completionNote;
|
|
329
|
+
if (command.note !== undefined) cloned.note = command.note;
|
|
330
|
+
if (command.query !== undefined) cloned.query = command.query;
|
|
331
|
+
if (command.includeCancelled !== undefined) cloned.includeCancelled = command.includeCancelled;
|
|
332
|
+
if (command.scope !== undefined) cloned.scope = command.scope;
|
|
333
|
+
if (command.confirm !== undefined) cloned.confirm = command.confirm;
|
|
334
|
+
return cloned;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function createTaskOperation(command: TaskCommand, at: string): TaskOperation {
|
|
338
|
+
if (command.action !== "create" && command.action !== "update" && command.action !== "clear") {
|
|
339
|
+
throw new TaskListError("invalid_argument", "只有 create、update、clear 可以记录为任务 operation");
|
|
340
|
+
}
|
|
341
|
+
return { version: 1, command: cloneTaskCommand(command), at: sanitizeTaskText(at).slice(0, 64) };
|
|
342
|
+
}
|
|
343
|
+
|
|
225
344
|
export function createEmptyState(now = new Date().toISOString()): TaskState {
|
|
226
345
|
return {
|
|
227
346
|
version: TASK_LIST_VERSION,
|
|
@@ -236,32 +355,48 @@ export function createEmptyState(now = new Date().toISOString()): TaskState {
|
|
|
236
355
|
export function normalizeState(value: unknown): TaskState {
|
|
237
356
|
if (!isRecord(value)) return createEmptyState();
|
|
238
357
|
|
|
239
|
-
const rawTasks = Array.isArray(value.tasks) ? value.tasks : [];
|
|
358
|
+
const rawTasks = Array.isArray(value.tasks) ? value.tasks.slice(0, MAX_TASKS) : [];
|
|
240
359
|
const tasks: Task[] = [];
|
|
241
360
|
const seenIds = new Set<number>();
|
|
361
|
+
const reservedExplicitIds = new Set(
|
|
362
|
+
rawTasks
|
|
363
|
+
.filter(isRecord)
|
|
364
|
+
.map((rawTask) => positiveInteger(rawTask.id))
|
|
365
|
+
.filter((id): id is number => id !== undefined),
|
|
366
|
+
);
|
|
367
|
+
const reservedDependencyIds = new Set(
|
|
368
|
+
rawTasks.flatMap((rawTask) => (isRecord(rawTask) ? safeIdList(rawTask.blockedBy) : [])),
|
|
369
|
+
);
|
|
370
|
+
const reservedReferenceIds = new Set([...reservedExplicitIds, ...reservedDependencyIds]);
|
|
242
371
|
let fallbackId = 1;
|
|
243
372
|
|
|
373
|
+
const nextAvailableId = (): number => {
|
|
374
|
+
const reserved = new Set([...reservedReferenceIds, ...seenIds]);
|
|
375
|
+
const id = findAvailableTaskId(fallbackId, reserved);
|
|
376
|
+
fallbackId = nextTaskId(id);
|
|
377
|
+
return id;
|
|
378
|
+
};
|
|
379
|
+
|
|
244
380
|
for (const rawTask of rawTasks) {
|
|
245
381
|
if (!isRecord(rawTask)) continue;
|
|
246
382
|
const rawId = positiveInteger(rawTask.id);
|
|
247
|
-
const id = rawId
|
|
248
|
-
fallbackId = Math.max(fallbackId, id + 1);
|
|
249
|
-
if (seenIds.has(id)) continue;
|
|
383
|
+
const id = rawId !== undefined && !seenIds.has(rawId) ? rawId : nextAvailableId();
|
|
250
384
|
seenIds.add(id);
|
|
385
|
+
if (id < MAX_TASK_ID) fallbackId = Math.max(fallbackId, id + 1);
|
|
251
386
|
|
|
252
387
|
const subject =
|
|
253
|
-
typeof rawTask.subject === "string" && rawTask.subject
|
|
254
|
-
? rawTask.subject
|
|
388
|
+
typeof rawTask.subject === "string" && sanitizeTaskText(rawTask.subject)
|
|
389
|
+
? sanitizeTaskText(rawTask.subject).slice(0, MAX_SUBJECT_LENGTH)
|
|
255
390
|
: `Task #${id}`;
|
|
256
391
|
const status = isTaskStatus(rawTask.status) ? rawTask.status : "pending";
|
|
257
392
|
const priority = isTaskPriority(rawTask.priority) ? rawTask.priority : "medium";
|
|
258
|
-
const description = typeof rawTask.description === "string" ? rawTask.description
|
|
393
|
+
const description = typeof rawTask.description === "string" ? sanitizeTaskText(rawTask.description).slice(0, MAX_FIELD_LENGTH) : "";
|
|
259
394
|
const activeForm =
|
|
260
|
-
typeof rawTask.activeForm === "string" && rawTask.activeForm
|
|
261
|
-
? rawTask.activeForm
|
|
395
|
+
typeof rawTask.activeForm === "string" && sanitizeTaskText(rawTask.activeForm)
|
|
396
|
+
? sanitizeTaskText(rawTask.activeForm).slice(0, MAX_SUBJECT_LENGTH)
|
|
262
397
|
: subject;
|
|
263
398
|
const blockedBy = safeIdList(rawTask.blockedBy);
|
|
264
|
-
const blockReason = typeof rawTask.blockReason === "string" ? rawTask.blockReason
|
|
399
|
+
const blockReason = typeof rawTask.blockReason === "string" ? sanitizeTaskText(rawTask.blockReason).slice(0, MAX_FIELD_LENGTH) : "";
|
|
265
400
|
const inferredBlockSource: TaskBlockSource =
|
|
266
401
|
status !== "blocked"
|
|
267
402
|
? ""
|
|
@@ -283,26 +418,46 @@ export function normalizeState(value: unknown): TaskState {
|
|
|
283
418
|
blockReason,
|
|
284
419
|
blockSource: status === "blocked" ? blockSource : "",
|
|
285
420
|
tags: safeStringList(rawTask.tags),
|
|
286
|
-
owner: typeof rawTask.owner === "string" ? rawTask.owner
|
|
421
|
+
owner: typeof rawTask.owner === "string" ? sanitizeTaskText(rawTask.owner).slice(0, 200) : "",
|
|
287
422
|
completionNote:
|
|
288
|
-
typeof rawTask.completionNote === "string" ? rawTask.completionNote
|
|
423
|
+
typeof rawTask.completionNote === "string" ? sanitizeTaskText(rawTask.completionNote).slice(0, MAX_FIELD_LENGTH) : "",
|
|
289
424
|
notes: safeNotes(rawTask.notes),
|
|
290
|
-
createdAt: typeof rawTask.createdAt === "string" ? rawTask.createdAt : "",
|
|
291
|
-
updatedAt: typeof rawTask.updatedAt === "string" ? rawTask.updatedAt : "",
|
|
425
|
+
createdAt: typeof rawTask.createdAt === "string" ? sanitizeTaskText(rawTask.createdAt).slice(0, 64) : "",
|
|
426
|
+
updatedAt: typeof rawTask.updatedAt === "string" ? sanitizeTaskText(rawTask.updatedAt).slice(0, 64) : "",
|
|
292
427
|
});
|
|
293
428
|
}
|
|
294
429
|
|
|
295
430
|
const rawNextId = positiveInteger(value.nextId) ?? 1;
|
|
296
431
|
const rawRevision = typeof value.revision === "number" && Number.isInteger(value.revision) && value.revision >= 0 ? value.revision : 0;
|
|
297
|
-
const updatedAt =
|
|
432
|
+
const updatedAt =
|
|
433
|
+
typeof value.updatedAt === "string" && sanitizeTaskText(value.updatedAt)
|
|
434
|
+
? sanitizeTaskText(value.updatedAt).slice(0, 64)
|
|
435
|
+
: new Date().toISOString();
|
|
298
436
|
|
|
299
437
|
const state: TaskState = {
|
|
300
438
|
version: TASK_LIST_VERSION,
|
|
301
439
|
revision: rawRevision,
|
|
302
|
-
nextId: Math.max(rawNextId, fallbackId,
|
|
440
|
+
nextId: findAvailableTaskId(Math.max(rawNextId, fallbackId), reservedTaskIds(tasks)),
|
|
303
441
|
updatedAt,
|
|
304
442
|
tasks,
|
|
305
443
|
};
|
|
444
|
+
|
|
445
|
+
// Repair impossible persisted states before exposing them to the tool or UI.
|
|
446
|
+
for (const task of state.tasks) {
|
|
447
|
+
const unresolved = unresolvedDependencies(state, task);
|
|
448
|
+
if (task.status === "completed" && unresolved.length > 0) {
|
|
449
|
+
task.status = "blocked";
|
|
450
|
+
task.blockReason = dependencyReason(unresolved);
|
|
451
|
+
task.blockSource = "dependency";
|
|
452
|
+
} else if (task.status === "completed" && task.evidence.length < task.acceptanceCriteria.length) {
|
|
453
|
+
task.status = "pending";
|
|
454
|
+
task.blockReason = "";
|
|
455
|
+
task.blockSource = "";
|
|
456
|
+
} else if (task.status === "blocked" && !task.blockReason && unresolved.length === 0) {
|
|
457
|
+
task.status = "pending";
|
|
458
|
+
task.blockSource = "";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
306
461
|
reconcileDependencyBlocks(state, updatedAt, false);
|
|
307
462
|
return state;
|
|
308
463
|
}
|
|
@@ -334,33 +489,34 @@ function validateReferences(state: TaskState, ids: number[], selfId?: number): v
|
|
|
334
489
|
function validateNoCycle(state: TaskState, taskId: number, dependencies: number[]): void {
|
|
335
490
|
const graph = new Map(state.tasks.map((task) => [task.id, task.blockedBy]));
|
|
336
491
|
graph.set(taskId, dependencies);
|
|
492
|
+
const visited = new Set<number>();
|
|
493
|
+
const stack = [...dependencies];
|
|
337
494
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (
|
|
341
|
-
visited.add(current);
|
|
342
|
-
for (const dependency of graph.get(current) ?? []) {
|
|
343
|
-
if (reachesTask(dependency, new Set(visited))) return true;
|
|
344
|
-
}
|
|
345
|
-
return false;
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
for (const dependency of dependencies) {
|
|
349
|
-
if (reachesTask(dependency, new Set())) {
|
|
495
|
+
while (stack.length > 0) {
|
|
496
|
+
const current = stack.pop()!;
|
|
497
|
+
if (current === taskId) {
|
|
350
498
|
throw new TaskListError("dependency_cycle", `任务 #${taskId} 的依赖会形成循环`);
|
|
351
499
|
}
|
|
500
|
+
if (visited.has(current)) continue;
|
|
501
|
+
visited.add(current);
|
|
502
|
+
stack.push(...(graph.get(current) ?? []));
|
|
352
503
|
}
|
|
353
504
|
}
|
|
354
505
|
|
|
355
|
-
|
|
356
|
-
|
|
506
|
+
interface DependencyReference {
|
|
507
|
+
id: number;
|
|
508
|
+
status: TaskStatus;
|
|
509
|
+
missing?: boolean;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function unresolvedDependencies(state: TaskState, task: Task): DependencyReference[] {
|
|
357
513
|
return task.blockedBy
|
|
358
|
-
.map((id) => state.tasks.find((candidate) => candidate.id === id))
|
|
359
|
-
.filter((candidate)
|
|
514
|
+
.map((id) => state.tasks.find((candidate) => candidate.id === id) ?? { id, status: "blocked" as const, missing: true })
|
|
515
|
+
.filter((candidate) => candidate.status !== "completed");
|
|
360
516
|
}
|
|
361
517
|
|
|
362
|
-
function dependencyReason(tasks:
|
|
363
|
-
return `等待任务 ${tasks.map((task) => `#${task.id}`).join(", ")} 完成`;
|
|
518
|
+
function dependencyReason(tasks: DependencyReference[]): string {
|
|
519
|
+
return `等待任务 ${tasks.map((task) => `#${task.id}${task.missing ? "(不存在)" : ""}`).join(", ")} 完成`;
|
|
364
520
|
}
|
|
365
521
|
|
|
366
522
|
function normalizeStatus(value: unknown): TaskStatus {
|
|
@@ -382,12 +538,23 @@ function appendNote(task: Task, text: string, at: string): void {
|
|
|
382
538
|
if (task.notes.length > MAX_NOTES) task.notes = task.notes.slice(-MAX_NOTES);
|
|
383
539
|
}
|
|
384
540
|
|
|
541
|
+
function appendUniqueBounded<T>(target: T[], additions: T[], field: string): void {
|
|
542
|
+
const uniqueAdditions = additions.filter((item) => !target.includes(item));
|
|
543
|
+
if (target.length + uniqueAdditions.length > MAX_LIST_ITEMS) {
|
|
544
|
+
throw new TaskListError("invalid_argument", `${field} 最多包含 ${MAX_LIST_ITEMS} 项`);
|
|
545
|
+
}
|
|
546
|
+
target.push(...uniqueAdditions);
|
|
547
|
+
}
|
|
548
|
+
|
|
385
549
|
function touch(state: TaskState, now: string): void {
|
|
386
550
|
state.revision += 1;
|
|
387
551
|
state.updatedAt = now;
|
|
388
552
|
}
|
|
389
553
|
|
|
390
554
|
function makeTask(state: TaskState, params: TaskCommand, now: string): Task {
|
|
555
|
+
if (state.tasks.length >= MAX_TASKS) {
|
|
556
|
+
throw new TaskListError("task_limit", `任务数量已达到上限 ${MAX_TASKS},请先清理已完成任务`);
|
|
557
|
+
}
|
|
391
558
|
const subject = requiredText(params.subject, "subject", MAX_SUBJECT_LENGTH);
|
|
392
559
|
const requestedStatus = params.status === undefined ? "pending" : normalizeStatus(params.status);
|
|
393
560
|
const priority = params.priority === undefined ? "medium" : normalizePriority(params.priority);
|
|
@@ -398,12 +565,15 @@ function makeTask(state: TaskState, params: TaskCommand, now: string): Task {
|
|
|
398
565
|
throw new TaskListError("invalid_transition", "新任务只能从 pending、in_progress 或 blocked 开始");
|
|
399
566
|
}
|
|
400
567
|
|
|
568
|
+
const id = allocateTaskId(state);
|
|
569
|
+
validateNoCycle(state, id, blockedBy);
|
|
570
|
+
|
|
401
571
|
const task: Task = {
|
|
402
|
-
id
|
|
572
|
+
id,
|
|
403
573
|
subject,
|
|
404
574
|
description: params.description === undefined ? "" : boundedText(params.description, "description"),
|
|
405
575
|
activeForm:
|
|
406
|
-
params.activeForm === undefined || !params.activeForm.trim()
|
|
576
|
+
params.activeForm === undefined || (typeof params.activeForm === "string" && !params.activeForm.trim())
|
|
407
577
|
? subject
|
|
408
578
|
: boundedText(params.activeForm, "activeForm", MAX_SUBJECT_LENGTH),
|
|
409
579
|
acceptanceCriteria: params.acceptanceCriteria === undefined ? [] : stringList(params.acceptanceCriteria, "acceptanceCriteria"),
|
|
@@ -467,16 +637,12 @@ function updateTask(state: TaskState, params: TaskCommand, now: string): { task:
|
|
|
467
637
|
}
|
|
468
638
|
if (params.evidence !== undefined) candidate.evidence = stringList(params.evidence, "evidence");
|
|
469
639
|
if (params.addEvidence !== undefined) {
|
|
470
|
-
|
|
471
|
-
if (!candidate.evidence.includes(item)) candidate.evidence.push(item);
|
|
472
|
-
}
|
|
640
|
+
appendUniqueBounded(candidate.evidence, stringList(params.addEvidence, "addEvidence"), "evidence");
|
|
473
641
|
}
|
|
474
642
|
if (params.priority !== undefined) candidate.priority = normalizePriority(params.priority);
|
|
475
643
|
if (params.blockedBy !== undefined) candidate.blockedBy = idList(params.blockedBy, "blockedBy");
|
|
476
644
|
if (params.addBlockedBy !== undefined) {
|
|
477
|
-
|
|
478
|
-
if (!candidate.blockedBy.includes(id)) candidate.blockedBy.push(id);
|
|
479
|
-
}
|
|
645
|
+
appendUniqueBounded(candidate.blockedBy, idList(params.addBlockedBy, "addBlockedBy"), "blockedBy");
|
|
480
646
|
}
|
|
481
647
|
if (params.removeBlockedBy !== undefined) {
|
|
482
648
|
const remove = new Set(idList(params.removeBlockedBy, "removeBlockedBy"));
|
|
@@ -488,9 +654,7 @@ function updateTask(state: TaskState, params: TaskCommand, now: string): { task:
|
|
|
488
654
|
}
|
|
489
655
|
if (params.tags !== undefined) candidate.tags = stringList(params.tags, "tags");
|
|
490
656
|
if (params.addTags !== undefined) {
|
|
491
|
-
|
|
492
|
-
if (!candidate.tags.includes(tag)) candidate.tags.push(tag);
|
|
493
|
-
}
|
|
657
|
+
appendUniqueBounded(candidate.tags, stringList(params.addTags, "addTags"), "tags");
|
|
494
658
|
}
|
|
495
659
|
if (params.removeTags !== undefined) {
|
|
496
660
|
const remove = new Set(stringList(params.removeTags, "removeTags"));
|
|
@@ -561,49 +725,66 @@ function updateTask(state: TaskState, params: TaskCommand, now: string): { task:
|
|
|
561
725
|
}
|
|
562
726
|
|
|
563
727
|
function reconcileDependencyBlocks(state: TaskState, now: string, recordNotes = true): number[] {
|
|
564
|
-
const changedIds
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
728
|
+
const changedIds = new Set<number>();
|
|
729
|
+
let passChanged = true;
|
|
730
|
+
|
|
731
|
+
// Reconcile to a fixed point so a reopened dependency cascades through the whole graph.
|
|
732
|
+
while (passChanged) {
|
|
733
|
+
passChanged = false;
|
|
734
|
+
for (const task of state.tasks) {
|
|
735
|
+
if (task.status === "cancelled" || task.blockSource === "manual") continue;
|
|
736
|
+
const before = cloneTask(task);
|
|
737
|
+
const unresolved = unresolvedDependencies(state, task);
|
|
738
|
+
|
|
739
|
+
if (unresolved.length > 0) {
|
|
740
|
+
if (task.status === "pending" || task.status === "in_progress" || task.status === "completed" || task.status === "blocked") {
|
|
741
|
+
task.status = "blocked";
|
|
742
|
+
task.blockReason = dependencyReason(unresolved);
|
|
743
|
+
task.blockSource = "dependency";
|
|
744
|
+
}
|
|
745
|
+
} else if (task.status === "blocked" && task.blockSource === "dependency") {
|
|
746
|
+
task.status = "pending";
|
|
747
|
+
task.blockReason = "";
|
|
748
|
+
task.blockSource = "";
|
|
576
749
|
}
|
|
577
|
-
} else if (task.status === "blocked" && task.blockSource === "dependency") {
|
|
578
|
-
task.status = "pending";
|
|
579
|
-
task.blockReason = "";
|
|
580
|
-
task.blockSource = "";
|
|
581
|
-
}
|
|
582
750
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
751
|
+
if (JSON.stringify(task) === JSON.stringify(before)) continue;
|
|
752
|
+
if (recordNotes && task.status !== before.status) {
|
|
753
|
+
const note = task.status === "blocked" ? `依赖未完成,自动标记为 blocked:${task.blockReason}` : "依赖已完成,自动恢复为 pending";
|
|
754
|
+
appendNote(task, note, now);
|
|
755
|
+
}
|
|
756
|
+
task.updatedAt = now;
|
|
757
|
+
changedIds.add(task.id);
|
|
758
|
+
passChanged = true;
|
|
587
759
|
}
|
|
588
|
-
task.updatedAt = now;
|
|
589
|
-
changedIds.push(task.id);
|
|
590
760
|
}
|
|
591
761
|
|
|
592
|
-
return changedIds;
|
|
762
|
+
return [...changedIds];
|
|
593
763
|
}
|
|
594
764
|
|
|
595
765
|
export function filterTasks(state: TaskState, params: Pick<TaskCommand, "status" | "priority" | "query" | "includeCancelled"> = {}): Task[] {
|
|
596
766
|
const status = params.status === undefined ? undefined : normalizeStatus(params.status);
|
|
597
767
|
const priority = params.priority === undefined ? undefined : normalizePriority(params.priority);
|
|
598
|
-
const query = params.query
|
|
768
|
+
const query = params.query === undefined ? undefined : boundedText(params.query, "query", MAX_FIELD_LENGTH).toLowerCase();
|
|
599
769
|
|
|
600
770
|
return state.tasks
|
|
601
|
-
.filter((task) => params.includeCancelled === true || task.status !== "cancelled")
|
|
771
|
+
.filter((task) => params.includeCancelled === true || status === "cancelled" || task.status !== "cancelled")
|
|
602
772
|
.filter((task) => status === undefined || task.status === status)
|
|
603
773
|
.filter((task) => priority === undefined || task.priority === priority)
|
|
604
774
|
.filter((task) => {
|
|
605
775
|
if (!query) return true;
|
|
606
|
-
return [
|
|
776
|
+
return [
|
|
777
|
+
task.subject,
|
|
778
|
+
task.description,
|
|
779
|
+
task.activeForm,
|
|
780
|
+
task.blockReason,
|
|
781
|
+
task.owner,
|
|
782
|
+
task.completionNote,
|
|
783
|
+
...task.tags,
|
|
784
|
+
...task.acceptanceCriteria,
|
|
785
|
+
...task.evidence,
|
|
786
|
+
...task.notes.map((note) => note.text),
|
|
787
|
+
].some((value) => value.toLowerCase().includes(query));
|
|
607
788
|
})
|
|
608
789
|
.sort(compareTasks)
|
|
609
790
|
.map(cloneTask);
|
|
@@ -719,17 +900,31 @@ export function applyTaskAction(current: TaskState, params: TaskCommand, now = n
|
|
|
719
900
|
};
|
|
720
901
|
}
|
|
721
902
|
const removedIds = new Set(removed.map((task) => task.id));
|
|
903
|
+
const completedIds = new Set(removed.filter((task) => task.status === "completed").map((task) => task.id));
|
|
722
904
|
state.tasks = scope === "all" ? [] : state.tasks.filter((task) => !removedIds.has(task.id));
|
|
905
|
+
|
|
906
|
+
const referenceChanges = new Set<number>();
|
|
723
907
|
for (const task of state.tasks) {
|
|
724
|
-
|
|
908
|
+
const removedCompletedDependencies = task.blockedBy.filter((id) => completedIds.has(id));
|
|
909
|
+
if (removedCompletedDependencies.length === 0) continue;
|
|
910
|
+
task.blockedBy = task.blockedBy.filter((id) => !completedIds.has(id));
|
|
911
|
+
task.updatedAt = now;
|
|
912
|
+
appendNote(task, `已清理完成的依赖任务 ${removedCompletedDependencies.map((id) => `#${id}`).join(", ")},移除依赖引用`, now);
|
|
913
|
+
referenceChanges.add(task.id);
|
|
725
914
|
}
|
|
915
|
+
|
|
916
|
+
// References to cancelled tasks remain dangling on purpose: cancellation is not completion.
|
|
917
|
+
// This keeps downstream work blocked and reserves the removed ID against accidental reuse.
|
|
918
|
+
const dependencyChanges = reconcileDependencyBlocks(state, now);
|
|
919
|
+
const affectedIds = new Set([...referenceChanges, ...dependencyChanges]);
|
|
920
|
+
const changedIds = [...removed.map((task) => task.id), ...affectedIds];
|
|
726
921
|
touch(state, now);
|
|
727
922
|
return {
|
|
728
923
|
action: "clear",
|
|
729
924
|
state,
|
|
730
|
-
changedIds
|
|
731
|
-
visibleTasks: state.tasks.map(cloneTask),
|
|
732
|
-
message: `已清理 ${removed.length} 个任务(范围:${scope}
|
|
925
|
+
changedIds,
|
|
926
|
+
visibleTasks: state.tasks.filter((task) => affectedIds.has(task.id)).map(cloneTask),
|
|
927
|
+
message: `已清理 ${removed.length} 个任务(范围:${scope})${affectedIds.size > 0 ? `;同步更新 ${affectedIds.size} 个依赖任务` : ""}`,
|
|
733
928
|
mutated: true,
|
|
734
929
|
};
|
|
735
930
|
}
|
|
@@ -753,9 +948,9 @@ export function priorityLabel(priority: TaskPriority): string {
|
|
|
753
948
|
return { high: "高", medium: "中", low: "低" }[priority];
|
|
754
949
|
}
|
|
755
950
|
|
|
756
|
-
export function summarizeState(state: TaskState
|
|
757
|
-
const summary = { total: 0, active: 0, blocked: 0, pending: 0, completed: 0, cancelled: 0, ready: 0 };
|
|
758
|
-
const statusById = new Map(
|
|
951
|
+
export function summarizeState(state: TaskState, dependencyState: TaskState = state): TaskSummary {
|
|
952
|
+
const summary: TaskSummary = { total: 0, active: 0, blocked: 0, pending: 0, completed: 0, cancelled: 0, ready: 0 };
|
|
953
|
+
const statusById = new Map(dependencyState.tasks.map((task) => [task.id, task.status]));
|
|
759
954
|
|
|
760
955
|
for (const task of state.tasks) {
|
|
761
956
|
if (task.status === "cancelled") {
|
|
@@ -768,10 +963,7 @@ export function summarizeState(state: TaskState): { total: number; active: numbe
|
|
|
768
963
|
if (task.status === "completed") summary.completed++;
|
|
769
964
|
if (task.status === "pending") {
|
|
770
965
|
summary.pending++;
|
|
771
|
-
const dependenciesResolved = task.blockedBy.every((id) =>
|
|
772
|
-
const status = statusById.get(id);
|
|
773
|
-
return status === undefined || status === "completed" || status === "cancelled";
|
|
774
|
-
});
|
|
966
|
+
const dependenciesResolved = task.blockedBy.every((id) => statusById.get(id) === "completed");
|
|
775
967
|
if (dependenciesResolved) summary.ready++;
|
|
776
968
|
}
|
|
777
969
|
}
|