@phren/cli 0.0.32 → 0.0.34
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/mcp/dist/cli/actions.js +3 -0
- package/mcp/dist/cli/config.js +3 -3
- package/mcp/dist/cli/govern.js +18 -8
- package/mcp/dist/cli/hooks-context.js +1 -1
- package/mcp/dist/cli/hooks-session.js +18 -62
- package/mcp/dist/cli/namespaces.js +1 -1
- package/mcp/dist/cli/search.js +5 -5
- package/mcp/dist/cli-hooks-prompt.js +7 -3
- package/mcp/dist/cli-hooks-session-handlers.js +3 -15
- package/mcp/dist/cli-hooks-stop.js +10 -48
- package/mcp/dist/content/archive.js +8 -20
- package/mcp/dist/content/learning.js +29 -8
- package/mcp/dist/data/access.js +13 -4
- package/mcp/dist/finding/lifecycle.js +9 -3
- package/mcp/dist/governance/audit.js +13 -5
- package/mcp/dist/governance/policy.js +13 -0
- package/mcp/dist/governance/rbac.js +1 -1
- package/mcp/dist/governance/scores.js +2 -1
- package/mcp/dist/hooks.js +52 -6
- package/mcp/dist/index.js +1 -1
- package/mcp/dist/init/init.js +66 -45
- package/mcp/dist/init/shared.js +1 -1
- package/mcp/dist/init-bootstrap.js +0 -47
- package/mcp/dist/init-fresh.js +13 -18
- package/mcp/dist/init-uninstall.js +22 -0
- package/mcp/dist/init-walkthrough.js +19 -24
- package/mcp/dist/link/doctor.js +9 -0
- package/mcp/dist/package-metadata.js +1 -1
- package/mcp/dist/phren-art.js +4 -120
- package/mcp/dist/proactivity.js +1 -1
- package/mcp/dist/project-topics.js +16 -46
- package/mcp/dist/provider-adapters.js +1 -1
- package/mcp/dist/runtime-profile.js +1 -1
- package/mcp/dist/shared/data-utils.js +25 -0
- package/mcp/dist/shared/fragment-graph.js +4 -18
- package/mcp/dist/shared/index.js +14 -10
- package/mcp/dist/shared/ollama.js +23 -5
- package/mcp/dist/shared/process.js +24 -0
- package/mcp/dist/shared/retrieval.js +7 -4
- package/mcp/dist/shared/search-fallback.js +1 -0
- package/mcp/dist/shared.js +2 -1
- package/mcp/dist/shell/render.js +1 -1
- package/mcp/dist/skill/registry.js +1 -1
- package/mcp/dist/skill/state.js +0 -3
- package/mcp/dist/task/github.js +1 -0
- package/mcp/dist/task/lifecycle.js +1 -6
- package/mcp/dist/tools/config.js +415 -400
- package/mcp/dist/tools/finding.js +390 -373
- package/mcp/dist/tools/ops.js +372 -365
- package/mcp/dist/tools/search.js +495 -487
- package/mcp/dist/tools/session.js +3 -2
- package/mcp/dist/tools/skills.js +9 -0
- package/mcp/dist/ui/page.js +1 -1
- package/mcp/dist/ui/server.js +645 -1040
- package/mcp/dist/utils.js +12 -8
- package/package.json +1 -1
- package/mcp/dist/init-dryrun.js +0 -55
- package/mcp/dist/init-migrate.js +0 -51
- package/mcp/dist/init-walkthrough-merge.js +0 -90
package/mcp/dist/ui/server.js
CHANGED
|
@@ -30,7 +30,7 @@ export function getWebUiBrowserCommand(url, platform = process.platform) {
|
|
|
30
30
|
return { command: process.env.ComSpec || "cmd.exe", args: ["/c", "start", "", url] };
|
|
31
31
|
return { command: "xdg-open", args: [url] };
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
async function launchWebUiBrowser(url) {
|
|
34
34
|
const { command, args } = getWebUiBrowserCommand(url);
|
|
35
35
|
await new Promise((resolve, reject) => {
|
|
36
36
|
try {
|
|
@@ -288,1089 +288,694 @@ function parseTopicPayload(raw) {
|
|
|
288
288
|
return null;
|
|
289
289
|
}
|
|
290
290
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
return;
|
|
306
|
-
pruneExpiredCsrfTokens(csrfTokens);
|
|
307
|
-
if (csrfTokens)
|
|
308
|
-
csrfTokens.set(crypto.randomUUID(), Date.now());
|
|
309
|
-
const nonce = crypto.randomBytes(16).toString("base64");
|
|
310
|
-
setCommonHeaders(res, nonce);
|
|
311
|
-
const html = renderPage(phrenPath, authToken, nonce);
|
|
312
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
313
|
-
res.end(html);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
setCommonHeaders(res);
|
|
317
|
-
if (pathname.startsWith("/api/") && req.method === "GET" && !requireGetAuth(req, res, url, authToken)) {
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
if (req.method === "GET" && pathname === "/api/projects") {
|
|
321
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
322
|
-
res.end(JSON.stringify(collectProjectsForUI(phrenPath, profile)));
|
|
291
|
+
function parseQs(url) {
|
|
292
|
+
return url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
|
|
293
|
+
}
|
|
294
|
+
function jsonOk(res, data, status = 200) {
|
|
295
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
296
|
+
res.end(JSON.stringify(data));
|
|
297
|
+
}
|
|
298
|
+
function jsonErr(res, error, status = 200) {
|
|
299
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
300
|
+
res.end(JSON.stringify({ ok: false, error }));
|
|
301
|
+
}
|
|
302
|
+
function withPostBody(req, res, url, ctx, handler) {
|
|
303
|
+
void readFormBody(req, res).then((parsed) => {
|
|
304
|
+
if (!parsed)
|
|
323
305
|
return;
|
|
324
|
-
|
|
325
|
-
if (req.method === "GET" && pathname === "/api/change-token") {
|
|
326
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
327
|
-
res.end(JSON.stringify({ token: computePhrenLiveStateToken(phrenPath) }));
|
|
306
|
+
if (!requirePostAuth(req, res, url, parsed, ctx.authToken, true))
|
|
328
307
|
return;
|
|
329
|
-
|
|
330
|
-
if (req.method === "GET" && pathname === "/api/runtime-health") {
|
|
331
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
332
|
-
res.end(JSON.stringify(readSyncSnapshot(phrenPath)));
|
|
308
|
+
if (!requireCsrf(res, parsed, ctx.csrfTokens, true))
|
|
333
309
|
return;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
310
|
+
handler(parsed);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
// ── GET handlers ──────────────────────────────────────────────────────────────
|
|
314
|
+
function handleGetHome(res, ctx) {
|
|
315
|
+
const nonce = crypto.randomBytes(16).toString("base64");
|
|
316
|
+
setCommonHeaders(res, nonce);
|
|
317
|
+
const html = ctx.renderPage(ctx.phrenPath, ctx.authToken, nonce);
|
|
318
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
319
|
+
res.end(html);
|
|
320
|
+
}
|
|
321
|
+
function handleGetProjects(res, ctx) {
|
|
322
|
+
jsonOk(res, collectProjectsForUI(ctx.phrenPath, ctx.profile));
|
|
323
|
+
}
|
|
324
|
+
function handleGetChangeToken(res, ctx) {
|
|
325
|
+
jsonOk(res, { token: computePhrenLiveStateToken(ctx.phrenPath) });
|
|
326
|
+
}
|
|
327
|
+
function handleGetRuntimeHealth(res, ctx) {
|
|
328
|
+
jsonOk(res, readSyncSnapshot(ctx.phrenPath));
|
|
329
|
+
}
|
|
330
|
+
function handleGetReviewQueue(res, ctx) {
|
|
331
|
+
jsonOk(res, readProjectQueue(ctx.phrenPath, ctx.profile));
|
|
332
|
+
}
|
|
333
|
+
function handleGetReviewActivity(res, ctx) {
|
|
334
|
+
jsonOk(res, { accepted: recentAccepted(ctx.phrenPath), usage: recentUsage(ctx.phrenPath) });
|
|
335
|
+
}
|
|
336
|
+
function handleGetProjectContent(res, url, ctx) {
|
|
337
|
+
const qs = parseQs(url);
|
|
338
|
+
const project = String(qs.project || "");
|
|
339
|
+
const file = String(qs.file || "");
|
|
340
|
+
if (!project || !isValidProjectName(project) || !file)
|
|
341
|
+
return jsonErr(res, "Invalid project or file", 400);
|
|
342
|
+
const allowedFiles = ["FINDINGS.md", TASKS_FILENAME, "CLAUDE.md", "summary.md"];
|
|
343
|
+
if (!allowedFiles.includes(file))
|
|
344
|
+
return jsonErr(res, `File not allowed: ${file}`, 400);
|
|
345
|
+
const filePath = safeProjectPath(ctx.phrenPath, project, file);
|
|
346
|
+
if (!filePath)
|
|
347
|
+
return jsonErr(res, "Invalid project or file path", 400);
|
|
348
|
+
if (!fs.existsSync(filePath))
|
|
349
|
+
return jsonErr(res, `File not found: ${file}`);
|
|
350
|
+
jsonOk(res, { ok: true, content: fs.readFileSync(filePath, "utf8") });
|
|
351
|
+
}
|
|
352
|
+
function handleGetProjectTopics(res, url, ctx) {
|
|
353
|
+
const project = String(parseQs(url).project || "");
|
|
354
|
+
if (!project || !isValidProjectName(project))
|
|
355
|
+
return jsonErr(res, "Invalid project", 400);
|
|
356
|
+
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
|
|
357
|
+
}
|
|
358
|
+
function handleGetProjectReferenceList(res, url, ctx) {
|
|
359
|
+
const project = String(parseQs(url).project || "");
|
|
360
|
+
if (!project || !isValidProjectName(project))
|
|
361
|
+
return jsonErr(res, "Invalid project", 400);
|
|
362
|
+
jsonOk(res, { ok: true, ...listProjectReferenceDocs(ctx.phrenPath, project) });
|
|
363
|
+
}
|
|
364
|
+
function handleGetProjectReferenceContent(res, url, ctx) {
|
|
365
|
+
const qs = parseQs(url);
|
|
366
|
+
const contentResult = readReferenceContent(ctx.phrenPath, String(qs.project || ""), String(qs.file || ""));
|
|
367
|
+
res.writeHead(contentResult.ok ? 200 : 400, { "content-type": "application/json; charset=utf-8" });
|
|
368
|
+
res.end(JSON.stringify(contentResult.ok ? { ok: true, content: contentResult.content } : { ok: false, error: contentResult.error }));
|
|
369
|
+
}
|
|
370
|
+
function handleGetSkills(res, ctx) {
|
|
371
|
+
jsonOk(res, collectSkillsForUI(ctx.phrenPath, ctx.profile));
|
|
372
|
+
}
|
|
373
|
+
function handleGetSkillContent(res, url, ctx) {
|
|
374
|
+
const filePath = String(parseQs(url).path || "");
|
|
375
|
+
if (!filePath || !isAllowedSkillPath(filePath, ctx.phrenPath))
|
|
376
|
+
return jsonErr(res, "Invalid path", 400);
|
|
377
|
+
if (!fs.existsSync(filePath))
|
|
378
|
+
return jsonErr(res, "File not found");
|
|
379
|
+
jsonOk(res, { ok: true, content: fs.readFileSync(filePath, "utf8") });
|
|
380
|
+
}
|
|
381
|
+
function handleGetHooks(res, ctx) {
|
|
382
|
+
jsonOk(res, getHooksData(ctx.phrenPath));
|
|
383
|
+
}
|
|
384
|
+
async function handleGetSearch(res, url, ctx) {
|
|
385
|
+
const searchParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
|
|
386
|
+
const query = searchParams.get("q") || searchParams.get("query") || "";
|
|
387
|
+
const searchProject = searchParams.get("project") || undefined;
|
|
388
|
+
const searchType = searchParams.get("type") || undefined;
|
|
389
|
+
const searchLimit = parseInt(searchParams.get("limit") || "10", 10) || 10;
|
|
390
|
+
if (!query.trim())
|
|
391
|
+
return jsonErr(res, "Missing query parameter (q or query).");
|
|
392
|
+
try {
|
|
393
|
+
const { runSearch } = await import("../cli/search.js");
|
|
394
|
+
const result = await runSearch({ query, limit: Math.min(searchLimit, 50), project: searchProject, type: searchType }, ctx.phrenPath, ctx.profile || "");
|
|
395
|
+
const fileDates = {};
|
|
396
|
+
for (const line of result.lines) {
|
|
397
|
+
const srcMatch = line.match(/^\[([^\]]+)\]\s/);
|
|
398
|
+
if (srcMatch) {
|
|
399
|
+
const sourceKey = srcMatch[1];
|
|
400
|
+
if (fileDates[sourceKey])
|
|
401
|
+
continue;
|
|
402
|
+
const slashIdx = sourceKey.indexOf("/");
|
|
403
|
+
if (slashIdx > 0) {
|
|
364
404
|
try {
|
|
365
|
-
const
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
pushed = true;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
catch { /* no remote or push failed */ }
|
|
372
|
-
const changedFiles = status.split("\n").filter(Boolean).length;
|
|
373
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
374
|
-
res.end(JSON.stringify({ ok: true, message: `Synced ${changedFiles} file(s).${pushed ? " Pushed to remote." : " No remote, saved locally."}` }));
|
|
375
|
-
}
|
|
376
|
-
catch (err) {
|
|
377
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
378
|
-
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
379
|
-
}
|
|
380
|
-
});
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
if (req.method === "GET" && pathname === "/api/review-queue") {
|
|
384
|
-
if (!requireGetAuth(req, res, url, authToken, true))
|
|
385
|
-
return;
|
|
386
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
387
|
-
res.end(JSON.stringify(readProjectQueue(phrenPath, profile)));
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
if (req.method === "GET" && pathname === "/api/review-activity") {
|
|
391
|
-
if (!requireGetAuth(req, res, url, authToken, true))
|
|
392
|
-
return;
|
|
393
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
394
|
-
res.end(JSON.stringify({
|
|
395
|
-
accepted: recentAccepted(phrenPath),
|
|
396
|
-
usage: recentUsage(phrenPath),
|
|
397
|
-
}));
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
// POST /api/approve — remove item from review queue (keep finding)
|
|
401
|
-
if (req.method === "POST" && pathname === "/api/approve") {
|
|
402
|
-
void readFormBody(req, res).then((parsed) => {
|
|
403
|
-
if (!parsed)
|
|
404
|
-
return;
|
|
405
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
406
|
-
return;
|
|
407
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
408
|
-
return;
|
|
409
|
-
const project = String(parsed.project || "");
|
|
410
|
-
const line = String(parsed.line || "");
|
|
411
|
-
if (!project || !isValidProjectName(project) || !line) {
|
|
412
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
413
|
-
res.end(JSON.stringify({ ok: false, error: "Missing project or line" }));
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
try {
|
|
417
|
-
const qPath = queueFilePath(phrenPath, project);
|
|
418
|
-
if (fs.existsSync(qPath)) {
|
|
419
|
-
const content = fs.readFileSync(qPath, "utf8");
|
|
420
|
-
const lines = content.split("\n");
|
|
421
|
-
const filtered = lines.filter((l) => l.trim() !== line.trim());
|
|
422
|
-
fs.writeFileSync(qPath, filtered.join("\n"));
|
|
423
|
-
}
|
|
424
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
425
|
-
res.end(JSON.stringify({ ok: true }));
|
|
426
|
-
}
|
|
427
|
-
catch (err) {
|
|
428
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
429
|
-
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
// POST /api/reject — remove item from review queue AND remove finding
|
|
435
|
-
if (req.method === "POST" && pathname === "/api/reject") {
|
|
436
|
-
void readFormBody(req, res).then((parsed) => {
|
|
437
|
-
if (!parsed)
|
|
438
|
-
return;
|
|
439
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
440
|
-
return;
|
|
441
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
442
|
-
return;
|
|
443
|
-
const project = String(parsed.project || "");
|
|
444
|
-
const line = String(parsed.line || "");
|
|
445
|
-
if (!project || !isValidProjectName(project) || !line) {
|
|
446
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
447
|
-
res.end(JSON.stringify({ ok: false, error: "Missing project or line" }));
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
try {
|
|
451
|
-
// Remove from review queue
|
|
452
|
-
const qPath = queueFilePath(phrenPath, project);
|
|
453
|
-
if (fs.existsSync(qPath)) {
|
|
454
|
-
const content = fs.readFileSync(qPath, "utf8");
|
|
455
|
-
const lines = content.split("\n");
|
|
456
|
-
const filtered = lines.filter((l) => l.trim() !== line.trim());
|
|
457
|
-
fs.writeFileSync(qPath, filtered.join("\n"));
|
|
458
|
-
}
|
|
459
|
-
// Also remove the finding from FINDINGS.md
|
|
460
|
-
// Extract text from the line (strip "- " prefix and inline metadata)
|
|
461
|
-
const findingText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
|
|
462
|
-
if (findingText) {
|
|
463
|
-
removeFinding(phrenPath, project, findingText);
|
|
405
|
+
const filePath = path.join(ctx.phrenPath, sourceKey.slice(0, slashIdx), sourceKey.slice(slashIdx + 1));
|
|
406
|
+
if (fs.existsSync(filePath))
|
|
407
|
+
fileDates[sourceKey] = fs.statSync(filePath).mtime.toISOString();
|
|
464
408
|
}
|
|
465
|
-
|
|
466
|
-
res.end(JSON.stringify({ ok: true }));
|
|
467
|
-
}
|
|
468
|
-
catch (err) {
|
|
469
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
470
|
-
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
409
|
+
catch { /* skip */ }
|
|
471
410
|
}
|
|
472
|
-
}
|
|
473
|
-
return;
|
|
411
|
+
}
|
|
474
412
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
413
|
+
jsonOk(res, { ok: true, query, results: result.lines, fileDates });
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
jsonErr(res, errorMessage(err));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function handleGetGraph(res, url, ctx) {
|
|
420
|
+
const graphParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
|
|
421
|
+
jsonOk(res, await buildGraph(ctx.phrenPath, ctx.profile, graphParams.get("project") || undefined));
|
|
422
|
+
}
|
|
423
|
+
function handleGetScores(res, ctx) {
|
|
424
|
+
let scores = {};
|
|
425
|
+
try {
|
|
426
|
+
const raw = fs.readFileSync(path.join(ctx.phrenPath, ".runtime", "memory-scores.json"), "utf-8");
|
|
427
|
+
const parsed = JSON.parse(raw);
|
|
428
|
+
if (parsed && typeof parsed === "object")
|
|
429
|
+
scores = parsed;
|
|
430
|
+
}
|
|
431
|
+
catch { /* file missing or unparseable */ }
|
|
432
|
+
jsonOk(res, scores);
|
|
433
|
+
}
|
|
434
|
+
function handleGetTasks(res, ctx) {
|
|
435
|
+
try {
|
|
436
|
+
const docs = readTasksAcrossProjects(ctx.phrenPath, ctx.profile);
|
|
437
|
+
const tasks = [];
|
|
438
|
+
for (const doc of docs) {
|
|
439
|
+
for (const section of ["Active", "Queue", "Done"]) {
|
|
440
|
+
for (const item of doc.items[section]) {
|
|
441
|
+
tasks.push({
|
|
442
|
+
project: doc.project, section: item.section, line: item.line, priority: item.priority,
|
|
443
|
+
pinned: item.pinned, githubIssue: item.githubIssue, githubUrl: item.githubUrl,
|
|
444
|
+
context: item.context, checked: item.checked, sessionId: item.sessionId,
|
|
445
|
+
});
|
|
501
446
|
}
|
|
502
|
-
});
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
if (req.method === "GET" && pathname.startsWith("/api/project-content")) {
|
|
506
|
-
const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
|
|
507
|
-
const project = String(qs.project || "");
|
|
508
|
-
const file = String(qs.file || "");
|
|
509
|
-
if (!project || !isValidProjectName(project) || !file) {
|
|
510
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
511
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid project or file" }));
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
const allowedFiles = ["FINDINGS.md", TASKS_FILENAME, "CLAUDE.md", "summary.md"];
|
|
515
|
-
if (!allowedFiles.includes(file)) {
|
|
516
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
517
|
-
res.end(JSON.stringify({ ok: false, error: `File not allowed: ${file}` }));
|
|
518
|
-
return;
|
|
519
447
|
}
|
|
520
|
-
const filePath = safeProjectPath(phrenPath, project, file);
|
|
521
|
-
if (!filePath) {
|
|
522
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
523
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid project or file path" }));
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
if (!fs.existsSync(filePath)) {
|
|
527
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
528
|
-
res.end(JSON.stringify({ ok: false, error: `File not found: ${file}` }));
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
532
|
-
res.end(JSON.stringify({ ok: true, content: fs.readFileSync(filePath, "utf8") }));
|
|
533
|
-
return;
|
|
534
448
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
449
|
+
jsonOk(res, { ok: true, tasks });
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
jsonOk(res, { ok: false, error: errorMessage(err), tasks: [] });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function handleGetSettings(res, url, ctx) {
|
|
456
|
+
try {
|
|
457
|
+
const prefs = readInstallPreferences(ctx.phrenPath);
|
|
458
|
+
const workflowPolicy = getWorkflowPolicy(ctx.phrenPath);
|
|
459
|
+
const retentionPolicy = getRetentionPolicy(ctx.phrenPath);
|
|
460
|
+
const hooksData = getHooksData(ctx.phrenPath);
|
|
461
|
+
const proactivityFindings = prefs.proactivityFindings || prefs.proactivity || "high";
|
|
462
|
+
const settingsProject = String(parseQs(url).project || "");
|
|
463
|
+
const merged = settingsProject && isValidProjectName(settingsProject) ? mergeConfig(ctx.phrenPath, settingsProject) : null;
|
|
464
|
+
const overrides = settingsProject && isValidProjectName(settingsProject) ? getProjectConfigOverrides(ctx.phrenPath, settingsProject) : null;
|
|
465
|
+
let projectInfo = null;
|
|
466
|
+
if (settingsProject && isValidProjectName(settingsProject)) {
|
|
467
|
+
const projectDir = path.join(ctx.phrenPath, settingsProject);
|
|
468
|
+
const configFile = path.join(projectDir, "phren.project.yaml");
|
|
469
|
+
const projConfig = readProjectConfig(ctx.phrenPath, settingsProject);
|
|
470
|
+
const findingsPath = path.join(projectDir, "FINDINGS.md");
|
|
471
|
+
const taskPath = path.join(projectDir, "tasks.md");
|
|
472
|
+
let findingCount = 0;
|
|
473
|
+
if (fs.existsSync(findingsPath))
|
|
474
|
+
findingCount = (fs.readFileSync(findingsPath, "utf8").match(/^- /gm) || []).length;
|
|
475
|
+
let taskCount = 0;
|
|
476
|
+
if (fs.existsSync(taskPath)) {
|
|
477
|
+
const queueMatch = fs.readFileSync(taskPath, "utf8").match(/## Queue[\s\S]*?(?=## |$)/);
|
|
478
|
+
if (queueMatch)
|
|
479
|
+
taskCount = (queueMatch[0].match(/^- /gm) || []).length;
|
|
542
480
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
481
|
+
projectInfo = {
|
|
482
|
+
diskPath: projConfig.sourcePath || projectDir, ownership: projConfig.ownership || "default",
|
|
483
|
+
configFile, configExists: fs.existsSync(configFile), hasFindings: fs.existsSync(findingsPath),
|
|
484
|
+
hasTasks: fs.existsSync(taskPath), hasSummary: fs.existsSync(path.join(projectDir, "summary.md")),
|
|
485
|
+
hasClaudeMd: fs.existsSync(path.join(projectDir, "CLAUDE.md")), findingCount, taskCount,
|
|
486
|
+
};
|
|
546
487
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
488
|
+
jsonOk(res, {
|
|
489
|
+
ok: true, proactivity: prefs.proactivity || "high", proactivityFindings,
|
|
490
|
+
proactivityTask: prefs.proactivityTask || prefs.proactivity || "high", taskMode: workflowPolicy.taskMode,
|
|
491
|
+
findingSensitivity: workflowPolicy.findingSensitivity || "balanced", autoCaptureEnabled: proactivityFindings !== "low",
|
|
492
|
+
consolidationEntryThreshold: CONSOLIDATION_ENTRY_THRESHOLD, hooksEnabled: hooksData.globalEnabled,
|
|
493
|
+
mcpEnabled: prefs.mcpEnabled !== false, hookTools: hooksData.tools,
|
|
494
|
+
retentionPolicy, workflowPolicy, merged, overrides, projectInfo,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
jsonErr(res, errorMessage(err));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function handleGetConfig(res, url, ctx) {
|
|
502
|
+
const project = String(parseQs(url).project || "");
|
|
503
|
+
if (project && !isValidProjectName(project))
|
|
504
|
+
return jsonErr(res, "Invalid project name", 400);
|
|
505
|
+
try {
|
|
506
|
+
const config = mergeConfig(ctx.phrenPath, project || undefined);
|
|
507
|
+
const projects = getProjectDirs(ctx.phrenPath, ctx.profile).map((d) => path.basename(d)).filter((p) => p !== "global");
|
|
508
|
+
jsonOk(res, { ok: true, config, projects });
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
jsonErr(res, errorMessage(err));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function handleGetCsrfToken(res, ctx) {
|
|
515
|
+
if (!ctx.csrfTokens)
|
|
516
|
+
return jsonOk(res, { ok: true, token: null });
|
|
517
|
+
pruneExpiredCsrfTokens(ctx.csrfTokens);
|
|
518
|
+
const token = crypto.randomUUID();
|
|
519
|
+
ctx.csrfTokens.set(token, Date.now());
|
|
520
|
+
jsonOk(res, { ok: true, token });
|
|
521
|
+
}
|
|
522
|
+
function handleGetFindings(res, pathname, ctx) {
|
|
523
|
+
const project = decodeURIComponent(pathname.slice("/api/findings/".length));
|
|
524
|
+
if (!project || !isValidProjectName(project))
|
|
525
|
+
return jsonErr(res, "Invalid project name", 400);
|
|
526
|
+
const result = readFindings(ctx.phrenPath, project);
|
|
527
|
+
jsonOk(res, result.ok ? { ok: true, data: { project, findings: result.data } } : { ok: false, error: result.error });
|
|
528
|
+
}
|
|
529
|
+
// ── POST handlers ─────────────────────────────────────────────────────────────
|
|
530
|
+
function handlePostSync(req, res, url, ctx) {
|
|
531
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
532
|
+
const message = String(parsed.message || "update phren");
|
|
533
|
+
try {
|
|
534
|
+
const EXEC_TIMEOUT = 15_000;
|
|
535
|
+
const runGit = (args) => execFileSync("git", args, { cwd: ctx.phrenPath, encoding: "utf8", timeout: EXEC_TIMEOUT }).trim();
|
|
536
|
+
const status = runGit(["status", "--porcelain"]);
|
|
537
|
+
if (!status)
|
|
538
|
+
return jsonOk(res, { ok: true, message: "Nothing to sync — working tree clean." });
|
|
539
|
+
runGit(["add", "--", "*.md", "*.json", "*.yaml", "*.yml", "*.jsonl", "*.txt"]);
|
|
540
|
+
const stagedFiles = runGit(["diff", "--cached", "--name-only"]);
|
|
541
|
+
if (!stagedFiles)
|
|
542
|
+
return jsonOk(res, { ok: true, message: "Nothing to sync — no matching files to commit." });
|
|
543
|
+
runGit(["commit", "-m", message, "--only", "--", ...stagedFiles.split("\n").filter(Boolean)]);
|
|
544
|
+
let pushed = false;
|
|
545
|
+
try {
|
|
546
|
+
if (runGit(["remote"])) {
|
|
547
|
+
runGit(["push"]);
|
|
548
|
+
pushed = true;
|
|
549
|
+
}
|
|
554
550
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
}
|
|
559
|
-
if (req.method === "GET" && pathname === "/api/project-reference-content") {
|
|
560
|
-
const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
|
|
561
|
-
const project = String(qs.project || "");
|
|
562
|
-
const file = String(qs.file || "");
|
|
563
|
-
const contentResult = readReferenceContent(phrenPath, project, file);
|
|
564
|
-
res.writeHead(contentResult.ok ? 200 : 400, { "content-type": "application/json; charset=utf-8" });
|
|
565
|
-
res.end(JSON.stringify(contentResult.ok ? { ok: true, content: contentResult.content } : { ok: false, error: contentResult.error }));
|
|
566
|
-
return;
|
|
551
|
+
catch { /* no remote or push failed */ }
|
|
552
|
+
const changedFiles = status.split("\n").filter(Boolean).length;
|
|
553
|
+
jsonOk(res, { ok: true, message: `Synced ${changedFiles} file(s).${pushed ? " Pushed to remote." : " No remote, saved locally."}` });
|
|
567
554
|
}
|
|
568
|
-
|
|
569
|
-
res
|
|
570
|
-
res.end(JSON.stringify(collectSkillsForUI(phrenPath, profile)));
|
|
571
|
-
return;
|
|
555
|
+
catch (err) {
|
|
556
|
+
jsonErr(res, errorMessage(err));
|
|
572
557
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
function handlePostApprove(req, res, url, ctx) {
|
|
561
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
562
|
+
const project = String(parsed.project || "");
|
|
563
|
+
const line = String(parsed.line || "");
|
|
564
|
+
if (!project || !isValidProjectName(project) || !line)
|
|
565
|
+
return jsonErr(res, "Missing project or line");
|
|
566
|
+
try {
|
|
567
|
+
const qPath = queueFilePath(ctx.phrenPath, project);
|
|
568
|
+
if (fs.existsSync(qPath)) {
|
|
569
|
+
const lines = fs.readFileSync(qPath, "utf8").split("\n").filter((l) => l.trim() !== line.trim());
|
|
570
|
+
fs.writeFileSync(qPath, lines.join("\n"));
|
|
585
571
|
}
|
|
586
|
-
res
|
|
587
|
-
res.end(JSON.stringify({ ok: true, content: fs.readFileSync(filePath, "utf8") }));
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
if (req.method === "GET" && pathname === "/api/hooks") {
|
|
591
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
592
|
-
res.end(JSON.stringify(getHooksData(phrenPath)));
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
if (req.method === "POST" && pathname === "/api/skill-save") {
|
|
596
|
-
void readFormBody(req, res).then((parsed) => {
|
|
597
|
-
if (!parsed)
|
|
598
|
-
return;
|
|
599
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
600
|
-
return;
|
|
601
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
602
|
-
return;
|
|
603
|
-
const filePath = String(parsed.path || "");
|
|
604
|
-
const content = String(parsed.content || "");
|
|
605
|
-
if (!filePath || !isAllowedSkillPath(filePath, phrenPath)) {
|
|
606
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
607
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid path" }));
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
try {
|
|
611
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
612
|
-
const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
|
|
613
|
-
fs.writeFileSync(tmpPath, content);
|
|
614
|
-
fs.renameSync(tmpPath, filePath);
|
|
615
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
616
|
-
res.end(JSON.stringify({ ok: true }));
|
|
617
|
-
}
|
|
618
|
-
catch (err) {
|
|
619
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
620
|
-
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
621
|
-
}
|
|
622
|
-
});
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
if (req.method === "POST" && pathname === "/api/skill-toggle") {
|
|
626
|
-
void readFormBody(req, res).then((parsed) => {
|
|
627
|
-
if (!parsed)
|
|
628
|
-
return;
|
|
629
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
630
|
-
return;
|
|
631
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
632
|
-
return;
|
|
633
|
-
const project = String(parsed.project || "");
|
|
634
|
-
const name = String(parsed.name || "");
|
|
635
|
-
const enabled = String(parsed.enabled || "") === "true";
|
|
636
|
-
if (!project || !name || (project.toLowerCase() !== "global" && !isValidProjectName(project))) {
|
|
637
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
638
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid skill toggle request" }));
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
const skill = findSkill(phrenPath, profile || "", project, name);
|
|
642
|
-
if (!skill || "error" in skill) {
|
|
643
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
644
|
-
res.end(JSON.stringify({ ok: false, error: skill && "error" in skill ? skill.error : "Skill not found" }));
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
setSkillEnabledAndSync(phrenPath, project, skill.name, enabled);
|
|
648
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
649
|
-
res.end(JSON.stringify({ ok: true, enabled }));
|
|
650
|
-
});
|
|
651
|
-
return;
|
|
572
|
+
jsonOk(res, { ok: true });
|
|
652
573
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
if (!parsed)
|
|
656
|
-
return;
|
|
657
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
658
|
-
return;
|
|
659
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
660
|
-
return;
|
|
661
|
-
const tool = String(parsed.tool || "").toLowerCase();
|
|
662
|
-
const validTools = ["claude", "copilot", "cursor", "codex"];
|
|
663
|
-
if (!validTools.includes(tool)) {
|
|
664
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
665
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid tool" }));
|
|
666
|
-
return;
|
|
667
|
-
}
|
|
668
|
-
const prefs = readInstallPreferences(phrenPath);
|
|
669
|
-
const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
|
|
670
|
-
const current = toolPrefs[tool] !== false && prefs.hooksEnabled !== false;
|
|
671
|
-
writeInstallPreferences(phrenPath, {
|
|
672
|
-
hookTools: { ...toolPrefs, [tool]: !current },
|
|
673
|
-
});
|
|
674
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
675
|
-
res.end(JSON.stringify({ ok: true, enabled: !current }));
|
|
676
|
-
});
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
if (req.method === "POST" && pathname === "/api/project-topics/save") {
|
|
680
|
-
void readFormBody(req, res).then((parsed) => {
|
|
681
|
-
if (!parsed)
|
|
682
|
-
return;
|
|
683
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
684
|
-
return;
|
|
685
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
686
|
-
return;
|
|
687
|
-
const project = String(parsed.project || "");
|
|
688
|
-
const rawTopics = String(parsed.topics || "");
|
|
689
|
-
if (!project || !isValidProjectName(project)) {
|
|
690
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
691
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
const topics = parseTopicsPayload(rawTopics);
|
|
695
|
-
if (!topics) {
|
|
696
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
697
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid topics payload" }));
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
const saved = writeProjectTopics(phrenPath, project, topics);
|
|
701
|
-
if (!saved.ok) {
|
|
702
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
703
|
-
res.end(JSON.stringify(saved));
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
for (const topic of saved.topics) {
|
|
707
|
-
const ensured = ensureTopicReferenceDoc(phrenPath, project, topic);
|
|
708
|
-
if (!ensured.ok) {
|
|
709
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
710
|
-
res.end(JSON.stringify({ ok: false, error: ensured.error }));
|
|
711
|
-
return;
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
715
|
-
res.end(JSON.stringify({ ok: true, ...getProjectTopicsResponse(phrenPath, project) }));
|
|
716
|
-
});
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
if (req.method === "POST" && pathname === "/api/project-topics/reclassify") {
|
|
720
|
-
void readFormBody(req, res).then((parsed) => {
|
|
721
|
-
if (!parsed)
|
|
722
|
-
return;
|
|
723
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
724
|
-
return;
|
|
725
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
726
|
-
return;
|
|
727
|
-
const project = String(parsed.project || "");
|
|
728
|
-
if (!project || !isValidProjectName(project)) {
|
|
729
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
730
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
const result = reclassifyLegacyTopicDocs(phrenPath, project);
|
|
734
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
735
|
-
res.end(JSON.stringify({ ok: true, ...result }));
|
|
736
|
-
});
|
|
737
|
-
return;
|
|
574
|
+
catch (err) {
|
|
575
|
+
jsonErr(res, errorMessage(err));
|
|
738
576
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid topic payload" }));
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
const pinned = pinProjectTopicSuggestion(phrenPath, project, topic);
|
|
761
|
-
if (!pinned.ok) {
|
|
762
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
763
|
-
res.end(JSON.stringify(pinned));
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
767
|
-
res.end(JSON.stringify({ ok: true, ...getProjectTopicsResponse(phrenPath, project) }));
|
|
768
|
-
});
|
|
769
|
-
return;
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
function handlePostReject(req, res, url, ctx) {
|
|
580
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
581
|
+
const project = String(parsed.project || "");
|
|
582
|
+
const line = String(parsed.line || "");
|
|
583
|
+
if (!project || !isValidProjectName(project) || !line)
|
|
584
|
+
return jsonErr(res, "Missing project or line");
|
|
585
|
+
try {
|
|
586
|
+
const qPath = queueFilePath(ctx.phrenPath, project);
|
|
587
|
+
if (fs.existsSync(qPath)) {
|
|
588
|
+
const lines = fs.readFileSync(qPath, "utf8").split("\n").filter((l) => l.trim() !== line.trim());
|
|
589
|
+
fs.writeFileSync(qPath, lines.join("\n"));
|
|
590
|
+
}
|
|
591
|
+
const findingText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
|
|
592
|
+
if (findingText)
|
|
593
|
+
removeFinding(ctx.phrenPath, project, findingText);
|
|
594
|
+
jsonOk(res, { ok: true });
|
|
770
595
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
if (!parsed)
|
|
774
|
-
return;
|
|
775
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
776
|
-
return;
|
|
777
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
778
|
-
return;
|
|
779
|
-
const project = String(parsed.project || "");
|
|
780
|
-
const slug = String(parsed.slug || "");
|
|
781
|
-
if (!project || !isValidProjectName(project)) {
|
|
782
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
783
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
const unpinned = unpinProjectTopicSuggestion(phrenPath, project, slug);
|
|
787
|
-
if (!unpinned.ok) {
|
|
788
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
789
|
-
res.end(JSON.stringify(unpinned));
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
793
|
-
res.end(JSON.stringify({ ok: true, ...getProjectTopicsResponse(phrenPath, project) }));
|
|
794
|
-
});
|
|
795
|
-
return;
|
|
596
|
+
catch (err) {
|
|
597
|
+
jsonErr(res, errorMessage(err));
|
|
796
598
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
try {
|
|
811
|
-
const { runSearch } = await import("../cli/search.js");
|
|
812
|
-
const result = await runSearch({ query, limit: Math.min(searchLimit, 50), project: searchProject, type: searchType }, phrenPath, profile || "");
|
|
813
|
-
// Build file date map from source headers like [project/filename]
|
|
814
|
-
const fileDates = {};
|
|
815
|
-
for (const line of result.lines) {
|
|
816
|
-
const srcMatch = line.match(/^\[([^\]]+)\]\s/);
|
|
817
|
-
if (srcMatch) {
|
|
818
|
-
const sourceKey = srcMatch[1];
|
|
819
|
-
if (fileDates[sourceKey])
|
|
820
|
-
continue;
|
|
821
|
-
const slashIdx = sourceKey.indexOf("/");
|
|
822
|
-
if (slashIdx > 0) {
|
|
823
|
-
const proj = sourceKey.slice(0, slashIdx);
|
|
824
|
-
const file = sourceKey.slice(slashIdx + 1);
|
|
825
|
-
try {
|
|
826
|
-
const filePath = path.join(phrenPath, proj, file);
|
|
827
|
-
if (fs.existsSync(filePath)) {
|
|
828
|
-
const stat = fs.statSync(filePath);
|
|
829
|
-
fileDates[sourceKey] = stat.mtime.toISOString();
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
catch { /* skip */ }
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
837
|
-
res.end(JSON.stringify({ ok: true, query, results: result.lines, fileDates }));
|
|
838
|
-
}
|
|
839
|
-
catch (err) {
|
|
840
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
841
|
-
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
842
|
-
}
|
|
843
|
-
return;
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
function handlePostEdit(req, res, url, ctx) {
|
|
602
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
603
|
+
const project = String(parsed.project || "");
|
|
604
|
+
const line = String(parsed.line || "");
|
|
605
|
+
const newText = String(parsed.new_text || "");
|
|
606
|
+
if (!project || !isValidProjectName(project) || !line || !newText)
|
|
607
|
+
return jsonErr(res, "Missing project, line, or new_text");
|
|
608
|
+
try {
|
|
609
|
+
const oldText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
|
|
610
|
+
const result = editFinding(ctx.phrenPath, project, oldText, newText);
|
|
611
|
+
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
|
|
844
612
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
const focusProject = graphParams.get("project") || undefined;
|
|
848
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
849
|
-
res.end(JSON.stringify(await buildGraph(phrenPath, profile, focusProject)));
|
|
850
|
-
return;
|
|
613
|
+
catch (err) {
|
|
614
|
+
jsonErr(res, errorMessage(err));
|
|
851
615
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
res
|
|
866
|
-
return;
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
function handlePostSkillSave(req, res, url, ctx) {
|
|
619
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
620
|
+
const filePath = String(parsed.path || "");
|
|
621
|
+
const content = String(parsed.content || "");
|
|
622
|
+
if (!filePath || !isAllowedSkillPath(filePath, ctx.phrenPath))
|
|
623
|
+
return jsonErr(res, "Invalid path");
|
|
624
|
+
try {
|
|
625
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
626
|
+
const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
|
|
627
|
+
fs.writeFileSync(tmpPath, content);
|
|
628
|
+
fs.renameSync(tmpPath, filePath);
|
|
629
|
+
jsonOk(res, { ok: true });
|
|
867
630
|
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
return;
|
|
871
|
-
try {
|
|
872
|
-
const docs = readTasksAcrossProjects(phrenPath, profile);
|
|
873
|
-
const tasks = [];
|
|
874
|
-
for (const doc of docs) {
|
|
875
|
-
for (const section of ["Active", "Queue", "Done"]) {
|
|
876
|
-
for (const item of doc.items[section]) {
|
|
877
|
-
tasks.push({
|
|
878
|
-
project: doc.project,
|
|
879
|
-
section: item.section,
|
|
880
|
-
line: item.line,
|
|
881
|
-
priority: item.priority,
|
|
882
|
-
pinned: item.pinned,
|
|
883
|
-
githubIssue: item.githubIssue,
|
|
884
|
-
githubUrl: item.githubUrl,
|
|
885
|
-
context: item.context,
|
|
886
|
-
checked: item.checked,
|
|
887
|
-
sessionId: item.sessionId,
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
893
|
-
res.end(JSON.stringify({ ok: true, tasks }));
|
|
894
|
-
}
|
|
895
|
-
catch (err) {
|
|
896
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
897
|
-
res.end(JSON.stringify({ ok: false, error: errorMessage(err), tasks: [] }));
|
|
898
|
-
}
|
|
899
|
-
return;
|
|
631
|
+
catch (err) {
|
|
632
|
+
jsonErr(res, errorMessage(err));
|
|
900
633
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
function handlePostSkillToggle(req, res, url, ctx) {
|
|
637
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
638
|
+
const project = String(parsed.project || "");
|
|
639
|
+
const name = String(parsed.name || "");
|
|
640
|
+
const enabled = String(parsed.enabled || "") === "true";
|
|
641
|
+
if (!project || !name || (project.toLowerCase() !== "global" && !isValidProjectName(project)))
|
|
642
|
+
return jsonErr(res, "Invalid skill toggle request");
|
|
643
|
+
const skill = findSkill(ctx.phrenPath, ctx.profile || "", project, name);
|
|
644
|
+
if (!skill || "error" in skill)
|
|
645
|
+
return jsonErr(res, skill && "error" in skill ? skill.error : "Skill not found");
|
|
646
|
+
setSkillEnabledAndSync(ctx.phrenPath, project, skill.name, enabled);
|
|
647
|
+
jsonOk(res, { ok: true, enabled });
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
function handlePostHookToggle(req, res, url, ctx) {
|
|
651
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
652
|
+
const tool = String(parsed.tool || "").toLowerCase();
|
|
653
|
+
if (!["claude", "copilot", "cursor", "codex"].includes(tool))
|
|
654
|
+
return jsonErr(res, "Invalid tool");
|
|
655
|
+
const prefs = readInstallPreferences(ctx.phrenPath);
|
|
656
|
+
const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
|
|
657
|
+
const current = toolPrefs[tool] !== false && prefs.hooksEnabled !== false;
|
|
658
|
+
writeInstallPreferences(ctx.phrenPath, { hookTools: { ...toolPrefs, [tool]: !current } });
|
|
659
|
+
jsonOk(res, { ok: true, enabled: !current });
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
function handlePostTopicsSave(req, res, url, ctx) {
|
|
663
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
664
|
+
const project = String(parsed.project || "");
|
|
665
|
+
if (!project || !isValidProjectName(project))
|
|
666
|
+
return jsonErr(res, "Invalid project", 400);
|
|
667
|
+
const topics = parseTopicsPayload(String(parsed.topics || ""));
|
|
668
|
+
if (!topics)
|
|
669
|
+
return jsonErr(res, "Invalid topics payload", 400);
|
|
670
|
+
const saved = writeProjectTopics(ctx.phrenPath, project, topics);
|
|
671
|
+
if (!saved.ok)
|
|
672
|
+
return jsonOk(res, saved);
|
|
673
|
+
for (const topic of saved.topics) {
|
|
674
|
+
const ensured = ensureTopicReferenceDoc(ctx.phrenPath, project, topic);
|
|
675
|
+
if (!ensured.ok)
|
|
676
|
+
return jsonErr(res, ensured.error);
|
|
921
677
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
678
|
+
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
function handlePostTopicsReclassify(req, res, url, ctx) {
|
|
682
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
683
|
+
const project = String(parsed.project || "");
|
|
684
|
+
if (!project || !isValidProjectName(project))
|
|
685
|
+
return jsonErr(res, "Invalid project", 400);
|
|
686
|
+
jsonOk(res, { ok: true, ...reclassifyLegacyTopicDocs(ctx.phrenPath, project) });
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
function handlePostTopicsPin(req, res, url, ctx) {
|
|
690
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
691
|
+
const project = String(parsed.project || "");
|
|
692
|
+
if (!project || !isValidProjectName(project))
|
|
693
|
+
return jsonErr(res, "Invalid project", 400);
|
|
694
|
+
const topic = parseTopicPayload(String(parsed.topic || ""));
|
|
695
|
+
if (!topic)
|
|
696
|
+
return jsonErr(res, "Invalid topic payload", 400);
|
|
697
|
+
const pinned = pinProjectTopicSuggestion(ctx.phrenPath, project, topic);
|
|
698
|
+
if (!pinned.ok)
|
|
699
|
+
return jsonOk(res, pinned);
|
|
700
|
+
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
function handlePostTopicsUnpin(req, res, url, ctx) {
|
|
704
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
705
|
+
const project = String(parsed.project || "");
|
|
706
|
+
if (!project || !isValidProjectName(project))
|
|
707
|
+
return jsonErr(res, "Invalid project", 400);
|
|
708
|
+
const unpinned = unpinProjectTopicSuggestion(ctx.phrenPath, project, String(parsed.slug || ""));
|
|
709
|
+
if (!unpinned.ok)
|
|
710
|
+
return jsonOk(res, unpinned);
|
|
711
|
+
jsonOk(res, { ok: true, ...getProjectTopicsResponse(ctx.phrenPath, project) });
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function handlePostTaskAction(req, res, url, ctx, action) {
|
|
715
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
716
|
+
const project = String(parsed.project || "");
|
|
717
|
+
const item = String(parsed.item || "");
|
|
718
|
+
if (!project || !item || !isValidProjectName(project))
|
|
719
|
+
return jsonErr(res, "Missing or invalid project/item", 400);
|
|
720
|
+
if (action === "complete") {
|
|
721
|
+
const result = completeTaskStore(ctx.phrenPath, project, item);
|
|
722
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
|
|
942
723
|
}
|
|
943
|
-
if (
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
return;
|
|
947
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
948
|
-
return;
|
|
949
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
950
|
-
return;
|
|
951
|
-
const project = String(parsed.project || "");
|
|
952
|
-
const item = String(parsed.item || "");
|
|
953
|
-
if (!project || !item || !isValidProjectName(project)) {
|
|
954
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
955
|
-
res.end(JSON.stringify({ ok: false, error: "Missing or invalid project/item" }));
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
const result = removeTaskStore(phrenPath, project, item);
|
|
959
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
960
|
-
res.end(JSON.stringify({ ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error }));
|
|
961
|
-
});
|
|
962
|
-
return;
|
|
724
|
+
else if (action === "add") {
|
|
725
|
+
const result = addTaskStore(ctx.phrenPath, project, item);
|
|
726
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? `Task added: ${result.data.line}` : undefined, error: result.ok ? undefined : result.error });
|
|
963
727
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
return;
|
|
968
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
969
|
-
return;
|
|
970
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
971
|
-
return;
|
|
972
|
-
const project = String(parsed.project || "");
|
|
973
|
-
const item = String(parsed.item || "");
|
|
974
|
-
if (!project || !item || !isValidProjectName(project)) {
|
|
975
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
976
|
-
res.end(JSON.stringify({ ok: false, error: "Missing or invalid project/item" }));
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
const updates = {};
|
|
980
|
-
if (Object.prototype.hasOwnProperty.call(parsed, "text"))
|
|
981
|
-
updates.text = String(parsed.text || "");
|
|
982
|
-
if (Object.prototype.hasOwnProperty.call(parsed, "priority"))
|
|
983
|
-
updates.priority = String(parsed.priority || "");
|
|
984
|
-
if (Object.prototype.hasOwnProperty.call(parsed, "section"))
|
|
985
|
-
updates.section = String(parsed.section || "");
|
|
986
|
-
const result = updateTaskStore(phrenPath, project, item, updates);
|
|
987
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
988
|
-
res.end(JSON.stringify({ ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error }));
|
|
989
|
-
});
|
|
990
|
-
return;
|
|
728
|
+
else {
|
|
729
|
+
const result = removeTaskStore(ctx.phrenPath, project, item);
|
|
730
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
|
|
991
731
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
function handlePostTaskUpdate(req, res, url, ctx) {
|
|
735
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
736
|
+
const project = String(parsed.project || "");
|
|
737
|
+
const item = String(parsed.item || "");
|
|
738
|
+
if (!project || !item || !isValidProjectName(project))
|
|
739
|
+
return jsonErr(res, "Missing or invalid project/item", 400);
|
|
740
|
+
const updates = {};
|
|
741
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "text"))
|
|
742
|
+
updates.text = String(parsed.text || "");
|
|
743
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "priority"))
|
|
744
|
+
updates.priority = String(parsed.priority || "");
|
|
745
|
+
if (Object.prototype.hasOwnProperty.call(parsed, "section"))
|
|
746
|
+
updates.section = String(parsed.section || "");
|
|
747
|
+
const result = updateTaskStore(ctx.phrenPath, project, item, updates);
|
|
748
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
function handlePostSettingsFindingSensitivity(req, res, url, ctx) {
|
|
752
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
753
|
+
const value = String(parsed.value || "");
|
|
754
|
+
const valid = ["minimal", "conservative", "balanced", "aggressive"];
|
|
755
|
+
if (!valid.includes(value))
|
|
756
|
+
return jsonErr(res, `Invalid finding sensitivity: "${value}". Must be one of: ${valid.join(", ")}`);
|
|
757
|
+
const result = updateWorkflowPolicy(ctx.phrenPath, { findingSensitivity: value });
|
|
758
|
+
jsonOk(res, result.ok ? { ok: true, findingSensitivity: result.data.findingSensitivity } : { ok: false, error: result.error });
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
function handlePostSettingsTaskMode(req, res, url, ctx) {
|
|
762
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
763
|
+
const value = String(parsed.value || "").trim().toLowerCase();
|
|
764
|
+
const valid = VALID_TASK_MODES;
|
|
765
|
+
if (!valid.includes(value))
|
|
766
|
+
return jsonErr(res, `Invalid task mode: "${value}". Must be one of: ${valid.join(", ")}`);
|
|
767
|
+
const result = updateWorkflowPolicy(ctx.phrenPath, { taskMode: value });
|
|
768
|
+
jsonOk(res, result.ok ? { ok: true, taskMode: result.data.taskMode } : { ok: false, error: result.error });
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
function handlePostSettingsProactivity(req, res, url, ctx) {
|
|
772
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
773
|
+
const value = String(parsed.value || "").trim().toLowerCase();
|
|
774
|
+
const valid = ["high", "medium", "low"];
|
|
775
|
+
if (!valid.includes(value))
|
|
776
|
+
return jsonErr(res, `Invalid proactivity: "${value}". Must be one of: ${valid.join(", ")}`);
|
|
777
|
+
writeInstallPreferences(ctx.phrenPath, { proactivity: value });
|
|
778
|
+
writeGovernanceInstallPreferences(ctx.phrenPath, { proactivity: value });
|
|
779
|
+
jsonOk(res, { ok: true, proactivity: value });
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
function handlePostSettingsAutoCapture(req, res, url, ctx) {
|
|
783
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
784
|
+
const enabled = String(parsed.enabled || "").toLowerCase() === "true";
|
|
785
|
+
const next = enabled ? "high" : "low";
|
|
786
|
+
writeInstallPreferences(ctx.phrenPath, { proactivityFindings: next });
|
|
787
|
+
writeGovernanceInstallPreferences(ctx.phrenPath, { proactivityFindings: next });
|
|
788
|
+
jsonOk(res, { ok: true, autoCaptureEnabled: enabled, proactivityFindings: next });
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
function handlePostSettingsMcpEnabled(req, res, url, ctx) {
|
|
792
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
793
|
+
const enabled = String(parsed.enabled || "").toLowerCase() === "true";
|
|
794
|
+
writeInstallPreferences(ctx.phrenPath, { mcpEnabled: enabled });
|
|
795
|
+
jsonOk(res, { ok: true, mcpEnabled: enabled });
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
function handlePostSettingsProjectOverrides(req, res, url, ctx) {
|
|
799
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
800
|
+
const project = String(parsed.project || "");
|
|
801
|
+
const field = String(parsed.field || "");
|
|
802
|
+
const value = String(parsed.value || "");
|
|
803
|
+
const clearField = String(parsed.clear || "") === "true";
|
|
804
|
+
if (!project || !isValidProjectName(project))
|
|
805
|
+
return jsonErr(res, "Invalid project name", 400);
|
|
806
|
+
const registeredProjects = getProjectDirs(ctx.phrenPath, ctx.profile).map((d) => path.basename(d)).filter((p) => p !== "global");
|
|
807
|
+
const registrationWarning = registeredProjects.includes(project) ? undefined : `Project '${project}' is not registered in the active profile. Config was saved but it will have no effect until the project is added with 'phren add'.`;
|
|
808
|
+
const VALID_FIELDS = {
|
|
809
|
+
findingSensitivity: ["minimal", "conservative", "balanced", "aggressive"],
|
|
810
|
+
proactivity: ["high", "medium", "low"], proactivityFindings: ["high", "medium", "low"],
|
|
811
|
+
proactivityTask: ["high", "medium", "low"], taskMode: ["off", "manual", "suggest", "auto"],
|
|
812
|
+
};
|
|
813
|
+
const NUMERIC_RETENTION_FIELDS = ["ttlDays", "retentionDays", "autoAcceptThreshold", "minInjectConfidence"];
|
|
814
|
+
const NUMERIC_WORKFLOW_FIELDS = ["lowConfidenceThreshold"];
|
|
815
|
+
try {
|
|
816
|
+
updateProjectConfigOverrides(ctx.phrenPath, project, (current) => {
|
|
817
|
+
const next = { ...current };
|
|
818
|
+
if (clearField) {
|
|
819
|
+
if (field in VALID_FIELDS)
|
|
820
|
+
delete next[field];
|
|
821
|
+
else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
|
|
822
|
+
if (next.retentionPolicy)
|
|
823
|
+
delete next.retentionPolicy[field];
|
|
1016
824
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
if (queueMatch)
|
|
1021
|
-
taskCount = (queueMatch[0].match(/^- /gm) || []).length;
|
|
825
|
+
else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
|
|
826
|
+
if (next.workflowPolicy)
|
|
827
|
+
delete next.workflowPolicy[field];
|
|
1022
828
|
}
|
|
1023
|
-
|
|
1024
|
-
diskPath: projConfig.sourcePath || projectDir,
|
|
1025
|
-
ownership: projConfig.ownership || "default",
|
|
1026
|
-
configFile,
|
|
1027
|
-
configExists: fs.existsSync(configFile),
|
|
1028
|
-
hasFindings: fs.existsSync(findingsPath),
|
|
1029
|
-
hasTasks: fs.existsSync(taskPath),
|
|
1030
|
-
hasSummary: fs.existsSync(path.join(projectDir, "summary.md")),
|
|
1031
|
-
hasClaudeMd: fs.existsSync(path.join(projectDir, "CLAUDE.md")),
|
|
1032
|
-
findingCount,
|
|
1033
|
-
taskCount,
|
|
1034
|
-
};
|
|
1035
|
-
}
|
|
1036
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1037
|
-
res.end(JSON.stringify({
|
|
1038
|
-
ok: true,
|
|
1039
|
-
proactivity: prefs.proactivity || "high",
|
|
1040
|
-
proactivityFindings,
|
|
1041
|
-
proactivityTask: prefs.proactivityTask || prefs.proactivity || "high",
|
|
1042
|
-
taskMode: workflowPolicy.taskMode,
|
|
1043
|
-
findingSensitivity: workflowPolicy.findingSensitivity || "balanced",
|
|
1044
|
-
autoCaptureEnabled: proactivityFindings !== "low",
|
|
1045
|
-
consolidationEntryThreshold: CONSOLIDATION_ENTRY_THRESHOLD,
|
|
1046
|
-
hooksEnabled: hooksData.globalEnabled,
|
|
1047
|
-
mcpEnabled: prefs.mcpEnabled !== false,
|
|
1048
|
-
hookTools: hooksData.tools,
|
|
1049
|
-
retentionPolicy,
|
|
1050
|
-
workflowPolicy,
|
|
1051
|
-
merged,
|
|
1052
|
-
overrides,
|
|
1053
|
-
projectInfo,
|
|
1054
|
-
}));
|
|
1055
|
-
}
|
|
1056
|
-
catch (err) {
|
|
1057
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1058
|
-
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
1059
|
-
}
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
|
-
if (req.method === "POST" && pathname === "/api/settings/finding-sensitivity") {
|
|
1063
|
-
void readFormBody(req, res).then((parsed) => {
|
|
1064
|
-
if (!parsed)
|
|
1065
|
-
return;
|
|
1066
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
1067
|
-
return;
|
|
1068
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
1069
|
-
return;
|
|
1070
|
-
const value = String(parsed.value || "");
|
|
1071
|
-
const valid = ["minimal", "conservative", "balanced", "aggressive"];
|
|
1072
|
-
if (!valid.includes(value)) {
|
|
1073
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
1074
|
-
res.end(JSON.stringify({ ok: false, error: `Invalid finding sensitivity: "${value}". Must be one of: ${valid.join(", ")}` }));
|
|
1075
|
-
return;
|
|
1076
|
-
}
|
|
1077
|
-
const result = updateWorkflowPolicy(phrenPath, { findingSensitivity: value });
|
|
1078
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1079
|
-
res.end(JSON.stringify(result.ok ? { ok: true, findingSensitivity: result.data.findingSensitivity } : { ok: false, error: result.error }));
|
|
1080
|
-
});
|
|
1081
|
-
return;
|
|
1082
|
-
}
|
|
1083
|
-
if (req.method === "POST" && pathname === "/api/settings/task-mode") {
|
|
1084
|
-
void readFormBody(req, res).then((parsed) => {
|
|
1085
|
-
if (!parsed)
|
|
1086
|
-
return;
|
|
1087
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
1088
|
-
return;
|
|
1089
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
1090
|
-
return;
|
|
1091
|
-
const value = String(parsed.value || "").trim().toLowerCase();
|
|
1092
|
-
const valid = VALID_TASK_MODES;
|
|
1093
|
-
if (!valid.includes(value)) {
|
|
1094
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1095
|
-
res.end(JSON.stringify({ ok: false, error: `Invalid task mode: "${value}". Must be one of: ${valid.join(", ")}` }));
|
|
1096
|
-
return;
|
|
829
|
+
return next;
|
|
1097
830
|
}
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
return;
|
|
1103
|
-
}
|
|
1104
|
-
if (req.method === "POST" && pathname === "/api/settings/proactivity") {
|
|
1105
|
-
void readFormBody(req, res).then((parsed) => {
|
|
1106
|
-
if (!parsed)
|
|
1107
|
-
return;
|
|
1108
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
1109
|
-
return;
|
|
1110
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
1111
|
-
return;
|
|
1112
|
-
const value = String(parsed.value || "").trim().toLowerCase();
|
|
1113
|
-
const valid = ["high", "medium", "low"];
|
|
1114
|
-
if (!valid.includes(value)) {
|
|
1115
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1116
|
-
res.end(JSON.stringify({ ok: false, error: `Invalid proactivity: "${value}". Must be one of: ${valid.join(", ")}` }));
|
|
1117
|
-
return;
|
|
831
|
+
if (field in VALID_FIELDS) {
|
|
832
|
+
if (!VALID_FIELDS[field].includes(value))
|
|
833
|
+
throw new Error(`Invalid value "${value}" for ${field}`);
|
|
834
|
+
next[field] = value;
|
|
1118
835
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
if (req.method === "POST" && pathname === "/api/settings/auto-capture") {
|
|
1127
|
-
void readFormBody(req, res).then((parsed) => {
|
|
1128
|
-
if (!parsed)
|
|
1129
|
-
return;
|
|
1130
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
1131
|
-
return;
|
|
1132
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
1133
|
-
return;
|
|
1134
|
-
const enabled = String(parsed.enabled || "").toLowerCase() === "true";
|
|
1135
|
-
const next = enabled ? "high" : "low";
|
|
1136
|
-
writeInstallPreferences(phrenPath, { proactivityFindings: next });
|
|
1137
|
-
writeGovernanceInstallPreferences(phrenPath, { proactivityFindings: next });
|
|
1138
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1139
|
-
res.end(JSON.stringify({ ok: true, autoCaptureEnabled: enabled, proactivityFindings: next }));
|
|
1140
|
-
});
|
|
1141
|
-
return;
|
|
1142
|
-
}
|
|
1143
|
-
if (req.method === "POST" && pathname === "/api/settings/mcp-enabled") {
|
|
1144
|
-
void readFormBody(req, res).then((parsed) => {
|
|
1145
|
-
if (!parsed)
|
|
1146
|
-
return;
|
|
1147
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
1148
|
-
return;
|
|
1149
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
1150
|
-
return;
|
|
1151
|
-
const enabled = String(parsed.enabled || "").toLowerCase() === "true";
|
|
1152
|
-
writeInstallPreferences(phrenPath, { mcpEnabled: enabled });
|
|
1153
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1154
|
-
res.end(JSON.stringify({ ok: true, mcpEnabled: enabled }));
|
|
1155
|
-
});
|
|
1156
|
-
return;
|
|
1157
|
-
}
|
|
1158
|
-
// POST /api/settings/project-overrides — write per-project config overrides
|
|
1159
|
-
if (req.method === "POST" && pathname === "/api/settings/project-overrides") {
|
|
1160
|
-
void readFormBody(req, res).then((parsed) => {
|
|
1161
|
-
if (!parsed)
|
|
1162
|
-
return;
|
|
1163
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
1164
|
-
return;
|
|
1165
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
1166
|
-
return;
|
|
1167
|
-
const project = String(parsed.project || "");
|
|
1168
|
-
const field = String(parsed.field || "");
|
|
1169
|
-
const value = String(parsed.value || "");
|
|
1170
|
-
const clearField = String(parsed.clear || "") === "true";
|
|
1171
|
-
if (!project || !isValidProjectName(project)) {
|
|
1172
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
1173
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
|
|
1174
|
-
return;
|
|
836
|
+
else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
|
|
837
|
+
const num = parseFloat(value);
|
|
838
|
+
if (!Number.isFinite(num) || num < 0)
|
|
839
|
+
throw new Error(`Invalid numeric value for ${field}`);
|
|
840
|
+
next.retentionPolicy = { ...next.retentionPolicy, [field]: num };
|
|
1175
841
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
proactivity: ["high", "medium", "low"],
|
|
1182
|
-
proactivityFindings: ["high", "medium", "low"],
|
|
1183
|
-
proactivityTask: ["high", "medium", "low"],
|
|
1184
|
-
taskMode: ["off", "manual", "suggest", "auto"],
|
|
1185
|
-
};
|
|
1186
|
-
const NUMERIC_RETENTION_FIELDS = ["ttlDays", "retentionDays", "autoAcceptThreshold", "minInjectConfidence"];
|
|
1187
|
-
const NUMERIC_WORKFLOW_FIELDS = ["lowConfidenceThreshold"];
|
|
1188
|
-
try {
|
|
1189
|
-
updateProjectConfigOverrides(phrenPath, project, (current) => {
|
|
1190
|
-
const next = { ...current };
|
|
1191
|
-
if (clearField) {
|
|
1192
|
-
if (field in VALID_FIELDS)
|
|
1193
|
-
delete next[field];
|
|
1194
|
-
else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
|
|
1195
|
-
if (next.retentionPolicy)
|
|
1196
|
-
delete next.retentionPolicy[field];
|
|
1197
|
-
}
|
|
1198
|
-
else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
|
|
1199
|
-
if (next.workflowPolicy)
|
|
1200
|
-
delete next.workflowPolicy[field];
|
|
1201
|
-
}
|
|
1202
|
-
return next;
|
|
1203
|
-
}
|
|
1204
|
-
if (field in VALID_FIELDS) {
|
|
1205
|
-
const allowed = VALID_FIELDS[field];
|
|
1206
|
-
if (!allowed.includes(value))
|
|
1207
|
-
throw new Error(`Invalid value "${value}" for ${field}`);
|
|
1208
|
-
next[field] = value;
|
|
1209
|
-
}
|
|
1210
|
-
else if (NUMERIC_RETENTION_FIELDS.includes(field)) {
|
|
1211
|
-
const num = parseFloat(value);
|
|
1212
|
-
if (!Number.isFinite(num) || num < 0)
|
|
1213
|
-
throw new Error(`Invalid numeric value for ${field}`);
|
|
1214
|
-
next.retentionPolicy = { ...next.retentionPolicy, [field]: num };
|
|
1215
|
-
}
|
|
1216
|
-
else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
|
|
1217
|
-
const num = parseFloat(value);
|
|
1218
|
-
if (!Number.isFinite(num) || num < 0 || num > 1)
|
|
1219
|
-
throw new Error(`Invalid value for ${field} (must be 0–1)`);
|
|
1220
|
-
next.workflowPolicy = { ...next.workflowPolicy, [field]: num };
|
|
1221
|
-
}
|
|
1222
|
-
else {
|
|
1223
|
-
throw new Error(`Unknown config field: ${field}`);
|
|
1224
|
-
}
|
|
1225
|
-
return next;
|
|
1226
|
-
});
|
|
1227
|
-
const merged = mergeConfig(phrenPath, project);
|
|
1228
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1229
|
-
res.end(JSON.stringify({ ok: true, config: merged, ...(registrationWarning ? { warning: registrationWarning } : {}) }));
|
|
842
|
+
else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
|
|
843
|
+
const num = parseFloat(value);
|
|
844
|
+
if (!Number.isFinite(num) || num < 0 || num > 1)
|
|
845
|
+
throw new Error(`Invalid value for ${field} (must be 0-1)`);
|
|
846
|
+
next.workflowPolicy = { ...next.workflowPolicy, [field]: num };
|
|
1230
847
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
848
|
+
else {
|
|
849
|
+
throw new Error(`Unknown config field: ${field}`);
|
|
1234
850
|
}
|
|
851
|
+
return next;
|
|
1235
852
|
});
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
if (req.method === "GET" && pathname === "/api/config") {
|
|
1239
|
-
if (!requireGetAuth(req, res, url, authToken, true))
|
|
1240
|
-
return;
|
|
1241
|
-
const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
|
|
1242
|
-
const project = String(qs.project || "");
|
|
1243
|
-
if (project && !isValidProjectName(project)) {
|
|
1244
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
1245
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
|
|
1246
|
-
return;
|
|
1247
|
-
}
|
|
1248
|
-
try {
|
|
1249
|
-
const config = mergeConfig(phrenPath, project || undefined);
|
|
1250
|
-
const projects = getProjectDirs(phrenPath, profile).map((d) => path.basename(d)).filter((p) => p !== "global");
|
|
1251
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1252
|
-
res.end(JSON.stringify({ ok: true, config, projects }));
|
|
1253
|
-
}
|
|
1254
|
-
catch (err) {
|
|
1255
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1256
|
-
res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
|
|
1257
|
-
}
|
|
1258
|
-
return;
|
|
853
|
+
jsonOk(res, { ok: true, config: mergeConfig(ctx.phrenPath, project), ...(registrationWarning ? { warning: registrationWarning } : {}) });
|
|
1259
854
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
return;
|
|
1263
|
-
if (!csrfTokens) {
|
|
1264
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
1265
|
-
res.end(JSON.stringify({ ok: true, token: null }));
|
|
1266
|
-
return;
|
|
1267
|
-
}
|
|
1268
|
-
pruneExpiredCsrfTokens(csrfTokens);
|
|
1269
|
-
const token = crypto.randomUUID();
|
|
1270
|
-
csrfTokens.set(token, Date.now());
|
|
1271
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1272
|
-
res.end(JSON.stringify({ ok: true, token }));
|
|
1273
|
-
return;
|
|
855
|
+
catch (err) {
|
|
856
|
+
jsonErr(res, errorMessage(err));
|
|
1274
857
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
function handleFindingsWrite(req, res, url, pathname, ctx) {
|
|
861
|
+
const project = decodeURIComponent(pathname.slice("/api/findings/".length));
|
|
862
|
+
if (!project || !isValidProjectName(project))
|
|
863
|
+
return jsonErr(res, "Invalid project name", 400);
|
|
864
|
+
if (req.method === "POST") {
|
|
865
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
866
|
+
const text = String(parsed.text || "");
|
|
867
|
+
if (!text)
|
|
868
|
+
return jsonErr(res, "text is required");
|
|
869
|
+
const result = addFindingStore(ctx.phrenPath, project, text);
|
|
870
|
+
jsonOk(res, { ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error });
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
else if (req.method === "PUT") {
|
|
874
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
875
|
+
const oldText = String(parsed.old_text || "");
|
|
876
|
+
const newText = String(parsed.new_text || "");
|
|
877
|
+
if (!oldText || !newText)
|
|
878
|
+
return jsonErr(res, "old_text and new_text are required");
|
|
879
|
+
const result = editFinding(ctx.phrenPath, project, oldText, newText);
|
|
880
|
+
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
// DELETE
|
|
885
|
+
withPostBody(req, res, url, ctx, (parsed) => {
|
|
886
|
+
const text = String(parsed.text || "");
|
|
887
|
+
if (!text)
|
|
888
|
+
return jsonErr(res, "text is required");
|
|
889
|
+
const result = removeFinding(ctx.phrenPath, project, text);
|
|
890
|
+
jsonOk(res, { ok: result.ok, error: result.ok ? undefined : result.error });
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// ── Main router ───────────────────────────────────────────────────────────────
|
|
895
|
+
export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
|
|
896
|
+
try {
|
|
897
|
+
repairPreexistingInstall(phrenPath);
|
|
898
|
+
}
|
|
899
|
+
catch (err) {
|
|
900
|
+
logger.debug("web-ui", `web-ui repair: ${errorMessage(err)}`);
|
|
901
|
+
}
|
|
902
|
+
const ctx = {
|
|
903
|
+
phrenPath, profile, authToken: opts?.authToken, csrfTokens: opts?.csrfTokens, renderPage,
|
|
904
|
+
};
|
|
905
|
+
return http.createServer(async (req, res) => {
|
|
906
|
+
const url = req.url || "/";
|
|
907
|
+
const pathname = url.includes("?") ? url.slice(0, url.indexOf("?")) : url;
|
|
908
|
+
// Home page
|
|
909
|
+
if (req.method === "GET" && pathname === "/") {
|
|
910
|
+
if (!requireGetAuth(req, res, url, ctx.authToken))
|
|
1281
911
|
return;
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
res.end(JSON.stringify({ ok: false, error: result.error }));
|
|
1287
|
-
}
|
|
1288
|
-
else {
|
|
1289
|
-
res.end(JSON.stringify({ ok: true, data: { project, findings: result.data } }));
|
|
1290
|
-
}
|
|
1291
|
-
return;
|
|
912
|
+
pruneExpiredCsrfTokens(ctx.csrfTokens);
|
|
913
|
+
if (ctx.csrfTokens)
|
|
914
|
+
ctx.csrfTokens.set(crypto.randomUUID(), Date.now());
|
|
915
|
+
return handleGetHome(res, ctx);
|
|
1292
916
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
if (!project || !isValidProjectName(project)) {
|
|
1297
|
-
res.writeHead(400, { "content-type": "application/json" });
|
|
1298
|
-
res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
|
|
1299
|
-
return;
|
|
1300
|
-
}
|
|
1301
|
-
void readFormBody(req, res).then((parsed) => {
|
|
1302
|
-
if (!parsed)
|
|
1303
|
-
return;
|
|
1304
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
1305
|
-
return;
|
|
1306
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
1307
|
-
return;
|
|
1308
|
-
const text = String(parsed.text || "");
|
|
1309
|
-
if (!text) {
|
|
1310
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
1311
|
-
res.end(JSON.stringify({ ok: false, error: "text is required" }));
|
|
1312
|
-
return;
|
|
1313
|
-
}
|
|
1314
|
-
const result = addFindingStore(phrenPath, project, text);
|
|
1315
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1316
|
-
res.end(JSON.stringify({ ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error }));
|
|
1317
|
-
});
|
|
917
|
+
setCommonHeaders(res);
|
|
918
|
+
// Auth gate for all GET /api/* routes
|
|
919
|
+
if (pathname.startsWith("/api/") && req.method === "GET" && !requireGetAuth(req, res, url, ctx.authToken))
|
|
1318
920
|
return;
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
return;
|
|
921
|
+
// ── GET routes ──
|
|
922
|
+
if (req.method === "GET") {
|
|
923
|
+
switch (pathname) {
|
|
924
|
+
case "/api/projects": return handleGetProjects(res, ctx);
|
|
925
|
+
case "/api/change-token": return handleGetChangeToken(res, ctx);
|
|
926
|
+
case "/api/runtime-health": return handleGetRuntimeHealth(res, ctx);
|
|
927
|
+
case "/api/review-queue": return handleGetReviewQueue(res, ctx);
|
|
928
|
+
case "/api/review-activity": return handleGetReviewActivity(res, ctx);
|
|
929
|
+
case "/api/project-topics": return handleGetProjectTopics(res, url, ctx);
|
|
930
|
+
case "/api/project-reference-list": return handleGetProjectReferenceList(res, url, ctx);
|
|
931
|
+
case "/api/project-reference-content": return handleGetProjectReferenceContent(res, url, ctx);
|
|
932
|
+
case "/api/skills": return handleGetSkills(res, ctx);
|
|
933
|
+
case "/api/hooks": return handleGetHooks(res, ctx);
|
|
934
|
+
case "/api/scores": return handleGetScores(res, ctx);
|
|
935
|
+
case "/api/tasks": return handleGetTasks(res, ctx);
|
|
936
|
+
case "/api/settings": return handleGetSettings(res, url, ctx);
|
|
937
|
+
case "/api/config": return handleGetConfig(res, url, ctx);
|
|
938
|
+
case "/api/csrf-token": return handleGetCsrfToken(res, ctx);
|
|
939
|
+
case "/api/search": return await handleGetSearch(res, url, ctx);
|
|
1327
940
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
if (!oldText || !newText) {
|
|
1338
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
1339
|
-
res.end(JSON.stringify({ ok: false, error: "old_text and new_text are required" }));
|
|
1340
|
-
return;
|
|
1341
|
-
}
|
|
1342
|
-
const result = editFinding(phrenPath, project, oldText, newText);
|
|
1343
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1344
|
-
res.end(JSON.stringify({ ok: result.ok, error: result.ok ? undefined : result.error }));
|
|
1345
|
-
});
|
|
1346
|
-
return;
|
|
941
|
+
// Prefix-matched GET routes
|
|
942
|
+
if (pathname.startsWith("/api/project-content"))
|
|
943
|
+
return handleGetProjectContent(res, url, ctx);
|
|
944
|
+
if (pathname.startsWith("/api/skill-content"))
|
|
945
|
+
return handleGetSkillContent(res, url, ctx);
|
|
946
|
+
if (pathname.startsWith("/api/graph"))
|
|
947
|
+
return await handleGetGraph(res, url, ctx);
|
|
948
|
+
if (pathname.startsWith("/api/findings/"))
|
|
949
|
+
return handleGetFindings(res, pathname, ctx);
|
|
1347
950
|
}
|
|
1348
|
-
//
|
|
1349
|
-
if (req.method === "
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
return;
|
|
951
|
+
// ── POST/PUT/DELETE routes ──
|
|
952
|
+
if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
|
|
953
|
+
switch (pathname) {
|
|
954
|
+
case "/api/sync": return handlePostSync(req, res, url, ctx);
|
|
955
|
+
case "/api/approve": return handlePostApprove(req, res, url, ctx);
|
|
956
|
+
case "/api/reject": return handlePostReject(req, res, url, ctx);
|
|
957
|
+
case "/api/edit": return handlePostEdit(req, res, url, ctx);
|
|
958
|
+
case "/api/skill-save": return handlePostSkillSave(req, res, url, ctx);
|
|
959
|
+
case "/api/skill-toggle": return handlePostSkillToggle(req, res, url, ctx);
|
|
960
|
+
case "/api/hook-toggle": return handlePostHookToggle(req, res, url, ctx);
|
|
961
|
+
case "/api/project-topics/save": return handlePostTopicsSave(req, res, url, ctx);
|
|
962
|
+
case "/api/project-topics/reclassify": return handlePostTopicsReclassify(req, res, url, ctx);
|
|
963
|
+
case "/api/project-topics/pin": return handlePostTopicsPin(req, res, url, ctx);
|
|
964
|
+
case "/api/project-topics/unpin": return handlePostTopicsUnpin(req, res, url, ctx);
|
|
965
|
+
case "/api/tasks/complete": return handlePostTaskAction(req, res, url, ctx, "complete");
|
|
966
|
+
case "/api/tasks/add": return handlePostTaskAction(req, res, url, ctx, "add");
|
|
967
|
+
case "/api/tasks/remove": return handlePostTaskAction(req, res, url, ctx, "remove");
|
|
968
|
+
case "/api/tasks/update": return handlePostTaskUpdate(req, res, url, ctx);
|
|
969
|
+
case "/api/settings/finding-sensitivity": return handlePostSettingsFindingSensitivity(req, res, url, ctx);
|
|
970
|
+
case "/api/settings/task-mode": return handlePostSettingsTaskMode(req, res, url, ctx);
|
|
971
|
+
case "/api/settings/proactivity": return handlePostSettingsProactivity(req, res, url, ctx);
|
|
972
|
+
case "/api/settings/auto-capture": return handlePostSettingsAutoCapture(req, res, url, ctx);
|
|
973
|
+
case "/api/settings/mcp-enabled": return handlePostSettingsMcpEnabled(req, res, url, ctx);
|
|
974
|
+
case "/api/settings/project-overrides": return handlePostSettingsProjectOverrides(req, res, url, ctx);
|
|
1355
975
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
if (!requirePostAuth(req, res, url, parsed, authToken, true))
|
|
1360
|
-
return;
|
|
1361
|
-
if (!requireCsrf(res, parsed, csrfTokens, true))
|
|
1362
|
-
return;
|
|
1363
|
-
const text = String(parsed.text || "");
|
|
1364
|
-
if (!text) {
|
|
1365
|
-
res.writeHead(200, { "content-type": "application/json" });
|
|
1366
|
-
res.end(JSON.stringify({ ok: false, error: "text is required" }));
|
|
1367
|
-
return;
|
|
1368
|
-
}
|
|
1369
|
-
const result = removeFinding(phrenPath, project, text);
|
|
1370
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
1371
|
-
res.end(JSON.stringify({ ok: result.ok, error: result.ok ? undefined : result.error }));
|
|
1372
|
-
});
|
|
1373
|
-
return;
|
|
976
|
+
// Prefix-matched write routes
|
|
977
|
+
if (pathname.startsWith("/api/findings/"))
|
|
978
|
+
return handleFindingsWrite(req, res, url, pathname, ctx);
|
|
1374
979
|
}
|
|
1375
980
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
1376
981
|
res.end("Not found");
|