@phren/cli 0.0.54 → 0.0.55

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.
@@ -1,7 +1,7 @@
1
1
  import { WEB_UI_STYLES, renderWebUiScript } from "./assets.js";
2
2
  import { renderGraphScript } from "./graph.js";
3
3
  import { PROJECT_REFERENCE_UI_STYLES, REVIEW_UI_STYLES, SETTINGS_TAB_UI_STYLES, TASK_UI_STYLES } from "./styles.js";
4
- import { renderSharedWebUiHelpers, renderSkillUiEnhancementScript, renderProjectReferenceEnhancementScript, renderTasksAndSettingsScript, renderSearchScript, renderEventWiringScript, renderGraphHostScript, } from "./scripts.js";
4
+ import { renderSharedWebUiHelpers, renderSkillUiEnhancementScript, renderProfileSwitcherScript, renderProjectReferenceEnhancementScript, renderTasksAndSettingsScript, renderReviewQueueKeyboardScript, renderSearchScript, renderEventWiringScript, renderGraphHostScript, } from "./scripts.js";
5
5
  function h(s) {
6
6
  return s
7
7
  .replace(/&/g, "&")
@@ -277,6 +277,18 @@ ${REVIEW_UI_STYLES}
277
277
  <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>
278
278
  </div>
279
279
  </section>
280
+ <section class="settings-section" style="border-top:3px solid color-mix(in srgb, var(--blue) 45%, var(--border))">
281
+ <div class="settings-section-header">Profile</div>
282
+ <div class="settings-section-body">
283
+ <div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:12px">
284
+ <label for="profile-select" style="display:block;margin-bottom:6px;color:var(--ink)">Active Profile:</label>
285
+ <select id="profile-select" 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);font-family:var(--font);width:100%;max-width:200px">
286
+ <option>Loading profiles...</option>
287
+ </select>
288
+ </div>
289
+ <div id="profile-status" style="font-size:var(--text-sm);color:var(--muted)"></div>
290
+ </div>
291
+ </section>
280
292
  <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))">
281
293
  <div class="settings-section-header">Project Info</div>
282
294
  <div class="settings-section-body">
@@ -313,6 +325,12 @@ ${REVIEW_UI_STYLES}
313
325
  <div id="settings-integrations" style="color:var(--muted)">Loading...</div>
314
326
  </div>
315
327
  </section>
328
+ <section class="settings-section settings-section-stores">
329
+ <div class="settings-section-header">Stores</div>
330
+ <div class="settings-section-body">
331
+ <div id="settings-stores" style="color:var(--muted)">Loading...</div>
332
+ </div>
333
+ </section>
316
334
  </div>
317
335
  </div>
318
336
  </div>
@@ -342,12 +360,18 @@ ${renderSharedWebUiHelpers(authToken || "")}
342
360
  ${renderSkillUiEnhancementScript(h(authToken || ""))}
343
361
  </script>
344
362
  <script${nonceAttr}>
363
+ ${renderProfileSwitcherScript(h(authToken || ""))}
364
+ </script>
365
+ <script${nonceAttr}>
345
366
  ${renderProjectReferenceEnhancementScript(h(authToken || ""))}
346
367
  </script>
347
368
  <script${nonceAttr}>
348
369
  ${renderTasksAndSettingsScript(authToken || "")}
349
370
  </script>
350
371
  <script${nonceAttr}>
372
+ ${renderReviewQueueKeyboardScript(authToken || "")}
373
+ </script>
374
+ <script${nonceAttr}>
351
375
  ${renderSearchScript(authToken || "")}
352
376
  </script>
353
377
  <script${nonceAttr}>
@@ -28,6 +28,62 @@ export function renderSharedWebUiHelpers(authToken) {
28
28
  };
