@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.d.ts.map +1 -1
- package/dist/serve.js +700 -129
- package/package.json +2 -2
- package/web-ui-dist/assets/index-B3wQpFUr.css +1 -0
- package/web-ui-dist/assets/index-Db8IRPH8.js +329 -0
- package/web-ui-dist/index.html +2 -2
- package/web-ui-dist/assets/index-CYtuauJC.css +0 -1
- package/web-ui-dist/assets/index-DubXBudL.js +0 -318
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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
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:
|
|
276
|
+
hub()?.broadcast({ type: "project-updated", data: persisted });
|
|
186
277
|
hub()?.broadcast({ type: "plan-rendered", data: {} });
|
|
187
|
-
return c.json(
|
|
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
|
|
197
|
-
|
|
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
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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:
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
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 (
|
|
303
|
-
return c.json({
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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(
|
|
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
|
-
|
|
346
|
-
await store.
|
|
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 (
|
|
442
|
-
return c.json({
|
|
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:
|
|
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: ${
|
|
451
|
-
return c.json(
|
|
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.
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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.
|
|
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((
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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:
|
|
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: ${
|
|
1345
|
+
await store.appendActivity("task_updated", taskId ?? "", `Task updated: ${result.task.title}`);
|
|
795
1346
|
}
|
|
796
|
-
return c.json(
|
|
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
|
-
|
|
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)
|