@agent-plan/server 0.2.26 → 0.2.27

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/serve.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { watch, existsSync, readFileSync, statSync } from "node:fs";
2
- import { writeFile } from "node:fs/promises";
3
- import { dirname, join } from "node:path";
2
+ import { lstat, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
4
  import { networkInterfaces } from "node:os";
5
+ import { randomUUID } from "node:crypto";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { createAdaptorServer } from "@hono/node-server";
7
8
  import { Hono } from "hono";
8
9
  import { cors } from "hono/cors";
9
- import { ExportService, PlanStore, PlanStoreError, createFeatureId, createPhaseId, createChecklistItemId, createRequirementId, createTaskId, findPhaseByRef, normalizeSlug, withFeatureLock, needsMotivation, checkExplicitTaskStart, recommendNextTask, reconcileRequirementMacroTasks, RequirementMacroTaskError } from "@agent-plan/core";
10
+ import { ExportService, PlanStore, PlanStoreError, PlanStaleWriteError, PlanWriterBusyError, createFeatureId, createPhaseId, createChecklistItemId, createRequirementId, createTaskId, findPhaseByRef, normalizeSlug, withFeatureLock, needsMotivation, checkExplicitTaskStart, recommendNextTask, recommendNextWork, reconcileRequirementMacroTasks, RequirementMacroTaskError } from "@agent-plan/core";
10
11
  import { WsHub } from "./ws-hub.js";
11
12
  // ─── Watcher ────────────────────────────────────────────────────────────
12
13
  let watcherAbort = null;
@@ -33,6 +34,48 @@ async function resolveRequirementPhaseIds(store, linkedPhaseIds) {
33
34
  function macroTaskInputs(value) {
34
35
  return Array.isArray(value) ? value : null;
35
36
  }
37
+ function hasDefinedField(body, fields) {
38
+ return fields.some((field) => body[field] !== undefined);
39
+ }
40
+ function noMutableFieldsResponse(fields) {
41
+ return {
42
+ error: "NO_MUTABLE_FIELDS_RECEIVED",
43
+ updated: false,
44
+ reason: "no-mutable-fields",
45
+ mutableFields: [...fields],
46
+ };
47
+ }
48
+ async function resolveAcceptedDecisionTarget(store, targetType, rawTargetRef) {
49
+ if (targetType === "project")
50
+ return { ok: true, owner: { kind: "project" }, targetRef: "project", event: { type: "project" } };
51
+ const targetRef = rawTargetRef?.trim();
52
+ if (!targetRef)
53
+ return { ok: false, error: `targetRef is required for ${targetType} accepted decisions` };
54
+ const [featuresDocument, phases] = await Promise.all([store.loadFeatures(), store.loadAllPhases()]);
55
+ const features = featuresDocument.features;
56
+ if (targetType === "feature") {
57
+ const feature = features.find((entry) => entry.id === targetRef || `F${String(entry.number).padStart(3, "0")}`.toLowerCase() === targetRef.toLowerCase() || entry.shortId?.toLowerCase() === targetRef.toLowerCase());
58
+ if (!feature)
59
+ return { ok: false, error: `feature not found: ${targetRef}` };
60
+ return { ok: true, owner: { kind: "feature", featureId: feature.id }, targetRef: `F${String(feature.number).padStart(3, "0")}`, event: { type: "feature", featureId: feature.id } };
61
+ }
62
+ if (targetType === "phase") {
63
+ const phase = findPhaseByRef(phases, features, targetRef);
64
+ if (!phase)
65
+ return { ok: false, error: `phase not found: ${targetRef}` };
66
+ return { ok: true, owner: { kind: "phase", phaseId: phase.id }, targetRef: `P${String(phase.number).padStart(3, "0")}`, event: { type: "phase", phaseId: phase.id, featureId: phase.featureId ?? "" } };
67
+ }
68
+ const found = phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task }))).find(({ task }) => task.id === targetRef)
69
+ ?? (() => {
70
+ const taskNumberMatch = targetRef.match(/^T(\d+)$/i);
71
+ return taskNumberMatch
72
+ ? phases.flatMap((phase) => phase.tasks.map((task) => ({ phase, task }))).find(({ task }) => task.number === Number(taskNumberMatch[1]))
73
+ : undefined;
74
+ })();
75
+ if (!found)
76
+ return { ok: false, error: `task not found: ${targetRef}` };
77
+ return { ok: true, owner: { kind: "task", phaseId: found.phase.id, taskId: found.task.id }, targetRef: `P${String(found.phase.number).padStart(3, "0")}/T${String(found.task.number).padStart(3, "0")}`, event: { type: "task", phaseId: found.phase.id, taskId: found.task.id, featureId: found.phase.featureId ?? "" } };
78
+ }
36
79
  function nextTaskNumber(phase) {
37
80
  const numbers = phase.tasks.map((task) => task.number || 0).filter((n) => Number.isFinite(n));
38
81
  return (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
@@ -109,11 +152,15 @@ function startWatcher(planRoot, hubRef) {
109
152
  watcherAbort = ac;
110
153
  watcherHubRef = hubRef;
111
154
  try {
112
- watch(planRoot, { recursive: true, signal: ac.signal }, (_event, filename) => {
155
+ const watcher = watch(planRoot, { recursive: true, signal: ac.signal }, (_event, filename) => {
113
156
  if (filename && !filename.includes(".tmp.")) {
114
157
  hubRef.current?.broadcast({ type: "file-changed", data: { filename } });
115
158
  }
116
159
  });
160
+ watcher.on("error", () => {
161
+ // Planner lock directories are intentionally short-lived; recursive watchers
162
+ // may report ENOENT when a writer releases a lock during a scan.
163
+ });
117
164
  }
118
165
  catch {
119
166
  // recursive watch may fail on some systems
@@ -138,6 +185,18 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
138
185
  // phase file -> PlanStoreError/ENOENT) bubble up to the host process console
139
186
  // (the agent session). Convert it to a clean HTTP response instead.
140
187
  app.onError((err, c) => {
188
+ if (err instanceof PlanWriterBusyError) {
189
+ return c.json({ error: err.code, message: err.message, details: err.details }, 423);
190
+ }
191
+ if (err instanceof PlanStaleWriteError) {
192
+ return c.json({ error: err.code, message: err.message, details: err.details }, 409);
193
+ }
194
+ if (err instanceof PlanStoreError && (err.details?.errorCode === "PLAN_UNSUPPORTED_ALLOCATION_KIND" || err.details?.errorCode === "PLAN_RUNTIME_SCHEMA_INCOMPATIBLE")) {
195
+ return c.json({ error: err.details.errorCode, message: err.message, details: err.details }, 409);
196
+ }
197
+ if (err instanceof PlanStoreError && typeof err.details?.errorCode === "string" && err.details.errorCode.startsWith("ACCEPTED_DECISION_")) {
198
+ return c.json({ error: err.details.errorCode, message: err.message, details: err.details }, err.details.errorCode === "ACCEPTED_DECISION_NOT_FOUND" ? 404 : 400);
199
+ }
141
200
  const isStoreRead = err instanceof PlanStoreError || /ENOENT|read failed/i.test(err.message);
142
201
  if (isStoreRead) {
143
202
  return c.json({ error: "not found", message: err.message }, 404);
@@ -167,10 +226,12 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
167
226
  app.get(route("/export"), async (c) => {
168
227
  const full = c.req.query("full") === "true";
169
228
  const exportService = new ExportService();
170
- const plan = await store.loadAll();
171
- const markdown = exportService.exportToMarkdown(plan, full);
172
- // Save to disk
173
- await writeFile(join(store.root, "EXPORT.md"), markdown, "utf-8");
229
+ const markdown = await store.runBatch(async () => {
230
+ const plan = await store.loadAll();
231
+ const rendered = exportService.exportToMarkdown(plan, full);
232
+ await writeFile(join(store.root, "EXPORT.md"), rendered, "utf-8");
233
+ return rendered;
234
+ });
174
235
  return c.json({ markdown, filePath: "EXPORT.md" });
175
236
  });
176
237
  app.get(route("/project"), async (c) => {
@@ -183,7 +244,34 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
183
244
  });
184
245
  app.put(route("/project"), async (c) => {
185
246
  const body = await c.req.json();
186
- const persisted = await store.updateProject(() => body);
247
+ const existingProject = await store.loadProject();
248
+ if (body.acceptedDecisions !== undefined && JSON.stringify(body.acceptedDecisions) !== JSON.stringify(existingProject.acceptedDecisions))
249
+ return c.json({ updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", message: "Raw acceptedDecisions replacement is disabled. Use the accepted-decisions semantic create, update, or delete endpoints so IDs and acceptedAt are preserved." }, 400);
250
+ const mutableFields = ["name", "goal", "description", "descriptionRef", "webPort", "scope", "outOfScope", "decisions", "globalRules", "technologies", "tools", "contentLanguage", "chatLanguage", "workflowRules", "projectGuidelines"];
251
+ if (!hasDefinedField(body, mutableFields))
252
+ return c.json(noMutableFieldsResponse(mutableFields), 400);
253
+ const persisted = await store.updateProject((current) => ({
254
+ ...current,
255
+ ...(body.name !== undefined ? { name: body.name } : {}),
256
+ ...(body.goal !== undefined ? { goal: body.goal } : {}),
257
+ ...(body.description !== undefined ? { description: body.description } : {}),
258
+ ...(body.descriptionRef !== undefined ? { descriptionRef: body.descriptionRef.trim() || undefined } : {}),
259
+ ...(body.webPort !== undefined ? { webPort: body.webPort } : {}),
260
+ ...(body.scope !== undefined ? { scope: body.scope } : {}),
261
+ ...(body.outOfScope !== undefined ? { outOfScope: body.outOfScope } : {}),
262
+ ...(body.decisions !== undefined ? { decisions: body.decisions } : {}),
263
+ ...(body.globalRules !== undefined ? { globalRules: body.globalRules } : {}),
264
+ ...(body.technologies !== undefined ? { technologies: body.technologies } : {}),
265
+ ...(body.tools !== undefined ? { tools: body.tools } : {}),
266
+ ...(body.contentLanguage !== undefined ? { contentLanguage: body.contentLanguage } : {}),
267
+ ...(body.chatLanguage !== undefined ? { chatLanguage: body.chatLanguage } : {}),
268
+ ...(body.workflowRules !== undefined ? { workflowRules: body.workflowRules } : {}),
269
+ ...(body.projectGuidelines !== undefined
270
+ ? { projectGuidelines: { ...current.projectGuidelines, content: body.projectGuidelines.content } }
271
+ : {}),
272
+ }), body.projectGuidelines !== undefined && body.expectedGuidelinesUpdatedAt !== undefined
273
+ ? { expectedGuidelinesUpdatedAt: body.expectedGuidelinesUpdatedAt }
274
+ : {});
187
275
  await store.writeGenerated();
188
276
  hub()?.broadcast({ type: "project-updated", data: persisted });
189
277
  hub()?.broadcast({ type: "plan-rendered", data: {} });
@@ -206,10 +294,78 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
206
294
  hub()?.broadcast({ type: "plan-rendered", data: {} });
207
295
  return c.json(result);
208
296
  });
297
+ const broadcastAcceptedDecisionMutation = (event, action) => {
298
+ if (event.type === "project")
299
+ hub()?.broadcast({ type: "project-updated", data: { action } });
300
+ if (event.type === "feature")
301
+ hub()?.broadcast({ type: "features-updated", data: { action, id: event.featureId, featureId: event.featureId } });
302
+ if (event.type === "phase")
303
+ hub()?.broadcast({ type: "phases-updated", data: { action, id: event.phaseId, phaseId: event.phaseId, featureId: event.featureId } });
304
+ if (event.type === "task")
305
+ hub()?.broadcast({ type: "phases-updated", data: { action: `task-${action}`, id: event.phaseId, phaseId: event.phaseId, featureId: event.featureId, taskId: event.taskId } });
306
+ hub()?.broadcast({ type: "plan-rendered", data: {} });
307
+ };
308
+ app.post(route("/accepted-decisions"), async (c) => {
309
+ const body = await c.req.json();
310
+ if (!body.targetType)
311
+ return c.json({ error: "targetType required" }, 400);
312
+ const target = await resolveAcceptedDecisionTarget(store, body.targetType, body.targetRef);
313
+ if (!target.ok)
314
+ return c.json({ error: target.error, created: false }, 404);
315
+ const createInput = {
316
+ title: body.title ?? "",
317
+ ...(body.decision !== undefined ? { decision: body.decision } : {}),
318
+ ...(body.rationale !== undefined ? { rationale: body.rationale } : {}),
319
+ ...(body.implementationNotes !== undefined ? { implementationNotes: body.implementationNotes } : {}),
320
+ };
321
+ const acceptedDecision = await store.createAcceptedDecision(target.owner, createInput);
322
+ await store.writeGenerated();
323
+ broadcastAcceptedDecisionMutation(target.event, "created");
324
+ return c.json({ acceptedDecision, created: true, targetRef: target.targetRef }, 201);
325
+ });
326
+ app.put(route("/accepted-decisions/:decisionId"), async (c) => {
327
+ const decisionId = c.req.param("decisionId") ?? "";
328
+ const body = await c.req.json();
329
+ if (!body.targetType)
330
+ return c.json({ error: "targetType required", updated: false }, 400);
331
+ const mutableFields = ["title", "decision", "rationale", "implementationNotes"];
332
+ if (!hasDefinedField(body, mutableFields))
333
+ return c.json(noMutableFieldsResponse(mutableFields), 400);
334
+ const target = await resolveAcceptedDecisionTarget(store, body.targetType, body.targetRef);
335
+ if (!target.ok)
336
+ return c.json({ error: target.error, updated: false }, 404);
337
+ const updateInput = {
338
+ ...(body.title !== undefined ? { title: body.title } : {}),
339
+ ...(body.decision !== undefined ? { decision: body.decision } : {}),
340
+ ...(body.rationale !== undefined ? { rationale: body.rationale } : {}),
341
+ ...(body.implementationNotes !== undefined ? { implementationNotes: body.implementationNotes } : {}),
342
+ };
343
+ const acceptedDecision = await store.updateAcceptedDecision(target.owner, decisionId, updateInput);
344
+ await store.writeGenerated();
345
+ broadcastAcceptedDecisionMutation(target.event, "updated");
346
+ return c.json({ acceptedDecision, updated: true, targetRef: target.targetRef });
347
+ });
348
+ app.delete(route("/accepted-decisions/:decisionId"), async (c) => {
349
+ const decisionId = c.req.param("decisionId") ?? "";
350
+ const body = await c.req.json().catch(() => ({}));
351
+ if (!body.targetType)
352
+ return c.json({ error: "targetType required", deleted: false }, 400);
353
+ const target = await resolveAcceptedDecisionTarget(store, body.targetType, body.targetRef);
354
+ if (!target.ok)
355
+ return c.json({ error: target.error, deleted: false }, 404);
356
+ if (body.confirmed !== true)
357
+ return c.json({ error: "confirmation required", deleted: false, confirmRequired: true }, 400);
358
+ const acceptedDecision = await store.deleteAcceptedDecision(target.owner, decisionId);
359
+ await store.writeGenerated();
360
+ broadcastAcceptedDecisionMutation(target.event, "deleted");
361
+ return c.json({ acceptedDecision, deleted: true, decisionId, targetRef: target.targetRef });
362
+ });
209
363
  // ── Requirements ─────────────────────────────────────────────────
210
364
  app.get(route("/requirements"), async (c) => c.json(await store.loadRequirements()));
211
365
  app.post(route("/requirements"), async (c) => {
212
366
  const body = await c.req.json();
367
+ if (body.status !== undefined)
368
+ return c.json({ error: "Requirements are declarative product outcomes and have no lifecycle status.", errorCode: "REQUIREMENT_STATUS_REMOVED" }, 400);
213
369
  const title = body.title?.trim();
214
370
  if (!title)
215
371
  return c.json({ error: "title required" }, 400);
@@ -232,7 +388,6 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
232
388
  id: createRequirementId(),
233
389
  title,
234
390
  description: body.description?.trim() ?? "",
235
- status: body.status ?? "planned",
236
391
  macroTasks,
237
392
  linkedPhaseIds: links.linkedPhaseIds,
238
393
  createdAt: now,
@@ -245,16 +400,21 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
245
400
  hub()?.broadcast({ type: "plan-rendered", data: {} });
246
401
  return c.json(requirement, 201);
247
402
  });
248
- app.put(route("/requirements/:id"), async (c) => {
403
+ app.put(route("/requirements/:id"), async (c) => store.runBatch(async () => {
249
404
  const id = c.req.param("id");
250
405
  if (!id)
251
406
  return c.json({ error: "id required" }, 400);
252
407
  const body = await c.req.json();
408
+ if (body.status !== undefined)
409
+ return c.json({ error: "Requirements are declarative product outcomes and have no lifecycle status.", errorCode: "REQUIREMENT_STATUS_REMOVED" }, 400);
253
410
  const existing = (await store.loadRequirements()).requirements.find((requirement) => requirement.id === id);
254
411
  if (!existing)
255
412
  return c.json({ error: "not found" }, 404);
413
+ const mutableFields = ["title", "description", "linkedPhaseIds", "macroTasks"];
414
+ if (!hasDefinedField(body, mutableFields))
415
+ return c.json(noMutableFieldsResponse(mutableFields), 400);
256
416
  const title = body.title?.trim();
257
- if (!title)
417
+ if (body.title !== undefined && !title)
258
418
  return c.json({ error: "title required" }, 400);
259
419
  const links = await resolveRequirementPhaseIds(store, body.linkedPhaseIds ?? existing.linkedPhaseIds);
260
420
  if ("error" in links)
@@ -271,23 +431,19 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
271
431
  const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
272
432
  return c.json({ error: message }, 400);
273
433
  }
274
- const requirement = {
275
- ...existing,
276
- title,
277
- description: body.description?.trim() ?? "",
278
- status: body.status ?? existing.status,
279
- macroTasks,
280
- linkedPhaseIds: links.linkedPhaseIds,
434
+ const requirement = await store.updateRequirement(id, (current) => ({
435
+ ...current,
436
+ ...(title !== undefined ? { title } : {}),
437
+ ...(body.description !== undefined ? { description: body.description.trim() } : {}),
438
+ ...(body.macroTasks !== undefined ? { macroTasks } : {}),
439
+ ...(body.linkedPhaseIds !== undefined ? { linkedPhaseIds: links.linkedPhaseIds } : {}),
281
440
  updatedAt: now,
282
- };
283
- await store.updateRequirements((doc) => ({
284
- requirements: doc.requirements.map((candidate) => candidate.id === id ? requirement : candidate),
285
- }));
441
+ }), body.expectedUpdatedAt !== undefined ? { expectedUpdatedAt: body.expectedUpdatedAt } : {});
286
442
  await store.writeGenerated();
287
443
  hub()?.broadcast({ type: "requirements-updated", data: { action: "updated", id, requirement } });
288
444
  hub()?.broadcast({ type: "plan-rendered", data: {} });
289
445
  return c.json(requirement);
290
- });
446
+ }));
291
447
  app.delete(route("/requirements/:id"), async (c) => {
292
448
  const id = c.req.param("id");
293
449
  let found = false;
@@ -321,13 +477,16 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
321
477
  if (!id)
322
478
  return c.json({ error: "id required" }, 400);
323
479
  const body = await c.req.json();
480
+ const mutableFields = ["title", "description"];
481
+ if (!hasDefinedField(body, mutableFields))
482
+ return c.json(noMutableFieldsResponse(mutableFields), 400);
324
483
  const title = body.title?.trim();
325
- if (!title)
326
- return c.json({ error: "title required" }, 400);
484
+ if (body.title !== undefined && !title)
485
+ return c.json({ error: "title required", updated: false }, 400);
327
486
  const current = (await store.loadIdeas()).ideas.find((idea) => idea.id === id);
328
487
  if (!current)
329
488
  return c.json({ error: "not found" }, 404);
330
- const idea = await store.updateIdea(id, { title, description: body.description?.trim() ?? "" });
489
+ const idea = await store.updateIdea(id, { ...(title !== undefined ? { title } : {}), ...(body.description !== undefined ? { description: body.description.trim() } : {}) });
331
490
  await store.writeGenerated();
332
491
  hub()?.broadcast({ type: "file-changed", data: { filename: "ideas.json" } });
333
492
  hub()?.broadcast({ type: "plan-rendered", data: {} });
@@ -346,9 +505,73 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
346
505
  hub()?.broadcast({ type: "plan-rendered", data: {} });
347
506
  return c.json({ deleted: id });
348
507
  });
508
+ // ── Hierarchical description freshness ──────────────────────────
509
+ app.get(route("/description-freshness"), async (c) => c.json(await store.previewDescriptionReconciliation()));
510
+ // ── Planner Markdown docs (path-safe viewer/editor) ─────────────
511
+ // Reads/writes are restricted to <planRoot>/docs/*.md. Traversal and
512
+ // symlink escapes are rejected. The Web UI opens viewer links in a new
513
+ // tab; saves require explicit confirmed=true. Editor baseline is a
514
+ // dependency-free textarea plus existing Markdown preview (no external
515
+ // editor dependency; see docs decision in T385: @uiw/react-md-editor
516
+ // and CodeMirror 6 were evaluated and rejected to avoid bundle cost).
517
+ function resolvePlannerDocPath(planRoot, rawPath) {
518
+ const normalized = rawPath.trim().replace(/\\/g, "/");
519
+ if (!/^\.planner\/docs\/.+\.md$/i.test(normalized) || normalized.includes("/../")) {
520
+ throw new Error(`Document path must be a Markdown file under .planner/docs/: ${rawPath}`);
521
+ }
522
+ const docsRoot = resolve(planRoot, "docs");
523
+ const target = resolve(planRoot, normalized.slice(".planner/".length));
524
+ if (!target.startsWith(`${docsRoot}${sep}`)) {
525
+ throw new Error(`Document escapes .planner/docs/: ${normalized}`);
526
+ }
527
+ return target;
528
+ }
529
+ app.get(route("/docs/view"), async (c) => {
530
+ const rawPath = c.req.query("path") ?? "";
531
+ let target;
532
+ try {
533
+ target = resolvePlannerDocPath(store.root, rawPath);
534
+ }
535
+ catch (error) {
536
+ return c.json({ error: error.message, path: rawPath }, 400);
537
+ }
538
+ const fileStat = await lstat(target).catch(() => null);
539
+ if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile()) {
540
+ return c.json({ error: "Document must be a regular file under .planner/docs/ (symlinks rejected).", path: rawPath }, 404);
541
+ }
542
+ const content = await readFile(target, "utf8").catch(() => null);
543
+ if (content === null)
544
+ return c.json({ error: "Document not found.", path: rawPath }, 404);
545
+ return c.json({ path: rawPath, content });
546
+ });
547
+ app.put(route("/docs/save"), async (c) => {
548
+ const body = await c.req.json().catch(() => ({ path: "", content: "", confirmed: false }));
549
+ const rawPath = typeof body.path === "string" ? body.path : "";
550
+ if (body.confirmed !== true) {
551
+ return c.json({ error: "Saving a planner document requires explicit confirmed=true.", path: rawPath, confirmRequired: true, saved: false }, 400);
552
+ }
553
+ let target;
554
+ try {
555
+ target = resolvePlannerDocPath(store.root, rawPath);
556
+ }
557
+ catch (error) {
558
+ return c.json({ error: error.message, path: rawPath }, 400);
559
+ }
560
+ const fileStat = await lstat(target).catch(() => null);
561
+ if (fileStat && (fileStat.isSymbolicLink() || !fileStat.isFile())) {
562
+ return c.json({ error: "Document must be a regular file under .planner/docs/ (symlinks rejected).", path: rawPath }, 400);
563
+ }
564
+ const content = typeof body.content === "string" ? body.content : "";
565
+ if (!content.trim())
566
+ return c.json({ error: "Document content must be non-empty Markdown.", path: rawPath }, 400);
567
+ const { mkdir: mkdirDoc } = await import("node:fs/promises");
568
+ await mkdirDoc(dirname(target), { recursive: true });
569
+ await writeFile(target, content, "utf8");
570
+ return c.json({ path: rawPath, saved: true });
571
+ });
349
572
  // ── Features ─────────────────────────────────────────────────────
350
573
  app.get(route("/features"), async (c) => c.json((await store.loadFeatures()).features));
351
- app.post(route("/features"), async (c) => {
574
+ app.post(route("/features"), async (c) => store.runBatch(async () => {
352
575
  const body = await c.req.json();
353
576
  const name = body.name?.trim();
354
577
  if (!name)
@@ -389,7 +612,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
389
612
  await store.syncStatuses();
390
613
  await store.appendActivity("feature_created", feature.id, `Feature created: ${feature.name}`);
391
614
  return c.json(feature, 201);
392
- });
615
+ }));
393
616
  app.get(route("/features/:id"), async (c) => {
394
617
  const id = c.req.param("id");
395
618
  const features = await store.loadFeatures();
@@ -398,30 +621,49 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
398
621
  return c.json({ error: "not found" }, 404);
399
622
  return c.json(feature);
400
623
  });
401
- app.put(route("/features/:id"), async (c) => {
624
+ app.put(route("/features/:id"), async (c) => store.runBatch(async () => {
402
625
  const id = c.req.param("id");
626
+ if (!id)
627
+ return c.json({ error: "id required" }, 400);
403
628
  const body = await c.req.json();
404
- if (body.id !== id)
629
+ if (body.id !== undefined && body.id !== id)
405
630
  return c.json({ error: "id mismatch" }, 400);
406
- const features = await store.loadFeatures();
407
- const idx = features.features.findIndex((f) => f.id === id);
408
- if (idx === -1)
631
+ const name = body.name?.trim();
632
+ if (body.name !== undefined && !name)
633
+ return c.json({ error: "name required" }, 400);
634
+ const existing = (await store.loadFeatures()).features.find((feature) => feature.id === id);
635
+ if (!existing)
409
636
  return c.json({ error: "not found" }, 404);
410
- if (entersGovernedState(features.features[idx]?.status, body.status) && !fromWebUi(c) && !featureGovernanceReady(body)) {
411
- return c.json({ error: "feature governance required: discuss the feature first, or set contextReady=true with a reason before starting work." }, 400);
412
- }
413
- await store.updateFeatures((doc) => {
414
- const i = doc.features.findIndex((f) => f.id === id);
415
- if (i !== -1)
416
- doc.features[i] = body;
417
- return doc;
418
- });
637
+ if (body.status !== undefined && body.status !== existing.status)
638
+ return c.json({ updated: false, errorCode: "DERIVED_STATUS_READ_ONLY", message: "Feature status is derived from child phases and tasks and cannot be updated directly." }, 400);
639
+ if (body.acceptedDecisions !== undefined && JSON.stringify(body.acceptedDecisions) !== JSON.stringify(existing.acceptedDecisions))
640
+ return c.json({ updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", message: "Raw acceptedDecisions replacement is disabled. Use the accepted-decisions semantic create, update, or delete endpoints so IDs and acceptedAt are preserved." }, 400);
641
+ const mutableFields = ["name", "description", "descriptionRef", "discussedAt", "contextReady", "contextReadyReason", "startDate", "endDate", "workDone", "workRemaining", "priority", "dependsOn"];
642
+ if (!hasDefinedField(body, mutableFields))
643
+ return c.json(noMutableFieldsResponse(mutableFields), 400);
644
+ const timestamp = nowISO();
645
+ const persisted = await store.updateFeature(id, (current) => ({
646
+ ...current,
647
+ ...(name !== undefined ? { name } : {}),
648
+ ...(body.description !== undefined ? { description: body.description } : {}),
649
+ ...(body.descriptionRef !== undefined ? { descriptionRef: body.descriptionRef.trim() || undefined } : {}),
650
+ ...(body.discussedAt !== undefined ? { discussedAt: body.discussedAt } : {}),
651
+ ...(body.contextReady !== undefined ? { contextReady: body.contextReady } : {}),
652
+ ...(body.contextReadyReason !== undefined ? { contextReadyReason: body.contextReadyReason } : {}),
653
+ ...(body.priority !== undefined ? { priority: body.priority } : {}),
654
+ ...(body.startDate !== undefined ? { startDate: body.startDate } : {}),
655
+ ...(body.endDate !== undefined ? { endDate: body.endDate } : {}),
656
+ ...(body.workDone !== undefined ? { workDone: body.workDone } : {}),
657
+ ...(body.workRemaining !== undefined ? { workRemaining: body.workRemaining } : {}),
658
+ ...(body.dependsOn !== undefined ? { dependsOn: body.dependsOn } : {}),
659
+ updatedAt: timestamp,
660
+ }), body.expectedUpdatedAt !== undefined ? { expectedUpdatedAt: body.expectedUpdatedAt } : {});
419
661
  await store.writeGenerated();
420
662
  hub()?.broadcast({ type: "features-updated", data: { action: "updated", id, featureId: id } });
421
663
  hub()?.broadcast({ type: "plan-rendered", data: {} });
422
664
  await store.syncStatuses();
423
- return c.json(body);
424
- });
665
+ return c.json((await store.loadFeatures()).features.find((feature) => feature.id === persisted.id) ?? persisted);
666
+ }));
425
667
  app.delete(route("/features/:id"), async (c) => {
426
668
  const id = c.req.param("id");
427
669
  await store.updateFeatures((doc) => {
@@ -450,11 +692,28 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
450
692
  if (requiresGovernance(full.status) && !fromWebUi(c) && !phaseGovernanceReady(full)) {
451
693
  return c.json({ error: "phase governance required: discuss the phase first, or set contextReady=true with a reason before starting work." }, 400);
452
694
  }
453
- await store.updatePhase(full.id, () => full);
454
- await store.writeGenerated();
695
+ let alreadyExists = false;
696
+ await store.runBatch(async () => {
697
+ alreadyExists = Boolean(await store.loadPhase(full.id).catch(() => null));
698
+ if (alreadyExists)
699
+ return;
700
+ await store.savePhase(full);
701
+ if (full.featureId) {
702
+ await store.updateFeatures((document) => {
703
+ const feature = document.features.find((entry) => entry.id === full.featureId);
704
+ if (feature && !feature.phaseIds.includes(full.id))
705
+ feature.phaseIds.push(full.id);
706
+ return document;
707
+ });
708
+ }
709
+ await store.writeGenerated();
710
+ });
711
+ if (alreadyExists) {
712
+ return c.json({ error: "phase already exists; use PUT with expectedUpdatedAt for updates" }, 409);
713
+ }
455
714
  hub()?.broadcast({ type: "phases-updated", data: { action: "created", id: full.id, phaseId: full.id, featureId: full.featureId ?? "" } });
456
715
  hub()?.broadcast({ type: "plan-rendered", data: {} });
457
- return c.json(full, 201);
716
+ return c.json(await store.loadPhaseWithRequirements(full.id), 201);
458
717
  }
459
718
  const title = body.title?.trim();
460
719
  if (!title)
@@ -463,7 +722,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
463
722
  if (!featureId)
464
723
  return c.json({ error: "featureId required: a phase must belong to a feature" }, 400);
465
724
  let phase;
466
- await withFeatureLock(featureId, async () => {
725
+ await store.runBatch(() => withFeatureLock(featureId, async () => {
467
726
  const allPhases = await store.loadAllPhases();
468
727
  const slug = normalizeSlug(title);
469
728
  const id = createPhaseId();
@@ -520,7 +779,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
520
779
  hub()?.broadcast({ type: "features-updated", data: { action: "updated", id: body.featureId, featureId: body.featureId } });
521
780
  }
522
781
  await store.writeGenerated();
523
- });
782
+ }));
524
783
  if (!phase)
525
784
  return c.json({ error: "phase creation failed" }, 500);
526
785
  hub()?.broadcast({ type: "phases-updated", data: { action: "created", id: phase.id, phaseId: phase.id, featureId: phase.featureId ?? "" } });
@@ -539,26 +798,75 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
539
798
  return c.json({ error: "phase not found" }, 404);
540
799
  }
541
800
  });
542
- app.put(route("/phases/:id"), async (c) => {
801
+ app.put(route("/phases/:id"), async (c) => store.runBatch(async () => {
543
802
  const id = c.req.param("id");
803
+ if (!id)
804
+ return c.json({ error: "id required" }, 400);
544
805
  const body = await c.req.json();
545
- if (body.id !== id)
806
+ if (body.id !== undefined && body.id !== id)
546
807
  return c.json({ error: "id mismatch" }, 400);
808
+ const title = body.title?.trim();
809
+ if (body.title !== undefined && !title)
810
+ return c.json({ error: "title required" }, 400);
547
811
  const existingPhase = await store.loadPhase(id).catch(() => null);
548
812
  if (!existingPhase)
549
813
  return c.json({ error: "phase not found" }, 404);
550
- if (entersGovernedState(existingPhase.status, body.status) && !fromWebUi(c) && !phaseGovernanceReady(body)) {
551
- return c.json({ error: "phase governance required: discuss the phase first, or set contextReady=true with a reason before starting work." }, 400);
814
+ if (body.status !== undefined && body.status !== existingPhase.status)
815
+ return c.json({ updated: false, errorCode: "DERIVED_STATUS_READ_ONLY", message: "Phase status is derived from child tasks and cannot be updated directly." }, 400);
816
+ if (body.acceptedDecisions !== undefined && JSON.stringify(body.acceptedDecisions) !== JSON.stringify(existingPhase.acceptedDecisions))
817
+ return c.json({ updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", message: "Raw acceptedDecisions replacement is disabled. Use the accepted-decisions semantic create, update, or delete endpoints so IDs and acceptedAt are preserved." }, 400);
818
+ let nextFeatureId = existingPhase.featureId;
819
+ if (body.featureId !== undefined) {
820
+ nextFeatureId = body.featureId.trim() || undefined;
821
+ if (nextFeatureId && !(await store.loadFeatures()).features.some((feature) => feature.id === nextFeatureId)) {
822
+ return c.json({ updated: false, errorCode: "FEATURE_NOT_FOUND", message: `Feature not found: ${body.featureId}` }, 404);
823
+ }
824
+ }
825
+ const mutableFields = ["title", "summary", "description", "descriptionRef", "featureId", "priority", "goals", "nonGoals", "dependencies", "risks", "openQuestions", "decisions", "completionCriteria", "notes", "discussedAt", "contextReady", "contextReadyReason"];
826
+ if (!hasDefinedField(body, mutableFields))
827
+ return c.json(noMutableFieldsResponse(mutableFields), 400);
828
+ const timestamp = nowISO();
829
+ const persisted = await store.updatePhase(id, (current) => ({
830
+ ...current,
831
+ ...(title !== undefined ? { title } : {}),
832
+ ...(body.slug !== undefined ? { slug: body.slug } : {}),
833
+ ...(body.featureId !== undefined ? { featureId: nextFeatureId } : {}),
834
+ ...(body.priority !== undefined ? { priority: body.priority } : {}),
835
+ ...(body.discussedAt !== undefined ? { discussedAt: body.discussedAt } : {}),
836
+ ...(body.contextReady !== undefined ? { contextReady: body.contextReady } : {}),
837
+ ...(body.contextReadyReason !== undefined ? { contextReadyReason: body.contextReadyReason } : {}),
838
+ ...(body.summary !== undefined ? { summary: body.summary } : {}),
839
+ ...(body.description !== undefined ? { description: body.description } : {}),
840
+ ...(body.descriptionRef !== undefined ? { descriptionRef: body.descriptionRef.trim() || undefined } : {}),
841
+ ...(body.notes !== undefined ? { notes: body.notes } : {}),
842
+ ...(body.goals !== undefined ? { goals: body.goals } : {}),
843
+ ...(body.nonGoals !== undefined ? { nonGoals: body.nonGoals } : {}),
844
+ ...(body.dependencies !== undefined ? { dependencies: body.dependencies } : {}),
845
+ ...(body.dependsOn !== undefined ? { dependsOn: body.dependsOn } : {}),
846
+ ...(body.risks !== undefined ? { risks: body.risks } : {}),
847
+ ...(body.openQuestions !== undefined ? { openQuestions: body.openQuestions } : {}),
848
+ ...(body.decisions !== undefined ? { decisions: body.decisions } : {}),
849
+ ...(body.completionCriteria !== undefined ? { completionCriteria: body.completionCriteria } : {}),
850
+ updatedAt: timestamp,
851
+ }), body.expectedUpdatedAt !== undefined ? { expectedUpdatedAt: body.expectedUpdatedAt } : {});
852
+ const targetFeatureId = body.featureId !== undefined ? nextFeatureId : undefined;
853
+ if (body.featureId !== undefined && targetFeatureId !== existingPhase.featureId) {
854
+ await store.updateFeatures((document) => {
855
+ for (const feature of document.features) {
856
+ feature.phaseIds = feature.id === targetFeatureId
857
+ ? [...new Set([...feature.phaseIds, id])]
858
+ : feature.phaseIds.filter((phaseId) => phaseId !== id);
859
+ }
860
+ return document;
861
+ });
552
862
  }
553
- await store.updatePhase(id, () => body);
554
863
  await store.writeGenerated();
555
- hub()?.broadcast({ type: "phases-updated", data: { action: "updated", id, phaseId: id, featureId: body.featureId ?? "" } });
864
+ hub()?.broadcast({ type: "phases-updated", data: { action: "updated", id, phaseId: id, featureId: persisted.featureId ?? "" } });
556
865
  hub()?.broadcast({ type: "plan-rendered", data: {} });
557
- // Propagate status up
558
866
  await store.syncStatuses();
559
- await store.appendActivity("phase_updated", id, `Phase updated: ${body.title} (${body.status})`);
560
- return c.json(body);
561
- });
867
+ await store.appendActivity("phase_updated", id, `Phase updated: ${persisted.title} (${persisted.status})`);
868
+ return c.json(await store.loadPhaseWithRequirements(id));
869
+ }));
562
870
  app.delete(route("/phases/:id"), async (c) => {
563
871
  const id = c.req.param("id");
564
872
  if (!id)
@@ -566,23 +874,25 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
566
874
  const existing = await store.loadPhase(id).catch(() => null);
567
875
  if (!existing)
568
876
  return c.json({ error: "phase not found" }, 404);
569
- await store.deletePhase(id);
570
- if (existing.featureId) {
571
- await store.updateFeatures((features) => {
572
- const feature = features.features.find((f) => f.id === existing.featureId);
573
- if (feature)
574
- feature.phaseIds = feature.phaseIds.filter((pid) => pid !== id);
575
- return features;
576
- });
577
- }
578
- await store.writeGenerated();
877
+ await store.runBatch(async () => {
878
+ await store.deletePhase(id);
879
+ if (existing.featureId) {
880
+ await store.updateFeatures((features) => {
881
+ const feature = features.features.find((f) => f.id === existing.featureId);
882
+ if (feature)
883
+ feature.phaseIds = feature.phaseIds.filter((pid) => pid !== id);
884
+ return features;
885
+ });
886
+ }
887
+ await store.writeGenerated();
888
+ });
579
889
  hub()?.broadcast({ type: "phases-updated", data: { action: "deleted", id, phaseId: id, featureId: existing.featureId ?? "" } });
580
890
  hub()?.broadcast({ type: "plan-rendered", data: {} });
581
891
  await store.syncStatuses();
582
892
  await store.appendActivity("phase_deleted", id ?? "", `Phase deleted: ${id}`);
583
893
  return c.json({ deleted: id });
584
894
  });
585
- app.post(route("/phases/:phaseId/tasks"), async (c) => {
895
+ app.post(route("/phases/:phaseId/tasks"), async (c) => store.runBatch(async () => {
586
896
  const phaseId = c.req.param("phaseId");
587
897
  if (!phaseId)
588
898
  return c.json({ error: "phaseId required" }, 400);
@@ -631,6 +941,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
631
941
  dependsOn: [],
632
942
  pauseSnapshot: null,
633
943
  pauseHistory: [],
944
+ activeOwnerSession: "",
634
945
  startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
635
946
  completedAt: initialStatus === "done" ? now : "",
636
947
  createdAt: now,
@@ -654,7 +965,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
654
965
  const seq = (n) => String(n && n > 0 ? n : 0).padStart(3, "0");
655
966
  const label = `T${seq(task.number)}(P${seq(phase.number)}/F${seq(featureNum)}) - ${task.title}`;
656
967
  return c.json({ ...task, label }, 201);
657
- });
968
+ }));
658
969
  // ── Tasks ─────────────────────────────────────────────────────────
659
970
  app.get(route("/tasks/active"), async (c) => {
660
971
  const [phases, featuresDoc] = await Promise.all([store.loadAllPhases(), store.loadFeatures()]);
@@ -692,8 +1003,8 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
692
1003
  return c.json(Array.from(activeTasksMap.values()));
693
1004
  });
694
1005
  app.get(route("/tasks/focus"), async (c) => {
695
- const [phases, featuresDoc, project] = await Promise.all([
696
- store.loadAllPhases(), store.loadFeatures(), store.loadProject(),
1006
+ const [phases, featuresDoc, project, resume] = await Promise.all([
1007
+ store.loadAllPhases(), store.loadFeatures(), store.loadProject(), store.loadResume(),
697
1008
  ]);
698
1009
  const featureIdToNum = new Map(featuresDoc.features.map((feature) => [feature.id, feature.number]));
699
1010
  const openReturn = [...project.workDeviations]
@@ -705,6 +1016,20 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
705
1016
  pendingByTask.set(deviation.resumeTaskId, deviation);
706
1017
  }
707
1018
  const pendingOrder = new Map(openReturn.map((deviation, index) => [deviation.id, index]));
1019
+ const summarizeFocusTask = (candidate, deviation) => ({
1020
+ id: candidate.task.id,
1021
+ number: candidate.task.number,
1022
+ shortId: candidate.task.shortId,
1023
+ title: candidate.task.title,
1024
+ phaseId: candidate.phase.id,
1025
+ phaseNumber: candidate.phase.number,
1026
+ featureId: candidate.feature?.id ?? "",
1027
+ featureNumber: candidate.feature?.number ?? 0,
1028
+ status: candidate.task.status,
1029
+ pauseSnapshot: candidate.task.pauseSnapshot ?? deviation?.snapshot ?? null,
1030
+ pendingResume: Boolean(candidate.task.pauseSnapshot ?? deviation),
1031
+ deviationId: deviation?.id ?? "",
1032
+ });
708
1033
  const active = [];
709
1034
  const pendingResume = [];
710
1035
  for (const phase of phases) {
@@ -744,12 +1069,14 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
744
1069
  return leftOrder - rightOrder;
745
1070
  return (right.pauseSnapshot?.pausedAt ?? "").localeCompare(left.pauseSnapshot?.pausedAt ?? "");
746
1071
  });
747
- return c.json({ active, pendingResume });
1072
+ const recommendation = recommendNextWork(featuresDoc.features, phases, project.workDeviations, resume?.currentPhaseId);
1073
+ const nextWork = recommendation.selection.candidate ? summarizeFocusTask(recommendation.selection.candidate, recommendation.selection.deviation) : null;
1074
+ return c.json({ active, pendingResume, nextWork, nextWorkReason: recommendation.selection.reason });
748
1075
  });
749
1076
  /** Canonical HTTP task-entry path. Generic task updates must never start work,
750
1077
  * because start/resume is responsible for clearing checkpoints and closing
751
1078
  * any matching return-stack deviation. */
752
- app.post(route("/tasks/:id/start"), async (c) => {
1079
+ app.post(route("/tasks/:id/start"), async (c) => store.runBatch(async () => {
753
1080
  const taskId = c.req.param("id");
754
1081
  const [phases, featuresDoc, project] = await Promise.all([
755
1082
  store.loadAllPhases(), store.loadFeatures(), store.loadProject(),
@@ -810,7 +1137,60 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
810
1137
  hub()?.broadcast({ type: "plan-rendered", data: {} });
811
1138
  await store.appendActivity("task_status", existing.id, `Task ${existing.id} → in-progress`);
812
1139
  return c.json(updated ?? existing);
813
- });
1140
+ }));
1141
+ app.post(route("/tasks/:id/reopen"), async (c) => store.runBatch(async () => {
1142
+ const taskId = c.req.param("id") ?? "";
1143
+ const body = await c.req.json().catch(() => ({}));
1144
+ const phase = (await store.loadAllPhases()).find((entry) => entry.tasks.some((task) => task.id === taskId));
1145
+ if (!phase)
1146
+ return c.json({ reopened: false, errorCode: "TASK_NOT_FOUND" }, 404);
1147
+ if (body.confirmed !== true)
1148
+ return c.json({ reopened: false, errorCode: "TASK_REOPEN_CONFIRMATION_REQUIRED", message: "Explicit confirmation is required to reopen a completed task." }, 400);
1149
+ try {
1150
+ const task = await store.reopenTask(phase.id, taskId, { confirmed: true });
1151
+ await store.syncTaskStatusRollup(phase.id);
1152
+ await store.writeGenerated();
1153
+ hub()?.broadcast({ type: "phases-updated", data: { action: "task-reopened", id: phase.id, phaseId: phase.id, featureId: phase.featureId ?? "", taskId } });
1154
+ hub()?.broadcast({ type: "plan-rendered", data: {} });
1155
+ await store.appendActivity("task_status", taskId, `Task ${taskId} reopened`);
1156
+ return c.json({ reopened: true, task });
1157
+ }
1158
+ catch (error) {
1159
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1160
+ return c.json({ reopened: false, errorCode: details?.errorCode ?? "TASK_REOPEN_FAILED", message: error instanceof Error ? error.message : "Task reopen failed." }, 409);
1161
+ }
1162
+ }));
1163
+ app.post(route("/tasks/:id/dependencies"), async (c) => store.runBatch(async () => {
1164
+ const taskId = c.req.param("id") ?? "";
1165
+ const body = await c.req.json();
1166
+ if (!body.phaseId || !body.dependsOnId)
1167
+ return c.json({ updated: false, errorCode: "DEPENDENCY_INPUT_REQUIRED" }, 400);
1168
+ try {
1169
+ const result = await store.addTaskDependency(body.phaseId, taskId, body.dependsOnId);
1170
+ await store.writeGenerated();
1171
+ return c.json({ updated: true, task: result });
1172
+ }
1173
+ catch (error) {
1174
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1175
+ return c.json({ updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_UPDATE_FAILED", message: error instanceof Error ? error.message : "Dependency update failed." }, 400);
1176
+ }
1177
+ }));
1178
+ app.delete(route("/tasks/:id/dependencies/:dependencyId"), async (c) => store.runBatch(async () => {
1179
+ const taskId = c.req.param("id") ?? "";
1180
+ const dependencyId = c.req.param("dependencyId") ?? "";
1181
+ const body = await c.req.json().catch(() => ({}));
1182
+ if (!body.phaseId || body.confirmed !== true)
1183
+ return c.json({ updated: false, errorCode: "CONFIRMATION_REQUIRED" }, 400);
1184
+ try {
1185
+ const result = await store.deleteTaskDependency(body.phaseId, taskId, dependencyId);
1186
+ await store.writeGenerated();
1187
+ return c.json({ updated: true, task: result });
1188
+ }
1189
+ catch (error) {
1190
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1191
+ return c.json({ updated: false, errorCode: details?.errorCode ?? "DEPENDENCY_DELETE_FAILED", message: error instanceof Error ? error.message : "Dependency deletion failed." }, 400);
1192
+ }
1193
+ }));
814
1194
  app.get(route("/tasks/:id"), async (c) => {
815
1195
  const taskId = c.req.param("id");
816
1196
  const phases = await store.loadAllPhases();
@@ -821,7 +1201,72 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
821
1201
  }
822
1202
  return c.json({ error: "task not found" }, 404);
823
1203
  });
824
- app.put(route("/tasks/:id"), async (c) => {
1204
+ app.post(route("/tasks/:id/subtasks"), async (c) => store.runBatch(async () => {
1205
+ const taskId = c.req.param("id") ?? "";
1206
+ const body = await c.req.json();
1207
+ if (!body.phaseId)
1208
+ return c.json({ errorCode: "PHASE_ID_REQUIRED", created: false }, 400);
1209
+ const task = (await store.loadPhase(body.phaseId).catch(() => null))?.tasks.find((candidate) => candidate.id === taskId);
1210
+ if (!task)
1211
+ return c.json({ errorCode: "TASK_NOT_FOUND", created: false }, 404);
1212
+ try {
1213
+ const subtask = await store.createSubtask(body.phaseId, taskId, { title: body.title ?? "", ...(body.description !== undefined ? { description: body.description } : {}), ...(body.status !== undefined ? { status: body.status } : {}) });
1214
+ await store.writeGenerated();
1215
+ return c.json({ created: true, subtask, task: (await store.loadPhase(body.phaseId)).tasks.find((candidate) => candidate.id === taskId) }, 201);
1216
+ }
1217
+ catch (error) {
1218
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1219
+ return c.json({ created: false, errorCode: details?.errorCode ?? "SUBTASK_CREATE_FAILED", message: error instanceof Error ? error.message : "Subtask creation failed." }, 400);
1220
+ }
1221
+ }));
1222
+ app.put(route("/tasks/:id/subtasks/:subtaskId"), async (c) => store.runBatch(async () => {
1223
+ const taskId = c.req.param("id") ?? "";
1224
+ const subtaskId = c.req.param("subtaskId") ?? "";
1225
+ const body = await c.req.json();
1226
+ if (!body.phaseId)
1227
+ return c.json({ errorCode: "PHASE_ID_REQUIRED", updated: false }, 400);
1228
+ try {
1229
+ const subtask = await store.updateSubtask(body.phaseId, taskId, subtaskId, { ...(body.title !== undefined ? { title: body.title } : {}), ...(body.description !== undefined ? { description: body.description } : {}), ...(body.status !== undefined ? { status: body.status } : {}) });
1230
+ await store.writeGenerated();
1231
+ return c.json({ updated: true, subtask });
1232
+ }
1233
+ catch (error) {
1234
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1235
+ return c.json({ updated: false, errorCode: details?.errorCode ?? "SUBTASK_UPDATE_FAILED", message: error instanceof Error ? error.message : "Subtask update failed." }, 400);
1236
+ }
1237
+ }));
1238
+ app.delete(route("/tasks/:id/subtasks/:subtaskId"), async (c) => store.runBatch(async () => {
1239
+ const taskId = c.req.param("id") ?? "";
1240
+ const subtaskId = c.req.param("subtaskId") ?? "";
1241
+ const body = await c.req.json().catch(() => ({}));
1242
+ if (!body.phaseId || body.confirmed !== true)
1243
+ return c.json({ deleted: false, errorCode: body.confirmed === true ? "PHASE_ID_REQUIRED" : "CONFIRMATION_REQUIRED" }, 400);
1244
+ try {
1245
+ await store.deleteSubtask(body.phaseId, taskId, subtaskId);
1246
+ await store.writeGenerated();
1247
+ return c.json({ deleted: true, subtaskId });
1248
+ }
1249
+ catch (error) {
1250
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1251
+ return c.json({ deleted: false, errorCode: details?.errorCode ?? "SUBTASK_DELETE_FAILED", message: error instanceof Error ? error.message : "Subtask deletion failed." }, 400);
1252
+ }
1253
+ }));
1254
+ app.put(route("/tasks/:id/subtasks/order"), async (c) => store.runBatch(async () => {
1255
+ const taskId = c.req.param("id") ?? "";
1256
+ const body = await c.req.json();
1257
+ if (!body.phaseId || !Array.isArray(body.orderedIds))
1258
+ return c.json({ updated: false, errorCode: "SUBTASK_ORDER_INVALID" }, 400);
1259
+ try {
1260
+ const subtasks = await store.reorderSubtasks(body.phaseId, taskId, body.orderedIds);
1261
+ await store.writeGenerated();
1262
+ return c.json({ updated: true, subtasks });
1263
+ }
1264
+ catch (error) {
1265
+ const details = error instanceof PlanStoreError ? error.details : undefined;
1266
+ return c.json({ updated: false, errorCode: details?.errorCode ?? "SUBTASK_ORDER_INVALID", message: error instanceof Error ? error.message : "Subtask reorder failed." }, 400);
1267
+ }
1268
+ }));
1269
+ app.put(route("/tasks/:id"), async (c) => store.runBatch(async () => {
825
1270
  const taskId = c.req.param("id");
826
1271
  const body = await c.req.json();
827
1272
  if (!body.phaseId)
@@ -829,15 +1274,17 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
829
1274
  const phase = await store.loadPhase(body.phaseId).catch(() => null);
830
1275
  if (!phase)
831
1276
  return c.json({ error: "phase not found" }, 404);
832
- const existing = phase.tasks.find((t) => t.id === taskId);
1277
+ const existing = phase.tasks.find((task) => task.id === taskId);
833
1278
  if (!existing)
834
1279
  return c.json({ error: "task not found" }, 404);
1280
+ if (body.acceptedDecisions !== undefined && JSON.stringify(body.acceptedDecisions) !== JSON.stringify(existing.acceptedDecisions))
1281
+ return c.json({ updated: false, errorCode: "ACCEPTED_DECISION_SEMANTIC_MUTATION_REQUIRED", message: "Raw acceptedDecisions replacement is disabled. Use the accepted-decisions semantic create, update, or delete endpoints so IDs and acceptedAt are preserved." }, 400);
1282
+ const mutableFields = ["title", "description", "descriptionRef", "status", "notes", "decisions", "priority", "checklist", "subtasks"];
1283
+ if (!hasDefinedField(body, mutableFields))
1284
+ return c.json(noMutableFieldsResponse(mutableFields), 400);
835
1285
  if (body.status && body.status !== existing.status && (body.status === "in-progress" || body.status === "paused" || existing.status === "paused")) {
836
1286
  return c.json({ error: "Task start/resume transitions require POST /tasks/:id/start so lifecycle state, checkpoints, and return stacks remain consistent." }, 400);
837
1287
  }
838
- // Validate motivation requirement for status transitions. The Web UI is the
839
- // human supervisor and is exempt (X-Planner-Source: web-ui) — agents calling
840
- // the API without that header are still required to motivate state changes.
841
1288
  if (body.status && body.status !== existing.status && needsMotivation(existing.status, body.status) && !fromWebUi(c)) {
842
1289
  if (!body.motivation || !body.motivation.trim()) {
843
1290
  return c.json({ error: `Status transition "${existing.status} → ${body.status}" requires a motivation. Provide the "motivation" field with a detailed explanation.` }, 400);
@@ -850,60 +1297,55 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
850
1297
  return c.json({ error: "feature governance required before starting task work: discuss the feature first, or set contextReady=true with a reason." }, 400);
851
1298
  }
852
1299
  }
853
- // NOTE: checklist completeness is advisory for the AI agent (enforced via
854
- // prompt rules), not a hard API gate. The web UI is for the human
855
- // supervisor, who may close a task regardless of checklist state.
856
- const now = nowISO();
857
- const updated = {
858
- ...existing,
859
- ...body,
860
- id: taskId,
861
- phaseId: existing.phaseId,
862
- shortName: existing.shortName,
863
- createdAt: existing.createdAt,
864
- startedAt: existing.startedAt,
865
- completedAt: existing.completedAt,
866
- updatedAt: now,
867
- };
868
- if (body.status) {
869
- applyTaskLifecycleDates(updated, body.status, now);
1300
+ if (body.subtasks !== undefined) {
1301
+ const existingSubtaskIds = new Set(existing.subtasks.map((subtask) => subtask.id));
1302
+ if (body.subtasks.some((subtask) => subtask.id !== undefined && !existingSubtaskIds.has(subtask.id)))
1303
+ return c.json({ updated: false, errorCode: "SUBTASK_ID_INVALID", message: "Subtask IDs must belong to the target task." }, 400);
870
1304
  }
871
- // Record status change in the incremental statusLog.
872
- if (body.status && body.status !== existing.status) {
873
- const entry = {
874
- id: createTaskId(),
875
- date: now,
876
- fromStatus: existing.status,
877
- toStatus: body.status,
878
- title: body.motivation?.split("\n")[0]?.trim() || `${existing.status} → ${body.status}`,
879
- description: body.motivation?.trim() || "",
1305
+ const now = nowISO();
1306
+ const expectedUpdatedAt = body.expectedUpdatedAt ?? body.updatedAt;
1307
+ const result = await store.updateTask(phase.id, existing.id, (current) => {
1308
+ const previousStatus = current.status;
1309
+ const next = {
1310
+ ...current,
1311
+ ...(body.title !== undefined ? { title: body.title } : {}),
1312
+ ...(body.status !== undefined ? { status: body.status } : {}),
1313
+ ...(body.priority !== undefined ? { priority: body.priority } : {}),
1314
+ ...(body.description !== undefined ? { description: body.description } : {}),
1315
+ ...(body.descriptionRef !== undefined ? { descriptionRef: body.descriptionRef.trim() || undefined } : {}),
1316
+ ...(body.notes !== undefined ? { notes: body.notes } : {}),
1317
+ ...(body.decisions !== undefined ? { decisions: body.decisions } : {}),
1318
+ ...(body.checklist !== undefined ? { checklist: body.checklist } : {}),
1319
+ ...(body.subtasks !== undefined ? { subtasks: body.subtasks.map((subtask) => ({ ...subtask, id: subtask.id || randomUUID(), title: subtask.title.trim(), description: subtask.description ?? "", status: subtask.status ?? "planned", createdAt: subtask.createdAt || now, updatedAt: now })) } : {}),
1320
+ updatedAt: now,
880
1321
  };
881
- updated.statusLog = [...(existing.statusLog ?? []), entry];
882
- }
883
- const tIdx = phase.tasks.findIndex((t) => t.id === taskId);
884
- phase.tasks[tIdx] = updated;
885
- phase.updatedAt = updated.updatedAt;
886
- // Atomic: serialize concurrent task updates on the same phase.
887
- await store.updatePhase(phase.id, (p) => {
888
- const i = p.tasks.findIndex((t) => t.id === taskId);
889
- if (i !== -1)
890
- p.tasks[i] = updated;
891
- p.updatedAt = updated.updatedAt;
892
- return p;
893
- });
1322
+ if (body.status !== undefined)
1323
+ applyTaskLifecycleDates(next, body.status, now);
1324
+ if (body.status !== undefined && body.status !== previousStatus) {
1325
+ const entry = {
1326
+ id: createTaskId(),
1327
+ date: now,
1328
+ fromStatus: previousStatus,
1329
+ toStatus: body.status,
1330
+ title: body.motivation?.split("\n")[0]?.trim() || `${previousStatus} → ${body.status}`,
1331
+ description: body.motivation?.trim() || "",
1332
+ };
1333
+ next.statusLog = [...current.statusLog, entry];
1334
+ }
1335
+ return next;
1336
+ }, expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {});
894
1337
  await store.writeGenerated();
895
1338
  await store.syncTaskStatusRollup(phase.id);
896
- hub()?.broadcast({ type: "phases-updated", data: { action: "task-updated", id: body.phaseId, phaseId: body.phaseId, featureId: phase.featureId ?? "", taskId } });
1339
+ hub()?.broadcast({ type: "phases-updated", data: { action: "task-updated", id: phase.id, phaseId: phase.id, featureId: phase.featureId ?? "", taskId } });
897
1340
  hub()?.broadcast({ type: "plan-rendered", data: {} });
898
- // Propagate status up
899
1341
  if (body.status && body.status !== existing.status) {
900
1342
  await store.appendActivity("task_status", taskId ?? "", `Task ${taskId} → ${body.status}`);
901
1343
  }
902
1344
  else {
903
- await store.appendActivity("task_updated", taskId ?? "", `Task updated: ${updated.title}`);
1345
+ await store.appendActivity("task_updated", taskId ?? "", `Task updated: ${result.task.title}`);
904
1346
  }
905
- return c.json(updated);
906
- });
1347
+ return c.json((await store.loadPhase(phase.id)).tasks.find((task) => task.id === taskId) ?? result.task);
1348
+ }));
907
1349
  app.delete(route("/tasks/:id"), async (c) => {
908
1350
  const taskId = c.req.param("id");
909
1351
  const phases = await store.loadAllPhases();
@@ -1080,6 +1522,24 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
1080
1522
  const archived = await store.listArchivedHandoffs();
1081
1523
  return c.json({ archived });
1082
1524
  });
1525
+ app.get(route("/phases/:id/handoff/preflight"), async (c) => {
1526
+ const id = c.req.param("id");
1527
+ if (!id)
1528
+ return c.json({ error: "id required" }, 400);
1529
+ const phase = await store.loadPhase(id).catch(() => null);
1530
+ if (!phase)
1531
+ return c.json({ error: "phase not found" }, 404);
1532
+ const audit = await store.preparePhaseHandoff(id);
1533
+ return c.json({
1534
+ handoffUpdatedAt: audit.handoffUpdatedAt,
1535
+ targetContentChars: audit.targetContentChars,
1536
+ maxContentChars: audit.maxContentChars,
1537
+ canonicalSections: audit.canonicalSections,
1538
+ requiredHumanInputs: audit.requiredHumanInputs,
1539
+ generatedMetadata: ["Created at", "Updated at", "Reason"],
1540
+ draftTemplate: audit.draftTemplate,
1541
+ });
1542
+ });
1083
1543
  app.get(route("/phases/:id/handoff"), async (c) => {
1084
1544
  const id = c.req.param("id");
1085
1545
  if (!id)