29
29
  })();`;
30
30
  }
31
+ export function renderProfileSwitcherScript(_authToken) {
32
+ return `(function() {
33
+ var esc = window._phrenEsc;
34
+ var authUrl = window._phrenAuthUrl;
35
+
36
+ function loadProfiles() {
37
+ fetch(authUrl('/api/profiles')).then(function(r) { return r.json(); }).then(function(data) {
38
+ var select = document.getElementById('profile-select');
39
+ if (!select) return;
40
+ if (!data.ok || !data.profiles) {
41
+ select.innerHTML = '<option>Error loading profiles</option>';
42
+ return;
43
+ }
44
+ var html = '';
45
+ data.profiles.forEach(function(p) {
46
+ var selected = p.name === data.activeProfile ? ' selected' : '';
47
+ html += '<option value="' + esc(p.name) + '"' + selected + '>' + esc(p.name) + '</option>';
48
+ });
49
+ select.innerHTML = html;
50
+ select.onchange = function() { switchProfile(this.value); };
51
+ }).catch(function(err) {
52
+ var select = document.getElementById('profile-select');
53
+ if (select) select.innerHTML = '<option>Error loading</option>';
54
+ });
55
+ }
56
+
57
+ function switchProfile(profileName) {
58
+ if (!profileName) return;
59
+ var status = document.getElementById('profile-status');
60
+ if (status) status.textContent = 'Switching...';
61
+ fetch(authUrl('/api/profile'), {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
64
+ body: window._phrenAuthBody('profile=' + encodeURIComponent(profileName))
65
+ }).then(function(r) { return r.json(); }).then(function(data) {
66
+ if (data.ok) {
67
+ if (status) status.textContent = 'Reloading...';
68
+ setTimeout(function() { location.reload(); }, 500);
69
+ } else {
70
+ if (status) status.textContent = 'Error: ' + (data.error || 'Unknown');
71
+ }
72
+ }).catch(function(err) {
73
+ if (status) status.textContent = 'Error loading';
74
+ });
75
+ }
76
+
77
+ window.phrenLoadProfiles = loadProfiles;
78
+ window.phrenSwitchProfile = switchProfile;
79
+
80
+ if (document.readyState === 'loading') {
81
+ document.addEventListener('DOMContentLoaded', loadProfiles);
82
+ } else {
83
+ loadProfiles();
84
+ }
85
+ })();`;
86
+ }
31
87
  export function renderSkillUiEnhancementScript(_authToken) {
32
88
  return `(function() {
33
89
  var _skillCurrent = null;
@@ -879,6 +935,48 @@ export function renderTasksAndSettingsScript(authToken) {
879
935
  });
880
936
  }
881
937
 
938
+ function postGlobalRetention(field, value, clearField) {
939
+ var csrfUrl = _tsAuthToken ? tsAuthUrl('/api/csrf-token') : '/api/csrf-token';
940
+ fetch(csrfUrl).then(function(r) { return r.json(); }).then(function(csrfData) {
941
+ var payload = { field: field, value: value || '', clear: clearField ? 'true' : 'false', globalUpdate: 'true' };
942
+ var body = new URLSearchParams(payload);
943
+ if (csrfData.token) body.set('_csrf', csrfData.token);
944
+ var url = _tsAuthToken ? tsAuthUrl('/api/settings/project-overrides') : '/api/settings/project-overrides';
945
+ return fetch(url, { method: 'POST', body: body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });
946
+ }).then(function(r) { return r.json(); }).then(function(data) {
947
+ if (!data.ok) {
948
+ setSettingsStatus(data.error || 'Failed to update retention policy', 'err');
949
+ return;
950
+ }
951
+ _settingsLoaded = false;
952
+ loadSettings();
953
+ setSettingsStatus('Retention policy updated', 'ok');
954
+ }).catch(function(err) {
955
+ setSettingsStatus('Failed: ' + String(err), 'err');
956
+ });
957
+ }
958
+
959
+ function postGlobalWorkflow(field, value, clearField) {
960
+ var csrfUrl = _tsAuthToken ? tsAuthUrl('/api/csrf-token') : '/api/csrf-token';
961
+ fetch(csrfUrl).then(function(r) { return r.json(); }).then(function(csrfData) {
962
+ var payload = { field: field, value: value || '', clear: clearField ? 'true' : 'false', globalUpdate: 'true' };
963
+ var body = new URLSearchParams(payload);
964
+ if (csrfData.token) body.set('_csrf', csrfData.token);
965
+ var url = _tsAuthToken ? tsAuthUrl('/api/settings/project-overrides') : '/api/settings/project-overrides';
966
+ return fetch(url, { method: 'POST', body: body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });
967
+ }).then(function(r) { return r.json(); }).then(function(data) {
968
+ if (!data.ok) {
969
+ setSettingsStatus(data.error || 'Failed to update workflow policy', 'err');
970
+ return;
971
+ }
972
+ _settingsLoaded = false;
973
+ loadSettings();
974
+ setSettingsStatus('Workflow policy updated', 'ok');
975
+ }).catch(function(err) {
976
+ setSettingsStatus('Failed: ' + String(err), 'err');
977
+ });
978
+ }
979
+
882
980
  function loadSettings() {
883
981
  var selectedProject = getSettingsProject();
884
982
  var baseUrl = '/api/settings';
@@ -1058,12 +1156,11 @@ export function renderTasksAndSettingsScript(authToken) {
1058
1156
  }
1059
1157
  retHtml += '</div>';
1060
1158
  if (note) retHtml += '<div class="settings-control-note">' + esc(note) + '</div>';
1061
- if (isProject) {
1062
- retHtml += '<div style="display:flex;gap:8px;align-items:center;margin-top:8px">' +
1063
- '<input type="number" id="ret-input-' + esc(field) + '" value="' + esc(String(value != null ? value : '')) + '" style="width:100px;border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 8px;font-size:var(--text-sm);background:var(--surface);color:var(--ink)">' +
1064
- '<button data-ts-action="setProjectRetention" data-field="' + esc(field) + '" class="settings-chip active" style="font-size:11px">Set</button>' +
1065
- '</div>';
1066
- }
1159
+ // Show editable inputs for both global and per-project
1160
+ retHtml += '<div style="display:flex;gap:8px;align-items:center;margin-top:8px">' +
1161
+ '<input type="number" id="ret-input-' + esc(field) + '" value="' + esc(String(value != null ? value : '')) + '" style="width:100px;border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 8px;font-size:var(--text-sm);background:var(--surface);color:var(--ink)">' +
1162
+ '<button data-ts-action="' + (isProject ? 'setProjectRetention' : 'setGlobalRetention') + '" data-field="' + esc(field) + '" class="settings-chip active" style="font-size:11px">Set</button>' +
1163
+ '</div>';
1067
1164
  retHtml += '</div>';
1068
1165
  }
