@veewo/claw-core 0.1.75 → 0.1.76

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/dist/src/plan.js CHANGED
@@ -6,6 +6,7 @@ import { ensureTaskContext, removeLegacyTaskMeta, resolveProjectContext, resolve
6
6
  import { resolvePlanEffectiveConfig } from "./effective-config.js";
7
7
  import { ClawError } from "./errors.js";
8
8
  import { readJsonFile, withFileLock, withSerializedAccess, writeJsonFile } from "./io.js";
9
+ import { tryCompleteKnowledgePlan, tryRegisterKnowledgePlan } from "./knowledge-sidecar.js";
9
10
  import { buildPlanEvent } from "./plan-events.js";
10
11
  import { isProcessStatus, } from "./requirements-gate.js";
11
12
  import { buildPlanViewModel } from "./plan-view.js";
@@ -82,6 +83,11 @@ export async function writePlan(input) {
82
83
  writeJsonFile(planPath, plan);
83
84
  });
84
85
  bindSessionToPlan(project, input.ownerSessionKey, planPath);
86
+ tryRegisterKnowledgePlan({
87
+ project,
88
+ sessionId: input.ownerSessionKey,
89
+ planPath,
90
+ });
85
91
  if (input.ownerSessionKey) {
86
92
  removeLegacyTaskMeta(task);
87
93
  }
@@ -134,9 +140,6 @@ export async function editPlan(input) {
134
140
  planFile,
135
141
  });
136
142
  }
