@zeliper/zscode-mcp-server 1.0.0 → 1.0.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.
Files changed (51) hide show
  1. package/dist/errors/index.d.ts +28 -0
  2. package/dist/errors/index.d.ts.map +1 -1
  3. package/dist/errors/index.js +50 -0
  4. package/dist/errors/index.js.map +1 -1
  5. package/dist/state/manager.d.ts +63 -2
  6. package/dist/state/manager.d.ts.map +1 -1
  7. package/dist/state/manager.js +480 -84
  8. package/dist/state/manager.js.map +1 -1
  9. package/dist/state/manager.test.d.ts +2 -0
  10. package/dist/state/manager.test.d.ts.map +1 -0
  11. package/dist/state/manager.test.js +310 -0
  12. package/dist/state/manager.test.js.map +1 -0
  13. package/dist/state/schema.d.ts +190 -9
  14. package/dist/state/schema.d.ts.map +1 -1
  15. package/dist/state/schema.js +39 -1
  16. package/dist/state/schema.js.map +1 -1
  17. package/dist/state/schema.test.d.ts +2 -0
  18. package/dist/state/schema.test.d.ts.map +1 -0
  19. package/dist/state/schema.test.js +300 -0
  20. package/dist/state/schema.test.js.map +1 -0
  21. package/dist/state/types.d.ts +5 -1
  22. package/dist/state/types.d.ts.map +1 -1
  23. package/dist/state/types.js +3 -1
  24. package/dist/state/types.js.map +1 -1
  25. package/dist/tools/archive.d.ts.map +1 -1
  26. package/dist/tools/archive.js +57 -0
  27. package/dist/tools/archive.js.map +1 -1
  28. package/dist/tools/context.d.ts.map +1 -1
  29. package/dist/tools/context.js +14 -0
  30. package/dist/tools/context.js.map +1 -1
  31. package/dist/tools/index.d.ts.map +1 -1
  32. package/dist/tools/index.js +6 -0
  33. package/dist/tools/index.js.map +1 -1
  34. package/dist/tools/memory.d.ts +7 -0
  35. package/dist/tools/memory.d.ts.map +1 -0
  36. package/dist/tools/memory.js +243 -0
  37. package/dist/tools/memory.js.map +1 -0
  38. package/dist/tools/modify.d.ts +6 -0
  39. package/dist/tools/modify.d.ts.map +1 -0
  40. package/dist/tools/modify.js +327 -0
  41. package/dist/tools/modify.js.map +1 -0
  42. package/dist/tools/plan.js +3 -3
  43. package/dist/tools/plan.js.map +1 -1
  44. package/dist/tools/staging.d.ts.map +1 -1
  45. package/dist/tools/staging.js +3 -3
  46. package/dist/tools/staging.js.map +1 -1
  47. package/dist/utils/paths.d.ts +34 -0
  48. package/dist/utils/paths.d.ts.map +1 -1
  49. package/dist/utils/paths.js +94 -2
  50. package/dist/utils/paths.js.map +1 -1
  51. package/package.json +10 -3
@@ -1,23 +1,29 @@
1
- import { readFile, writeFile, access, rm, cp } from "fs/promises";
1
+ import { readFile, access, rm, cp } from "fs/promises";
2
2
  import { join, dirname } from "path";
3
3
  import { constants } from "fs";
4
4
  import { StateSchema } from "./schema.js";
5
- import { normalizePath, toPosixPath, ensureDir } from "../utils/paths.js";
5
+ import { STATE_VERSION, } from "./types.js";
6
+ import { normalizePath, toPosixPath, ensureDir, isPathSafe, isValidId, atomicWriteFile, generateSecureId, } from "../utils/paths.js";
7
+ import { ProjectNotInitializedError, PlanNotFoundError, PlanInvalidStateError, StagingNotFoundError, StagingOrderError, StagingPlanMismatchError, StagingInvalidStateError, TaskNotFoundError, TaskInvalidStateError, TaskStateTransitionError, CircularDependencyError, MemoryNotFoundError, PathTraversalError, InvalidIdError, } from "../errors/index.js";
8
+ // ============ Constants ============
9
+ const MAX_HISTORY_ENTRIES = 1000;
10
+ // Valid task state transitions
11
+ const VALID_TASK_TRANSITIONS = {
12
+ pending: ["in_progress", "blocked", "cancelled"],
13
+ in_progress: ["done", "blocked", "cancelled"],
14
+ blocked: ["in_progress", "cancelled"],
15
+ done: [], // Terminal state - no transitions allowed
16
+ cancelled: [], // Terminal state - no transitions allowed
17
+ };
6
18
  // ============ ID Generator ============