1069
1166
  retRow('TTL days', 'ttlDays', ret.ttlDays, 'Memories older than this are eligible for pruning.');
@@ -1085,14 +1182,11 @@ export function renderTasksAndSettingsScript(authToken) {
1085
1182
  if (isProject && lctOverride) {
1086
1183
  wfHtml += '<button data-ts-action="clearProjectOverride" data-field="lowConfidenceThreshold" class="settings-chip" style="font-size:11px">Clear</button>';
1087
1184
  }
1088
- if (isProject) {
1089
- wfHtml += '</div><div style="display:flex;gap:8px;align-items:center;margin-top:8px">' +
1090
- '<input type="number" id="wf-input-lowConfidenceThreshold" min="0" max="1" step="0.05" value="' + esc(String(wf.lowConfidenceThreshold != null ? wf.lowConfidenceThreshold : '')) + '" style="width:100px;border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 8px;font-size:var(--text-sm);background:var(--surface);color:var(--ink)">' +
1091
- '<button data-ts-action="setProjectWorkflow" data-field="lowConfidenceThreshold" class="settings-chip active" style="font-size:11px">Set</button>' +
1092
- '</div>';
1093
- } else {
1094
- wfHtml += '</div>';
1095
- }
1185
+ // Show editable inputs for both global and per-project
1186
+ wfHtml += '</div><div style="display:flex;gap:8px;align-items:center;margin-top:8px">' +
1187
+ '<input type="number" id="wf-input-lowConfidenceThreshold" min="0" max="1" step="0.05" value="' + esc(String(wf.lowConfidenceThreshold != null ? wf.lowConfidenceThreshold : '')) + '" style="width:100px;border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 8px;font-size:var(--text-sm);background:var(--surface);color:var(--ink)">' +
1188
+ '<button data-ts-action="' + (isProject ? 'setProjectWorkflow' : 'setGlobalWorkflow') + '" data-field="lowConfidenceThreshold" class="settings-chip active" style="font-size:11px">Set</button>' +
1189
+ '</div>';
1096
1190
  wfHtml += '<div class="settings-control-note">Memories below this confidence score are flagged for review.</div></div>';
1097
1191
  wfHtml += '<div class="settings-control">';
1098
1192
  wfHtml += '<div class="settings-control-header"><span class="settings-control-label">Risky sections</span>' + sourceBadge(riskySectionsOverride);
@@ -1123,11 +1217,62 @@ export function renderTasksAndSettingsScript(authToken) {
1123
1217
  } else if (integrationsEl && isProject) {
1124
1218
  integrationsEl.innerHTML = '<div style="color:var(--muted);font-size:var(--text-sm)">Integration settings are global — switch to Global scope to edit them.</div>';
1125
1219
  }
1220
+
1221
+ // Load stores if on global scope
1222
+ if (!isProject) {
1223
+ loadStores();
1224
+ }
1126
1225
  }).catch(function(err) {
1127
1226
  setSettingsStatus('Failed to load settings: ' + String(err), 'err');
1128
1227
  });
1129
1228
  }
1130
1229
 
