@agent-plan/server 0.2.25 → 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, createTaskId, findPhaseByRef, normalizeSlug, withFeatureLock, needsMotivation, checkExplicitTaskStart, recommendNextTask } 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;
@@ -30,6 +31,51 @@ async function resolveRequirementPhaseIds(store, linkedPhaseIds) {
30
31
  return { error: `linked phase not found: ${refs[missing]}` };
31
32
  return { linkedPhaseIds: [...new Set(resolved.map((phase) => phase.id))] };
32
33
  }
34
+ function macroTaskInputs(value) {
35
+ return Array.isArray(value) ? value : null;
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
+ }
33
79
  function nextTaskNumber(phase) {
34
80
  const numbers = phase.tasks.map((task) => task.number || 0).filter((n) => Number.isFinite(n));
35
81
  return (numbers.length > 0 ? Math.max(...numbers) : 0) + 1;
@@ -106,11 +152,15 @@ function startWatcher(planRoot, hubRef) {
106
152
  watcherAbort = ac;
107
153
  watcherHubRef = hubRef;
108
154
  try {
109
- watch(planRoot, { recursive: true, signal: ac.signal }, (_event, filename) => {
155
+ const watcher = watch(planRoot, { recursive: true, signal: ac.signal }, (_event, filename) => {
110
156
  if (filename && !filename.includes(".tmp.")) {
111
157
  hubRef.current?.broadcast({ type: "file-changed", data: { filename } });
112
158
  }
113
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
+ });
114
164
  }
115
165
  catch {
116
166
  // recursive watch may fail on some systems
@@ -135,6 +185,18 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
135
185
  // phase file -> PlanStoreError/ENOENT) bubble up to the host process console
136
186
  // (the agent session). Convert it to a clean HTTP response instead.
137
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
+ }
138
200
  const isStoreRead = err instanceof PlanStoreError || /ENOENT|read failed/i.test(err.message);
139
201
  if (isStoreRead) {
140
202
  return c.json({ error: "not found", message: err.message }, 404);
@@ -164,10 +226,12 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
164
226
  app.get(route("/export"), async (c) => {
165
227
  const full = c.req.query("full") === "true";
166
228
  const exportService = new ExportService();
167
- const plan = await store.loadAll();
168
- const markdown = exportService.exportToMarkdown(plan, full);
169
- // Save to disk
170
- 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
+ });
171
235
  return c.json({ markdown, filePath: "EXPORT.md" });
172
236
  });
173
237
  app.get(route("/project"), async (c) => {
@@ -180,49 +244,206 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
180
244
  });
181
245
  app.put(route("/project"), async (c) => {
182
246
  const body = await c.req.json();
183
- 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
+ : {});
184
275
  await store.writeGenerated();
185
- hub()?.broadcast({ type: "project-updated", data: body });
276
+ hub()?.broadcast({ type: "project-updated", data: persisted });
186
277
  hub()?.broadcast({ type: "plan-rendered", data: {} });
187
- return c.json(body);
278
+ return c.json(persisted);
279
+ });
280
+ app.get(route("/project/context-migration"), async (c) => {
281
+ return c.json(await store.previewLegacyProjectContextMigration());
282
+ });
283
+ app.post(route("/project/context-migration"), async (c) => {
284
+ const body = await c.req.json().catch(() => ({}));
285
+ if (body.confirm !== true) {
286
+ return c.json({
287
+ error: "PROJECT_CONTEXT_MIGRATION_CONFIRMATION_REQUIRED",
288
+ message: "Preview the legacy project-context migration, then retry with confirm=true to apply it.",
289
+ }, 400);
290
+ }
291
+ const result = await store.migrateLegacyProjectContext();
292
+ await store.writeGenerated();
293
+ hub()?.broadcast({ type: "project-updated", data: result.project });
294
+ hub()?.broadcast({ type: "plan-rendered", data: {} });
295
+ return c.json(result);
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 });
188
362
  });
189
363
  // ── Requirements ─────────────────────────────────────────────────
