@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.
Files changed (59) hide show
  1. package/mcp/dist/cli/actions.js +3 -0
  2. package/mcp/dist/cli/config.js +3 -3
  3. package/mcp/dist/cli/govern.js +18 -8
  4. package/mcp/dist/cli/hooks-context.js +1 -1
  5. package/mcp/dist/cli/hooks-session.js +18 -62
  6. package/mcp/dist/cli/namespaces.js +1 -1
  7. package/mcp/dist/cli/search.js +5 -5
  8. package/mcp/dist/cli-hooks-prompt.js +7 -3
  9. package/mcp/dist/cli-hooks-session-handlers.js +3 -15
  10. package/mcp/dist/cli-hooks-stop.js +10 -48
  11. package/mcp/dist/content/archive.js +8 -20
  12. package/mcp/dist/content/learning.js +29 -8
  13. package/mcp/dist/data/access.js +13 -4
  14. package/mcp/dist/finding/lifecycle.js +9 -3
  15. package/mcp/dist/governance/audit.js +13 -5
  16. package/mcp/dist/governance/policy.js +13 -0
  17. package/mcp/dist/governance/rbac.js +1 -1
  18. package/mcp/dist/governance/scores.js +2 -1
  19. package/mcp/dist/hooks.js +52 -6
  20. package/mcp/dist/index.js +1 -1
  21. package/mcp/dist/init/init.js +66 -45
  22. package/mcp/dist/init/shared.js +1 -1
  23. package/mcp/dist/init-bootstrap.js +0 -47
  24. package/mcp/dist/init-fresh.js +13 -18
  25. package/mcp/dist/init-uninstall.js +22 -0
  26. package/mcp/dist/init-walkthrough.js +19 -24
  27. package/mcp/dist/link/doctor.js +9 -0
  28. package/mcp/dist/package-metadata.js +1 -1
  29. package/mcp/dist/phren-art.js +4 -120
  30. package/mcp/dist/proactivity.js +1 -1
  31. package/mcp/dist/project-topics.js +16 -46
  32. package/mcp/dist/provider-adapters.js +1 -1
  33. package/mcp/dist/runtime-profile.js +1 -1
  34. package/mcp/dist/shared/data-utils.js +25 -0
  35. package/mcp/dist/shared/fragment-graph.js +4 -18
  36. package/mcp/dist/shared/index.js +14 -10
  37. package/mcp/dist/shared/ollama.js +23 -5
  38. package/mcp/dist/shared/process.js +24 -0
  39. package/mcp/dist/shared/retrieval.js +7 -4
  40. package/mcp/dist/shared/search-fallback.js +1 -0
  41. package/mcp/dist/shared.js +2 -1
  42. package/mcp/dist/shell/render.js +1 -1
  43. package/mcp/dist/skill/registry.js +1 -1
  44. package/mcp/dist/skill/state.js +0 -3
  45. package/mcp/dist/task/github.js +1 -0
  46. package/mcp/dist/task/lifecycle.js +1 -6
  47. package/mcp/dist/tools/config.js +415 -400
  48. package/mcp/dist/tools/finding.js +390 -373
  49. package/mcp/dist/tools/ops.js +372 -365
  50. package/mcp/dist/tools/search.js +495 -487
  51. package/mcp/dist/tools/session.js +3 -2
  52. package/mcp/dist/tools/skills.js +9 -0
  53. package/mcp/dist/ui/page.js +1 -1
  54. package/mcp/dist/ui/server.js +645 -1040
  55. package/mcp/dist/utils.js +12 -8
  56. package/package.json +1 -1
  57. package/mcp/dist/init-dryrun.js +0 -55
  58. package/mcp/dist/init-migrate.js +0 -51
  59. package/mcp/dist/init-walkthrough-merge.js +0 -90
