@phren/cli 0.0.39 → 0.0.41

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.
@@ -7,7 +7,7 @@ import { handleExtractMemories } from "./extract.js";
7
7
  import { handleGovernMemories, handlePruneMemories, handleConsolidateMemories, handleMaintain, handleBackgroundMaintenance, } from "./govern.js";
8
8
  import { handleConfig, handleIndexPolicy, handleRetentionPolicy, handleWorkflowPolicy, } from "./config.js";
9
9
  import { parseSearchArgs } from "./search.js";
10
- import { handleDetectSkills, handleFindingNamespace, handleHooksNamespace, handleProjectsNamespace, handleSkillsNamespace, handleSkillList, handleTaskNamespace, } from "./namespaces.js";
10
+ import { handleDetectSkills, handleFindingNamespace, handleHooksNamespace, handleProjectsNamespace, handleSkillsNamespace, handleSkillList, handleStoreNamespace, handleTaskNamespace, } from "./namespaces.js";
11
11
  import { handleTaskView, handleSessionsView, handleQuickstart, handleDebugInjection, handleInspectIndex, } from "./ops.js";
12
12
  import { handleAddFinding, handleDoctor, handleFragmentSearch, handleMemoryUi, handlePinCanonical, handleQualityFeedback, handleRelatedDocs, handleReview, handleConsolidationStatus, handleSessionContext, handleSearch, handleShell, handleStatus, handleUpdate, } from "./actions.js";
13
13
  import { handleGraphNamespace } from "./graph.js";
@@ -107,6 +107,8 @@ export async function runCliCommand(command, args) {
107
107
  return handleConsolidationStatus(args);
108
108
  case "session-context":
109
109
  return handleSessionContext();
110
+ case "store":
111
+ return handleStoreNamespace(args);
110
112
  default:
111
113
  console.error(`Unknown command: ${command}\nRun 'phren --help' for available commands.`);
112
114
  process.exit(1);
@@ -603,6 +603,24 @@ export async function handleHookSessionStart() {
603
603
  },
604
604
  });
605
605
  appendAuditLog(phrenPath, "hook_session_start", `pull=${hasRemote ? (pull.ok ? "ok" : "fail") : "skipped-local"} doctor=${doctor.ok ? "ok" : "issues"} maintenance=${maintenanceScheduled ? "scheduled" : "skipped"}`);
606
+ // Pull non-primary stores from store registry (best-effort, non-blocking)
607
+ try {
608
+ const { getNonPrimaryStores } = await import("../store-registry.js");
609
+ const otherStores = getNonPrimaryStores(phrenPath);
610
+ for (const store of otherStores) {
611
+ if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git")))
612
+ continue;
613
+ try {
614
+ await runBestEffortGit(["pull", "--rebase", "--quiet"], store.path);
615
+ }
616
+ catch (err) {
617
+ debugLog(`session-start store-pull ${store.name}: ${errorMessage(err)}`);
618
+ }
619
+ }
620
+ }
621
+ catch {
622
+ // store-registry not available or no stores — skip silently
623
+ }
606
624
  // Sync intent warning: if the user intended sync but remote is missing or pull failed, warn once