190
364
  app.get(route("/requirements"), async (c) => c.json(await store.loadRequirements()));
191
365
  app.post(route("/requirements"), async (c) => {
192
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);
369
+ const title = body.title?.trim();
370
+ if (!title)
371
+ return c.json({ error: "title required" }, 400);
193
372
  const links = await resolveRequirementPhaseIds(store, body.linkedPhaseIds);
194
373
  if ("error" in links)
195
374
  return c.json({ error: links.error }, 400);
196
- const requirement = { ...body, linkedPhaseIds: links.linkedPhaseIds };
197
- const reqs = await store.updateRequirements((doc) => { doc.requirements.push(requirement); return doc; });
375
+ const inputs = macroTaskInputs(body.macroTasks ?? []);
376
+ if (!inputs)
377
+ return c.json({ error: "macroTasks must be an array" }, 400);
378
+ const now = nowISO();
379
+ let macroTasks;
380
+ try {
381
+ macroTasks = reconcileRequirementMacroTasks([], inputs, now);
382
+ }
383
+ catch (error) {
384
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
385
+ return c.json({ error: message }, 400);
386
+ }
387
+ const requirement = {
388
+ id: createRequirementId(),
389
+ title,
390
+ description: body.description?.trim() ?? "",
391
+ macroTasks,
392
+ linkedPhaseIds: links.linkedPhaseIds,
393
+ createdAt: now,
394
+ updatedAt: now,
395
+ sessionInfo: [],
396
+ };
397
+ const reqs = await store.updateRequirements((doc) => ({ requirements: [...doc.requirements, requirement] }));
198
398
  await store.writeGenerated();
199
399
  hub()?.broadcast({ type: "requirements-updated", data: reqs });
200
400
  hub()?.broadcast({ type: "plan-rendered", data: {} });
201
401
  return c.json(requirement, 201);
202
402
  });
203
- app.put(route("/requirements/:id"), async (c) => {
403
+ app.put(route("/requirements/:id"), async (c) => store.runBatch(async () => {
204
404
  const id = c.req.param("id");
405
+ if (!id)
406
+ return c.json({ error: "id required" }, 400);
205
407
  const body = await c.req.json();
206
- const links = await resolveRequirementPhaseIds(store, body.linkedPhaseIds);
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);
410
+ const existing = (await store.loadRequirements()).requirements.find((requirement) => requirement.id === id);
411
+ if (!existing)
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);
416
+ const title = body.title?.trim();
417
+ if (body.title !== undefined && !title)
418
+ return c.json({ error: "title required" }, 400);
419
+ const links = await resolveRequirementPhaseIds(store, body.linkedPhaseIds ?? existing.linkedPhaseIds);
207
420
  if ("error" in links)
208
421
  return c.json({ error: links.error }, 400);
209
- const requirement = { ...body, linkedPhaseIds: links.linkedPhaseIds };
210
- let found = false;
211
- const reqs = await store.updateRequirements((doc) => {
212
- const idx = doc.requirements.findIndex((r) => r.id === id);
213
- if (idx === -1)
214
- return doc;
215
- doc.requirements[idx] = requirement;
216
- found = true;
217
- return doc;
218
- });
219
- if (!found)
220
- return c.json({ error: "not found" }, 404);
422
+ const inputs = macroTaskInputs(body.macroTasks ?? existing.macroTasks);
423
+ if (!inputs)
424
+ return c.json({ error: "macroTasks must be an array" }, 400);
425
+ const now = nowISO();
426
+ let macroTasks;
427
+ try {
428
+ macroTasks = reconcileRequirementMacroTasks(existing.macroTasks, inputs, now);
429
+ }
430
+ catch (error) {
431
+ const message = error instanceof RequirementMacroTaskError ? error.message : "Invalid macro tasks.";
432
+ return c.json({ error: message }, 400);
433
+ }
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 } : {}),
440
+ updatedAt: now,
441
+ }), body.expectedUpdatedAt !== undefined ? { expectedUpdatedAt: body.expectedUpdatedAt } : {});
221
442
  await store.writeGenerated();
