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