@phren/cli 0.0.5 → 0.0.7

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.
@@ -8,7 +8,7 @@ import { readCustomHooks } from "./hooks.js";
8
8
  import { hookConfigPaths, hookConfigRoots } from "./provider-adapters.js";
9
9
  import { getAllSkills } from "./skill-registry.js";
10
10
  import { resolveTaskFilePath, readTasks, TASKS_FILENAME } from "./data-tasks.js";
11
- import { buildIndex, queryRows } from "./shared-index.js";
11
+ import { buildIndex, queryDocBySourceKey, queryRows } from "./shared-index.js";
12
12
  import { readProjectTopics, classifyTopicForText } from "./project-topics.js";
13
13
  import { entryScoreKey } from "./governance-scores.js";
14
14
  function extractGithubUrl(content) {
@@ -271,10 +271,9 @@ export async function buildGraph(phrenPath, profile, focusProject) {
271
271
  const rows = queryRows(db, `SELECT e.id, e.name, e.type, COUNT(DISTINCT el.source_doc) as ref_count
272
272
  FROM entities e JOIN entity_links el ON el.target_id = e.id WHERE e.type != 'document'
273
273
  GROUP BY e.id, e.name, e.type ORDER BY ref_count DESC LIMIT 500`, []);
274
- const refRows = queryRows(db, `SELECT e.id, el.source_doc, d.content, d.filename
274
+ const refRows = queryRows(db, `SELECT e.id, el.source_doc
275
275
  FROM entities e
276
276
  JOIN entity_links el ON el.target_id = e.id
277
- LEFT JOIN docs d ON d.source_key = el.source_doc
278
277
  WHERE e.type != 'document'`, []);
279
278
  const refsByEntity = new Map();
280
279
  const seenEntityDoc = new Set();
@@ -291,8 +290,9 @@ export async function buildGraph(phrenPath, profile, focusProject) {
291
290
  continue;
292
291
  seenEntityDoc.add(entityDocKey);
293
292
  const project = projectFromSourceDoc(doc);
294
- const content = typeof row[2] === "string" ? row[2] : "";
295
- const filename = typeof row[3] === "string" ? row[3] : "";
293
+ const docRow = queryDocBySourceKey(db, phrenPath, doc);
294
+ const content = docRow?.content ?? "";
295
+ const filename = docRow?.filename ?? "";
296
296
  const scoreKey = project && filename && content ? entryScoreKey(project, filename, content) : undefined;
297
297
  const refs = refsByEntity.get(entityId) ?? [];
298
298
  refs.push({ doc, project, scoreKey });
@@ -445,7 +445,7 @@ export function collectProjectsForUI(phrenPath, profile) {
445
445
  }
446
446
  }
447
447
  catch (err) {
448
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
448
+ if (process.env.PHREN_DEBUG)
449
449
  process.stderr.write(`[phren] memory-ui filterByProfile: ${errorMessage(err)}\n`);
450
450
  }
451
451
  const results = [];
@@ -461,7 +461,7 @@ export function collectProjectsForUI(phrenPath, profile) {
461
461
  let findingCount = 0;
462
462
  if (fs.existsSync(findingsPath)) {
463
463
  const content = fs.readFileSync(findingsPath, "utf8");
464
- findingCount = (content.match(/^- \[/gm) || []).length;
464
+ findingCount = (content.match(/^- /gm) || []).length;
465
465
  }
466
466
  const sparkline = new Array(8).fill(0);
467
467
  if (fs.existsSync(findingsPath)) {
@@ -50,7 +50,10 @@ export function renderGraphScript() {
50
50
  trailPoints: [], /* fading trail behind movement */
51
51
  initialized: false,
52
52
  waypoints: [], /* edge-walk waypoints [{x,y}] */
53
- waypointIdx: 0 /* current waypoint index */
53
+ waypointIdx: 0, /* current waypoint index */
54
+ tripDist: 0, /* total distance of current trip (for ease-in-out) */
55
+ tripProgress: 0, /* distance traveled so far this trip */
56
+ targetNodeId: null /* id of destination node (set in phrenMoveTo) */
54
57
  };
55
58
 
56
59
  /* ── phren sprite image ─────────────────────────────────────────────── */
@@ -74,6 +77,18 @@ export function renderGraphScript() {
74
77
  phren.targetX = phren.x;
75
78
  phren.targetY = phren.y;
76
79
  phren.initialized = true;
80
+ /* set initial current node to nearest visible node */
81
+ var _initNearest = null, _initNearestD = Infinity;
82
+ for (var _ni = 0; _ni < nodes.length; _ni++) {
83
+ var _nnd = nodes[_ni];
84
+ var _ndx = _nnd.x - phren.x, _ndy = _nnd.y - phren.y;
85
+ var _ndd = _ndx * _ndx + _ndy * _ndy;
86
+ if (_ndd < _initNearestD) { _initNearestD = _ndd; _initNearest = _nnd; }
87
+ }
88
+ if (_initNearest) {
89
+ phrenCurrentNodeId = _initNearest.id;
90
+ phrenRefreshAdjacentLinks();
91
+ }
77
92
  }
78
93
 
79
94
  /* find shortest edge path from nearest node to target node via BFS */
@@ -140,13 +155,31 @@ export function renderGraphScript() {
140
155
  phren.moving = true;
141
156
  phren.arriving = false;
142
157
  phren.trailPoints = [{ x: phren.x, y: phren.y, age: 0 }];
158
+ /* record trip distance for ease-in-out interpolation */
159
+ var tdx = x - phren.x, tdy = y - phren.y;
160
+ phren.tripDist = Math.sqrt(tdx * tdx + tdy * tdy);
161
+ phren.tripProgress = 0;
143
162
  /* try edge-walking if a target node is given */
144
163
  phren.waypoints = targetNode ? phrenFindEdgePath(targetNode) : [];
145
164
  phren.waypointIdx = 0;
165
+ phren.targetNodeId = targetNode ? targetNode.id : null;
146
166
  /* ensure animation loop is running so phren movement renders */
147
167
  if (!animFrame) animFrame = requestAnimationFrame(tick);
148
168
  }
149
169
 
170
+ function phrenRefreshAdjacentLinks() {
171
+ phrenAdjacentLinks = [];
172
+ if (!phrenCurrentNodeId) return;
173
+ for (var i = 0; i < visibleLinks.length; i++) {
174
+ var lk = visibleLinks[i];
175
+ var sid = lk._source ? lk._source.id : (lk.source && typeof lk.source === 'object' ? lk.source.id : lk.source);
176
+ var tid = lk._target ? lk._target.id : (lk.target && typeof lk.target === 'object' ? lk.target.id : lk.target);
177
+ if (sid === phrenCurrentNodeId || tid === phrenCurrentNodeId) {
178
+ phrenAdjacentLinks.push(lk);
179
+ }
180
+ }
181
+ }
182
+
150
183
  function phrenUpdate(dt) {
151
184
  phren.idlePhase += dt;
152
185
  if (phren.moving) {
@@ -172,25 +205,35 @@ export function renderGraphScript() {
172
205
  phren.moving = false;
173
206
  phren.arriving = true;
174
207
  phren.arriveTimer = 0;
208
+ /* update keyboard-nav current node */
209
+ if (phren.targetNodeId) {
210
+ phrenCurrentNodeId = phren.targetNodeId;
211
+ phrenSelectedEdgeIdx = -1;
212
+ phrenRefreshAdjacentLinks();
213
+ }
175
214
  }
176
215
  } else {
177
- /* ease-out movement fast start, gentle arrival */
178
- var speed = Math.max(3, dist * 0.08);
216
+ /* ease-in-out via sine curve over the full trip distance */
217
+ var t = phren.tripDist > 0 ? Math.min(1, phren.tripProgress / phren.tripDist) : 1;
218
+ var easeInOut = 0.5 - 0.5 * Math.cos(Math.PI * t);
219
+ var baseSpeed = Math.max(3, phren.tripDist * 0.12);
220
+ var speed = Math.max(1.5, baseSpeed * (0.15 + 0.85 * easeInOut));
179
221
  phren.x += (dx / dist) * speed;
180
222
  phren.y += (dy / dist) * speed;
181
- /* record trail */
223
+ phren.tripProgress += speed;
224
+ /* record trail — longer buffer for gradual fade */
182
225
  phren.trailPoints.push({ x: phren.x, y: phren.y, age: 0 });
183
- if (phren.trailPoints.length > 30) phren.trailPoints.shift();
226
+ if (phren.trailPoints.length > 50) phren.trailPoints.shift();
184
227
  }
185
228
  }
186
229
  if (phren.arriving) {
187
230
  phren.arriveTimer += dt;
188
- if (phren.arriveTimer > 0.8) phren.arriving = false;
231
+ if (phren.arriveTimer > 1.0) phren.arriving = false;
189
232
  }
190
- /* age trail points */
233
+ /* age trail points — 2s lifetime for a longer, gradual fade */
191
234
  for (var i = phren.trailPoints.length - 1; i >= 0; i--) {
192
235
  phren.trailPoints[i].age += dt;
193
- if (phren.trailPoints[i].age > 1.0) phren.trailPoints.splice(i, 1);
236
+ if (phren.trailPoints[i].age > 2.0) phren.trailPoints.splice(i, 1);
194
237
  }
195
238
  }
196
239
 
@@ -199,15 +242,16 @@ export function renderGraphScript() {
199
242
  var px = phren.x, py = phren.y;
200
243
  var s = 1 / scale; /* unit size in graph coords */
201
244
 
202
- /* trail — purple tinted */
245
+ /* trail — purple tinted, 2s fade for a longer gradual tail */
203
246
  if (phren.trailPoints.length > 1) {
204
247
  for (var i = 1; i < phren.trailPoints.length; i++) {
205
248
  var pt = phren.trailPoints[i];
206
249
  var prev = phren.trailPoints[i - 1];
207
- var alpha = Math.max(0, 0.35 * (1 - pt.age / 1.0));
250
+ var fadeT = 1 - pt.age / 2.0;
251
+ var alpha = Math.max(0, 0.38 * fadeT * fadeT); /* quadratic for softer tail */
208
252
  ctx.beginPath();
209
253
  ctx.strokeStyle = 'rgba(123,104,174,' + alpha + ')';
210
- ctx.lineWidth = (2.5 * (1 - pt.age / 1.0)) * s;
254
+ ctx.lineWidth = (3.0 * fadeT) * s;
211
255
  ctx.moveTo(prev.x, prev.y);
212
256
  ctx.lineTo(pt.x, pt.y);
213
257
  ctx.stroke();
@@ -231,16 +275,33 @@ export function renderGraphScript() {
231
275
  spriteScreenSize = 48 + 8 * (1 - phren.arriveTimer / 0.4);
232
276
  }
233
277
  var spriteSize = spriteScreenSize * s; /* convert to graph coords */
234
- /* bob up/down 2-3px when moving (sine wave) */
235
- var bobOffset = phren.moving ? Math.sin(phren.idlePhase * 8) * 2.5 * s : 0;
278
+ /* bob up/down when walking sine wave synced to walk progress */
279
+ var walkPhase = phren.tripDist > 0
280
+ ? (phren.tripProgress / phren.tripDist) * Math.PI * 6
281
+ : phren.idlePhase * 8;
282
+ var bobOffset = phren.moving ? Math.sin(walkPhase) * 2.5 * s : 0;
283
+ /* bounce on arrival — damped overshoot then settle */
284
+ var bounceOffset = (phren.arriving && phren.arriveTimer < 0.55)
285
+ ? -3 * Math.sin(phren.arriveTimer * Math.PI * 4.5) * Math.exp(-phren.arriveTimer * 8) * s
286
+ : 0;
287
+ /* idle breathing — gentle scale pulse (3s loop) when resting */
288
+ var idleScale = (!phren.moving && !phren.arriving)
289
+ ? 1.0 + 0.02 * Math.sin(phren.idlePhase * (2 * Math.PI / 3))
290
+ : 1.0;
291
+ var totalYOffset = bobOffset + bounceOffset;
236
292
  /* crisp pixel art — no smoothing */
237
293
  ctx.imageSmoothingEnabled = false;
238
- ctx.drawImage(phrenImg, px - spriteSize / 2, py - spriteSize / 2 + bobOffset, spriteSize, spriteSize);
294
+ if (idleScale !== 1.0) {
295
+ ctx.translate(px, py);
296
+ ctx.scale(idleScale, idleScale);
297
+ ctx.translate(-px, -py);
298
+ }
299
+ ctx.drawImage(phrenImg, px - spriteSize / 2, py - spriteSize / 2 + totalYOffset, spriteSize, spriteSize);
239
300
  /* arrival flash: cyan glow ring */
240
- if (phren.arriving && phren.arriveTimer < 0.5) {
241
- var ringAlpha = 0.6 * (1 - phren.arriveTimer / 0.5);
301
+ if (phren.arriving && phren.arriveTimer < 0.6) {
302
+ var ringAlpha = 0.6 * (1 - phren.arriveTimer / 0.6);
242
303
  ctx.beginPath();
243
- ctx.arc(px, py + bobOffset, spriteSize * 0.55, 0, Math.PI * 2);
304
+ ctx.arc(px, py + totalYOffset, spriteSize * 0.55, 0, Math.PI * 2);
244
305
  ctx.strokeStyle = 'rgba(0,229,255,' + ringAlpha + ')';
245
306
  ctx.lineWidth = 2 * s;
246
307
  ctx.shadowColor = 'rgba(0,229,255,' + (ringAlpha * 0.5) + ')';
@@ -296,11 +357,15 @@ export function renderGraphScript() {
296
357
  var alpha = 1.0;
297
358
  var animFrame = null;
298
359
  var canvas, ctx, tooltip;
360
+ var _nodeSelectCb = null; /* external callback for node selection */
299
361
  var pulseT = 0;
300
362
  var _tooltipNode = null, _tooltipTimer = null;
301
363
  var _prevVisibleCount = 0;
302
364
  var focusedNodeIndex = -1;
303
365
  var liveRegion = null;
366
+ var phrenCurrentNodeId = null; /* id of node phren is currently at */
367
+ var phrenSelectedEdgeIdx = -1; /* index into phrenAdjacentLinks (-1 = none) */
368
+ var phrenAdjacentLinks = []; /* edges connected to current node */
304
369
 
305
370
  /* ── helpers ────────────────────────────────────────────────────────── */
306
371
  function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
@@ -932,6 +997,44 @@ export function renderGraphScript() {
932
997
  }
933
998
  ctx.lineWidth = baseEdgeWidth;
934
999
 
1000
+ /* 1b. selected edge highlight for phren keyboard navigation */
1001
+ if (phrenSelectedEdgeIdx >= 0 && phrenSelectedEdgeIdx < phrenAdjacentLinks.length) {
1002
+ var selLk = phrenAdjacentLinks[phrenSelectedEdgeIdx];
1003
+ if (selLk._source && selLk._target) {
1004
+ var selSid = selLk._source.id;
1005
+ var selDest = (selSid === phrenCurrentNodeId) ? selLk._target : selLk._source;
1006
+ ctx.save();
1007
+ /* glow edge */
1008
+ ctx.beginPath();
1009
+ ctx.strokeStyle = 'rgba(0,229,255,0.85)';
1010
+ ctx.lineWidth = 2.5 / scale;
1011
+ ctx.shadowColor = 'rgba(0,229,255,0.45)';
1012
+ ctx.shadowBlur = 10 / scale;
1013
+ ctx.moveTo(selLk._source.x, selLk._source.y);
1014
+ ctx.lineTo(selLk._target.x, selLk._target.y);
1015
+ ctx.stroke();
1016
+ ctx.shadowBlur = 0;
1017
+ /* destination label near edge midpoint */
1018
+ var selMx = (selLk._source.x + selLk._target.x) / 2;
1019
+ var selMy = (selLk._source.y + selLk._target.y) / 2;
1020
+ var selLbl = (selDest.label || selDest.id || '').slice(0, 32);
1021
+ if (selLbl) {
1022
+ var selFs = Math.max(10, Math.round(11 / scale));
1023
+ ctx.font = '600 ' + selFs + 'px sans-serif';
1024
+ ctx.textAlign = 'center';
1025
+ ctx.textBaseline = 'bottom';
1026
+ var selTw = ctx.measureText(selLbl).width;
1027
+ var selPad = 5 / scale;
1028
+ var selBh = selFs + 2 * selPad;
1029
+ ctx.fillStyle = 'rgba(10,12,30,0.88)';
1030
+ ctx.fillRect(selMx - selTw / 2 - selPad, selMy - selBh - 4 / scale, selTw + 2 * selPad, selBh);
1031
+ ctx.fillStyle = 'rgba(0,229,255,1)';
1032
+ ctx.fillText(selLbl, selMx, selMy - 4 / scale);
1033
+ }
1034
+ ctx.restore();
1035
+ }
1036
+ }
1037
+
935
1038
  /* 2. health rings with dash patterns + text labels (WCAG 1.4.1) */
936
1039
  for (var i = 0; i < nodes.length; i++) {
937
1040
  var nd = nodes[i];
@@ -1106,6 +1209,7 @@ export function renderGraphScript() {
1106
1209
 
1107
1210
  /* ── hit testing ────────────────────────────────────────────────────── */
1108
1211
  function hitTest(mx, my) {
1212
+ /* mx/my must be CSS pixels (same as e.clientX - rect.left from mouse events) */
1109
1213
  var gx = (mx - panX) / scale;
1110
1214
  var gy = (my - panY) / scale;
1111
1215
  var closest = null, closestDist = Infinity;
@@ -1129,6 +1233,13 @@ export function renderGraphScript() {
1129
1233
  if (node && typeof node.x === 'number' && typeof node.y === 'number') {
1130
1234
  phrenMoveTo(node.x, node.y, node);
1131
1235
  }
1236
+ /* fire external callback so VS Code extension can react to selection */
1237
+ if (_nodeSelectCb && node) {
1238
+ var rect = canvas ? canvas.getBoundingClientRect() : { left: 0, top: 0 };
1239
+ var cx = node.x * scale + panX + rect.left;
1240
+ var cy = node.y * scale + panY + rect.top;
1241
+ try { _nodeSelectCb(node, cx, cy); } catch(e) { /* swallow callback errors */ }
1242
+ }
1132
1243
  var metaEl = document.getElementById('graph-detail-meta');
1133
1244
  var bodyEl = document.getElementById('graph-detail-body');
1134
1245
  if (!metaEl || !bodyEl) return;
@@ -1413,7 +1524,20 @@ export function renderGraphScript() {
1413
1524
  render();
1414
1525
  break;
1415
1526
  case 'Enter':
1416
- if (focusedNodeIndex >= 0 && focusedNodeIndex < visibleNodes.length) {
1527
+ case ' ':
1528
+ e.preventDefault();
1529
+ if (phrenSelectedEdgeIdx >= 0 && phrenSelectedEdgeIdx < phrenAdjacentLinks.length) {
1530
+ /* walk phren along selected edge */
1531
+ var wLk = phrenAdjacentLinks[phrenSelectedEdgeIdx];
1532
+ var wSid = wLk._source ? wLk._source.id : null;
1533
+ var wDest = (wLk._source && wLk._target)
1534
+ ? (wSid === phrenCurrentNodeId ? wLk._target : wLk._source)
1535
+ : null;
1536
+ if (wDest) {
1537
+ phrenMoveTo(wDest.x, wDest.y, wDest);
1538
+ announce('Walking to ' + (wDest.label || wDest.id || ''));
1539
+ }
1540
+ } else if (e.key === 'Enter' && focusedNodeIndex >= 0 && focusedNodeIndex < visibleNodes.length) {
1417
1541
  var sn = visibleNodes[focusedNodeIndex];
1418
1542
  renderGraphDetails(sn);
1419
1543
  announceNode(sn);
@@ -1423,7 +1547,10 @@ export function renderGraphScript() {
1423
1547
  }
1424
1548
  break;
1425
1549
  case 'Escape':
1426
- if (selectedNode) {
1550
+ if (phrenSelectedEdgeIdx >= 0) {
1551
+ phrenSelectedEdgeIdx = -1;
1552
+ render();
1553
+ } else if (selectedNode) {
1427
1554
  var prevFocus = focusedNodeIndex;
1428
1555
  renderGraphDetails(null);
1429
1556
  focusedNodeIndex = prevFocus;
@@ -1436,12 +1563,22 @@ export function renderGraphScript() {
1436
1563
  break;
1437
1564
  case 'ArrowLeft':
1438
1565
  e.preventDefault();
1439
- panX += PAN_STEP;
1566
+ if (phrenAdjacentLinks.length > 0) {
1567
+ phrenSelectedEdgeIdx = phrenSelectedEdgeIdx < 0
1568
+ ? phrenAdjacentLinks.length - 1
1569
+ : (phrenSelectedEdgeIdx - 1 + phrenAdjacentLinks.length) % phrenAdjacentLinks.length;
1570
+ } else {
1571
+ panX += PAN_STEP;
1572
+ }
1440
1573
  render();
1441
1574
  break;
1442
1575
  case 'ArrowRight':
1443
1576
  e.preventDefault();
1444
- panX -= PAN_STEP;
1577
+ if (phrenAdjacentLinks.length > 0) {
1578
+ phrenSelectedEdgeIdx = (phrenSelectedEdgeIdx + 1) % phrenAdjacentLinks.length;
1579
+ } else {
1580
+ panX -= PAN_STEP;
1581
+ }
1445
1582
  render();
1446
1583
  break;
1447
1584
  case 'ArrowUp':
@@ -1678,7 +1815,8 @@ export function renderGraphScript() {
1678
1815
  lastTickTime = timestamp;
1679
1816
  simulate();
1680
1817
  render();
1681
- if (alpha > 0 || dragging) {
1818
+ var phrenStillActive = phren.moving || phren.arriving || phren.trailPoints.length > 0;
1819
+ if (alpha > 0 || dragging || phrenStillActive) {
1682
1820
  animFrame = requestAnimationFrame(tick);
1683
1821
  } else {
1684
1822
  animFrame = null;
@@ -1897,12 +2035,29 @@ export function renderGraphScript() {
1897
2035
  }
1898
2036
 
1899
2037
  applyFilters();
1900
- phrenInit(visibleNodes);
1901
2038
  buildFilterBar();
1902
2039
  initPositions();
2040
+ phrenInit(visibleNodes); /* must run after initPositions so node coords are valid */
1903
2041
  setupInteraction();
1904
2042
  startSimulation();
1905
2043
  announceGraphSummary();
2044
+ },
2045
+ /** Register a callback fired when the user selects a node. */
2046
+ onNodeSelect: function(cb) { _nodeSelectCb = cb; },
2047
+ /** Hit-test at CSS screen coordinates (same units as e.clientX/clientY minus canvas rect).
2048
+ * Note: pass CSS pixels, NOT canvas.width/height (which are native/DPR-scaled). */
2049
+ getNodeAt: function(x, y) { return hitTest(x, y); },
2050
+ /** Returns the node id where phren is currently located, or null. */
2051
+ getCurrentNode: function() { return phrenCurrentNodeId; },
2052
+ /** Programmatically move the phren character to a node (by id). */
2053
+ walkTo: function(nodeId) {
2054
+ for (var i = 0; i < visibleNodes.length; i++) {
2055
+ if (visibleNodes[i].id === nodeId) {
2056
+ phrenMoveTo(visibleNodes[i].x, visibleNodes[i].y, visibleNodes[i]);
2057
+ return true;
2058
+ }
2059
+ }
2060
+ return false;
1906
2061
  }
1907
2062
  };
1908
2063
  })();
@@ -5,13 +5,14 @@ import * as yaml from "js-yaml";
5
5
  import { readInstallPreferences } from "./init-preferences.js";
6
6
  import { debugLog } from "./shared.js";
7
7
  import { errorMessage } from "./utils.js";
8
+ import { withFileLock } from "./governance-locks.js";
8
9
  export const PROJECT_OWNERSHIP_MODES = ["phren-managed", "detached", "repo-managed"];
9
10
  export const PROJECT_HOOK_EVENTS = ["UserPromptSubmit", "Stop", "SessionStart", "PostToolUse"];
10
11
  export function parseProjectOwnershipMode(raw) {
11
12
  if (!raw)
12
13
  return undefined;
13
14
  const normalized = raw.trim().toLowerCase();
14
- if (normalized === "phren" || normalized === "managed" || normalized === "phren")
15
+ if (normalized === "phren" || normalized === "managed")
15
16
  return "phren-managed";
16
17
  if (normalized === "repo" || normalized === "external")
17
18
  return "repo-managed";
@@ -37,17 +38,22 @@ export function readProjectConfig(phrenPath, project) {
37
38
  }
38
39
  }
39
40
  export function writeProjectConfig(phrenPath, project, patch) {
40
- const configPath = projectConfigPath(phrenPath, project);
41
- const current = readProjectConfig(phrenPath, project);
42
- const next = {
43
- ...current,
44
- ...patch,
45
- };
46
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
47
- const tmpPath = `${configPath}.tmp-${crypto.randomUUID()}`;
48
- fs.writeFileSync(tmpPath, yaml.dump(next, { lineWidth: 1000 }));
49
- fs.renameSync(tmpPath, configPath);
50
- return next;
41
+ const configPath = path.resolve(projectConfigPath(phrenPath, project));
42
+ if (!configPath.startsWith(phrenPath + path.sep) && configPath !== phrenPath) {
43
+ throw new Error(`Project config path escapes phren store`);
44
+ }
45
+ return withFileLock(configPath, () => {
46
+ const current = readProjectConfig(phrenPath, project);
47
+ const next = {
48
+ ...current,
49
+ ...patch,
50
+ };
51
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
52
+ const tmpPath = `${configPath}.tmp-${crypto.randomUUID()}`;
53
+ fs.writeFileSync(tmpPath, yaml.dump(next, { lineWidth: 1000 }));
54
+ fs.renameSync(tmpPath, configPath);
55
+ return next;
56
+ });
51
57
  }
52
58
  export function getProjectSourcePath(phrenPath, project, config) {
53
59
  const raw = (config ?? readProjectConfig(phrenPath, project)).sourcePath;
@@ -75,11 +81,24 @@ export function isProjectHookEnabled(phrenPath, project, event, config) {
75
81
  return true;
76
82
  }
77
83
  export function writeProjectHookConfig(phrenPath, project, patch) {
78
- const current = readProjectConfig(phrenPath, project);
79
- return writeProjectConfig(phrenPath, project, {
80
- hooks: {
81
- ...normalizeHookConfig(current),
82
- ...patch,
83
- },
84
+ // Move read+merge inside the lock so concurrent writers cannot clobber each other.
85
+ const configPath = path.resolve(projectConfigPath(phrenPath, project));
86
+ if (!configPath.startsWith(phrenPath + path.sep) && configPath !== phrenPath) {
87
+ throw new Error(`Project config path escapes phren store`);
88
+ }
89
+ return withFileLock(configPath, () => {
90
+ const current = readProjectConfig(phrenPath, project);
91
+ const next = {
92
+ ...current,
93
+ hooks: {
94
+ ...normalizeHookConfig(current),
95
+ ...patch,
96
+ },
97
+ };
98
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
99
+ const tmpPath = `${configPath}.tmp-${crypto.randomUUID()}`;
100
+ fs.writeFileSync(tmpPath, yaml.dump(next, { lineWidth: 1000 }));
101
+ fs.renameSync(tmpPath, configPath);
102
+ return next;
84
103
  });
85
104
  }
@@ -3,6 +3,6 @@ export { checkConsolidationNeeded, validateFindingsFormat, stripTaskDoneSection,
3
3
  export { filterTrustedFindings, filterTrustedFindingsDetailed, } from "./content-citation.js";
4
4
  export { scanForSecrets, resolveCoref, isDuplicateFinding, detectConflicts, extractDynamicEntities, checkSemanticDedup, checkSemanticConflicts, } from "./content-dedup.js";
5
5
  export { countActiveFindings, autoArchiveToReference, } from "./content-archive.js";
6
- export { upsertCanonical, addFindingToFile, addFindingsToFile, } from "./content-learning.js";
6
+ export { upsertCanonical, addFindingToFile, addFindingsToFile, autoDetectFindingType, } from "./content-learning.js";
7
7
  export { FINDING_LIFECYCLE_STATUSES, FINDING_TYPE_DECAY, extractFindingType, parseFindingLifecycle, buildLifecycleComments, isInactiveFindingLine, } from "./finding-lifecycle.js";
8
8
  export { METADATA_REGEX, parseStatus, parseStatusField, parseSupersession, parseSupersedesRef, parseContradiction, parseAllContradictions, parseFindingId, parseCreatedDate, isCitationLine, isArchiveStart, isArchiveEnd, stripLifecycleMetadata, stripRelationMetadata, stripAllMetadata, stripComments, addMetadata, } from "./content-metadata.js";
@@ -6,12 +6,13 @@ import { globSync } from "glob";
6
6
  import { debugLog, appendIndexEvent, getProjectDirs, collectNativeMemoryFiles, runtimeFile, homeDir, readRootManifest, } from "./shared.js";
7
7
  import { getIndexPolicy, withFileLock } from "./shared-governance.js";
8
8
  import { stripTaskDoneSection } from "./shared-content.js";
9
+ import { isInactiveFindingLine } from "./finding-lifecycle.js";
9
10
  import { invalidateDfCache } from "./shared-search-fallback.js";
10
11
  import { errorMessage } from "./utils.js";
11
12
  import { beginUserFragmentBuildCache, endUserFragmentBuildCache, extractAndLinkFragments, ensureGlobalEntitiesTable, } from "./shared-fragment-graph.js";
12
13
  import { bootstrapSqlJs } from "./shared-sqljs.js";
13
14
  import { getProjectOwnershipMode, getProjectSourcePath, readProjectConfig } from "./project-config.js";
14
- import { buildSourceDocKey, queryDocRows, queryRows, } from "./index-query.js";
15
+ import { buildSourceDocKey, queryDocBySourceKey, queryDocRows, } from "./index-query.js";
15
16
  import { classifyTopicForText, readProjectTopics, } from "./project-topics.js";
16
17
  export { porterStem } from "./shared-stemmer.js";
17
18
  export { cosineFallback } from "./shared-search-fallback.js";
@@ -101,6 +102,11 @@ export function classifyFile(filename, relPath) {
101
102
  }
102
103
  const IMPORT_RE = /^@import\s+(.+)$/gm;
103
104
  const MAX_IMPORT_DEPTH = 5;
105
+ const IMPORT_ROOT_PREFIX = "shared/";
106
+ function isAllowedImportPath(importPath) {
107
+ const normalized = importPath.replace(/\\/g, "/");
108
+ return normalized.startsWith(IMPORT_ROOT_PREFIX) && normalized.toLowerCase().endsWith(".md");
109
+ }
104
110
  /**
105
111
  * Internal recursive helper for resolveImports. Tracks `seen` (cycle detection) and `depth` (runaway
106
112
  * recursion guard) — callers should never pass these; use the public `resolveImports` instead.
@@ -110,6 +116,9 @@ function _resolveImportsRecursive(content, phrenPath, seen, depth) {
110
116
  return content;
111
117
  return content.replace(IMPORT_RE, (_match, importPath) => {
112
118
  const trimmed = importPath.trim();
119
+ if (!isAllowedImportPath(trimmed)) {
120
+ return "<!-- @import blocked: only shared/*.md allowed -->";
121
+ }
113
122
  const globalRoot = path.resolve(phrenPath, "global");
114
123
  const resolved = path.join(globalRoot, trimmed);
115
124
  // Use lexical resolution first for the prefix check
@@ -464,6 +473,10 @@ export function normalizeIndexedContent(content, type, phrenPath, maxChars) {
464
473
  if (type === "task") {
465
474
  normalized = stripTaskDoneSection(normalized);
466
475
  }
476
+ if (type === "findings") {
477
+ const lines = normalized.split("\n");
478
+ normalized = lines.filter(line => !isInactiveFindingLine(line)).join("\n");
479
+ }
467
480
  if (typeof maxChars === "number" && maxChars >= 0) {
468
481
  normalized = normalized.slice(0, maxChars);
469
482
  }
@@ -782,8 +795,8 @@ function mergeManualLinks(db, phrenPath) {
782
795
  for (const link of manualLinks) {
783
796
  try {
784
797
  // Validate: skip manual links whose sourceDoc no longer exists in the index
785
- const docCheck = queryRows(db, "SELECT 1 FROM docs WHERE source_key = ? LIMIT 1", [link.sourceDoc]);
786
- if (!docCheck || docCheck.length === 0) {
798
+ const docCheck = queryDocBySourceKey(db, phrenPath, link.sourceDoc);
799
+ if (!docCheck) {
787
800
  if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
788
801
  process.stderr.write(`[phren] manualLinks: pruning stale link to "${link.sourceDoc}"\n`);
789
802
  pruned = true;