222
- hub()?.broadcast({ type: "requirements-updated", data: reqs });
443
+ hub()?.broadcast({ type: "requirements-updated", data: { action: "updated", id, requirement } });
223
444
  hub()?.broadcast({ type: "plan-rendered", data: {} });
224
445
  return c.json(requirement);
225
- });
446
+ }));
226
447
  app.delete(route("/requirements/:id"), async (c) => {
227
448
  const id = c.req.param("id");
228
449
  let found = false;
@@ -238,9 +459,119 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
238
459
  hub()?.broadcast({ type: "plan-rendered", data: {} });
239
460
  return c.json({ deleted: id });
240
461
  });
462
+ // ── Ideas Inbox ──────────────────────────────────────────────────
463
+ app.get(route("/ideas"), async (c) => c.json(await store.loadIdeas()));
464
+ app.post(route("/ideas"), async (c) => {
465
+ const body = await c.req.json();
466
+ const title = body.title?.trim();
467
+ if (!title)
468
+ return c.json({ error: "title required" }, 400);
469
+ const idea = await store.createIdea({ title, ...(body.description !== undefined ? { description: body.description.trim() } : {}) });
470
+ await store.writeGenerated();
471
+ hub()?.broadcast({ type: "file-changed", data: { filename: "ideas.json" } });
472
+ hub()?.broadcast({ type: "plan-rendered", data: {} });
473
+ return c.json(idea, 201);
474
+ });
475
+ app.put(route("/ideas/:id"), async (c) => {
476
+ const id = c.req.param("id");
477
+ if (!id)
478
+ return c.json({ error: "id required" }, 400);
479
+ const body = await c.req.json();
480
+ const mutableFields = ["title", "description"];
481
+ if (!hasDefinedField(body, mutableFields))
482
+ return c.json(noMutableFieldsResponse(mutableFields), 400);
483
+ const title = body.title?.trim();
484
+ if (body.title !== undefined && !title)
485
+ return c.json({ error: "title required", updated: false }, 400);
486
+ const current = (await store.loadIdeas()).ideas.find((idea) => idea.id === id);
487
+ if (!current)
488
+ return c.json({ error: "not found" }, 404);
489
+ const idea = await store.updateIdea(id, { ...(title !== undefined ? { title } : {}), ...(body.description !== undefined ? { description: body.description.trim() } : {}) });
490
+ await store.writeGenerated();
491
+ hub()?.broadcast({ type: "file-changed", data: { filename: "ideas.json" } });
492
+ hub()?.broadcast({ type: "plan-rendered", data: {} });
493
+ return c.json(idea);
494
+ });
495
+ app.delete(route("/ideas/:id"), async (c) => {
496
+ const id = c.req.param("id");
497
+ if (!id)
498
+ return c.json({ error: "id required" }, 400);
499
+ const current = (await store.loadIdeas()).ideas.find((idea) => idea.id === id);
500
+ if (!current)
501
+ return c.json({ error: "not found" }, 404);
502
+ await store.deleteIdea(id);
503
+ await store.writeGenerated();
504
+ hub()?.broadcast({ type: "file-changed", data: { filename: "ideas.json" } });
505
+ hub()?.broadcast({ type: "plan-rendered", data: {} });
506
+ return c.json({ deleted: id });
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
+ });
241
572
  // ── Features ─────────────────────────────────────────────────────
242
573
  app.get(route("/features"), async (c) => c.json((await store.loadFeatures()).features));
243
- app.post(route("/features"), async (c) => {
574
+ app.post(route("/features"), async (c) => store.runBatch(async () => {
244
575
  const body = await c.req.json();
245
576
  const name = body.name?.trim();
246
577
  if (!name)
@@ -281,7 +612,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
281
612
  await store.syncStatuses();
282
613
  await store.appendActivity("feature_created", feature.id, `Feature created: ${feature.name}`);
283
614
  return c.json(feature, 201);
284
- });
615
+ }));
285
616
  app.get(route("/features/:id"), async (c) => {
286
617
  const id = c.req.param("id");
287
618
  const features = await store.loadFeatures();
@@ -290,30 +621,49 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
290
621
  return c.json({ error: "not found" }, 404);
291
622
  return c.json(feature);
292
623
  });