607
625
  try {
608
626
  const syncPrefs = readInstallPreferences(phrenPath);
@@ -9,7 +9,7 @@ import { mergeConfig, } from "../shared/governance.js";
9
9
  import { buildIndex, detectProject, } from "../shared/index.js";
10
10
  import { isProjectHookEnabled } from "../project-config.js";
11
11
  import { checkConsolidationNeeded, } from "../shared/content.js";
12
- import { buildRobustFtsQuery, extractKeywordEntries, isFeatureEnabled, clampInt, errorMessage, loadSynonymMap, learnSynonym, STOP_WORDS, } from "../utils.js";
12
+ import { buildRobustFtsQuery, extractKeywordEntries, isFeatureEnabled, clampInt, errorMessage, } from "../utils.js";
13
13
  import { getHooksEnabledPreference } from "../init/init.js";
14
14
  import { logger } from "../logger.js";
15
15
  import { isToolHookEnabled } from "../hooks.js";
@@ -37,65 +37,9 @@ import { getGitContext, trackSessionMetrics, } from "./hooks-session.js";
37
37
  import { approximateTokens } from "../shared/retrieval.js";
38
38
  import { resolveRuntimeProfile } from "../runtime-profile.js";
39
39
  import { handleTaskPromptLifecycle } from "../task/lifecycle.js";
40
- function synonymTermKnown(term, map) {
41
- if (Object.prototype.hasOwnProperty.call(map, term))
42
- return true;
43
- for (const values of Object.values(map)) {
44
- if (values.includes(term))
45
- return true;
46
- }
47
- return false;
48
- }
49
- function termAppearsInText(term, text) {
50
- const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
51
- const pattern = new RegExp(`\\b${escaped}\\b`, "i");
52
- return pattern.test(text);
53
- }
54
- function autoLearnQuerySynonyms(phrenPath, project, keywordEntries, rows) {
55
- if (!project)
56
- return;
57
- const synonymMap = loadSynonymMap(project, phrenPath);
58
- const knownTerms = new Set([
59
- ...Object.keys(synonymMap),
60
- ...Object.values(synonymMap).flat(),
61
- ]);
62
- const queryTerms = [...new Set(keywordEntries
63
- .map((item) => item.trim().toLowerCase())
64
- .filter((item) => item.length > 2 && !STOP_WORDS.has(item)))];
65
- const unknownTerms = queryTerms.filter((term) => !synonymTermKnown(term, synonymMap));
66
- if (unknownTerms.length === 0)
67
- return;
68
- const corpus = rows
69
- .slice(0, 8)
70
- .map((row) => row.content.slice(0, 6000))
71
- .join("\n")
72
- .toLowerCase();
73
- if (!corpus.trim())
74
- return;
75
- const learned = [];
76
- for (const unknown of unknownTerms.slice(0, 3)) {
77
- const related = [...knownTerms]
78
- .filter((candidate) => candidate.length > 2
79
- && candidate !== unknown
80
- && !STOP_WORDS.has(candidate)
81
- && !queryTerms.includes(candidate)
82
- && termAppearsInText(candidate, corpus))
83
- .slice(0, 4);
84
- if (related.length === 0)
85
- continue;
86
- try {
87
- learnSynonym(phrenPath, project, unknown, related);
88
- learned.push({ term: unknown, related });
89
- }
90
- catch (err) {
91
- debugLog(`hook-prompt synonym-learn failed for "${unknown}": ${errorMessage(err)}`);
92
- }
93
- }
94
- if (learned.length > 0) {
95
- const details = learned.map((entry) => `${entry.term}->${entry.related.join(",")}`).join("; ");
96
- debugLog(`hook-prompt learned synonyms project=${project} ${details}`);
97
- }
98
- }
40
+ // Auto-learn from prompts was removed — it learned conversational noise ("bro", "idk", typos)
41
+ // as synonyms for high-frequency terms. Manual `phren config synonyms add` still works.
42
+ // Future: finding-based co-occurrence mining (phren maintain command, not live hook).
99
43
  async function readStdin() {
100
44
  return new Promise((resolve, reject) => {
101
45
  const chunks = [];
@@ -195,7 +139,6 @@ export async function handleHookPrompt() {
195
139
  stage.searchMs = Date.now() - tSearch0;
196
140
  if (!rows || !rows.length)
197
141
  process.exit(0);
198
- autoLearnQuerySynonyms(getPhrenPath(), detectedProject, keywordEntries, rows);
199
142
  const tTrust0 = Date.now();
200
143
  const policy = resolvedConfig.retentionPolicy;
201
144
  const memoryTtlDays = Number.parseInt(process.env.PHREN_MEMORY_TTL_DAYS || String(policy.ttlDays), 10);
@@ -16,6 +16,7 @@ import { addFinding, removeFinding } from "../core/finding.js";
16
16
  import { supersedeFinding, retractFinding, resolveFindingContradiction } from "../finding/lifecycle.js";
17
17
  import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES, validateCustomHookCommand } from "../hooks.js";
18
18
  import { runtimeFile } from "../shared.js";
19
+ import { resolveAllStores, addStoreToRegistry, removeStoreFromRegistry, generateStoreId, readTeamBootstrap, } from "../store-registry.js";
19
20
  const HOOK_TOOLS = ["claude", "copilot", "cursor", "codex"];
20
21
  function printSkillsUsage() {
21
22
  console.log("Usage:");
@@ -1501,3 +1502,198 @@ export async function handleFindingNamespace(args) {
1501
1502
  printFindingUsage();
1502
1503
  process.exit(1);
1503
1504
  }
1505
+ // ── Store namespace ──────────────────────────────────────────────────────────
1506
+ function printStoreUsage() {
1507
+ console.log("Usage:");
1508
+ console.log(" phren store list List registered stores");
1509
+ console.log(" phren store add <name> --remote <url> Add a team store");
1510
+ console.log(" phren store remove <name> Remove a store (local only)");
1511
+ console.log(" phren store sync Pull all stores");
1512
+ }
1513
+ export async function handleStoreNamespace(args) {
1514
+ const subcommand = args[0];
1515
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
1516
+ printStoreUsage();
1517
+ return;
1518
+ }
1519
+ const phrenPath = getPhrenPath();
1520
+ if (subcommand === "list") {
1521
+ const stores = resolveAllStores(phrenPath);
1522
+ if (stores.length === 0) {
1523
+ console.log("No stores registered.");
1524
+ return;
1525
+ }
1526
+ console.log(`${stores.length} store(s):\n`);
1527
+ for (const store of stores) {
1528
+ const exists = fs.existsSync(store.path) ? "ok" : "MISSING";
1529
+ const syncInfo = store.remote ?? "(local)";
1530
+ const projectCount = countStoreProjects(store);
1531
+ console.log(` ${store.name} (${store.role})`);
1532
+ console.log(` id: ${store.id}`);
1533
+ console.log(` path: ${store.path} [${exists}]`);
1534
+ console.log(` remote: ${syncInfo}`);
1535
+ console.log(` sync: ${store.sync}`);
1536
+ console.log(` projects: ${projectCount}`);
1537
+ // Show last sync status if available
1538
+ const health = readHealthForStore(store.path);
1539
+ if (health) {
1540
+ console.log(` last sync: ${health}`);
1541
+ }
1542
+ console.log();
1543
+ }
1544
+ return;
1545
+ }
1546
+ if (subcommand === "add") {
1547
+ const name = args[1];
1548
+ if (!name) {
1549
+ console.error("Usage: phren store add <name> --remote <url> [--role team|readonly]");
1550
+ process.exit(1);
1551
+ }
1552
+ // Validate store name to prevent path traversal
1553
+ if (!isValidProjectName(name)) {
1554
+ console.error(`Invalid store name: "${name}". Use lowercase letters, numbers, and hyphens.`);
1555
+ process.exit(1);
1556
+ }
1557
+ const remote = getOptionValue(args.slice(2), "--remote");
1558
+ if (!remote) {
1559
+ console.error("--remote <url> is required. Provide the git clone URL for the team store.");
1560
+ process.exit(1);
1561
+ }
1562
+ // Prevent git option injection via --remote
1563
+ if (remote.startsWith("-")) {
1564
+ console.error(`Invalid remote URL: "${remote}". URLs must not start with "-".`);
1565
+ process.exit(1);
1566
+ }
1567
+ const roleArg = getOptionValue(args.slice(2), "--role") ?? "team";
1568
+ if (roleArg !== "team" && roleArg !== "readonly") {
1569
+ console.error(`Invalid role: "${roleArg}". Use "team" or "readonly".`);
1570
+ process.exit(1);
1571
+ }
1572
+ const storesDir = path.join(path.dirname(phrenPath), ".phren-stores");
1573
+ const storePath = path.join(storesDir, name);
1574
+ if (fs.existsSync(storePath)) {
1575
+ console.error(`Directory already exists: ${storePath}`);
1576
+ process.exit(1);
1577
+ }
1578
+ // Clone the remote
1579
+ console.log(`Cloning ${remote} into ${storePath}...`);
1580
+ try {
1581
+ fs.mkdirSync(storesDir, { recursive: true });
1582
+ execFileSync("git", ["clone", "--", remote, storePath], {
1583
+ stdio: "inherit",
1584
+ timeout: 60_000,
1585
+ });
1586
+ }
1587
+ catch (err) {
1588
+ console.error(`Clone failed: ${errorMessage(err)}`);
1589
+ process.exit(1);
1590
+ }
1591
+ // Read .phren-team.yaml if present
1592
+ const bootstrap = readTeamBootstrap(storePath);
1593
+ const storeName = bootstrap?.name ?? name;
1594
+ const storeRole = bootstrap?.default_role ?? roleArg;
1595
+ const entry = {
1596
+ id: generateStoreId(),
1597
+ name: storeName,
1598
+ path: storePath,
1599
+ role: storeRole === "primary" ? "team" : storeRole, // Never allow adding a second primary
1600
+ sync: storeRole === "readonly" ? "pull-only" : "managed-git",
1601
+ remote,
1602
+ };
1603
+ try {
1604
+ addStoreToRegistry(phrenPath, entry);
1605
+ }
1606
+ catch (err) {
1607
+ console.error(`Failed to register store: ${errorMessage(err)}`);
1608
+ process.exit(1);
1609
+ }
1610
+ console.log(`\nStore "${storeName}" added (${entry.role}).`);
1611
+ console.log(` id: ${entry.id}`);
1612
+ console.log(` path: ${storePath}`);
1613
+ return;
1614
+ }
1615
+ if (subcommand === "remove") {
1616
+ const name = args[1];
1617
+ if (!name) {
1618
+ console.error("Usage: phren store remove <name>");
1619
+ process.exit(1);
1620
+ }
1621
+ try {
1622
+ const removed = removeStoreFromRegistry(phrenPath, name);
1623
+ console.log(`Store "${name}" removed from registry.`);
1624
+ console.log(` Local directory preserved at: ${removed.path}`);
1625
+ console.log(` To delete: rm -rf "${removed.path}"`);
1626
+ }
1627
+ catch (err) {
1628
+ console.error(`${errorMessage(err)}`);
1629
+ process.exit(1);
1630
+ }
1631
+ return;
1632
+ }
1633
+ if (subcommand === "sync") {
1634
+ const stores = resolveAllStores(phrenPath);
1635
+ let hasErrors = false;
1636
+ for (const store of stores) {
1637
+ if (!fs.existsSync(store.path)) {
1638
+ console.log(` ${store.name}: SKIP (path missing)`);
1639
+ continue;
1640
+ }
1641
+ const gitDir = path.join(store.path, ".git");
1642
+ if (!fs.existsSync(gitDir)) {
1643
+ console.log(` ${store.name}: SKIP (not a git repo)`);
1644
+ continue;
1645
+ }
1646
+ try {
1647
+ execFileSync("git", ["pull", "--rebase", "--quiet"], {
1648
+ cwd: store.path,
1649
+ stdio: "pipe",
1650
+ timeout: 30_000,
1651
+ });
1652
+ console.log(` ${store.name}: ok`);
1653
+ }
1654
+ catch (err) {
1655
+ console.log(` ${store.name}: FAILED (${errorMessage(err).split("\n")[0]})`);
1656
+ hasErrors = true;
1657
+ }
1658
+ }
1659
+ if (hasErrors) {
1660
+ console.error("\nSome stores failed to sync. Run 'phren doctor' for details.");
1661
+ }
1662
+ return;
1663
+ }
1664
+ console.error(`Unknown store subcommand: ${subcommand}`);
1665
+ printStoreUsage();
1666
+ process.exit(1);
1667
+ }
1668
+ function countStoreProjects(store) {
1669
+ if (!fs.existsSync(store.path))
1670
+ return 0;
1671
+ try {
1672
+ return getProjectDirs(store.path).length;
1673
+ }
1674
+ catch {
1675
+ return 0;
1676
+ }
1677
+ }
1678
+ function readHealthForStore(storePath) {
1679
+ try {
1680
+ const healthPath = path.join(storePath, ".runtime", "health.json");
1681
+ if (!fs.existsSync(healthPath))
1682
+ return null;
1683
+ const raw = JSON.parse(fs.readFileSync(healthPath, "utf8"));
1684
+ const lastSync = raw?.lastSync;
1685
+ if (!lastSync)
1686
+ return null;
1687
+ const parts = [];
1688
+ if (lastSync.lastPullStatus)
1689
+ parts.push(`pull=${lastSync.lastPullStatus}`);
1690
+ if (lastSync.lastPushStatus)
1691
+ parts.push(`push=${lastSync.lastPushStatus}`);
1692
+ if (lastSync.lastSuccessfulPullAt)
1693
+ parts.push(`at=${lastSync.lastSuccessfulPullAt.slice(0, 16)}`);
1694
+ return parts.join(", ") || null;
1695
+ }
1696
+ catch {
1697
+ return null;
1698
+ }
1699
+ }
@@ -485,6 +485,24 @@ export async function handleHookStop() {
485
485
  });
486
486
  appendAuditLog(phrenPath, "hook_stop", `status=saved-local detail=${JSON.stringify(syncDetail)}`);
487
487
  }); // end withFileLock(gitOpLockPath)
488
+ // Pull non-primary stores (best-effort, non-blocking)
489
+ try {
490
+ const { getNonPrimaryStores } = await import("./store-registry.js");
491
+ const otherStores = getNonPrimaryStores(phrenPath);
492
+ for (const store of otherStores) {
493
+ if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git")))
494
+ continue;
495
+ try {
496
+ await runBestEffortGit(["pull", "--rebase", "--quiet"], store.path);
497
+ }
498
+ catch (err) {
499
+ debugLog(`hook-stop store-pull ${store.name}: ${errorMessage(err)}`);
500
+ }
501
+ }
502
+ }
503
+ catch {
504
+ // store-registry not available or no stores — skip silently
505
+ }
488
506
  // Auto governance scheduling (non-blocking)
489
507
  scheduleWeeklyGovernance();
490
508
  }