1230
+ function loadStores() {
1231
+ var storesEl = document.getElementById('settings-stores');
1232
+ if (!storesEl) return;
1233
+ var url = _tsAuthToken ? tsAuthUrl('/api/stores') : '/api/stores';
1234
+ fetch(url).then(function(r) { return r.json(); }).then(function(data) {
1235
+ if (!data.ok || !Array.isArray(data.stores)) {
1236
+ storesEl.innerHTML = '<div style="color:var(--muted);font-size:var(--text-sm)">No team stores configured.</div>';
1237
+ return;
1238
+ }
1239
+ if (data.stores.length === 0) {
1240
+ storesEl.innerHTML = '<div style="color:var(--muted);font-size:var(--text-sm)">No team stores configured.</div>';
1241
+ return;
1242
+ }
1243
+ var html = '';
1244
+ data.stores.forEach(function(store) {
1245
+ html += '<div class="settings-control" style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;margin-bottom:12px">';
1246
+ html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">';
1247
+ html += '<div>';
1248
+ html += '<div style="font-weight:600;color:var(--ink)">' + esc(store.name) + '</div>';
1249
+ html += '<div style="font-size:var(--text-sm);color:var(--muted)">Role: ' + esc(store.role) + '</div>';
1250
+ html += '</div>';
1251
+ html += '</div>';
1252
+ html += '<div style="font-size:var(--text-xs);color:var(--muted);margin-bottom:10px;font-family:var(--mono);word-break:break-all">' + esc(store.path) + '</div>';
1253
+ html += '<div style="font-size:var(--text-sm);color:var(--ink);margin-bottom:6px;font-weight:500">Projects</div>';
1254
+ if (Array.isArray(store.availableProjects) && store.availableProjects.length > 0) {
1255
+ var subscribed = Array.isArray(store.subscribedProjects) ? store.subscribedProjects : [];
1256
+ html += '<div style="display:flex;flex-direction:column;gap:6px">';
1257
+ store.availableProjects.forEach(function(proj) {
1258
+ var isSubscribed = subscribed.indexOf(proj) !== -1;
1259
+ html += '<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:var(--text-sm)">';
1260
+ html += '<input type="checkbox" class="store-project-checkbox" data-store="' + esc(store.name) + '" data-project="' + esc(proj) + '" ' + (isSubscribed ? 'checked' : '') + ' style="cursor:pointer;width:16px;height:16px">';
1261
+ html += '<span>' + esc(proj) + '</span>';
1262
+ html += '</label>';
1263
+ });
1264
+ html += '</div>';
1265
+ } else {
1266
+ html += '<div style="color:var(--muted);font-size:var(--text-sm)">No projects available</div>';
1267
+ }
1268
+ html += '</div>';
1269
+ });
1270
+ storesEl.innerHTML = html;
1271
+ }).catch(function(err) {
1272
+ storesEl.innerHTML = '<div style="color:var(--error);font-size:var(--text-sm)">Failed to load stores: ' + esc(String(err)) + '</div>';
1273
+ });
1274
+ }
1275
+
1131
1276
  // Hook into switchTab to lazy-load
1132
1277
  var _origSwitchTab = window.switchTab;
1133
1278
  var _tasksLoaded = false;
@@ -1138,6 +1283,43 @@ export function renderTasksAndSettingsScript(authToken) {
1138
1283
  if (tab === 'settings' && !_settingsLoaded) { _settingsLoaded = true; loadSettings(); }
1139
1284
  };
1140
1285
 
1286
+ // Handle store project checkbox changes
1287
+ document.addEventListener('change', function(e) {
1288
+ var target = e.target;
1289
+ if (target && target.classList && target.classList.contains('store-project-checkbox')) {
1290
+ var storeName = target.getAttribute('data-store');
1291
+ var projectName = target.getAttribute('data-project');
1292
+ var isChecked = target.checked;
1293
+ if (storeName && projectName) {
1294
+ handleStoreProjectToggle(storeName, projectName, isChecked);
1295
+ }
1296
+ }
1297
+ });
1298
+
1299
+ function handleStoreProjectToggle(storeName, projectName, isSubscribing) {
1300
+ var endpoint = isSubscribing ? '/api/stores/subscribe' : '/api/stores/unsubscribe';
1301
+ var csrfUrl = _tsAuthToken ? tsAuthUrl('/api/csrf-token') : '/api/csrf-token';
1302
+ fetch(csrfUrl).then(function(r) { return r.json(); }).then(function(csrfData) {
1303
+ var payload = { store: storeName, projects: [projectName] };
1304
+ var body = new URLSearchParams();
1305
+ body.set('store', storeName);
1306
+ body.set('projects', JSON.stringify([projectName]));
1307
+ if (csrfData.token) body.set('_csrf', csrfData.token);
1308
+ var url = _tsAuthToken ? tsAuthUrl(endpoint) : endpoint;
1309
+ return fetch(url, { method: 'POST', body: body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });
1310
+ }).then(function(r) { return r.json(); }).then(function(data) {
1311
+ if (!data.ok) {
1312
+ setSettingsStatus((isSubscribing ? 'Subscribe' : 'Unsubscribe') + ' failed: ' + (data.error || ''), 'err');
1313
+ loadStores();
1314
+ return;
1315
+ }
1316
+ setSettingsStatus((isSubscribing ? 'Subscribed to' : 'Unsubscribed from') + ' ' + projectName, 'ok');
1317
+ loadStores();
1318
+ }).catch(function(err) {
1319
+ setSettingsStatus('Request failed: ' + String(err), 'err');
1320
+ });
1321
+ }
1322
+
1141
1323
  // Event delegation for dynamically generated tasks/settings UI