@@ -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
- export async function launchWebUiBrowser(url) {
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
- export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
292
- try {
293
- repairPreexistingInstall(phrenPath);
294
- }
295
- catch (err) {
296
- logger.debug("web-ui", `web-ui repair: ${errorMessage(err)}`);
297
- }
298
- const authToken = opts?.authToken;
299
- const csrfTokens = opts?.csrfTokens;
300
- return http.createServer(async (req, res) => {
301
- const url = req.url || "/";
302
- const pathname = url.includes("?") ? url.slice(0, url.indexOf("?")) : url;
303
- if (req.method === "GET" && pathname === "/") {
304
- if (!requireGetAuth(req, res, url, authToken))
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
- if (req.method === "POST" && pathname === "/api/sync") {
336
- void readFormBody(req, res).then((parsed) => {
337
- if (!parsed)
338
- return;
339
- if (!requirePostAuth(req, res, url, parsed, authToken, true))
340
- return;
341
- if (!requireCsrf(res, parsed, csrfTokens, true))
342
- return;
343
- const message = String(parsed.message || "update phren");
344
- try {
345
- const EXEC_TIMEOUT = 15_000;
346
- const runGit = (args) => execFileSync("git", args, { cwd: phrenPath, encoding: "utf8", timeout: EXEC_TIMEOUT }).trim();
347
- const status = runGit(["status", "--porcelain"]);
348
- if (!status) {
349
- res.writeHead(200, { "content-type": "application/json" });
350
- res.end(JSON.stringify({ ok: true, message: "Nothing to sync — working tree clean." }));
351
- return;
352
- }
353
- runGit(["add", "--", "*.md", "*.json", "*.yaml", "*.yml", "*.jsonl", "*.txt"]);
354
- // Use --only to commit exactly the files we just staged,
355
- // avoiding committing unrelated previously-staged changes.
356
- const stagedFiles = runGit(["diff", "--cached", "--name-only"]);
357
- if (!stagedFiles) {
358
- res.writeHead(200, { "content-type": "application/json" });
359
- res.end(JSON.stringify({ ok: true, message: "Nothing to sync — no matching files to commit." }));
360
- return;
361
- }
362
- runGit(["commit", "-m", message, "--only", "--", ...stagedFiles.split("\n").filter(Boolean)]);
363
- let pushed = false;
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 remotes = runGit(["remote"]);
366
- if (remotes) {
367
- runGit(["push"]);
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
- res.writeHead(200, { "content-type": "application/json" });
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
- // POST /api/edit edit a finding's text
476
- if (req.method === "POST" && pathname === "/api/edit") {
477
- void readFormBody(req, res).then((parsed) => {
478
- if (!parsed)
479
- return;
480
- if (!requirePostAuth(req, res, url, parsed, authToken, true))
481
- return;
482
- if (!requireCsrf(res, parsed, csrfTokens, true))
483
- return;
484
- const project = String(parsed.project || "");
485
- const line = String(parsed.line || "");
486
- const newText = String(parsed.new_text || "");
487
- if (!project || !isValidProjectName(project) || !line || !newText) {
488
- res.writeHead(200, { "content-type": "application/json" });
489
- res.end(JSON.stringify({ ok: false, error: "Missing project, line, or new_text" }));
490
- return;
491
- }
492
- try {
493
- const oldText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
494
- const result = editFinding(phrenPath, project, oldText, newText);
495
- res.writeHead(200, { "content-type": "application/json" });
496
- res.end(JSON.stringify({ ok: result.ok, error: result.ok ? undefined : result.error }));
497
- }
498
- catch (err) {
499
- res.writeHead(200, { "content-type": "application/json" });
500
- res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
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
- if (req.method === "GET" && pathname === "/api/project-topics") {
536
- const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
537
- const project = String(qs.project || "");
538
- if (!project || !isValidProjectName(project)) {
539
- res.writeHead(400, { "content-type": "application/json" });
540
- res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
541
- return;
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
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
544
- res.end(JSON.stringify({ ok: true, ...getProjectTopicsResponse(phrenPath, project) }));
545
- return;
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
- if (req.method === "GET" && pathname === "/api/project-reference-list") {
548
- const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
549
- const project = String(qs.project || "");
550
- if (!project || !isValidProjectName(project)) {
551
- res.writeHead(400, { "content-type": "application/json" });
552
- res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
553
- return;
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
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
556
- res.end(JSON.stringify({ ok: true, ...listProjectReferenceDocs(phrenPath, project) }));
557
- return;
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
- if (req.method === "GET" && pathname === "/api/skills") {
569
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
570
- res.end(JSON.stringify(collectSkillsForUI(phrenPath, profile)));
571
- return;
555
+ catch (err) {
556
+ jsonErr(res, errorMessage(err));
572
557
  }
573
- if (req.method === "GET" && pathname.startsWith("/api/skill-content")) {
574
- const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
575
- const filePath = String(qs.path || "");
576
- if (!filePath || !isAllowedSkillPath(filePath, phrenPath)) {
577
- res.writeHead(400, { "content-type": "application/json" });
578
- res.end(JSON.stringify({ ok: false, error: "Invalid path" }));
579
- return;
580
- }
581
- if (!fs.existsSync(filePath)) {
582
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
583
- res.end(JSON.stringify({ ok: false, error: "File not found" }));
584
- return;
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.writeHead(200, { "content-type": "application/json; charset=utf-8" });
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
- if (req.method === "POST" && pathname === "/api/hook-toggle") {
654
- void readFormBody(req, res).then((parsed) => {
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
- if (req.method === "POST" && pathname === "/api/project-topics/pin") {
740
- void readFormBody(req, res).then((parsed) => {
741
- if (!parsed)
742
- return;
743
- if (!requirePostAuth(req, res, url, parsed, authToken, true))
744
- return;
745
- if (!requireCsrf(res, parsed, csrfTokens, true))
746
- return;
747
- const project = String(parsed.project || "");
748
- const rawTopic = String(parsed.topic || "");
749
- if (!project || !isValidProjectName(project)) {
750
- res.writeHead(400, { "content-type": "application/json" });
751
- res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
752
- return;
753
- }
754
- const topic = parseTopicPayload(rawTopic);
755
- if (!topic) {
756
- res.writeHead(400, { "content-type": "application/json" });
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
- if (req.method === "POST" && pathname === "/api/project-topics/unpin") {
772
- void readFormBody(req, res).then((parsed) => {
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
- if (req.method === "GET" && pathname === "/api/search") {
798
- if (!requireGetAuth(req, res, url, authToken, true))
799
- return;
800
- const searchParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
801
- const query = searchParams.get("q") || searchParams.get("query") || "";
802
- const searchProject = searchParams.get("project") || undefined;
803
- const searchType = searchParams.get("type") || undefined;
804
- const searchLimit = parseInt(searchParams.get("limit") || "10", 10) || 10;
805
- if (!query.trim()) {
806
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
807
- res.end(JSON.stringify({ ok: false, error: "Missing query parameter (q or query)." }));
808
- return;
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
- if (req.method === "GET" && pathname.startsWith("/api/graph")) {
846
- const graphParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
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
- if (req.method === "GET" && pathname === "/api/scores") {
853
- let scores = {};
854
- try {
855
- const raw = fs.readFileSync(path.join(phrenPath, ".runtime", "memory-scores.json"), "utf-8");
856
- const parsed = JSON.parse(raw);
857
- if (parsed && typeof parsed === "object") {
858
- scores = parsed;
859
- }
860
- }
861
- catch {
862
- // file missing or unparseable – return empty
863
- }
864
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
865
- res.end(JSON.stringify(scores));
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
- if (req.method === "GET" && pathname === "/api/tasks") {
869
- if (!requireGetAuth(req, res, url, authToken, true))
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
- if (req.method === "POST" && pathname === "/api/tasks/complete") {
902
- void readFormBody(req, res).then((parsed) => {
903
- if (!parsed)
904
- return;
905
- if (!requirePostAuth(req, res, url, parsed, authToken, true))
906
- return;
907
- if (!requireCsrf(res, parsed, csrfTokens, true))
908
- return;
909
- const project = String(parsed.project || "");
910
- const item = String(parsed.item || "");
911
- if (!project || !item || !isValidProjectName(project)) {
912
- res.writeHead(400, { "content-type": "application/json" });
913
- res.end(JSON.stringify({ ok: false, error: "Missing or invalid project/item" }));
914
- return;
915
- }
916
- const result = completeTaskStore(phrenPath, project, item);
917
- res.writeHead(200, { "content-type": "application/json" });
918
- res.end(JSON.stringify({ ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error }));
919
- });
920
- return;
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
- if (req.method === "POST" && pathname === "/api/tasks/add") {
923
- void readFormBody(req, res).then((parsed) => {
924
- if (!parsed)
925
- return;
926
- if (!requirePostAuth(req, res, url, parsed, authToken, true))
927
- return;
928
- if (!requireCsrf(res, parsed, csrfTokens, true))
929
- return;
930
- const project = String(parsed.project || "");
931
- const item = String(parsed.item || "");
932
- if (!project || !item || !isValidProjectName(project)) {
933
- res.writeHead(400, { "content-type": "application/json" });
934
- res.end(JSON.stringify({ ok: false, error: "Missing or invalid project/item" }));
935
- return;
936
- }
937
- const result = addTaskStore(phrenPath, project, item);
938
- res.writeHead(200, { "content-type": "application/json" });
939
- res.end(JSON.stringify({ ok: result.ok, message: result.ok ? `Task added: ${result.data.line}` : undefined, error: result.ok ? undefined : result.error }));
940
- });
941
- return;
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 (req.method === "POST" && pathname === "/api/tasks/remove") {
944
- void readFormBody(req, res).then((parsed) => {
945
- if (!parsed)
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
- if (req.method === "POST" && pathname === "/api/tasks/update") {
965
- void readFormBody(req, res).then((parsed) => {
966
- if (!parsed)
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
- if (req.method === "GET" && pathname === "/api/settings") {
993
- if (!requireGetAuth(req, res, url, authToken, true))
994
- return;
995
- try {
996
- const prefs = readInstallPreferences(phrenPath);
997
- const workflowPolicy = getWorkflowPolicy(phrenPath);
998
- const retentionPolicy = getRetentionPolicy(phrenPath);
999
- const hooksData = getHooksData(phrenPath);
1000
- const proactivityFindings = prefs.proactivityFindings || prefs.proactivity || "high";
1001
- const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
1002
- const settingsProject = String(qs.project || "");
1003
- const merged = settingsProject && isValidProjectName(settingsProject) ? mergeConfig(phrenPath, settingsProject) : null;
1004
- const overrides = settingsProject && isValidProjectName(settingsProject) ? getProjectConfigOverrides(phrenPath, settingsProject) : null;
1005
- // Build project info when a specific project is selected
1006
- let projectInfo = null;
1007
- if (settingsProject && isValidProjectName(settingsProject)) {
1008
- const projectDir = path.join(phrenPath, settingsProject);
1009
- const configFile = path.join(projectDir, "phren.project.yaml");
1010
- const projConfig = readProjectConfig(phrenPath, settingsProject);
1011
- const findingsPath = path.join(projectDir, "FINDINGS.md");
1012
- const taskPath = path.join(projectDir, "tasks.md");
1013
- let findingCount = 0;
1014
- if (fs.existsSync(findingsPath)) {
1015
- findingCount = (fs.readFileSync(findingsPath, "utf8").match(/^- /gm) || []).length;
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
- let taskCount = 0;
1018
- if (fs.existsSync(taskPath)) {
1019
- const queueMatch = fs.readFileSync(taskPath, "utf8").match(/## Queue[\s\S]*?(?=## |$)/);
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
- projectInfo = {
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
- const result = updateWorkflowPolicy(phrenPath, { taskMode: value });
1099
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1100
- res.end(JSON.stringify(result.ok ? { ok: true, taskMode: result.data.taskMode } : { ok: false, error: result.error }));
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
- writeInstallPreferences(phrenPath, { proactivity: value });
1120
- writeGovernanceInstallPreferences(phrenPath, { proactivity: value });
1121
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1122
- res.end(JSON.stringify({ ok: true, proactivity: value }));
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
- const registeredProjects = getProjectDirs(phrenPath, profile).map((d) => path.basename(d)).filter((p) => p !== "global");
1177
- const isRegistered = registeredProjects.includes(project);
1178
- const registrationWarning = isRegistered ? 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'.`;
1179
- const VALID_FIELDS = {
1180
- findingSensitivity: ["minimal", "conservative", "balanced", "aggressive"],
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
- catch (err) {
1232
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
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
- return;
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
- if (req.method === "GET" && pathname === "/api/csrf-token") {
1261
- if (!requireGetAuth(req, res, url, authToken, true))
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
- // GET /api/findings/:project — list findings for a project
1276
- if (req.method === "GET" && pathname.startsWith("/api/findings/")) {
1277
- const project = decodeURIComponent(pathname.slice("/api/findings/".length));
1278
- if (!project || !isValidProjectName(project)) {
1279
- res.writeHead(400, { "content-type": "application/json" });
1280
- res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
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
- const result = readFindings(phrenPath, project);
1284
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1285
- if (!result.ok) {
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
- // POST /api/findings/:project — add a finding
1294
- if (req.method === "POST" && pathname.startsWith("/api/findings/")) {
1295
- const project = decodeURIComponent(pathname.slice("/api/findings/".length));
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
- // PUT /api/findings/:project — edit a finding (old_text new_text)
1321
- if (req.method === "PUT" && pathname.startsWith("/api/findings/")) {
1322
- const project = decodeURIComponent(pathname.slice("/api/findings/".length));
1323
- if (!project || !isValidProjectName(project)) {
1324
- res.writeHead(400, { "content-type": "application/json" });
1325
- res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
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
- void readFormBody(req, res).then((parsed) => {
1329
- if (!parsed)
1330
- return;
1331
- if (!requirePostAuth(req, res, url, parsed, authToken, true))
1332
- return;
1333
- if (!requireCsrf(res, parsed, csrfTokens, true))
1334
- return;
1335
- const oldText = String(parsed.old_text || "");
1336
- const newText = String(parsed.new_text || "");
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
- // DELETE /api/findings/:project remove a finding by text match
1349
- if (req.method === "DELETE" && pathname.startsWith("/api/findings/")) {
1350
- const project = decodeURIComponent(pathname.slice("/api/findings/".length));
1351
- if (!project || !isValidProjectName(project)) {
1352
- res.writeHead(400, { "content-type": "application/json" });
1353
- res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
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
- void readFormBody(req, res).then((parsed) => {
1357
- if (!parsed)
1358
- return;
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");