7
- function generateRandomId(length) {
8
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
9
- let result = "";
10
- for (let i = 0; i < length; i++) {
11
- result += chars.charAt(Math.floor(Math.random() * chars.length));
12
- }
13
- return result;
14
- }
19
+ // Uses cryptographically secure random generation
15
20
  const idGenerator = {
16
- generatePlanId: () => `plan-${generateRandomId(8)}`,
17
- generateStagingId: () => `staging-${generateRandomId(4)}`,
18
- generateTaskId: () => `task-${generateRandomId(8)}`,
19
- generateHistoryId: () => `hist-${Date.now()}-${generateRandomId(4)}`,
20
- generateDecisionId: () => `dec-${Date.now()}-${generateRandomId(4)}`,
21
+ generatePlanId: () => `plan-${generateSecureId(8)}`,
22
+ generateStagingId: () => `staging-${generateSecureId(4)}`,
23
+ generateTaskId: () => `task-${generateSecureId(8)}`,
24
+ generateHistoryId: () => `hist-${Date.now()}-${generateSecureId(4)}`,
25
+ generateDecisionId: () => `dec-${Date.now()}-${generateSecureId(4)}`,
26
+ generateMemoryId: () => `mem-${generateSecureId(8)}`,
21
27
  };
22
28
  // ============ StateManager Class ============
