@phren/cli 0.0.14 → 0.0.16

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.
@@ -6,6 +6,7 @@ import { errorMessage } from "./utils.js";
6
6
  import { readInstallPreferences } from "./init-preferences.js";
7
7
  import { readCustomHooks } from "./hooks.js";
8
8
  import { hookConfigPaths, hookConfigRoots } from "./provider-adapters.js";
9
+ import { readProjectConfig, isProjectHookEnabled, PROJECT_HOOK_EVENTS } from "./project-config.js";
9
10
  import { getAllSkills } from "./skill-registry.js";
10
11
  import { resolveTaskFilePath, readTasks, TASKS_FILENAME } from "./data-tasks.js";
11
12
  import { buildIndex, queryDocBySourceKey, queryRows } from "./shared-index.js";
@@ -101,7 +102,7 @@ export function collectSkillsForUI(phrenPath, profile = "") {
101
102
  enabled: skill.enabled,
102
103
  }));
103
104
  }
104
- export function getHooksData(phrenPath) {
105
+ export function getHooksData(phrenPath, profile) {
105
106
  const prefs = readInstallPreferences(phrenPath);
106
107
  const globalEnabled = prefs.hooksEnabled !== false;
107
108
  const toolPrefs = (prefs.hookTools && typeof prefs.hookTools === "object") ? prefs.hookTools : {};
@@ -112,7 +113,28 @@ export function getHooksData(phrenPath) {
112
113
  configPath: paths[tool],
113
114
  exists: fs.existsSync(paths[tool]),
114
115
  }));
115
- return { globalEnabled, tools, customHooks: readCustomHooks(phrenPath) };
116
+ // Collect per-project hook overrides
117
+ const projectOverrides = [];
118
+ const projects = getProjectDirs(phrenPath, profile)
119
+ .map((dir) => path.basename(dir))
120
+ .filter((p) => p !== "global");
121
+ for (const project of projects) {
122
+ const config = readProjectConfig(phrenPath, project);
123
+ const hasOverrides = typeof config.hooks?.enabled === "boolean" ||
124
+ PROJECT_HOOK_EVENTS.some((ev) => typeof config.hooks?.[ev] === "boolean");
125
+ if (!hasOverrides)
126
+ continue;
127
+ projectOverrides.push({
128
+ project,
129
+ baseEnabled: typeof config.hooks?.enabled === "boolean" ? config.hooks.enabled : null,
130
+ events: PROJECT_HOOK_EVENTS.map((event) => ({
131
+ event,
132
+ configured: typeof config.hooks?.[event] === "boolean" ? config.hooks[event] : null,
133
+ enabled: isProjectHookEnabled(phrenPath, project, event, config),
134
+ })),
135
+ });
136
+ }
137
+ return { globalEnabled, tools, customHooks: readCustomHooks(phrenPath), projectOverrides };
116
138
  }
