@phren/cli 0.1.11 → 0.1.13

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.
@@ -428,8 +428,13 @@ async function runBestEffortGit(args, cwd) {
428
428
  }
429
429
  async function countUnsyncedCommits(cwd) {
430
430
  const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
431
- if (!upstream.ok || !upstream.output)
432
- return 0;
431
+ if (!upstream.ok || !upstream.output) {
432
+ const allCommits = await runBestEffortGit(["rev-list", "--count", "HEAD"], cwd);
433
+ if (!allCommits.ok || !allCommits.output)
434
+ return 0;
435
+ const parsed = Number.parseInt(allCommits.output.trim(), 10);
436
+ return Number.isNaN(parsed) ? 0 : parsed;
437
+ }
433
438
  const ahead = await runBestEffortGit(["rev-list", "--count", `${upstream.output.trim()}..HEAD`], cwd);
434
439
  if (!ahead.ok || !ahead.output)
435
440
  return 0;
@@ -1719,6 +1719,26 @@ export async function handleStoreNamespace(args) {
1719
1719
  stdio: "pipe",
1720
1720
  timeout: 30_000,
1721
1721
  });
1722
+ // Re-apply sparse-checkout after pull on primary store to avoid materializing all files
1723
+ if (store.role === "primary") {
1724
+ try {
1725
+ const sparseList = execFileSync("git", ["sparse-checkout", "list"], {
1726
+ cwd: store.path,
1727
+ stdio: "pipe",
1728
+ timeout: 10_000,
1729
+ }).toString().trim();
1730
+ if (sparseList) {
1731
+ execFileSync("git", ["sparse-checkout", "reapply"], {
1732
+ cwd: store.path,
1733
+ stdio: "pipe",
1734
+ timeout: 10_000,
1735
+ });
1736
+ }
1737
+ }
1738
+ catch {
1739
+ // sparse-checkout not configured — nothing to reapply
1740
+ }
1741
+ }
1722
1742
  console.log(` ${store.name}: ok`);
1723
1743
  }
