@phren/cli 0.0.52 → 0.0.53

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,6 +8,7 @@ import { errorMessage, isValidProjectName } from "./utils.js";
8
8
  import { TASK_FILE_ALIASES } from "./data/tasks.js";
9
9
  import { withSafeLock } from "./shared/data-utils.js";
10
10
  import { logger } from "./logger.js";
11
+ import { getNonPrimaryStores, getStoreProjectDirs } from "./store-registry.js";
11
12
  export function resolveActiveProfile(phrenPath, requestedProfile) {
12
13
  const manifest = readRootManifest(phrenPath);
13
14
  if (manifest?.installMode === "project-local") {
@@ -308,12 +309,10 @@ export function listProjectCards(phrenPath, profile) {
308
309
  const seen = new Set(dirs.map((d) => path.basename(d)));
309
310
  // Include projects from team stores
310
311
  try {
311
- const storeRegistry = require("./store-registry.js");
312
- const { getNonPrimaryStores } = storeRegistry;
313
312
  for (const store of getNonPrimaryStores(phrenPath)) {
314
313
  if (!fs.existsSync(store.path))
315
314
  continue;
316
- for (const dir of getProjectDirs(store.path)) {
315
+ for (const dir of getStoreProjectDirs(store)) {
317
316
  const name = path.basename(dir);
318
317
  if (seen.has(name) || name === "global")
319
318
  continue;
@@ -28,13 +28,13 @@ function getAllStoreProjectDirs(phrenPath, profile) {
28
28
  */
29
29
  async function refreshStoreProjectDirs(phrenPath, profile) {
30
30
  try {
31
- const { getNonPrimaryStores } = await import("../store-registry.js");
31
+ const { getNonPrimaryStores, getStoreProjectDirs } = await import("../store-registry.js");
32
32
  const otherStores = getNonPrimaryStores(phrenPath);
33
33
  const dirs = [];
34
34
  for (const store of otherStores) {
35
35
  if (!fs.existsSync(store.path))
36
36
  continue;
37
- dirs.push(...getProjectDirs(store.path));
37
+ dirs.push(...getStoreProjectDirs(store));
38
38
  }
39
39
  _cachedStoreProjectDirs = dirs;
40
40
  _cachedStorePhrenPath = phrenPath;
@@ -457,7 +457,8 @@ function renderMemoryQueueView(ctx, cursor, height) {
457
457
  return vp.lines;
458
458
  }
459
459
  export function getProjectSkills(phrenPath, project) {
460
- return getScopedSkills(phrenPath, "", project).map((skill) => ({
460
+ const storePath = resolveProjectStorePath(phrenPath, project);
461
+ return getScopedSkills(storePath, "", project).map((skill) => ({
461
462
  name: skill.name,
462
463
  path: skill.path,
463
464
  enabled: skill.enabled,
@@ -541,10 +542,11 @@ const LIFECYCLE_HOOKS = [
541
542
  export function getHookEntries(phrenPath, project) {
542
543
  const prefs = readInstallPreferences(phrenPath);
543
544
  const hooksEnabled = prefs.hooksEnabled !== false;
544
- const projectConfig = project ? readProjectConfig(phrenPath, project) : undefined;
545
+ const storePath = project ? resolveProjectStorePath(phrenPath, project) : phrenPath;
546
+ const projectConfig = project ? readProjectConfig(storePath, project) : undefined;
545
547
  return LIFECYCLE_HOOKS.map((h) => ({
546
548
  ...h,
547
- enabled: hooksEnabled && isProjectHookEnabled(phrenPath, project, h.event, projectConfig),
549
+ enabled: hooksEnabled && isProjectHookEnabled(storePath, project, h.event, projectConfig),
548
550
  }));
549
551
  }
550
552
  function renderHooksView(ctx, cursor, height) {
@@ -5,6 +5,7 @@ import * as yaml from "js-yaml";
5
5
  import { expandHomePath, atomicWriteText } from "./phren-paths.js";
6
6
  import { withFileLock } from "./governance/locks.js";
7
7
  import { isRecord, PhrenError } from "./phren-core.js";
8
+ import { getProjectDirs } from "./shared.js";
8
9
  // ── Constants ────────────────────────────────────────────────────────────────
9
10
  const STORES_FILENAME = "stores.yaml";
10
11
  const TEAM_BOOTSTRAP_FILENAME = ".phren-team.yaml";
@@ -110,6 +111,14 @@ export function getNonPrimaryStores(phrenPath) {
110
111
  export function findStoreByName(phrenPath, name) {
111
112
  return resolveAllStores(phrenPath).find((s) => s.name === name);
112
113
  }
114
+ /** Get project directories for a store, filtered by the store's subscription list (if set). */
115
+ export function getStoreProjectDirs(store) {
116
+ const allDirs = getProjectDirs(store.path);
117
+ if (!store.projects || store.projects.length === 0)
118
+ return allDirs;
119
+ const allowed = new Set(store.projects);
120
+ return allDirs.filter(dir => path.basename(dir) !== "global" && allowed.has(path.basename(dir)));
121
+ }
113
122
  // ── Team bootstrap ───────────────────────────────────────────────────────────
114
123
  export function readTeamBootstrap(storePath) {
115
124
  const filePath = path.join(storePath, TEAM_BOOTSTRAP_FILENAME);
@@ -178,6 +187,38 @@ export function updateStoreProjects(phrenPath, storeName, projects) {
178
187
  writeStoreRegistry(phrenPath, registry);
179
188
  });
180
189
  }
190
+ /** Add projects to a store's subscription list. Deduplicates. Uses file locking. */
191
+ export function subscribeStoreProjects(phrenPath, storeName, projects) {
192
+ withFileLock(storesFilePath(phrenPath), () => {
193
+ const registry = readStoreRegistry(phrenPath);
194
+ if (!registry)
195
+ throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`);
196
+ const store = registry.stores.find((s) => s.name === storeName);
197
+ if (!store)
198
+ throw new Error(`${PhrenError.NOT_FOUND}: Store "${storeName}" not found`);
199
+ const existing = new Set(store.projects || []);
200
+ for (const project of projects) {
201
+ existing.add(project);
202
+ }
203
+ store.projects = Array.from(existing).sort().length > 0 ? Array.from(existing).sort() : undefined;
204
+ writeStoreRegistry(phrenPath, registry);
205
+ });
206
+ }
207
+ /** Remove projects from a store's subscription list. Uses file locking. */
208
+ export function unsubscribeStoreProjects(phrenPath, storeName, projects) {
209
+ withFileLock(storesFilePath(phrenPath), () => {
210
+ const registry = readStoreRegistry(phrenPath);
211
+ if (!registry)
212
+ throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`);
213
+ const store = registry.stores.find((s) => s.name === storeName);
214
+ if (!store)
215
+ throw new Error(`${PhrenError.NOT_FOUND}: Store "${storeName}" not found`);
216
+ const toRemove = new Set(projects);
217
+ const remaining = (store.projects || []).filter((p) => !toRemove.has(p));
218
+ store.projects = remaining.length > 0 ? remaining : undefined;
219
+ writeStoreRegistry(phrenPath, registry);
220
+ });
221
+ }
181
222
  // ── Validation ───────────────────────────────────────────────────────────────
182
223
  function validateRegistry(registry) {
183
224
  if (registry.version !== 1)
@@ -282,15 +282,16 @@ export function register(server, ctx) {
282
282
  }, async ({ project, action }) => {
283
283
  if (!isValidProjectName(project))
284
284
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
285
+ const resolved = resolveStoreForProject(ctx, project);
285
286
  return withWriteQueue(async () => {
286
- const activeProject = findProjectNameCaseInsensitive(phrenPath, project);
287
- const archivedProject = findArchivedProjectNameCaseInsensitive(phrenPath, project);
287
+ const activeProject = findProjectNameCaseInsensitive(resolved.phrenPath, project);
288
+ const archivedProject = findArchivedProjectNameCaseInsensitive(resolved.phrenPath, project);
288
289
  if (action === "archive") {
289
290
  if (!activeProject) {
290
291
  return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
291
292
  }
292
- const projectDir = path.join(phrenPath, activeProject);
293
- const archiveDir = path.join(phrenPath, `${activeProject}.archived`);
293
+ const projectDir = path.join(resolved.phrenPath, activeProject);
294
+ const archiveDir = path.join(resolved.phrenPath, `${activeProject}.archived`);
294
295
  if (!fs.existsSync(projectDir)) {
295
296
  return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
296
297
  }
@@ -316,12 +317,12 @@ export function register(server, ctx) {
316
317
  return mcpResponse({ ok: false, error: `Project "${activeProject}" already exists as an active project.` });
317
318
  }
318
319
  if (!archivedProject) {
319
- const entries = fs.readdirSync(phrenPath).filter((e) => e.endsWith(".archived"));
320
+ const entries = fs.readdirSync(resolved.phrenPath).filter((e) => e.endsWith(".archived"));
320
321
  const available = entries.map((e) => e.replace(/\.archived$/, ""));
321
322
  return mcpResponse({ ok: false, error: `No archive found for "${project}".`, data: { availableArchives: available } });
322
323
  }
323
- const projectDir = path.join(phrenPath, archivedProject);
324
- const archiveDir = path.join(phrenPath, `${archivedProject}.archived`);
324
+ const projectDir = path.join(resolved.phrenPath, archivedProject);
325
+ const archiveDir = path.join(resolved.phrenPath, `${archivedProject}.archived`);
325
326
  fs.renameSync(archiveDir, projectDir);
326
327
  try {
327
328
  await rebuildIndex();
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import { createHash } from "crypto";
6
+ import { execFileSync } from "child_process";
6
7
  import { isValidProjectName, errorMessage } from "../utils.js";
7
8
  import { resolveAllStores } from "../store-registry.js";
8
9
  import { readFindings } from "../data/access.js";
@@ -529,14 +530,13 @@ async function handleListProjects(ctx, { page, page_size }) {
529
530
  ? projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0])
530
531
  : [];
531
532
  // Gather projects from non-primary stores
532
- const { getNonPrimaryStores } = await import("../store-registry.js");
533
- const { getProjectDirs } = await import("../phren-paths.js");
533
+ const { getNonPrimaryStores, getStoreProjectDirs } = await import("../store-registry.js");
534
534
  const nonPrimaryStores = getNonPrimaryStores(phrenPath);
535
535
  const storeProjects = [];
536
536
  for (const store of nonPrimaryStores) {
537
537
  if (!fs.existsSync(store.path))
538
538
  continue;
539
- const dirs = getProjectDirs(store.path);
539
+ const dirs = getStoreProjectDirs(store);
540
540
  for (const dir of dirs) {
541
541
  const projName = path.basename(dir);
542
542
  storeProjects.push({ name: projName, store: store.name });
@@ -691,12 +691,11 @@ async function handleStoreList(ctx) {
691
691
  const { phrenPath } = ctx;
692
692
  const stores = resolveAllStores(phrenPath);
693
693
  const storeData = stores.map((store) => {
694
- let health = null;
694
+ // Get last sync time from git log
695
+ let lastSync = null;
695
696
  try {
696
- const healthPath = path.join(store.path, ".runtime", "health.json");
697
- if (fs.existsSync(healthPath)) {
698
- health = JSON.parse(fs.readFileSync(healthPath, "utf8"))?.lastSync ?? null;
699
- }
697
+ const result = execFileSync("git", ["log", "-1", "--format=%ci"], { cwd: store.path, encoding: "utf8", timeout: 3000 }).trim();
698
+ lastSync = result || null;
700
699
  }
701
700
  catch { /* non-critical */ }
702
701
  return {
@@ -708,7 +707,7 @@ async function handleStoreList(ctx) {
708
707
  remote: store.remote ?? null,
709
708
  exists: fs.existsSync(store.path),
710
709
  projects: store.projects ?? null,
711
- lastSync: health,
710
+ lastSync,
712
711
  };
713
712
  });
714
713
  const lines = storeData.map((s) => {
@@ -355,11 +355,11 @@ export async function getSessionArtifacts(phrenPath, sessionId, project) {
355
355
  }
356
356
  // Team store projects
357
357
  try {
358
- const { getNonPrimaryStores } = await import("../store-registry.js");
358
+ const { getNonPrimaryStores, getStoreProjectDirs } = await import("../store-registry.js");
359
359
  for (const store of getNonPrimaryStores(phrenPath)) {
360
360
  if (!fs.existsSync(store.path))
361
361
  continue;
362
- const storeDirs = getProjectDirs(store.path).map(d => path.basename(d)).filter(p => p !== "global");
362
+ const storeDirs = getStoreProjectDirs(store).map((d) => path.basename(d)).filter((p) => p !== "global");
363
363
  const storeTargetProjects = project ? (storeDirs.includes(project) ? [project] : []) : storeDirs;
364
364
  for (const proj of storeTargetProjects) {
365
365
  readProjectArtifacts(store.path, proj);
@@ -2,7 +2,7 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { createHash } from "crypto";
4
4
  import { getProjectDirs, runtimeDir, runtimeHealthFile, memoryUsageLogFile, homePath, } from "../shared.js";
5
- import { getNonPrimaryStores } from "../store-registry.js";
5
+ import { getNonPrimaryStores, getStoreProjectDirs } from "../store-registry.js";
6
6
  import { errorMessage } from "../utils.js";
7
7
  import { readInstallPreferences } from "../init/preferences.js";
8
8
  import { readCustomHooks } from "../hooks.js";
@@ -158,11 +158,15 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
158
158
  .filter((project) => project !== "global");
159
159
  for (const project of primaryProjects)
160
160
  storeProjects.push({ storePath: phrenPath, project });
161
+ // Map store paths to store names
162
+ const storePathToName = new Map();
163
+ storePathToName.set(phrenPath, "primary");
161
164
  for (const store of getNonPrimaryStores(phrenPath)) {
162
165
  if (!fs.existsSync(store.path))
163
166
  continue;
167
+ storePathToName.set(store.path, store.name);
164
168
  try {
165
- const storeProjectDirs = getProjectDirs(store.path)
169
+ const storeProjectDirs = getStoreProjectDirs(store)
166
170
  .map((projectDir) => path.basename(projectDir))
167
171
  .filter((project) => project !== "global");
168
172
  for (const project of storeProjectDirs)
@@ -187,6 +191,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
187
191
  }
188
192
  }
189
193
  const findingsPath = path.join(storePath, project, "FINDINGS.md");
194
+ const storeName = storePathToName.get(storePath) || "unknown";
190
195
  if (!fs.existsSync(findingsPath)) {
191
196
  if (!addedProjectNodeIds.has(project)) {
192
197
  addedProjectNodeIds.add(project);
@@ -197,6 +202,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
197
202
  group: "project",
198
203
  refCount: 0,
199
204
  project,
205
+ store: storeName,
200
206
  tagged: false,
201
207
  });
202
208
  }
@@ -211,6 +217,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
211
217
  group: "project",
212
218
  refCount: 1,
213
219
  project,
220
+ store: storeName,
214
221
  tagged: false,
215
222
  });
216
223
  }
@@ -263,6 +270,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
263
270
  group: `topic:${topic.slug}`,
264
271
  refCount: taggedCount,
265
272
  project,
273
+ store: storeName,
266
274
  tagged: true,
267
275
  scoreKey,
268
276
  scoreKeys: [scoreKey],
@@ -297,6 +305,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
297
305
  group: `topic:${topic.slug}`,
298
306
  refCount: taggedCount,
299
307
  project,
308
+ store: storeName,
300
309
  tagged: true,
301
310
  scoreKey,
302
311
  scoreKeys: [scoreKey],
@@ -331,6 +340,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
331
340
  group: `topic:${topic.slug}`,
332
341
  refCount: untaggedAdded,
333
342
  project,
343
+ store: storeName,
334
344
  tagged: false,
335
345
  scoreKey,
336
346
  scoreKeys: [scoreKey],
@@ -347,6 +357,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
347
357
  const taskResult = readTasks(storePath, project);
348
358
  if (!taskResult.ok)
349
359
  continue;
360
+ const taskStoreName = storePathToName.get(storePath) || "unknown";
350
361
  const doc = taskResult.data;
351
362
  let taskCount = 0;
352
363
  const MAX_TASKS = 50;
@@ -364,6 +375,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
364
375
  fullLabel: item.line,
365
376
  group,
366
377
  project,
378
+ store: taskStoreName,
367
379
  tagged: false,
368
380
  scoreKey,
369
381
  scoreKeys: [scoreKey],
@@ -471,6 +483,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
471
483
  // ── Reference docs ────────────────────────────────────────────────
472
484
  try {
473
485
  for (const { storePath, project } of storeProjects) {
486
+ const refStoreName = storePathToName.get(storePath) || "unknown";
474
487
  const refDir = path.join(storePath, project, "reference");
475
488
  if (!fs.existsSync(refDir) || !fs.statSync(refDir).isDirectory())
476
489
  continue;
@@ -488,6 +501,7 @@ export async function buildGraph(phrenPath, profile, focusProject, existingDb) {
488
501
  fullLabel: file,
489
502
  group: "reference",
490
503
  project,
504
+ store: refStoreName,
491
505
  tagged: false,
492
506
  scoreKeys: [],
493
507
  refDocs: [{ doc: docRef, project }],
@@ -640,7 +654,7 @@ export function collectProjectsForUI(phrenPath, profile) {
640
654
  for (const store of teamStores) {
641
655
  if (!fs.existsSync(store.path))
642
656
  continue;
643
- const teamProjects = getProjectDirs(store.path).map((d) => path.basename(d)).filter((p) => p !== "global");
657
+ const teamProjects = getStoreProjectDirs(store).map((d) => path.basename(d)).filter((p) => p !== "global");
644
658
  for (const project of teamProjects) {
645
659
  if (seen.has(project))
646
660
  continue; // skip if same name exists in primary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/cli",
3
- "version": "0.0.52",
3
+ "version": "0.0.53",
4
4
  "description": "Knowledge layer for AI agents. Phren learns and recalls.",
5
5
  "type": "module",
6
6
  "bin": {