117
139
  export async function buildGraph(phrenPath, profile, focusProject) {
118
140
  const projects = getProjectDirs(phrenPath, profile).map((projectDir) => path.basename(projectDir)).filter((project) => project !== "global");
@@ -164,27 +164,30 @@ export function renderGraphScript() {
164
164
  return path;
165
165
  }
166
166
 
167
- /* pin a node so the force simulation skips it (prevents jitter while phren walks) */
168
- var phrenPinnedNode = null;
167
+ /* pin nodes so the force simulation skips them (prevents jitter while phren walks) */
168
+ var phrenPinnedNodes = [];
169
169
 
170
170
  function phrenPinNode(node) {
171
- if (phrenPinnedNode && phrenPinnedNode !== node) {
172
- /* unpin previous target — let physics resume on it */
173
- phrenPinnedNode._phrenPinned = false;
174
- phrenPinnedNode.vx = 0;
175
- phrenPinnedNode.vy = 0;
176
- }
177
- if (node) {
171
+ if (node && !node._phrenPinned) {
178
172
  node._phrenPinned = true;
179
173
  node.vx = 0;
180
174
  node.vy = 0;
175
+ phrenPinnedNodes.push(node);
181
176
  }
182
- phrenPinnedNode = node;
177
+ }
178
+
179
+ function phrenUnpinAll() {
180
+ for (var i = 0; i < phrenPinnedNodes.length; i++) {
181
+ phrenPinnedNodes[i]._phrenPinned = false;
182
+ phrenPinnedNodes[i].vx = 0;
183
+ phrenPinnedNodes[i].vy = 0;
184
+ }
185
+ phrenPinnedNodes = [];
183
186
  }
184
187
 
185
188
  function phrenMoveTo(x, y, targetNode) {
186
- /* pin the target node so physics doesn't move it while phren walks there */
187
- phrenPinNode(targetNode);
189
+ /* unpin all previously pinned nodes before starting a new move */
190
+ phrenUnpinAll();
188
191
  phren.targetX = x;
189
192
  phren.targetY = y;
190
193
  phren.moving = true;
@@ -199,6 +202,12 @@ export function renderGraphScript() {
199
202
  phren.waypointIdx = 0;
200
203
  phren.targetNodeId = targetNode ? targetNode.id : null;
201
204
  phren.targetNodeRef = targetNode || null; /* live reference for position tracking */
205
+ /* pin target node and all waypoint nodes so physics doesn't move them during walk */
206
+ phrenPinNode(targetNode);
207
+ for (var i = 0; i < phren.waypoints.length; i++) {
208
+ var wpn = phrenNodeById(phren.waypoints[i]);
209
+ if (wpn) phrenPinNode(wpn);
210
+ }
202
211
  /* ensure animation loop is running so phren movement renders */
203
212
  if (!animFrame) animFrame = requestAnimationFrame(tick);
204
213
  }
@@ -238,16 +247,45 @@ export function renderGraphScript() {
238
247
  var dx = wx - phren.x;
239
248
  var dy = wy - phren.y;
240
249
  var dist = Math.sqrt(dx * dx + dy * dy);
241
- if (dist < 2) {
250
+ /* snap-to-target helper used both by arrival check and overshoot clamp */
251
+ var snapped = false;
252
+ if (dist < 4) {
242
253
  phren.x = wx;
243
254
  phren.y = wy;
244
- /* advance to next waypoint or finish */
255
+ snapped = true;
256
+ } else {
257
+ /* ease-in-out via sine curve over the full trip distance */
258
+ var t = phren.tripDist > 0 ? Math.min(1, phren.tripProgress / phren.tripDist) : 1;
259
+ var easeInOut = 0.5 - 0.5 * Math.cos(Math.PI * t);
260
+ var baseSpeed = Math.max(3, phren.tripDist * 0.12);
261
+ var speed = Math.max(1.5, baseSpeed * (0.15 + 0.85 * easeInOut));
262
+ /* clamp speed to remaining distance — prevents overshoot oscillation */
263
+ if (speed >= dist) {
264
+ phren.x = wx;
265
+ phren.y = wy;
266
+ speed = dist;
267
+ snapped = true;
268
+ } else {
269
+ phren.x += (dx / dist) * speed;
270
+ phren.y += (dy / dist) * speed;
271
+ }
272
+ phren.tripProgress += speed;
273
+ /* record trail — longer buffer for gradual fade */
274
+ phren.trailPoints.push({ x: phren.x, y: phren.y, age: 0 });
275
+ if (phren.trailPoints.length > 50) phren.trailPoints.shift();
276
+ }
277
+ /* advance waypoint or finish when snapped to current target */
278
+ if (snapped) {
245
279
  if (phren.waypoints.length > 0 && phren.waypointIdx < phren.waypoints.length) {
246
280
  phren.waypointIdx++;
247
281
  } else {
248
282
  phren.moving = false;
249
283
  phren.arriving = true;
250
284
  phren.arriveTimer = 0;
285
+ /* clear trail on arrival so purple line doesn't linger */
286
+ phren.trailPoints = [];
287
+ /* unpin all nodes now that phren has arrived */
288
+ phrenUnpinAll();
251
289
  /* update keyboard-nav current node */
252
290
  if (phren.targetNodeId) {
253
291
  phrenCurrentNodeId = phren.targetNodeId;
@@ -255,18 +293,6 @@ export function renderGraphScript() {
255
293
  phrenRefreshAdjacentLinks();
256
294
  }
257
295
  }
258
- } else {
259
- /* ease-in-out via sine curve over the full trip distance */
260
- var t = phren.tripDist > 0 ? Math.min(1, phren.tripProgress / phren.tripDist) : 1;
261
- var easeInOut = 0.5 - 0.5 * Math.cos(Math.PI * t);
262
- var baseSpeed = Math.max(3, phren.tripDist * 0.12);
263
- var speed = Math.max(1.5, baseSpeed * (0.15 + 0.85 * easeInOut));
264
- phren.x += (dx / dist) * speed;
265
- phren.y += (dy / dist) * speed;
266
- phren.tripProgress += speed;
267
- /* record trail — longer buffer for gradual fade */
268
- phren.trailPoints.push({ x: phren.x, y: phren.y, age: 0 });
269
- if (phren.trailPoints.length > 50) phren.trailPoints.shift();
270
296
  }
271
297
  }
272
298
  if (phren.arriving) {
@@ -312,10 +338,10 @@ export function renderGraphScript() {
312
338
  /* sprite rendering */
313
339
  if (phrenImgReady) {
314
340
  ctx.save();
315
- /* fixed 128px size in screen pixels, scale up to 148px on arrival flash */
316
- var spriteScreenSize = 128;
341
+ /* fixed 48px size in screen pixels, scale up to 56px on arrival flash */
342
+ var spriteScreenSize = 48;
317
343
  if (phren.arriving && phren.arriveTimer < 0.4) {
318
- spriteScreenSize = 128 + 20 * (1 - phren.arriveTimer / 0.4);
344
+ spriteScreenSize = 48 + 8 * (1 - phren.arriveTimer / 0.4);
319
345
  }
320
346
  var spriteSize = spriteScreenSize * s; /* convert to graph coords */
321
347
  /* bob up/down when walking — sine wave synced to walk progress */
@@ -1428,8 +1454,8 @@ export function renderGraphScript() {
1428
1454
  if (healthText) html += '<div style="margin-top:4px">' + healthText + '</div>';
1429
1455
  if (node.project) {
1430
1456
  html += '<div style="display:flex;gap:8px;margin-top:12px">';
1431
- html += '<button class="btn btn-sm" onclick="window.graphNodeEdit(' + JSON.stringify(node.project) + ',' + JSON.stringify(findingText) + ')" style="padding:5px 14px;font-size:12px">Edit</button>';
1432
- html += '<button class="btn btn-sm" onclick="window.graphNodeDelete(' + JSON.stringify(node.project) + ',' + JSON.stringify(findingText) + ')" style="padding:5px 14px;font-size:12px;color:#ef4444;border-color:#ef4444">Delete</button>';
1457
+ html += '<button class="btn btn-sm" data-action="graphNodeEdit" data-project="' + esc(node.project) + '" data-finding="' + esc(findingText) + '" style="padding:5px 14px;font-size:12px">Edit</button>';
1458
+ html += '<button class="btn btn-sm" data-action="graphNodeDelete" data-project="' + esc(node.project) + '" data-finding="' + esc(findingText) + '" style="padding:5px 14px;font-size:12px;color:#ef4444;border-color:#ef4444">Delete</button>';
1433
1459
  html += '</div>';
1434
1460
  }
1435
1461
 
@@ -1481,8 +1507,8 @@ export function renderGraphScript() {
1481
1507
  html += '<div style="margin-top:8px;padding:8px 10px;border-radius:6px;border:1px solid var(--border);background:var(--surface-alt,var(--surface))">';
1482
1508
  html += '<div style="font-size:12px;line-height:1.5;color:var(--ink)">' + esc(lfText) + '</div>';
1483
1509
  html += '<div style="display:flex;gap:6px;margin-top:6px">';
1484
- html += '<button class="btn btn-sm" onclick="window.graphNodeEdit(' + JSON.stringify(lf.project) + ',' + JSON.stringify(lfText) + ')" style="padding:3px 10px;font-size:11px">Edit</button>';
1485
- html += '<button class="btn btn-sm" onclick="window.graphNodeDelete(' + JSON.stringify(lf.project) + ',' + JSON.stringify(lfText) + ')" style="padding:3px 10px;font-size:11px;color:#ef4444;border-color:#ef4444">Delete</button>';
1510
+ html += '<button class="btn btn-sm" data-action="graphNodeEdit" data-project="' + esc(lf.project) + '" data-finding="' + esc(lfText) + '" style="padding:3px 10px;font-size:11px">Edit</button>';
1511
+ html += '<button class="btn btn-sm" data-action="graphNodeDelete" data-project="' + esc(lf.project) + '" data-finding="' + esc(lfText) + '" style="padding:3px 10px;font-size:11px;color:#ef4444;border-color:#ef4444">Delete</button>';
1486
1512
  html += '</div></div>';
1487
1513
  }
1488
1514
  }
@@ -2030,11 +2056,25 @@ export function renderGraphScript() {
2030
2056
  renderGraphDetails(null);
2031
2057
  };
2032
2058
 
2059
+ /* ── delegated click handler for graph detail panel buttons ─────────── */
2060
+ document.addEventListener('click', function(e) {
2061
+ var target = e.target;
2062
+ if (!target || typeof target.closest !== 'function') return;
2063
+ var actionEl = target.closest('[data-action]');
2064
+ if (!actionEl) return;
2065
+ var action = actionEl.getAttribute('data-action');
2066
+ var project = actionEl.getAttribute('data-project');
2067
+ var finding = actionEl.getAttribute('data-finding');
2068
+ if (action === 'graphNodeEdit' && project && finding) { window.graphNodeEdit(project, finding); }
2069
+ else if (action === 'graphNodeDelete' && project && finding) { window.graphNodeDelete(project, finding); }
2070
+ });
2071
+
2033
2072
  /* ── node edit/delete actions ────────────────────────────────────────── */
2034
2073
 
2035
- function fetchCsrfToken(cb) {
2036
- fetch('/api/csrf-token').then(function(r) { return r.json(); }).then(function(d) { cb(d.token || ''); }).catch(function() { cb(''); });
2037
- }
2074
+ var authUrl = window._phrenAuthUrl || function(u) { return u; };
2075
+ var fetchCsrfToken = window._phrenFetchCsrfToken || function(cb) {
2076
+ fetch(authUrl('/api/csrf-token')).then(function(r) { return r.json(); }).then(function(d) { cb(d.token || ''); }).catch(function() { cb(''); });
2077
+ };
2038
2078
 
2039
2079
  window.graphNodeEdit = function(project, findingText) {
2040
2080
  var panel = document.getElementById('graph-detail-panel');
@@ -2070,7 +2110,7 @@ export function renderGraphScript() {
2070
2110
  body.set('old_text', findingText);
2071
2111
  body.set('new_text', newText);
2072
2112
  if (csrf) body.set('_csrf', csrf);
2073
- fetch('/api/findings/' + encodeURIComponent(project), { method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
2113
+ fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
2074
2114
  .then(function(r) { return r.json(); })
2075
2115
  .then(function(d) {
2076
2116
  if (d.ok) {
@@ -2096,7 +2136,7 @@ export function renderGraphScript() {
2096
2136
  var body = new URLSearchParams();
2097
2137
  body.set('text', findingText);
2098
2138
  if (csrf) body.set('_csrf', csrf);
2099
- fetch('/api/findings/' + encodeURIComponent(project), { method: 'DELETE', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
2139
+ fetch(authUrl('/api/findings/' + encodeURIComponent(project)), { method: 'DELETE', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
2100
2140
  .then(function(r) { return r.json(); })
2101
2141
  .then(function(d) {
2102
2142
  if (d.ok) {
@@ -321,7 +321,7 @@ ${TASK_UI_STYLES}
321
321
  </div>
322
322
 
323
323
  <script${nonceAttr}>
324
- ${renderWebUiScript(h(authToken || ""))}
324
+ ${renderWebUiScript(authToken || "")}
325
325
  </script>
326
326
  <script${nonceAttr}>
327
327
  ${renderGraphScript()}
@@ -330,7 +330,7 @@ ${renderGraphScript()}
330
330
  ${renderReviewQueueEditSyncScript()}
331
331
  </script>
332
332
  <script${nonceAttr}>
333
- ${renderSharedWebUiHelpers(h(authToken || ""))}
333
+ ${renderSharedWebUiHelpers(authToken || "")}
334
334
  </script>
335
335
  <script${nonceAttr}>
336
336
  ${renderSkillUiEnhancementScript(h(authToken || ""))}
@@ -7,8 +7,9 @@
7
7
  * window._phrenFetchCsrfToken(cb) — fetch the CSRF token and call cb(token)
8
8
  */
9
9
  export function renderSharedWebUiHelpers(authToken) {
10
+ const safeToken = JSON.stringify(authToken).slice(1, -1); // escape for JS string literal
10
11
  return `(function() {
11
- window._phrenAuthToken = '${authToken}';
12
+ window._phrenAuthToken = '${safeToken}';
12
13
  window._phrenEsc = function(s) {
13
14
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
14
15
  };
@@ -600,6 +601,10 @@ export function renderTasksAndSettingsScript(authToken) {
600
601
  return base + (base.indexOf('?') === -1 ? '?' : '&') + '_auth=' + encodeURIComponent(_tsAuthToken);
601
602
  }
602
603
 
604
+ function loadJson(url) {
605
+ return fetch(url).then(function(r) { return r.json(); });
606
+ }
607
+
603
608
  function priorityBadge(p) {
604
609
  if (!p) return '';
605
610
  var colors = { high: '#ef4444', medium: '#f59e0b', low: '#6b7280' };
@@ -679,6 +684,7 @@ export function renderTasksAndSettingsScript(authToken) {
679
684
  };
680
685
 
681
686
  window.toggleDoneSection = function(btn) {
687
+ if (!btn) return;
682
688
  var list = btn.nextElementSibling;
683
689
  var arrow = btn.querySelector('.task-toggle-arrow');
684
690
  if (!list) return;
@@ -708,10 +714,10 @@ export function renderTasksAndSettingsScript(authToken) {
708
714
  });
709
715
  var doneTasks = showDone ? [] : _allTasks.filter(function(t) {
710
716
  if (projectFilter && t.project !== projectFilter) return false;
711
- return t.section === 'Done';
717
+ return t.section === 'Done' || t.checked;
712
718
  });
713
719
 
714
- var activeCount = tasks.filter(function(t) { return t.section !== 'Done'; }).length;
720
+ var activeCount = tasks.filter(function(t) { return t.section !== 'Done' && !t.checked; }).length;
715
721
  var countEl = document.getElementById('tasks-count');
716
722
  if (countEl) countEl.textContent = activeCount + ' active' + (doneTasks.length ? ', ' + doneTasks.length + ' done' : '');
717
723
 
@@ -732,12 +738,13 @@ export function renderTasksAndSettingsScript(authToken) {
732
738
  return pa - pb;
733
739
  }
734
740
 
735
- // Separate into priority groups
736
- var high = tasks.filter(function(t) { return t.priority === 'high' && t.section !== 'Done'; }).sort(sortByPriority);
737
- var medium = tasks.filter(function(t) { return t.priority === 'medium' && t.section !== 'Done'; }).sort(sortByPriority);
738
- var low = tasks.filter(function(t) { return t.priority === 'low' && t.section !== 'Done'; }).sort(sortByPriority);
739
- var noPriority = tasks.filter(function(t) { return !t.priority && t.section !== 'Done'; }).sort(sortByPriority);
740
- var doneVisible = tasks.filter(function(t) { return t.section === 'Done'; });
741
+ // Separate into priority groups (exclude checked tasks even if not in Done section)
742
+ function isActive(t) { return t.section !== 'Done' && !t.checked; }
743
+ var high = tasks.filter(function(t) { return t.priority === 'high' && isActive(t); }).sort(sortByPriority);
744
+ var medium = tasks.filter(function(t) { return t.priority === 'medium' && isActive(t); }).sort(sortByPriority);
745
+ var low = tasks.filter(function(t) { return t.priority === 'low' && isActive(t); }).sort(sortByPriority);
746
+ var noPriority = tasks.filter(function(t) { return !t.priority && isActive(t); }).sort(sortByPriority);
747
+ var doneVisible = tasks.filter(function(t) { return t.section === 'Done' || t.checked; });
741
748
 
742
749
  function renderTaskCard(t) {
743
750
  var borderClass = t.priority === 'high' ? ' task-card-high' : t.priority === 'medium' ? ' task-card-medium' : t.priority === 'low' ? ' task-card-low' : '';
@@ -755,10 +762,10 @@ export function renderTasksAndSettingsScript(authToken) {
755
762
  if (t.context) html += '<span class="task-card-context">' + esc(t.context) + '</span>';
756
763
  html += '</div>';
757
764
  html += '<div class="task-card-actions">';
758
- if (t.section !== 'Done') {
759
- html += '<button class="task-done-btn" onclick="completeTaskFromUi(\'' + esc(t.project).replace(/'/g, "\\'") + '\', \'' + esc(t.line).replace(/'/g, "\\'") + '\')" title="Mark done"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M3 8.5l3.5 3.5 6.5-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Done</button>';
765
+ if (t.section !== 'Done' && !t.checked) {
766
+ html += '<button class="task-done-btn" data-ts-action="completeTask" data-project="' + esc(t.project) + '" data-item="' + esc(t.line) + '" title="Mark done"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M3 8.5l3.5 3.5 6.5-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Done</button>';
760
767
  }
761
- html += '<button class="task-remove-btn" onclick="removeTaskFromUi(\'' + esc(t.project).replace(/'/g, "\\'") + '\', \'' + esc(t.line).replace(/'/g, "\\'") + '\')" title="Delete task" style="background:none;border:1px solid var(--border);border-radius:var(--radius-sm);padding:2px 8px;cursor:pointer;color:var(--muted);font-size:var(--text-xs)"><svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>';
768
+ html += '<button class="task-remove-btn" data-ts-action="removeTask" data-project="' + esc(t.project) + '" data-item="' + esc(t.line) + '" title="Delete task" style="background:none;border:1px solid var(--border);border-radius:var(--radius-sm);padding:2px 8px;cursor:pointer;color:var(--muted);font-size:var(--text-xs)"><svg width="12" height="12" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>';
762
769
  html += '</div>';
763
770
  html += '</div>';
764
771
  return html;
@@ -766,12 +773,12 @@ export function renderTasksAndSettingsScript(authToken) {
766
773
 
767
774
  var html = '';
768
775
 
769
- // Add task input at top
770
- var projects = projectFilter ? [projectFilter] : Array.from(new Set(_allTasks.map(function(t) { return t.project; }))).sort();
776
+ // Add task input at top (only when a specific project is selected)
777
+ var projects = projectFilter ? [projectFilter] : [];
771
778
  projects.forEach(function(proj) {
772
779
  html += '<div class="task-add-bar">';
773
- html += '<input id="task-add-input-' + esc(proj) + '" type="text" class="task-add-input" placeholder="Add a task to ' + esc(proj) + '\u2026" onkeydown="if(event.key===\'Enter\')addTaskFromUi(\'' + esc(proj).replace(/'/g, "\\'") + '\')">';
774
- html += '<button class="task-add-btn" onclick="addTaskFromUi(\'' + esc(proj).replace(/'/g, "\\'") + '\')"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Add</button>';
780
+ html += '<input id="task-add-input-' + esc(proj) + '" type="text" class="task-add-input" placeholder="Add a task to ' + esc(proj) + '\u2026" data-ts-action="addTaskKeydown" data-project="' + esc(proj) + '">';
781
+ html += '<button class="task-add-btn" data-ts-action="addTask" data-project="' + esc(proj) + '"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Add</button>';
775
782
  html += '</div>';
776
783
  });
777
784
 
@@ -797,7 +804,7 @@ export function renderTasksAndSettingsScript(authToken) {
797
804
  var allDone = showDone ? doneVisible : doneTasks;
798
805
  if (allDone.length) {
799
806
  html += '<div class="task-done-section">';
800
- html += '<button class="task-done-toggle" onclick="toggleDoneSection(this)">';
807
+ html += '<button class="task-done-toggle" data-ts-action="toggleDoneSection">';
801
808
  html += '<span class="task-toggle-arrow">\u25B6</span> Completed <span class="task-section-count">' + allDone.length + '</span></button>';
802
809
  html += '<div class="task-done-list" style="display:none">';
803
810
  html += '<div class="task-card-grid">';
@@ -897,7 +904,7 @@ export function renderTasksAndSettingsScript(authToken) {
897
904
  var scopeNote = document.getElementById('settings-scope-note');
898
905
  if (scopeNote) {
899
906
  scopeNote.textContent = selectedProject
900
- ? 'Showing effective config for "' + selectedProject + '". Overrides are saved to that project\'s phren.project.yaml.'
907
+ ? 'Showing effective config for "' + selectedProject + '". Overrides are saved to that project\\\'s phren.project.yaml.'
901
908
  : 'Showing global settings. Select a project to view and edit per-project overrides.';
902
909
  }
903
910
 
@@ -1217,7 +1224,11 @@ export function renderTasksAndSettingsScript(authToken) {
1217
1224
  var actionEl = target.closest('[data-ts-action]');
1218
1225
  if (!actionEl) return;
1219
1226
  var action = actionEl.getAttribute('data-ts-action');
1220
- if (action === 'setFindingSensitivity') { setFindingSensitivity(actionEl.getAttribute('data-level')); }
1227
+ if (action === 'toggleDoneSection') { toggleDoneSection(actionEl); }
1228
+ else if (action === 'completeTask') { completeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
1229
+ else if (action === 'removeTask') { removeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
1230
+ else if (action === 'addTask') { addTaskFromUi(actionEl.getAttribute('data-project')); }
1231
+ else if (action === 'setFindingSensitivity') { setFindingSensitivity(actionEl.getAttribute('data-level')); }
1221
1232
  else if (action === 'toggleAutoCapture') { setAutoCapture(actionEl.getAttribute('data-enabled') !== 'true'); }
1222
1233
  else if (action === 'setTaskMode') { setTaskMode(actionEl.getAttribute('data-mode')); }
1223
1234
  else if (action === 'setProactivity') { setProactivity(actionEl.getAttribute('data-level')); }
@@ -1254,6 +1265,15 @@ export function renderTasksAndSettingsScript(authToken) {
1254
1265
  }
1255
1266
  });
1256
1267
 
1268
+ // Keydown delegation for add-task inputs (Enter key)
1269
+ document.addEventListener('keydown', function(e) {
1270
+ var target = e.target;
1271
+ if (!target || !target.getAttribute) return;
1272
+ if (target.getAttribute('data-ts-action') === 'addTaskKeydown' && e.key === 'Enter') {
1273
+ addTaskFromUi(target.getAttribute('data-project'));
1274
+ }
1275
+ });
1276
+
1257
1277
  window.setFindingSensitivity = function(level) {
1258
1278
  var descriptions = {
1259
1279
  high: 'Capture findings proactively, including minor observations.',
@@ -7,12 +7,12 @@ import * as querystring from "querystring";
7
7
  import { spawn, execFileSync } from "child_process";
8
8
  import { computePhrenLiveStateToken, getProjectDirs, } from "./shared.js";
9
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";
10
+ import { isValidProjectName, errorMessage, queueFilePath, safeProjectPath } from "./utils.js";
11
11
  import { readInstallPreferences, writeInstallPreferences, writeGovernanceInstallPreferences } from "./init-preferences.js";
12
12
  import { buildGraph, collectProjectsForUI, collectSkillsForUI, getHooksData, isAllowedFilePath, readSyncSnapshot, recentAccepted, recentUsage, } from "./memory-ui-data.js";
13
13
  import { CONSOLIDATION_ENTRY_THRESHOLD } from "./content-validate.js";
14
14
  import { ensureTopicReferenceDoc, getProjectTopicsResponse, listProjectReferenceDocs, pinProjectTopicSuggestion, readReferenceContent, reclassifyLegacyTopicDocs, unpinProjectTopicSuggestion, writeProjectTopics, } from "./project-topics.js";
15
- import { getWorkflowPolicy, updateWorkflowPolicy, mergeConfig, getRetentionPolicy, getProjectConfigOverrides } from "./governance-policy.js";
15
+ import { getWorkflowPolicy, updateWorkflowPolicy, mergeConfig, getRetentionPolicy, getProjectConfigOverrides, VALID_TASK_MODES } from "./governance-policy.js";
16
16
  import { updateProjectConfigOverrides } from "./project-config.js";
17
17
  import { findSkill } from "./skill-registry.js";
18
18
  import { setSkillEnabledAndSync } from "./skill-files.js";
@@ -193,6 +193,8 @@ function readFormBody(req, res) {
193
193
  req.on("data", (chunk) => {
194
194
  received += chunk.length;
195
195
  if (received > MAX_FORM_BODY_BYTES) {
196
+ res.writeHead(413, { "Content-Type": "application/json" });
197
+ res.end(JSON.stringify({ ok: false, error: "Request body too large" }));
196
198
  req.destroy();
197
199
  resolve(null);
198
200
  return;
@@ -388,6 +390,111 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
388
390
  }));
389
391
  return;
390
392
  }
393
+ // POST /api/approve — remove item from review queue (keep finding)
394
+ if (req.method === "POST" && pathname === "/api/approve") {
395
+ void readFormBody(req, res).then((parsed) => {
396
+ if (!parsed)
397
+ return;
398
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
399
+ return;
400
+ if (!requireCsrf(res, parsed, csrfTokens, true))
401
+ return;
402
+ const project = String(parsed.project || "");
403
+ const line = String(parsed.line || "");
404
+ if (!project || !isValidProjectName(project) || !line) {
405
+ res.writeHead(200, { "content-type": "application/json" });
406
+ res.end(JSON.stringify({ ok: false, error: "Missing project or line" }));
407
+ return;
408
+ }
409
+ try {
410
+ const qPath = queueFilePath(phrenPath, project);
411
+ if (fs.existsSync(qPath)) {
412
+ const content = fs.readFileSync(qPath, "utf8");
413
+ const lines = content.split("\n");
414
+ const filtered = lines.filter((l) => l.trim() !== line.trim());
415
+ fs.writeFileSync(qPath, filtered.join("\n"));
416
+ }
417
+ res.writeHead(200, { "content-type": "application/json" });
418
+ res.end(JSON.stringify({ ok: true }));
419
+ }
420
+ catch (err) {
421
+ res.writeHead(200, { "content-type": "application/json" });
422
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
423
+ }
424
+ });
425
+ return;
426
+ }
427
+ // POST /api/reject — remove item from review queue AND remove finding
428
+ if (req.method === "POST" && pathname === "/api/reject") {
429
+ void readFormBody(req, res).then((parsed) => {
430
+ if (!parsed)
431
+ return;
432
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
433
+ return;
434
+ if (!requireCsrf(res, parsed, csrfTokens, true))
435
+ return;
436
+ const project = String(parsed.project || "");
437
+ const line = String(parsed.line || "");
438
+ if (!project || !isValidProjectName(project) || !line) {
439
+ res.writeHead(200, { "content-type": "application/json" });
440
+ res.end(JSON.stringify({ ok: false, error: "Missing project or line" }));
441
+ return;
442
+ }
443
+ try {
444
+ // Remove from review queue
445
+ const qPath = queueFilePath(phrenPath, project);
446
+ if (fs.existsSync(qPath)) {
447
+ const content = fs.readFileSync(qPath, "utf8");
448
+ const lines = content.split("\n");
449
+ const filtered = lines.filter((l) => l.trim() !== line.trim());
450
+ fs.writeFileSync(qPath, filtered.join("\n"));
451
+ }
452
+ // Also remove the finding from FINDINGS.md
453
+ // Extract text from the line (strip "- " prefix and inline metadata)
454
+ const findingText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
455
+ if (findingText) {
456
+ removeFinding(phrenPath, project, findingText);
457
+ }
458
+ res.writeHead(200, { "content-type": "application/json" });
459
+ res.end(JSON.stringify({ ok: true }));
460
+ }
461
+ catch (err) {
462
+ res.writeHead(200, { "content-type": "application/json" });
463
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
464
+ }
465
+ });
466
+ return;
467
+ }
468
+ // POST /api/edit — edit a finding's text
469
+ if (req.method === "POST" && pathname === "/api/edit") {
470
+ void readFormBody(req, res).then((parsed) => {
471
+ if (!parsed)
472
+ return;
473
+ if (!requirePostAuth(req, res, url, parsed, authToken, true))
474
+ return;
475
+ if (!requireCsrf(res, parsed, csrfTokens, true))
476
+ return;
477
+ const project = String(parsed.project || "");
478
+ const line = String(parsed.line || "");
479
+ const newText = String(parsed.new_text || "");
480
+ if (!project || !isValidProjectName(project) || !line || !newText) {
481
+ res.writeHead(200, { "content-type": "application/json" });
482
+ res.end(JSON.stringify({ ok: false, error: "Missing project, line, or new_text" }));
483
+ return;
484
+ }
485
+ try {
486
+ const oldText = line.replace(/^-\s*/, "").replace(/<!--.*?-->/g, "").trim();
487
+ const result = editFinding(phrenPath, project, oldText, newText);
488
+ res.writeHead(200, { "content-type": "application/json" });
489
+ res.end(JSON.stringify({ ok: result.ok, error: result.ok ? undefined : result.error }));
490
+ }
491
+ catch (err) {
492
+ res.writeHead(200, { "content-type": "application/json" });
493
+ res.end(JSON.stringify({ ok: false, error: errorMessage(err) }));
494
+ }
495
+ });
496
+ return;
497
+ }
391
498
  if (req.method === "GET" && pathname.startsWith("/api/project-content")) {
392
499
  const qs = url.includes("?") ? querystring.parse(url.slice(url.indexOf("?") + 1)) : {};
393
500
  const project = String(qs.project || "");
@@ -403,7 +510,12 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
403
510
  res.end(JSON.stringify({ ok: false, error: `File not allowed: ${file}` }));
404
511
  return;
405
512
  }
406
- const filePath = path.join(phrenPath, project, file);
513
+ const filePath = safeProjectPath(phrenPath, project, file);
514
+ if (!filePath) {
515
+ res.writeHead(400, { "content-type": "application/json" });
516
+ res.end(JSON.stringify({ ok: false, error: "Invalid project or file path" }));
517
+ return;
518
+ }
407
519
  if (!fs.existsSync(filePath)) {
408
520
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
409
521
  res.end(JSON.stringify({ ok: false, error: `File not found: ${file}` }));
@@ -919,7 +1031,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
919
1031
  if (!requireCsrf(res, parsed, csrfTokens, true))
920
1032
  return;
921
1033
  const value = String(parsed.value || "").trim().toLowerCase();
922
- const valid = ["off", "manual", "auto"];
1034
+ const valid = VALID_TASK_MODES;
923
1035
  if (!valid.includes(value)) {
924
1036
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
925
1037
  res.end(JSON.stringify({ ok: false, error: `Invalid task mode: "${value}". Must be one of: ${valid.join(", ")}` }));
@@ -1056,7 +1168,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
1056
1168
  });
1057
1169
  const merged = mergeConfig(phrenPath, project);
1058
1170
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1059
- res.end(JSON.stringify({ ok: true, config: merged }));
1171
+ res.end(JSON.stringify({ ok: true, config: merged, ...(registrationWarning ? { warning: registrationWarning } : {}) }));
1060
1172
  }
1061
1173
  catch (err) {
1062
1174
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
@@ -305,6 +305,23 @@ export function findProjectNameCaseInsensitive(phrenPath, name) {
305
305
  }
306
306
  return null;
307
307
  }
308
+ export function findArchivedProjectNameCaseInsensitive(phrenPath, name) {
309
+ const needle = name.toLowerCase();
310
+ try {
311
+ for (const entry of fs.readdirSync(phrenPath, { withFileTypes: true })) {
312
+ if (!entry.isDirectory() || !entry.name.endsWith(".archived"))
313
+ continue;
314
+ const archivedName = entry.name.slice(0, -".archived".length);
315
+ if (archivedName.toLowerCase() === needle)
316
+ return archivedName;
317
+ }
318
+ }
319
+ catch (err) {
320
+ if ((process.env.PHREN_DEBUG))
321
+ process.stderr.write(`[phren] findArchivedProjectNameCaseInsensitive: ${errorMessage(err)}\n`);
322
+ }
323
+ return null;
324
+ }
308
325
  function getLocalProjectDirs(phrenPath, manifest) {
309
326
  const primaryProject = manifest.primaryProject;
310
327
  if (!primaryProject || !isValidProjectName(primaryProject))
@@ -4,7 +4,7 @@ import { debugLog, runtimeFile } from "./phren-paths.js";
4
4
  import { errorMessage } from "./utils.js";
5
5
  export { HOOK_TOOL_NAMES, hookConfigPath } from "./provider-adapters.js";
6
6
  export { EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS, PhrenError, phrenOk, phrenErr, forwardErr, parsePhrenErrorCode, isRecord, withDefaults, FINDING_TYPES, FINDING_TAGS, KNOWN_OBSERVATION_TAGS, DOC_TYPES, capCache, RESERVED_PROJECT_DIR_NAMES, } from "./phren-core.js";
7
- export { ROOT_MANIFEST_FILENAME, homeDir, homePath, expandHomePath, defaultPhrenPath, rootManifestPath, readRootManifest, writeRootManifest, resolveInstallContext, findNearestPhrenPath, isProjectLocalMode, runtimeDir, tryUnlink, sessionsDir, runtimeFile, installPreferencesFile, runtimeHealthFile, shellStateFile, sessionMetricsFile, memoryScoresFile, memoryUsageLogFile, sessionMarker, debugLog, appendIndexEvent, resolveFindingsPath, findPhrenPath, ensurePhrenPath, findPhrenPathWithArg, normalizeProjectNameForCreate, findProjectNameCaseInsensitive, getProjectDirs, collectNativeMemoryFiles, computePhrenLiveStateToken, getPhrenPath, qualityMarkers, atomicWriteText, } from "./phren-paths.js";
7
+ export { ROOT_MANIFEST_FILENAME, homeDir, homePath, expandHomePath, defaultPhrenPath, rootManifestPath, readRootManifest, writeRootManifest, resolveInstallContext, findNearestPhrenPath, isProjectLocalMode, runtimeDir, tryUnlink, sessionsDir, runtimeFile, installPreferencesFile, runtimeHealthFile, shellStateFile, sessionMetricsFile, memoryScoresFile, memoryUsageLogFile, sessionMarker, debugLog, appendIndexEvent, resolveFindingsPath, findPhrenPath, ensurePhrenPath, findPhrenPathWithArg, normalizeProjectNameForCreate, findProjectNameCaseInsensitive, findArchivedProjectNameCaseInsensitive, getProjectDirs, collectNativeMemoryFiles, computePhrenLiveStateToken, getPhrenPath, qualityMarkers, atomicWriteText, } from "./phren-paths.js";
8
8
  export { PROACTIVITY_LEVELS, getProactivityLevel, getProactivityLevelForFindings, getProactivityLevelForTask, hasExplicitFindingSignal, hasExplicitTaskSignal, hasExecutionIntent, hasDiscoveryIntent, shouldAutoCaptureFindingsForLevel, shouldAutoCaptureTaskForLevel, } from "./proactivity.js";
9
9
  const MEMORY_SCOPE_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
10
10
  export function normalizeMemoryScope(scope) {