293
- app.put(route("/features/:id"), async (c) => {
624
+ app.put(route("/features/:id"), async (c) => store.runBatch(async () => {
294
625
  const id = c.req.param("id");
626
+ if (!id)
627
+ return c.json({ error: "id required" }, 400);
295
628
  const body = await c.req.json();
296
- if (body.id !== id)
629
+ if (body.id !== undefined && body.id !== id)
297
630
  return c.json({ error: "id mismatch" }, 400);
298
- const features = await store.loadFeatures();
299
- const idx = features.features.findIndex((f) => f.id === id);
300
- 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)
301
636
  return c.json({ error: "not found" }, 404);
302
- if (entersGovernedState(features.features[idx]?.status, body.status) && !fromWebUi(c) && !featureGovernanceReady(body)) {
303
- return c.json({ error: "feature governance required: discuss the feature first, or set contextReady=true with a reason before starting work." }, 400);
304
- }
305
- await store.updateFeatures((doc) => {
306
- const i = doc.features.findIndex((f) => f.id === id);
307
- if (i !== -1)
308
- doc.features[i] = body;
309
- return doc;
310
- });
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 } : {});
311
661
  await store.writeGenerated();
312
662
  hub()?.broadcast({ type: "features-updated", data: { action: "updated", id, featureId: id } });
313
663
  hub()?.broadcast({ type: "plan-rendered", data: {} });
314
664
  await store.syncStatuses();
315
- return c.json(body);
316
- });
665
+ return c.json((await store.loadFeatures()).features.find((feature) => feature.id === persisted.id) ?? persisted);
666
+ }));
317
667
  app.delete(route("/features/:id"), async (c) => {
318
668
  const id = c.req.param("id");
319
669
  await store.updateFeatures((doc) => {
@@ -342,11 +692,28 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
342
692
  if (requiresGovernance(full.status) && !fromWebUi(c) && !phaseGovernanceReady(full)) {
343
693
  return c.json({ error: "phase governance required: discuss the phase first, or set contextReady=true with a reason before starting work." }, 400);
344
694
  }
345
- await store.updatePhase(full.id, () => full);
346
- 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
+ }
347
714
  hub()?.broadcast({ type: "phases-updated", data: { action: "created", id: full.id, phaseId: full.id, featureId: full.featureId ?? "" } });
348
715
  hub()?.broadcast({ type: "plan-rendered", data: {} });
349
- return c.json(full, 201);
716
+ return c.json(await store.loadPhaseWithRequirements(full.id), 201);
350
717
  }
351
718
  const title = body.title?.trim();
352
719
  if (!title)
@@ -355,7 +722,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
355
722
  if (!featureId)
356
723
  return c.json({ error: "featureId required: a phase must belong to a feature" }, 400);
357
724
  let phase;
358
- await withFeatureLock(featureId, async () => {
725
+ await store.runBatch(() => withFeatureLock(featureId, async () => {
359
726
  const allPhases = await store.loadAllPhases();
360
727
  const slug = normalizeSlug(title);
361
728
  const id = createPhaseId();
@@ -393,6 +760,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
393
760
  updatedAt: now,
394
761
  handoff: "",
395
762
  handoffUpdatedAt: "",
763
+ handoffAudit: null,
396
764
  handoffReadAt: "",
397
765
  handoffHistory: [],
398
766
  statusLog: [],
@@ -411,7 +779,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
411
779
  hub()?.broadcast({ type: "features-updated", data: { action: "updated", id: body.featureId, featureId: body.featureId } });
412
780
  }
413
781
  await store.writeGenerated();
414
- });
782
+ }));
415
783
  if (!phase)
416
784
  return c.json({ error: "phase creation failed" }, 500);
417
785
  hub()?.broadcast({ type: "phases-updated", data: { action: "created", id: phase.id, phaseId: phase.id, featureId: phase.featureId ?? "" } });
@@ -430,26 +798,75 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
430
798
  return c.json({ error: "phase not found" }, 404);
431
799
  }