1724
1744
  catch (err) {
package/dist/cli/team.js CHANGED
@@ -7,6 +7,7 @@ import * as path from "path";
7
7
  import { execFileSync } from "child_process";
8
8
  import { getPhrenPath } from "../shared.js";
9
9
  import { isValidProjectName } from "../utils.js";
10
+ import { addProjectToProfile, resolveActiveProfile } from "../profile-store.js";
10
11
  import { addStoreToRegistry, findStoreByName, generateStoreId, readTeamBootstrap, updateStoreProjects, } from "../store-registry.js";
11
12
  const EXEC_TIMEOUT_MS = 30_000;
12
13
  function getOptionValue(args, name) {
@@ -243,6 +244,17 @@ async function handleTeamAddProject(args) {
243
244
  if (!currentProjects.includes(projectName)) {
244
245
  updateStoreProjects(phrenPath, storeName, [...currentProjects, projectName]);
245
246
  }
247
+ // Add project to active profile so it's visible to profile-filtered operations
248
+ const activeProfile = resolveActiveProfile(phrenPath);
249
+ if (activeProfile.ok && activeProfile.data) {
250
+ const addResult = addProjectToProfile(phrenPath, activeProfile.data, projectName);
251
+ if (!addResult.ok) {
252
+ throw new Error(addResult.error);
253
+ }
254
+ }
255
+ else if (!activeProfile.ok && activeProfile.code !== "FILE_NOT_FOUND") {
256
+ throw new Error(activeProfile.error);
257
+ }
246
258
  console.log(`Added project "${projectName}" to team store "${storeName}"`);
247
259
  console.log(` Path: ${projectDir}`);
248
260
  console.log(` Journal: ${journalDir}`);
@@ -123,8 +123,15 @@ export async function runBestEffortGit(args, cwd) {
123
123
  }
124
124
  export async function countUnsyncedCommits(cwd) {
125
125
  const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
126
- if (!upstream.ok || !upstream.output)
127
- return 0;
126
+ if (!upstream.ok || !upstream.output) {
127
+ // No upstream tracking branch — count all local commits as unsynced
128
+ // so the warning at the call site fires instead of silently returning 0
129
+ const allCommits = await runBestEffortGit(["rev-list", "--count", "HEAD"], cwd);
130
+ if (!allCommits.ok || !allCommits.output)
131
+ return 0;
132
+ const parsed = Number.parseInt(allCommits.output.trim(), 10);
133
+ return Number.isNaN(parsed) ? 0 : parsed;
134
+ }
128
135
  const ahead = await runBestEffortGit(["rev-list", "--count", `${upstream.output.trim()}..HEAD`], cwd);
129
136
  if (!ahead.ok || !ahead.output)
130
137
  return 0;
@@ -435,32 +435,20 @@ export async function handleHookStop() {
435
435
  return;
436
436
  }
437
437
  // Check if HEAD has an upstream tracking branch before attempting sync.
438
- // Detached HEAD or branches without upstream would cause silent push failures.
438
+ // If no upstream is set but a remote exists, auto-set it to avoid silent push failures.
439
439
  const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], phrenPath);
440
440
  if (!upstream.ok || !upstream.output) {
441
- const unsyncedCommits = await countUnsyncedCommits(phrenPath);
442
- const noUpstreamDetail = "commit created; no upstream tracking branch";
443
- finalizeTaskSession({
444
- phrenPath,
445
- sessionId: taskSessionId,
446
- status: "no-upstream",
447
- detail: noUpstreamDetail,
448
- });
449
- updateRuntimeHealth(phrenPath, {
450
- lastStopAt: now,
451
- lastAutoSave: { at: now, status: "no-upstream", detail: noUpstreamDetail },
452
- lastSync: {
453
- lastPushAt: now,
454
- lastPushStatus: "no-upstream",
455
- lastPushDetail: noUpstreamDetail,
456
- unsyncedCommits,
457
- },
458
- });
459
- appendAuditLog(phrenPath, "hook_stop", "status=no-upstream");
460
- if (unsyncedCommits > 3) {
461
- process.stderr.write(`phren: ${unsyncedCommits} unsynced commits — no upstream tracking branch.\n`);
441
+ // Try to auto-set upstream: get current branch and set tracking to origin/<branch>
442
+ const branch = await runBestEffortGit(["rev-parse", "--abbrev-ref", "HEAD"], phrenPath);
443
+ if (branch.ok && branch.output) {
444
+ const branchName = branch.output.trim();
445
+ const setUpstream = await runBestEffortGit(["branch", "--set-upstream-to", `origin/${branchName}`, branchName], phrenPath);
446
+ if (!setUpstream.ok) {
447
+ // Upstream auto-set failed — log and continue to sync anyway
448
+ logger.debug("hookStop", `failed to auto-set upstream for ${branchName}`);
449
+ }
462
450
  }
463
- return;
451
+ // Fall through to scheduleBackgroundSync instead of returning early
464
452
  }
465
453
  const unsyncedCommits = await countUnsyncedCommits(phrenPath);
466
454
  const scheduled = scheduleBackgroundSync(phrenPath);
@@ -310,8 +310,19 @@ export function listProjectCards(phrenPath, profile) {
310
310
  const dirs = getProjectDirs(phrenPath, profile).sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
311
311
  const cards = dirs.map(buildProjectCard);
312
312
  const seen = new Set(dirs.map((d) => path.basename(d)));
313
- // Include projects from team stores
313
+ // Include projects from team stores, filtered by active profile
314
314
  try {
315
+ // Resolve the profile's project allow-list (if any)
316
+ let profileProjectNames;
317
+ if (profile) {
318
+ const profiles = listProfiles(phrenPath);
319
+ if (profiles.ok) {
320
+ const active = profiles.data.find((p) => p.name === profile);
321
+ if (active && active.projects.length > 0) {
322
+ profileProjectNames = new Set(active.projects);
323
+ }
324
+ }
325
+ }
315
326
  for (const store of getNonPrimaryStores(phrenPath)) {
316
327
  if (!fs.existsSync(store.path))
317
328
  continue;
@@ -319,6 +330,8 @@ export function listProjectCards(phrenPath, profile) {
319
330
  const name = path.basename(dir);
320
331
  if (seen.has(name) || name === "global")
321
332
  continue;
333
+ if (profileProjectNames && !profileProjectNames.has(name))
334
+ continue;
322
335
  seen.add(name);
323
336
  cards.push(buildProjectCard(dir));
324
337
  }
@@ -30,12 +30,30 @@ async function refreshStoreProjectDirs(phrenPath, profile) {
30
30
  try {
31
31
  const { getNonPrimaryStores, getStoreProjectDirs } = await import("../store-registry.js");
32
32
  const otherStores = getNonPrimaryStores(phrenPath);
33
- const dirs = [];
33
+ let dirs = [];
34
34
  for (const store of otherStores) {
35
35
  if (!fs.existsSync(store.path))
36
36
  continue;
37
37
  dirs.push(...getStoreProjectDirs(store));
38
38
  }
39
+ // Filter by active profile's project list, matching getProjectDirs behavior
40
+ if (profile) {
41
+ const profilePath = path.join(phrenPath, "profiles", `${profile}.yaml`);
42
+ if (fs.existsSync(profilePath)) {
43
+ try {
44
+ const yaml = await import("js-yaml");
45
+ const data = yaml.load(fs.readFileSync(profilePath, "utf-8"), { schema: yaml.CORE_SCHEMA });
46
+ const projects = data?.projects;
47
+ if (Array.isArray(projects)) {
48
+ const allowed = new Set(projects.map(String));
49
+ dirs = dirs.filter(dir => allowed.has(path.basename(dir)));
50
+ }
51
+ }
52
+ catch {
53
+ // Profile parse error — include all dirs as fallback
54
+ }
55
+ }
56
+ }
39
57
  _cachedStoreProjectDirs = dirs;
40
58
  _cachedStorePhrenPath = phrenPath;
41
59
  }
@@ -445,8 +463,9 @@ function globAllFiles(phrenPath, profile) {
445
463
  const allAbsolutePaths = [];
446
464
  for (const dir of projectDirs) {
447
465
  const projectName = path.basename(dir);
448
- const config = readProjectConfig(phrenPath, projectName);
449
- const ownership = getProjectOwnershipMode(phrenPath, projectName, config);
466
+ const storePath = path.dirname(dir);
467
+ const config = readProjectConfig(storePath, projectName);
468
+ const ownership = getProjectOwnershipMode(storePath, projectName, config);
450
469
  const mdFilesSet = new Set();
451
470
  for (const pattern of indexPolicy.includeGlobs) {
452
471
  const dot = indexPolicy.includeHidden || pattern.startsWith(".") || pattern.includes("/.");
@@ -495,20 +495,21 @@ export async function searchKnowledgeRows(db, options) {
495
495
  * Returns an array of results tagged with their source store. Read-only — no mutations.
496
496
  */
497
497
  export async function searchFederatedStores(localPhrenPath, options) {
498
- let nonPrimaryStores;
498
+ // Registered non-primary stores are already included in the main FTS index
499
+ // by buildIndex (via refreshStoreProjectDirs). Only search unregistered
500
+ // federation paths from PHREN_FEDERATION_PATHS to avoid double indexing.
501
+ let registeredStorePaths;
499
502
  try {
500
503
  const { getNonPrimaryStores } = await import("../store-registry.js");
501
- nonPrimaryStores = getNonPrimaryStores(localPhrenPath).map((s) => ({
502
- path: s.path, name: s.name, id: s.id,
503
- }));
504
+ registeredStorePaths = new Set(getNonPrimaryStores(localPhrenPath).map((s) => s.path));
504
505
  }
505
506
  catch {
506
- // Fallback: parse PHREN_FEDERATION_PATHS directly (pre-registry compat)
507
- const raw = process.env.PHREN_FEDERATION_PATHS ?? "";
508
- nonPrimaryStores = raw.split(":").map((p) => p.trim())
509
- .filter((p) => p.length > 0 && p !== localPhrenPath && fs.existsSync(p))
510
- .map((p) => ({ path: p, name: path.basename(p), id: "" }));
507
+ registeredStorePaths = new Set();
511
508
  }
509
+ const raw = process.env.PHREN_FEDERATION_PATHS ?? "";
510
+ const nonPrimaryStores = raw.split(":").map((p) => p.trim())
511
+ .filter((p) => p.length > 0 && p !== localPhrenPath && !registeredStorePaths.has(p) && fs.existsSync(p))
512
+ .map((p) => ({ path: p, name: path.basename(p), id: "" }));
512
513
  if (nonPrimaryStores.length === 0)
513
514
  return [];
514
515
  const allRows = [];
package/dist/tools/ops.js CHANGED
@@ -10,16 +10,48 @@ import { addProjectFromPath } from "../core/project.js";
10
10
  import { PROJECT_OWNERSHIP_MODES, parseProjectOwnershipMode } from "../project-config.js";
11
11
  import { resolveRuntimeProfile } from "../runtime-profile.js";
12
12
  import { getMachineName } from "../machine-identity.js";
13
+ import { resolveAllStores } from "../store-registry.js";
13
14
  import { getProjectConsolidationStatus, CONSOLIDATION_ENTRY_THRESHOLD } from "../content/validate.js";
14
15
  import { logger } from "../logger.js";
15
16
  import { getRuntimeHealth } from "../governance/policy.js";
16
17
  import { countUnsyncedCommits } from "../cli-hooks-git.js";
17
18
  // ── Handlers ─────────────────────────────────────────────────────────────────
18
- async function handleAddProject(ctx, { path: targetPath, profile: requestedProfile, ownership }) {
19
+ async function handleAddProject(ctx, { path: targetPath, profile: requestedProfile, ownership, store: storeName }) {
19
20
  const { phrenPath, profile, withWriteQueue } = ctx;
20
21
  return withWriteQueue(async () => {
21
22
  try {
22
- const added = addProjectFromPath(phrenPath, targetPath, requestedProfile || profile || undefined, parseProjectOwnershipMode(ownership) ?? undefined);
23
+ // Resolve the target store path: explicit store > auto-route by project claim > primary
24
+ let targetPhrenPath = phrenPath;
25
+ let storeRole = "primary";
26
+ if (storeName) {
27
+ const stores = resolveAllStores(phrenPath);
28
+ const store = stores.find((s) => s.name === storeName);
29
+ if (!store) {
30
+ return mcpResponse({ ok: false, error: `Store "${storeName}" not found` });
31
+ }
32
+ if (store.role === "readonly") {
33
+ return mcpResponse({ ok: false, error: `Store "${storeName}" is read-only` });
34
+ }
35
+ targetPhrenPath = store.path;
36
+ storeRole = store.role;
37
+ }
38
+ else {
39
+ // Check if any non-primary writable store claims this project
40
+ const projectName = targetPath
41
+ ? path.basename(path.resolve(targetPath)).toLowerCase().replace(/[^a-z0-9_-]/g, "-")
42
+ : undefined;
43
+ if (projectName) {
44
+ const stores = resolveAllStores(phrenPath);
45
+ for (const store of stores) {
46
+ if (store.role !== "readonly" && store.role !== "primary" && store.projects?.includes(projectName)) {
47
+ targetPhrenPath = store.path;
48
+ storeRole = store.role;
49
+ break;
50
+ }
51
+ }
52
+ }
53
+ }
54
+ const added = addProjectFromPath(targetPhrenPath, targetPath, requestedProfile || profile || undefined, parseProjectOwnershipMode(ownership) ?? undefined);
23
55
  if (!added.ok) {
24
56
  return mcpResponse({
25
57
  ok: false,
@@ -29,7 +61,9 @@ async function handleAddProject(ctx, { path: targetPath, profile: requestedProfi
29
61
  await ctx.rebuildIndex();
30
62
  return mcpResponse({
31
63
  ok: true,
32
- message: `Added project "${added.data.project}" (${added.data.ownership}) from ${added.data.path}.`,
64
+ message: `Added project "${added.data.project}" (${added.data.ownership}) from ${added.data.path}` +
65
+ (storeRole !== "primary" ? ` [store: ${storeName || storeRole}]` : "") +
66
+ `.`,
33
67
  data: added.data,
34
68
  });
35
69
  }
@@ -419,6 +453,8 @@ export function register(server, ctx) {
419
453
  profile: z.string().optional().describe("Profile to update. Defaults to the active profile."),
420
454
  ownership: z.enum(PROJECT_OWNERSHIP_MODES).optional()
421
455
  .describe("How Phren should treat repo-facing instruction files: phren-managed, detached, or repo-managed."),
456
+ store: z.string().optional()
457
+ .describe("Target store name (from stores.yaml). If omitted, auto-routes to the store that claims this project, or falls back to the primary store."),
422
458
  }),
423
459
  }, (params) => handleAddProject(ctx, params));
424
460
  server.registerTool("health_check", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Knowledge layer for AI agents — CLI, MCP server, and data layer",
5
5
  "type": "module",
6
6
  "bin": {