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