432
800
  });
433
- app.put(route("/phases/:id"), async (c) => {
801
+ app.put(route("/phases/:id"), async (c) => store.runBatch(async () => {
434
802
  const id = c.req.param("id");
803
+ if (!id)
804
+ return c.json({ error: "id required" }, 400);
435
805
  const body = await c.req.json();
436
- if (body.id !== id)
806
+ if (body.id !== undefined && body.id !== id)
437
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);
438
811
  const existingPhase = await store.loadPhase(id).catch(() => null);
439
812
  if (!existingPhase)
440
813
  return c.json({ error: "phase not found" }, 404);
441
- if (entersGovernedState(existingPhase.status, body.status) && !fromWebUi(c) && !phaseGovernanceReady(body)) {
442
- 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
+ });
443
862
  }
444
- await store.updatePhase(id, () => body);
445
863
  await store.writeGenerated();
446
- 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 ?? "" } });
447
865
  hub()?.broadcast({ type: "plan-rendered", data: {} });
448
- // Propagate status up
449
866
  await store.syncStatuses();
450
- await store.appendActivity("phase_updated", id, `Phase updated: ${body.title} (${body.status})`);
451
- return c.json(body);
452
- });
867
+ await store.appendActivity("phase_updated", id, `Phase updated: ${persisted.title} (${persisted.status})`);
868
+ return c.json(await store.loadPhaseWithRequirements(id));
869
+ }));
453
870
  app.delete(route("/phases/:id"), async (c) => {
454
871
  const id = c.req.param("id");
455
872
  if (!id)
@@ -457,23 +874,25 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
457
874
  const existing = await store.loadPhase(id).catch(() => null);
458
875
  if (!existing)
459
876
  return c.json({ error: "phase not found" }, 404);
460
- await store.deletePhase(id);
461
- if (existing.featureId) {
462
- await store.updateFeatures((features) => {
463
- const feature = features.features.find((f) => f.id === existing.featureId);
464
- if (feature)
465
- feature.phaseIds = feature.phaseIds.filter((pid) => pid !== id);
466
- return features;
467
- });
468
- }
469
- 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
+ });
470
889
  hub()?.broadcast({ type: "phases-updated", data: { action: "deleted", id, phaseId: id, featureId: existing.featureId ?? "" } });
471
890
  hub()?.broadcast({ type: "plan-rendered", data: {} });
472
891
  await store.syncStatuses();
473
892
  await store.appendActivity("phase_deleted", id ?? "", `Phase deleted: ${id}`);
474
893
  return c.json({ deleted: id });
475
894
  });
476
- app.post(route("/phases/:phaseId/tasks"), async (c) => {
895
+ app.post(route("/phases/:phaseId/tasks"), async (c) => store.runBatch(async () => {
477
896
  const phaseId = c.req.param("phaseId");
478
897
  if (!phaseId)
479
898
  return c.json({ error: "phaseId required" }, 400);
@@ -522,6 +941,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
522
941
  dependsOn: [],
523
942
  pauseSnapshot: null,
524
943
  pauseHistory: [],
944
+ activeOwnerSession: "",
525
945
  startedAt: initialStatus === "in-progress" || initialStatus === "done" ? now : "",
526
946
  completedAt: initialStatus === "done" ? now : "",
527
947
  createdAt: now,
@@ -545,7 +965,7 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
545
965
  const seq = (n) => String(n && n > 0 ? n : 0).padStart(3, "0");
546
966
  const label = `T${seq(task.number)}(P${seq(phase.number)}/F${seq(featureNum)}) - ${task.title}`;
547
967
  return c.json({ ...task, label }, 201);
548
- });
968
+ }));
549
969
  // ── Tasks ─────────────────────────────────────────────────────────