137
- if (input.patch?.tasks !== undefined && (input.taskId !== undefined || input.taskStatus !== undefined)) {
138
- throw new ClawError("PROJECT_CONFIG_INVALID", "patch.tasks cannot be combined with taskId/taskStatus updates in the same plan edit. Update tasks and task progress in separate commands.");
139
- }
140
143
  return withSerializedAccess(planPath, async () => {
141
144
  const previous = normalizePlanDocument(readJsonFile(planPath));
142
145
  const previousStatus = previous.status;
@@ -146,107 +149,180 @@ export async function editPlan(input) {
146
149
  const changedTaskIds = [];
147
150
  const appendedTaskIds = [];
148
151
  const completedTaskIds = [];
149
- const next = structuredClone(previous);
152
+ let next = structuredClone(previous);
150
153
  const requestedStatus = input.planStatus ? normalizePlanStatus(input.planStatus) : undefined;
151
- if (requestedStatus) {
152
- const validation = canSetPlanStatus(previousStatus, requestedStatus);
153
- if (!validation.ok) {
154
- throw new ClawError(validation.code, validation.error);
155
- }
156
- next.status = requestedStatus;
157
- }
158
- if (input.patch) {
159
- applyPlanPatch(next, input.patch);
160
- }
161
- if (previousStatus !== "end.completed" && next.status === "end.completed") {
162
- next.completedAt = new Date().toISOString();
163
- }
164
- else if (previousStatus === "end.completed" && next.status !== "end.completed") {
165
- delete next.completedAt;
166
- }
167
- if (input.appendTasks?.length) {
168
- if (isEnd(previous.status) && !requestedStatus && input.patch?.status === undefined) {
169
- next.status = "prepare.requirements";
170
- }
171
- const currentIds = new Set(next.tasks.map((taskItem) => taskItem.id));
172
- const appendedTasks = normalizePlanTasks(input.appendTasks, nextAvailableTaskId(next.tasks));
173
- for (const taskItem of appendedTasks) {
174
- if (currentIds.has(taskItem.id)) {
175
- throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${taskItem.id} already exists in plan.`);
154
+ let operationChain;
155
+ if (input.operations) {
156
+ const chain = await applyPlanMutationOperations({
157
+ projectRoot: task.project.projectRoot,
158
+ initialPlan: previous,
159
+ operations: input.operations,
160
+ });
161
+ next = chain.plan;
162
+ operationChain = chain.result;
163
+ const previousTasks = new Map(previous.tasks.map((item) => [item.id, item]));
164
+ const nextTasks = new Map(next.tasks.map((item) => [item.id, item]));
165
+ for (const [id, taskItem] of nextTasks) {
166
+ const prior = previousTasks.get(id);
167
+ if (!prior) {
168
+ appendedTaskIds.push(id);
169
+ changedTaskIds.push(id);
170
+ }
171
+ else if (JSON.stringify(prior) !== JSON.stringify(taskItem)) {
172
+ changedTaskIds.push(id);
173
+ if (prior.status !== "done" && taskItem.status === "done") {
174
+ completedTaskIds.push(id);
175
+ }
176
176
  }
177
- validatePlanTask(taskItem);
178
- next.tasks.push(taskItem);
179
- currentIds.add(taskItem.id);
180
- appendedTaskIds.push(taskItem.id);
181
- }
182
- }
183
- next.requirements = normalizePlanRequirements(next.requirements);
184
- next.tasks = normalizePlanTasks(Array.isArray(next.tasks) ? next.tasks : []);
185
- if (input.taskId !== undefined || input.taskStatus !== undefined) {
186
- if (input.taskId === undefined || input.taskStatus === undefined) {
187
- throw new ClawError("PROJECT_CONFIG_INVALID", "taskId and taskStatus must be provided together when updating a plan task status.");
188
- }
189
- if (!isProcess(next.status)) {
190
- throw new ClawError("TASK_STATUS_FORBIDDEN_IN_NON_ACTIVE_PLAN", "Task progress can only be updated while plan.status is process.*. If requirements are already confirmed, move the plan to process.active first.", {
191
- planStatus: next.status,
192
- suggestedCommand: `claw plan edit --task ${task.taskName}${planFile === "plan.json" ? "" : ` --plan ${planFile}`} --plan-status process.active`,
193
- });
194
- }
195
- const planTask = next.tasks.find((item) => item.id === input.taskId);
196
- if (!planTask) {
197
- throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${input.taskId} was not found in this plan.`);
198
- }
199
- validatePlanTaskStatus(input.taskStatus);
200
- const previousTaskStatus = planTask.status;
201
- planTask.status = input.taskStatus;
202
- if (input.taskChoiceId !== undefined) {
203
- planTask.choiceId = input.taskChoiceId;
204
177
  }
205
- else if (input.taskStatus !== "done") {
206
- delete planTask.choiceId;
178
+ for (const id of previousTasks.keys()) {
179
+ if (!nextTasks.has(id)) {
180
+ changedTaskIds.push(id);
181
+ }
207
182
  }
208
- changedTaskIds.push(planTask.id);
209
- if (previousTaskStatus !== "done" && input.taskStatus === "done") {
210
- completedTaskIds.push(planTask.id);
183
+ for (const taskId of completedTaskIds) {
211
184
  events.push(buildPlanEvent("plan_task_completed", {
212
185
  mutationId,
213
186
  commandSource,
214
187
  planPath,
215
188
  planTitle: next.title,
216
189
  planStatus: next.status,
217
- taskId: planTask.id,
218
- affectedPlanTaskIds: [planTask.id],
190
+ taskId,
191
+ affectedPlanTaskIds: [taskId],
219
192
  }));
220
193
  }
221
194
  }
222
- if (input.completeLifecycleBridge) {
223
- if (requestedStatus !== "process.active") {
224
- throw new ClawError("PROJECT_CONFIG_INVALID", "Completing the lifecycle bridge requires planStatus=process.active.");
195
+ else {
196
+ if (requestedStatus) {
197
+ const validation = canSetPlanStatus(previousStatus, requestedStatus);
198
+ if (!validation.ok) {
199
+ throw new ClawError(validation.code, validation.error);
200
+ }
201
+ next.status = requestedStatus;
202
+ }
203
+ if (input.updates) {
204
+ applyPlanFieldUpdates(next, input.updates);
205
+ }
206
+ if (previousStatus !== "end.completed" && next.status === "end.completed") {
207
+ next.completedAt = new Date().toISOString();
225
208
  }
226
- const lifecycleTitles = new Set([
227
- "Use the planning skill to refine the request and append executable tasks",
228
- "Enter process.active",
229
- ]);
230
- const lifecycleTasks = next.tasks.filter((taskItem) => lifecycleTitles.has(taskItem.title));
231
- if (lifecycleTasks.length !== lifecycleTitles.size) {
232
- throw new ClawError("PROJECT_CONFIG_INVALID", "Atomic plan start is only available when both default lifecycle bridge tasks are present.");
209
+ else if (previousStatus === "end.completed" && next.status !== "end.completed") {
210
+ delete next.completedAt;
233
211
  }
234
- for (const lifecycleTask of lifecycleTasks) {
235
- if (lifecycleTask.status === "done") {
236
- continue;
212
+ if (input.appendTasks?.length) {
213
+ if (isEnd(previous.status) && !requestedStatus) {
214
+ next.status = "prepare.requirements";
215
+ }
216
+ const currentIds = new Set(next.tasks.map((taskItem) => taskItem.id));
217
+ const appendedTasks = normalizePlanTasks(input.appendTasks, nextAvailableTaskId(next.tasks));
218
+ for (const taskItem of appendedTasks) {
219
+ if (currentIds.has(taskItem.id)) {
220
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${taskItem.id} already exists in plan.`);
221
+ }
222
+ validatePlanTask(taskItem);
223
+ next.tasks.push(taskItem);
224
+ currentIds.add(taskItem.id);
225
+ appendedTaskIds.push(taskItem.id);
226
+ }
227
+ }
228
+ if (input.removeTaskIds?.length) {
229
+ const removeIds = new Set(input.removeTaskIds);
230
+ for (const taskId of removeIds) {
231
+ if (!next.tasks.some((taskItem) => taskItem.id === taskId)) {
232
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${taskId} was not found in this plan.`);
233
+ }
234
+ if (input.taskId === taskId) {
235
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${taskId} cannot be updated and removed in the same plan edit.`);
236
+ }
237
+ changedTaskIds.push(taskId);
238
+ }
239
+ next.tasks = next.tasks.filter((taskItem) => !removeIds.has(taskItem.id));
240
+ }
241
+ next.requirements = normalizePlanRequirements(next.requirements);
242
+ next.tasks = normalizePlanTasks(Array.isArray(next.tasks) ? next.tasks : []);
243
+ const hasTaskMutation = input.taskId !== undefined
244
+ || input.taskStatus !== undefined
245
+ || input.taskChoiceId !== undefined
246
+ || input.taskTitle !== undefined
247
+ || input.taskDetail !== undefined;
248
+ if (hasTaskMutation) {
249
+ if (input.taskId === undefined) {
250
+ throw new ClawError("PROJECT_CONFIG_INVALID", "taskId is required when updating a plan task.");
251
+ }
252
+ if (input.taskStatus !== undefined && !isProcess(next.status)) {
253
+ throw new ClawError("TASK_STATUS_FORBIDDEN_IN_NON_ACTIVE_PLAN", "Task progress can only be updated while plan.status is process.*. If requirements are already confirmed, move the plan to process.active first.", {
254
+ planStatus: next.status,
255
+ suggestedCommand: "claw plan resume",
256
+ });
257
+ }
258
+ const planTask = next.tasks.find((item) => item.id === input.taskId);
259
+ if (!planTask) {
260
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${input.taskId} was not found in this plan.`);
261
+ }
262
+ const previousTaskStatus = planTask.status;
263
+ if (input.taskTitle !== undefined) {
264
+ planTask.title = input.taskTitle;
265
+ }
266
+ if (input.taskDetail !== undefined) {
267
+ planTask.detail = input.taskDetail;
268
+ }
269
+ if (input.taskStatus !== undefined) {
270
+ validatePlanTaskStatus(input.taskStatus);
271
+ planTask.status = input.taskStatus;
272
+ if (input.taskChoiceId !== undefined) {
273
+ planTask.choiceId = input.taskChoiceId;
274
+ }
275
+ else if (input.taskStatus !== "done") {
276
+ delete planTask.choiceId;
277
+ }
278
+ }
279
+ else if (input.taskChoiceId !== undefined) {
280
+ throw new ClawError("PROJECT_CONFIG_INVALID", "taskChoiceId requires taskStatus=done in the same plan edit.");
281
+ }
282
+ validatePlanTask(planTask);
283
+ changedTaskIds.push(planTask.id);
284
+ if (previousTaskStatus !== "done" && planTask.status === "done") {
285
+ completedTaskIds.push(planTask.id);
286
+ events.push(buildPlanEvent("plan_task_completed", {
287
+ mutationId,
288
+ commandSource,
289
+ planPath,
290
+ planTitle: next.title,
291
+ planStatus: next.status,
292
+ taskId: planTask.id,
293
+ affectedPlanTaskIds: [planTask.id],
294
+ }));
295
+ }
296
+ }
297
+ if (input.completeLifecycleBridge) {
298
+ if (requestedStatus !== "process.active") {
299
+ throw new ClawError("PROJECT_CONFIG_INVALID", "Completing the lifecycle bridge requires planStatus=process.active.");
300
+ }
301
+ const lifecycleTitles = new Set([
302
+ "Analyze the request and fill executable tasks with the planning skill",
303
+ "Enter process.active",
304
+ ]);
305
+ const lifecycleTasks = next.tasks.filter((taskItem) => lifecycleTitles.has(taskItem.title));
306
+ if (lifecycleTasks.length !== lifecycleTitles.size) {
307
+ throw new ClawError("PROJECT_CONFIG_INVALID", "Atomic plan start is only available when both default lifecycle bridge tasks are present.");
308
+ }
309
+ for (const lifecycleTask of lifecycleTasks) {
310
+ if (lifecycleTask.status === "done") {
311
+ continue;
312
+ }
313
+ lifecycleTask.status = "done";
314
+ changedTaskIds.push(lifecycleTask.id);
315
+ completedTaskIds.push(lifecycleTask.id);
316
+ events.push(buildPlanEvent("plan_task_completed", {
317
+ mutationId,
318
+ commandSource,
319
+ planPath,
320
+ planTitle: next.title,
321
+ planStatus: next.status,
322
+ taskId: lifecycleTask.id,
323
+ affectedPlanTaskIds: [lifecycleTask.id],
324
+ }));
237
325
  }
238
- lifecycleTask.status = "done";
239
- changedTaskIds.push(lifecycleTask.id);
240
- completedTaskIds.push(lifecycleTask.id);
241
- events.push(buildPlanEvent("plan_task_completed", {
242
- mutationId,
243
- commandSource,
244
- planPath,
245
- planTitle: next.title,
246
- planStatus: next.status,
247
- taskId: lifecycleTask.id,
248
- affectedPlanTaskIds: [lifecycleTask.id],
249
- }));
250
326
  }
251
327
  }
252
328
  await validateDoneTransitions({
@@ -265,12 +341,12 @@ export async function editPlan(input) {
265
341
  taskName: task.taskName,
266
342
  planFile,
267
343
  planPath,
268
- suggestedCommand: `claw plan show --task ${task.taskName}${planFile === "plan.json" ? "" : ` --plan ${planFile}`}`,
344
+ suggestedCommand: `claw plan show --task-name ${task.taskName}${planFile === "plan.json" ? "" : ` --plan-file ${planFile}`}`,
269
345
  });
270
346
  }
271
347
  writeJsonFile(planPath, next);
272
348
  });
273
- if (previousStatus !== next.status || input.patch || changedTaskIds.length > 0 || input.appendTasks?.length) {
349
+ if (previousStatus !== next.status || input.updates || changedTaskIds.length > 0 || input.appendTasks?.length || input.removeTaskIds?.length || (operationChain?.completedOperations ?? 0) > 0) {
274
350
  events.unshift(buildPlanEvent("plan_changed", {
275
351
  mutationId,
276
352
  commandSource,
@@ -323,6 +399,22 @@ export async function editPlan(input) {
323
399
  else {
324
400
  bindSessionToPlan(task.project, input.ownerSessionKey, resultPlanPath);
325
401
  }
402
+ if (completionHooks) {
403
+ tryCompleteKnowledgePlan({
404
+ project: task.project,
405
+ sessionId: input.ownerSessionKey,
406
+ completedPlanPath: planPath,
407
+ ...(completionHooks.subplanClosureCandidate ? { resumedPlanPath: resultPlanPath } : {}),
408
+ completedAt: next.completedAt,
409
+ });
410
+ }
411
+ else if (!resultPlan.status.startsWith("end.")) {
412
+ tryRegisterKnowledgePlan({
413
+ project: task.project,
414
+ sessionId: input.ownerSessionKey,
415
+ planPath: resultPlanPath,
416
+ });
417
+ }
326
418
  if (input.ownerSessionKey) {
327
419
  removeLegacyTaskMeta(task);
328
420
  }
@@ -350,6 +442,7 @@ export async function editPlan(input) {
350
442
  previousStatus,
351
443
  completionHooks,
352
444
  changedTaskIds,
445
+ appendedTaskIds,
353
446
  completedTaskIds,
354
447
  }),
355
448
  }),
@@ -360,6 +453,7 @@ export async function editPlan(input) {
360
453
  plan: resultPlan,
361
454
  }),
362
455
  events,
456
+ ...(operationChain ? { operationChain } : {}),
363
457
  };
364
458
  });
365
459
  }
@@ -522,41 +616,204 @@ function validatePlanTaskStatus(status) {
522
616
  throw new ClawError("PROJECT_CONFIG_INVALID", `Unsupported plan task status "${status}". Canonical values are pending, in_progress, subagent_running, done, blocked.`);
523
617
  }
524
618
  }
525
- const DELETE_PATCH_VALUE = Symbol("delete-plan-patch-value");
526
- function isPlainPatchObject(value) {
527
- return typeof value === "object" && value !== null && !Array.isArray(value);
528
- }
529
- function mergePlanPatchValue(current, patch) {
530
- if (patch === null) {
531
- return DELETE_PATCH_VALUE;
532
- }
533
- if (Array.isArray(patch)) {
534
- return structuredClone(patch);
535
- }
536
- if (isPlainPatchObject(patch)) {
537
- const base = isPlainPatchObject(current) ? structuredClone(current) : {};
538
- for (const [key, value] of Object.entries(patch)) {
539
- const merged = mergePlanPatchValue(base[key], value);
540
- if (merged === DELETE_PATCH_VALUE) {
541
- delete base[key];
619
+ async function applyPlanMutationOperations(input) {
620
+ let current = structuredClone(input.initialPlan);
621
+ let completedOperations = 0;
622
+ let failedOperation;
623
+ for (let index = 0; index < input.operations.length; index += 1) {
624
+ const operation = input.operations[index];
625
+ const candidate = structuredClone(current);
626
+ try {
627
+ switch (operation.type) {
628
+ case "plan.update":
629
+ applyPlanFieldUpdates(candidate, operation.updates);
630
+ break;
631
+ case "plan.status": {
632
+ const status = normalizePlanStatus(operation.status);
633
+ if (!status) {
634
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Unsupported plan status "${operation.status}".`);
635
+ }
636
+ const validation = canSetPlanStatus(current.status, status);
637
+ if (!validation.ok) {
638
+ throw new ClawError(validation.code, validation.error);
639
+ }
640
+ candidate.status = status;
641
+ if (current.status !== "end.completed" && status === "end.completed") {
642
+ candidate.completedAt = new Date().toISOString();
643
+ }
644
+ else if (current.status === "end.completed" && status !== "end.completed") {
645
+ delete candidate.completedAt;
646
+ }
647
+ break;
648
+ }
649
+ case "task.add": {
650
+ if (isEnd(current.status)) {
651
+ candidate.status = "prepare.requirements";
652
+ delete candidate.completedAt;
653
+ }
654
+ const [task] = normalizePlanTasks([{
655
+ title: operation.title,
656
+ ...(operation.detail !== undefined ? { detail: operation.detail } : {}),
657
+ status: "pending",
658
+ }], nextAvailableTaskId(candidate.tasks));
659
+ validatePlanTask(task);
660
+ candidate.tasks.push(task);
661
+ break;
662
+ }
663
+ case "task.edit": {
664
+ if (operation.status !== undefined && !isProcess(candidate.status)) {
665
+ throw new ClawError("TASK_STATUS_FORBIDDEN_IN_NON_ACTIVE_PLAN", "Task progress can only be updated while plan.status is process.*. If requirements are already confirmed, move the plan to process.active first.", { planStatus: candidate.status, suggestedCommand: "claw plan resume" });
666
+ }
667
+ const task = candidate.tasks.find((item) => item.id === operation.id);
668
+ if (!task) {
669
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${operation.id} was not found in this plan.`);
670
+ }
671
+ if (operation.title !== undefined)
672
+ task.title = operation.title;
673
+ if (operation.detail !== undefined)
674
+ task.detail = operation.detail;
675
+ if (operation.status !== undefined) {
676
+ validatePlanTaskStatus(operation.status);
677
+ task.status = operation.status;
678
+ if (operation.choiceId !== undefined) {
679
+ task.choiceId = operation.choiceId;
680
+ }
681
+ else if (operation.status !== "done") {
682
+ delete task.choiceId;
683
+ }
684
+ }
685
+ else if (operation.choiceId !== undefined) {
686
+ throw new ClawError("PROJECT_CONFIG_INVALID", "taskChoiceId requires taskStatus=done in the same task edit group.");
687
+ }
688
+ validatePlanTask(task);
689
+ break;
690
+ }
691
+ case "task.remove": {
692
+ const taskIndex = candidate.tasks.findIndex((item) => item.id === operation.id);
693
+ if (taskIndex < 0) {
694
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${operation.id} was not found in this plan.`);
695
+ }
696
+ candidate.tasks.splice(taskIndex, 1);
697
+ break;
698
+ }
542
699
  }
543
- else {
544
- base[key] = merged;
700
+ candidate.requirements = normalizePlanRequirements(candidate.requirements);
701
+ candidate.tasks = normalizePlanTasks(candidate.tasks);
702
+ await validateDoneTransitions({
703
+ projectRoot: input.projectRoot,
704
+ previousPlan: current,
705
+ nextPlan: candidate,
706
+ });
707
+ validatePlanDocument(candidate);
708
+ if (candidate.status === "end.completed" && !candidate.retrospective?.summary?.trim()) {
709
+ throw new ClawError("RETROSPECTIVE_REQUIRED", "end.completed requires retrospective.summary before the plan can be completed.");
545
710
  }
711
+ current = candidate;
712
+ completedOperations += 1;
713
+ }
714
+ catch (error) {
715
+ const clawError = error instanceof ClawError
716
+ ? error
717
+ : new ClawError("PROJECT_CONFIG_INVALID", error instanceof Error ? error.message : String(error));
718
+ failedOperation = {
719
+ index,
720
+ type: operation.type,
721
+ error: {
722
+ code: clawError.code,
723
+ message: clawError.message,
724
+ ...(clawError.details ? { details: clawError.details } : {}),
725
+ },
726
+ };
727
+ break;
546
728
  }
547
- return base;
548
729
  }
549
- return patch;
730
+ return {
731
+ plan: current,
732
+ result: {
733
+ status: failedOperation ? "partial" : "completed",
734
+ completedOperations,
735
+ remainingOperations: input.operations.length - completedOperations - (failedOperation ? 1 : 0),
736
+ ...(failedOperation ? { failedOperation } : {}),
737
+ },
738
+ };
550
739
  }
551
- function applyPlanPatch(target, patch) {
552
- const merged = mergePlanPatchValue(target, patch);
553
- if (!isPlainPatchObject(merged)) {
554
- throw new ClawError("PROJECT_CONFIG_INVALID", "Plan patch must be a JSON object.");
740
+ function applyPlanFieldUpdates(target, updates) {
741
+ if (updates.goalText !== undefined) {
742
+ target.goal.text = updates.goalText;
743
+ }
744
+ if (updates.requirementsSummary !== undefined
745
+ || updates.openQuestions?.length
746
+ || updates.removeOpenQuestions?.length
747
+ || updates.acceptanceCriteria?.length
748
+ || updates.removeAcceptanceCriteria?.length) {
749
+ target.requirements = normalizePlanRequirements(target.requirements);
750
+ if (updates.requirementsSummary !== undefined) {
751
+ target.requirements.summary = updates.requirementsSummary;
752
+ }
753
+ if (updates.openQuestions?.length) {
754
+ target.requirements.openQuestions.push(...updates.openQuestions);
755
+ }
756
+ if (updates.removeOpenQuestions?.length) {
757
+ target.requirements.openQuestions = removeRequiredStrings(target.requirements.openQuestions, updates.removeOpenQuestions, "open question");
758
+ }
759
+ if (updates.acceptanceCriteria?.length) {
760
+ target.requirements.acceptanceCriteria.push(...updates.acceptanceCriteria);
761
+ }
762
+ if (updates.removeAcceptanceCriteria?.length) {
763
+ target.requirements.acceptanceCriteria = removeRequiredStrings(target.requirements.acceptanceCriteria, updates.removeAcceptanceCriteria, "acceptance criterion");
764
+ }
765
+ }
766
+ if (updates.planSummary !== undefined) {
767
+ target.summary = updates.planSummary;
768
+ }
769
+ if (updates.rules?.length) {
770
+ target.rules = [...(target.rules ?? []), ...updates.rules];
771
+ }
772
+ if (updates.removeRules?.length) {
773
+ target.rules = removeRequiredStrings(target.rules ?? [], updates.removeRules, "rule");
555
774
  }
556
- for (const key of Object.keys(target)) {
557
- delete target[key];
775
+ if (updates.keyDecisions?.length) {
776
+ target.keyDecisions = [...(target.keyDecisions ?? []), ...updates.keyDecisions];
777
+ }
778
+ if (updates.removeKeyDecisions?.length) {
779
+ target.keyDecisions = removeRequiredStrings(target.keyDecisions ?? [], updates.removeKeyDecisions, "key decision");
780
+ }
781
+ if (updates.references?.length) {
782
+ target.references = [...(target.references ?? []), ...updates.references];
783
+ }
784
+ if (updates.removeReferencePaths?.length) {
785
+ const currentReferences = target.references ?? [];
786
+ const retainedPaths = removeRequiredStrings(currentReferences.map((item) => item.path), updates.removeReferencePaths, "reference path");
787
+ const retained = new Set(retainedPaths);
788
+ target.references = currentReferences.filter((item) => retained.has(item.path));
789
+ }
790
+ if (updates.retrospectiveSummary !== undefined
791
+ || updates.whatWorked?.length
792
+ || updates.issues?.length
793
+ || updates.followUps?.length) {
794
+ target.retrospective = target.retrospective ?? { summary: "" };
795
+ if (updates.retrospectiveSummary !== undefined) {
796
+ target.retrospective.summary = updates.retrospectiveSummary;
797
+ }
798
+ if (updates.whatWorked?.length) {
799
+ target.retrospective.whatWorked = [...(target.retrospective.whatWorked ?? []), ...updates.whatWorked];
800
+ }
801
+ if (updates.issues?.length) {
802
+ target.retrospective.issues = [...(target.retrospective.issues ?? []), ...updates.issues];
803
+ }
804
+ if (updates.followUps?.length) {
805
+ target.retrospective.followUps = [...(target.retrospective.followUps ?? []), ...updates.followUps];
806
+ }
807
+ }
808
+ }
809
+ function removeRequiredStrings(current, requested, label) {
810
+ const requestedValues = [...new Set(requested)];
811
+ const missing = requestedValues.filter((value) => !current.includes(value));
812
+ if (missing.length > 0) {
813
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Cannot remove ${label}; exact value not found: ${missing.map((value) => JSON.stringify(value)).join(", ")}.`, { label, missing });
558
814
  }
559
- Object.assign(target, merged);
815
+ const removed = new Set(requestedValues);
816
+ return current.filter((value) => !removed.has(value));
560
817
  }
561
818
  async function createSeedPlan(projectRoot, projectConfig, templateName, taskName, title, goalText, status = "prepare.requirements", forcePlanning = false, host) {
562
819
  const effectiveTemplateName = templateName?.trim() || projectConfig?.defaultPlanTemplate?.trim() || defaultPlanTemplateName();