@veewo/claw-core 0.1.62 → 0.1.63

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 (41) hide show
  1. package/README.md +16 -16
  2. package/dist/src/context.js +1 -0
  3. package/dist/src/context.js.map +1 -1
  4. package/dist/src/effective-config.d.ts +2 -0
  5. package/dist/src/effective-config.js +10 -0
  6. package/dist/src/effective-config.js.map +1 -0
  7. package/dist/src/embedding-config.d.ts +9 -0
  8. package/dist/src/embedding-config.js +21 -0
  9. package/dist/src/embedding-config.js.map +1 -0
  10. package/dist/src/errors.d.ts +1 -1
  11. package/dist/src/errors.js.map +1 -1
  12. package/dist/src/index.d.ts +1 -0
  13. package/dist/src/index.js +1 -0
  14. package/dist/src/index.js.map +1 -1
  15. package/dist/src/init.js +1 -0
  16. package/dist/src/init.js.map +1 -1
  17. package/dist/src/io.d.ts +4 -0
  18. package/dist/src/io.js +77 -0
  19. package/dist/src/io.js.map +1 -1
  20. package/dist/src/paths.d.ts +1 -0
  21. package/dist/src/paths.js +25 -1
  22. package/dist/src/paths.js.map +1 -1
  23. package/dist/src/plan-templates.d.ts +25 -2
  24. package/dist/src/plan-templates.js +582 -28
  25. package/dist/src/plan-templates.js.map +1 -1
  26. package/dist/src/plan.js +301 -213
  27. package/dist/src/plan.js.map +1 -1
  28. package/dist/src/project-check.js +10 -0
  29. package/dist/src/project-check.js.map +1 -1
  30. package/dist/src/project-config-defaults.d.ts +6 -0
  31. package/dist/src/project-config-defaults.js +5 -0
  32. package/dist/src/project-config-defaults.js.map +1 -0
  33. package/dist/src/templates/plans/default.d.ts +44 -14
  34. package/dist/src/templates/plans/default.js +44 -10
  35. package/dist/src/templates/plans/default.js.map +1 -1
  36. package/dist/src/types.d.ts +15 -1
  37. package/dist/src/workflow-guidance.config.json +396 -395
  38. package/dist/src/workflow-guidance.d.ts +2 -1
  39. package/dist/src/workflow-guidance.js +166 -21
  40. package/dist/src/workflow-guidance.js.map +1 -1
  41. package/package.json +44 -44
package/dist/src/plan.js CHANGED
@@ -2,12 +2,13 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { buildCompletionHooks } from "./completion-hooks.js";
4
4
  import { ensureTaskMeta, resolveProjectContext, resolveTaskContext, resolveTaskName, saveTaskMeta } from "./context.js";
5
+ import { resolvePlanEffectiveConfig } from "./effective-config.js";
5
6
  import { ClawError } from "./errors.js";
6
- import { readJsonFile, withFileLock, writeJsonFile } from "./io.js";
7
+ import { readJsonFile, withFileLock, withSerializedAccess, writeJsonFile } from "./io.js";
7
8
  import { buildPlanEvent } from "./plan-events.js";
8
9
  import { isProcessStatus, } from "./requirements-gate.js";
9
10
  import { buildPlanViewModel } from "./plan-view.js";
10
- import { renderSeedTemplateText, resolveSeedPlanTemplate } from "./plan-templates.js";
11
+ import { getTemplateTaskDoneChoices, renderSeedTemplateText, resolveSeedPlanTemplate } from "./plan-templates.js";
11
12
  import { ensureInsideDir, normalizePlanFile, slugFromFilePath } from "./paths.js";
12
13
  import { buildGoalModeObjective, buildPlanWorkflowGuidance } from "./workflow-guidance.js";
