@phren/cli 0.0.17 → 0.0.18

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.
@@ -86,42 +86,22 @@ ${TASK_UI_STYLES}
86
86
 
87
87
  <!-- ── Review Tab ────────────────────────────────────────── -->
88
88
  <div id="tab-review" class="tab-content">
89
- <div class="card" style="margin-bottom:16px">
90
- <div class="card-header"><h2>Sync State</h2></div>
91
- <div class="card-body">
92
- <div id="sync-state-summary" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;font-size:var(--text-base)">
93
- <div><strong>Auto-save</strong><div class="text-muted">${h(sync.autoSaveStatus || "n/a")}</div></div>
94
- <div><strong>Last pull</strong><div class="text-muted">${h(sync.lastPullStatus || "n/a")} ${h(sync.lastPullAt || "")}</div></div>
95
- <div><strong>Last push</strong><div class="text-muted">${h(sync.lastPushStatus || "n/a")} ${h(sync.lastPushAt || "")}</div></div>
96
- <div><strong>Unsynced commits</strong><div class="text-muted">${h(String(sync.unsyncedCommits || 0))}</div></div>
97
- </div>
98
- </div>
99
- </div>
100
- <details class="review-help" style="margin-bottom:16px">
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>
103
- </details>
104
-
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>
106
-
107
- <div id="review-summary-banner" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;align-items:center"></div>
108
-
109
- <div class="review-filters" id="review-filters" style="display:none">
89
+ <div class="review-toolbar" id="review-filters" style="display:none">
110
90
  <select id="review-filter-project">
111
91
  <option value="">All projects</option>
112
92
  </select>
113
- <select id="review-filter-machine">
114
- <option value="">All machines</option>
115
- </select>
116
- <select id="review-filter-model">
117
- <option value="">All models</option>
118
- </select>
119
- <span id="review-filter-count" class="text-muted" style="font-size:var(--text-sm);margin-left:8px"></span>
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">
93
+ <label class="review-flagged-toggle" id="review-flagged-toggle">
94
+ <input type="checkbox" id="highlight-only-btn" />
95
+ <span>Flagged only</span>
96
+ </label>
97
+ <span id="review-filter-count" class="text-muted" style="font-size:var(--text-sm);margin-left:auto"></span>
98
+ <label id="review-select-all" style="display:none;align-items:center;gap:6px;font-size:var(--text-sm);color:var(--muted);cursor:pointer;user-select:none">
121
99
  <input type="checkbox" onchange="toggleSelectAll(this.checked)" style="width:14px;height:14px;cursor:pointer;accent-color:var(--accent)" />
122
100
  Select all
123
101
  </label>
124
- <button class="btn btn-sm" id="highlight-only-btn">Flagged only</button>
102
+ <span id="review-sync-status" class="review-sync-dot" title="Sync status">
103
+ <span class="review-sync-indicator" id="review-sync-indicator"></span>
104
+ </span>
125
105
  </div>
126
106
 
127
107
  <div id="batch-bar" class="batch-bar">
@@ -131,10 +111,6 @@ ${TASK_UI_STYLES}
131
111
  <button class="btn btn-sm" onclick="clearBatchSelection()">Clear</button>
132
112
  </div>
133
113
 
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">
135
- <span><kbd>j</kbd>/<kbd>k</kbd> navigate</span>
136
- </div>
137
-
138
114
  <div class="review-cards" id="review-cards-list">
139
115
  <div class="review-cards-loading" style="text-align:center;padding:40px;color:var(--muted)">Loading...</div>
140
116
  </div>
@@ -156,9 +132,10 @@ ${TASK_UI_STYLES}
156
132
  <div style="max-width:720px;margin:0 auto">
157
133
  <div style="display:flex;gap:8px;margin-bottom:16px">
158
134
  <input type="text" id="search-query" placeholder="Search fragments, findings, tasks..." style="flex:1;border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px 12px;background:var(--surface);color:var(--ink);font-size:var(--text-base);font-family:var(--font);outline:none" />
159
- <select id="search-project-filter" style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 10px;background:var(--surface);color:var(--ink);font-size:var(--text-sm)">
160
- <option value="">All projects</option>
161
- </select>
135
+ <div id="search-project-wrap" style="position:relative">
136
+ <button id="search-project-btn" type="button" onclick="window._phrenToggleProjectDropdown()" style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 10px;background:var(--surface);color:var(--ink);font-size:var(--text-sm);cursor:pointer;font-family:var(--font);min-width:120px;text-align:left;white-space:nowrap">All projects</button>
137
+ <div id="search-project-dropdown" style="display:none;position:absolute;top:100%;left:0;z-index:50;margin-top:4px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:0 4px 12px rgba(0,0,0,.15);max-height:240px;overflow-y:auto;min-width:160px"></div>
138
+ </div>
162
139
  <select id="search-type-filter" style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 10px;background:var(--surface);color:var(--ink);font-size:var(--text-sm)">