1142
1324
  document.addEventListener('click', function(e) {
1143
1325
  var target = e.target;
@@ -1175,6 +1357,25 @@ export function renderTasksAndSettingsScript(authToken) {
1175
1357
  var val = inputEl ? inputEl.value : '';
1176
1358
  postProjectOverride(proj, field, val, false);
1177
1359
  }
1360
+ else if (action === 'setGlobalRetention') {
1361
+ var field = actionEl.getAttribute('data-field') || '';
1362
+ var inputEl = document.getElementById('ret-input-' + field);
1363
+ var val = inputEl ? inputEl.value : '';
1364
+ postGlobalRetention(field, val);
1365
+ }
1366
+ else if (action === 'setProjectRetention') {
1367
+ var proj = getSettingsProject();
1368
+ var field = actionEl.getAttribute('data-field') || '';
1369
+ var inputEl = document.getElementById('ret-input-' + field);
1370
+ var val = inputEl ? inputEl.value : '';
1371
+ postProjectOverride(proj, field, val, false);
1372
+ }
1373
+ else if (action === 'setGlobalWorkflow') {
1374
+ var field = actionEl.getAttribute('data-field') || '';
1375
+ var inputEl = document.getElementById('wf-input-' + field);
1376
+ var val = inputEl ? inputEl.value : '';
1377
+ postGlobalWorkflow(field, val);
1378
+ }
1178
1379
  else if (action === 'setProjectWorkflow') {
1179
1380
  var proj = getSettingsProject();
1180
1381
  var field = actionEl.getAttribute('data-field') || '';
@@ -1832,7 +2033,7 @@ export function renderGraphHostScript() {
1832
2033
  if (priorityEl) updates.priority = priorityEl.value;
1833
2034
  graphRequest('/api/tasks/update', 'POST', {
1834
2035
  project: currentNode.projectName,
1835
- item: currentNode.tooltipLabel || currentNode.fullLabel || currentNode.displayLabel || '',
2036
+ item: currentNode.stableId || currentNode.id || currentNode.tooltipLabel || currentNode.fullLabel || currentNode.displayLabel || '',
1836
2037
  text: updates.text,
1837
2038
  section: updates.section || '',
1838
2039
  priority: updates.priority || ''
@@ -1869,7 +2070,7 @@ export function renderGraphHostScript() {
1869
2070
  if (currentNode.kind === 'task') {
1870
2071
  graphRequest('/api/tasks/remove', 'POST', {
1871
2072
  project: currentNode.projectName,
1872
- item: currentNode.tooltipLabel || currentNode.fullLabel || currentNode.displayLabel || ''
2073
+ item: currentNode.stableId || currentNode.id || currentNode.tooltipLabel || currentNode.fullLabel || currentNode.displayLabel || ''
1873
2074
  }).then(function(result) {
1874
2075
  if (!result || !result.ok) throw new Error(result && result.error ? result.error : 'Delete failed');
1875
2076
  graphToast('Task removed', 'ok');
@@ -1884,7 +2085,7 @@ export function renderGraphHostScript() {
1884
2085
  if (!currentNode) return;
1885
2086
  graphRequest('/api/tasks/complete', 'POST', {
1886
2087
  project: currentNode.projectName,
1887
- item: currentNode.tooltipLabel || currentNode.fullLabel || currentNode.displayLabel || ''
2088
+ item: currentNode.stableId || currentNode.id || currentNode.tooltipLabel || currentNode.fullLabel || currentNode.displayLabel || ''
1888
2089
  }).then(function(result) {
1889
2090
  if (!result || !result.ok) throw new Error(result && result.error ? result.error : 'Update failed');
1890
2091
  graphToast('Task completed', 'ok');
@@ -1898,7 +2099,7 @@ export function renderGraphHostScript() {
1898
2099
  if (!currentNode) return;
1899
2100
  graphRequest('/api/tasks/update', 'POST', {
1900
2101
  project: currentNode.projectName,
1901
- item: currentNode.tooltipLabel || currentNode.fullLabel || currentNode.displayLabel || '',
2102
+ item: currentNode.stableId || currentNode.id || currentNode.tooltipLabel || currentNode.fullLabel || currentNode.displayLabel || '',
1902
2103
  section: section
1903
2104
  }).then(function(result) {
1904
2105
  if (!result || !result.ok) throw new Error(result && result.error ? result.error : 'Update failed');
@@ -2009,3 +2210,87 @@ export function renderGraphHostScript() {
2009
2210
  }
2010
2211
  })();`;
2011
2212
  }
2213
+ export function renderReviewQueueKeyboardScript(_authToken) {
2214
+ return `(function() {
2215
+ var _reviewHighlightIndex = -1;
2216
+ var _currentTab = '';
2217
+ var esc = window._phrenEsc;
2218
+ var authUrl = window._phrenAuthUrl;
2219
+ var authBody = window._phrenAuthBody;
2220
+ var fetchCsrfToken = window._phrenFetchCsrfToken;
2221
+
2222
+ function updateReviewHighlight() {
2223
+ var cards = document.querySelectorAll('#review-cards-list [data-review-card]');
2224
+ cards.forEach(function(card, i) {
2225
+ if (i === _reviewHighlightIndex) {
2226
+ card.classList.add('review-card-highlight');
2227
+ card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2228
+ } else {
2229
+ card.classList.remove('review-card-highlight');
2230
+ }
2231
+ });
2232
+ }
2233
+
2234
+ function getApproveButton(card) {
2235
+ return card ? card.querySelector('[data-review-type="approve"]') : null;
2236
+ }
2237
+
2238
+ function getRejectButton(card) {
2239
+ return card ? card.querySelector('[data-review-type="reject"]') : null;
2240
+ }
2241
+
2242
+ function triggerCardAction(card, action) {
2243
+ if (!card) return;
2244
+ if (action === 'approve') {
2245
+ var approveBtn = getApproveButton(card);
2246
+ if (approveBtn) approveBtn.click();
2247
+ } else if (action === 'reject') {
2248
+ var rejectBtn = getRejectButton(card);
2249
+ if (rejectBtn) rejectBtn.click();
2250
+ }
2251
+ }
2252
+
2253
+ var baseWindowSwitchTab = window.switchTab;
2254
+ if (typeof baseWindowSwitchTab === 'function') {
2255
+ window.switchTab = function(tab) {
2256
+ baseWindowSwitchTab(tab);
2257
+ _currentTab = tab;
2258
+ if (tab === 'review') {
2259
+ _reviewHighlightIndex = -1;
2260
+ setTimeout(function() { updateReviewHighlight(); }, 100);
2261
+ }
2262
+ };
2263
+ }
2264
+
2265
+ document.addEventListener('keydown', function(e) {
2266
+ if (_currentTab !== 'review') return;
2267
+ var cards = document.querySelectorAll('#review-cards-list [data-review-card]');
2268
+ if (cards.length === 0) return;
2269
+
2270
+ if (e.key === 'j' || e.key === 'ArrowDown') {
2271
+ e.preventDefault();
2272
+ _reviewHighlightIndex = Math.min(_reviewHighlightIndex + 1, cards.length - 1);
2273
+ updateReviewHighlight();
2274
+ } else if (e.key === 'k' || e.key === 'ArrowUp') {
2275
+ e.preventDefault();
2276
+ _reviewHighlightIndex = Math.max(_reviewHighlightIndex - 1, -1);
2277
+ updateReviewHighlight();
2278
+ } else if (e.key === 'a') {
2279
+ e.preventDefault();
2280
+ if (_reviewHighlightIndex >= 0) {
2281
+ triggerCardAction(cards[_reviewHighlightIndex], 'approve');
2282
+ }
2283
+ } else if (e.key === 'd') {
2284
+ e.preventDefault();
2285
+ if (_reviewHighlightIndex >= 0) {
2286
+ triggerCardAction(cards[_reviewHighlightIndex], 'reject');
2287
+ }
2288
+ } else if (e.key === 'Enter') {
2289
+ e.preventDefault();
2290
+ if (_reviewHighlightIndex >= 0) {
2291
+ cards[_reviewHighlightIndex].click();
2292
+ }
2293
+ }
2294
+ });
2295
+ })();`;
2296
+ }
@@ -6,7 +6,7 @@ import * as path from "path";
6
6
  import * as querystring from "querystring";
7
7
  import { spawn, execFileSync } from "child_process";
8
8
  import { computePhrenLiveStateToken, getProjectDirs, } from "../shared.js";
9
- import { getNonPrimaryStores } from "../store-registry.js";
9
+ import { getNonPrimaryStores, subscribeStoreProjects, unsubscribeStoreProjects } from "../store-registry.js";
10
10
  import { editFinding, readReviewQueue, removeFinding, readFindings, addFinding as addFindingStore, readTasksAcrossProjects, addTask as addTaskStore, completeTask as completeTaskStore, removeTask as removeTaskStore, updateTask as updateTaskStore, TASKS_FILENAME, } from "../data/access.js";
11
11
  import { isValidProjectName, errorMessage, queueFilePath, safeProjectPath } from "../utils.js";
12
12
  import { readInstallPreferences, writeInstallPreferences, writeGovernanceInstallPreferences } from "../init/preferences.js";
@@ -339,6 +339,28 @@ function handleGetProjects(res, ctx) {
339
339
  function handleGetChangeToken(res, ctx) {
340
340
  jsonOk(res, { token: computePhrenLiveStateToken(ctx.phrenPath) });
341
341
  }
342
+ function handleGetStores(res, ctx) {
343
+ try {
344
+ const stores = getNonPrimaryStores(ctx.phrenPath);
345
+ const storeData = stores.map((store) => {
346
+ const availableProjects = getProjectDirs(store.path)
347
+ .map((dir) => path.basename(dir))
348
+ .filter((p) => p !== "global");
349
+ const subscribedProjects = store.projects || [];
350
+ return {
351
+ name: store.name,
352
+ role: store.role,
353
+ path: store.path,
354
+ availableProjects,
355
+ subscribedProjects,
356
+ };
357
+ });
358
+ jsonOk(res, { ok: true, stores: storeData });
359
+ }
360
+ catch (err) {
361
+ jsonErr(res, errorMessage(err), 500);
362
+ }
363
+ }
342
364
  function handleGetRuntimeHealth(res, ctx) {
343
365
  jsonOk(res, readSyncSnapshot(ctx.phrenPath));
344
366
  }
@@ -816,12 +838,116 @@ function handlePostSettingsMcpEnabled(req, res, url, ctx) {
816
838
  jsonOk(res, { ok: true, mcpEnabled: enabled });
817
839
  });
818
840
  }
841
+ function handleGetProfiles(res, ctx) {
842
+ try {
843
+ const { listProfiles } = require("../profile-store.js");
844
+ const profileResult = listProfiles(ctx.phrenPath);
845
+ if (!profileResult.ok) {
846
+ return jsonOk(res, { ok: false, error: profileResult.error, profiles: [] });
847
+ }
848
+ const profiles = profileResult.data || [];
849
+ jsonOk(res, { ok: true, profiles, activeProfile: ctx.profile || undefined });
850
+ }
851
+ catch (err) {
852
+ jsonOk(res, { ok: false, error: errorMessage(err), profiles: [] });
853
+ }
854
+ }
855
+ function handlePostProfile(req, res, url, ctx) {
856
+ withPostBody(req, res, url, ctx, (parsed) => {
857
+ const profileName = String(parsed.profile || "").trim();
858
+ if (!profileName)
859
+ return jsonErr(res, "Profile name required", 400);
860
+ try {
861
+ const { setMachineProfile, getDefaultMachineAlias } = require("../profile-store.js");
862
+ const machineAlias = getDefaultMachineAlias();
863
+ const result = setMachineProfile(ctx.phrenPath, machineAlias, profileName);
864
+ if (!result.ok)
865
+ return jsonErr(res, result.error || "Failed to switch profile", 500);
866
+ jsonOk(res, { ok: true, message: `Switched to profile: ${profileName}`, profile: profileName });
867
+ }
868
+ catch (err) {
869
+ jsonErr(res, errorMessage(err), 500);
870
+ }
871
+ });
872
+ }
873
+ function handlePostStoreSubscribe(req, res, url, ctx) {
874
+ withPostBody(req, res, url, ctx, (parsed) => {
875
+ const storeName = String(parsed.store || "");
876
+ const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
877
+ if (!storeName)
878
+ return jsonErr(res, "Missing store name", 400);
879
+ try {
880
+ subscribeStoreProjects(ctx.phrenPath, storeName, projects);
881
+ jsonOk(res, { ok: true });
882
+ }
883
+ catch (err) {
884
+ jsonErr(res, errorMessage(err), 500);
885
+ }
886
+ });
887
+ }
888
+ function handlePostStoreUnsubscribe(req, res, url, ctx) {
889
+ withPostBody(req, res, url, ctx, (parsed) => {
890
+ const storeName = String(parsed.store || "");
891
+ const projects = Array.isArray(parsed.projects) ? parsed.projects : [];
892
+ if (!storeName)
893
+ return jsonErr(res, "Missing store name", 400);
894
+ try {
895
+ unsubscribeStoreProjects(ctx.phrenPath, storeName, projects);
896
+ jsonOk(res, { ok: true });
897
+ }
898
+ catch (err) {
899
+ jsonErr(res, errorMessage(err), 500);
900
+ }
901
+ });
902
+ }
819
903
  function handlePostSettingsProjectOverrides(req, res, url, ctx) {
820
904
  withPostBody(req, res, url, ctx, (parsed) => {
905
+ const globalUpdate = String(parsed.globalUpdate || "") === "true";
821
906
  const project = String(parsed.project || "");
822
907
  const field = String(parsed.field || "");
823
908
  const value = String(parsed.value || "");
824
909
  const clearField = String(parsed.clear || "") === "true";
910
+ const NUMERIC_RETENTION_FIELDS = ["ttlDays", "retentionDays", "autoAcceptThreshold", "minInjectConfidence"];
911
+ const NUMERIC_WORKFLOW_FIELDS = ["lowConfidenceThreshold"];
912
+ // Handle global retention/workflow policy updates
913
+ if (globalUpdate) {
914
+ try {
915
+ if (NUMERIC_RETENTION_FIELDS.includes(field)) {
916
+ const { updateRetentionPolicy } = require("../governance/policy.js");
917
+ let updateData = {};
918
+ if (!clearField) {
919
+ const num = parseFloat(value);
920
+ if (!Number.isFinite(num) || num < 0)
921
+ throw new Error("Invalid numeric value for " + field);
922
+ updateData[field] = num;
923
+ }
924
+ const result = updateRetentionPolicy(ctx.phrenPath, updateData);
925
+ if (!result.ok)
926
+ return jsonErr(res, result.error || "Failed to update retention policy", 500);
927
+ return jsonOk(res, { ok: true, retentionPolicy: result.data });
928
+ }
929
+ else if (NUMERIC_WORKFLOW_FIELDS.includes(field)) {
930
+ const { updateWorkflowPolicy } = require("../governance/policy.js");
931
+ let updateData = {};
932
+ if (!clearField) {
933
+ const num = parseFloat(value);
934
+ if (!Number.isFinite(num) || num < 0 || num > 1)
935
+ throw new Error("Invalid value for " + field + " (must be 0-1)");
936
+ updateData[field] = num;
937
+ }
938
+ const result = updateWorkflowPolicy(ctx.phrenPath, updateData);
939
+ if (!result.ok)
940
+ return jsonErr(res, result.error || "Failed to update workflow policy", 500);
941
+ return jsonOk(res, { ok: true, workflowPolicy: result.data });
942
+ }
943
+ else {
944
+ return jsonErr(res, "Unknown config field: " + field, 400);
945
+ }
946
+ }
947
+ catch (err) {
948
+ return jsonErr(res, errorMessage(err));
949
+ }
950
+ }
825
951
  if (!project || !isValidProjectName(project))
826
952
  return jsonErr(res, "Invalid project name", 400);
827
953
  const registeredProjects = getProjectDirs(ctx.phrenPath, ctx.profile).map((d) => path.basename(d)).filter((p) => p !== "global");
@@ -831,8 +957,6 @@ function handlePostSettingsProjectOverrides(req, res, url, ctx) {
831
957
  proactivity: ["high", "medium", "low"], proactivityFindings: ["high", "medium", "low"],
832
958
  proactivityTask: ["high", "medium", "low"], taskMode: ["off", "manual", "suggest", "auto"],
833
959
  };
834
- const NUMERIC_RETENTION_FIELDS = ["ttlDays", "retentionDays", "autoAcceptThreshold", "minInjectConfidence"];
835
- const NUMERIC_WORKFLOW_FIELDS = ["lowConfidenceThreshold"];
836
960
  try {
837
961
  updateProjectConfigOverrides(ctx.phrenPath, project, (current) => {
838
962
  const next = { ...current };
@@ -944,6 +1068,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
944
1068
  switch (pathname) {
945
1069
  case "/api/projects": return handleGetProjects(res, ctx);
946
1070
  case "/api/change-token": return handleGetChangeToken(res, ctx);
1071
+ case "/api/stores": return handleGetStores(res, ctx);
947
1072
  case "/api/runtime-health": return handleGetRuntimeHealth(res, ctx);
948
1073
  case "/api/review-queue": return handleGetReviewQueue(res, ctx);
949
1074
  case "/api/review-activity": return handleGetReviewActivity(res, ctx);
@@ -957,6 +1082,7 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
957
1082
  case "/api/settings": return handleGetSettings(res, url, ctx);
958
1083
  case "/api/config": return handleGetConfig(res, url, ctx);
959
1084
  case "/api/csrf-token": return handleGetCsrfToken(res, ctx);
1085
+ case "/api/profiles": return handleGetProfiles(res, ctx);
960
1086
  case "/api/search": return await handleGetSearch(res, url, ctx);
961
1087
  }
962
1088
  // Prefix-matched GET routes
@@ -979,6 +1105,9 @@ export function createWebUiHttpServer(phrenPath, renderPage, profile, opts) {
979
1105
  case "/api/skill-save": return handlePostSkillSave(req, res, url, ctx);
980
1106
  case "/api/skill-toggle": return handlePostSkillToggle(req, res, url, ctx);
981
1107
  case "/api/hook-toggle": return handlePostHookToggle(req, res, url, ctx);
1108
+ case "/api/profile": return handlePostProfile(req, res, url, ctx);
1109
+ case "/api/stores/subscribe": return handlePostStoreSubscribe(req, res, url, ctx);
1110
+ case "/api/stores/unsubscribe": return handlePostStoreUnsubscribe(req, res, url, ctx);
982
1111
  case "/api/project-topics/save": return handlePostTopicsSave(req, res, url, ctx);
983
1112
  case "/api/project-topics/reclassify": return handlePostTopicsReclassify(req, res, url, ctx);
984
1113
  case "/api/project-topics/pin": return handlePostTopicsPin(req, res, url, ctx);
@@ -655,4 +655,10 @@ export const REVIEW_UI_STYLES = `
655
655
  .review-sync-indicator.error {
656
656
  background: var(--danger);
657
657
  }
658
+ /* ── Review Card Highlight (keyboard shortcuts) ──── */
659
+ .review-card-highlight {
660
+ border: 2px solid var(--blue) !important;
661
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.08), var(--shadow) !important;
662
+ background: color-mix(in srgb, var(--surface) 98%, var(--blue)) !important;
663
+ }
658
664
  `;