13
14
  const PLAN_STATUSES = [
@@ -98,12 +99,13 @@ export async function writePlan(input) {
98
99
  eventType,
99
100
  ...(plan.parentPlan ? { parentPlan: plan.parentPlan } : {}),
100
101
  ...(plan.parentTaskId !== undefined ? { parentTaskId: plan.parentTaskId } : {}),
101
- workflowGuidance: buildPlanWorkflowGuidance({
102
+ workflowGuidance: await buildPlanWorkflowGuidance({
102
103
  taskName,
103
104
  planFile,
104
105
  plan,
105
106
  commandSource: input.parentTaskId !== undefined ? "subplan.create" : "plan.create",
106
- projectConfig: project.projectConfig,
107
+ projectRoot: project.projectRoot,
108
+ projectConfig: resolvePlanEffectiveConfig(project.projectConfig, plan),
107
109
  host: input.host,
108
110
  }),
109
111
  plan,
@@ -125,155 +127,186 @@ export async function editPlan(input) {
125
127
  planFile,
126
128
  });
127
129
  }
128
- const previous = normalizePlanDocument(readJsonFile(planPath));
129
- const previousStatus = previous.status;
130
- const events = [];
131
- const changedTaskIds = [];
132
- const completedTaskIds = [];
133
- const next = structuredClone(previous);
134
- const requestedStatus = input.planStatus ? normalizePlanStatus(input.planStatus) : undefined;
135
- if (requestedStatus) {
136
- const validation = canSetPlanStatus(previousStatus, requestedStatus);
137
- if (!validation.ok) {
138
- throw new ClawError(validation.code, validation.error);
130
+ if (input.patch?.tasks !== undefined && (input.taskId !== undefined || input.taskStatus !== undefined)) {
131
+ 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.");
132
+ }
133
+ return withSerializedAccess(planPath, async () => {
134
+ const previous = normalizePlanDocument(readJsonFile(planPath));
135
+ const previousStatus = previous.status;
136
+ const events = [];
137
+ const changedTaskIds = [];
138
+ const appendedTaskIds = [];
139
+ const completedTaskIds = [];
140
+ const next = structuredClone(previous);
141
+ const requestedStatus = input.planStatus ? normalizePlanStatus(input.planStatus) : undefined;
142
+ if (requestedStatus) {
143
+ const validation = canSetPlanStatus(previousStatus, requestedStatus);
144
+ if (!validation.ok) {
145
+ throw new ClawError(validation.code, validation.error);
146
+ }
147
+ next.status = requestedStatus;
139
148
  }
140
- next.status = requestedStatus;
141
- }
142
- if (input.patch) {
143
- applyPlanPatch(next, input.patch);
144
- }
145
- if (input.appendTasks?.length) {
146
- if (isEnd(previous.status) && !requestedStatus && input.patch?.status === undefined) {
147
- next.status = "prepare.requirements";
149
+ if (input.patch) {
150
+ applyPlanPatch(next, input.patch);
148
151
  }
149
- const currentIds = new Set(next.tasks.map((taskItem) => taskItem.id));
150
- const appendedTasks = normalizePlanTasks(input.appendTasks, nextAvailableTaskId(next.tasks));
151
- for (const taskItem of appendedTasks) {
152
- if (currentIds.has(taskItem.id)) {
153
- throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${taskItem.id} already exists in plan.`);
152
+ if (input.appendTasks?.length) {
153
+ if (isEnd(previous.status) && !requestedStatus && input.patch?.status === undefined) {
154
+ next.status = "prepare.requirements";
155
+ }
156
+ const currentIds = new Set(next.tasks.map((taskItem) => taskItem.id));
157
+ const appendedTasks = normalizePlanTasks(input.appendTasks, nextAvailableTaskId(next.tasks));
158
+ for (const taskItem of appendedTasks) {
159
+ if (currentIds.has(taskItem.id)) {
160
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${taskItem.id} already exists in plan.`);
161
+ }
162
+ validatePlanTask(taskItem);
163
+ next.tasks.push(taskItem);
164
+ currentIds.add(taskItem.id);
165
+ appendedTaskIds.push(taskItem.id);
154
166
  }
155
- validatePlanTask(taskItem);
156
- next.tasks.push(taskItem);
157
- currentIds.add(taskItem.id);
158
167
  }
159
- }
160
- if (input.taskId !== undefined || input.taskStatus !== undefined) {
161
- if (input.taskId === undefined || input.taskStatus === undefined) {
162
- throw new ClawError("PROJECT_CONFIG_INVALID", "taskId and taskStatus must be provided together when updating a plan task status.");
168
+ next.requirements = normalizePlanRequirements(next.requirements);
169
+ next.tasks = normalizePlanTasks(Array.isArray(next.tasks) ? next.tasks : []);
170
+ if (input.taskId !== undefined || input.taskStatus !== undefined) {
171
+ if (input.taskId === undefined || input.taskStatus === undefined) {
172
+ throw new ClawError("PROJECT_CONFIG_INVALID", "taskId and taskStatus must be provided together when updating a plan task status.");
173
+ }
174
+ if (!isProcess(next.status)) {
175
+ 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.", {
176
+ planStatus: next.status,
177
+ suggestedCommand: `claw plan edit --task ${task.taskName}${planFile === "plan.json" ? "" : ` --plan ${planFile}`} --plan-status process.active`,
178
+ });
179
+ }
180
+ const planTask = next.tasks.find((item) => item.id === input.taskId);
181
+ if (!planTask) {
182
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${input.taskId} was not found in this plan.`);
183
+ }
184
+ validatePlanTaskStatus(input.taskStatus);
185
+ const previousTaskStatus = planTask.status;
186
+ planTask.status = input.taskStatus;
187
+ if (input.taskChoiceId !== undefined) {
188
+ planTask.choiceId = input.taskChoiceId;
189
+ }
190
+ else if (input.taskStatus !== "done") {
191
+ delete planTask.choiceId;
192
+ }
193
+ changedTaskIds.push(planTask.id);
194
+ if (previousTaskStatus !== "done" && input.taskStatus === "done") {
195
+ completedTaskIds.push(planTask.id);
196
+ events.push(buildPlanEvent("plan_task_completed", {
197
+ planPath,
198
+ planTitle: next.title,
199
+ planStatus: next.status,
200
+ taskId: planTask.id,
201
+ affectedPlanTaskIds: [planTask.id],
202
+ }));
203
+ }
163
204
  }
164
- if (!isProcess(next.status)) {
165
- 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.", {
166
- planStatus: next.status,
167
- suggestedCommand: `claw plan edit --task ${task.taskName}${planFile === "plan.json" ? "" : ` --plan ${planFile}`} --plan-status process.active`,
168
- });
205
+ await validateDoneTransitions({
206
+ projectRoot: task.project.projectRoot,
207
+ previousPlan: previous,
208
+ nextPlan: next,
209
+ });
210
+ validatePlanDocument(next);
211
+ if (next.status === "end.completed" && !next.retrospective?.summary?.trim()) {
212
+ throw new ClawError("RETROSPECTIVE_REQUIRED", "end.completed requires retrospective.summary before the plan can be completed.");
169
213
  }
170
- const planTask = next.tasks.find((item) => item.id === input.taskId);
171
- if (!planTask) {
172
- throw new ClawError("PROJECT_CONFIG_INVALID", `Task id ${input.taskId} was not found in this plan.`);
214
+ withFileLock(planPath, () => {
215
+ const current = normalizePlanDocument(readJsonFile(planPath));
216
+ if (!plansMatch(current, previous)) {
217
+ throw new ClawError("PLAN_STALE_EDIT", `Plan "${planFile}" changed after this edit command read it. Re-run the edit after inspecting the latest plan state.`, {
218
+ taskName: task.taskName,
219
+ planFile,
220
+ planPath,
221
+ suggestedCommand: `claw plan show --task ${task.taskName}${planFile === "plan.json" ? "" : ` --plan ${planFile}`}`,
222
+ });
223
+ }
224
+ writeJsonFile(planPath, next);
225
+ });
226
+ task.meta.activePlan = planFile;
227
+ task.meta.rules = next.rules;
228
+ task.meta.status = taskMetaStatusForPlanStatus(next.status);
229
+ if (next.taskType !== undefined) {
230
+ task.meta.taskType = next.taskType;
173
231
  }
174
- validatePlanTaskStatus(input.taskStatus);
175
- const previousTaskStatus = planTask.status;
176
- planTask.status = input.taskStatus;
177
- changedTaskIds.push(planTask.id);
178
- if (previousTaskStatus !== "done" && input.taskStatus === "done") {
179
- completedTaskIds.push(planTask.id);
180
- events.push(buildPlanEvent("plan_task_completed", {
232
+ saveTaskMeta(task);
233
+ if (previousStatus !== next.status || input.patch || changedTaskIds.length > 0 || input.appendTasks?.length) {
234
+ events.unshift(buildPlanEvent("plan_changed", {
181
235
  planPath,
182
236
  planTitle: next.title,
183
237
  planStatus: next.status,
184
- taskId: planTask.id,
185
- affectedPlanTaskIds: [planTask.id],
238
+ previousStatus,
239
+ ...(changedTaskIds.length > 0 ? { affectedPlanTaskIds: changedTaskIds } : {}),
186
240
  }));
187
241
  }
188
- }
189
- validatePlanDocument(next);
190
- if (next.status === "end.completed" && !next.retrospective?.summary?.trim()) {
191
- throw new ClawError("RETROSPECTIVE_REQUIRED", "end.completed requires retrospective.summary before the plan can be completed.");
192
- }
193
- withFileLock(planPath, () => {
194
- writeJsonFile(planPath, next);
195
- });
196
- task.meta.activePlan = planFile;
197
- task.meta.rules = next.rules;
198
- task.meta.status = taskMetaStatusForPlanStatus(next.status);
199
- if (next.taskType !== undefined) {
200
- task.meta.taskType = next.taskType;
201
- }
202
- saveTaskMeta(task);
203
- if (previousStatus !== next.status || input.patch || changedTaskIds.length > 0 || input.appendTasks?.length) {
204
- events.unshift(buildPlanEvent("plan_changed", {
205
- planPath,
206
- planTitle: next.title,
207
- planStatus: next.status,
208
- previousStatus,
209
- ...(changedTaskIds.length > 0 ? { affectedPlanTaskIds: changedTaskIds } : {}),
210
- }));
211
- }
212
- const completionHooks = previousStatus !== "end.completed" && next.status === "end.completed"
213
- ? buildCompletionHooks({ task, planPath, plan: next })
214
- : undefined;
215
- if (completionHooks) {
216
- events.push(buildPlanEvent("plan_completed", {
217
- planPath,
218
- planTitle: next.title,
219
- planStatus: next.status,
220
- previousStatus,
221
- }));
222
- }
223
- let resultPlanFile = planFile;
224
- let resultPlanPath = planPath;
225
- let resultPlan = next;
226
- if (completionHooks?.subplanClosureCandidate) {
227
- const resumedParent = completeSubplanAndResumeParent({
228
- task,
229
- closure: completionHooks.subplanClosureCandidate,
230
- });
231
- resultPlanFile = resumedParent.planFile;
232
- resultPlanPath = resumedParent.planPath;
233
- resultPlan = resumedParent.plan;
234
- task.meta.activePlan = resumedParent.planFile;
235
- task.meta.rules = resumedParent.plan.rules;
236
- task.meta.status = taskMetaStatusForPlanStatus(resumedParent.plan.status);
237
- if (resumedParent.plan.taskType !== undefined) {
238
- task.meta.taskType = resumedParent.plan.taskType;
242
+ const completionHooks = previousStatus !== "end.completed" && next.status === "end.completed"
243
+ ? buildCompletionHooks({ task, planPath, plan: next })
244
+ : undefined;
245
+ if (completionHooks) {
246
+ events.push(buildPlanEvent("plan_completed", {
247
+ planPath,
248
+ planTitle: next.title,
249
+ planStatus: next.status,
250
+ previousStatus,
251
+ }));
239
252
  }
240
- else {
241
- delete task.meta.taskType;
253
+ let resultPlanFile = planFile;
254
+ let resultPlanPath = planPath;
255
+ let resultPlan = next;
256
+ if (completionHooks?.subplanClosureCandidate) {
257
+ const resumedParent = completeSubplanAndResumeParent({
258
+ task,
259
+ closure: completionHooks.subplanClosureCandidate,
260
+ });
261
+ resultPlanFile = resumedParent.planFile;
262
+ resultPlanPath = resumedParent.planPath;
263
+ resultPlan = resumedParent.plan;
264
+ task.meta.activePlan = resumedParent.planFile;
265
+ task.meta.rules = resumedParent.plan.rules;
266
+ task.meta.status = taskMetaStatusForPlanStatus(resumedParent.plan.status);
267
+ if (resumedParent.plan.taskType !== undefined) {
268
+ task.meta.taskType = resumedParent.plan.taskType;
269
+ }
270
+ else {
271
+ delete task.meta.taskType;
272
+ }
273
+ saveTaskMeta(task);
242
274
  }
243
- saveTaskMeta(task);
244
- }
245
- return {
246
- taskName: task.taskName,
247
- planPath: resultPlanPath,
248
- planFile: resultPlanFile,
249
- planStatus: resultPlan.status,
250
- previousPlanStatus: previousStatus,
251
- emittedEvents: events.map((event) => event.type),
252
- changedTaskIds,
253
- ...(completionHooks ? { completionHooks } : {}),
254
- workflowGuidance: buildPlanWorkflowGuidance({
255
- taskName: task.taskName,
256
- planFile: resultPlanFile,
257
- plan: resultPlan,
258
- commandSource: "plan.edit",
259
- projectConfig: task.project.projectConfig,
260
- host: input.host,
261
- ...(completionHooks?.subplanClosureCandidate
262
- ? {}
263
- : {
264
- previousStatus,
265
- completionHooks,
266
- changedTaskIds,
267
- completedTaskIds,
268
- }),
269
- }),
270
- planView: buildPlanViewModel({
275
+ return {
271
276
  taskName: task.taskName,
277
+ planPath: resultPlanPath,
272
278
  planFile: resultPlanFile,
273
- plan: resultPlan,
274
- }),
275
- events,
276
- };
279
+ planStatus: resultPlan.status,
280
+ previousPlanStatus: previousStatus,
281
+ emittedEvents: events.map((event) => event.type),
282
+ changedTaskIds,
283
+ appendedTaskIds,
284
+ ...(completionHooks ? { completionHooks } : {}),
285
+ workflowGuidance: await buildPlanWorkflowGuidance({
286
+ taskName: task.taskName,
287
+ planFile: resultPlanFile,
288
+ plan: resultPlan,
289
+ commandSource: "plan.edit",
290
+ projectRoot: task.project.projectRoot,
291
+ projectConfig: resolvePlanEffectiveConfig(task.project.projectConfig, resultPlan),
292
+ host: input.host,
293
+ ...(completionHooks?.subplanClosureCandidate
294
+ ? {}
295
+ : {
296
+ previousStatus,
297
+ completionHooks,
298
+ changedTaskIds,
299
+ completedTaskIds,
300
+ }),
301
+ }),
302
+ planView: buildPlanViewModel({
303
+ taskName: task.taskName,
304
+ planFile: resultPlanFile,
305
+ plan: resultPlan,
306
+ }),
307
+ events,
308
+ };
309
+ });
277
310
  }
278
311
  export function showPlan(input) {
279
312
  const resolved = resolveShowPlanTarget(input);
@@ -357,6 +390,9 @@ function normalizePlanRequirements(requirements) {
357
390
  acceptanceCriteria: Array.isArray(requirements?.acceptanceCriteria) ? requirements.acceptanceCriteria : [],
358
391
  };
359
392
  }
393
+ function plansMatch(left, right) {
394
+ return JSON.stringify(left) === JSON.stringify(right);
395
+ }
360
396
  function normalizePlanTasks(tasks, startId = 1) {
361
397
  const explicitIds = tasks
362
398
  .map((task) => task.id)
@@ -387,6 +423,7 @@ function normalizePlanTask(task, fallbackId) {
387
423
  const normalizedTask = {
388
424
  ...task,
389
425
  id: Number.isInteger(task.id) ? task.id : fallbackId ?? task.id,
426
+ status: task.status ?? "pending",
390
427
  };
391
428
  validatePlanTask(normalizedTask);
392
429
  return normalizedTask;
@@ -416,59 +453,55 @@ function validatePlanTask(task) {
416
453
  throw new ClawError("PROJECT_CONFIG_INVALID", `Plan task ${task.id} is missing title.`);
417
454
  }
418
455
  validatePlanTaskStatus(task.status);
456
+ if (task.choiceId !== undefined) {
457
+ if (typeof task.choiceId !== "string" || !task.choiceId.trim()) {
458
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Plan task ${task.id} has an invalid choiceId.`);
459
+ }
460
+ if (task.status !== "done") {
461
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Plan task ${task.id} can only use choiceId when status is done.`);
462
+ }
463
+ }
419
464
  }
420
465
  function validatePlanTaskStatus(status) {
421
466
  if (!PLAN_TASK_STATUSES.includes(status)) {
422
467
  throw new ClawError("PROJECT_CONFIG_INVALID", `Unsupported plan task status "${status}". Canonical values are pending, in_progress, subagent_running, done, blocked.`);
423
468
  }
424
469
  }
425
- function applyPlanPatch(target, patch) {
426
- if (patch.title !== undefined) {
427
- target.title = patch.title;
428
- }
429
- if (patch.goal !== undefined) {
430
- target.goal = { text: patch.goal.text };
431
- }
432
- if (patch.summary !== undefined) {
433
- target.summary = patch.summary;
434
- }
435
- if (patch.requirements !== undefined) {
436
- target.requirements = normalizePlanRequirements(patch.requirements);
437
- }
438
- if (patch.taskType !== undefined) {
439
- target.taskType = patch.taskType;
440
- }
441
- if (patch.leaveReason !== undefined) {
442
- target.leaveReason = patch.leaveReason;
443
- }
444
- if (patch.parentPlan !== undefined) {
445
- target.parentPlan = patch.parentPlan;
446
- }
447
- if (patch.parentTaskId !== undefined) {
448
- target.parentTaskId = patch.parentTaskId;
449
- }
450
- if (patch.references !== undefined) {
451
- target.references = patch.references;
452
- }
453
- if (patch.rules !== undefined) {
454
- target.rules = patch.rules;
455
- }
456
- if (patch.retrospective !== undefined) {
457
- target.retrospective = patch.retrospective;
458
- }
459
- if (patch.keyDecisions !== undefined) {
460
- target.keyDecisions = patch.keyDecisions;
470
+ const DELETE_PATCH_VALUE = Symbol("delete-plan-patch-value");
471
+ function isPlainPatchObject(value) {
472
+ return typeof value === "object" && value !== null && !Array.isArray(value);
473
+ }
474
+ function mergePlanPatchValue(current, patch) {
475
+ if (patch === null) {
476
+ return DELETE_PATCH_VALUE;
477
+ }
478
+ if (Array.isArray(patch)) {
479
+ return structuredClone(patch);
480
+ }
481
+ if (isPlainPatchObject(patch)) {
482
+ const base = isPlainPatchObject(current) ? structuredClone(current) : {};
483
+ for (const [key, value] of Object.entries(patch)) {
484
+ const merged = mergePlanPatchValue(base[key], value);
485
+ if (merged === DELETE_PATCH_VALUE) {
486
+ delete base[key];
487
+ }
488
+ else {
489
+ base[key] = merged;
490
+ }
491
+ }
492
+ return base;
461
493
  }
462
- if (patch.tasks !== undefined) {
463
- target.tasks = normalizePlanTasks(patch.tasks);
494
+ return patch;
495
+ }
496
+ function applyPlanPatch(target, patch) {
497
+ const merged = mergePlanPatchValue(target, patch);
498
+ if (!isPlainPatchObject(merged)) {
499
+ throw new ClawError("PROJECT_CONFIG_INVALID", "Plan patch must be a JSON object.");
464
500
  }
465
- if (patch.status !== undefined) {
466
- const normalized = normalizePlanStatus(patch.status);
467
- if (!normalized) {
468
- throw new ClawError("PLAN_STATUS_INVALID", `Unsupported plan status "${String(patch.status)}".`);
469
- }
470
- target.status = normalized;
501
+ for (const key of Object.keys(target)) {
502
+ delete target[key];
471
503
  }
504
+ Object.assign(target, merged);
472
505
  }
473
506
  async function createSeedPlan(projectRoot, projectConfig, templateName, taskName, title, goalText, status = "prepare.requirements", forcePlanning = false, host) {
474
507
  const effectiveTemplateName = templateName?.trim() || projectConfig?.defaultPlanTemplate?.trim() || defaultPlanTemplateName();
@@ -476,12 +509,17 @@ async function createSeedPlan(projectRoot, projectConfig, templateName, taskName
476
509
  projectRoot,
477
510
  templateName: effectiveTemplateName,
478
511
  });
479
- const planningEnabled = forcePlanning || projectConfig?.planning !== false;
480
- const planningSkill = projectConfig?.externalPlanningSkill?.trim() || "the built-in planning skill";
512
+ const effectiveConfig = resolvePlanEffectiveConfig(projectConfig, {
513
+ configOverride: template.configOverride,
514
+ });
515
+ const planningEnabled = forcePlanning || effectiveConfig?.planning !== false;
516
+ const planningSkill = effectiveConfig?.externalPlanningSkill?.trim() || "the built-in planning skill";
481
517
  if (!planningEnabled) {
482
518
  return {
483
519
  title: title ?? taskName,
484
- status: template.planningDisabledStatus,
520
+ templateId: template.id,
521
+ ...(template.configOverride ? { configOverride: template.configOverride } : {}),
522
+ status: "process.active",
485
523
  goal: {
486
524
  text: goalText ?? title ?? taskName,
487
525
  },
@@ -505,47 +543,84 @@ async function createSeedPlan(projectRoot, projectConfig, templateName, taskName
505
543
  },
506
544
  };
507
545
  }
508
- const effectiveGoalText = goalText ?? title ?? taskName;
509
- const activationDetail = buildActivationTaskDetail({
510
- baseDetail: template.activationTask.detail,
511
- goalModeDetail: template.activationTask.goalModeDetail,
512
- host,
513
- goalModeEnabled: projectConfig?.goalMode !== false,
514
- planGoal: effectiveGoalText,
515
- });
546
+ const effectiveTitle = title ?? template.title ?? taskName;
547
+ const effectiveGoalText = goalText ?? (template.goal?.text?.trim() || effectiveTitle);
548
+ const compiledTasks = template.tasks.map((taskItem) => ({
549
+ id: taskItem.id,
550
+ title: taskItem.title,
551
+ ...(taskItem.detail || taskItem.goalModeDetail
552
+ ? {
553
+ detail: buildCompiledTaskDetail({
554
+ baseDetail: taskItem.detail,
555
+ goalModeDetail: taskItem.goalModeDetail,
556
+ host,
557
+ goalModeEnabled: effectiveConfig?.goalMode !== false,
558
+ planGoal: effectiveGoalText,
559
+ planningSkill,
560
+ }),
561
+ }
562
+ : {}),
563
+ status: taskItem.status,
564
+ ...(taskItem.execution ? { execution: taskItem.execution } : {}),
565
+ ...(taskItem.sessionKey ? { sessionKey: taskItem.sessionKey } : {}),
566
+ }));
516
567
  return {
517
- title: title ?? taskName,
518
- status: template.planningEnabledStatus ?? status,
568
+ title: effectiveTitle,
569
+ templateId: template.id,
570
+ ...(template.configOverride ? { configOverride: template.configOverride } : {}),
571
+ status: template.status ?? status,
519
572
  goal: {
520
573
  text: effectiveGoalText,
521
574
  },
522
- requirements: {
575
+ requirements: template.requirements ?? {
523
576
  summary: "",
524
577
  openQuestions: [],
525
578
  acceptanceCriteria: [],
526
579
  },
527
- tasks: [
528
- {
529
- id: 1,
530
- title: template.planningTask.title,
531
- detail: renderSeedTemplateText(template.planningTask.detail, { planningSkill }),
532
- status: "pending",
533
- },
534
- {
535
- id: 2,
536
- title: template.activationTask.title,
537
- detail: activationDetail,
538
- status: "pending",
539
- },
540
- ],
541
- references: [],
542
- rules: [],
543
- keyDecisions: [],
544
- retrospective: {
580
+ tasks: compiledTasks,
581
+ references: template.references ?? [],
582
+ rules: template.rules ?? [],
583
+ keyDecisions: template.keyDecisions ?? [],
584
+ retrospective: template.retrospective ?? {
545
585
  summary: "",
546
586
  },
547
587
  };
548
588
  }
589
+ async function validateDoneTransitions(params) {
590
+ const { projectRoot, previousPlan, nextPlan } = params;
591
+ if (!nextPlan.templateId?.trim()) {
592
+ return;
593
+ }
594
+ const template = await resolveSeedPlanTemplate({
595
+ projectRoot,
596
+ templateName: nextPlan.templateId,
597
+ });
598
+ const previousTasks = new Map(previousPlan.tasks.map((task) => [task.id, task]));
599
+ for (const task of nextPlan.tasks) {
600
+ const previousTask = previousTasks.get(task.id);
601
+ const choices = getTemplateTaskDoneChoices(template, task.id);
602
+ const availableChoices = choices ? Object.keys(choices) : [];
603
+ const isDoneTransition = previousTask?.status !== "done" && task.status === "done";
604
+ if (isDoneTransition && choices && !task.choiceId) {
605
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task ${task.id} requires choiceId because this template defines onDone choices. Provide one of: ${availableChoices.join(", ")}.`, {
606
+ taskId: task.id,
607
+ availableChoices,
608
+ });
609
+ }
610
+ if (task.choiceId && !choices) {
611
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task ${task.id} does not define onDone choices, so choiceId is not allowed.`, {
612
+ taskId: task.id,
613
+ });
614
+ }
615
+ if (task.choiceId && choices && !Object.prototype.hasOwnProperty.call(choices, task.choiceId)) {
616
+ throw new ClawError("PROJECT_CONFIG_INVALID", `Task ${task.id} has an invalid choiceId "${task.choiceId}". Expected one of: ${availableChoices.join(", ")}.`, {
617
+ taskId: task.id,
618
+ choiceId: task.choiceId,
619
+ availableChoices,
620
+ });
621
+ }
622
+ }
623
+ }
549
624
  function defaultPlanTemplateName() {
550
625
  return "default";
551
626
  }
@@ -562,6 +637,19 @@ function buildActivationTaskDetail(params) {
562
637
  : goalModeDetail;
563
638
  return `${baseDetail} ${normalizedGoalModeDetail} and use \`${buildGoalModeObjective(planGoal)}\` as the goal objective.`;
564
639
  }
640
+ function buildCompiledTaskDetail(params) {
641
+ const renderedBase = params.baseDetail ? renderSeedTemplateText(params.baseDetail, { planningSkill: params.planningSkill }) : "";
642
+ if (!params.goalModeDetail) {
643
+ return renderedBase || undefined;
644
+ }
645
+ return buildActivationTaskDetail({
646
+ baseDetail: renderedBase,
647
+ goalModeDetail: params.goalModeDetail,
648
+ host: params.host,
649
+ goalModeEnabled: params.goalModeEnabled,
650
+ planGoal: params.planGoal,
651
+ });
652
+ }
565
653
  function deriveTaskName(input) {
566
654
  if (input.taskName?.trim()) {
567
655
  return resolveTaskName(input.taskName);