@@ -81,6 +81,12 @@ const HELP_TOPICS = {
81
81
  phren verify Check init completed OK
82
82
  phren uninstall Remove phren config and hooks
83
83
  phren update [--refresh-starter] Update to latest version
84
+ `,
85
+ stores: `Stores:
86
+ phren store list List registered stores
87
+ phren store add <name> --remote <url> Add a team store
88
+ phren store remove <name> Remove a store (local only)
89
+ phren store sync Pull all stores
84
90
  `,
85
91
  env: `Environment variables:
86
92
  PHREN_PATH Override phren directory (default: ~/.phren)
@@ -168,6 +174,7 @@ const CLI_COMMANDS = [
168
174
  "review",
169
175
  "consolidation-status",
170
176
  "session-context",
177
+ "store",
171
178
  ];
172
179
  async function flushTopLevelOutput() {
173
180
  await Promise.all([
@@ -340,6 +347,7 @@ export async function runTopLevelCommand(argv) {
340
347
  applyStarterUpdate: initArgs.includes("--apply-starter-update"),
341
348
  dryRun: initArgs.includes("--dry-run"),
342
349
  yes: initArgs.includes("--yes") || initArgs.includes("-y"),
350
+ express: initArgs.includes("--express"),
343
351
  _walkthroughCloneUrl: cloneUrl,
344
352
  });
345
353
  return finish();
@@ -114,7 +114,7 @@ function detectRepoRootForStorage(phrenPath) {
114
114
  return detectProjectDir(process.cwd(), phrenPath);
115
115
  }
116
116
  // Interactive walkthrough for first-time init
117
- export async function runWalkthrough(phrenPath) {
117
+ export async function runWalkthrough(phrenPath, options) {
118
118
  const prompts = await createWalkthroughPrompts();
119
119
  const style = await createWalkthroughStyle();
120
120
  const divider = style.header("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
@@ -137,6 +137,42 @@ export async function runWalkthrough(phrenPath) {
137
137
  printSection("Welcome");
138
138
  log("Let's set up persistent memory for your AI agents.");
139
139
  log("Every option can be changed later.\n");
140
+ // Express mode: skip the entire walkthrough with recommended defaults
141
+ const useExpress = options?.express === true
142
+ || (options?.express !== false && await prompts.confirm("Use recommended settings? (global storage, MCP on, hooks on, auto tasks)", true));
143
+ if (useExpress) {
144
+ const expressResult = {
145
+ storageChoice: "global",
146
+ storagePath: path.resolve(homePath(".phren")),
147
+ machine: getMachineName(),
148
+ profile: "personal",
149
+ mcp: "on",
150
+ hooks: "on",
151
+ projectOwnershipDefault: "phren-managed",
152
+ findingsProactivity: "high",
153
+ taskProactivity: "high",
154
+ lowConfidenceThreshold: 0.7,
155
+ riskySections: ["Stale", "Conflicts"],
156
+ taskMode: "auto",
157
+ bootstrapCurrentProject: false,
158
+ ollamaEnabled: false,
159
+ autoCaptureEnabled: false,
160
+ semanticDedupEnabled: false,
161
+ semanticConflictEnabled: false,
162
+ findingSensitivity: "balanced",
163
+ domain: "software",
164
+ };
165
+ printSummary([
166
+ `Storage: global (${expressResult.storagePath})`,
167
+ `Machine: ${expressResult.machine}`,
168
+ "MCP: enabled",
169
+ "Hooks: enabled",
170
+ "Project ownership: phren-managed",
171
+ "Task mode: auto",
172
+ "Domain: software",
173
+ ]);
174
+ return expressResult;
175
+ }
140
176
  printSection("Storage Location");
141
177
  log("Where should phren store data?");
142
178
  const storageChoice = await prompts.select("Storage location", [
@@ -7,7 +7,7 @@ import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import { execFileSync } from "child_process";
9
9
  import { getMachineName, persistMachineName } from "../machine-identity.js";
10
- import { atomicWriteText, debugLog, expandHomePath, hookConfigPath, writeRootManifest, } from "../shared.js";
10
+ import { atomicWriteText, debugLog, expandHomePath, writeRootManifest, } from "../shared.js";
11
11
  import { isValidProjectName, errorMessage } from "../utils.js";
12
12
  import { logger } from "../logger.js";
13
13
  export { configureClaude, configureVSCode, configureCursorMcp, configureCopilotMcp, configureCodexMcp, logMcpTargetStatus, resetVSCodeProbeCache, patchJsonFile, } from "./config.js";
@@ -25,7 +25,7 @@ import { configureMcpTargets, configureHooksIfEnabled, applyOnboardingPreference
25
25
  import { runWalkthrough, createWalkthroughPrompts, createWalkthroughStyle } from "./init-walkthrough.js";
26
26
  import { getMcpEnabledPreference, getHooksEnabledPreference, writeInstallPreferences, readInstallPreferences, } from "./preferences.js";
27
27
  import { ensureGovernanceFiles, repairPreexistingInstall, runPostInitVerify, applyStarterTemplateUpdates, listTemplates, applyTemplate, ensureProjectScaffold, ensureLocalGitRepo, bootstrapFromExisting, updateMachinesYaml, detectProjectDir, isProjectTracked, } from "./setup.js";
28
- import { DEFAULT_PHREN_PATH, STARTER_DIR, VERSION, log, confirmPrompt } from "./shared.js";
28
+ import { DEFAULT_PHREN_PATH, STARTER_DIR, VERSION, log } from "./shared.js";
29
29
  import { PROJECT_OWNERSHIP_MODES, getProjectOwnershipDefault, } from "../project-config.js";
30
30
  import { getWorkflowPolicy } from "../shared/governance.js";
31
31
  import { addProjectToProfile } from "../profile-store.js";
@@ -140,8 +140,10 @@ export async function runInit(opts = {}) {
140
140
  }
141
141
  let hasExistingInstall = hasInstallMarkers(phrenPath);
142
142
  // Interactive walkthrough for first-time installs (skip with --yes or non-TTY)
143
- if (!hasExistingInstall && !dryRun && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
144
- const answers = await runWalkthrough(phrenPath);
143
+ // --express bypasses the TTY check since it skips all interactive prompts
144
+ const isTTY = process.stdin.isTTY && process.stdout.isTTY;
145
+ if (!hasExistingInstall && !dryRun && !opts.yes && (isTTY || opts.express)) {
146
+ const answers = await runWalkthrough(phrenPath, { express: opts.express });
145
147
  opts._walkthroughStorageChoice = answers.storageChoice;
146
148
  opts._walkthroughStoragePath = answers.storagePath;
147
149
  opts._walkthroughStorageRepoRoot = answers.storageRepoRoot;
@@ -343,20 +345,6 @@ export async function runInit(opts = {}) {
343
345
  log(` Default project ownership: ${ownershipDefault}`);
344
346
  log(` Task mode: ${getWorkflowPolicy(phrenPath).taskMode}`);
345
347
  log(` Git repo: ${existingGitRepo.detail}`);
346
- // Confirmation prompt before writing config
347
- if (!opts.yes) {
348
- const settingsPath = hookConfigPath("claude");
349
- const modifications = [];
350
- modifications.push(` ${settingsPath} (update MCP server + hooks)`);
351
- log(`\nWill modify:`);
352
- for (const mod of modifications)
353
- log(mod);
354
- const confirmed = await confirmPrompt("\nProceed?");
355
- if (!confirmed) {
356
- log("Aborted.");
357
- return;
358
- }
359
- }
360
348
  // Always reconfigure MCP and hooks (picks up new features on upgrade)
361
349
  configureMcpTargets(phrenPath, { mcpEnabled, hooksEnabled }, "Updated");
362
350
  configureHooksIfEnabled(phrenPath, hooksEnabled, "Updated");
@@ -539,17 +527,6 @@ export async function runInit(opts = {}) {
539
527
  if (repairedAssets.length > 0) {
540
528
  log(` Recreated missing generated assets: ${repairedAssets.join(", ")}`);
541
529
  }
542
- // Confirmation prompt before writing agent config
543
- if (!opts.yes) {
544
- const settingsPath = hookConfigPath("claude");
545
- log(`\nWill modify:`);
546
- log(` ${settingsPath} (add MCP server + hooks)`);
547
- const confirmed = await confirmPrompt("\nProceed?");
548
- if (!confirmed) {
549
- log("Aborted.");
550
- return;
551
- }
552
- }
553
530
  // Configure MCP for all detected AI coding tools and hooks
554
531
  configureMcpTargets(phrenPath, { mcpEnabled, hooksEnabled }, "Configured");
555
532
  configureHooksIfEnabled(phrenPath, hooksEnabled, "Configured");
@@ -891,8 +891,15 @@ export function ensureLocalGitRepo(phrenPath) {
891
891
  stdio: ["ignore", "pipe", "ignore"],
892
892
  timeout: EXEC_TIMEOUT_QUICK_MS,
893
893
  }).trim();
894
- const resolvedTopLevel = path.resolve(topLevel);
895
- const resolvedPhrenPath = path.resolve(phrenPath);
894
+ // Normalize both paths: resolve symlinks (macOS /var→/private/var) and
895
+ // on Windows resolve 8.3 short names (RUNNER~1→runneradmin) + case-insensitive
896
+ const realpath = process.platform === "win32" ? fs.realpathSync.native : fs.realpathSync;
897
+ let resolvedTopLevel = realpath(path.resolve(topLevel));
898
+ let resolvedPhrenPath = realpath(path.resolve(phrenPath));
899
+ if (process.platform === "win32") {
900
+ resolvedTopLevel = resolvedTopLevel.toLowerCase();
901
+ resolvedPhrenPath = resolvedPhrenPath.toLowerCase();
902
+ }
896
903
  if (resolvedTopLevel === resolvedPhrenPath) {
897
904
  // phrenPath IS the repo root — it has its own git repo
898
905
  return { ok: true, initialized: false, detail: "existing git repo" };
@@ -491,39 +491,44 @@ export async function searchKnowledgeRows(db, options) {
491
491
  return { safeQuery, rows, usedFallback };
492
492
  }
493
493
  /**
494
- * Parse PHREN_FEDERATION_PATHS env var and return valid, distinct paths.
495
- * Paths are colon-separated. The local phrenPath is excluded to avoid duplicate results.
496
- */
497
- function parseFederationPaths(localPhrenPath) {
498
- const raw = process.env.PHREN_FEDERATION_PATHS ?? "";
499
- if (!raw.trim())
500
- return [];
501
- return raw
502
- .split(":")
503
- .map((p) => p.trim())
504
- .filter((p) => p.length > 0 && p !== localPhrenPath && fs.existsSync(p));
505
- }
506
- /**
507
- * Search additional phren stores defined in PHREN_FEDERATION_PATHS.
494
+ * Search additional phren stores defined in the store registry (or PHREN_FEDERATION_PATHS).
508
495
  * Returns an array of results tagged with their source store. Read-only — no mutations.
509
496
  */
510
497
  export async function searchFederatedStores(localPhrenPath, options) {
511
- const federationPaths = parseFederationPaths(localPhrenPath);
512
- if (federationPaths.length === 0)
498
+ let nonPrimaryStores;
499
+ try {
500
+ 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
+ }
505
+ 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: "" }));
511
+ }
512
+ if (nonPrimaryStores.length === 0)
513
513
  return [];
514
514
  const allRows = [];
515
- for (const storePath of federationPaths) {
515
+ for (const store of nonPrimaryStores) {
516
516
  try {
517
- const federatedDb = await buildIndex(storePath);
518
- const result = await searchKnowledgeRows(federatedDb, { ...options, phrenPath: storePath });
517
+ const federatedDb = await buildIndex(store.path);
518
+ const result = await searchKnowledgeRows(federatedDb, { ...options, phrenPath: store.path });
519
519
  if (result.rows && result.rows.length > 0) {
520
520
  for (const row of result.rows) {
521
- allRows.push({ ...row, federationSource: storePath });
521
+ allRows.push({
522
+ ...row,
523
+ federationSource: store.path,
524
+ storeName: store.name,
525
+ storeId: store.id || undefined,
526
+ });
522
527
  }
523
528
  }
524
529
  }
525
530
  catch (err) {
526
- logger.debug(`federatedSearch storePath=${storePath}`, errorMessage(err));
531
+ logger.debug(`federatedSearch store=${store.name}`, errorMessage(err));
527
532
  // Federation errors are non-fatal — continue with other stores
528
533
  }
529
534
  }
@@ -0,0 +1,269 @@
1
+ import * as fs from "fs";
2
+ import * as crypto from "crypto";
3
+ import * as path from "path";
4
+ import * as yaml from "js-yaml";
5
+ import { expandHomePath, atomicWriteText } from "./phren-paths.js";
6
+ import { withFileLock } from "./governance/locks.js";
7
+ import { isRecord, PhrenError } from "./phren-core.js";
8
+ // ── Constants ────────────────────────────────────────────────────────────────
9
+ const STORES_FILENAME = "stores.yaml";
10
+ const TEAM_BOOTSTRAP_FILENAME = ".phren-team.yaml";
11
+ const VALID_ROLES = new Set(["primary", "team", "readonly"]);
12
+ const VALID_SYNC_MODES = new Set(["managed-git", "pull-only"]);
13
+ // ── Path helpers ─────────────────────────────────────────────────────────────
14
+ export function storesFilePath(phrenPath) {
15
+ return path.join(phrenPath, STORES_FILENAME);
16
+ }
17
+ // ── ID generation ────────────────────────────────────────────────────────────
18
+ export function generateStoreId() {
19
+ return crypto.randomUUID().replace(/-/g, "").slice(0, 8);
20
+ }
21
+ /**
22
+ * Deterministic ID from a filesystem path — used for PHREN_FEDERATION_PATHS
23
+ * backward-compat entries so the same path always produces the same ID.
24
+ */
25
+ function deterministicIdFromPath(storePath) {
26
+ return crypto.createHash("sha256").update(storePath).digest("hex").slice(0, 8);
27
+ }
28
+ // ── Read / Write ─────────────────────────────────────────────────────────────
29
+ export function readStoreRegistry(phrenPath) {
30
+ const filePath = storesFilePath(phrenPath);
31
+ if (!fs.existsSync(filePath))
32
+ return null;
33
+ let raw;
34
+ try {
35
+ raw = fs.readFileSync(filePath, "utf8");
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ let parsed;
41
+ try {
42
+ parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ const registry = normalizeRegistry(parsed);
48
+ if (!registry)
49
+ return null;
50
+ // Validate on read too — reject malformed registries before they reach hooks/sync
51
+ const err = validateRegistry(registry);
52
+ if (err)
53
+ return null;
54
+ return registry;
55
+ }
56
+ export function writeStoreRegistry(phrenPath, registry) {
57
+ const err = validateRegistry(registry);
58
+ if (err)
59
+ throw new Error(`${PhrenError.VALIDATION_ERROR}: ${err}`);
60
+ // Collapse paths to ~ prefix for portability
61
+ const portable = {
62
+ version: 1,
63
+ stores: registry.stores.map((s) => ({
64
+ ...s,
65
+ path: collapsePath(s.path),
66
+ })),
67
+ };
68
+ atomicWriteText(storesFilePath(phrenPath), yaml.dump(portable, { lineWidth: 200 }));
69
+ }
70
+ // ── Resolution ───────────────────────────────────────────────────────────────
71
+ /**
72
+ * Resolve the full list of stores. This is the **key backward-compat function**:
73
+ * - If stores.yaml exists → parse and return entries
74
+ * - If stores.yaml is missing → return a single implicit primary entry for phrenPath
75
+ * - In both cases, append PHREN_FEDERATION_PATHS entries as readonly stores
76
+ */
77
+ export function resolveAllStores(phrenPath) {
78
+ const registry = readStoreRegistry(phrenPath);
79
+ const stores = registry ? [...registry.stores] : [implicitPrimaryStore(phrenPath)];
80
+ // Append PHREN_FEDERATION_PATHS entries that aren't already in the registry
81
+ const knownPaths = new Set(stores.map((s) => s.path));
82
+ for (const fedPath of parseFederationPathsEnv(phrenPath)) {
83
+ if (!knownPaths.has(fedPath)) {
84
+ stores.push({
85
+ id: deterministicIdFromPath(fedPath),
86
+ name: path.basename(fedPath),
87
+ path: fedPath,
88
+ role: "readonly",
89
+ sync: "pull-only",
90
+ });
91
+ knownPaths.add(fedPath);
92
+ }
93
+ }
94
+ return stores;
95
+ }
96
+ /** The primary store (role=primary). Falls back to implicit entry. */
97
+ export function getPrimaryStore(phrenPath) {
98
+ const stores = resolveAllStores(phrenPath);
99
+ return stores.find((s) => s.role === "primary") ?? implicitPrimaryStore(phrenPath);
100
+ }
101
+ /** All stores that can be read (all roles). */
102
+ export function getReadableStores(phrenPath) {
103
+ return resolveAllStores(phrenPath);
104
+ }
105
+ /** Non-primary stores (for federation search, multi-store sync). */
106
+ export function getNonPrimaryStores(phrenPath) {
107
+ return resolveAllStores(phrenPath).filter((s) => s.role !== "primary");
108
+ }
109
+ /** Find a store by name. */
110
+ export function findStoreByName(phrenPath, name) {
111
+ return resolveAllStores(phrenPath).find((s) => s.name === name);
112
+ }
113
+ // ── Team bootstrap ───────────────────────────────────────────────────────────
114
+ export function readTeamBootstrap(storePath) {
115
+ const filePath = path.join(storePath, TEAM_BOOTSTRAP_FILENAME);
116
+ if (!fs.existsSync(filePath))
117
+ return null;
118
+ try {
119
+ const raw = fs.readFileSync(filePath, "utf8");
120
+ const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
121
+ if (!isRecord(parsed) || typeof parsed.name !== "string")
122
+ return null;
123
+ return {
124
+ name: parsed.name,
125
+ description: typeof parsed.description === "string" ? parsed.description : undefined,
126
+ default_role: typeof parsed.default_role === "string" && isStoreRole(parsed.default_role)
127
+ ? parsed.default_role
128
+ : undefined,
129
+ };
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ // ── Registry mutation helpers ────────────────────────────────────────────────
136
+ /** Add a store entry to the registry. Creates stores.yaml if needed. Uses file locking. */
137
+ export function addStoreToRegistry(phrenPath, entry) {
138
+ withFileLock(storesFilePath(phrenPath), () => {
139
+ let registry = readStoreRegistry(phrenPath);
140
+ if (!registry) {
141
+ // First time — also add the implicit primary store
142
+ registry = { version: 1, stores: [implicitPrimaryStore(phrenPath)] };
143
+ }
144
+ const existing = registry.stores.find((s) => s.name === entry.name);
145
+ if (existing)
146
+ throw new Error(`${PhrenError.VALIDATION_ERROR}: Store "${entry.name}" already exists`);
147
+ registry.stores.push(entry);
148
+ writeStoreRegistry(phrenPath, registry);
149
+ });
150
+ }
151
+ /** Remove a store entry by name. Refuses to remove primary. Uses file locking. */
152
+ export function removeStoreFromRegistry(phrenPath, name) {
153
+ return withFileLock(storesFilePath(phrenPath), () => {
154
+ const registry = readStoreRegistry(phrenPath);
155
+ if (!registry)
156
+ throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`);
157
+ const idx = registry.stores.findIndex((s) => s.name === name);
158
+ if (idx === -1)
159
+ throw new Error(`${PhrenError.NOT_FOUND}: Store "${name}" not found`);
160
+ const entry = registry.stores[idx];
161
+ if (entry.role === "primary")
162
+ throw new Error(`${PhrenError.VALIDATION_ERROR}: Cannot remove the primary store`);
163
+ registry.stores.splice(idx, 1);
164
+ writeStoreRegistry(phrenPath, registry);
165
+ return entry;
166
+ });
167
+ }
168
+ // ── Validation ───────────────────────────────────────────────────────────────
169
+ function validateRegistry(registry) {
170
+ if (registry.version !== 1)
171
+ return `Unsupported registry version: ${registry.version}`;
172
+ if (!Array.isArray(registry.stores) || registry.stores.length === 0)
173
+ return "Registry must have at least one store";
174
+ const names = new Set();
175
+ const ids = new Set();
176
+ for (const store of registry.stores) {
177
+ if (!store.id || typeof store.id !== "string")
178
+ return `Store missing id`;
179
+ if (!store.name || typeof store.name !== "string")
180
+ return `Store missing name`;
181
+ if (!store.path || typeof store.path !== "string")
182
+ return `Store "${store.name}" missing path`;
183
+ if (!isStoreRole(store.role))
184
+ return `Store "${store.name}" has invalid role: ${store.role}`;
185
+ if (!isStoreSyncMode(store.sync))
186
+ return `Store "${store.name}" has invalid sync mode: ${store.sync}`;
187
+ if (names.has(store.name))
188
+ return `Duplicate store name: "${store.name}"`;
189
+ if (ids.has(store.id))
190
+ return `Duplicate store id: "${store.id}"`;
191
+ names.add(store.name);
192
+ ids.add(store.id);
193
+ }
194
+ const primaryCount = registry.stores.filter((s) => s.role === "primary").length;
195
+ if (primaryCount !== 1)
196
+ return `Registry must have exactly one primary store (found ${primaryCount})`;
197
+ return null;
198
+ }
199
+ // ── Normalization ────────────────────────────────────────────────────────────
200
+ function normalizeRegistry(parsed) {
201
+ if (!isRecord(parsed))
202
+ return null;
203
+ if (parsed.version !== 1)
204
+ return null;
205
+ if (!Array.isArray(parsed.stores))
206
+ return null;
207
+ const stores = [];
208
+ for (const raw of parsed.stores) {
209
+ if (!isRecord(raw))
210
+ return null;
211
+ const id = typeof raw.id === "string" ? raw.id : "";
212
+ const name = typeof raw.name === "string" ? raw.name : "";
213
+ const rawPath = typeof raw.path === "string" ? raw.path : "";
214
+ const role = typeof raw.role === "string" && isStoreRole(raw.role) ? raw.role : null;
215
+ const sync = typeof raw.sync === "string" && isStoreSyncMode(raw.sync) ? raw.sync : "managed-git";
216
+ const remote = typeof raw.remote === "string" ? raw.remote : undefined;
217
+ const projects = Array.isArray(raw.projects)
218
+ ? raw.projects.filter((p) => typeof p === "string")
219
+ : undefined;
220
+ if (!id || !name || !rawPath || !role)
221
+ return null;
222
+ stores.push({
223
+ id,
224
+ name,
225
+ path: path.resolve(expandHomePath(rawPath)),
226
+ role,
227
+ sync,
228
+ remote,
229
+ projects: projects && projects.length > 0 ? projects : undefined,
230
+ });
231
+ }
232
+ if (stores.length === 0)
233
+ return null;
234
+ return { version: 1, stores };
235
+ }
236
+ // ── Internal helpers ─────────────────────────────────────────────────────────
237
+ function implicitPrimaryStore(phrenPath) {
238
+ return {
239
+ id: deterministicIdFromPath(phrenPath),
240
+ name: "personal",
241
+ path: phrenPath,
242
+ role: "primary",
243
+ sync: "managed-git",
244
+ };
245
+ }
246
+ function parseFederationPathsEnv(localPhrenPath) {
247
+ const raw = process.env.PHREN_FEDERATION_PATHS ?? "";
248
+ if (!raw.trim())
249
+ return [];
250
+ return raw
251
+ .split(":")
252
+ .map((p) => p.trim())
253
+ .filter((p) => p.length > 0)
254
+ .map((p) => path.resolve(expandHomePath(p)))
255
+ .filter((p) => p !== localPhrenPath && fs.existsSync(p));
256
+ }
257
+ function isStoreRole(value) {
258
+ return VALID_ROLES.has(value);
259
+ }
260
+ function isStoreSyncMode(value) {
261
+ return VALID_SYNC_MODES.has(value);
262
+ }
263
+ function collapsePath(absPath) {
264
+ const home = process.env.HOME || process.env.USERPROFILE || "";
265
+ if (home && absPath.startsWith(home + path.sep)) {
266
+ return "~/" + absPath.slice(home.length + 1);
267
+ }
268
+ return absPath;
269
+ }
@@ -0,0 +1,110 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { getProjectDirs } from "./phren-paths.js";
4
+ import { PhrenError } from "./phren-core.js";
5
+ import { isValidProjectName } from "./utils.js";
6
+ import { resolveAllStores } from "./store-registry.js";
7
+ // ── Parsing ──────────────────────────────────────────────────────────────────
8
+ /**
9
+ * Parse a project reference that may be store-qualified.
10
+ *
11
+ * "arc" → { projectName: "arc" }
12
+ * "arc-team/arc" → { storeName: "arc-team", projectName: "arc" }
13
+ */
14
+ export function parseStoreQualified(input) {
15
+ const trimmed = input.trim();
16
+ const slashIdx = trimmed.indexOf("/");
17
+ if (slashIdx === -1) {
18
+ return { projectName: trimmed };
19
+ }
20
+ const storeName = trimmed.slice(0, slashIdx);
21
+ const projectName = trimmed.slice(slashIdx + 1);
22
+ // Only treat as store-qualified if both parts are valid names
23
+ if (storeName && projectName && !projectName.includes("/")) {
24
+ return { storeName, projectName };
25
+ }
26
+ // Malformed — treat whole thing as project name (will fail validation later)
27
+ return { projectName: trimmed };
28
+ }
29
+ // ── Resolution ───────────────────────────────────────────────────────────────
30
+ /**
31
+ * Resolve a project reference to a specific store + directory.
32
+ *
33
+ * Resolution rules:
34
+ * 1. If store-qualified ("store/project"), find that store and project within it
35
+ * 2. If bare ("project"), scan all readable stores for a matching project dir
36
+ * 3. Exactly one match → return it
37
+ * 4. Zero matches → throw NOT_FOUND
38
+ * 5. Multiple matches → throw VALIDATION_ERROR with disambiguation message
39
+ */
40
+ export function resolveProject(phrenPath, input, profile) {
41
+ const { storeName, projectName } = parseStoreQualified(input);
42
+ if (!isValidProjectName(projectName)) {
43
+ throw new Error(`${PhrenError.VALIDATION_ERROR}: Invalid project name: "${projectName}"`);
44
+ }
45
+ if (storeName && !isValidStoreName(storeName)) {
46
+ throw new Error(`${PhrenError.VALIDATION_ERROR}: Invalid store name: "${storeName}"`);
47
+ }
48
+ const stores = resolveAllStores(phrenPath);
49
+ // Store-qualified: find exact store
50
+ if (storeName) {
51
+ const store = stores.find((s) => s.name === storeName);
52
+ if (!store) {
53
+ const available = stores.map((s) => s.name).join(", ");
54
+ throw new Error(`${PhrenError.NOT_FOUND}: Store "${storeName}" not found. Available: ${available}`);
55
+ }
56
+ const projectDir = findProjectInStore(store, projectName, profile);
57
+ if (!projectDir) {
58
+ throw new Error(`${PhrenError.NOT_FOUND}: Project "${projectName}" not found in store "${storeName}"`);
59
+ }
60
+ return { store, projectName, projectDir };
61
+ }
62
+ // Bare project: scan all stores
63
+ const matches = [];
64
+ for (const store of stores) {
65
+ const projectDir = findProjectInStore(store, projectName, profile);
66
+ if (projectDir) {
67
+ matches.push({ store, projectName, projectDir });
68
+ }
69
+ }
70
+ if (matches.length === 1)
71
+ return matches[0];
72
+ if (matches.length === 0) {
73
+ throw new Error(`${PhrenError.NOT_FOUND}: Project "${projectName}" not found in any store`);
74
+ }
75
+ // Ambiguous — multiple stores have this project
76
+ const storeNames = matches.map((m) => `${m.store.name}/${projectName}`).join(", ");
77
+ throw new Error(`${PhrenError.VALIDATION_ERROR}: Project "${projectName}" exists in multiple stores. ` +
78
+ `Use store-qualified name to disambiguate: ${storeNames}`);
79
+ }
80
+ /**
81
+ * List all projects across all readable stores.
82
+ * Returns entries with store context for display.
83
+ */
84
+ export function listAllProjects(phrenPath, profile) {
85
+ const stores = resolveAllStores(phrenPath);
86
+ const results = [];
87
+ for (const store of stores) {
88
+ const dirs = getProjectDirs(store.path, profile);
89
+ for (const dir of dirs) {
90
+ const projectName = path.basename(dir);
91
+ results.push({ store, projectName, projectDir: dir });
92
+ }
93
+ }
94
+ return results;
95
+ }
96
+ // ── Internal helpers ─────────────────────────────────────────────────────────
97
+ const STORE_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
98
+ function isValidStoreName(name) {
99
+ return STORE_NAME_PATTERN.test(name);
100
+ }
101
+ function findProjectInStore(store, projectName, profile) {
102
+ if (!fs.existsSync(store.path))
103
+ return null;
104
+ const dirs = getProjectDirs(store.path, profile);
105
+ for (const dir of dirs) {
106
+ if (path.basename(dir) === projectName)
107
+ return dir;
108
+ }
109
+ return null;
110
+ }
@@ -1,8 +1,10 @@
1
1
  import { mcpResponse } from "./types.js";
2
2
  import { z } from "zod";
3
3
  import * as fs from "fs";
4
+ import * as path from "path";
4
5
  import { createHash } from "crypto";
5
6
  import { isValidProjectName, errorMessage } from "../utils.js";
7
+ import { resolveAllStores } from "../store-registry.js";
6
8
  import { readFindings } from "../data/access.js";
7
9
  import { debugLog, runtimeFile, DOC_TYPES, FINDING_TAGS, isMemoryScopeVisible, normalizeMemoryScope, } from "../shared.js";
8
10
  import { FINDING_LIFECYCLE_STATUSES, parseFindingLifecycle, } from "../shared/content.js";
@@ -610,6 +612,40 @@ async function handleGetFindings(ctx, { project, limit, include_superseded, incl
610
612
  data: { project, findings: capped, total: filteredItems.length, status: status ?? null, include_history: includeHistory, historyCount: hiddenHistoryCount },
611
613
  });
612
614
  }
615
+ async function handleStoreList(ctx) {
616
+ const { phrenPath } = ctx;
617
+ const stores = resolveAllStores(phrenPath);
618
+ const storeData = stores.map((store) => {
619
+ let health = null;
620
+ try {
621
+ const healthPath = path.join(store.path, ".runtime", "health.json");
622
+ if (fs.existsSync(healthPath)) {
623
+ health = JSON.parse(fs.readFileSync(healthPath, "utf8"))?.lastSync ?? null;
624
+ }
625
+ }
626
+ catch { /* non-critical */ }
627
+ return {
628
+ id: store.id,
629
+ name: store.name,
630
+ path: store.path,
631
+ role: store.role,
632
+ sync: store.sync,
633
+ remote: store.remote ?? null,
634
+ exists: fs.existsSync(store.path),
635
+ projects: store.projects ?? null,
636
+ lastSync: health,
637
+ };
638
+ });
639
+ const lines = storeData.map((s) => {
640
+ const status = s.exists ? "ok" : "MISSING";
641
+ return `- ${s.name} (${s.role}) [${status}] ${s.remote ?? "(local)"}`;
642
+ });
643
+ return mcpResponse({
644
+ ok: true,
645
+ message: `${storeData.length} store(s):\n${lines.join("\n")}`,
646
+ data: { stores: storeData },
647
+ });
648
+ }
613
649
  // ── Registration ─────────────────────────────────────────────────────────────
614
650
  export function register(server, ctx) {
615
651
  server.registerTool("get_memory_detail", {
@@ -668,4 +704,10 @@ export function register(server, ctx) {
668
704
  status: z.enum(FINDING_LIFECYCLE_STATUSES).optional().describe("Filter findings by lifecycle status."),
669
705
  }),
670
706
  }, (params) => handleGetFindings(ctx, params));
707
+ server.registerTool("store_list", {
708
+ title: "◆ phren · stores",
709
+ description: "List all registered phren stores and their sync status. " +
710
+ "Shows the primary store plus any team or readonly stores from the store registry.",
711
+ inputSchema: z.object({}),
712
+ }, () => handleStoreList(ctx));
671
713
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/cli",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "description": "Knowledge layer for AI agents. Phren learns and recalls.",
5
5
  "type": "module",
6
6
  "bin": {