550
970
  app.get(route("/tasks/active"), async (c) => {
551
971
  const [phases, featuresDoc] = await Promise.all([store.loadAllPhases(), store.loadFeatures()]);
@@ -583,8 +1003,8 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
583
1003
  return c.json(Array.from(activeTasksMap.values()));
584
1004
  });
585
1005
  app.get(route("/tasks/focus"), async (c) => {
586
- const [phases, featuresDoc, project] = await Promise.all([
587
- store.loadAllPhases(), store.loadFeatures(), store.loadProject(),
1006
+ const [phases, featuresDoc, project, resume] = await Promise.all([
1007
+ store.loadAllPhases(), store.loadFeatures(), store.loadProject(), store.loadResume(),
588
1008
  ]);
589
1009
  const featureIdToNum = new Map(featuresDoc.features.map((feature) => [feature.id, feature.number]));
590
1010
  const openReturn = [...project.workDeviations]
@@ -596,6 +1016,20 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
596
1016
  pendingByTask.set(deviation.resumeTaskId, deviation);
597
1017
  }
598
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
+ });
599
1033
  const active = [];
600
1034
  const pendingResume = [];
601
1035
  for (const phase of phases) {
@@ -635,12 +1069,14 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
635
1069
  return leftOrder - rightOrder;
636
1070
  return (right.pauseSnapshot?.pausedAt ?? "").localeCompare(left.pauseSnapshot?.pausedAt ?? "");
637
1071
  });
638
- 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 });
639
1075
  });
640
1076
  /** Canonical HTTP task-entry path. Generic task updates must never start work,
641
1077
  * because start/resume is responsible for clearing checkpoints and closing
642
1078
  * any matching return-stack deviation. */
643
- app.post(route("/tasks/:id/start"), async (c) => {
1079
+ app.post(route("/tasks/:id/start"), async (c) => store.runBatch(async () => {
644
1080
  const taskId = c.req.param("id");
645
1081
  const [phases, featuresDoc, project] = await Promise.all([
646
1082
  store.loadAllPhases(), store.loadFeatures(), store.loadProject(),
@@ -701,7 +1137,60 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
701
1137
  hub()?.broadcast({ type: "plan-rendered", data: {} });
702
1138
  await store.appendActivity("task_status", existing.id, `Task ${existing.id} → in-progress`);
703
1139
  return c.json(updated ?? existing);
704
- });
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
+ }));
705
1194
  app.get(route("/tasks/:id"), async (c) => {
706
1195
  const taskId = c.req.param("id");
707
1196
  const phases = await store.loadAllPhases();
@@ -712,7 +1201,72 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
712
1201
  }
713
1202
  return c.json({ error: "task not found" }, 404);
714
1203
  });
715
- 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 () => {
716
1270
  const taskId = c.req.param("id");
717
1271
  const body = await c.req.json();
718
1272
  if (!body.phaseId)
@@ -720,15 +1274,17 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
720
1274
  const phase = await store.loadPhase(body.phaseId).catch(() => null);
721
1275
  if (!phase)
722
1276
  return c.json({ error: "phase not found" }, 404);
723
- const existing = phase.tasks.find((t) => t.id === taskId);
1277
+ const existing = phase.tasks.find((task) => task.id === taskId);
724
1278
  if (!existing)
725
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);
726
1285
  if (body.status && body.status !== existing.status && (body.status === "in-progress" || body.status === "paused" || existing.status === "paused")) {
727
1286
  return c.json({ error: "Task start/resume transitions require POST /tasks/:id/start so lifecycle state, checkpoints, and return stacks remain consistent." }, 400);
728
1287
  }
729
- // Validate motivation requirement for status transitions. The Web UI is the
730
- // human supervisor and is exempt (X-Planner-Source: web-ui) — agents calling
731
- // the API without that header are still required to motivate state changes.
732
1288
  if (body.status && body.status !== existing.status && needsMotivation(existing.status, body.status) && !fromWebUi(c)) {
733
1289
  if (!body.motivation || !body.motivation.trim()) {
734
1290
  return c.json({ error: `Status transition "${existing.status} → ${body.status}" requires a motivation. Provide the "motivation" field with a detailed explanation.` }, 400);
@@ -741,60 +1297,55 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
741
1297
  return c.json({ error: "feature governance required before starting task work: discuss the feature first, or set contextReady=true with a reason." }, 400);
742
1298
  }
743
1299
  }
744
- // NOTE: checklist completeness is advisory for the AI agent (enforced via
745
- // prompt rules), not a hard API gate. The web UI is for the human
746
- // supervisor, who may close a task regardless of checklist state.
747
- const now = nowISO();
748
- const updated = {
749
- ...existing,
750
- ...body,
751
- id: taskId,
752
- phaseId: existing.phaseId,
753
- shortName: existing.shortName,
754
- createdAt: existing.createdAt,
755
- startedAt: existing.startedAt,
756
- completedAt: existing.completedAt,
757
- updatedAt: now,
758
- };
759
- if (body.status) {
760
- 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);
761
1304
  }
