@phren/cli 0.0.6 → 0.0.8

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.
@@ -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
  })();
@@ -134,7 +134,7 @@ let cachedPhrenPath;
134
134
  let cachedPhrenPathKey;
135
135
  export function findPhrenPath() {
136
136
  const cacheKey = [
137
- ((process.env.PHREN_PATH || process.env.PHREN_PATH) ?? ""),
137
+ ((process.env.PHREN_PATH) ?? ""),
138
138
  process.env.HOME ?? "",
139
139
  process.env.USERPROFILE ?? "",
140
140
  process.cwd(),
@@ -142,7 +142,7 @@ export function findPhrenPath() {
142
142
  if (cachedPhrenPath !== undefined && cachedPhrenPathKey === cacheKey)
143
143
  return cachedPhrenPath;
144
144
  cachedPhrenPathKey = cacheKey;
145
- const envVal = (process.env.PHREN_PATH || process.env.PHREN_PATH)?.trim();
145
+ const envVal = (process.env.PHREN_PATH)?.trim();
146
146
  if (envVal) {
147
147
  const resolved = path.resolve(expandHomePath(envVal));
148
148
  cachedPhrenPath = isPhrenRootCandidate(resolved) ? resolved : null;
@@ -170,7 +170,7 @@ export function ensurePhrenPath() {
170
170
  });
171
171
  cachedPhrenPath = defaultPath;
172
172
  cachedPhrenPathKey = [
173
- ((process.env.PHREN_PATH || process.env.PHREN_PATH) ?? ""),
173
+ ((process.env.PHREN_PATH) ?? ""),
174
174
  process.env.HOME ?? "",
175
175
  process.env.USERPROFILE ?? "",
176
176
  process.cwd(),
@@ -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
  }
@@ -36,7 +36,7 @@ const LOW_VALUE_BULLET_FRACTION = 0.5;
36
36
  // ── Intent and scoring helpers ───────────────────────────────────────────────
37
37
  export function detectTaskIntent(prompt) {
38
38
  const p = prompt.toLowerCase();
39
- if (/\/\w+/.test(p) || /\b(skill|swarm|command|lineup|slash command)\b/.test(p))
39
+ if (/(^|\s)\/[a-z][a-z0-9_-]{1,63}(?=$|\s|[.,:;!?])/.test(p) || /\b(skill|swarm|lineup|slash command)\b/.test(p))
40
40
  return "skill";
41
41
  if (/(bug|error|fix|broken|regression|fail|stack trace)/.test(p))
42
42
  return "debug";
@@ -400,7 +400,7 @@ export async function searchDocumentsAsync(db, safeQuery, prompt, keywords, dete
400
400
  }
401
401
  catch (err) {
402
402
  // Vector search failure is non-fatal — return sync result
403
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
403
+ if (process.env.PHREN_DEBUG)
404
404
  process.stderr.write(`[phren] hybridSearch vectorFallback: ${err instanceof Error ? err.message : String(err)}\n`);
405
405
  return syncResult;
406
406
  }
@@ -500,7 +500,7 @@ export async function searchKnowledgeRows(db, options) {
500
500
  }
501
501
  }
502
502
  catch (err) {
503
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG)) {
503
+ if (process.env.PHREN_DEBUG) {
504
504
  process.stderr.write(`[phren] vectorFallback: ${err instanceof Error ? err.message : String(err)}\n`);
505
505
  }
506
506
  }
@@ -754,7 +754,7 @@ export function markStaleCitations(snippet) {
754
754
  }
755
755
  }
756
756
  catch (err) {
757
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
757
+ if (process.env.PHREN_DEBUG)
758
758
  process.stderr.write(`[phren] applyCitationAnnotations fileRead: ${err instanceof Error ? err.message : String(err)}\n`);
759
759
  stale = true;
760
760
  }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { debugLog, runtimeFile } from "./phren-paths.js";
4
- import { errorMessage, isValidProjectName } from "./utils.js";
4
+ import { errorMessage } from "./utils.js";
5
5
  export { HOOK_TOOL_NAMES, hookConfigPath } from "./provider-adapters.js";
6
6
  export { EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS, PhrenError, phrenOk, phrenErr, forwardErr, parsePhrenErrorCode, isRecord, withDefaults, FINDING_TYPES, FINDING_TAGS, KNOWN_OBSERVATION_TAGS, DOC_TYPES, capCache, } from "./phren-core.js";
7
7
  export { ROOT_MANIFEST_FILENAME, homeDir, homePath, expandHomePath, defaultPhrenPath, rootManifestPath, readRootManifest, writeRootManifest, resolveInstallContext, findNearestPhrenPath, isProjectLocalMode, runtimeDir, tryUnlink, sessionsDir, runtimeFile, installPreferencesFile, runtimeHealthFile, shellStateFile, sessionMetricsFile, memoryScoresFile, memoryUsageLogFile, sessionMarker, debugLog, appendIndexEvent, resolveFindingsPath, findPhrenPath, ensurePhrenPath, findPhrenPathWithArg, normalizeProjectNameForCreate, findProjectNameCaseInsensitive, getProjectDirs, collectNativeMemoryFiles, computePhrenLiveStateToken, getPhrenPath, qualityMarkers, atomicWriteText, } from "./phren-paths.js";
@@ -28,15 +28,6 @@ export function isMemoryScopeVisible(itemScope, activeScope) {
28
28
  export function impactLogFile(phrenPath) {
29
29
  return runtimeFile(phrenPath, "impact.jsonl");
30
30
  }
31
- function isProjectDirEntry(entry) {
32
- return entry.isDirectory()
33
- && !entry.name.startsWith(".")
34
- && !entry.name.endsWith(".archived")
35
- && !RESERVED_PROJECT_DIR_NAMES.has(entry.name);
36
- }
37
- function isCanonicalProjectDirName(name) {
38
- return name === name.toLowerCase() && isValidProjectName(name);
39
- }
40
31
  export function appendAuditLog(phrenPath, event, details) {
41
32
  const logPath = runtimeFile(phrenPath, "audit.log");
42
33
  const line = `[${new Date().toISOString()}] ${event} ${details}\n`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/cli",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Knowledge layer for AI agents. Claude remembers you. Phren remembers your work.",
5
5
  "type": "module",
6
6
  "bin": {