@phren/cli 0.0.1

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 (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/mcp/dist/capabilities/cli.js +61 -0
  4. package/mcp/dist/capabilities/index.js +15 -0
  5. package/mcp/dist/capabilities/mcp.js +61 -0
  6. package/mcp/dist/capabilities/types.js +57 -0
  7. package/mcp/dist/capabilities/vscode.js +61 -0
  8. package/mcp/dist/capabilities/web-ui.js +61 -0
  9. package/mcp/dist/cli-actions.js +302 -0
  10. package/mcp/dist/cli-config.js +580 -0
  11. package/mcp/dist/cli-extract.js +305 -0
  12. package/mcp/dist/cli-govern.js +371 -0
  13. package/mcp/dist/cli-graph.js +169 -0
  14. package/mcp/dist/cli-hooks-citations.js +44 -0
  15. package/mcp/dist/cli-hooks-context.js +56 -0
  16. package/mcp/dist/cli-hooks-globs.js +83 -0
  17. package/mcp/dist/cli-hooks-output.js +130 -0
  18. package/mcp/dist/cli-hooks-retrieval.js +2 -0
  19. package/mcp/dist/cli-hooks-session.js +1402 -0
  20. package/mcp/dist/cli-hooks.js +350 -0
  21. package/mcp/dist/cli-namespaces.js +989 -0
  22. package/mcp/dist/cli-ops.js +253 -0
  23. package/mcp/dist/cli-search.js +407 -0
  24. package/mcp/dist/cli.js +108 -0
  25. package/mcp/dist/content-archive.js +278 -0
  26. package/mcp/dist/content-citation.js +391 -0
  27. package/mcp/dist/content-dedup.js +622 -0
  28. package/mcp/dist/content-learning.js +472 -0
  29. package/mcp/dist/content-metadata.js +186 -0
  30. package/mcp/dist/content-validate.js +462 -0
  31. package/mcp/dist/core-finding.js +54 -0
  32. package/mcp/dist/core-project.js +36 -0
  33. package/mcp/dist/core-search.js +50 -0
  34. package/mcp/dist/data-access.js +400 -0
  35. package/mcp/dist/data-tasks.js +821 -0
  36. package/mcp/dist/embedding.js +344 -0
  37. package/mcp/dist/entrypoint.js +387 -0
  38. package/mcp/dist/finding-context.js +172 -0
  39. package/mcp/dist/finding-impact.js +181 -0
  40. package/mcp/dist/finding-journal.js +122 -0
  41. package/mcp/dist/finding-lifecycle.js +259 -0
  42. package/mcp/dist/governance-audit.js +22 -0
  43. package/mcp/dist/governance-locks.js +96 -0
  44. package/mcp/dist/governance-policy.js +648 -0
  45. package/mcp/dist/governance-scores.js +355 -0
  46. package/mcp/dist/hooks.js +449 -0
  47. package/mcp/dist/impact-scoring.js +22 -0
  48. package/mcp/dist/index-query.js +168 -0
  49. package/mcp/dist/index.js +205 -0
  50. package/mcp/dist/init-config.js +336 -0
  51. package/mcp/dist/init-preferences.js +62 -0
  52. package/mcp/dist/init-setup.js +1305 -0
  53. package/mcp/dist/init-shared.js +29 -0
  54. package/mcp/dist/init.js +1730 -0
  55. package/mcp/dist/link-checksums.js +62 -0
  56. package/mcp/dist/link-context.js +257 -0
  57. package/mcp/dist/link-doctor.js +591 -0
  58. package/mcp/dist/link-skills.js +212 -0
  59. package/mcp/dist/link.js +596 -0
  60. package/mcp/dist/logger.js +15 -0
  61. package/mcp/dist/machine-identity.js +38 -0
  62. package/mcp/dist/mcp-config.js +254 -0
  63. package/mcp/dist/mcp-data.js +315 -0
  64. package/mcp/dist/mcp-extract-facts.js +78 -0
  65. package/mcp/dist/mcp-extract.js +133 -0
  66. package/mcp/dist/mcp-finding.js +557 -0
  67. package/mcp/dist/mcp-graph.js +339 -0
  68. package/mcp/dist/mcp-hooks.js +256 -0
  69. package/mcp/dist/mcp-memory.js +58 -0
  70. package/mcp/dist/mcp-ops.js +328 -0
  71. package/mcp/dist/mcp-search.js +628 -0
  72. package/mcp/dist/mcp-session.js +651 -0
  73. package/mcp/dist/mcp-skills.js +189 -0
  74. package/mcp/dist/mcp-tasks.js +551 -0
  75. package/mcp/dist/mcp-types.js +7 -0
  76. package/mcp/dist/memory-ui-assets.js +6 -0
  77. package/mcp/dist/memory-ui-data.js +513 -0
  78. package/mcp/dist/memory-ui-graph.js +1910 -0
  79. package/mcp/dist/memory-ui-page.js +353 -0
  80. package/mcp/dist/memory-ui-scripts.js +1387 -0
  81. package/mcp/dist/memory-ui-server.js +1218 -0
  82. package/mcp/dist/memory-ui-styles.js +555 -0
  83. package/mcp/dist/memory-ui.js +9 -0
  84. package/mcp/dist/package-metadata.js +13 -0
  85. package/mcp/dist/phren-art.js +52 -0
  86. package/mcp/dist/phren-core.js +108 -0
  87. package/mcp/dist/phren-dotenv.js +67 -0
  88. package/mcp/dist/phren-paths.js +476 -0
  89. package/mcp/dist/proactivity.js +172 -0
  90. package/mcp/dist/profile-store.js +228 -0
  91. package/mcp/dist/project-config.js +85 -0
  92. package/mcp/dist/project-locator.js +25 -0
  93. package/mcp/dist/project-topics.js +1134 -0
  94. package/mcp/dist/provider-adapters.js +176 -0
  95. package/mcp/dist/runtime-profile.js +18 -0
  96. package/mcp/dist/session-checkpoints.js +131 -0
  97. package/mcp/dist/session-utils.js +68 -0
  98. package/mcp/dist/shared-content.js +8 -0
  99. package/mcp/dist/shared-embedding-cache.js +143 -0
  100. package/mcp/dist/shared-fragment-graph.js +456 -0
  101. package/mcp/dist/shared-governance.js +4 -0
  102. package/mcp/dist/shared-index.js +1334 -0
  103. package/mcp/dist/shared-ollama.js +192 -0
  104. package/mcp/dist/shared-paths.js +1 -0
  105. package/mcp/dist/shared-retrieval.js +796 -0
  106. package/mcp/dist/shared-search-fallback.js +375 -0
  107. package/mcp/dist/shared-sqljs.js +42 -0
  108. package/mcp/dist/shared-stemmer.js +171 -0
  109. package/mcp/dist/shared-vector-index.js +199 -0
  110. package/mcp/dist/shared.js +114 -0
  111. package/mcp/dist/shell-entry.js +209 -0
  112. package/mcp/dist/shell-input.js +943 -0
  113. package/mcp/dist/shell-palette.js +119 -0
  114. package/mcp/dist/shell-render.js +252 -0
  115. package/mcp/dist/shell-state-store.js +81 -0
  116. package/mcp/dist/shell-types.js +13 -0
  117. package/mcp/dist/shell-view-list.js +14 -0
  118. package/mcp/dist/shell-view.js +707 -0
  119. package/mcp/dist/shell.js +352 -0
  120. package/mcp/dist/skill-files.js +117 -0
  121. package/mcp/dist/skill-registry.js +279 -0
  122. package/mcp/dist/skill-state.js +28 -0
  123. package/mcp/dist/startup-embedding.js +57 -0
  124. package/mcp/dist/status.js +323 -0
  125. package/mcp/dist/synonyms.json +670 -0
  126. package/mcp/dist/task-hygiene.js +251 -0
  127. package/mcp/dist/task-lifecycle.js +347 -0
  128. package/mcp/dist/tasks-github.js +76 -0
  129. package/mcp/dist/telemetry.js +165 -0
  130. package/mcp/dist/test-global-setup.js +37 -0
  131. package/mcp/dist/tool-registry.js +104 -0
  132. package/mcp/dist/update.js +97 -0
  133. package/mcp/dist/utils.js +543 -0
  134. package/package.json +67 -0
  135. package/skills/README.md +7 -0
  136. package/skills/consolidate/SKILL.md +152 -0
  137. package/skills/discover/SKILL.md +175 -0
  138. package/skills/init/SKILL.md +216 -0
  139. package/skills/profiles/SKILL.md +121 -0
  140. package/skills/sync/SKILL.md +261 -0
  141. package/starter/README.md +74 -0
  142. package/starter/global/CLAUDE.md +89 -0
  143. package/starter/global/skills/humanize.md +30 -0
  144. package/starter/global/skills/pipeline.md +35 -0
  145. package/starter/global/skills/release.md +35 -0
  146. package/starter/machines.yaml +8 -0
  147. package/starter/my-api/.claude/skills/README.md +7 -0
  148. package/starter/my-api/CLAUDE.md +33 -0
  149. package/starter/my-api/FINDINGS.md +9 -0
  150. package/starter/my-api/summary.md +7 -0
  151. package/starter/my-api/tasks.md +7 -0
  152. package/starter/my-first-project/.claude/skills/README.md +7 -0
  153. package/starter/my-first-project/CLAUDE.md +49 -0
  154. package/starter/my-first-project/FINDINGS.md +24 -0
  155. package/starter/my-first-project/summary.md +11 -0
  156. package/starter/my-first-project/tasks.md +25 -0
  157. package/starter/my-frontend/.claude/skills/README.md +7 -0
  158. package/starter/my-frontend/CLAUDE.md +33 -0
  159. package/starter/my-frontend/FINDINGS.md +9 -0
  160. package/starter/my-frontend/summary.md +7 -0
  161. package/starter/my-frontend/tasks.md +7 -0
  162. package/starter/profiles/default.yaml +4 -0
  163. package/starter/profiles/personal.yaml +4 -0
  164. package/starter/profiles/work.yaml +4 -0
  165. package/starter/templates/README.md +7 -0
  166. package/starter/templates/frontend/CLAUDE.md +23 -0
  167. package/starter/templates/frontend/FINDINGS.md +7 -0
  168. package/starter/templates/frontend/reference/README.md +4 -0
  169. package/starter/templates/frontend/summary.md +7 -0
  170. package/starter/templates/frontend/tasks.md +11 -0
  171. package/starter/templates/library/CLAUDE.md +22 -0
  172. package/starter/templates/library/FINDINGS.md +7 -0
  173. package/starter/templates/library/reference/README.md +4 -0
  174. package/starter/templates/library/summary.md +7 -0
  175. package/starter/templates/library/tasks.md +11 -0
  176. package/starter/templates/monorepo/CLAUDE.md +21 -0
  177. package/starter/templates/monorepo/FINDINGS.md +7 -0
  178. package/starter/templates/monorepo/reference/README.md +4 -0
  179. package/starter/templates/monorepo/summary.md +7 -0
  180. package/starter/templates/monorepo/tasks.md +11 -0
  181. package/starter/templates/python-project/CLAUDE.md +21 -0
  182. package/starter/templates/python-project/FINDINGS.md +7 -0
  183. package/starter/templates/python-project/reference/README.md +4 -0
  184. package/starter/templates/python-project/summary.md +7 -0
  185. package/starter/templates/python-project/tasks.md +10 -0
@@ -0,0 +1,1218 @@
1
+ import * as http from "http";
2
+ import * as crypto from "crypto";
3
+ import { timingSafeEqual } from "crypto";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as querystring from "querystring";
7
+ import { spawn, execFileSync } from "child_process";
8
+ import { PhrenError, computePhrenLiveStateToken, getProjectDirs, } from "./shared.js";
9
+ import { editFinding, readReviewQueue, removeFinding, readFindings, addFinding as addFindingStore, readTasksAcrossProjects, addTask as addTaskStore, completeTask as completeTaskStore, removeTask as removeTaskStore, TASKS_FILENAME, } from "./data-access.js";
10
+ import { isValidProjectName, errorMessage } from "./utils.js";
11
+ import { readInstallPreferences, writeInstallPreferences, writeGovernanceInstallPreferences } from "./init-preferences.js";
12
+ import { buildGraph, collectProjectsForUI, collectSkillsForUI, getHooksData, isAllowedFilePath, readSyncSnapshot, recentAccepted, recentUsage, } from "./memory-ui-data.js";
13
+ import { CONSOLIDATION_ENTRY_THRESHOLD } from "./content-validate.js";
14
+ import { ensureTopicReferenceDoc, getProjectTopicsResponse, listProjectReferenceDocs, pinProjectTopicSuggestion, readReferenceContent, reclassifyLegacyTopicDocs, unpinProjectTopicSuggestion, writeProjectTopics, } from "./project-topics.js";
15
+ import { getWorkflowPolicy, updateWorkflowPolicy } from "./governance-policy.js";
16
+ import { findSkill } from "./skill-registry.js";
17
+ import { setSkillEnabledAndSync } from "./skill-files.js";
18
+ import { listAllSessions, getSessionArtifacts } from "./mcp-session.js";
19
+ import { repairPreexistingInstall } from "./init-setup.js";
20
+ const CSRF_TOKEN_TTL_MS = 15 * 60 * 1000;
21
+ const MAX_FORM_BODY_BYTES = 1_048_576;
22
+ const WEB_UI_READY_ATTEMPTS = 12;
23
+ const WEB_UI_READY_DELAY_MS = 75;
24
+ const WEB_UI_PORT_RETRY_ATTEMPTS = 3;
25
+ export function getWebUiBrowserCommand(url, platform = process.platform) {
26
+ if (platform === "darwin")
27
+ return { command: "open", args: [url] };
28
+ if (platform === "win32")
29
+ return { command: process.env.ComSpec || "cmd.exe", args: ["/c", "start", "", url] };
30
+ return { command: "xdg-open", args: [url] };
31
+ }
32
+ export async function launchWebUiBrowser(url) {
33
+ const { command, args } = getWebUiBrowserCommand(url);
34
+ await new Promise((resolve, reject) => {
35
+ try {
36
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
37
+ child.once("error", reject);
38
+ child.once("spawn", () => {
39
+ child.removeListener("error", reject);
40
+ child.unref();
41
+ resolve();
42
+ });
43
+ }
44
+ catch (err) {
45
+ reject(err);
46
+ }
47
+ });
48
+ }
49
+ export async function waitForWebUiReady(url, attempts = WEB_UI_READY_ATTEMPTS, delayMs = WEB_UI_READY_DELAY_MS) {
50
+ for (let attempt = 0; attempt < attempts; attempt++) {
51
+ const ready = await new Promise((resolve) => {
52
+ const req = http.get(url, (res) => {
53
+ res.resume();
54
+ resolve(true);
55
+ });
56
+ req.setTimeout(1000, () => {
57
+ req.destroy();
58
+ resolve(false);
59
+ });
60
+ req.on("error", () => resolve(false));
61
+ });
62
+ if (ready)
63
+ return true;
64
+ if (attempt < attempts - 1) {
65
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+ function isAddressInUse(err) {
71
+ return Boolean(err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE");
72
+ }
73
+ async function listenOnLoopback(server, port) {
74
+ return await new Promise((resolve, reject) => {
75
+ const onError = (err) => {
76
+ server.off("listening", onListening);
77
+ reject(err);
78
+ };
79
+ const onListening = () => {
80
+ server.off("error", onError);
81
+ const address = server.address();
82
+ if (!address || typeof address === "string") {
83
+ reject(new Error("failed to determine web-ui port"));
84
+ return;
85
+ }
86
+ resolve(address.port);
87
+ };
88
+ server.once("error", onError);
89
+ server.once("listening", onListening);
90
+ server.listen(port, "127.0.0.1");
91
+ });
92
+ }
93
+ async function bindWebUiPort(server, requestedPort, allowPortFallback) {
94
+ const candidates = [requestedPort];
95
+ if (allowPortFallback && requestedPort > 0) {
96
+ for (let i = 1; i <= WEB_UI_PORT_RETRY_ATTEMPTS; i++) {
97
+ candidates.push(requestedPort + i);
98
+ }
99
+ }
100
+ let lastError = null;
101
+ for (let i = 0; i < candidates.length; i++) {
102
+ const candidate = candidates[i];
103
+ try {
104
+ if (candidate !== requestedPort) {
105
+ process.stderr.write(`[phren] web-ui port ${candidate - 1} is busy, retrying on ${candidate}\n`);
106
+ }
107
+ return await listenOnLoopback(server, candidate);
108
+ }
109
+ catch (err) {
110
+ lastError = err;
111
+ if (!allowPortFallback || !isAddressInUse(err) || i === candidates.length - 1)
112
+ break;
113
+ }
114
+ }
115
+ throw lastError instanceof Error ? lastError : new Error("failed to bind web-ui server");
116
+ }
117
+ function pruneExpiredCsrfTokens(csrfTokens) {
118
+ if (!csrfTokens)
119
+ return;
120
+ const now = Date.now();
121
+ for (const [token, createdAt] of csrfTokens) {
122
+ if (now - createdAt > CSRF_TOKEN_TTL_MS)
123
+ csrfTokens.delete(token);
124
+ }
125
+ }
126
+ function setCommonHeaders(res, nonce) {
127
+ res.setHeader("Referrer-Policy", "no-referrer");
128
+ if (nonce) {
129
+ // Page responses: allow nonce-gated inline scripts but disallow inline event handlers
130
+ res.setHeader("Content-Security-Policy", `default-src 'self'; script-src 'self' 'nonce-${nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.bunny.net; font-src https://fonts.bunny.net; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'`);
131
+ }
132
+ else {
133
+ // API responses: no inline scripts needed
134
+ res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'");
135
+ }
136
+ res.setHeader("X-Content-Type-Options", "nosniff");
137
+ res.setHeader("X-Frame-Options", "DENY");
138
+ }
139
+ function getSubmittedAuthToken(req, url, parsedBody) {
140
+ const authHeader = req.headers.authorization;
141
+ if (typeof authHeader === "string") {
142
+ const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
143
+ if (bearerMatch)
144
+ return bearerMatch[1];
145
+ }
146
+ const query = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
147
+ const queryAuth = query._auth;
148
+ if (typeof queryAuth === "string")
149
+ return queryAuth;
150
+ const bodyAuth = parsedBody?._auth;
151
+ if (typeof bodyAuth === "string")
152
+ return bodyAuth;
153
+ return "";
154
+ }
155
+ function authTokensMatch(submitted, authToken) {
156
+ if (!authToken || !submitted)
157
+ return false;
158
+ const submittedBuffer = Buffer.from(submitted);
159
+ const authTokenBuffer = Buffer.from(authToken);
160
+ if (submittedBuffer.length !== authTokenBuffer.length)
161
+ return false;
162
+ return timingSafeEqual(submittedBuffer, authTokenBuffer);
163
+ }
164
+ function rejectUnauthorized(res, json = false) {
165
+ if (json) {
166
+ res.writeHead(401, { "content-type": "application/json" });
167
+ res.end(JSON.stringify({ ok: false, error: "Unauthorized" }));
168
+ return;
169
+ }
170
+ res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
171
+ res.end("Unauthorized");
172
+ }
173
+ function requireGetAuth(req, res, url, authToken, json = false) {
174
+ if (!authToken)
175
+ return true;
176
+ const submitted = getSubmittedAuthToken(req, url);
177
+ if (authTokensMatch(submitted, authToken))
178
+ return true;
179
+ rejectUnauthorized(res, json);
180
+ return false;
181
+ }
182
+ function readFormBody(req, res) {
183
+ const contentLength = parseInt(req.headers["content-length"] || "0", 10);
184
+ if (contentLength > MAX_FORM_BODY_BYTES) {
185
+ res.writeHead(413, { "content-type": "text/plain" });
186
+ res.end("Request body too large");
187
+ return Promise.resolve(null);
188
+ }
189
+ return new Promise((resolve) => {
190
+ let body = "";
191
+ let received = 0;
192
+ req.on("data", (chunk) => {
193
+ received += chunk.length;
194
+ if (received > MAX_FORM_BODY_BYTES) {
195
+ req.destroy();
196
+ resolve(null);
197
+ return;
198
+ }
199
+ body += String(chunk);
200
+ });
201
+ req.on("end", () => resolve(querystring.parse(body)));
202
+ req.on("error", () => resolve(null));
203
+ req.on("close", () => {
204
+ if (received > MAX_FORM_BODY_BYTES)
205
+ resolve(null);
206
+ });
207
+ });
208
+ }
209
+ function requirePostAuth(req, res, url, parsed, authToken, json = false) {
210
+ if (!authToken)
211
+ return true;
212
+ const submitted = getSubmittedAuthToken(req, url, parsed);
213
+ if (authTokensMatch(submitted, authToken))
214
+ return true;
215
+ rejectUnauthorized(res, json);
216
+ return false;
217
+ }
218
+ function requireCsrf(res, parsed, csrfTokens, json = false) {
219
+ if (!csrfTokens)
220
+ return true;
221
+ pruneExpiredCsrfTokens(csrfTokens);
222
+ const submitted = String(parsed._csrf || "");
223
+ if (submitted && csrfTokens.delete(submitted))
224
+ return true;
225
+ if (json) {
226
+ res.writeHead(403, { "content-type": "application/json" });
227
+ res.end(JSON.stringify({ ok: false, error: "Invalid or missing CSRF token" }));
228
+ return false;
229
+ }
230
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
231
+ res.end("Invalid or missing CSRF token");
232
+ return false;
233
+ }
234
+ function readProjectQueue(phrenPath, profile) {
235
+ const projects = getProjectDirs(phrenPath, profile).map((projectDir) => path.basename(projectDir)).filter((project) => project !== "global");
236
+ const items = [];
237
+ for (const project of projects) {
238
+ const queueResult = readReviewQueue(phrenPath, project);
239
+ const queueItems = queueResult.ok ? queueResult.data : [];
240
+ for (const item of queueItems) {
241
+ items.push({
242
+ project,
243
+ section: item.section,
244
+ line: item.line,
245
+ text: item.text,
246
+ date: item.date,
247
+ machine: item.machine || undefined,
248
+ model: item.model || undefined,
249
+ });
250
+ }
251
+ }
252
+ return items;
253
+ }
254
+ function runQueueAction(_phrenPath, pathname, _project, _line, _newText) {
255
+ if (pathname === "/api/approve" || pathname === "/approve")
256
+ return { ok: false, error: "Queue approval has been removed. Use the review queue as a read-only reference." };
257
+ if (pathname === "/api/reject" || pathname === "/reject")
258
+ return { ok: false, error: "Queue rejection has been removed. Use the review queue as a read-only reference." };
259
+ if (pathname === "/api/edit" || pathname === "/edit")
260
+ return { ok: false, error: "Queue editing has been removed. Use the review queue as a read-only reference." };
261
+ return { ok: false, error: "unknown action" };
262
+ }
263
+ function handleLegacyQueueActionResult(res, result) {
264
+ if (result.ok) {
265
+ res.writeHead(302, { location: "/" });
266
+ res.end();
267
+ return;
268
+ }
269
+ const code = result.code;
270
+ if (code === PhrenError.PERMISSION_DENIED || result.error.includes("requires maintainer/admin role")) {
271
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
272
+ res.end(result.error);
273
+ return;
274
+ }
275
+ if (code === PhrenError.NOT_FOUND) {
276
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
277
+ res.end(result.error);
278
+ return;
279
+ }
280
+ if (code === PhrenError.INVALID_PROJECT_NAME || code === PhrenError.EMPTY_INPUT) {
281
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
282
+ res.end(result.error);
283
+ return;
284
+ }
285
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
286
+ res.end(result.error);
287
+ }
288
+ function parseTopicsPayload(raw) {
289
+ try {
290
+ const parsed = JSON.parse(raw);
291
+ if (!Array.isArray(parsed))
292
+ return null;
293
+ return parsed.map((topic) => ({
294
+ slug: String(topic?.slug || ""),
295
+ label: String(topic?.label || ""),
296
+ description: String(topic?.description || ""),
297
+ keywords: Array.isArray(topic?.keywords) ? topic.keywords.map((keyword) => String(keyword)) : [],
298
+ }));
299
+ }
300
+ catch {
301
+ return null;
302
+ }
303
+ }
304
+ function parseTopicPayload(raw) {
305
+ try {
306
+ const topic = JSON.parse(raw);
307
+ if (!topic || typeof topic !== "object")
308
+ return null;
309
+ return {
310
+ slug: String(topic.slug || ""),
311
+ label: String(topic.label || ""),
312
+ description: String(topic.description || ""),
313
+ keywords: Array.isArray(topic.keywords)
314
+ ? topic.keywords.map((keyword) => String(keyword))
315
+ : [],
316
+ };
317
+ }
318
+ catch {
319
+ return null;
320
+ }
321
+ }
322
+ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
323
+ try {
324
+ repairPreexistingInstall(phrenPath);
325
+ }
326
+ catch (err) {
327
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
328
+ process.stderr.write(`[phren] web-ui repair: ${errorMessage(err)}\n`);
329
+ }
330
+ const authToken = opts?.authToken;
331
+ const csrfTokens = opts?.csrfTokens;
332
+ return http.createServer(async (req, res) => {
333
+ const url = req.url || "/";
334
+ const pathname = url.includes("?") ? url.slice(0, url.indexOf("?")) : url;
335
+ if (req.method === "GET" && pathname === "/") {
336
+ if (!requireGetAuth(req, res, url, authToken))
337
+ return;
338
+ pruneExpiredCsrfTokens(csrfTokens);
339
+ if (csrfTokens)
340
+ csrfTokens.set(crypto.randomUUID(), Date.now());
341
+ const nonce = crypto.randomBytes(16).toString("base64");
342
+ setCommonHeaders(res, nonce);
343
+ const html = renderPage(phrenPath, authToken, nonce);
344
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
345
+ res.end(html);
346
+ return;
347
+ }
348
+ setCommonHeaders(res);
349
+ if (pathname.startsWith("/api/") && req.method === "GET" && !requireGetAuth(req, res, url, authToken)) {
350
+ return;
351
+ }
352
+ if (req.method === "GET" && pathname === "/api/projects") {
353
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
354
+ res.end(JSON.stringify(collectProjectsForUI(phrenPath, profile)));
355
+ return;
356
+ }
357
+ if (req.method === "GET" && pathname === "/api/change-token") {
358
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
359
+ res.end(JSON.stringify({ token: computePhrenLiveStateToken(phrenPath) }));
360
+ return;
361
+ }
362
+ if (req.method === "GET" && pathname === "/api/runtime-health") {
363
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
364
+ res.end(JSON.stringify(readSyncSnapshot(phrenPath)));
365
+ return;
366
+ }
367
+ if (req.method === "POST" && pathname === "/api/sync") {
368
+ void readFormBody(req, res).then((parsed) => {
369
+ if (!parsed)
370
+ return;
371
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
372
+ return;
373
+ if (!requireCsrf(res, parsed, csrfTokens, true))
374
+ return;
375
+ const message = String(parsed.message || "update phren");
376
+ try {
377
+ const EXEC_TIMEOUT = 15_000;
378
+ const runGit = (args) => execFileSync("git", args, { cwd: phrenPath, encoding: "utf8", timeout: EXEC_TIMEOUT }).trim();
379
+ const status = runGit(["status", "--porcelain"]);
380
+ if (!status) {
381
+ res.writeHead(200, { "content-type": "application/json" });
382
+ res.end(JSON.stringify({ ok: true, message: "Nothing to sync — working tree clean." }));
383
+ return;
384
+ }
385
+ runGit(["add", "--", "*.md", "*.json", "*.yaml", "*.yml", "*.jsonl", "*.txt"]);
386
+ runGit(["commit", "-m", message]);
387
+ let pushed = false;
388
+ try {
389
+ const remotes = runGit(["remote"]);
390
+ if (remotes) {
391
+ runGit(["push"]);
392
+ pushed = true;
393
+ }
394
+ }
395
+ catch { /* no remote or push failed */ }
396
+ const changedFiles = status.split("\n").filter(Boolean).length;
397
+ res.writeHead(200, { "content-type": "application/json" });
398
+ res.end(JSON.stringify({ ok: true, message: `Synced ${changedFiles} file(s).${pushed ? " Pushed to remote." : " No remote, saved locally."}` }));
399
+ }
400
+ catch (err) {
401
+ res.writeHead(200, { "content-type": "application/json" });
402
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
403
+ }
404
+ });
405
+ return;
406
+ }
407
+ if (req.method === "GET" && pathname === "/api/review-queue") {
408
+ if (!requireGetAuth(req, res, url, authToken, true))
409
+ return;
410
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
411
+ res.end(JSON.stringify(readProjectQueue(phrenPath, profile)));
412
+ return;
413
+ }
414
+ if (req.method === "GET" && pathname === "/api/review-activity") {
415
+ if (!requireGetAuth(req, res, url, authToken, true))
416
+ return;
417
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
418
+ res.end(JSON.stringify({
419
+ accepted: recentAccepted(phrenPath),
420
+ usage: recentUsage(phrenPath),
421
+ }));
422
+ return;
423
+ }
424
+ if (req.method === "GET" && pathname.startsWith("/api/project-content")) {
425
+ const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
426
+ const project = String(qs.project || "");
427
+ const file = String(qs.file || "");
428
+ if (!project || !isValidProjectName(project) || !file) {
429
+ res.writeHead(400, { "content-type": "application/json" });
430
+ res.end(JSON.stringify({ ok: false, error: "Invalid project or file" }));
431
+ return;
432
+ }
433
+ const allowedFiles = ["FINDINGS.md", TASKS_FILENAME, "CLAUDE.md", "summary.md"];
434
+ if (!allowedFiles.includes(file)) {
435
+ res.writeHead(400, { "content-type": "application/json" });
436
+ res.end(JSON.stringify({ ok: false, error: `File not allowed: ${file}` }));
437
+ return;
438
+ }
439
+ const filePath = path.join(phrenPath, project, file);
440
+ if (!fs.existsSync(filePath)) {
441
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
442
+ res.end(JSON.stringify({ ok: false, error: `File not found: ${file}` }));
443
+ return;
444
+ }
445
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
446
+ res.end(JSON.stringify({ ok: true, content: fs.readFileSync(filePath, "utf8") }));
447
+ return;
448
+ }
449
+ if (req.method === "GET" && pathname === "/api/project-topics") {
450
+ const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
451
+ const project = String(qs.project || "");
452
+ if (!project || !isValidProjectName(project)) {
453
+ res.writeHead(400, { "content-type": "application/json" });
454
+ res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
455
+ return;
456
+ }
457
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
458
+ res.end(JSON.stringify({ ok: true, ...getProjectTopicsResponse(phrenPath, project) }));
459
+ return;
460
+ }
461
+ if (req.method === "GET" && pathname === "/api/project-reference-list") {
462
+ const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
463
+ const project = String(qs.project || "");
464
+ if (!project || !isValidProjectName(project)) {
465
+ res.writeHead(400, { "content-type": "application/json" });
466
+ res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
467
+ return;
468
+ }
469
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
470
+ res.end(JSON.stringify({ ok: true, ...listProjectReferenceDocs(phrenPath, project) }));
471
+ return;
472
+ }
473
+ if (req.method === "GET" && pathname === "/api/project-reference-content") {
474
+ const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
475
+ const project = String(qs.project || "");
476
+ const file = String(qs.file || "");
477
+ const contentResult = readReferenceContent(phrenPath, project, file);
478
+ res.writeHead(contentResult.ok ? 200 : 400, { "content-type": "application/json; charset=utf-8" });
479
+ res.end(JSON.stringify(contentResult.ok ? { ok: true, content: contentResult.content } : { ok: false, error: contentResult.error }));
480
+ return;
481
+ }
482
+ if (req.method === "GET" && pathname === "/api/skills") {
483
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
484
+ res.end(JSON.stringify(collectSkillsForUI(phrenPath, profile)));
485
+ return;
486
+ }
487
+ if (req.method === "GET" && pathname.startsWith("/api/skill-content")) {
488
+ const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
489
+ const filePath = String(qs.path || "");
490
+ if (!filePath || !isAllowedFilePath(filePath, phrenPath)) {
491
+ res.writeHead(400, { "content-type": "application/json" });
492
+ res.end(JSON.stringify({ ok: false, error: "Invalid path" }));
493
+ return;
494
+ }
495
+ if (!fs.existsSync(filePath)) {
496
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
497
+ res.end(JSON.stringify({ ok: false, error: "File not found" }));
498
+ return;
499
+ }
500
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
501
+ res.end(JSON.stringify({ ok: true, content: fs.readFileSync(filePath, "utf8") }));
502
+ return;
503
+ }
504
+ if (req.method === "GET" && pathname === "/api/hooks") {
505
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
506
+ res.end(JSON.stringify(getHooksData(phrenPath)));
507
+ return;
508
+ }
509
+ if (req.method === "POST" && pathname === "/api/skill-save") {
510
+ void readFormBody(req, res).then((parsed) => {
511
+ if (!parsed)
512
+ return;
513
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
514
+ return;
515
+ if (!requireCsrf(res, parsed, csrfTokens, true))
516
+ return;
517
+ const filePath = String(parsed.path || "");
518
+ const content = String(parsed.content || "");
519
+ if (!filePath || !isAllowedFilePath(filePath, phrenPath)) {
520
+ res.writeHead(200, { "content-type": "application/json" });
521
+ res.end(JSON.stringify({ ok: false, error: "Invalid path" }));
522
+ return;
523
+ }
524
+ try {
525
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
526
+ const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
527
+ fs.writeFileSync(tmpPath, content);
528
+ fs.renameSync(tmpPath, filePath);
529
+ res.writeHead(200, { "content-type": "application/json" });
530
+ res.end(JSON.stringify({ ok: true }));
531
+ }
532
+ catch (err) {
533
+ res.writeHead(200, { "content-type": "application/json" });
534
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
535
+ }
536
+ });
537
+ return;
538
+ }
539
+ if (req.method === "POST" && pathname === "/api/skill-toggle") {
540
+ void readFormBody(req, res).then((parsed) => {
541
+ if (!parsed)
542
+ return;
543
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
544
+ return;
545
+ if (!requireCsrf(res, parsed, csrfTokens, true))
546
+ return;
547
+ const project = String(parsed.project || "");
548
+ const name = String(parsed.name || "");
549
+ const enabled = String(parsed.enabled || "") === "true";
550
+ if (!project || !name || (project.toLowerCase() !== "global" && !isValidProjectName(project))) {
551
+ res.writeHead(200, { "content-type": "application/json" });
552
+ res.end(JSON.stringify({ ok: false, error: "Invalid skill toggle request" }));
553
+ return;
554
+ }
555
+ const skill = findSkill(phrenPath, profile || "", project, name);
556
+ if (!skill || "error" in skill) {
557
+ res.writeHead(200, { "content-type": "application/json" });
558
+ res.end(JSON.stringify({ ok: false, error: skill && "error" in skill ? skill.error : "Skill not found" }));
559
+ return;
560
+ }
561
+ setSkillEnabledAndSync(phrenPath, project, skill.name, enabled);
562
+ res.writeHead(200, { "content-type": "application/json" });
563
+ res.end(JSON.stringify({ ok: true, enabled }));
564
+ });
565
+ return;
566
+ }
567
+ if (req.method === "POST" && pathname === "/api/hook-toggle") {
568
+ void readFormBody(req, res).then((parsed) => {
569
+ if (!parsed)
570
+ return;
571
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
572
+ return;
573
+ if (!requireCsrf(res, parsed, csrfTokens, true))
574
+ return;
575
+ const tool = String(parsed.tool || "").toLowerCase();
576
+ const validTools = ["claude", "copilot", "cursor", "codex"];
577
+ if (!validTools.includes(tool)) {
578
+ res.writeHead(200, { "content-type": "application/json" });
579
+ res.end(JSON.stringify({ ok: false, error: "Invalid tool" }));
580
+ return;
581
+ }
582
+ const prefs = readInstallPreferences(phrenPath);
583
+ const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
584
+ const current = toolPrefs[tool] !== false && prefs.hooksEnabled !== false;
585
+ writeInstallPreferences(phrenPath, {
586
+ hookTools: { ...toolPrefs, [tool]: !current },
587
+ });
588
+ res.writeHead(200, { "content-type": "application/json" });
589
+ res.end(JSON.stringify({ ok: true, enabled: !current }));
590
+ });
591
+ return;
592
+ }
593
+ if (req.method === "POST" && pathname === "/api/project-topics/save") {
594
+ void readFormBody(req, res).then((parsed) => {
595
+ if (!parsed)
596
+ return;
597
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
598
+ return;
599
+ if (!requireCsrf(res, parsed, csrfTokens, true))
600
+ return;
601
+ const project = String(parsed.project || "");
602
+ const rawTopics = String(parsed.topics || "");
603
+ if (!project || !isValidProjectName(project)) {
604
+ res.writeHead(400, { "content-type": "application/json" });
605
+ res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
606
+ return;
607
+ }
608
+ const topics = parseTopicsPayload(rawTopics);
609
+ if (!topics) {
610
+ res.writeHead(400, { "content-type": "application/json" });
611
+ res.end(JSON.stringify({ ok: false, error: "Invalid topics payload" }));
612
+ return;
613
+ }
614
+ const saved = writeProjectTopics(phrenPath, project, topics);
615
+ if (!saved.ok) {
616
+ res.writeHead(200, { "content-type": "application/json" });
617
+ res.end(JSON.stringify(saved));
618
+ return;
619
+ }
620
+ for (const topic of saved.topics) {
621
+ const ensured = ensureTopicReferenceDoc(phrenPath, project, topic);
622
+ if (!ensured.ok) {
623
+ res.writeHead(200, { "content-type": "application/json" });
624
+ res.end(JSON.stringify({ ok: false, error: ensured.error }));
625
+ return;
626
+ }
627
+ }
628
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
629
+ res.end(JSON.stringify({ ok: true, ...getProjectTopicsResponse(phrenPath, project) }));
630
+ });
631
+ return;
632
+ }
633
+ if (req.method === "POST" && pathname === "/api/project-topics/reclassify") {
634
+ void readFormBody(req, res).then((parsed) => {
635
+ if (!parsed)
636
+ return;
637
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
638
+ return;
639
+ if (!requireCsrf(res, parsed, csrfTokens, true))
640
+ return;
641
+ const project = String(parsed.project || "");
642
+ if (!project || !isValidProjectName(project)) {
643
+ res.writeHead(400, { "content-type": "application/json" });
644
+ res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
645
+ return;
646
+ }
647
+ const result = reclassifyLegacyTopicDocs(phrenPath, project);
648
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
649
+ res.end(JSON.stringify({ ok: true, ...result }));
650
+ });
651
+ return;
652
+ }
653
+ if (req.method === "POST" && pathname === "/api/project-topics/pin") {
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 project = String(parsed.project || "");
662
+ const rawTopic = String(parsed.topic || "");
663
+ if (!project || !isValidProjectName(project)) {
664
+ res.writeHead(400, { "content-type": "application/json" });
665
+ res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
666
+ return;
667
+ }
668
+ const topic = parseTopicPayload(rawTopic);
669
+ if (!topic) {
670
+ res.writeHead(400, { "content-type": "application/json" });
671
+ res.end(JSON.stringify({ ok: false, error: "Invalid topic payload" }));
672
+ return;
673
+ }
674
+ const pinned = pinProjectTopicSuggestion(phrenPath, project, topic);
675
+ if (!pinned.ok) {
676
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
677
+ res.end(JSON.stringify(pinned));
678
+ return;
679
+ }
680
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
681
+ res.end(JSON.stringify({ ok: true, ...getProjectTopicsResponse(phrenPath, project) }));
682
+ });
683
+ return;
684
+ }
685
+ if (req.method === "POST" && pathname === "/api/project-topics/unpin") {
686
+ void readFormBody(req, res).then((parsed) => {
687
+ if (!parsed)
688
+ return;
689
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
690
+ return;
691
+ if (!requireCsrf(res, parsed, csrfTokens, true))
692
+ return;
693
+ const project = String(parsed.project || "");
694
+ const slug = String(parsed.slug || "");
695
+ if (!project || !isValidProjectName(project)) {
696
+ res.writeHead(400, { "content-type": "application/json" });
697
+ res.end(JSON.stringify({ ok: false, error: "Invalid project" }));
698
+ return;
699
+ }
700
+ const unpinned = unpinProjectTopicSuggestion(phrenPath, project, slug);
701
+ if (!unpinned.ok) {
702
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
703
+ res.end(JSON.stringify(unpinned));
704
+ return;
705
+ }
706
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
707
+ res.end(JSON.stringify({ ok: true, ...getProjectTopicsResponse(phrenPath, project) }));
708
+ });
709
+ return;
710
+ }
711
+ if (req.method === "GET" && pathname === "/api/search") {
712
+ if (!requireGetAuth(req, res, url, authToken, true))
713
+ return;
714
+ const searchParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
715
+ const query = searchParams.get("q") || searchParams.get("query") || "";
716
+ const searchProject = searchParams.get("project") || undefined;
717
+ const searchType = searchParams.get("type") || undefined;
718
+ const searchLimit = parseInt(searchParams.get("limit") || "10", 10) || 10;
719
+ if (!query.trim()) {
720
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
721
+ res.end(JSON.stringify({ ok: false, error: "Missing query parameter (q or query)." }));
722
+ return;
723
+ }
724
+ try {
725
+ const { runSearch } = await import("./cli-search.js");
726
+ const result = await runSearch({ query, limit: Math.min(searchLimit, 50), project: searchProject, type: searchType }, phrenPath, profile || "");
727
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
728
+ res.end(JSON.stringify({ ok: true, query, results: result.lines }));
729
+ }
730
+ catch (err) {
731
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
732
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
733
+ }
734
+ return;
735
+ }
736
+ if (req.method === "GET" && pathname.startsWith("/api/graph")) {
737
+ const graphParams = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
738
+ const focusProject = graphParams.get("project") || undefined;
739
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
740
+ res.end(JSON.stringify(await buildGraph(phrenPath, profile, focusProject)));
741
+ return;
742
+ }
743
+ if (req.method === "GET" && pathname === "/api/scores") {
744
+ let scores = {};
745
+ try {
746
+ const raw = fs.readFileSync(path.join(phrenPath, ".runtime", "memory-scores.json"), "utf-8");
747
+ const parsed = JSON.parse(raw);
748
+ if (parsed && typeof parsed === "object") {
749
+ scores = parsed;
750
+ }
751
+ }
752
+ catch {
753
+ // file missing or unparseable – return empty
754
+ }
755
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
756
+ res.end(JSON.stringify(scores));
757
+ return;
758
+ }
759
+ if (req.method === "GET" && pathname === "/api/tasks") {
760
+ if (!requireGetAuth(req, res, url, authToken, true))
761
+ return;
762
+ try {
763
+ const docs = readTasksAcrossProjects(phrenPath, profile);
764
+ const tasks = [];
765
+ for (const doc of docs) {
766
+ for (const section of ["Active", "Queue", "Done"]) {
767
+ for (const item of doc.items[section]) {
768
+ tasks.push({
769
+ project: doc.project,
770
+ section: item.section,
771
+ line: item.line,
772
+ priority: item.priority,
773
+ pinned: item.pinned,
774
+ githubIssue: item.githubIssue,
775
+ githubUrl: item.githubUrl,
776
+ context: item.context,
777
+ checked: item.checked,
778
+ });
779
+ }
780
+ }
781
+ }
782
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
783
+ res.end(JSON.stringify({ ok: true, tasks }));
784
+ }
785
+ catch (err) {
786
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
787
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err), tasks: [] }));
788
+ }
789
+ return;
790
+ }
791
+ if (req.method === "POST" && pathname === "/api/tasks/complete") {
792
+ void readFormBody(req, res).then((parsed) => {
793
+ if (!parsed)
794
+ return;
795
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
796
+ return;
797
+ if (!requireCsrf(res, parsed, csrfTokens, true))
798
+ return;
799
+ const project = String(parsed.project || "");
800
+ const item = String(parsed.item || "");
801
+ if (!project || !item || !isValidProjectName(project)) {
802
+ res.writeHead(400, { "content-type": "application/json" });
803
+ res.end(JSON.stringify({ ok: false, error: "Missing or invalid project/item" }));
804
+ return;
805
+ }
806
+ const result = completeTaskStore(phrenPath, project, item);
807
+ res.writeHead(200, { "content-type": "application/json" });
808
+ res.end(JSON.stringify({ ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error }));
809
+ });
810
+ return;
811
+ }
812
+ if (req.method === "POST" && pathname === "/api/tasks/add") {
813
+ void readFormBody(req, res).then((parsed) => {
814
+ if (!parsed)
815
+ return;
816
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
817
+ return;
818
+ if (!requireCsrf(res, parsed, csrfTokens, true))
819
+ return;
820
+ const project = String(parsed.project || "");
821
+ const item = String(parsed.item || "");
822
+ if (!project || !item || !isValidProjectName(project)) {
823
+ res.writeHead(400, { "content-type": "application/json" });
824
+ res.end(JSON.stringify({ ok: false, error: "Missing or invalid project/item" }));
825
+ return;
826
+ }
827
+ const result = addTaskStore(phrenPath, project, item);
828
+ res.writeHead(200, { "content-type": "application/json" });
829
+ res.end(JSON.stringify({ ok: result.ok, message: result.ok ? `Task added: ${result.data.line}` : undefined, error: result.ok ? undefined : result.error }));
830
+ });
831
+ return;
832
+ }
833
+ if (req.method === "POST" && pathname === "/api/tasks/remove") {
834
+ void readFormBody(req, res).then((parsed) => {
835
+ if (!parsed)
836
+ return;
837
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
838
+ return;
839
+ if (!requireCsrf(res, parsed, csrfTokens, true))
840
+ return;
841
+ const project = String(parsed.project || "");
842
+ const item = String(parsed.item || "");
843
+ if (!project || !item || !isValidProjectName(project)) {
844
+ res.writeHead(400, { "content-type": "application/json" });
845
+ res.end(JSON.stringify({ ok: false, error: "Missing or invalid project/item" }));
846
+ return;
847
+ }
848
+ const result = removeTaskStore(phrenPath, project, item);
849
+ res.writeHead(200, { "content-type": "application/json" });
850
+ res.end(JSON.stringify({ ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error }));
851
+ });
852
+ return;
853
+ }
854
+ if (req.method === "GET" && pathname === "/api/sessions") {
855
+ if (!requireGetAuth(req, res, url, authToken, true))
856
+ return;
857
+ try {
858
+ const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
859
+ const sessionId = typeof qs.sessionId === "string" ? qs.sessionId : undefined;
860
+ const project = typeof qs.project === "string" ? qs.project : undefined;
861
+ const limit = parseInt(typeof qs.limit === "string" ? qs.limit : "50", 10) || 50;
862
+ if (sessionId) {
863
+ const sessions = listAllSessions(phrenPath, 200);
864
+ const session = sessions.find(s => s.sessionId === sessionId || s.sessionId.startsWith(sessionId));
865
+ if (!session) {
866
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
867
+ res.end(JSON.stringify({ ok: false, error: "Session not found" }));
868
+ return;
869
+ }
870
+ const artifacts = getSessionArtifacts(phrenPath, session.sessionId, project);
871
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
872
+ res.end(JSON.stringify({ ok: true, session, ...artifacts }));
873
+ }
874
+ else {
875
+ const sessions = listAllSessions(phrenPath, limit);
876
+ const filtered = project ? sessions.filter(s => s.project === project) : sessions;
877
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
878
+ res.end(JSON.stringify({ ok: true, sessions: filtered }));
879
+ }
880
+ }
881
+ catch (err) {
882
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
883
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err), sessions: [] }));
884
+ }
885
+ return;
886
+ }
887
+ if (req.method === "GET" && pathname === "/api/settings") {
888
+ if (!requireGetAuth(req, res, url, authToken, true))
889
+ return;
890
+ try {
891
+ const prefs = readInstallPreferences(phrenPath);
892
+ const workflowPolicy = getWorkflowPolicy(phrenPath);
893
+ const hooksData = getHooksData(phrenPath);
894
+ const proactivityFindings = prefs.proactivityFindings || prefs.proactivity || "high";
895
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
896
+ res.end(JSON.stringify({
897
+ ok: true,
898
+ proactivity: prefs.proactivity || "high",
899
+ proactivityFindings,
900
+ proactivityTask: prefs.proactivityTask || prefs.proactivity || "high",
901
+ taskMode: workflowPolicy.taskMode,
902
+ findingSensitivity: workflowPolicy.findingSensitivity || "balanced",
903
+ autoCaptureEnabled: proactivityFindings !== "low",
904
+ consolidationEntryThreshold: CONSOLIDATION_ENTRY_THRESHOLD,
905
+ hooksEnabled: hooksData.globalEnabled,
906
+ mcpEnabled: prefs.mcpEnabled !== false,
907
+ hookTools: hooksData.tools,
908
+ }));
909
+ }
910
+ catch (err) {
911
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
912
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
913
+ }
914
+ return;
915
+ }
916
+ if (req.method === "POST" && pathname === "/api/settings/finding-sensitivity") {
917
+ void readFormBody(req, res).then((parsed) => {
918
+ if (!parsed)
919
+ return;
920
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
921
+ return;
922
+ if (!requireCsrf(res, parsed, csrfTokens, true))
923
+ return;
924
+ const value = String(parsed.value || "");
925
+ const valid = ["minimal", "conservative", "balanced", "aggressive"];
926
+ if (!valid.includes(value)) {
927
+ res.writeHead(200, { "content-type": "application/json" });
928
+ res.end(JSON.stringify({ ok: false, error: `Invalid finding sensitivity: "${value}". Must be one of: ${valid.join(", ")}` }));
929
+ return;
930
+ }
931
+ const result = updateWorkflowPolicy(phrenPath, { findingSensitivity: value });
932
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
933
+ res.end(JSON.stringify(result.ok ? { ok: true, findingSensitivity: result.data.findingSensitivity } : { ok: false, error: result.error }));
934
+ });
935
+ return;
936
+ }
937
+ if (req.method === "POST" && pathname === "/api/settings/task-mode") {
938
+ void readFormBody(req, res).then((parsed) => {
939
+ if (!parsed)
940
+ return;
941
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
942
+ return;
943
+ if (!requireCsrf(res, parsed, csrfTokens, true))
944
+ return;
945
+ const value = String(parsed.value || "").trim().toLowerCase();
946
+ const valid = ["off", "manual", "auto"];
947
+ if (!valid.includes(value)) {
948
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
949
+ res.end(JSON.stringify({ ok: false, error: `Invalid task mode: "${value}". Must be one of: ${valid.join(", ")}` }));
950
+ return;
951
+ }
952
+ const result = updateWorkflowPolicy(phrenPath, { taskMode: value });
953
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
954
+ res.end(JSON.stringify(result.ok ? { ok: true, taskMode: result.data.taskMode } : { ok: false, error: result.error }));
955
+ });
956
+ return;
957
+ }
958
+ if (req.method === "POST" && pathname === "/api/settings/proactivity") {
959
+ void readFormBody(req, res).then((parsed) => {
960
+ if (!parsed)
961
+ return;
962
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
963
+ return;
964
+ if (!requireCsrf(res, parsed, csrfTokens, true))
965
+ return;
966
+ const value = String(parsed.value || "").trim().toLowerCase();
967
+ const valid = ["high", "medium", "low"];
968
+ if (!valid.includes(value)) {
969
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
970
+ res.end(JSON.stringify({ ok: false, error: `Invalid proactivity: "${value}". Must be one of: ${valid.join(", ")}` }));
971
+ return;
972
+ }
973
+ writeInstallPreferences(phrenPath, { proactivity: value });
974
+ writeGovernanceInstallPreferences(phrenPath, { proactivity: value });
975
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
976
+ res.end(JSON.stringify({ ok: true, proactivity: value }));
977
+ });
978
+ return;
979
+ }
980
+ if (req.method === "POST" && pathname === "/api/settings/auto-capture") {
981
+ void readFormBody(req, res).then((parsed) => {
982
+ if (!parsed)
983
+ return;
984
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
985
+ return;
986
+ if (!requireCsrf(res, parsed, csrfTokens, true))
987
+ return;
988
+ const enabled = String(parsed.enabled || "").toLowerCase() === "true";
989
+ const next = enabled ? "high" : "low";
990
+ writeInstallPreferences(phrenPath, { proactivityFindings: next });
991
+ writeGovernanceInstallPreferences(phrenPath, { proactivityFindings: next });
992
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
993
+ res.end(JSON.stringify({ ok: true, autoCaptureEnabled: enabled, proactivityFindings: next }));
994
+ });
995
+ return;
996
+ }
997
+ if (req.method === "POST" && pathname === "/api/settings/mcp-enabled") {
998
+ void readFormBody(req, res).then((parsed) => {
999
+ if (!parsed)
1000
+ return;
1001
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
1002
+ return;
1003
+ if (!requireCsrf(res, parsed, csrfTokens, true))
1004
+ return;
1005
+ const enabled = String(parsed.enabled || "").toLowerCase() === "true";
1006
+ writeInstallPreferences(phrenPath, { mcpEnabled: enabled });
1007
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1008
+ res.end(JSON.stringify({ ok: true, mcpEnabled: enabled }));
1009
+ });
1010
+ return;
1011
+ }
1012
+ if (req.method === "GET" && pathname === "/api/csrf-token") {
1013
+ if (!requireGetAuth(req, res, url, authToken, true))
1014
+ return;
1015
+ if (!csrfTokens) {
1016
+ res.writeHead(200, { "content-type": "application/json" });
1017
+ res.end(JSON.stringify({ ok: true, token: null }));
1018
+ return;
1019
+ }
1020
+ pruneExpiredCsrfTokens(csrfTokens);
1021
+ const token = crypto.randomUUID();
1022
+ csrfTokens.set(token, Date.now());
1023
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1024
+ res.end(JSON.stringify({ ok: true, token }));
1025
+ return;
1026
+ }
1027
+ if (req.method === "POST" && ["/api/approve", "/api/reject", "/api/edit"].includes(pathname)) {
1028
+ void readFormBody(req, res).then((parsed) => {
1029
+ if (!parsed)
1030
+ return;
1031
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
1032
+ return;
1033
+ if (!requireCsrf(res, parsed, csrfTokens, true))
1034
+ return;
1035
+ const project = String(parsed.project || "");
1036
+ const line = String(parsed.line || "");
1037
+ const newText = String(parsed.new_text || "");
1038
+ if (!project || !line || !isValidProjectName(project)) {
1039
+ res.writeHead(400, { "content-type": "application/json" });
1040
+ res.end(JSON.stringify({ ok: false, error: "Missing or invalid project/line" }));
1041
+ return;
1042
+ }
1043
+ const result = runQueueAction(phrenPath, pathname, project, line, newText);
1044
+ res.writeHead(200, { "content-type": "application/json" });
1045
+ res.end(JSON.stringify({ ok: result.ok, error: result.ok ? undefined : result.error }));
1046
+ });
1047
+ return;
1048
+ }
1049
+ if (req.method === "POST" && ["/approve", "/reject", "/edit"].includes(pathname)) {
1050
+ void readFormBody(req, res).then((parsed) => {
1051
+ if (!parsed)
1052
+ return;
1053
+ if (!requirePostAuth(req, res, url, parsed, authToken))
1054
+ return;
1055
+ if (!requireCsrf(res, parsed, csrfTokens))
1056
+ return;
1057
+ const project = String(parsed.project || "");
1058
+ const line = String(parsed.line || "");
1059
+ const newText = String(parsed.new_text || "");
1060
+ if (!project || !line) {
1061
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
1062
+ res.end("Missing project/line");
1063
+ return;
1064
+ }
1065
+ if (!isValidProjectName(project)) {
1066
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
1067
+ res.end("Invalid project name");
1068
+ return;
1069
+ }
1070
+ handleLegacyQueueActionResult(res, runQueueAction(phrenPath, pathname, project, line, newText));
1071
+ });
1072
+ return;
1073
+ }
1074
+ // GET /api/findings/:project — list findings for a project
1075
+ if (req.method === "GET" && pathname.startsWith("/api/findings/")) {
1076
+ const project = decodeURIComponent(pathname.slice("/api/findings/".length));
1077
+ if (!project || !isValidProjectName(project)) {
1078
+ res.writeHead(400, { "content-type": "application/json" });
1079
+ res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
1080
+ return;
1081
+ }
1082
+ const result = readFindings(phrenPath, project);
1083
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1084
+ if (!result.ok) {
1085
+ res.end(JSON.stringify({ ok: false, error: result.error }));
1086
+ }
1087
+ else {
1088
+ res.end(JSON.stringify({ ok: true, data: { project, findings: result.data } }));
1089
+ }
1090
+ return;
1091
+ }
1092
+ // POST /api/findings/:project — add a finding
1093
+ if (req.method === "POST" && pathname.startsWith("/api/findings/")) {
1094
+ const project = decodeURIComponent(pathname.slice("/api/findings/".length));
1095
+ if (!project || !isValidProjectName(project)) {
1096
+ res.writeHead(400, { "content-type": "application/json" });
1097
+ res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
1098
+ return;
1099
+ }
1100
+ void readFormBody(req, res).then((parsed) => {
1101
+ if (!parsed)
1102
+ return;
1103
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
1104
+ return;
1105
+ if (!requireCsrf(res, parsed, csrfTokens, true))
1106
+ return;
1107
+ const text = String(parsed.text || "");
1108
+ if (!text) {
1109
+ res.writeHead(200, { "content-type": "application/json" });
1110
+ res.end(JSON.stringify({ ok: false, error: "text is required" }));
1111
+ return;
1112
+ }
1113
+ const result = addFindingStore(phrenPath, project, text);
1114
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1115
+ res.end(JSON.stringify({ ok: result.ok, message: result.ok ? result.data : undefined, error: result.ok ? undefined : result.error }));
1116
+ });
1117
+ return;
1118
+ }
1119
+ // PUT /api/findings/:project — edit a finding (old_text → new_text)
1120
+ if (req.method === "PUT" && pathname.startsWith("/api/findings/")) {
1121
+ const project = decodeURIComponent(pathname.slice("/api/findings/".length));
1122
+ if (!project || !isValidProjectName(project)) {
1123
+ res.writeHead(400, { "content-type": "application/json" });
1124
+ res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
1125
+ return;
1126
+ }
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 oldText = String(parsed.old_text || "");
1135
+ const newText = String(parsed.new_text || "");
1136
+ if (!oldText || !newText) {
1137
+ res.writeHead(200, { "content-type": "application/json" });
1138
+ res.end(JSON.stringify({ ok: false, error: "old_text and new_text are required" }));
1139
+ return;
1140
+ }
1141
+ const result = editFinding(phrenPath, project, oldText, newText);
1142
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1143
+ res.end(JSON.stringify({ ok: result.ok, error: result.ok ? undefined : result.error }));
1144
+ });
1145
+ return;
1146
+ }
1147
+ // DELETE /api/findings/:project — remove a finding by text match
1148
+ if (req.method === "DELETE" && pathname.startsWith("/api/findings/")) {
1149
+ const project = decodeURIComponent(pathname.slice("/api/findings/".length));
1150
+ if (!project || !isValidProjectName(project)) {
1151
+ res.writeHead(400, { "content-type": "application/json" });
1152
+ res.end(JSON.stringify({ ok: false, error: "Invalid project name" }));
1153
+ return;
1154
+ }
1155
+ void readFormBody(req, res).then((parsed) => {
1156
+ if (!parsed)
1157
+ return;
1158
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
1159
+ return;
1160
+ if (!requireCsrf(res, parsed, csrfTokens, true))
1161
+ return;
1162
+ const text = String(parsed.text || "");
1163
+ if (!text) {
1164
+ res.writeHead(200, { "content-type": "application/json" });
1165
+ res.end(JSON.stringify({ ok: false, error: "text is required" }));
1166
+ return;
1167
+ }
1168
+ const result = removeFinding(phrenPath, project, text);
1169
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1170
+ res.end(JSON.stringify({ ok: result.ok, error: result.ok ? undefined : result.error }));
1171
+ });
1172
+ return;
1173
+ }
1174
+ res.writeHead(404, { "content-type": "text/plain" });
1175
+ res.end("Not found");
1176
+ });
1177
+ }
1178
+ export async function startWebUiServer(phrenPath, port, renderPage, profile, opts = {}) {
1179
+ const authToken = crypto.randomUUID();
1180
+ const csrfTokens = new Map();
1181
+ const server = createWebUiHttpServer(phrenPath, renderPage, profile, { authToken, csrfTokens });
1182
+ const boundPort = await bindWebUiPort(server, port, Boolean(opts.allowPortFallback && port !== 0));
1183
+ const publicUrl = `http://127.0.0.1:${boundPort}`;
1184
+ const reviewUrl = `${publicUrl}/?_auth=${encodeURIComponent(authToken)}`;
1185
+ const ready = await waitForWebUiReady(reviewUrl);
1186
+ process.stdout.write(`phren web-ui running at ${publicUrl}\n`);
1187
+ process.stderr.write(`open: ${reviewUrl}\n`);
1188
+ if (!ready) {
1189
+ process.stderr.write("[phren] web-ui health check did not confirm readiness before launch\n");
1190
+ }
1191
+ const shouldAutoOpen = opts.autoOpen ?? Boolean(process.stdout.isTTY);
1192
+ if (shouldAutoOpen && ready) {
1193
+ try {
1194
+ if (opts.browserLauncher)
1195
+ await opts.browserLauncher(reviewUrl);
1196
+ else
1197
+ await launchWebUiBrowser(reviewUrl);
1198
+ }
1199
+ catch (err) {
1200
+ process.stderr.write(`[phren] web-ui browser launch failed: ${errorMessage(err)}\n`);
1201
+ process.stdout.write(`secure session URL: ${reviewUrl}\n`);
1202
+ }
1203
+ }
1204
+ else if (shouldAutoOpen && !ready) {
1205
+ process.stderr.write("[phren] skipped auto-open because readiness check failed; use the secure URL below\n");
1206
+ process.stdout.write(`secure session URL: ${reviewUrl}\n`);
1207
+ }
1208
+ else {
1209
+ process.stdout.write(`secure session URL: ${reviewUrl}\n`);
1210
+ }
1211
+ await new Promise((resolve) => {
1212
+ const shutdown = () => {
1213
+ server.close(() => resolve());
1214
+ };
1215
+ process.on("SIGTERM", shutdown);
1216
+ process.on("SIGINT", shutdown);
1217
+ });
1218
+ }