163
140
  <option value="">All types</option>
164
141
  <option value="finding">Findings</option>
@@ -275,6 +252,12 @@ ${TASK_UI_STYLES}
275
252
  <div id="settings-scope-note" style="font-size:var(--text-sm);color:var(--muted)">Showing global settings. Select a project to view and edit per-project overrides.</div>
276
253
  </div>
277
254
  </section>
255
+ <section id="settings-project-info-section" class="settings-section" style="display:none;border-top:3px solid color-mix(in srgb, var(--accent) 45%, var(--border))">
256
+ <div class="settings-section-header">Project Info</div>
257
+ <div class="settings-section-body">
258
+ <div id="settings-project-info" style="color:var(--muted)"></div>
259
+ </div>
260
+ </section>
278
261
  <section class="settings-section settings-section-findings">
279
262
  <div class="settings-section-header">Findings</div>
280
263
  <div class="settings-section-body">
@@ -974,6 +974,35 @@ export function renderTasksAndSettingsScript(authToken) {
974
974
 
975
975
  var isProject = Boolean(selectedProject);
976
976
 
977
+ // Render project info section
978
+ var infoSection = document.getElementById('settings-project-info-section');
979
+ var infoEl = document.getElementById('settings-project-info');
980
+ if (infoSection && infoEl) {
981
+ if (isProject && data.projectInfo) {
982
+ var pi = data.projectInfo;
983
+ infoSection.style.display = '';
984
+ var infoHtml = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;font-size:var(--text-sm)">';
985
+ infoHtml += '<div><strong style="color:var(--ink)">Disk path</strong><div class="text-muted" style="font-family:var(--mono);font-size:var(--text-xs);word-break:break-all">' + esc(pi.diskPath) + '</div></div>';
986
+ infoHtml += '<div><strong style="color:var(--ink)">Ownership</strong><div class="text-muted">' + esc(pi.ownership) + '</div></div>';
987
+ infoHtml += '<div><strong style="color:var(--ink)">Config file</strong><div class="text-muted" style="font-family:var(--mono);font-size:var(--text-xs);word-break:break-all">' + esc(pi.configFile) + '</div>' + (pi.configExists ? '<span style="color:var(--green);font-size:var(--text-xs)">exists</span>' : '<span style="color:var(--muted);font-size:var(--text-xs)">not created</span>') + '</div>';
988
+ infoHtml += '<div><strong style="color:var(--ink)">Findings</strong><div class="text-muted">' + pi.findingCount + ' entries</div></div>';
989
+ infoHtml += '<div><strong style="color:var(--ink)">Tasks</strong><div class="text-muted">' + pi.taskCount + ' in queue</div></div>';
990
+ infoHtml += '</div>';
991
+ var files = [];
992
+ if (pi.hasFindings) files.push('FINDINGS.md');
993
+ if (pi.hasTasks) files.push('tasks.md');
994
+ if (pi.hasSummary) files.push('summary.md');
995
+ if (pi.hasClaudeMd) files.push('CLAUDE.md');
996
+ if (files.length) {
997
+ infoHtml += '<div style="margin-top:10px;font-size:var(--text-xs);color:var(--muted)">Files: ' + files.map(function(f) { return '<span class="badge" style="margin-right:4px">' + esc(f) + '</span>'; }).join('') + '</div>';
998
+ }
999
+ infoEl.innerHTML = infoHtml;
1000
+ } else {
1001
+ infoSection.style.display = 'none';
1002
+ infoEl.innerHTML = '';
1003
+ }
1004
+ }
1005
+
977
1006
  function sourceBadge(isOverride) {
978
1007
  if (!isProject) return '';
979
1008
  return isOverride
@@ -1189,21 +1218,21 @@ export function renderTasksAndSettingsScript(authToken) {
1189
1218
  }
1190
1219
  list.innerHTML = '<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">' +
1191
1220
  '<thead><tr style="border-bottom:1px solid var(--border);text-align:left">' +
1192
- '<th style="padding:8px">Session</th><th style="padding:8px">Project</th><th style="padding:8px">Date</th>' +
1193
- '<th style="padding:8px">Duration</th><th style="padding:8px">Findings</th><th style="padding:8px">Status</th></tr></thead><tbody>' +
1221
+ '<th style="padding:8px">Session</th><th style="padding:8px">Project</th><th style="padding:8px">Started</th>' +
1222
+ '<th style="padding:8px">Ended</th><th style="padding:8px">Duration</th><th style="padding:8px">Findings</th></tr></thead><tbody>' +
1194
1223
  sessions.map(function(s) {
1195
1224
  var id = s.sessionId.slice(0, 8);
1196
- var date = (s.startedAt || '').slice(0, 16).replace('T', ' ');
1225
+ var startDate = (s.startedAt || '').slice(0, 16).replace('T', ' ');
1226
+ var endDate = s.endedAt ? (s.endedAt || '').slice(0, 16).replace('T', ' ') : '<span style="color:var(--green)">active</span>';
1197
1227
  var dur = s.durationMins != null ? s.durationMins + 'm' : '—';
1198
- var status = s.status === 'active' ? '<span style="color:var(--green)">● active</span>' : 'ended';
1199
1228
  var summarySnip = s.summary ? '<div class="text-muted" style="font-size:var(--text-xs);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(s.summary.slice(0, 80)) + '</div>' : '';
1200
1229
  return '<tr style="border-bottom:1px solid var(--border);cursor:pointer" data-ts-action="showSessionDetail" data-session-id="' + esc(s.sessionId) + '">' +
1201
1230
  '<td style="padding:8px;font-family:monospace">' + esc(id) + summarySnip + '</td>' +
1202
1231
  '<td style="padding:8px">' + esc(s.project || '—') + '</td>' +
1203
- '<td style="padding:8px">' + esc(date) + '</td>' +
1232
+ '<td style="padding:8px">' + esc(startDate) + '</td>' +
1233
+ '<td style="padding:8px">' + endDate + '</td>' +
1204
1234
  '<td style="padding:8px">' + esc(dur) + '</td>' +
1205
- '<td style="padding:8px">' + (s.findingsAdded || 0) + '</td>' +
1206
- '<td style="padding:8px">' + status + '</td></tr>';
1235
+ '<td style="padding:8px">' + (s.findingsAdded || 0) + '</td></tr>';
1207
1236
  }).join('') + '</tbody></table>';
1208
1237
  }
1209
1238
 
@@ -1359,94 +1388,161 @@ export function renderSearchScript(authToken) {
1359
1388
 
1360
1389
  var esc = window._phrenEsc;
1361
1390
 
1391
+ function relativeDate(iso) {
1392
+ if (!iso) return '';
1393
+ var d = new Date(iso);
1394
+ var now = new Date();
1395
+ var diff = now.getTime() - d.getTime();
1396
+ var days = Math.floor(diff / 86400000);
1397
+ if (days < 1) return 'today';
1398
+ if (days === 1) return '1d ago';
1399
+ if (days < 7) return days + 'd ago';
1400
+ if (days < 30) return Math.floor(days / 7) + 'w ago';
1401
+ var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1402
+ return months[d.getMonth()] + ' ' + d.getDate();
1403
+ }
1404
+
1405
+ // Multi-select project filter
1406
+ var _selectedProjects = [];
1407
+ function getSelectedProjects() { return _selectedProjects.slice(); }
1408
+ function toggleProjectFilter(name) {
1409
+ var idx = _selectedProjects.indexOf(name);
1410
+ if (idx === -1) _selectedProjects.push(name);
1411
+ else _selectedProjects.splice(idx, 1);
1412
+ renderProjectFilterLabel();
1413
+ renderProjectFilterChecks();
1414
+ }
1415
+ function renderProjectFilterLabel() {
1416
+ var btn = document.getElementById('search-project-btn');
1417
+ if (!btn) return;
1418
+ if (!_selectedProjects.length) btn.textContent = 'All projects';
1419
+ else if (_selectedProjects.length === 1) btn.textContent = _selectedProjects[0];
1420
+ else btn.textContent = _selectedProjects.length + ' projects';
1421
+ }
1422
+ function renderProjectFilterChecks() {
1423
+ var items = document.querySelectorAll('#search-project-dropdown input[type=checkbox]');
1424
+ for (var i = 0; i < items.length; i++) {
1425
+ items[i].checked = _selectedProjects.indexOf(items[i].value) !== -1;
1426
+ }
1427
+ }
1428
+ window._phrenToggleProjectDropdown = function() {
1429
+ var dd = document.getElementById('search-project-dropdown');
1430
+ if (dd) dd.style.display = dd.style.display === 'none' ? 'block' : 'none';
1431
+ };
1432
+ document.addEventListener('click', function(e) {
1433
+ var wrap = document.getElementById('search-project-wrap');
1434
+ var dd = document.getElementById('search-project-dropdown');
1435
+ if (wrap && dd && !wrap.contains(e.target)) dd.style.display = 'none';
1436
+ });
1437
+
1438
+ function parseResults(lines) {
1439
+ var cards = [];
1440
+ var current = null;
1441
+ for (var i = 0; i < lines.length; i++) {
1442
+ var line = lines[i];
1443
+ if (!line.trim()) continue;
1444
+ if (line.startsWith('[') && line.indexOf(']') > 0) {
1445
+ if (current) cards.push(current);
1446
+ var bracket = line.indexOf(']');
1447
+ var source = line.slice(1, bracket);
1448
+ var meta = line.slice(bracket + 1).trim();
1449
+ current = { source: source, meta: meta, snippets: [] };
1450
+ } else if (line === '(keyword fallback)') {
1451
+ // skip
1452
+ } else if (current) {
1453
+ current.snippets.push(line);
1454
+ } else {
1455
+ cards.push({ source: '', meta: '', snippets: [line] });
1456
+ }
1457
+ }
1458
+ if (current) cards.push(current);
1459
+ return cards;
1460
+ }
1461
+ function renderCards(cards) {
1462
+ var html = '';
1463
+ for (var c = 0; c < cards.length; c++) {
1464
+ var card = cards[c];
1465
+ html += '<div class="card" style="margin-bottom:8px">';
1466
+ html += '<div class="card-header" style="padding:10px 14px;display:flex;align-items:center">';
1467
+ if (card.source) {
1468
+ html += '<span style="font-weight:500;font-size:var(--text-sm)">' + esc(card.source) + '</span>';
1469
+ }
1470
+ if (card.meta) {
1471
+ html += '<span class="text-muted" style="font-size:var(--text-xs);margin-left:8px">' + esc(card.meta) + '</span>';
1472
+ }
1473
+ html += '</div>';
1474
+ if (card.snippets.length) {
1475
+ html += '<div class="card-body" style="padding:10px 14px;font-size:var(--text-sm);white-space:pre-wrap;color:var(--ink-secondary)">';
1476
+ html += esc(card.snippets.join('\\n'));
1477
+ html += '</div>';
1478
+ }
1479
+ html += '</div>';
1480
+ }
1481
+ return html;
1482
+ }
1483
+
1362
1484
  function doSearch() {
1363
1485
  var q = document.getElementById('search-query').value.trim();
1364
1486
  if (!q) return;
1365
- var project = document.getElementById('search-project-filter').value;
1487
+ var projects = getSelectedProjects();
1366
1488
  var type = document.getElementById('search-type-filter').value;
1367
1489
  var statusEl = document.getElementById('search-status');
1368
1490
  var resultsEl = document.getElementById('search-results');
1369
1491
  statusEl.textContent = 'Searching...';
1370
1492
  resultsEl.innerHTML = '';
1371
1493
 
1372
- var url = '/api/search?q=' + encodeURIComponent(q) + '&limit=20';
1373
- if (project) url += '&project=' + encodeURIComponent(project);
1374
- if (type) url += '&type=' + encodeURIComponent(type);
1375
-
1376
- fetch(searchAuthUrl(url)).then(function(r) { return r.json(); }).then(function(data) {
1377
- if (!data.ok) {
1378
- statusEl.textContent = '';
1379
- resultsEl.innerHTML = '<div style="padding:24px;color:var(--muted);text-align:center">' + esc(data.error || 'Search failed') + '</div>';
1380
- return;
1381
- }
1382
- var lines = data.results || [];
1383
- if (!lines.length) {
1384
- statusEl.textContent = 'No results.';
1385
- resultsEl.innerHTML = '<div style="padding:40px;color:var(--muted);text-align:center">No results for \\u201c' + esc(q) + '\\u201d</div>';
1386
- return;
1494
+ var fetches = [];
1495
+ if (projects.length <= 1) {
1496
+ var url = '/api/search?q=' + encodeURIComponent(q) + '&limit=20';
1497
+ if (projects.length === 1) url += '&project=' + encodeURIComponent(projects[0]);
1498
+ if (type) url += '&type=' + encodeURIComponent(type);
1499
+ fetches.push(fetch(searchAuthUrl(url)).then(function(r) { return r.json(); }));
1500
+ } else {
1501
+ for (var pi = 0; pi < projects.length; pi++) {
1502
+ (function(proj) {
1503
+ var purl = '/api/search?q=' + encodeURIComponent(q) + '&limit=20&project=' + encodeURIComponent(proj);
1504
+ if (type) purl += '&type=' + encodeURIComponent(type);
1505
+ fetches.push(fetch(searchAuthUrl(purl)).then(function(r) { return r.json(); }));
1506
+ })(projects[pi]);
1387
1507
  }
1508
+ }
1388
1509
 
1389
- // Parse lines into result cards: lines starting with [ are headers, others are content
1390
- var cards = [];
1391
- var current = null;
1392
- for (var i = 0; i < lines.length; i++) {
1393
- var line = lines[i];
1394
- if (!line.trim()) continue;
1395
- if (line.startsWith('[') && line.indexOf(']') > 0) {
1396
- if (current) cards.push(current);
1397
- var bracket = line.indexOf(']');
1398
- var source = line.slice(1, bracket);
1399
- var meta = line.slice(bracket + 1).trim();
1400
- current = { source: source, meta: meta, snippets: [] };
1401
- } else if (line === '(keyword fallback)') {
1402
- // skip
1403
- } else if (current) {
1404
- current.snippets.push(line);
1405
- } else {
1406
- // orphan line, show as standalone
1407
- cards.push({ source: '', meta: '', snippets: [line] });
1408
- }
1510
+ Promise.all(fetches).then(function(results) {
1511
+ var allCards = [];
1512
+ var hasError = false;
1513
+ for (var ri = 0; ri < results.length; ri++) {
1514
+ if (!results[ri].ok) { hasError = true; continue; }
1515
+ var parsed = parseResults(results[ri].results || []);
1516
+ allCards = allCards.concat(parsed);
1409
1517
  }
1410
- if (current) cards.push(current);
1411
-
1412
- statusEl.textContent = cards.length + ' result(s)';
1413
- var html = '';
1414
- for (var c = 0; c < cards.length; c++) {
1415
- var card = cards[c];
1416
- html += '<div class="card" style="margin-bottom:8px">';
1417
- html += '<div class="card-header" style="padding:10px 14px">';
1418
- if (card.source) {
1419
- html += '<span style="font-weight:500;font-size:var(--text-sm)">' + esc(card.source) + '</span>';
1420
- }
1421
- if (card.meta) {
1422
- html += '<span class="text-muted" style="font-size:var(--text-xs);margin-left:8px">' + esc(card.meta) + '</span>';
1423
- }
1424
- html += '</div>';
1425
- if (card.snippets.length) {
1426
- html += '<div class="card-body" style="padding:10px 14px;font-size:var(--text-sm);white-space:pre-wrap;color:var(--ink-secondary)">';
1427
- html += esc(card.snippets.join('\\n'));
1428
- html += '</div>';
1429
- }
1430
- html += '</div>';
1518
+ if (!allCards.length) {
1519
+ statusEl.textContent = hasError ? 'Search error.' : 'No results.';
1520
+ resultsEl.innerHTML = '<div style="padding:40px;color:var(--muted);text-align:center">' + (hasError ? 'Search failed' : 'No results for \\u201c' + esc(q) + '\\u201d') + '</div>';
1521
+ return;
1431
1522
  }
1432
- resultsEl.innerHTML = html;
1523
+ statusEl.textContent = allCards.length + ' result(s)';
1524
+ resultsEl.innerHTML = renderCards(allCards);
1433
1525
  }).catch(function(err) {
1434
1526
  statusEl.textContent = '';
1435
1527
  resultsEl.innerHTML = '<div style="padding:24px;color:var(--muted);text-align:center">Search error: ' + esc(String(err)) + '</div>';
1436
1528
  });
1437
1529
  }
1438
1530
 
1439
- // Populate project filter
1531
+ // Populate project filter dropdown
1440
1532
  function loadSearchProjects() {
1441
1533
  if (_searchProjectsLoaded) return;
1442
1534
  _searchProjectsLoaded = true;
1443
1535
  fetch(searchAuthUrl('/api/projects')).then(function(r) { return r.json(); }).then(function(data) {
1444
1536
  if (!data.ok) return;
1445
- var sel = document.getElementById('search-project-filter');
1537
+ var dd = document.getElementById('search-project-dropdown');
1538
+ if (!dd) return;
1539
+ var html = '';
1446
1540
  (data.projects || []).forEach(function(p) {
1447
- var opt = document.createElement('option');
1448
- opt.value = p.name; opt.textContent = p.name;
1449
- sel.appendChild(opt);
1541
+ html += '<label class="search-ms-item" style="display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:var(--text-sm);white-space:nowrap"><input type="checkbox" value="' + esc(p.name) + '" style="accent-color:var(--accent);cursor:pointer" /><span>' + esc(p.name) + '</span></label>';
1542
+ });
1543
+ dd.innerHTML = html;
1544
+ dd.querySelectorAll('input[type=checkbox]').forEach(function(cb) {
1545
+ cb.addEventListener('change', function() { toggleProjectFilter(cb.value); });
1450
1546
  });
1451
1547
  }).catch(function() {});
1452
1548
  }
@@ -1495,13 +1591,9 @@ export function renderEventWiringScript() {
1495
1591
  // --- Review filters ---
1496
1592
  var reviewFilterProject = document.getElementById('review-filter-project');
1497
1593
  if (reviewFilterProject) reviewFilterProject.addEventListener('change', function() { filterReviewCards(); });
1498
- var reviewFilterMachine = document.getElementById('review-filter-machine');
1499
- if (reviewFilterMachine) reviewFilterMachine.addEventListener('change', function() { filterReviewCards(); });
1500
- var reviewFilterModel = document.getElementById('review-filter-model');
1501
- if (reviewFilterModel) reviewFilterModel.addEventListener('change', function() { filterReviewCards(); });
1502
1594
 
1503
1595
  var highlightBtn = document.getElementById('highlight-only-btn');
1504
- if (highlightBtn) highlightBtn.addEventListener('click', function() { toggleHighlightOnly(this); });
1596
+ if (highlightBtn) highlightBtn.addEventListener('change', function() { filterReviewCards(); });
1505
1597
 
1506
1598
  // --- Graph controls ---
1507
1599
  var graphZoomIn = document.getElementById('graph-zoom-in');
@@ -1521,6 +1613,20 @@ export function renderEventWiringScript() {
1521
1613
  var sessionsFilterProject = document.getElementById('sessions-filter-project');
1522
1614
  if (sessionsFilterProject) sessionsFilterProject.addEventListener('change', function() { loadSessions(); });
1523
1615
 
1616
+ // --- Mascot click animation ---
1617
+ var mascotSvg = document.querySelector('.header-brand svg');
1618
+ if (mascotSvg) {
1619
+ mascotSvg.addEventListener('click', function() {
1620
+ mascotSvg.classList.remove('popped');
1621
+ void mascotSvg.offsetWidth;
1622
+ mascotSvg.classList.add('popped');
1623
+ mascotSvg.addEventListener('animationend', function handler() {
1624
+ mascotSvg.classList.remove('popped');
1625
+ mascotSvg.removeEventListener('animationend', handler);
1626
+ });
1627
+ });
1628
+ }
1629
+
1524
1630
  // --- Command palette ---
1525
1631
  var cmdpal = document.getElementById('cmdpal');
1526
1632
  if (cmdpal) cmdpal.addEventListener('click', function(e) { closeCmdPal(e); });
@@ -13,7 +13,7 @@ import { buildGraph, collectProjectsForUI, collectSkillsForUI, getHooksData, isA
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
15
  import { getWorkflowPolicy, updateWorkflowPolicy, mergeConfig, getRetentionPolicy, getProjectConfigOverrides, VALID_TASK_MODES } from "./governance-policy.js";
16
- import { updateProjectConfigOverrides } from "./project-config.js";
16
+ import { readProjectConfig, updateProjectConfigOverrides } from "./project-config.js";
17
17
  import { findSkill } from "./skill-registry.js";
18
18
  import { setSkillEnabledAndSync } from "./skill-files.js";
19
19
  import { listAllSessions, getSessionArtifacts } from "./mcp-session.js";
@@ -803,8 +803,31 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
803
803
  try {
804
804
  const { runSearch } = await import("./cli-search.js");
805
805
  const result = await runSearch({ query, limit: Math.min(searchLimit, 50), project: searchProject, type: searchType }, phrenPath, profile || "");
806
+ // Build file date map from source headers like [project/filename]
807
+ const fileDates = {};
808
+ for (const line of result.lines) {
809
+ const srcMatch = line.match(/^\[([^\]]+)\]\s/);
810
+ if (srcMatch) {
811
+ const sourceKey = srcMatch[1];
812
+ if (fileDates[sourceKey])
813
+ continue;
814
+ const slashIdx = sourceKey.indexOf("/");
815
+ if (slashIdx > 0) {
816
+ const proj = sourceKey.slice(0, slashIdx);
817
+ const file = sourceKey.slice(slashIdx + 1);
818
+ try {
819
+ const filePath = path.join(phrenPath, proj, file);
820
+ if (fs.existsSync(filePath)) {
821
+ const stat = fs.statSync(filePath);
822
+ fileDates[sourceKey] = stat.mtime.toISOString();
823
+ }
824
+ }
825
+ catch { /* skip */ }
826
+ }
827
+ }
828
+ }
806
829
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
807
- res.end(JSON.stringify({ ok: true, query, results: result.lines }));
830
+ res.end(JSON.stringify({ ok: true, query, results: result.lines, fileDates }));
808
831
  }
809
832
  catch (err) {
810
833
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
@@ -977,6 +1000,37 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
977
1000
  const settingsProject = String(qs.project || "");
978
1001
  const merged = settingsProject && isValidProjectName(settingsProject) ? mergeConfig(phrenPath, settingsProject) : null;
979
1002
  const overrides = settingsProject && isValidProjectName(settingsProject) ? getProjectConfigOverrides(phrenPath, settingsProject) : null;
1003
+ // Build project info when a specific project is selected
1004
+ let projectInfo = null;
1005
+ if (settingsProject && isValidProjectName(settingsProject)) {
1006
+ const projectDir = path.join(phrenPath, settingsProject);
1007
+ const configFile = path.join(projectDir, "phren.project.yaml");
1008
+ const projConfig = readProjectConfig(phrenPath, settingsProject);
1009
+ const findingsPath = path.join(projectDir, "FINDINGS.md");
1010
+ const taskPath = path.join(projectDir, "tasks.md");
1011
+ let findingCount = 0;
1012
+ if (fs.existsSync(findingsPath)) {
1013
+ findingCount = (fs.readFileSync(findingsPath, "utf8").match(/^- /gm) || []).length;
1014
+ }
1015
+ let taskCount = 0;
1016
+ if (fs.existsSync(taskPath)) {
1017
+ const queueMatch = fs.readFileSync(taskPath, "utf8").match(/## Queue[\s\S]*?(?=## |$)/);
1018
+ if (queueMatch)
1019
+ taskCount = (queueMatch[0].match(/^- /gm) || []).length;
1020
+ }
1021
+ projectInfo = {
1022
+ diskPath: projConfig.sourcePath || projectDir,
1023
+ ownership: projConfig.ownership || "default",
1024
+ configFile,
1025
+ configExists: fs.existsSync(configFile),
1026
+ hasFindings: fs.existsSync(findingsPath),
1027
+ hasTasks: fs.existsSync(taskPath),
1028
+ hasSummary: fs.existsSync(path.join(projectDir, "summary.md")),
1029
+ hasClaudeMd: fs.existsSync(path.join(projectDir, "CLAUDE.md")),
1030
+ findingCount,
1031
+ taskCount,
1032
+ };
1033
+ }
980
1034
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
981
1035
  res.end(JSON.stringify({
982
1036
  ok: true,
@@ -994,6 +1048,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
994
1048
  workflowPolicy,
995
1049
  merged,
996
1050
  overrides,
1051
+ projectInfo,
997
1052
  }));
998
1053
  }
999
1054
  catch (err) {
@@ -25,7 +25,7 @@ export const EXTRA_ENTITY_PATTERNS = [
25
25
  { re: /\b\d{4}[-/]\d{2}[-/]\d{2}\b/g, label: "date" },
26
26
  ];
27
27
  /** Union of all directory names reserved by phren infrastructure — not valid project names. */
28
- export const RESERVED_PROJECT_DIR_NAMES = new Set(["global", ".runtime", ".sessions", ".governance", "profiles", "templates"]);
28
+ export const RESERVED_PROJECT_DIR_NAMES = new Set(["global", ".runtime", ".sessions", ".config", "profiles", "templates"]);
29
29
  // Default timeout for execFileSync calls (30s for most operations, 10s for quick probes like `which`)
30
30
  export const EXEC_TIMEOUT_MS = 30_000;
31
31
  export const EXEC_TIMEOUT_QUICK_MS = 10_000;
@@ -108,7 +108,7 @@ function hasRootManifest(candidate) {
108
108
  }
109
109
  function hasInstallMarkers(candidate) {
110
110
  return fs.existsSync(path.join(candidate, "machines.yaml"))
111
- || fs.existsSync(path.join(candidate, ".governance"))
111
+ || fs.existsSync(path.join(candidate, ".config"))
112
112
  || fs.existsSync(path.join(candidate, "global"));
113
113
  }
114
114
  function isPhrenRootCandidate(candidate) {
@@ -460,7 +460,7 @@ export function computePhrenLiveStateToken(phrenPath) {
460
460
  pushDirTokens(parts, path.join(phrenPath, "profiles"));
461
461
  }
462
462
  pushDirTokens(parts, path.join(phrenPath, "global", "skills"));
463
- pushFileToken(parts, path.join(phrenPath, ".governance", "access-control.json"));
463
+ pushFileToken(parts, path.join(phrenPath, ".config", "access-control.json"));
464
464
  pushFileToken(parts, rootManifestPath(phrenPath));
465
465
  pushFileToken(parts, runtimeHealthFile(phrenPath));
466
466
  pushFileToken(parts, runtimeFile(phrenPath, "audit.log"));
@@ -20,6 +20,37 @@ function parseProactivityLevel(raw) {
20
20
  function resolveProactivityPhrenPath(explicitPhrenPath) {
21
21
  return explicitPhrenPath ?? findPhrenPath();
22
22
  }
23
+ /** Read per-user preferences from ~/.phren/.users/<actor>/preferences.json. Actor from PHREN_ACTOR env var. */
24
+ export function readUserPreferences(explicitPhrenPath) {
25
+ const phrenPath = resolveProactivityPhrenPath(explicitPhrenPath);
26
+ if (!phrenPath)
27
+ return {};
28
+ const actor = (process.env.PHREN_ACTOR || "").trim();
29
+ if (!actor || !/^[a-zA-Z0-9_@.-]{1,128}$/.test(actor))
30
+ return {};
31
+ // Sanitize actor name to safe path component (no path traversal)
32
+ const safeActor = actor.replace(/[^a-zA-Z0-9_@.-]/g, "_");
33
+ const prefsFile = `${phrenPath}/.users/${safeActor}/preferences.json`;
34
+ if (!fs.existsSync(prefsFile))
35
+ return {};
36
+ try {
37
+ const parsed = JSON.parse(fs.readFileSync(prefsFile, "utf8"));
38
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
39
+ return {};
40
+ return {
41
+ proactivity: parseProactivityLevel(parsed.proactivity),
42
+ proactivityFindings: parseProactivityLevel(parsed.proactivityFindings),
43
+ proactivityTask: parseProactivityLevel(parsed.proactivityTask),
44
+ findingSensitivity: ["minimal", "conservative", "balanced", "aggressive"].includes(String(parsed.findingSensitivity))
45
+ ? parsed.findingSensitivity
46
+ : undefined,
47
+ };
48
+ }
49
+ catch (err) {
50
+ debugLog(`readUserPreferences: failed to parse ${prefsFile}: ${errorMessage(err)}`);
51
+ return {};
52
+ }
53
+ }
23
54
  function readGovernanceProactivityPreferences(explicitPhrenPath) {
24
55
  const phrenPath = resolveProactivityPhrenPath(explicitPhrenPath);
25
56
  if (!phrenPath)
@@ -41,6 +72,10 @@ function readGovernanceProactivityPreferences(explicitPhrenPath) {
41
72
  return {};
42
73
  }
43
74
  function getConfiguredProactivityDefault(explicitPhrenPath) {
75
+ // Resolution chain: user prefs (highest) → governance prefs → install prefs → default
76
+ const userPrefs = readUserPreferences(explicitPhrenPath);
77
+ if (userPrefs.proactivity)
78
+ return userPrefs.proactivity;
44
79
  const governancePreference = readGovernanceProactivityPreferences(explicitPhrenPath).proactivity;
45
80
  if (governancePreference)
46
81
  return governancePreference;
@@ -75,6 +110,12 @@ function getWorkflowPolicySensitivityLevel(explicitPhrenPath) {
75
110
  }
76
111
  }
77
112
  function getConfiguredProactivityLevelForFindingsDefault(explicitPhrenPath) {
113
+ // User prefs take priority over governance prefs
114
+ const userPrefs = readUserPreferences(explicitPhrenPath);
115
+ if (userPrefs.proactivityFindings)
116
+ return userPrefs.proactivityFindings;
117
+ if (userPrefs.proactivity)
118
+ return userPrefs.proactivity;
78
119
  const prefs = readGovernanceProactivityPreferences(explicitPhrenPath);
79
120
  return prefs.proactivityFindings
80
121
  ?? prefs.proactivity
@@ -82,6 +123,12 @@ function getConfiguredProactivityLevelForFindingsDefault(explicitPhrenPath) {
82
123
  ?? getConfiguredProactivityDefault(explicitPhrenPath);
83
124
  }
84
125
  function getConfiguredProactivityLevelForTaskDefault(explicitPhrenPath) {
126
+ // User prefs take priority over governance prefs
127
+ const userPrefs = readUserPreferences(explicitPhrenPath);
128
+ if (userPrefs.proactivityTask)
129
+ return userPrefs.proactivityTask;
130
+ if (userPrefs.proactivity)
131
+ return userPrefs.proactivity;
85
132
  const prefs = readGovernanceProactivityPreferences(explicitPhrenPath);
86
133
  return prefs.proactivityTask
87
134
  ?? prefs.proactivity