pi-quests 0.3.0 → 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.
@@ -1,38 +1,83 @@
1
1
  import type { AgentToolResult, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { DEFAULT_CONFIG, type ResolvedConfig } from "../config.js";
2
3
  import { logger } from "../logger.js";
4
+ import { getQuestSkillDocument } from "../prompts.js";
3
5
  import {
6
+ formatAddResult,
4
7
  formatBatchAddResult,
8
+ formatBlockedBySteps,
5
9
  formatDeleteResult,
10
+ formatDescriptionRequiredError,
11
+ formatEmptyDescriptionsError,
12
+ formatIdRequiredError,
13
+ formatMissingDescriptionsError,
6
14
  formatNotFound,
15
+ formatNothingToRevertError,
16
+ formatParentDoneError,
17
+ formatParentNotFoundError,
7
18
  formatQuestList,
19
+ formatReorderedQuestNotFoundError,
20
+ formatReorderNotFoundError,
21
+ formatReparentDemoteHasStepsError,
22
+ formatReparentResult,
23
+ formatReparentSelfParentError,
24
+ formatReparentTargetDoneError,
25
+ formatReparentTargetIsStepError,
26
+ formatReparentTargetNotFoundError,
27
+ formatStepCannotHaveSteps,
28
+ formatTargetIdRequiredError,
8
29
  formatToggleResult,
30
+ formatUnknownActionError,
9
31
  formatUpdateResult,
10
32
  } from "./formatters.js";
11
- import { QUEST_ACTIONS, type Quest } from "./types.js";
33
+ import { QUEST_ACTIONS, type Quest, type Step } from "./types.js";
12
34
 
13
35
  export type HistoryEntry =
14
- | { type: typeof QUEST_ACTIONS.add; id: number }
15
- | { type: typeof QUEST_ACTIONS.toggle; id: number }
16
- | { type: typeof QUEST_ACTIONS.update; id: number; previousDescription: string }
17
- | { type: typeof QUEST_ACTIONS.delete; quest: Quest; index: number }
36
+ | { type: typeof QUEST_ACTIONS.add; id: string; parentId?: string }
37
+ | { type: typeof QUEST_ACTIONS.toggle; id: string }
38
+ | { 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
+ }
18
46
  | {
19
47
  type: typeof QUEST_ACTIONS.clear;
20
48
  previousQuests: Quest[];
21
- previousNextId: number;
49
+ previousSteps?: Step[];
22
50
  all: false;
23
51
  }
24
- | { type: typeof QUEST_ACTIONS.clear; quests: Quest[]; nextId: number; all: true }
25
- | { type: typeof QUEST_ACTIONS.reorder; quest: Quest; oldIndex: number; previousIds: number[] };
52
+ | { type: typeof QUEST_ACTIONS.clear; quests: Quest[]; steps?: Step[]; all: true }
53
+ | {
54
+ type: typeof QUEST_ACTIONS.reorder;
55
+ quest: Quest;
56
+ oldIndex: number;
57
+ previousIds: string[];
58
+ targetId: string;
59
+ }
60
+ | {
61
+ type: typeof QUEST_ACTIONS.reparent;
62
+ id: string;
63
+ previousParentId?: string;
64
+ previousQuestIndex?: number;
65
+ };
26
66
 
27
67
  export type QuestAction =
28
68
  | { type: typeof QUEST_ACTIONS.add; descriptions?: string[] }
69
+ | { type: typeof QUEST_ACTIONS.split; id?: string; descriptions?: string[] }
70
+ | { type: typeof QUEST_ACTIONS.add_step; id?: string; descriptions?: string[] }
29
71
  | { type: typeof QUEST_ACTIONS.list }
30
- | { type: typeof QUEST_ACTIONS.toggle; id?: number }
31
- | { type: typeof QUEST_ACTIONS.update; id?: number; description?: string }
32
- | { type: typeof QUEST_ACTIONS.delete; id?: number }
72
+ | { type: typeof QUEST_ACTIONS.toggle; id?: string }
73
+ | { type: typeof QUEST_ACTIONS.update; id?: string; description?: string }
74
+ | { type: typeof QUEST_ACTIONS.delete; id?: string }
33
75
  | { type: typeof QUEST_ACTIONS.clear; all?: boolean }
34
- | { type: typeof QUEST_ACTIONS.reorder; id?: number; targetIndex?: number }
35
- | { type: typeof QUEST_ACTIONS.revert };
76
+ | { type: typeof QUEST_ACTIONS.reorder; id?: string; targetId?: string }
77
+ | { type: typeof QUEST_ACTIONS.revert }
78
+ | { type: typeof QUEST_ACTIONS.reparent; id?: string; parentId?: string }
79
+ | { type: typeof QUEST_ACTIONS.rules }
80
+ | { type: typeof QUEST_ACTIONS.skill };
36
81
 
37
82
  export type QuestOperationResult = { success: boolean; message: string; quest?: Quest };
38
83
 
@@ -44,8 +89,35 @@ export type QuestOperationResult = { success: boolean; message: string; quest?:
44
89
  */
45
90
  export class QuestLog {
46
91
  private quests: Quest[] = [];
47
- private nextId = 1;
92
+ private steps: Step[] = [];
93
+ private usedIds: Set<string> = new Set();
48
94
  private history: HistoryEntry[] = [];
95
+
96
+ private readonly ID_LENGTH: number;
97
+ private readonly MAX_IDS: number;
98
+
99
+ constructor(config: ResolvedConfig = DEFAULT_CONFIG) {
100
+ this.ID_LENGTH = config.ids.length;
101
+ this.MAX_IDS = 16 ** this.ID_LENGTH;
102
+ }
103
+
104
+ private generateId(): string {
105
+ if (this.usedIds.size >= this.MAX_IDS) {
106
+ logger.debug("quests:state", "generate-id-exhausted", { usedIds: this.usedIds.size });
107
+ throw new Error("No available quest IDs. Clear done or all quests to free up IDs.");
108
+ }
109
+
110
+ let id: string;
111
+ do {
112
+ id = Math.floor(Math.random() * this.MAX_IDS)
113
+ .toString(16)
114
+ .padStart(this.ID_LENGTH, "0")
115
+ .toLowerCase();
116
+ } while (this.usedIds.has(id));
117
+
118
+ this.usedIds.add(id);
119
+ return id;
120
+ }
49
121
  private undoHandlers: {
50
122
  [K in HistoryEntry["type"]]: (entry: Extract<HistoryEntry, { type: K }>) => {
51
123
  success: boolean;
@@ -54,51 +126,68 @@ export class QuestLog {
54
126
  } = {
55
127
  [QUEST_ACTIONS.add]: (entry) => {
56
128
  this.quests = this.quests.filter((q) => q.id !== entry.id);
129
+ this.steps = this.steps.filter((q) => q.id !== entry.id);
130
+ this.usedIds.delete(entry.id);
57
131
 
58
- const maxId = this.quests.reduce((max, q) => Math.max(max, q.id), 0);
59
- this.nextId = Math.max(maxId + 1, entry.id);
60
-
61
- logger.debug("quests:state", "revert-add", { id: entry.id, total: this.quests.length });
62
- return { success: true, message: `Reverted add quest #${entry.id}` };
132
+ logger.debug("quests:state", "revert-add", {
133
+ id: entry.id,
134
+ total: this.quests.length + this.steps.length,
135
+ });
136
+ return { success: true, message: `Reverted add quest [${entry.id}]` };
63
137
  },
64
138
  [QUEST_ACTIONS.toggle]: (entry) => {
65
- const quest = this.quests.find((q) => q.id === entry.id);
139
+ const quest = this.findById(entry.id);
66
140
 
67
141
  if (quest) {
68
142
  quest.done = !quest.done;
69
143
  logger.debug("quests:state", "revert-toggle", { id: entry.id, done: quest.done });
70
- return { success: true, message: `Reverted toggle for quest #${entry.id}` };
144
+ return { success: true, message: `Reverted toggle for quest [${entry.id}]` };
71
145
  }
72
146
 
73
147
  logger.debug("quests:state", "revert-toggle-not-found", { id: entry.id });
74
- return { success: false, message: `Quest #${entry.id} not found` };
148
+ return { success: false, message: formatNotFound(entry.id) };
75
149
  },
76
150
  [QUEST_ACTIONS.update]: (entry) => {
77
- const quest = this.quests.find((q) => q.id === entry.id);
151
+ const quest = this.findById(entry.id);
78
152
  if (quest) {
79
153
  quest.description = entry.previousDescription;
80
154
  logger.debug("quests:state", "revert-update", { id: entry.id });
81
- return { success: true, message: `Reverted update for quest #${entry.id}` };
155
+ return { success: true, message: `Reverted update for quest [${entry.id}]` };
82
156
  }
83
157
 
84
158
  logger.debug("quests:state", "revert-update-not-found", { id: entry.id });
85
- return { success: false, message: `Quest #${entry.id} not found` };
159
+ return { success: false, message: formatNotFound(entry.id) };
86
160
  },
87
161
  [QUEST_ACTIONS.delete]: (entry) => {
88
- this.quests.splice(entry.index, 0, entry.quest);
162
+ if (entry.isStep) {
163
+ this.steps.splice(entry.index, 0, entry.quest as Step);
164
+ } else {
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);
89
174
 
90
175
  logger.debug("quests:state", "revert-delete", {
91
176
  id: entry.quest.id,
92
177
  index: entry.index,
93
- total: this.quests.length,
178
+ total: this.quests.length + this.steps.length,
94
179
  });
95
- return { success: true, message: `Reverted delete for quest #${entry.quest.id}` };
180
+ return { success: true, message: `Reverted delete for quest [${entry.quest.id}]` };
96
181
  },
97
182
  [QUEST_ACTIONS.clear]: (entry) => {
98
183
  if ("previousQuests" in entry) {
99
- const restoredCount = entry.previousQuests.length - this.quests.length;
184
+ const restoredCount =
185
+ entry.previousQuests.length +
186
+ (entry.previousSteps?.length ?? 0) -
187
+ (this.quests.length + this.steps.length);
100
188
  this.quests = [...entry.previousQuests];
101
- this.nextId = entry.previousNextId;
189
+ this.steps = [...(entry.previousSteps ?? [])];
190
+ this.usedIds = new Set([...this.quests, ...this.steps].map((q) => q.id));
102
191
 
103
192
  return {
104
193
  success: true,
@@ -107,13 +196,15 @@ export class QuestLog {
107
196
  }
108
197
 
109
198
  this.quests = [...entry.quests];
110
- this.nextId = entry.nextId;
199
+ this.steps = [...(entry.steps ?? [])];
200
+ this.usedIds = new Set([...this.quests, ...this.steps].map((q) => q.id));
111
201
 
112
202
  return { success: true, message: `Reverted clear (${entry.quests.length} quests restored)` };
113
203
  },
114
204
  [QUEST_ACTIONS.reorder]: (entry) => {
115
205
  const currentIndex = this.quests.indexOf(entry.quest);
116
- if (currentIndex === -1) return { success: false, message: "Reordered quest not found" };
206
+ if (currentIndex === -1)
207
+ return { success: false, message: formatReorderedQuestNotFoundError() };
117
208
 
118
209
  this.quests.splice(currentIndex, 1);
119
210
  this.quests.splice(entry.oldIndex, 0, entry.quest);
@@ -121,24 +212,67 @@ export class QuestLog {
121
212
  this.quests[i].id = entry.previousIds[i];
122
213
  }
123
214
 
124
- this.nextId = Math.max(...entry.previousIds, 0) + 1;
125
- return { success: true, message: `Reverted reorder for quest #${entry.quest.id}` };
215
+ return { success: true, message: `Reverted reorder for quest [${entry.quest.id}]` };
216
+ },
217
+ [QUEST_ACTIONS.reparent]: (entry) => {
218
+ const quest = this.findById(entry.id);
219
+ if (!quest) return { success: false, message: formatNotFound(entry.id) };
220
+ if (entry.previousParentId !== undefined) {
221
+ this.quests = this.quests.filter((q) => q.id !== entry.id);
222
+ (quest as Step).parentId = entry.previousParentId;
223
+ if (!this.steps.some((s) => s.id === entry.id)) this.steps.push(quest as Step);
224
+ } else if (entry.previousQuestIndex !== undefined) {
225
+ this.steps = this.steps.filter((s) => s.id !== entry.id);
226
+ delete (quest as Partial<Step>).parentId;
227
+ this.quests.splice(entry.previousQuestIndex, 0, quest);
228
+ }
229
+ return { success: true, message: `Reverted reparent for quest [${entry.id}]` };
126
230
  },
127
231
  };
128
232
 
233
+ /*
234
+ * Returns all quests including steps, inserted in order
235
+ * steps are returned immediately after their parent quest
236
+ */
129
237
  getAll(): Quest[] {
238
+ const result: Quest[] = [];
239
+ for (const q of this.quests) {
240
+ result.push(q);
241
+ result.push(...this.steps.filter((step) => step.parentId === q.id));
242
+ }
243
+ return result;
244
+ }
245
+
246
+ /*
247
+ * Returns only top-level quests. Steps should be accessed via `getSteps()`.
248
+ */
249
+ getQuests(): Quest[] {
130
250
  return [...this.quests];
131
251
  }
132
252
 
133
- getNextId(): number {
134
- return this.nextId;
253
+ /*
254
+ * Returns steps for a given parent quest ID. Steps are not included in `getQuests()`.
255
+ */
256
+ getSteps(parentId: string): Step[] {
257
+ return this.steps.filter((step) => step.parentId === parentId);
258
+ }
259
+
260
+ getParent(step: Step): Quest | undefined {
261
+ return this.quests.find((q) => q.id === step.parentId);
135
262
  }
136
263
 
137
- add(description: string, additionalContext?: string): Quest {
264
+ private findById(id: string): Quest | Step | undefined {
265
+ return this.quests.find((q) => q.id === id) ?? this.steps.find((step) => step.id === id);
266
+ }
267
+
268
+ getUsedIds(): string[] {
269
+ return Array.from(this.usedIds);
270
+ }
271
+
272
+ add(description: string): Quest {
138
273
  const quest: Quest = {
139
- id: this.nextId++,
274
+ id: this.generateId(),
140
275
  description,
141
- additionalContext,
142
276
  done: false,
143
277
  createdAt: Date.now(),
144
278
  };
@@ -154,26 +288,77 @@ export class QuestLog {
154
288
  return quest;
155
289
  }
156
290
 
157
- toggle(id: number): Quest | undefined {
158
- const quest = this.quests.find((q) => q.id === id);
291
+ addStep(description: string, parentId: string): Step {
292
+ if (this.steps.some((step) => step.id === parentId)) {
293
+ throw new Error(formatStepCannotHaveSteps(parentId));
294
+ }
295
+ const parent = this.quests.find((q) => q.id === parentId);
296
+ if (!parent) {
297
+ throw new Error(formatParentNotFoundError(parentId));
298
+ }
299
+ if (parent.done) {
300
+ throw new Error(formatParentDoneError(parentId));
301
+ }
302
+ const step: Step = {
303
+ id: this.generateId(),
304
+ description,
305
+ done: false,
306
+ createdAt: Date.now(),
307
+ parentId,
308
+ };
309
+ this.steps.push(step);
310
+ this.history.push({ type: QUEST_ACTIONS.add, id: step.id, parentId });
311
+ logger.debug("quests:state", "add-step", {
312
+ id: step.id,
313
+ parentId,
314
+ description,
315
+ totalSteps: this.steps.length,
316
+ });
317
+ return step;
318
+ }
319
+
320
+ split(id: string, descriptions: string[]): Step[] {
321
+ if (this.steps.some((step) => step.id === id)) {
322
+ throw new Error(formatStepCannotHaveSteps(id));
323
+ }
324
+ const parent = this.quests.find((q) => q.id === id);
325
+ if (!parent) throw new Error(formatNotFound(id));
326
+ if (parent.done) throw new Error(formatParentDoneError(id));
327
+ const created: Step[] = [];
328
+ for (const desc of descriptions) {
329
+ created.push(this.addStep(desc, id));
330
+ }
331
+ return created;
332
+ }
333
+
334
+ toggle(id: string): Quest | Step | null | undefined {
335
+ const quest = this.findById(id);
159
336
  if (!quest) {
160
337
  logger.debug("quests:state", "toggle-not-found", { id });
161
338
  return undefined;
162
339
  }
163
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
+ }
347
+ }
348
+
164
349
  quest.done = !quest.done;
165
350
  this.history.push({ type: QUEST_ACTIONS.toggle, id });
166
351
 
167
352
  logger.debug("quests:state", QUEST_ACTIONS.toggle, {
168
353
  id,
169
354
  done: quest.done,
170
- total: this.quests.length,
355
+ total: this.quests.length + this.steps.length,
171
356
  });
172
357
  return quest;
173
358
  }
174
359
 
175
- update(id: number, description: string): Quest | undefined {
176
- const quest = this.quests.find((q) => q.id === id);
360
+ update(id: string, description: string): Quest | Step | undefined {
361
+ const quest = this.findById(id);
177
362
  if (!quest) {
178
363
  logger.debug("quests:state", "update-not-found", { id });
179
364
  return undefined;
@@ -185,77 +370,184 @@ export class QuestLog {
185
370
  logger.debug("quests:state", QUEST_ACTIONS.update, {
186
371
  id,
187
372
  description,
188
- total: this.quests.length,
373
+ total: this.quests.length + this.steps.length,
189
374
  });
190
375
  return quest;
191
376
  }
192
377
 
193
- delete(id: number): Quest | undefined {
378
+ delete(id: string): Quest | Step | null | undefined {
194
379
  const index = this.quests.findIndex((q) => q.id === id);
195
- if (index === -1) {
196
- logger.debug("quests:state", "delete-not-found", { id });
197
- return undefined;
380
+ if (index !== -1) {
381
+ const steps = this.getSteps(id);
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;
198
403
  }
199
-
200
- const [quest] = this.quests.splice(index, 1);
201
- this.history.push({ type: QUEST_ACTIONS.delete, quest, index });
202
-
203
- logger.debug("quests:state", QUEST_ACTIONS.delete, { id, index, total: this.quests.length });
204
- return quest;
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;
415
+ }
416
+ logger.debug("quests:state", "delete-not-found", { id });
417
+ return undefined;
205
418
  }
206
419
 
207
420
  clear(all = false): number {
208
421
  if (!all) {
422
+ const doneParentIds = new Set(this.quests.filter((q) => q.done).map((q) => q.id));
209
423
  const done = this.quests.filter((q) => q.done);
210
- const previousQuests = this.quests.map((q) => ({ ...q }));
211
- const previousNextId = this.nextId;
424
+ const removedSteps = this.steps.filter(
425
+ (step) => step.done || doneParentIds.has(step.parentId),
426
+ );
427
+ const keptSteps = this.steps.filter(
428
+ (step) => !step.done && !doneParentIds.has(step.parentId),
429
+ );
430
+ const previousQuests = [...this.quests];
431
+ const previousSteps = [...this.steps];
432
+ for (const q of done) this.usedIds.delete(q.id);
433
+ for (const q of removedSteps) this.usedIds.delete(q.id);
212
434
  this.quests = this.quests.filter((q) => !q.done);
213
- for (let i = 0; i < this.quests.length; i++) {
214
- this.quests[i].id = i + 1;
215
- }
216
- this.nextId = this.quests.length + 1;
435
+ this.steps = keptSteps;
217
436
  this.history.push({
218
437
  type: QUEST_ACTIONS.clear,
219
438
  previousQuests,
220
- previousNextId,
439
+ previousSteps,
221
440
  all: false,
222
441
  });
223
- logger.debug("quests:state", QUEST_ACTIONS.clear, { count: done.length, all });
224
- return done.length;
442
+ const count = done.length + removedSteps.length;
443
+ logger.debug("quests:state", QUEST_ACTIONS.clear, { count, all });
444
+ return count;
225
445
  }
226
- const count = this.quests.length;
446
+ const count = this.quests.length + this.steps.length;
227
447
  this.history.push({
228
448
  type: QUEST_ACTIONS.clear,
229
449
  quests: [...this.quests],
230
- nextId: this.nextId,
450
+ steps: [...this.steps],
231
451
  all: true,
232
452
  });
233
453
  this.quests = [];
234
- this.nextId = 1;
454
+ this.steps = [];
455
+ this.usedIds.clear();
235
456
  logger.debug("quests:state", QUEST_ACTIONS.clear, { count, all });
236
457
  return count;
237
458
  }
238
459
 
239
- reorder(id: number, targetIndex: number): Quest | undefined {
460
+ reorder(id: string, targetId: string): Quest | undefined {
461
+ if (this.steps.some((step) => step.id === id || step.id === targetId)) {
462
+ logger.debug("quests:state", "reorder-step-rejected", { id, targetId });
463
+ return undefined;
464
+ }
240
465
  const idx = this.quests.findIndex((q) => q.id === id);
241
466
  if (idx === -1) {
242
467
  logger.debug("quests:state", "reorder-not-found", { id });
243
468
  return undefined;
244
469
  }
245
470
 
471
+ const targetIdx = this.quests.findIndex((q) => q.id === targetId);
472
+ if (targetIdx === -1) {
473
+ logger.debug("quests:state", "reorder-target-not-found", { id, targetId });
474
+ return undefined;
475
+ }
476
+
477
+ if (idx === targetIdx) {
478
+ return this.quests[idx];
479
+ }
480
+
246
481
  const previousIds = this.quests.map((q) => q.id);
247
482
  const [quest] = this.quests.splice(idx, 1);
248
- this.quests.splice(targetIndex, 0, quest);
249
- for (let i = 0; i < this.quests.length; i++) {
250
- this.quests[i].id = i + 1;
483
+
484
+ let insertIndex = targetIdx;
485
+ if (idx < targetIdx) {
486
+ insertIndex = targetIdx - 1;
251
487
  }
252
488
 
253
- this.nextId = this.quests.length + 1;
254
- this.history.push({ type: QUEST_ACTIONS.reorder, quest, oldIndex: idx, previousIds });
489
+ this.quests.splice(insertIndex, 0, quest);
490
+ this.history.push({ type: QUEST_ACTIONS.reorder, quest, oldIndex: idx, previousIds, targetId });
255
491
  logger.debug("quests:state", QUEST_ACTIONS.reorder, {
256
492
  id: quest.id,
257
- targetIndex,
258
- total: this.quests.length,
493
+ targetId,
494
+ total: this.quests.length + this.steps.length,
495
+ });
496
+
497
+ return quest;
498
+ }
499
+
500
+ reparent(id: string, parentId?: string): Quest | Step | undefined {
501
+ const quest = this.findById(id);
502
+ if (!quest) return undefined;
503
+
504
+ if (parentId) {
505
+ if (id === parentId) {
506
+ throw new Error(formatReparentSelfParentError(id));
507
+ }
508
+ const target = this.findById(parentId);
509
+ if (!target) {
510
+ throw new Error(formatReparentTargetNotFoundError(parentId));
511
+ }
512
+ if ("parentId" in target) {
513
+ throw new Error(formatReparentTargetIsStepError(parentId));
514
+ }
515
+ if (target.done) {
516
+ throw new Error(formatReparentTargetDoneError(parentId));
517
+ }
518
+ if (!("parentId" in quest)) {
519
+ const steps = this.getSteps(id);
520
+ if (steps.length > 0) {
521
+ throw new Error(formatReparentDemoteHasStepsError(id));
522
+ }
523
+ }
524
+ const previousQuestIndex = this.quests.findIndex((q) => q.id === id);
525
+ const previousParentId = "parentId" in quest ? quest.parentId : undefined;
526
+ this.quests = this.quests.filter((q) => q.id !== id);
527
+ this.steps = this.steps.filter((s) => s.id !== id);
528
+ (quest as Step).parentId = parentId;
529
+ this.steps.push(quest as Step);
530
+ this.history.push({
531
+ type: QUEST_ACTIONS.reparent,
532
+ id,
533
+ previousParentId,
534
+ previousQuestIndex: previousQuestIndex === -1 ? undefined : previousQuestIndex,
535
+ });
536
+ } else {
537
+ if (!("parentId" in quest)) {
538
+ return quest;
539
+ }
540
+ const previousParentId = quest.parentId;
541
+ this.steps = this.steps.filter((s) => s.id !== id);
542
+ delete (quest as Partial<Step>).parentId;
543
+ this.quests.push(quest);
544
+ this.history.push({ type: QUEST_ACTIONS.reparent, id, previousParentId });
545
+ }
546
+
547
+ logger.debug("quests:state", QUEST_ACTIONS.reparent, {
548
+ id,
549
+ parentId,
550
+ total: this.quests.length + this.steps.length,
259
551
  });
260
552
 
261
553
  return quest;
@@ -265,7 +557,7 @@ export class QuestLog {
265
557
  const entry = this.history.pop();
266
558
  if (!entry) {
267
559
  logger.debug("quests:state", "revert-empty");
268
- return { success: false, message: "Nothing to revert" };
560
+ return { success: false, message: formatNothingToRevertError() };
269
561
  }
270
562
 
271
563
  logger.debug("quests:state", "revert", { type: entry.type });
@@ -290,20 +582,40 @@ export class QuestLog {
290
582
  if (action.descriptions.some((d) => !d || d.trim().length === 0)) {
291
583
  return {
292
584
  success: false,
293
- message: "Error: all descriptions in a batch must be non-empty",
585
+ message: formatEmptyDescriptionsError(),
294
586
  };
295
587
  }
296
- const added: { id: number; description: string }[] = [];
297
- for (const desc of action.descriptions) {
298
- const q = this.add(desc);
299
- added.push({ id: q.id, description: q.description });
588
+ if (action.descriptions.length === 1) {
589
+ try {
590
+ const [firstDesc] = action.descriptions;
591
+ const q = this.add(firstDesc);
592
+
593
+ return { success: true, message: formatAddResult(q) };
594
+ } catch (err) {
595
+ return {
596
+ success: false,
597
+ message: `Error: ${err instanceof Error ? err.message : String(err)}`,
598
+ };
599
+ }
600
+ }
601
+ try {
602
+ const added: { id: string; description: string }[] = [];
603
+ for (const desc of action.descriptions) {
604
+ const q = this.add(desc);
605
+ added.push({ id: q.id, description: q.description });
606
+ }
607
+
608
+ return { success: true, message: formatBatchAddResult(added) };
609
+ } catch (err) {
610
+ return {
611
+ success: false,
612
+ message: `Error: ${err instanceof Error ? err.message : String(err)}`,
613
+ };
300
614
  }
301
-
302
- return { success: true, message: formatBatchAddResult(added) };
303
615
  }
304
616
  return {
305
617
  success: false,
306
- message: "Error: at least one description is required for add action",
618
+ message: formatMissingDescriptionsError(),
307
619
  };
308
620
  }
309
621
  case QUEST_ACTIONS.list: {
@@ -312,23 +624,26 @@ export class QuestLog {
312
624
  }
313
625
  case QUEST_ACTIONS.toggle: {
314
626
  if (action.id === undefined) {
315
- return { success: false, message: "Error: id is required for toggle action" };
627
+ return { success: false, message: formatIdRequiredError("toggle") };
316
628
  }
317
629
 
318
630
  const q = this.toggle(action.id);
319
- if (!q) {
631
+ if (q === undefined) {
320
632
  return { success: false, message: formatNotFound(action.id) };
321
633
  }
634
+ if (q === null) {
635
+ return { success: false, message: formatBlockedBySteps(action.id) };
636
+ }
322
637
 
323
638
  return { success: true, message: formatToggleResult(action.id, q.done), quest: q };
324
639
  }
325
640
  case QUEST_ACTIONS.update: {
326
641
  if (action.id === undefined) {
327
- return { success: false, message: "Error: id is required for update action" };
642
+ return { success: false, message: formatIdRequiredError("update") };
328
643
  }
329
644
 
330
- if (!action.description) {
331
- return { success: false, message: "Error: description is required for update action" };
645
+ if (!action.description || action.description.trim().length === 0) {
646
+ return { success: false, message: formatDescriptionRequiredError() };
332
647
  }
333
648
 
334
649
  const q = this.update(action.id, action.description);
@@ -340,13 +655,16 @@ export class QuestLog {
340
655
  }
341
656
  case QUEST_ACTIONS.delete: {
342
657
  if (action.id === undefined) {
343
- return { success: false, message: "Error: id is required for delete action" };
658
+ return { success: false, message: formatIdRequiredError("delete") };
344
659
  }
345
660
 
346
661
  const q = this.delete(action.id);
347
- if (!q) {
662
+ if (q === undefined) {
348
663
  return { success: false, message: formatNotFound(action.id) };
349
664
  }
665
+ if (q === null) {
666
+ return { success: false, message: formatBlockedBySteps(action.id) };
667
+ }
350
668
 
351
669
  return { success: true, message: formatDeleteResult(q), quest: q };
352
670
  }
@@ -360,21 +678,66 @@ export class QuestLog {
360
678
  }
361
679
  case QUEST_ACTIONS.reorder: {
362
680
  if (action.id === undefined)
363
- return { success: false, message: "Error: id is required for reorder action" };
681
+ return { success: false, message: formatIdRequiredError("reorder") };
364
682
 
365
- if (action.targetIndex === undefined)
366
- return { success: false, message: "Error: targetIndex is required for reorder action" };
683
+ if (action.targetId === undefined)
684
+ return { success: false, message: formatTargetIdRequiredError() };
367
685
 
368
- const q = this.reorder(action.id, action.targetIndex);
369
- if (!q) return { success: false, message: formatNotFound(action.id) };
686
+ const q = this.reorder(action.id, action.targetId);
687
+ if (!q) return { success: false, message: formatReorderNotFoundError() };
370
688
 
371
- return { success: true, message: `Reordered quest #${q.id}: ${q.description}`, quest: q };
689
+ return { success: true, message: `Reordered quest [${q.id}]: ${q.description}`, quest: q };
690
+ }
691
+ case QUEST_ACTIONS.split:
692
+ case QUEST_ACTIONS.add_step: {
693
+ if (action.id === undefined) {
694
+ return { success: false, message: formatIdRequiredError("split") };
695
+ }
696
+ if (!action.descriptions || action.descriptions.length === 0) {
697
+ return { success: false, message: formatMissingDescriptionsError("split") };
698
+ }
699
+ if (action.descriptions.some((d) => !d || d.trim().length === 0)) {
700
+ return { success: false, message: formatEmptyDescriptionsError() };
701
+ }
702
+ try {
703
+ const steps = this.split(action.id, action.descriptions);
704
+ return {
705
+ success: true,
706
+ message: `Split quest [${action.id}] into ${steps.length} steps`,
707
+ };
708
+ } catch (err) {
709
+ return {
710
+ success: false,
711
+ message: `Error: ${err instanceof Error ? err.message : String(err)}`,
712
+ };
713
+ }
714
+ }
715
+ case QUEST_ACTIONS.reparent: {
716
+ if (action.id === undefined)
717
+ return { success: false, message: formatIdRequiredError("reparent") };
718
+ try {
719
+ const q = this.reparent(action.id, action.parentId);
720
+ if (!q) return { success: false, message: formatNotFound(action.id) };
721
+ return { success: true, message: formatReparentResult(q, action.parentId), quest: q };
722
+ } catch (err) {
723
+ return {
724
+ success: false,
725
+ message: `Error: ${err instanceof Error ? err.message : String(err)}`,
726
+ };
727
+ }
728
+ }
729
+ case QUEST_ACTIONS.rules:
730
+ case QUEST_ACTIONS.skill: {
731
+ return { success: true, message: getQuestSkillDocument() };
372
732
  }
373
733
  case QUEST_ACTIONS.revert: {
374
734
  return this.revert();
375
735
  }
376
736
  default: {
377
- return { success: false, message: `Unknown action: ${(action as { type: string }).type}` };
737
+ return {
738
+ success: false,
739
+ message: formatUnknownActionError((action as { type: string }).type),
740
+ };
378
741
  }
379
742
  }
380
743
  }
@@ -398,15 +761,21 @@ export class QuestLog {
398
761
 
399
762
  if (lastState) {
400
763
  const quests = lastState.quests;
401
- const nextId = lastState.nextId;
402
764
  const questCount = Array.isArray(quests) ? quests.length : 0;
403
765
 
404
- this.quests = Array.isArray(quests) ? [...(quests as Quest[])] : [];
405
- this.nextId = typeof nextId === "number" ? nextId : 1;
406
- logger.debug("quests:state", "reconstruct", { toolResults, questCount, nextId: this.nextId });
766
+ if (Array.isArray(quests)) {
767
+ this.quests = (quests as Quest[]).filter((q) => !("parentId" in q && q.parentId));
768
+ this.steps = (quests as Quest[]).filter((q) => "parentId" in q && q.parentId) as Step[];
769
+ } else {
770
+ this.quests = [];
771
+ this.steps = [];
772
+ }
773
+ this.usedIds = new Set([...this.quests, ...this.steps].map((q) => q.id));
774
+ logger.debug("quests:state", "reconstruct", { toolResults, questCount });
407
775
  } else {
408
776
  this.quests = [];
409
- this.nextId = 1;
777
+ this.steps = [];
778
+ this.usedIds.clear();
410
779
  logger.debug("quests:state", "reconstruct-empty", { toolResults });
411
780
  }
412
781
 
@@ -423,7 +792,7 @@ export function makeToolResult(
423
792
  content: [{ type: "text", text }],
424
793
  details: {
425
794
  quests: questLog.getAll(),
426
- nextId: questLog.getNextId(),
795
+ usedIds: questLog.getUsedIds(),
427
796
  displayQuests,
428
797
  },
429
798
  };