@phren/cli 0.0.15 → 0.0.17

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.
@@ -338,10 +338,10 @@ export function renderGraphScript() {
338
338
  /* sprite rendering */
339
339
  if (phrenImgReady) {
340
340
  ctx.save();
341
- /* fixed 128px size in screen pixels, scale up to 148px on arrival flash */
342
- var spriteScreenSize = 128;
341
+ /* fixed 48px size in screen pixels, scale up to 56px on arrival flash */
342
+ var spriteScreenSize = 48;
343
343
  if (phren.arriving && phren.arriveTimer < 0.4) {
344
- spriteScreenSize = 128 + 20 * (1 - phren.arriveTimer / 0.4);
344
+ spriteScreenSize = 48 + 8 * (1 - phren.arriveTimer / 0.4);
345
345
  }
346
346
  var spriteSize = spriteScreenSize * s; /* convert to graph coords */
347
347
  /* bob up/down when walking — sine wave synced to walk progress */
@@ -1454,8 +1454,8 @@ export function renderGraphScript() {
1454
1454
  if (healthText) html += '<div style="margin-top:4px">' + healthText + '</div>';
1455
1455
  if (node.project) {
1456
1456
  html += '<div style="display:flex;gap:8px;margin-top:12px">';
1457
- 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>';
1458
- 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>';
1459
1459
  html += '</div>';
1460
1460
  }
1461
1461
 
@@ -1507,8 +1507,8 @@ export function renderGraphScript() {
1507
1507
  html += '<div style="margin-top:8px;padding:8px 10px;border-radius:6px;border:1px solid var(--border);background:var(--surface-alt,var(--surface))">';
1508
1508
  html += '<div style="font-size:12px;line-height:1.5;color:var(--ink)">' + esc(lfText) + '</div>';
1509
1509
  html += '<div style="display:flex;gap:6px;margin-top:6px">';
1510
- 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>';
1511
- 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>';
1512
1512
  html += '</div></div>';
1513
1513
  }
1514
1514
  }
@@ -2056,11 +2056,25 @@ export function renderGraphScript() {
2056
2056
  renderGraphDetails(null);
2057
2057
  };
2058
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
+
2059
2072
  /* ── node edit/delete actions ────────────────────────────────────────── */
2060
2073
 
2061
- function fetchCsrfToken(cb) {
2062
- fetch('/api/csrf-token').then(function(r) { return r.json(); }).then(function(d) { cb(d.token || ''); }).catch(function() { cb(''); });
2063
- }
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
+ };
2064
2078
 
2065
2079
  window.graphNodeEdit = function(project, findingText) {
2066
2080
  var panel = document.getElementById('graph-detail-panel');
@@ -2096,7 +2110,7 @@ export function renderGraphScript() {
2096
2110
  body.set('old_text', findingText);
2097
2111
  body.set('new_text', newText);
2098
2112
  if (csrf) body.set('_csrf', csrf);
2099
- 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() })
2100
2114
  .then(function(r) { return r.json(); })
2101
2115
  .then(function(d) {
2102
2116
  if (d.ok) {
@@ -2122,7 +2136,7 @@ export function renderGraphScript() {
2122
2136
  var body = new URLSearchParams();
2123
2137
  body.set('text', findingText);
2124
2138
  if (csrf) body.set('_csrf', csrf);
2125
- 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() })
2126
2140
  .then(function(r) { return r.json(); })
2127
2141
  .then(function(d) {
2128
2142
  if (d.ok) {
@@ -98,24 +98,11 @@ ${TASK_UI_STYLES}
98
98
  </div>
99
99
  </div>
100
100
  <details class="review-help" style="margin-bottom:16px">
101
- <summary>Help: How the Review Queue works</summary>
102
- <dl>
103
- <dt>What is the Review Queue?</dt>
104
- <dd>Fragments flagged by governance for human review. Items accumulate here when <code>phren maintain govern</code> is run.</dd>
105
- <dt>Can I approve, reject, or edit items here?</dt>
106
- <dd>Yes. Each review card has <strong>Approve</strong>, <strong>Reject</strong>, and <strong>Edit</strong> buttons. Approve accepts the fragment, Reject removes it, and Edit lets you revise the text before accepting. You can also use batch actions to approve or reject multiple items at once.</dd>
107
- <dt>How do I clear items?</dt>
108
- <dd>Approve or reject items directly in the UI, or use maintenance flows such as <code>phren maintain prune</code>.</dd>
109
- <dt>Is this automatic?</dt>
110
- <dd>No. Agents do not auto-accept review-queue items.</dd>
111
- <dt>How do items get here?</dt>
112
- <dd><code>phren maintain govern</code> flags stale or low-confidence fragments for review.</dd>
113
- <dt>How to reduce noise?</dt>
114
- <dd>Run <code>phren maintain prune</code> to auto-remove expired items without manual review.</dd>
115
- </dl>
101
+ <summary>How review works</summary>
102
+ <p style="margin-top:8px;font-size:var(--text-sm);color:var(--muted)">Items waiting for your review. Approve to keep, reject to remove. You can also edit the text before approving, or use the checkboxes to bulk approve or reject multiple items at once.</p>
116
103
  </details>
117
104
 
118
- <p style="font-size:var(--text-sm);color:var(--muted);margin-bottom:12px;letter-spacing:-0.01em">Fragments flagged for review. Approve, reject, or edit items directly from this tab.</p>
105
+ <p style="font-size:var(--text-sm);color:var(--muted);margin-bottom:12px;letter-spacing:-0.01em">Items waiting for your review. Approve to keep, reject to remove.</p>
119
106
 
120
107
  <div id="review-summary-banner" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center"></div>
121
108
 
@@ -130,7 +117,18 @@ ${TASK_UI_STYLES}
130
117
  <option value="">All models</option>
131
118
  </select>
132
119
  <span id="review-filter-count" class="text-muted" style="font-size:var(--text-sm);margin-left:8px"></span>
133
- <button class="btn btn-sm" id="highlight-only-btn" style="margin-left:auto">Flagged only</button>
120
+ <label id="review-select-all" style="display:none;margin-left:auto;align-items:center;gap:6px;font-size:var(--text-sm);color:var(--muted);cursor:pointer;user-select:none">
121
+ <input type="checkbox" onchange="toggleSelectAll(this.checked)" style="width:14px;height:14px;cursor:pointer;accent-color:var(--accent)" />
122
+ Select all
123
+ </label>
124
+ <button class="btn btn-sm" id="highlight-only-btn">Flagged only</button>
125
+ </div>
126
+
127
+ <div id="batch-bar" class="batch-bar">
128
+ <span id="batch-count" class="batch-bar-count"></span>
129
+ <button class="btn btn-sm btn-approve" onclick="batchAction('approve')">Approve selected</button>
130
+ <button class="btn btn-sm btn-reject" onclick="batchAction('reject')">Reject selected</button>
131
+ <button class="btn btn-sm" onclick="clearBatchSelection()">Clear</button>
134
132
  </div>
135
133
 
136
134
  <div id="review-kbd-hints" style="font-size:var(--text-xs);color:var(--muted);margin-bottom:12px;display:none;gap:16px;flex-wrap:wrap">
@@ -224,7 +222,7 @@ ${TASK_UI_STYLES}
224
222
  <div style="padding:20px;color:var(--muted)">Loading...</div>
225
223
  </div>
226
224
  <div class="split-reader" id="hooks-reader">
227
- <div class="reader-empty">Select a hook config to view its contents.</div>
225
+ <div class="reader-empty">Select a hook config to view its contents.<br/><span style="font-size:var(--text-sm);color:var(--muted);margin-top:8px;display:inline-block">Per-project hooks can also be configured in Settings &gt; [project name].</span></div>
228
226
  </div>
229
227
  </div>
230
228
  </div>
@@ -321,7 +319,7 @@ ${TASK_UI_STYLES}
321
319
  </div>
322
320
 
323
321
  <script${nonceAttr}>
324
- ${renderWebUiScript(h(authToken || ""))}
322
+ ${renderWebUiScript(authToken || "")}
325
323
  </script>
326
324
  <script${nonceAttr}>
327
325
  ${renderGraphScript()}
@@ -330,7 +328,7 @@ ${renderGraphScript()}
330
328
  ${renderReviewQueueEditSyncScript()}
331
329
  </script>
332
330
  <script${nonceAttr}>
333
- ${renderSharedWebUiHelpers(h(authToken || ""))}
331
+ ${renderSharedWebUiHelpers(authToken || "")}
334
332
  </script>
335
333
  <script${nonceAttr}>
336
334
  ${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
  };
@@ -604,6 +605,8 @@ export function renderTasksAndSettingsScript(authToken) {
604
605
  return fetch(url).then(function(r) { return r.json(); });
605
606
  }
606
607
 
608
+ var _taskViewMode = 'list';
609
+
607
610
  function priorityBadge(p) {
608
611
  if (!p) return '';
609
612
  var colors = { high: '#ef4444', medium: '#f59e0b', low: '#6b7280' };
@@ -611,6 +614,12 @@ export function renderTasksAndSettingsScript(authToken) {
611
614
  return '<span class="task-priority-badge task-priority-' + esc(p) + '">' + esc(p) + '</span>';
612
615
  }
613
616
 
617
+ function sessionBadge(sessionId) {
618
+ if (!sessionId) return '';
619
+ var short = sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
620
+ return '<span class="task-session-badge" title="Session ' + esc(sessionId) + '">' + esc(short) + '</span>';
621
+ }
622
+
614
623
  function statusChip(section, checked) {
615
624
  if (checked || section === 'Done') return '<span class="task-status-chip task-status-done" title="Done"><svg width="12" height="12" 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</span>';
616
625
  if (section === 'Active') return '<span class="task-status-chip task-status-active" title="In Progress">In Progress</span>';
@@ -683,6 +692,7 @@ export function renderTasksAndSettingsScript(authToken) {
683
692
  };
684
693
 
685
694
  window.toggleDoneSection = function(btn) {
695
+ if (!btn) return;
686
696
  var list = btn.nextElementSibling;
687
697
  var arrow = btn.querySelector('.task-toggle-arrow');
688
698
  if (!list) return;
@@ -712,10 +722,10 @@ export function renderTasksAndSettingsScript(authToken) {
712
722
  });
713
723
  var doneTasks = showDone ? [] : _allTasks.filter(function(t) {
714
724
  if (projectFilter && t.project !== projectFilter) return false;
715
- return t.section === 'Done';
725
+ return t.section === 'Done' || t.checked;
716
726
  });
717
727
 
718
- var activeCount = tasks.filter(function(t) { return t.section !== 'Done'; }).length;
728
+ var activeCount = tasks.filter(function(t) { return t.section !== 'Done' && !t.checked; }).length;
719
729
  var countEl = document.getElementById('tasks-count');
720
730
  if (countEl) countEl.textContent = activeCount + ' active' + (doneTasks.length ? ', ' + doneTasks.length + ' done' : '');
721
731
 
@@ -729,19 +739,26 @@ export function renderTasksAndSettingsScript(authToken) {
729
739
  // Group by priority within sections
730
740
  var priorityOrder = { high: 0, medium: 1, low: 2 };
731
741
  function sortByPriority(a, b) {
732
- var pa = priorityOrder[a.priority] !== undefined ? priorityOrder[a.priority] : 1;
733
- var pb = priorityOrder[b.priority] !== undefined ? priorityOrder[b.priority] : 1;
742
+ // Unchecked before checked
743
+ var aChecked = a.checked || a.section === 'Done' ? 1 : 0;
744
+ var bChecked = b.checked || b.section === 'Done' ? 1 : 0;
745
+ if (aChecked !== bChecked) return aChecked - bChecked;
746
+ // Pinned first
734
747
  if (a.pinned && !b.pinned) return -1;
735
748
  if (!a.pinned && b.pinned) return 1;
749
+ // Then by priority
750
+ var pa = priorityOrder[a.priority] !== undefined ? priorityOrder[a.priority] : 1;
751
+ var pb = priorityOrder[b.priority] !== undefined ? priorityOrder[b.priority] : 1;
736
752
  return pa - pb;
737
753
  }
738
754
 
739
- // Separate into priority groups
740
- var high = tasks.filter(function(t) { return t.priority === 'high' && t.section !== 'Done'; }).sort(sortByPriority);
741
- var medium = tasks.filter(function(t) { return t.priority === 'medium' && t.section !== 'Done'; }).sort(sortByPriority);
742
- var low = tasks.filter(function(t) { return t.priority === 'low' && t.section !== 'Done'; }).sort(sortByPriority);
743
- var noPriority = tasks.filter(function(t) { return !t.priority && t.section !== 'Done'; }).sort(sortByPriority);
744
- var doneVisible = tasks.filter(function(t) { return t.section === 'Done'; });
755
+ // Separate into priority groups (exclude checked tasks even if not in Done section)
756
+ function isActive(t) { return t.section !== 'Done' && !t.checked; }
757
+ var high = tasks.filter(function(t) { return t.priority === 'high' && isActive(t); }).sort(sortByPriority);
758
+ var medium = tasks.filter(function(t) { return t.priority === 'medium' && isActive(t); }).sort(sortByPriority);
759
+ var low = tasks.filter(function(t) { return t.priority === 'low' && isActive(t); }).sort(sortByPriority);
760
+ var noPriority = tasks.filter(function(t) { return !t.priority && isActive(t); }).sort(sortByPriority);
761
+ var doneVisible = tasks.filter(function(t) { return t.section === 'Done' || t.checked; });
745
762
 
746
763
  function renderTaskCard(t) {
747
764
  var borderClass = t.priority === 'high' ? ' task-card-high' : t.priority === 'medium' ? ' task-card-medium' : t.priority === 'low' ? ' task-card-low' : '';
@@ -753,16 +770,17 @@ export function renderTasksAndSettingsScript(authToken) {
753
770
  html += pinIndicator(t.pinned);
754
771
  html += githubBadge(t.githubIssue, t.githubUrl);
755
772
  html += priorityBadge(t.priority);
773
+ html += sessionBadge(t.sessionId);
756
774
  html += '</div>';
757
775
  html += '<div class="task-card-body">';
758
776
  html += '<span class="task-card-text">' + esc(t.line) + '</span>';
759
777
  if (t.context) html += '<span class="task-card-context">' + esc(t.context) + '</span>';
760
778
  html += '</div>';
761
779
  html += '<div class="task-card-actions">';
762
- if (t.section !== 'Done') {
763
- 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>';
780
+ if (t.section !== 'Done' && !t.checked) {
781
+ 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>';
764
782
  }
765
- 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>';
783
+ 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>';
766
784
  html += '</div>';
767
785
  html += '</div>';
768
786
  return html;
@@ -770,21 +788,46 @@ export function renderTasksAndSettingsScript(authToken) {
770
788
 
771
789
  var html = '';
772
790
 
773
- // Add task input at top
774
- var projects = projectFilter ? [projectFilter] : Array.from(new Set(_allTasks.map(function(t) { return t.project; }))).sort();
791
+ // Summary bar
792
+ var allActive = _allTasks.filter(function(t) { return t.section !== 'Done' && !t.checked; });
793
+ var highCount = allActive.filter(function(t) { return t.priority === 'high'; }).length;
794
+ var medCount = allActive.filter(function(t) { return t.priority === 'medium'; }).length;
795
+ var lowCount = allActive.filter(function(t) { return t.priority === 'low'; }).length;
796
+ var projectCounts = {};
797
+ allActive.forEach(function(t) { projectCounts[t.project] = (projectCounts[t.project] || 0) + 1; });
798
+ var topProjects = Object.keys(projectCounts).sort(function(a, b) { return projectCounts[b] - projectCounts[a]; }).slice(0, 3);
799
+ html += '<div class="task-summary-bar">';
800
+ html += '<span class="task-summary-total">' + allActive.length + ' active</span>';
801
+ if (highCount) html += '<span class="task-summary-pill task-summary-high">' + highCount + ' high</span>';
802
+ if (medCount) html += '<span class="task-summary-pill task-summary-medium">' + medCount + ' medium</span>';
803
+ if (lowCount) html += '<span class="task-summary-pill task-summary-low">' + lowCount + ' low</span>';
804
+ if (topProjects.length) {
805
+ html += '<span class="task-summary-projects">';
806
+ topProjects.forEach(function(p) { html += '<span class="task-summary-project">' + esc(p) + ' (' + projectCounts[p] + ')</span>'; });
807
+ html += '</span>';
808
+ }
809
+ html += '<span class="task-view-toggle" style="margin-left:auto">';
810
+ html += '<button class="task-view-btn' + (_taskViewMode === 'list' ? ' active' : '') + '" data-ts-action="setTaskView" data-mode="list" title="List view"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>';
811
+ html += '<button class="task-view-btn' + (_taskViewMode === 'compact' ? ' active' : '') + '" data-ts-action="setTaskView" data-mode="compact" title="Compact view"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><rect x="1" y="2" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="9" y="2" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="1" y="9" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="9" y="9" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.2"/></svg></button>';
812
+ html += '</span>';
813
+ html += '</div>';
814
+
815
+ // Add task input at top (only when a specific project is selected)
816
+ var projects = projectFilter ? [projectFilter] : [];
775
817
  projects.forEach(function(proj) {
776
818
  html += '<div class="task-add-bar">';
777
- 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, "\\\\'") + '\\')">';
778
- 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>';
819
+ 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) + '">';
820
+ 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>';
779
821
  html += '</div>';
780
822
  });
781
823
 
782
824
  // Priority sections
783
825
  function renderSection(title, items, icon) {
784
826
  if (!items.length) return '';
827
+ var gridClass = _taskViewMode === 'compact' ? 'task-card-grid task-card-grid-compact' : 'task-card-grid';
785
828
  var shtml = '<div class="task-priority-section">';
786
829
  shtml += '<div class="task-section-header"><span class="task-section-icon">' + icon + '</span> ' + title + ' <span class="task-section-count">' + items.length + '</span></div>';
787
- shtml += '<div class="task-card-grid">';
830
+ shtml += '<div class="' + gridClass + '">';
788
831
  items.forEach(function(t) { shtml += renderTaskCard(t); });
789
832
  shtml += '</div></div>';
790
833
  return shtml;
@@ -801,7 +844,7 @@ export function renderTasksAndSettingsScript(authToken) {
801
844
  var allDone = showDone ? doneVisible : doneTasks;
802
845
  if (allDone.length) {
803
846
  html += '<div class="task-done-section">';
804
- html += '<button class="task-done-toggle" onclick="toggleDoneSection(this)">';
847
+ html += '<button class="task-done-toggle" data-ts-action="toggleDoneSection">';
805
848
  html += '<span class="task-toggle-arrow">\u25B6</span> Completed <span class="task-section-count">' + allDone.length + '</span></button>';
806
849
  html += '<div class="task-done-list" style="display:none">';
807
850
  html += '<div class="task-card-grid">';
@@ -1221,7 +1264,12 @@ export function renderTasksAndSettingsScript(authToken) {
1221
1264
  var actionEl = target.closest('[data-ts-action]');
1222
1265
  if (!actionEl) return;
1223
1266
  var action = actionEl.getAttribute('data-ts-action');
1224
- if (action === 'setFindingSensitivity') { setFindingSensitivity(actionEl.getAttribute('data-level')); }
1267
+ if (action === 'toggleDoneSection') { toggleDoneSection(actionEl); }
1268
+ else if (action === 'completeTask') { completeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
1269
+ else if (action === 'removeTask') { removeTaskFromUi(actionEl.getAttribute('data-project'), actionEl.getAttribute('data-item')); }
1270
+ else if (action === 'addTask') { addTaskFromUi(actionEl.getAttribute('data-project')); }
1271
+ else if (action === 'setTaskView') { _taskViewMode = actionEl.getAttribute('data-mode') || 'list'; filterTasks(); }
1272
+ else if (action === 'setFindingSensitivity') { setFindingSensitivity(actionEl.getAttribute('data-level')); }
1225
1273
  else if (action === 'toggleAutoCapture') { setAutoCapture(actionEl.getAttribute('data-enabled') !== 'true'); }
1226
1274
  else if (action === 'setTaskMode') { setTaskMode(actionEl.getAttribute('data-mode')); }
1227
1275
  else if (action === 'setProactivity') { setProactivity(actionEl.getAttribute('data-level')); }
@@ -1258,6 +1306,15 @@ export function renderTasksAndSettingsScript(authToken) {
1258
1306
  }
1259
1307
  });
1260
1308
 
1309
+ // Keydown delegation for add-task inputs (Enter key)
1310
+ document.addEventListener('keydown', function(e) {
1311
+ var target = e.target;
1312
+ if (!target || !target.getAttribute) return;
1313
+ if (target.getAttribute('data-ts-action') === 'addTaskKeydown' && e.key === 'Enter') {
1314
+ addTaskFromUi(target.getAttribute('data-project'));
1315
+ }
1316
+ });
1317
+
1261
1318
  window.setFindingSensitivity = function(level) {
1262
1319
  var descriptions = {
1263
1320
  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, queueFilePath } 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;
@@ -508,7 +510,12 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
508
510
  res.end(JSON.stringify({ ok: false, error: `File not allowed: ${file}` }));
509
511
  return;
510
512
  }
511
- 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
+ }
512
519
  if (!fs.existsSync(filePath)) {
513
520
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
514
521
  res.end(JSON.stringify({ ok: false, error: `File not found: ${file}` }));
@@ -847,6 +854,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
847
854
  githubUrl: item.githubUrl,
848
855
  context: item.context,
849
856
  checked: item.checked,
857
+ sessionId: item.sessionId,
850
858
  });
851
859
  }
852
860
  }
@@ -1024,7 +1032,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
1024
1032
  if (!requireCsrf(res, parsed, csrfTokens, true))
1025
1033
  return;
1026
1034
  const value = String(parsed.value || "").trim().toLowerCase();
1027
- const valid = ["off", "manual", "auto"];
1035
+ const valid = VALID_TASK_MODES;
1028
1036
  if (!valid.includes(value)) {
1029
1037
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1030
1038
  res.end(JSON.stringify({ ok: false, error: `Invalid task mode: "${value}". Must be one of: ${valid.join(", ")}` }));
@@ -1161,7 +1169,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
1161
1169
  });
1162
1170
  const merged = mergeConfig(phrenPath, project);
1163
1171
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
1164
- res.end(JSON.stringify({ ok: true, config: merged }));
1172
+ res.end(JSON.stringify({ ok: true, config: merged, ...(registrationWarning ? { warning: registrationWarning } : {}) }));
1165
1173
  }
1166
1174
  catch (err) {
1167
1175
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
@@ -552,4 +552,106 @@ export const TASK_UI_STYLES = `
552
552
  transition: transform 0.2s;
553
553
  }
554
554
  .task-done-list { padding-top: 8px; }
555
+
556
+ /* ── Task Summary Bar ──────────────────────────── */
557
+ .task-summary-bar {
558
+ display: flex;
559
+ align-items: center;
560
+ gap: 10px;
561
+ padding: 10px 14px;
562
+ background: var(--surface-sunken, var(--surface));
563
+ border: 1px solid var(--border);
564
+ border-radius: var(--radius);
565
+ margin-bottom: 16px;
566
+ flex-wrap: wrap;
567
+ font-size: var(--text-sm);
568
+ }
569
+ .task-summary-total {
570
+ font-weight: 600;
571
+ color: var(--ink);
572
+ font-size: var(--text-base);
573
+ }
574
+ .task-summary-pill {
575
+ display: inline-flex;
576
+ align-items: center;
577
+ padding: 2px 8px;
578
+ border-radius: 999px;
579
+ font-size: 11px;
580
+ font-weight: 600;
581
+ }
582
+ .task-summary-high { background: #ef444422; color: #ef4444; }
583
+ .task-summary-medium { background: #f59e0b22; color: #f59e0b; }
584
+ .task-summary-low { background: #6b728022; color: #6b7280; }
585
+ .task-summary-projects {
586
+ display: flex;
587
+ gap: 6px;
588
+ align-items: center;
589
+ }
590
+ .task-summary-project {
591
+ font-size: 11px;
592
+ color: var(--muted);
593
+ padding: 1px 6px;
594
+ border: 1px solid var(--border);
595
+ border-radius: var(--radius-sm);
596
+ }
597
+
598
+ /* ── Task Session Badge ──────────────────────────── */
599
+ .task-session-badge {
600
+ display: inline-block;
601
+ padding: 1px 6px;
602
+ border-radius: var(--radius-sm);
603
+ font-size: 10px;
604
+ font-family: var(--mono, monospace);
605
+ background: var(--surface-sunken, var(--surface));
606
+ color: var(--muted);
607
+ border: 1px solid var(--border);
608
+ }
609
+
610
+ /* ── Task View Toggle ──────────────────────────── */
611
+ .task-view-toggle {
612
+ display: flex;
613
+ gap: 2px;
614
+ border: 1px solid var(--border);
615
+ border-radius: var(--radius-sm);
616
+ overflow: hidden;
617
+ }
618
+ .task-view-btn {
619
+ display: flex;
620
+ align-items: center;
621
+ justify-content: center;
622
+ width: 30px;
623
+ height: 28px;
624
+ background: var(--surface);
625
+ border: none;
626
+ color: var(--muted);
627
+ cursor: pointer;
628
+ transition: background 0.15s, color 0.15s;
629
+ }
630
+ .task-view-btn:hover { color: var(--ink); }
631
+ .task-view-btn.active {
632
+ background: var(--accent-dim);
633
+ color: var(--accent);
634
+ }
635
+
636
+ /* ── Task Compact Grid ──────────────────────────── */
637
+ .task-card-grid-compact {
638
+ display: grid !important;
639
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
640
+ gap: 8px;
641
+ }
642
+ .task-card-grid-compact .task-card {
643
+ padding: 10px 12px;
644
+ }
645
+ .task-card-grid-compact .task-card-body {
646
+ gap: 2px;
647
+ }
648
+ .task-card-grid-compact .task-card-text {
649
+ font-size: var(--text-sm);
650
+ display: -webkit-box;
651
+ -webkit-line-clamp: 2;
652
+ -webkit-box-orient: vertical;
653
+ overflow: hidden;
654
+ }
655
+ .task-card-grid-compact .task-card-context { display: none; }
656
+ .task-card-grid-compact .task-card-actions { margin-top: 6px; }
555
657
  `;
@@ -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) {
@@ -3,6 +3,7 @@ import * as path from "path";
3
3
  import { getProjectDirs } from "./shared.js";
4
4
  import { parseSkillFrontmatter } from "./link-skills.js";
5
5
  import { isSkillEnabled } from "./skill-state.js";
6
+ import { safeProjectPath } from "./utils.js";
6
7
  function normalizeCommand(raw, fallbackName) {
7
8
  const value = typeof raw === "string" && raw.trim() ? raw.trim() : `/${fallbackName}`;
8
9
  return value.startsWith("/") ? value : `/${value}`;
@@ -28,13 +29,35 @@ function collectSkills(phrenPath, root, sourceLabel, scopeType, sourceKind, seen
28
29
  if (!fs.existsSync(root))
29
30
  return [];
30
31
  const results = [];
32
+ const realRoot = (() => {
33
+ try {
34
+ return fs.realpathSync(root);
35
+ }
36
+ catch {
37
+ return path.resolve(root);
38
+ }
39
+ })();
31
40
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
32
41
  const isFolder = entry.isDirectory();
33
- const filePath = isFolder
42
+ const candidatePath = isFolder
34
43
  ? path.join(root, entry.name, "SKILL.md")
35
44
  : entry.name.endsWith(".md") ? path.join(root, entry.name) : null;
36
- if (!filePath || seen.has(filePath) || !fs.existsSync(filePath))
45
+ if (!candidatePath)
46
+ continue;
47
+ const safeCandidate = safeProjectPath(root, path.relative(root, candidatePath));
48
+ if (!safeCandidate || seen.has(safeCandidate) || !fs.existsSync(safeCandidate))
37
49
  continue;
50
+ try {
51
+ if (fs.lstatSync(safeCandidate).isSymbolicLink())
52
+ continue;
53
+ const realCandidate = fs.realpathSync(safeCandidate);
54
+ if (realCandidate !== realRoot && !realCandidate.startsWith(realRoot + path.sep))
55
+ continue;
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ const filePath = safeCandidate;
38
61
  seen.add(filePath);
39
62
  const name = isFolder ? entry.name : entry.name.replace(/\.md$/, "");
40
63
  const { frontmatter } = parseSkillFrontmatter(fs.readFileSync(filePath, "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/cli",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "Knowledge layer for AI agents. Phren learns and recalls.",
5
5
  "type": "module",
6
6
  "bin": {