23
29
  export class StateManager {
@@ -64,8 +70,47 @@ export class StateManager {
64
70
  if (!this.state) {
65
71
  throw new Error("No state to save");
66
72
  }
67
- await ensureDir(dirname(this.stateFilePath));
68
- await writeFile(this.stateFilePath, JSON.stringify(this.state, null, 2), "utf-8");
73
+ // Use atomic write to prevent data corruption
74
+ await atomicWriteFile(this.stateFilePath, JSON.stringify(this.state, null, 2));
75
+ }
76
+ // ============ Validation Helpers ============
77
+ ensureInitialized() {
78
+ if (!this.state) {
79
+ throw new ProjectNotInitializedError();
80
+ }
81
+ return this.state;
82
+ }
83
+ requirePlan(planId) {
84
+ const state = this.ensureInitialized();
85
+ const plan = state.plans[planId];
86
+ if (!plan) {
87
+ throw new PlanNotFoundError(planId);
88
+ }
89
+ return plan;
90
+ }
91
+ requireStaging(stagingId) {
92
+ const state = this.ensureInitialized();
93
+ const staging = state.stagings[stagingId];
94
+ if (!staging) {
95
+ throw new StagingNotFoundError(stagingId);
96
+ }
97
+ return staging;
98
+ }
99
+ requireTask(taskId) {
100
+ const state = this.ensureInitialized();
101
+ const task = state.tasks[taskId];
102
+ if (!task) {
103
+ throw new TaskNotFoundError(taskId);
104
+ }
105
+ return task;
106
+ }
107
+ requireMemory(memoryId) {
108
+ const state = this.ensureInitialized();
109
+ const memory = state.context.memories.find(m => m.id === memoryId);
110
+ if (!memory) {
111
+ throw new MemoryNotFoundError(memoryId);
112
+ }
113
+ return memory;
69
114
  }
70
115
  // ============ Getters ============
71
116
  getState() {
@@ -120,7 +165,7 @@ export class StateManager {
120
165
  updatedAt: now,
121
166
  };
122
167
  this.state = {
123
- version: "2.0.0",
168
+ version: STATE_VERSION,
124
169
  project,
125
170
  plans: {},
126
171
  stagings: {},
@@ -130,6 +175,7 @@ export class StateManager {
130
175
  lastUpdated: now,
131
176
  activeFiles: [],
132
177
  decisions: [],
178
+ memories: [],
133
179
  },
134
180
  };
135
181
  await this.addHistory("project_initialized", { projectName: name });
@@ -138,9 +184,7 @@ export class StateManager {
138
184
  }
139
185
  // ============ Plan Operations ============
140
186
  async createPlan(title, description, stagingConfigs) {
141
- if (!this.state) {
142
- throw new Error("Project not initialized");
143
- }
187
+ const state = this.ensureInitialized();
144
188
  const now = new Date().toISOString();
145
189
  const planId = idGenerator.generatePlanId();
146
190
  const artifactsRoot = toPosixPath(join(".claude", "plans", planId, "artifacts"));
@@ -173,7 +217,7 @@ export class StateManager {
173
217
  };
174
218
  tasks.push(task);
175
219
  taskIds.push(taskId);
176
- this.state.tasks[taskId] = task;
220
+ state.tasks[taskId] = task;
177
221
  }
178
222
  // Resolve task dependencies within the same staging
179
223
  for (let i = 0; i < tasks.length; i++) {
@@ -197,7 +241,7 @@ export class StateManager {
197
241
  artifacts_path: artifactsPath,
198
242
  createdAt: now,
199
243
  };
200
- this.state.stagings[stagingId] = staging;
244
+ state.stagings[stagingId] = staging;
201
245
  stagingIds.push(stagingId);
202
246
  }
203
247
  // Create plan
@@ -211,17 +255,13 @@ export class StateManager {
211
255
  createdAt: now,
212
256
  updatedAt: now,
213
257
  };
214
- this.state.plans[planId] = plan;
258
+ state.plans[planId] = plan;
215
259
  await this.addHistory("plan_created", { planId, title, stagingCount: stagingIds.length });
216
260
  await this.save();
217
261
  return plan;
218
262
  }
219
263
  async updatePlanStatus(planId, status) {
220
- if (!this.state)
221
- throw new Error("Project not initialized");
222
- const plan = this.getPlan(planId);
223
- if (!plan)
224
- throw new Error(`Plan not found: ${planId}`);
264
+ const plan = this.requirePlan(planId);
225
265
  plan.status = status;
226
266
  plan.updatedAt = new Date().toISOString();
227
267
  if (status === "completed") {
@@ -234,22 +274,17 @@ export class StateManager {
234
274
  }
235
275
  // ============ Staging Operations ============
236
276
  async startStaging(planId, stagingId) {
237
- if (!this.state)
238
- throw new Error("Project not initialized");
239
- const plan = this.getPlan(planId);
240
- if (!plan)
241
- throw new Error(`Plan not found: ${planId}`);
242
- const staging = this.getStaging(stagingId);
243
- if (!staging)
244
- throw new Error(`Staging not found: ${stagingId}`);
277
+ const state = this.ensureInitialized();
278
+ const plan = this.requirePlan(planId);
279
+ const staging = this.requireStaging(stagingId);
245
280
  if (staging.planId !== planId) {
246
- throw new Error(`Staging ${stagingId} does not belong to plan ${planId}`);
281
+ throw new StagingPlanMismatchError(stagingId, planId, staging.planId);
247
282
  }
248
283
  // Check if previous staging is completed
249
284
  if (staging.order > 0) {
250
285
  const prevStaging = this.getStagingByOrder(planId, staging.order - 1);
251
286
  if (prevStaging && prevStaging.status !== "completed") {
252
- throw new Error(`Cannot start staging: previous staging ${prevStaging.id} is not completed`);
287
+ throw new StagingOrderError(stagingId, prevStaging.id);
253
288
  }
254
289
  }
255
290
  const now = new Date().toISOString();
@@ -258,9 +293,9 @@ export class StateManager {
258
293
  plan.currentStagingId = stagingId;
259
294
  plan.status = "active";
260
295
  plan.updatedAt = now;
261
- this.state.context.currentPlanId = planId;
262
- this.state.context.currentStagingId = stagingId;
263
- this.state.context.lastUpdated = now;
296
+ state.context.currentPlanId = planId;
297
+ state.context.currentStagingId = stagingId;
298
+ state.context.lastUpdated = now;
264
299
  // Create artifacts directory
265
300
  const artifactsDir = normalizePath(join(this.projectRoot, staging.artifacts_path));
266
301
  await ensureDir(artifactsDir);
@@ -268,12 +303,9 @@ export class StateManager {
268
303
  await this.save();
269
304
  return staging;
270
305
  }
271
- async completestaging(stagingId) {
272
- if (!this.state)
273
- throw new Error("Project not initialized");
274
- const staging = this.getStaging(stagingId);
275
- if (!staging)
276
- throw new Error(`Staging not found: ${stagingId}`);
306
+ async completeStaging(stagingId) {
307
+ const state = this.ensureInitialized();
308
+ const staging = this.requireStaging(stagingId);
277
309
  const now = new Date().toISOString();
278
310
  staging.status = "completed";
279
311
  staging.completedAt = now;
@@ -288,8 +320,8 @@ export class StateManager {
288
320
  if (allCompleted) {
289
321
  plan.status = "completed";
290
322
  plan.completedAt = now;
291
- this.state.context.currentPlanId = undefined;
292
- this.state.context.currentStagingId = undefined;
323
+ state.context.currentPlanId = undefined;
324
+ state.context.currentStagingId = undefined;
293
325
  }
294
326
  }
295
327
  await this.addHistory("staging_completed", { stagingId, stagingName: staging.name });
@@ -297,11 +329,12 @@ export class StateManager {
297
329
  }
298
330
  // ============ Task Operations ============
299
331
  async updateTaskStatus(taskId, status, notes) {
300
- if (!this.state)
301
- throw new Error("Project not initialized");
302
- const task = this.getTask(taskId);
303
- if (!task)
304
- throw new Error(`Task not found: ${taskId}`);
332
+ const task = this.requireTask(taskId);
333
+ // Validate state transition
334
+ const allowedTransitions = VALID_TASK_TRANSITIONS[task.status];
335
+ if (!allowedTransitions.includes(status)) {
336
+ throw new TaskStateTransitionError(taskId, task.status, status, allowedTransitions);
337
+ }
305
338
  const now = new Date().toISOString();
306
339
  task.status = status;
307
340
  task.updatedAt = now;
@@ -323,7 +356,7 @@ export class StateManager {
323
356
  return t?.status === "done";
324
357
  });
325
358
  if (allTasksDone) {
326
- await this.completestaging(staging.id);
359
+ await this.completeStaging(staging.id);
327
360
  }
328
361
  }
329
362
  }
@@ -333,19 +366,24 @@ export class StateManager {
333
366
  await this.save();
334
367
  }
335
368
  async saveTaskOutput(taskId, output) {
336
- if (!this.state)
337
- throw new Error("Project not initialized");
338
- const task = this.getTask(taskId);
339
- if (!task)
340
- throw new Error(`Task not found: ${taskId}`);
369
+ // Validate taskId to prevent path traversal
370
+ if (!isValidId(taskId)) {
371
+ throw new InvalidIdError(taskId, "task");
372
+ }
373
+ const task = this.requireTask(taskId);
341
374
  task.output = output;
342
375
  task.updatedAt = new Date().toISOString();
343
376
  // Save output to artifacts file
344
377
  const staging = this.getStaging(task.stagingId);
345
378
  if (staging) {
379
+ const claudeDir = normalizePath(join(this.projectRoot, ".claude"));
346
380
  const outputPath = normalizePath(join(this.projectRoot, staging.artifacts_path, `${taskId}-output.json`));
347
- await ensureDir(dirname(outputPath));
348
- await writeFile(outputPath, JSON.stringify(output, null, 2), "utf-8");
381
+ // Verify path is within the .claude directory (path traversal protection)
382
+ if (!isPathSafe(claudeDir, outputPath)) {
383
+ throw new PathTraversalError(outputPath);
384
+ }
385
+ // Use atomic write to prevent data corruption
386
+ await atomicWriteFile(outputPath, JSON.stringify(output, null, 2));
349
387
  }
350
388
  await this.save();
351
389
  }
@@ -369,17 +407,23 @@ export class StateManager {
369
407
  }
370
408
  // ============ Archive Operations ============
371
409
  async archivePlan(planId, reason) {
372
- if (!this.state)
373
- throw new Error("Project not initialized");
374
- const plan = this.getPlan(planId);
375
- if (!plan)
376
- throw new Error(`Plan not found: ${planId}`);
410
+ const state = this.ensureInitialized();
411
+ // Validate planId to prevent path traversal
412
+ if (!isValidId(planId)) {
413
+ throw new InvalidIdError(planId, "plan");
414
+ }
415
+ const plan = this.requirePlan(planId);
377
416
  if (plan.status !== "completed" && plan.status !== "cancelled") {
378
- throw new Error(`Cannot archive plan in ${plan.status} status. Only completed or cancelled plans can be archived.`);
417
+ throw new PlanInvalidStateError(planId, plan.status, ["completed", "cancelled"]);
379
418
  }
380
419
  const now = new Date().toISOString();
420
+ const claudeDir = normalizePath(join(this.projectRoot, ".claude"));
381
421
  const sourcePath = normalizePath(join(this.projectRoot, ".claude", "plans", planId));
382
422
  const archivePath = normalizePath(join(this.projectRoot, ".claude", "archive", planId));
423
+ // Verify paths are within the .claude directory (path traversal protection)
424
+ if (!isPathSafe(claudeDir, sourcePath) || !isPathSafe(claudeDir, archivePath)) {
425
+ throw new PathTraversalError(planId);
426
+ }
383
427
  // Move plan directory to archive
384
428
  try {
385
429
  await ensureDir(dirname(archivePath));
@@ -394,23 +438,56 @@ export class StateManager {
394
438
  plan.archivedAt = now;
395
439
  plan.updatedAt = now;
396
440
  // Clear current context if this was the active plan
397
- if (this.state.context.currentPlanId === planId) {
398
- this.state.context.currentPlanId = undefined;
399
- this.state.context.currentStagingId = undefined;
441
+ if (state.context.currentPlanId === planId) {
442
+ state.context.currentPlanId = undefined;
443
+ state.context.currentStagingId = undefined;
400
444
  }
401
445
  await this.addHistory("plan_archived", { planId, title: plan.title, reason });
402
446
  await this.save();
403
447
  return toPosixPath(join(".claude", "archive", planId));
404
448
  }
449
+ async unarchivePlan(planId) {
450
+ this.ensureInitialized();
451
+ // Validate planId to prevent path traversal
452
+ if (!isValidId(planId)) {
453
+ throw new InvalidIdError(planId, "plan");
454
+ }
455
+ const plan = this.requirePlan(planId);
456
+ if (plan.status !== "archived") {
457
+ throw new PlanInvalidStateError(planId, plan.status, ["archived"]);
458
+ }
459
+ const now = new Date().toISOString();
460
+ const claudeDir = normalizePath(join(this.projectRoot, ".claude"));
461
+ const archivePath = normalizePath(join(this.projectRoot, ".claude", "archive", planId));
462
+ const restorePath = normalizePath(join(this.projectRoot, ".claude", "plans", planId));
463
+ // Verify paths are within the .claude directory (path traversal protection)
464
+ if (!isPathSafe(claudeDir, archivePath) || !isPathSafe(claudeDir, restorePath)) {
465
+ throw new PathTraversalError(planId);
466
+ }
467
+ // Move plan directory back from archive
468
+ try {
469
+ await ensureDir(dirname(restorePath));
470
+ await cp(archivePath, restorePath, { recursive: true });
471
+ await rm(archivePath, { recursive: true, force: true });
472
+ }
473
+ catch (error) {
474
+ // Directory might not exist if no artifacts were created
475
+ console.error(`Unarchive directory operation failed: ${error}`);
476
+ }
477
+ // Restore plan to completed status (since it was completed before archiving)
478
+ plan.status = "completed";
479
+ plan.archivedAt = undefined;
480
+ plan.updatedAt = now;
481
+ await this.addHistory("plan_unarchived", { planId, title: plan.title });
482
+ await this.save();
483
+ return { plan, restoredPath: toPosixPath(join(".claude", "plans", planId)) };
484
+ }
405
485
  // ============ Cancel Operations ============
406
486
  async cancelPlan(planId, reason) {
407
- if (!this.state)
408
- throw new Error("Project not initialized");
409
- const plan = this.getPlan(planId);
410
- if (!plan)
411
- throw new Error(`Plan not found: ${planId}`);
487
+ const state = this.ensureInitialized();
488
+ const plan = this.requirePlan(planId);
412
489
  if (plan.status === "archived" || plan.status === "cancelled") {
413
- throw new Error(`Plan is already ${plan.status}`);
490
+ throw new PlanInvalidStateError(planId, plan.status, ["draft", "active", "completed"]);
414
491
  }
415
492
  const now = new Date().toISOString();
416
493
  let affectedStagings = 0;
@@ -435,9 +512,9 @@ export class StateManager {
435
512
  plan.status = "cancelled";
436
513
  plan.updatedAt = now;
437
514
  // Clear current context if this was the active plan
438
- if (this.state.context.currentPlanId === planId) {
439
- this.state.context.currentPlanId = undefined;
440
- this.state.context.currentStagingId = undefined;
515
+ if (state.context.currentPlanId === planId) {
516
+ state.context.currentPlanId = undefined;
517
+ state.context.currentStagingId = undefined;
441
518
  }
442
519
  await this.addHistory("plan_cancelled", { planId, title: plan.title, reason, affectedStagings, affectedTasks });
443
520
  await this.save();
@@ -455,10 +532,14 @@ export class StateManager {
455
532
  };
456
533
  this.state.history.push(entry);
457
534
  this.state.context.lastUpdated = entry.timestamp;
535
+ // Enforce history size limit - remove oldest entries if exceeded
536
+ if (this.state.history.length > MAX_HISTORY_ENTRIES) {
537
+ const excess = this.state.history.length - MAX_HISTORY_ENTRIES;
538
+ this.state.history.splice(0, excess);
539
+ }
458
540
  }
459
541
  async addDecision(title, decision, rationale, relatedPlanId, relatedStagingId) {
460
- if (!this.state)
461
- throw new Error("Project not initialized");
542
+ const state = this.ensureInitialized();
462
543
  const now = new Date().toISOString();
463
544
  const decisionEntry = {
464
545
  id: idGenerator.generateDecisionId(),
@@ -469,11 +550,105 @@ export class StateManager {
469
550
  relatedStagingId,
470
551
  timestamp: now,
471
552
  };
472
- this.state.context.decisions.push(decisionEntry);
553
+ state.context.decisions.push(decisionEntry);
473
554
  await this.addHistory("decision_added", { decisionId: decisionEntry.id, title });
474
555
  await this.save();
475
556
  return decisionEntry;
476
557
  }
558
+ // ============ Memory Operations ============
559
+ async addMemory(category, title, content, tags, priority) {
560
+ const state = this.ensureInitialized();
561
+ const now = new Date().toISOString();
562
+ const memory = {
563
+ id: idGenerator.generateMemoryId(),
564
+ category,
565
+ title,
566
+ content,
567
+ tags: tags ?? [],
568
+ priority: priority ?? 50,
569
+ enabled: true,
570
+ createdAt: now,
571
+ updatedAt: now,
572
+ };
573
+ state.context.memories.push(memory);
574
+ await this.addHistory("memory_added", { memoryId: memory.id, title, category });
575
+ await this.save();
576
+ return memory;
577
+ }
578
+ listMemories(category, tags, enabledOnly = true) {
579
+ if (!this.state)
580
+ return [];
581
+ let memories = this.state.context.memories;
582
+ if (enabledOnly) {
583
+ memories = memories.filter(m => m.enabled);
584
+ }
585
+ if (category) {
586
+ memories = memories.filter(m => m.category === category);
587
+ }
588
+ if (tags && tags.length > 0) {
589
+ memories = memories.filter(m => tags.some(tag => m.tags.includes(tag)));
590
+ }
591
+ // Sort by priority (descending)
592
+ return memories.sort((a, b) => b.priority - a.priority);
593
+ }
594
+ getMemoriesForContext(context) {
595
+ if (!this.state)
596
+ return [];
597
+ const enabledMemories = this.state.context.memories.filter(m => m.enabled);
598
+ let result;
599
+ if (context === "all") {
600
+ result = enabledMemories;
601
+ }
602
+ else if (context === "general") {
603
+ result = enabledMemories.filter(m => m.category === "general");
604
+ }
605
+ else {
606
+ // Return general + context-specific memories
607
+ result = enabledMemories.filter(m => m.category === "general" || m.category === context);
608
+ }
609
+ // Sort by priority (descending)
610
+ return result.sort((a, b) => b.priority - a.priority);
611
+ }
612
+ async updateMemory(memoryId, updates) {
613
+ const memory = this.requireMemory(memoryId);
614
+ const now = new Date().toISOString();
615
+ if (updates.title !== undefined)
616
+ memory.title = updates.title;
617
+ if (updates.content !== undefined)
618
+ memory.content = updates.content;
619
+ if (updates.category !== undefined)
620
+ memory.category = updates.category;
621
+ if (updates.tags !== undefined)
622
+ memory.tags = updates.tags;
623
+ if (updates.priority !== undefined)
624
+ memory.priority = updates.priority;
625
+ if (updates.enabled !== undefined)
626
+ memory.enabled = updates.enabled;
627
+ memory.updatedAt = now;
628
+ await this.addHistory("memory_updated", { memoryId, updates });
629
+ await this.save();
630
+ return memory;
631
+ }
632
+ async removeMemory(memoryId) {
633
+ const state = this.ensureInitialized();
634
+ const memory = this.requireMemory(memoryId);
635
+ const index = state.context.memories.findIndex(m => m.id === memoryId);
636
+ state.context.memories.splice(index, 1);
637
+ await this.addHistory("memory_removed", { memoryId, title: memory.title });
638
+ await this.save();
639
+ }
640
+ getMemory(memoryId) {
641
+ return this.state?.context.memories.find(m => m.id === memoryId);
642
+ }
643
+ getCategories() {
644
+ if (!this.state)
645
+ return [];
646
+ const categories = new Set();
647
+ for (const memory of this.state.context.memories) {
648
+ categories.add(memory.category);
649
+ }
650
+ return Array.from(categories).sort();
651
+ }
477
652
  // ============ Session Operations ============
478
653
  async startSession() {
479
654
  await this.addHistory("session_started", {});
@@ -488,6 +663,227 @@ export class StateManager {
488
663
  await this.addHistory("session_ended", { summary });
489
664
  await this.save();
490
665
  }
666
+ // ============ Modify Operations ============
667
+ async updatePlan(planId, updates) {
668
+ const plan = this.requirePlan(planId);
669
+ if (plan.status === "archived" || plan.status === "cancelled") {
670
+ throw new PlanInvalidStateError(planId, plan.status, ["draft", "active", "completed"]);
671
+ }
672
+ const now = new Date().toISOString();
673
+ if (updates.title !== undefined)
674
+ plan.title = updates.title;
675
+ if (updates.description !== undefined)
676
+ plan.description = updates.description;
677
+ plan.updatedAt = now;
678
+ await this.addHistory("plan_updated", { planId, updates });
679
+ await this.save();
680
+ return plan;
681
+ }
682
+ async updateStaging(stagingId, updates) {
683
+ const staging = this.requireStaging(stagingId);
684
+ if (staging.status === "completed" || staging.status === "cancelled") {
685
+ throw new StagingInvalidStateError(stagingId, staging.status, ["pending", "in_progress"]);
686
+ }
687
+ if (updates.name !== undefined)
688
+ staging.name = updates.name;
689
+ if (updates.description !== undefined)
690
+ staging.description = updates.description;
691
+ if (updates.execution_type !== undefined)
692
+ staging.execution_type = updates.execution_type;
693
+ await this.addHistory("staging_updated", { stagingId, updates });
694
+ await this.save();
695
+ return staging;
696
+ }
697
+ async addStaging(planId, config) {
698
+ const state = this.ensureInitialized();
699
+ const plan = this.requirePlan(planId);
700
+ if (plan.status === "archived" || plan.status === "cancelled" || plan.status === "completed") {
701
+ throw new PlanInvalidStateError(planId, plan.status, ["draft", "active"]);
702
+ }
703
+ const now = new Date().toISOString();
704
+ const stagingId = idGenerator.generateStagingId();
705
+ const insertAt = config.insertAt ?? plan.stagings.length;
706
+ // Reorder existing stagings
707
+ const stagings = this.getStagingsByPlan(planId);
708
+ for (const s of stagings) {
709
+ if (s.order >= insertAt) {
710
+ s.order++;
711
+ }
712
+ }
713
+ const artifactsPath = toPosixPath(join(plan.artifacts_root, stagingId));
714
+ const staging = {
715
+ id: stagingId,
716
+ planId,
717
+ name: config.name,
718
+ description: config.description,
719
+ order: insertAt,
720
+ execution_type: config.execution_type,
721
+ status: "pending",
722
+ tasks: [],
723
+ artifacts_path: artifactsPath,
724
+ createdAt: now,
725
+ };
726
+ state.stagings[stagingId] = staging;
727
+ // Insert into plan's staging list
728
+ plan.stagings.splice(insertAt, 0, stagingId);
729
+ plan.updatedAt = now;
730
+ await this.addHistory("staging_added", { planId, stagingId, name: config.name });
731
+ await this.save();
732
+ return staging;
733
+ }
734
+ async removeStaging(stagingId) {
735
+ const state = this.ensureInitialized();
736
+ const staging = this.requireStaging(stagingId);
737
+ if (staging.status === "in_progress") {
738
+ throw new StagingInvalidStateError(stagingId, staging.status, ["pending", "completed", "cancelled"]);
739
+ }
740
+ const plan = this.requirePlan(staging.planId);
741
+ // Remove all tasks in this staging
742
+ for (const taskId of staging.tasks) {
743
+ delete state.tasks[taskId];
744
+ }
745
+ // Remove staging from plan
746
+ plan.stagings = plan.stagings.filter(id => id !== stagingId);
747
+ plan.updatedAt = new Date().toISOString();
748
+ // Reorder remaining stagings
749
+ const remainingStagings = this.getStagingsByPlan(staging.planId);
750
+ remainingStagings.sort((a, b) => a.order - b.order);
751
+ remainingStagings.forEach((s, i) => {
752
+ s.order = i;
753
+ });
754
+ // Remove staging
755
+ delete state.stagings[stagingId];
756
+ await this.addHistory("staging_removed", { stagingId, stagingName: staging.name });
757
+ await this.save();
758
+ }
759
+ // ============ Circular Dependency Detection ============
760
+ /**
761
+ * Check for circular dependencies in task dependencies
762
+ * @param taskId - The task being checked
763
+ * @param depends_on - Dependencies to validate
764
+ * @returns dependency chain if circular, null otherwise
765
+ */
766
+ detectCircularDependency(taskId, depends_on, visited = new Set()) {
767
+ if (visited.has(taskId)) {
768
+ return Array.from(visited);
769
+ }
770
+ visited.add(taskId);
771
+ for (const depId of depends_on) {
772
+ const depTask = this.getTask(depId);
773
+ if (!depTask)
774
+ continue;
775
+ if (depTask.depends_on.includes(taskId)) {
776
+ return [...Array.from(visited), depId, taskId];
777
+ }
778
+ const chain = this.detectCircularDependency(depId, depTask.depends_on, new Set(visited));
779
+ if (chain) {
780
+ return chain;
781
+ }
782
+ }
783
+ return null;
784
+ }
785
+ async addTask(stagingId, config) {
786
+ const state = this.ensureInitialized();
787
+ const staging = this.requireStaging(stagingId);
788
+ if (staging.status === "completed" || staging.status === "cancelled") {
789
+ throw new StagingInvalidStateError(stagingId, staging.status, ["pending", "in_progress"]);
790
+ }
791
+ const now = new Date().toISOString();
792
+ const taskId = idGenerator.generateTaskId();
793
+ const existingTasks = this.getTasksByStaging(stagingId);
794
+ // Check for circular dependencies
795
+ if (config.depends_on && config.depends_on.length > 0) {
796
+ const circularChain = this.detectCircularDependency(taskId, config.depends_on);
797
+ if (circularChain) {
798
+ throw new CircularDependencyError(taskId, circularChain);
799
+ }
800
+ }
801
+ const task = {
802
+ id: taskId,
803
+ planId: staging.planId,
804
+ stagingId,
805
+ title: config.title,
806
+ description: config.description,
807
+ priority: config.priority,
808
+ status: "pending",
809
+ execution_mode: config.execution_mode,
810
+ depends_on: config.depends_on ?? [],
811
+ order: existingTasks.length,
812
+ createdAt: now,
813
+ updatedAt: now,
814
+ };
815
+ state.tasks[taskId] = task;
816
+ staging.tasks.push(taskId);
817
+ await this.addHistory("task_added", { stagingId, taskId, title: config.title });
818
+ await this.save();
819
+ return task;
820
+ }
821
+ async removeTask(taskId) {
822
+ const state = this.ensureInitialized();
823
+ const task = this.requireTask(taskId);
824
+ if (task.status === "in_progress") {
825
+ throw new TaskInvalidStateError(taskId, task.status, ["pending", "done", "blocked", "cancelled"]);
826
+ }
827
+ const staging = this.requireStaging(task.stagingId);
828
+ // Remove task from staging
829
+ staging.tasks = staging.tasks.filter(id => id !== taskId);
830
+ // Remove this task from other tasks' dependencies
831
+ for (const t of Object.values(state.tasks)) {
832
+ t.depends_on = t.depends_on.filter(id => id !== taskId);
833
+ }
834
+ // Reorder remaining tasks
835
+ const remainingTasks = this.getTasksByStaging(task.stagingId);
836
+ remainingTasks.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
837
+ remainingTasks.forEach((t, i) => {
838
+ t.order = i;
839
+ });
840
+ // Remove task
841
+ delete state.tasks[taskId];
842
+ await this.addHistory("task_removed", { taskId, taskTitle: task.title });
843
+ await this.save();
844
+ }
845
+ async updateTaskDetails(taskId, updates) {
846
+ const task = this.requireTask(taskId);
847
+ if (task.status === "done" || task.status === "cancelled") {
848
+ throw new TaskInvalidStateError(taskId, task.status, ["pending", "in_progress", "blocked"]);
849
+ }
850
+ const now = new Date().toISOString();
851
+ if (updates.title !== undefined)
852
+ task.title = updates.title;
853
+ if (updates.description !== undefined)
854
+ task.description = updates.description;
855
+ if (updates.priority !== undefined)
856
+ task.priority = updates.priority;
857
+ if (updates.execution_mode !== undefined)
858
+ task.execution_mode = updates.execution_mode;
859
+ // Handle depends_on updates with circular dependency check
860
+ if (updates.depends_on !== undefined) {
861
+ // Validate all dependency IDs exist and are in the same staging
862
+ for (const depId of updates.depends_on) {
863
+ const depTask = this.getTask(depId);
864
+ if (!depTask) {
865
+ throw new TaskNotFoundError(depId);
866
+ }
867
+ if (depTask.stagingId !== task.stagingId) {
868
+ throw new TaskInvalidStateError(depId, "different staging", ["same staging as dependent task"]);
869
+ }
870
+ // Cannot depend on itself
871
+ if (depId === taskId) {
872
+ throw new CircularDependencyError(taskId, [taskId]);
873
+ }
874
+ }
875
+ // Check for circular dependencies
876
+ const circularChain = this.detectCircularDependency(taskId, updates.depends_on);
877
+ if (circularChain) {
878
+ throw new CircularDependencyError(taskId, circularChain);
879
+ }
880
+ task.depends_on = updates.depends_on;
881
+ }
882
+ task.updatedAt = now;
883
+ await this.addHistory("task_updated", { taskId, updates });
884
+ await this.save();
885
+ return task;
886
+ }
491
887
  // ============ Utility Methods ============
492
888
  getProjectRoot() {
493
889
  return this.projectRoot;