pi-quests 0.6.2 → 0.8.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 +11 -0
- package/README.md +1 -1
- package/package.json +18 -13
- package/src/commands/handler.ts +13 -9
- package/src/commands/parse-args.ts +26 -14
- package/src/config.ts +2 -62
- package/src/index.ts +4 -42
- package/src/prompts/skill.md +4 -2
- package/src/quest/dataplane.ts +442 -115
- package/src/quest/formatters.ts +16 -2
- package/src/quest/tracker.ts +26 -152
- package/src/quest/types.ts +4 -2
- package/src/renderers/changelog.ts +4 -4
- package/src/renderers/commands.ts +2 -2
- package/src/renderers/quests.ts +2 -2
- package/src/renderers/status.ts +1 -1
- package/src/renderers/tools.ts +31 -10
- package/src/tools/handler.ts +75 -65
- package/src/tools/params.ts +9 -3
package/src/quest/dataplane.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { AgentToolResult, ExtensionContext } from "@
|
|
1
|
+
import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DEFAULT_CONFIG, type ResolvedConfig } from "../config.js";
|
|
3
3
|
import { logger } from "../logger.js";
|
|
4
4
|
import { getQuestSkillDocument } from "../prompts.js";
|
|
5
5
|
import {
|
|
6
6
|
formatAddResult,
|
|
7
7
|
formatBatchAddResult,
|
|
8
|
+
formatBatchDeleteResult,
|
|
9
|
+
formatBatchToggleResult,
|
|
8
10
|
formatBlockedBySteps,
|
|
9
11
|
formatDeleteResult,
|
|
10
12
|
formatDescriptionRequiredError,
|
|
@@ -12,7 +14,8 @@ import {
|
|
|
12
14
|
formatIdRequiredError,
|
|
13
15
|
formatMissingDescriptionsError,
|
|
14
16
|
formatNotFound,
|
|
15
|
-
|
|
17
|
+
formatNothingToRedoError,
|
|
18
|
+
formatNothingToUndoError,
|
|
16
19
|
formatParentDoneError,
|
|
17
20
|
formatParentNotFoundError,
|
|
18
21
|
formatQuestList,
|
|
@@ -32,17 +35,17 @@ import {
|
|
|
32
35
|
} from "./formatters.js";
|
|
33
36
|
import { QUEST_ACTIONS, type Quest, type Step } from "./types.js";
|
|
34
37
|
|
|
38
|
+
type DeletedQuestSnapshot = {
|
|
39
|
+
deletedIds: string[];
|
|
40
|
+
previousQuests: Quest[];
|
|
41
|
+
previousSteps: Step[];
|
|
42
|
+
};
|
|
43
|
+
|
|
35
44
|
export type HistoryEntry =
|
|
36
45
|
| { type: typeof QUEST_ACTIONS.add; id: string; parentId?: string }
|
|
37
|
-
| { type: typeof QUEST_ACTIONS.toggle;
|
|
46
|
+
| { type: typeof QUEST_ACTIONS.toggle; ids: string[] }
|
|
38
47
|
| { type: typeof QUEST_ACTIONS.update; id: string; previousDescription: string }
|
|
39
|
-
| {
|
|
40
|
-
type: typeof QUEST_ACTIONS.delete;
|
|
41
|
-
quest: Quest | Step;
|
|
42
|
-
index: number;
|
|
43
|
-
isStep?: boolean;
|
|
44
|
-
cascadeDeletedSteps?: Step[];
|
|
45
|
-
}
|
|
48
|
+
| ({ type: typeof QUEST_ACTIONS.delete; ids: string[] } & DeletedQuestSnapshot)
|
|
46
49
|
| {
|
|
47
50
|
type: typeof QUEST_ACTIONS.clear;
|
|
48
51
|
previousQuests: Quest[];
|
|
@@ -69,17 +72,40 @@ export type QuestAction =
|
|
|
69
72
|
| { type: typeof QUEST_ACTIONS.split; id?: string; descriptions?: string[] }
|
|
70
73
|
| { type: typeof QUEST_ACTIONS.add_step; id?: string; descriptions?: string[] }
|
|
71
74
|
| { type: typeof QUEST_ACTIONS.list }
|
|
72
|
-
| { type: typeof QUEST_ACTIONS.toggle; id?: string }
|
|
75
|
+
| { type: typeof QUEST_ACTIONS.toggle; id?: string; ids?: string[] }
|
|
73
76
|
| { type: typeof QUEST_ACTIONS.update; id?: string; description?: string }
|
|
74
|
-
| { type: typeof QUEST_ACTIONS.delete; id?: string }
|
|
77
|
+
| { type: typeof QUEST_ACTIONS.delete; id?: string; ids?: string[] }
|
|
75
78
|
| { type: typeof QUEST_ACTIONS.clear; all?: boolean }
|
|
76
79
|
| { type: typeof QUEST_ACTIONS.reorder; id?: string; targetId?: string }
|
|
77
|
-
| { type: typeof QUEST_ACTIONS.
|
|
80
|
+
| { type: typeof QUEST_ACTIONS.undo }
|
|
81
|
+
| { type: typeof QUEST_ACTIONS.redo }
|
|
78
82
|
| { type: typeof QUEST_ACTIONS.reparent; id?: string; parentId?: string }
|
|
79
83
|
| { type: typeof QUEST_ACTIONS.rules }
|
|
80
84
|
| { type: typeof QUEST_ACTIONS.skill };
|
|
81
85
|
|
|
82
|
-
export type
|
|
86
|
+
export type RedoEntry =
|
|
87
|
+
| { type: typeof QUEST_ACTIONS.add; quest: Quest | Step }
|
|
88
|
+
| { type: typeof QUEST_ACTIONS.toggle; ids: string[] }
|
|
89
|
+
| { type: typeof QUEST_ACTIONS.update; id: string; description: string }
|
|
90
|
+
| { type: typeof QUEST_ACTIONS.delete; ids: string[] }
|
|
91
|
+
| { type: typeof QUEST_ACTIONS.clear; all: boolean }
|
|
92
|
+
| { type: typeof QUEST_ACTIONS.reorder; id: string; targetId: string }
|
|
93
|
+
| { type: typeof QUEST_ACTIONS.reparent; id: string; parentId?: string };
|
|
94
|
+
|
|
95
|
+
export type QuestOperationResult = {
|
|
96
|
+
success: boolean;
|
|
97
|
+
message: string;
|
|
98
|
+
quest?: Quest;
|
|
99
|
+
quests?: Quest[];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type ToggleBatchResult =
|
|
103
|
+
| { quests: (Quest | Step)[]; ids: string[] }
|
|
104
|
+
| { error: "notFound" | "blocked"; id: string };
|
|
105
|
+
|
|
106
|
+
type DeleteBatchResult =
|
|
107
|
+
| { quests: (Quest | Step)[]; ids: string[] }
|
|
108
|
+
| { error: "notFound" | "blocked"; id: string };
|
|
83
109
|
|
|
84
110
|
/**
|
|
85
111
|
* In-memory quest data plane.
|
|
@@ -92,6 +118,8 @@ export class QuestLog {
|
|
|
92
118
|
private steps: Step[] = [];
|
|
93
119
|
private usedIds: Set<string> = new Set();
|
|
94
120
|
private history: HistoryEntry[] = [];
|
|
121
|
+
private redoStack: RedoEntry[] = [];
|
|
122
|
+
private inRedo = false;
|
|
95
123
|
|
|
96
124
|
private readonly ID_LENGTH: number;
|
|
97
125
|
private readonly MAX_IDS: number;
|
|
@@ -136,16 +164,30 @@ export class QuestLog {
|
|
|
136
164
|
return { success: true, message: `Reverted add quest [${entry.id}]` };
|
|
137
165
|
},
|
|
138
166
|
[QUEST_ACTIONS.toggle]: (entry) => {
|
|
139
|
-
const
|
|
167
|
+
const quests = entry.ids.map((id) => this.findById(id));
|
|
168
|
+
const missingId = entry.ids.find((_, index) => !quests[index]);
|
|
140
169
|
|
|
141
|
-
if (
|
|
170
|
+
if (missingId) {
|
|
171
|
+
logger.debug("quests:state", "revert-toggle-not-found", { ids: entry.ids, missingId });
|
|
172
|
+
return { success: false, message: formatNotFound(missingId) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const resolvedQuests = quests.filter((quest): quest is Quest | Step => quest !== undefined);
|
|
176
|
+
for (const quest of resolvedQuests) {
|
|
142
177
|
quest.done = !quest.done;
|
|
143
|
-
logger.debug("quests:state", "revert-toggle", { id: entry.id, done: quest.done });
|
|
144
|
-
return { success: true, message: `Reverted toggle for quest [${entry.id}]` };
|
|
145
178
|
}
|
|
146
179
|
|
|
147
|
-
logger.debug("quests:state", "revert-toggle
|
|
148
|
-
|
|
180
|
+
logger.debug("quests:state", "revert-toggle", {
|
|
181
|
+
ids: entry.ids,
|
|
182
|
+
done: resolvedQuests.map((quest) => quest.done),
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
success: true,
|
|
186
|
+
message:
|
|
187
|
+
entry.ids.length === 1
|
|
188
|
+
? `Reverted toggle for quest [${entry.ids[0]}]`
|
|
189
|
+
: `Reverted toggle for ${entry.ids.length} quests`,
|
|
190
|
+
};
|
|
149
191
|
},
|
|
150
192
|
[QUEST_ACTIONS.update]: (entry) => {
|
|
151
193
|
const quest = this.findById(entry.id);
|
|
@@ -159,25 +201,21 @@ export class QuestLog {
|
|
|
159
201
|
return { success: false, message: formatNotFound(entry.id) };
|
|
160
202
|
},
|
|
161
203
|
[QUEST_ACTIONS.delete]: (entry) => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
this.quests.splice(entry.index, 0, entry.quest);
|
|
166
|
-
if (entry.cascadeDeletedSteps) {
|
|
167
|
-
for (const sub of entry.cascadeDeletedSteps) {
|
|
168
|
-
this.steps.push(sub);
|
|
169
|
-
this.usedIds.add(sub.id);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
this.usedIds.add(entry.quest.id);
|
|
204
|
+
this.quests = [...entry.previousQuests];
|
|
205
|
+
this.steps = [...entry.previousSteps];
|
|
206
|
+
this.usedIds = new Set([...this.quests, ...this.steps].map((quest) => quest.id));
|
|
174
207
|
|
|
175
208
|
logger.debug("quests:state", "revert-delete", {
|
|
176
|
-
|
|
177
|
-
index: entry.index,
|
|
209
|
+
ids: entry.deletedIds,
|
|
178
210
|
total: this.quests.length + this.steps.length,
|
|
179
211
|
});
|
|
180
|
-
return {
|
|
212
|
+
return {
|
|
213
|
+
success: true,
|
|
214
|
+
message:
|
|
215
|
+
entry.ids.length === 1
|
|
216
|
+
? `Reverted delete for quest [${entry.ids[0]}]`
|
|
217
|
+
: `Reverted delete for ${entry.ids.length} tasks`,
|
|
218
|
+
};
|
|
181
219
|
},
|
|
182
220
|
[QUEST_ACTIONS.clear]: (entry) => {
|
|
183
221
|
if ("previousQuests" in entry) {
|
|
@@ -230,6 +268,114 @@ export class QuestLog {
|
|
|
230
268
|
},
|
|
231
269
|
};
|
|
232
270
|
|
|
271
|
+
private redoHandlers: {
|
|
272
|
+
[K in RedoEntry["type"]]: (entry: Extract<RedoEntry, { type: K }>) => {
|
|
273
|
+
success: boolean;
|
|
274
|
+
message: string;
|
|
275
|
+
};
|
|
276
|
+
} = {
|
|
277
|
+
[QUEST_ACTIONS.add]: (entry) => {
|
|
278
|
+
if ("parentId" in entry.quest && entry.quest.parentId) {
|
|
279
|
+
this.steps.push(entry.quest as Step);
|
|
280
|
+
this.history.push({
|
|
281
|
+
type: QUEST_ACTIONS.add,
|
|
282
|
+
id: entry.quest.id,
|
|
283
|
+
parentId: entry.quest.parentId,
|
|
284
|
+
});
|
|
285
|
+
} else {
|
|
286
|
+
this.quests.push(entry.quest);
|
|
287
|
+
this.history.push({ type: QUEST_ACTIONS.add, id: entry.quest.id });
|
|
288
|
+
}
|
|
289
|
+
this.usedIds.add(entry.quest.id);
|
|
290
|
+
return { success: true, message: `Redone add quest [${entry.quest.id}]` };
|
|
291
|
+
},
|
|
292
|
+
[QUEST_ACTIONS.toggle]: (entry) => {
|
|
293
|
+
const result = this.toggleMany(entry.ids);
|
|
294
|
+
if ("error" in result) {
|
|
295
|
+
return {
|
|
296
|
+
success: false,
|
|
297
|
+
message:
|
|
298
|
+
result.error === "blocked"
|
|
299
|
+
? formatBlockedBySteps(result.id)
|
|
300
|
+
: formatNotFound(result.id),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
success: true,
|
|
306
|
+
message:
|
|
307
|
+
entry.ids.length === 1
|
|
308
|
+
? `Redone toggle for quest [${entry.ids[0]}]`
|
|
309
|
+
: `Redone toggle for ${entry.ids.length} quests`,
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
[QUEST_ACTIONS.update]: (entry) => {
|
|
313
|
+
const q = this.update(entry.id, entry.description);
|
|
314
|
+
if (q) return { success: true, message: `Redone update for quest [${entry.id}]` };
|
|
315
|
+
return { success: false, message: formatNotFound(entry.id) };
|
|
316
|
+
},
|
|
317
|
+
[QUEST_ACTIONS.delete]: (entry) => {
|
|
318
|
+
const result = this.deleteMany(entry.ids);
|
|
319
|
+
if ("error" in result) {
|
|
320
|
+
return {
|
|
321
|
+
success: false,
|
|
322
|
+
message:
|
|
323
|
+
result.error === "blocked"
|
|
324
|
+
? formatBlockedBySteps(result.id)
|
|
325
|
+
: formatNotFound(result.id),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const firstQuest = result.quests[0];
|
|
330
|
+
return {
|
|
331
|
+
success: true,
|
|
332
|
+
message:
|
|
333
|
+
entry.ids.length === 1 && firstQuest
|
|
334
|
+
? `Redone delete for quest [${firstQuest.id}]`
|
|
335
|
+
: `Redone delete for ${entry.ids.length} tasks`,
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
[QUEST_ACTIONS.clear]: (entry) => {
|
|
339
|
+
const count = this.clear(entry.all);
|
|
340
|
+
return {
|
|
341
|
+
success: true,
|
|
342
|
+
message: `Redone clear (${count} quests ${entry.all ? "" : "completed "}cleared)`,
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
[QUEST_ACTIONS.reorder]: (entry) => {
|
|
346
|
+
const q = this.reorder(entry.id, entry.targetId);
|
|
347
|
+
if (q) return { success: true, message: `Redone reorder for quest [${entry.id}]` };
|
|
348
|
+
return { success: false, message: formatReorderedQuestNotFoundError() };
|
|
349
|
+
},
|
|
350
|
+
[QUEST_ACTIONS.reparent]: (entry) => {
|
|
351
|
+
try {
|
|
352
|
+
const q = this.reparent(entry.id, entry.parentId);
|
|
353
|
+
if (q) return { success: true, message: `Redone reparent for quest [${entry.id}]` };
|
|
354
|
+
return { success: false, message: formatNotFound(entry.id) };
|
|
355
|
+
} catch (err) {
|
|
356
|
+
return {
|
|
357
|
+
success: false,
|
|
358
|
+
message: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
redo(): QuestOperationResult {
|
|
365
|
+
const entry = this.redoStack.pop();
|
|
366
|
+
if (!entry) {
|
|
367
|
+
return { success: false, message: formatNothingToRedoError() };
|
|
368
|
+
}
|
|
369
|
+
const handler = this.redoHandlers[entry.type];
|
|
370
|
+
if (handler) {
|
|
371
|
+
this.inRedo = true;
|
|
372
|
+
const result = handler(entry as never);
|
|
373
|
+
this.inRedo = false;
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
return { success: false, message: "Unknown redo entry" };
|
|
377
|
+
}
|
|
378
|
+
|
|
233
379
|
/*
|
|
234
380
|
* Returns all quests including steps, inserted in order
|
|
235
381
|
* steps are returned immediately after their parent quest
|
|
@@ -265,11 +411,73 @@ export class QuestLog {
|
|
|
265
411
|
return this.quests.find((q) => q.id === id) ?? this.steps.find((step) => step.id === id);
|
|
266
412
|
}
|
|
267
413
|
|
|
414
|
+
private normalizeIds(ids: string[]): string[] {
|
|
415
|
+
return [...new Set(ids)];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private validateToggleBatch(ids: string[]): ToggleBatchResult {
|
|
419
|
+
const normalizedIds = this.normalizeIds(ids);
|
|
420
|
+
const quests: (Quest | Step)[] = [];
|
|
421
|
+
|
|
422
|
+
for (const id of normalizedIds) {
|
|
423
|
+
const quest = this.findById(id);
|
|
424
|
+
if (!quest) {
|
|
425
|
+
logger.debug("quests:state", "toggle-not-found", { id });
|
|
426
|
+
return { error: "notFound", id };
|
|
427
|
+
}
|
|
428
|
+
quests.push(quest);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const toggledIds = new Set(normalizedIds);
|
|
432
|
+
for (const quest of quests) {
|
|
433
|
+
if (quest.done || "parentId" in quest) continue;
|
|
434
|
+
|
|
435
|
+
const steps = this.getSteps(quest.id);
|
|
436
|
+
const hasIncompleteSteps = steps.some((step) => {
|
|
437
|
+
const finalDone = toggledIds.has(step.id) ? !step.done : step.done;
|
|
438
|
+
return !finalDone;
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
if (hasIncompleteSteps) {
|
|
442
|
+
logger.debug("quests:state", "toggle-blocked-steps", { id: quest.id, ids: normalizedIds });
|
|
443
|
+
return { error: "blocked", id: quest.id };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return { quests, ids: normalizedIds };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private applyToggleBatch(quests: (Quest | Step)[], ids: string[]): Quest[] {
|
|
451
|
+
for (const quest of quests) {
|
|
452
|
+
quest.done = !quest.done;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
this.history.push({ type: QUEST_ACTIONS.toggle, ids });
|
|
456
|
+
logger.debug("quests:state", QUEST_ACTIONS.toggle, {
|
|
457
|
+
ids,
|
|
458
|
+
total: this.quests.length + this.steps.length,
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
return quests;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
toggleMany(ids: string[]): ToggleBatchResult {
|
|
465
|
+
if (!this.inRedo) this.redoStack = [];
|
|
466
|
+
const result = this.validateToggleBatch(ids);
|
|
467
|
+
if ("error" in result) return result;
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
ids: result.ids,
|
|
471
|
+
quests: this.applyToggleBatch(result.quests, result.ids),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
268
475
|
getUsedIds(): string[] {
|
|
269
476
|
return Array.from(this.usedIds);
|
|
270
477
|
}
|
|
271
478
|
|
|
272
479
|
add(description: string): Quest {
|
|
480
|
+
if (!this.inRedo) this.redoStack = [];
|
|
273
481
|
const quest: Quest = {
|
|
274
482
|
id: this.generateId(),
|
|
275
483
|
description,
|
|
@@ -289,6 +497,7 @@ export class QuestLog {
|
|
|
289
497
|
}
|
|
290
498
|
|
|
291
499
|
addStep(description: string, parentId: string): Step {
|
|
500
|
+
if (!this.inRedo) this.redoStack = [];
|
|
292
501
|
if (this.steps.some((step) => step.id === parentId)) {
|
|
293
502
|
throw new Error(formatStepCannotHaveSteps(parentId));
|
|
294
503
|
}
|
|
@@ -332,32 +541,16 @@ export class QuestLog {
|
|
|
332
541
|
}
|
|
333
542
|
|
|
334
543
|
toggle(id: string): Quest | Step | null | undefined {
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
return undefined;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if (!quest.done && !("parentId" in quest)) {
|
|
342
|
-
const steps = this.getSteps(id);
|
|
343
|
-
if (steps.some((q) => !q.done)) {
|
|
344
|
-
logger.debug("quests:state", "toggle-blocked-steps", { id });
|
|
345
|
-
return null;
|
|
346
|
-
}
|
|
544
|
+
const result = this.toggleMany([id]);
|
|
545
|
+
if ("error" in result) {
|
|
546
|
+
return result.error === "blocked" ? null : undefined;
|
|
347
547
|
}
|
|
348
548
|
|
|
349
|
-
|
|
350
|
-
this.history.push({ type: QUEST_ACTIONS.toggle, id });
|
|
351
|
-
|
|
352
|
-
logger.debug("quests:state", QUEST_ACTIONS.toggle, {
|
|
353
|
-
id,
|
|
354
|
-
done: quest.done,
|
|
355
|
-
total: this.quests.length + this.steps.length,
|
|
356
|
-
});
|
|
357
|
-
return quest;
|
|
549
|
+
return result.quests[0];
|
|
358
550
|
}
|
|
359
551
|
|
|
360
552
|
update(id: string, description: string): Quest | Step | undefined {
|
|
553
|
+
if (!this.inRedo) this.redoStack = [];
|
|
361
554
|
const quest = this.findById(id);
|
|
362
555
|
if (!quest) {
|
|
363
556
|
logger.debug("quests:state", "update-not-found", { id });
|
|
@@ -375,49 +568,91 @@ export class QuestLog {
|
|
|
375
568
|
return quest;
|
|
376
569
|
}
|
|
377
570
|
|
|
571
|
+
private validateDeleteBatch(
|
|
572
|
+
ids: string[],
|
|
573
|
+
): { ids: string[]; deletedIds: string[] } | { error: "notFound" | "blocked"; id: string } {
|
|
574
|
+
const normalizedIds = this.normalizeIds(ids);
|
|
575
|
+
const selectedIds = new Set(normalizedIds);
|
|
576
|
+
const deletedIds = new Set<string>();
|
|
577
|
+
|
|
578
|
+
for (const id of normalizedIds) {
|
|
579
|
+
const quest = this.findById(id);
|
|
580
|
+
if (!quest) {
|
|
581
|
+
logger.debug("quests:state", "delete-not-found", { id });
|
|
582
|
+
return { error: "notFound", id };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if ("parentId" in quest) {
|
|
586
|
+
deletedIds.add(quest.id);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const steps = this.getSteps(quest.id);
|
|
591
|
+
const hasUnselectedIncompleteStep = steps.some(
|
|
592
|
+
(step) => !step.done && !selectedIds.has(step.id),
|
|
593
|
+
);
|
|
594
|
+
if (hasUnselectedIncompleteStep) {
|
|
595
|
+
logger.debug("quests:state", "delete-blocked-steps", { id: quest.id, ids: normalizedIds });
|
|
596
|
+
return { error: "blocked", id: quest.id };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
deletedIds.add(quest.id);
|
|
600
|
+
for (const step of steps) {
|
|
601
|
+
deletedIds.add(step.id);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return { ids: normalizedIds, deletedIds: [...deletedIds] };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private applyDeleteBatch(ids: string[], deletedIds: string[]): Quest[] {
|
|
609
|
+
const deletedIdSet = new Set(deletedIds);
|
|
610
|
+
const previousQuests = [...this.quests];
|
|
611
|
+
const previousSteps = [...this.steps];
|
|
612
|
+
const deletedQuests = this.getAll().filter((quest) => deletedIdSet.has(quest.id));
|
|
613
|
+
|
|
614
|
+
this.quests = this.quests.filter((quest) => !deletedIdSet.has(quest.id));
|
|
615
|
+
this.steps = this.steps.filter((step) => !deletedIdSet.has(step.id));
|
|
616
|
+
this.usedIds = new Set([...this.quests, ...this.steps].map((quest) => quest.id));
|
|
617
|
+
this.history.push({
|
|
618
|
+
type: QUEST_ACTIONS.delete,
|
|
619
|
+
ids,
|
|
620
|
+
deletedIds,
|
|
621
|
+
previousQuests,
|
|
622
|
+
previousSteps,
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
logger.debug("quests:state", QUEST_ACTIONS.delete, {
|
|
626
|
+
ids,
|
|
627
|
+
deletedIds,
|
|
628
|
+
total: this.quests.length + this.steps.length,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
return deletedQuests;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
deleteMany(ids: string[]): DeleteBatchResult {
|
|
635
|
+
if (!this.inRedo) this.redoStack = [];
|
|
636
|
+
const result = this.validateDeleteBatch(ids);
|
|
637
|
+
if ("error" in result) return result;
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
ids: result.ids,
|
|
641
|
+
quests: this.applyDeleteBatch(result.ids, result.deletedIds),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
378
645
|
delete(id: string): Quest | Step | null | undefined {
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
if (steps.some((q) => !q.done)) {
|
|
383
|
-
logger.debug("quests:state", "delete-blocked-steps", { id });
|
|
384
|
-
return null;
|
|
385
|
-
}
|
|
386
|
-
const [quest] = this.quests.splice(index, 1);
|
|
387
|
-
this.usedIds.delete(quest.id);
|
|
388
|
-
const cascadeDeletedSteps = this.steps.filter((step) => step.parentId === id);
|
|
389
|
-
this.history.push({ type: QUEST_ACTIONS.delete, quest, index, cascadeDeletedSteps });
|
|
390
|
-
this.steps = this.steps.filter((step) => {
|
|
391
|
-
if (step.parentId === id) {
|
|
392
|
-
this.usedIds.delete(step.id);
|
|
393
|
-
return false;
|
|
394
|
-
}
|
|
395
|
-
return true;
|
|
396
|
-
});
|
|
397
|
-
logger.debug("quests:state", QUEST_ACTIONS.delete, {
|
|
398
|
-
id,
|
|
399
|
-
index,
|
|
400
|
-
total: this.quests.length + this.steps.length,
|
|
401
|
-
});
|
|
402
|
-
return quest;
|
|
403
|
-
}
|
|
404
|
-
const stepIndex = this.steps.findIndex((step) => step.id === id);
|
|
405
|
-
if (stepIndex !== -1) {
|
|
406
|
-
const [quest] = this.steps.splice(stepIndex, 1);
|
|
407
|
-
this.usedIds.delete(quest.id);
|
|
408
|
-
this.history.push({ type: QUEST_ACTIONS.delete, quest, index: stepIndex, isStep: true });
|
|
409
|
-
logger.debug("quests:state", QUEST_ACTIONS.delete, {
|
|
410
|
-
id,
|
|
411
|
-
index: stepIndex,
|
|
412
|
-
total: this.quests.length + this.steps.length,
|
|
413
|
-
});
|
|
414
|
-
return quest;
|
|
646
|
+
const result = this.deleteMany([id]);
|
|
647
|
+
if ("error" in result) {
|
|
648
|
+
return result.error === "blocked" ? null : undefined;
|
|
415
649
|
}
|
|
416
|
-
|
|
417
|
-
return
|
|
650
|
+
|
|
651
|
+
return result.quests[0];
|
|
418
652
|
}
|
|
419
653
|
|
|
420
654
|
clear(all = false): number {
|
|
655
|
+
if (!this.inRedo) this.redoStack = [];
|
|
421
656
|
if (!all) {
|
|
422
657
|
const doneParentIds = new Set(this.quests.filter((q) => q.done).map((q) => q.id));
|
|
423
658
|
const done = this.quests.filter((q) => q.done);
|
|
@@ -458,6 +693,7 @@ export class QuestLog {
|
|
|
458
693
|
}
|
|
459
694
|
|
|
460
695
|
reorder(id: string, targetId: string): Quest | undefined {
|
|
696
|
+
if (!this.inRedo) this.redoStack = [];
|
|
461
697
|
if (this.steps.some((step) => step.id === id || step.id === targetId)) {
|
|
462
698
|
logger.debug("quests:state", "reorder-step-rejected", { id, targetId });
|
|
463
699
|
return undefined;
|
|
@@ -498,6 +734,7 @@ export class QuestLog {
|
|
|
498
734
|
}
|
|
499
735
|
|
|
500
736
|
reparent(id: string, parentId?: string): Quest | Step | undefined {
|
|
737
|
+
if (!this.inRedo) this.redoStack = [];
|
|
501
738
|
const quest = this.findById(id);
|
|
502
739
|
if (!quest) return undefined;
|
|
503
740
|
|
|
@@ -553,29 +790,72 @@ export class QuestLog {
|
|
|
553
790
|
return quest;
|
|
554
791
|
}
|
|
555
792
|
|
|
556
|
-
|
|
793
|
+
undo(): QuestOperationResult {
|
|
557
794
|
const entry = this.history.pop();
|
|
558
795
|
if (!entry) {
|
|
559
|
-
logger.debug("quests:state", "
|
|
560
|
-
return { success: false, message:
|
|
796
|
+
logger.debug("quests:state", "undo-empty");
|
|
797
|
+
return { success: false, message: formatNothingToUndoError() };
|
|
561
798
|
}
|
|
562
799
|
|
|
563
|
-
logger.debug("quests:state", "
|
|
800
|
+
logger.debug("quests:state", "undo", { type: entry.type });
|
|
801
|
+
this.redoStack.push(this.buildRedoEntry(entry));
|
|
564
802
|
|
|
565
803
|
const handler = this.undoHandlers[entry.type];
|
|
566
804
|
if (handler) {
|
|
567
805
|
return handler(entry as never);
|
|
568
806
|
}
|
|
569
807
|
|
|
570
|
-
logger.debug("quests:state", "
|
|
808
|
+
logger.debug("quests:state", "undo-unknown", { type: (entry as { type: string }).type });
|
|
571
809
|
return { success: false, message: "Unknown history entry" };
|
|
572
810
|
}
|
|
573
811
|
|
|
812
|
+
private buildRedoEntry(entry: HistoryEntry): RedoEntry {
|
|
813
|
+
switch (entry.type) {
|
|
814
|
+
case QUEST_ACTIONS.add: {
|
|
815
|
+
const quest = this.findById(entry.id);
|
|
816
|
+
if (!quest) throw new Error(formatNotFound(entry.id));
|
|
817
|
+
return { type: QUEST_ACTIONS.add, quest };
|
|
818
|
+
}
|
|
819
|
+
case QUEST_ACTIONS.toggle:
|
|
820
|
+
return { type: QUEST_ACTIONS.toggle, ids: entry.ids };
|
|
821
|
+
case QUEST_ACTIONS.update:
|
|
822
|
+
return {
|
|
823
|
+
type: QUEST_ACTIONS.update,
|
|
824
|
+
id: entry.id,
|
|
825
|
+
description: this.findById(entry.id)?.description ?? entry.previousDescription,
|
|
826
|
+
};
|
|
827
|
+
case QUEST_ACTIONS.delete:
|
|
828
|
+
return { type: QUEST_ACTIONS.delete, ids: entry.ids };
|
|
829
|
+
case QUEST_ACTIONS.clear:
|
|
830
|
+
return { type: QUEST_ACTIONS.clear, all: entry.all };
|
|
831
|
+
case QUEST_ACTIONS.reorder:
|
|
832
|
+
return { type: QUEST_ACTIONS.reorder, id: entry.quest.id, targetId: entry.targetId };
|
|
833
|
+
case QUEST_ACTIONS.reparent: {
|
|
834
|
+
const quest = this.findById(entry.id);
|
|
835
|
+
if (!quest) throw new Error(formatNotFound(entry.id));
|
|
836
|
+
return {
|
|
837
|
+
type: QUEST_ACTIONS.reparent,
|
|
838
|
+
id: entry.id,
|
|
839
|
+
parentId: "parentId" in quest ? quest.parentId : undefined,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
574
845
|
/**
|
|
575
846
|
* Execute a quest action and return a presentation-ready result.
|
|
576
847
|
* This is the primary API for all adapter layers (commands, tools).
|
|
577
848
|
*/
|
|
578
849
|
execute(action: QuestAction): QuestOperationResult {
|
|
850
|
+
if (
|
|
851
|
+
action.type !== QUEST_ACTIONS.list &&
|
|
852
|
+
action.type !== QUEST_ACTIONS.rules &&
|
|
853
|
+
action.type !== QUEST_ACTIONS.skill &&
|
|
854
|
+
action.type !== QUEST_ACTIONS.undo &&
|
|
855
|
+
action.type !== QUEST_ACTIONS.redo
|
|
856
|
+
) {
|
|
857
|
+
this.redoStack = [];
|
|
858
|
+
}
|
|
579
859
|
switch (action.type) {
|
|
580
860
|
case QUEST_ACTIONS.add: {
|
|
581
861
|
if (action.descriptions && action.descriptions.length > 0) {
|
|
@@ -623,19 +903,42 @@ export class QuestLog {
|
|
|
623
903
|
return { success: true, message: formatQuestList(quests) };
|
|
624
904
|
}
|
|
625
905
|
case QUEST_ACTIONS.toggle: {
|
|
626
|
-
|
|
906
|
+
const ids = action.ids?.length ? action.ids : action.id ? [action.id] : undefined;
|
|
907
|
+
if (!ids || ids.length === 0) {
|
|
627
908
|
return { success: false, message: formatIdRequiredError("toggle") };
|
|
628
909
|
}
|
|
629
910
|
|
|
630
|
-
const
|
|
631
|
-
if (
|
|
632
|
-
return {
|
|
911
|
+
const result = this.toggleMany(ids);
|
|
912
|
+
if ("error" in result) {
|
|
913
|
+
return {
|
|
914
|
+
success: false,
|
|
915
|
+
message:
|
|
916
|
+
result.error === "blocked"
|
|
917
|
+
? formatBlockedBySteps(result.id)
|
|
918
|
+
: formatNotFound(result.id),
|
|
919
|
+
};
|
|
633
920
|
}
|
|
634
|
-
|
|
635
|
-
|
|
921
|
+
|
|
922
|
+
if (result.quests.length === 1) {
|
|
923
|
+
const quest = result.quests[0];
|
|
924
|
+
if (!quest) {
|
|
925
|
+
return { success: false, message: formatNotFound(result.ids[0]) };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return {
|
|
929
|
+
success: true,
|
|
930
|
+
message: formatToggleResult(result.ids[0], quest.done),
|
|
931
|
+
quest,
|
|
932
|
+
};
|
|
636
933
|
}
|
|
637
934
|
|
|
638
|
-
return {
|
|
935
|
+
return {
|
|
936
|
+
success: true,
|
|
937
|
+
message: formatBatchToggleResult(
|
|
938
|
+
result.quests.map((quest, index) => ({ id: result.ids[index], done: quest.done })),
|
|
939
|
+
),
|
|
940
|
+
quests: result.quests,
|
|
941
|
+
};
|
|
639
942
|
}
|
|
640
943
|
case QUEST_ACTIONS.update: {
|
|
641
944
|
if (action.id === undefined) {
|
|
@@ -654,19 +957,38 @@ export class QuestLog {
|
|
|
654
957
|
return { success: true, message: formatUpdateResult(q), quest: q };
|
|
655
958
|
}
|
|
656
959
|
case QUEST_ACTIONS.delete: {
|
|
657
|
-
|
|
960
|
+
const ids = action.ids?.length ? action.ids : action.id ? [action.id] : undefined;
|
|
961
|
+
if (!ids || ids.length === 0) {
|
|
658
962
|
return { success: false, message: formatIdRequiredError("delete") };
|
|
659
963
|
}
|
|
660
964
|
|
|
661
|
-
const
|
|
662
|
-
if (
|
|
663
|
-
return {
|
|
965
|
+
const result = this.deleteMany(ids);
|
|
966
|
+
if ("error" in result) {
|
|
967
|
+
return {
|
|
968
|
+
success: false,
|
|
969
|
+
message:
|
|
970
|
+
result.error === "blocked"
|
|
971
|
+
? formatBlockedBySteps(result.id)
|
|
972
|
+
: formatNotFound(result.id),
|
|
973
|
+
};
|
|
664
974
|
}
|
|
665
|
-
|
|
666
|
-
|
|
975
|
+
|
|
976
|
+
if (result.ids.length === 1) {
|
|
977
|
+
const quest = result.quests[0];
|
|
978
|
+
if (!quest) {
|
|
979
|
+
return { success: false, message: formatNotFound(result.ids[0]) };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return { success: true, message: formatDeleteResult(quest), quest };
|
|
667
983
|
}
|
|
668
984
|
|
|
669
|
-
return {
|
|
985
|
+
return {
|
|
986
|
+
success: true,
|
|
987
|
+
message: formatBatchDeleteResult(
|
|
988
|
+
result.quests.map((quest) => ({ id: quest.id, description: quest.description })),
|
|
989
|
+
),
|
|
990
|
+
quests: result.quests,
|
|
991
|
+
};
|
|
670
992
|
}
|
|
671
993
|
case QUEST_ACTIONS.clear: {
|
|
672
994
|
const count = this.clear(action.all);
|
|
@@ -730,8 +1052,11 @@ export class QuestLog {
|
|
|
730
1052
|
case QUEST_ACTIONS.skill: {
|
|
731
1053
|
return { success: true, message: getQuestSkillDocument() };
|
|
732
1054
|
}
|
|
733
|
-
case QUEST_ACTIONS.
|
|
734
|
-
return this.
|
|
1055
|
+
case QUEST_ACTIONS.undo: {
|
|
1056
|
+
return this.undo();
|
|
1057
|
+
}
|
|
1058
|
+
case QUEST_ACTIONS.redo: {
|
|
1059
|
+
return this.redo();
|
|
735
1060
|
}
|
|
736
1061
|
default: {
|
|
737
1062
|
return {
|
|
@@ -787,6 +1112,7 @@ export function makeToolResult(
|
|
|
787
1112
|
text: string,
|
|
788
1113
|
questLog: QuestLog,
|
|
789
1114
|
displayQuests?: Quest[],
|
|
1115
|
+
snapshotQuests?: Quest[],
|
|
790
1116
|
): AgentToolResult<unknown> {
|
|
791
1117
|
return {
|
|
792
1118
|
content: [{ type: "text", text }],
|
|
@@ -794,6 +1120,7 @@ export function makeToolResult(
|
|
|
794
1120
|
quests: questLog.getAll(),
|
|
795
1121
|
usedIds: questLog.getUsedIds(),
|
|
796
1122
|
displayQuests,
|
|
1123
|
+
snapshotQuests,
|
|
797
1124
|
},
|
|
798
1125
|
};
|
|
799
1126
|
}
|