@agent-plan/server 0.2.26 → 0.2.28

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