762
- // Record status change in the incremental statusLog.
763
- if (body.status && body.status !== existing.status) {
764
- const entry = {
765
- id: createTaskId(),
766
- date: now,
767
- fromStatus: existing.status,
768
- toStatus: body.status,
769
- title: body.motivation?.split("\n")[0]?.trim() || `${existing.status} → ${body.status}`,
770
- 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,
771
1321
  };
772
- updated.statusLog = [...(existing.statusLog ?? []), entry];
773
- }
774
- const tIdx = phase.tasks.findIndex((t) => t.id === taskId);
775
- phase.tasks[tIdx] = updated;
776
- phase.updatedAt = updated.updatedAt;
777
- // Atomic: serialize concurrent task updates on the same phase.
778
- await store.updatePhase(phase.id, (p) => {
779
- const i = p.tasks.findIndex((t) => t.id === taskId);
780
- if (i !== -1)
781
- p.tasks[i] = updated;
782
- p.updatedAt = updated.updatedAt;
783
- return p;
784
- });
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 } : {});
785
1337
  await store.writeGenerated();
786
1338
  await store.syncTaskStatusRollup(phase.id);
787
- 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 } });
788
1340
  hub()?.broadcast({ type: "plan-rendered", data: {} });
789
- // Propagate status up
790
1341
  if (body.status && body.status !== existing.status) {
791
1342
  await store.appendActivity("task_status", taskId ?? "", `Task ${taskId} → ${body.status}`);
792
1343
  }
793
1344
  else {
794
- await store.appendActivity("task_updated", taskId ?? "", `Task updated: ${updated.title}`);
1345
+ await store.appendActivity("task_updated", taskId ?? "", `Task updated: ${result.task.title}`);
795
1346
  }
796
- return c.json(updated);
797
- });
1347
+ return c.json((await store.loadPhase(phase.id)).tasks.find((task) => task.id === taskId) ?? result.task);
1348
+ }));
798
1349
  app.delete(route("/tasks/:id"), async (c) => {
799
1350
  const taskId = c.req.param("id");
800
1351
  const phases = await store.loadAllPhases();
@@ -962,13 +1513,33 @@ function createApiApp(store, hubRef, apiPrefix = "", uiConfig, isBusy) {
962
1513
  });
963
1514
  // ── Handoff (entity-scoped, phase.handoff) ────────────────────
964
1515
  app.get(route("/handoffs"), async (c) => {
965
- const list = await store.listHandoffs();
1516
+ // The browser renders expanded bodies; agent list tools use the compact
1517
+ // default and never transport all handoff content at once.
1518
+ const list = await store.listHandoffs({ includeContent: true });
966
1519
  return c.json({ handoffs: list });
967
1520
  });
968
1521
  app.get(route("/handoffs/archive"), async (c) => {
969
1522
  const archived = await store.listArchivedHandoffs();
970
1523
  return c.json({ archived });
971
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
+ });
972
1543
  app.get(route("/phases/:id/handoff"), async (c) => {
973
1544
  const id = c.req.param("id");
974
1545
  if (!id)