@phren/cli 0.0.40 → 0.0.42
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.
- package/mcp/dist/cli/cli.js +5 -1
- package/mcp/dist/cli/hooks-session.js +18 -0
- package/mcp/dist/cli/namespaces.js +304 -0
- package/mcp/dist/cli-hooks-stop.js +18 -0
- package/mcp/dist/entrypoint.js +8 -0
- package/mcp/dist/finding/journal.js +88 -1
- package/mcp/dist/shared/retrieval.js +26 -21
- package/mcp/dist/store-registry.js +269 -0
- package/mcp/dist/store-routing.js +110 -0
- package/mcp/dist/tools/finding.js +35 -3
- package/mcp/dist/tools/search.js +123 -20
- package/mcp/dist/tools/tasks.js +18 -7
- package/mcp/dist/tools/types.js +22 -0
- package/package.json +1 -1
package/mcp/dist/cli/cli.js
CHANGED
|
@@ -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, handlePromoteNamespace, 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,10 @@ 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);
|
|
112
|
+
case "promote":
|
|
113
|
+
return handlePromoteNamespace(args);
|
|
110
114
|
default:
|
|
111
115
|
console.error(`Unknown command: ${command}\nRun 'phren --help' for available commands.`);
|
|
112
116
|
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);
|
|
@@ -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,306 @@ 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
|
+
console.log(" phren store activity [--limit N] Recent team findings");
|
|
1513
|
+
}
|
|
1514
|
+
export async function handleStoreNamespace(args) {
|
|
1515
|
+
const subcommand = args[0];
|
|
1516
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
1517
|
+
printStoreUsage();
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const phrenPath = getPhrenPath();
|
|
1521
|
+
if (subcommand === "list") {
|
|
1522
|
+
const stores = resolveAllStores(phrenPath);
|
|
1523
|
+
if (stores.length === 0) {
|
|
1524
|
+
console.log("No stores registered.");
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
console.log(`${stores.length} store(s):\n`);
|
|
1528
|
+
for (const store of stores) {
|
|
1529
|
+
const exists = fs.existsSync(store.path) ? "ok" : "MISSING";
|
|
1530
|
+
const syncInfo = store.remote ?? "(local)";
|
|
1531
|
+
const projectCount = countStoreProjects(store);
|
|
1532
|
+
console.log(` ${store.name} (${store.role})`);
|
|
1533
|
+
console.log(` id: ${store.id}`);
|
|
1534
|
+
console.log(` path: ${store.path} [${exists}]`);
|
|
1535
|
+
console.log(` remote: ${syncInfo}`);
|
|
1536
|
+
console.log(` sync: ${store.sync}`);
|
|
1537
|
+
console.log(` projects: ${projectCount}`);
|
|
1538
|
+
// Show last sync status if available
|
|
1539
|
+
const health = readHealthForStore(store.path);
|
|
1540
|
+
if (health) {
|
|
1541
|
+
console.log(` last sync: ${health}`);
|
|
1542
|
+
}
|
|
1543
|
+
console.log();
|
|
1544
|
+
}
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (subcommand === "add") {
|
|
1548
|
+
const name = args[1];
|
|
1549
|
+
if (!name) {
|
|
1550
|
+
console.error("Usage: phren store add <name> --remote <url> [--role team|readonly]");
|
|
1551
|
+
process.exit(1);
|
|
1552
|
+
}
|
|
1553
|
+
// Validate store name to prevent path traversal
|
|
1554
|
+
if (!isValidProjectName(name)) {
|
|
1555
|
+
console.error(`Invalid store name: "${name}". Use lowercase letters, numbers, and hyphens.`);
|
|
1556
|
+
process.exit(1);
|
|
1557
|
+
}
|
|
1558
|
+
const remote = getOptionValue(args.slice(2), "--remote");
|
|
1559
|
+
if (!remote) {
|
|
1560
|
+
console.error("--remote <url> is required. Provide the git clone URL for the team store.");
|
|
1561
|
+
process.exit(1);
|
|
1562
|
+
}
|
|
1563
|
+
// Prevent git option injection via --remote
|
|
1564
|
+
if (remote.startsWith("-")) {
|
|
1565
|
+
console.error(`Invalid remote URL: "${remote}". URLs must not start with "-".`);
|
|
1566
|
+
process.exit(1);
|
|
1567
|
+
}
|
|
1568
|
+
const roleArg = getOptionValue(args.slice(2), "--role") ?? "team";
|
|
1569
|
+
if (roleArg !== "team" && roleArg !== "readonly") {
|
|
1570
|
+
console.error(`Invalid role: "${roleArg}". Use "team" or "readonly".`);
|
|
1571
|
+
process.exit(1);
|
|
1572
|
+
}
|
|
1573
|
+
const storesDir = path.join(path.dirname(phrenPath), ".phren-stores");
|
|
1574
|
+
const storePath = path.join(storesDir, name);
|
|
1575
|
+
if (fs.existsSync(storePath)) {
|
|
1576
|
+
console.error(`Directory already exists: ${storePath}`);
|
|
1577
|
+
process.exit(1);
|
|
1578
|
+
}
|
|
1579
|
+
// Clone the remote
|
|
1580
|
+
console.log(`Cloning ${remote} into ${storePath}...`);
|
|
1581
|
+
try {
|
|
1582
|
+
fs.mkdirSync(storesDir, { recursive: true });
|
|
1583
|
+
execFileSync("git", ["clone", "--", remote, storePath], {
|
|
1584
|
+
stdio: "inherit",
|
|
1585
|
+
timeout: 60_000,
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
catch (err) {
|
|
1589
|
+
console.error(`Clone failed: ${errorMessage(err)}`);
|
|
1590
|
+
process.exit(1);
|
|
1591
|
+
}
|
|
1592
|
+
// Read .phren-team.yaml if present
|
|
1593
|
+
const bootstrap = readTeamBootstrap(storePath);
|
|
1594
|
+
const storeName = bootstrap?.name ?? name;
|
|
1595
|
+
const storeRole = bootstrap?.default_role ?? roleArg;
|
|
1596
|
+
const entry = {
|
|
1597
|
+
id: generateStoreId(),
|
|
1598
|
+
name: storeName,
|
|
1599
|
+
path: storePath,
|
|
1600
|
+
role: storeRole === "primary" ? "team" : storeRole, // Never allow adding a second primary
|
|
1601
|
+
sync: storeRole === "readonly" ? "pull-only" : "managed-git",
|
|
1602
|
+
remote,
|
|
1603
|
+
};
|
|
1604
|
+
try {
|
|
1605
|
+
addStoreToRegistry(phrenPath, entry);
|
|
1606
|
+
}
|
|
1607
|
+
catch (err) {
|
|
1608
|
+
console.error(`Failed to register store: ${errorMessage(err)}`);
|
|
1609
|
+
process.exit(1);
|
|
1610
|
+
}
|
|
1611
|
+
console.log(`\nStore "${storeName}" added (${entry.role}).`);
|
|
1612
|
+
console.log(` id: ${entry.id}`);
|
|
1613
|
+
console.log(` path: ${storePath}`);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
if (subcommand === "remove") {
|
|
1617
|
+
const name = args[1];
|
|
1618
|
+
if (!name) {
|
|
1619
|
+
console.error("Usage: phren store remove <name>");
|
|
1620
|
+
process.exit(1);
|
|
1621
|
+
}
|
|
1622
|
+
try {
|
|
1623
|
+
const removed = removeStoreFromRegistry(phrenPath, name);
|
|
1624
|
+
console.log(`Store "${name}" removed from registry.`);
|
|
1625
|
+
console.log(` Local directory preserved at: ${removed.path}`);
|
|
1626
|
+
console.log(` To delete: rm -rf "${removed.path}"`);
|
|
1627
|
+
}
|
|
1628
|
+
catch (err) {
|
|
1629
|
+
console.error(`${errorMessage(err)}`);
|
|
1630
|
+
process.exit(1);
|
|
1631
|
+
}
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (subcommand === "activity") {
|
|
1635
|
+
const stores = resolveAllStores(phrenPath);
|
|
1636
|
+
const teamStores = stores.filter((s) => s.role === "team");
|
|
1637
|
+
if (teamStores.length === 0) {
|
|
1638
|
+
console.log("No team stores registered. Add one with: phren store add <name> --remote <url>");
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
const { readTeamJournalEntries } = await import("../finding/journal.js");
|
|
1642
|
+
const limit = Number(getOptionValue(args.slice(1), "--limit") ?? "20");
|
|
1643
|
+
const allEntries = [];
|
|
1644
|
+
for (const store of teamStores) {
|
|
1645
|
+
if (!fs.existsSync(store.path))
|
|
1646
|
+
continue;
|
|
1647
|
+
const projectDirs = getProjectDirs(store.path);
|
|
1648
|
+
for (const dir of projectDirs) {
|
|
1649
|
+
const projectName = path.basename(dir);
|
|
1650
|
+
const journalEntries = readTeamJournalEntries(store.path, projectName);
|
|
1651
|
+
for (const je of journalEntries) {
|
|
1652
|
+
for (const entry of je.entries) {
|
|
1653
|
+
allEntries.push({ store: store.name, project: projectName, date: je.date, actor: je.actor, entry });
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
allEntries.sort((a, b) => b.date.localeCompare(a.date));
|
|
1659
|
+
const capped = allEntries.slice(0, limit);
|
|
1660
|
+
if (capped.length === 0) {
|
|
1661
|
+
console.log("No team activity yet.");
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
console.log(`Team activity (${capped.length}/${allEntries.length}):\n`);
|
|
1665
|
+
let lastDate = "";
|
|
1666
|
+
for (const e of capped) {
|
|
1667
|
+
if (e.date !== lastDate) {
|
|
1668
|
+
console.log(`## ${e.date}`);
|
|
1669
|
+
lastDate = e.date;
|
|
1670
|
+
}
|
|
1671
|
+
console.log(` [${e.store}/${e.project}] ${e.actor}: ${e.entry}`);
|
|
1672
|
+
}
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
if (subcommand === "sync") {
|
|
1676
|
+
const stores = resolveAllStores(phrenPath);
|
|
1677
|
+
let hasErrors = false;
|
|
1678
|
+
for (const store of stores) {
|
|
1679
|
+
if (!fs.existsSync(store.path)) {
|
|
1680
|
+
console.log(` ${store.name}: SKIP (path missing)`);
|
|
1681
|
+
continue;
|
|
1682
|
+
}
|
|
1683
|
+
const gitDir = path.join(store.path, ".git");
|
|
1684
|
+
if (!fs.existsSync(gitDir)) {
|
|
1685
|
+
console.log(` ${store.name}: SKIP (not a git repo)`);
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1688
|
+
try {
|
|
1689
|
+
execFileSync("git", ["pull", "--rebase", "--quiet"], {
|
|
1690
|
+
cwd: store.path,
|
|
1691
|
+
stdio: "pipe",
|
|
1692
|
+
timeout: 30_000,
|
|
1693
|
+
});
|
|
1694
|
+
console.log(` ${store.name}: ok`);
|
|
1695
|
+
}
|
|
1696
|
+
catch (err) {
|
|
1697
|
+
console.log(` ${store.name}: FAILED (${errorMessage(err).split("\n")[0]})`);
|
|
1698
|
+
hasErrors = true;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
if (hasErrors) {
|
|
1702
|
+
console.error("\nSome stores failed to sync. Run 'phren doctor' for details.");
|
|
1703
|
+
}
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
console.error(`Unknown store subcommand: ${subcommand}`);
|
|
1707
|
+
printStoreUsage();
|
|
1708
|
+
process.exit(1);
|
|
1709
|
+
}
|
|
1710
|
+
// ── Promote namespace ────────────────────────────────────────────────────────
|
|
1711
|
+
export async function handlePromoteNamespace(args) {
|
|
1712
|
+
if (!args[0] || args[0] === "--help" || args[0] === "-h") {
|
|
1713
|
+
console.log("Usage:");
|
|
1714
|
+
console.log(' phren promote <project> "finding text..." --to <store>');
|
|
1715
|
+
console.log(" Copies a finding from the primary store to a team store.");
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const phrenPath = getPhrenPath();
|
|
1719
|
+
const project = args[0];
|
|
1720
|
+
if (!isValidProjectName(project)) {
|
|
1721
|
+
console.error(`Invalid project name: "${project}"`);
|
|
1722
|
+
process.exit(1);
|
|
1723
|
+
}
|
|
1724
|
+
const toStore = getOptionValue(args.slice(1), "--to");
|
|
1725
|
+
if (!toStore) {
|
|
1726
|
+
console.error("--to <store> is required. Specify the target team store.");
|
|
1727
|
+
process.exit(1);
|
|
1728
|
+
}
|
|
1729
|
+
// Everything between project and --to is the finding text
|
|
1730
|
+
const toIdx = args.indexOf("--to");
|
|
1731
|
+
const findingText = args.slice(1, toIdx !== -1 ? toIdx : undefined).join(" ").trim();
|
|
1732
|
+
if (!findingText) {
|
|
1733
|
+
console.error("Finding text is required.");
|
|
1734
|
+
process.exit(1);
|
|
1735
|
+
}
|
|
1736
|
+
const stores = resolveAllStores(phrenPath);
|
|
1737
|
+
const targetStore = stores.find((s) => s.name === toStore);
|
|
1738
|
+
if (!targetStore) {
|
|
1739
|
+
const available = stores.map((s) => s.name).join(", ");
|
|
1740
|
+
console.error(`Store "${toStore}" not found. Available: ${available}`);
|
|
1741
|
+
process.exit(1);
|
|
1742
|
+
}
|
|
1743
|
+
if (targetStore.role === "readonly") {
|
|
1744
|
+
console.error(`Store "${toStore}" is read-only.`);
|
|
1745
|
+
process.exit(1);
|
|
1746
|
+
}
|
|
1747
|
+
if (targetStore.role === "primary") {
|
|
1748
|
+
console.error(`Cannot promote to primary store — finding is already there.`);
|
|
1749
|
+
process.exit(1);
|
|
1750
|
+
}
|
|
1751
|
+
// Find the matching finding in the primary store
|
|
1752
|
+
const { readFindings } = await import("../data/access.js");
|
|
1753
|
+
const findingsResult = readFindings(phrenPath, project);
|
|
1754
|
+
if (!findingsResult.ok) {
|
|
1755
|
+
console.error(`Could not read findings for project "${project}".`);
|
|
1756
|
+
process.exit(1);
|
|
1757
|
+
}
|
|
1758
|
+
const match = findingsResult.data.find((item) => item.text.includes(findingText) || findingText.includes(item.text));
|
|
1759
|
+
if (!match) {
|
|
1760
|
+
console.error(`No finding matching "${findingText.slice(0, 80)}..." found in ${project}.`);
|
|
1761
|
+
process.exit(1);
|
|
1762
|
+
}
|
|
1763
|
+
// Write to target store
|
|
1764
|
+
const targetProjectDir = path.join(targetStore.path, project);
|
|
1765
|
+
fs.mkdirSync(targetProjectDir, { recursive: true });
|
|
1766
|
+
const targetFindingsPath = path.join(targetProjectDir, "FINDINGS.md");
|
|
1767
|
+
const { addFindingToFile } = await import("../shared/content.js");
|
|
1768
|
+
const result = addFindingToFile(targetStore.path, project, match.text);
|
|
1769
|
+
if (!result.ok) {
|
|
1770
|
+
console.error(`Failed to add finding to ${toStore}: ${result.error}`);
|
|
1771
|
+
process.exit(1);
|
|
1772
|
+
}
|
|
1773
|
+
console.log(`Promoted to ${toStore}/${project}:`);
|
|
1774
|
+
console.log(` "${match.text.slice(0, 120)}${match.text.length > 120 ? "..." : ""}"`);
|
|
1775
|
+
}
|
|
1776
|
+
function countStoreProjects(store) {
|
|
1777
|
+
if (!fs.existsSync(store.path))
|
|
1778
|
+
return 0;
|
|
1779
|
+
try {
|
|
1780
|
+
return getProjectDirs(store.path).length;
|
|
1781
|
+
}
|
|
1782
|
+
catch {
|
|
1783
|
+
return 0;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
function readHealthForStore(storePath) {
|
|
1787
|
+
try {
|
|
1788
|
+
const healthPath = path.join(storePath, ".runtime", "health.json");
|
|
1789
|
+
if (!fs.existsSync(healthPath))
|
|
1790
|
+
return null;
|
|
1791
|
+
const raw = JSON.parse(fs.readFileSync(healthPath, "utf8"));
|
|
1792
|
+
const lastSync = raw?.lastSync;
|
|
1793
|
+
if (!lastSync)
|
|
1794
|
+
return null;
|
|
1795
|
+
const parts = [];
|
|
1796
|
+
if (lastSync.lastPullStatus)
|
|
1797
|
+
parts.push(`pull=${lastSync.lastPullStatus}`);
|
|
1798
|
+
if (lastSync.lastPushStatus)
|
|
1799
|
+
parts.push(`push=${lastSync.lastPushStatus}`);
|
|
1800
|
+
if (lastSync.lastSuccessfulPullAt)
|
|
1801
|
+
parts.push(`at=${lastSync.lastSuccessfulPullAt.slice(0, 16)}`);
|
|
1802
|
+
return parts.join(", ") || null;
|
|
1803
|
+
}
|
|
1804
|
+
catch {
|
|
1805
|
+
return null;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
@@ -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
|
}
|
package/mcp/dist/entrypoint.js
CHANGED
|
@@ -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,8 @@ const CLI_COMMANDS = [
|
|
|
168
174
|
"review",
|
|
169
175
|
"consolidation-status",
|
|
170
176
|
"session-context",
|
|
177
|
+
"store",
|
|
178
|
+
"promote",
|
|
171
179
|
];
|
|
172
180
|
async function flushTopLevelOutput() {
|
|
173
181
|
await Promise.all([
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as crypto from "crypto";
|
|
4
|
-
import { runtimeDir, phrenOk, phrenErr, PhrenError } from "../shared.js";
|
|
4
|
+
import { runtimeDir, phrenOk, phrenErr, PhrenError, atomicWriteText } from "../shared.js";
|
|
5
5
|
import { withFileLock } from "../shared/governance.js";
|
|
6
6
|
import { addFindingToFile } from "../shared/content.js";
|
|
7
7
|
import { isValidProjectName, errorMessage } from "../utils.js";
|
|
@@ -120,3 +120,90 @@ export function compactFindingJournals(phrenPath, project) {
|
|
|
120
120
|
}
|
|
121
121
|
return result;
|
|
122
122
|
}
|
|
123
|
+
// ── Team store journal (append-only markdown, committed to git) ──────────────
|
|
124
|
+
const TEAM_JOURNAL_DIR = "journal";
|
|
125
|
+
/**
|
|
126
|
+
* Append a finding to a team store's journal.
|
|
127
|
+
* Each actor gets one file per day — no merge conflicts possible.
|
|
128
|
+
* These are markdown files committed to git (not runtime JSONL).
|
|
129
|
+
*/
|
|
130
|
+
export function appendTeamJournal(storePath, project, finding, actor) {
|
|
131
|
+
const resolvedActor = actor || process.env.PHREN_ACTOR || process.env.USER || "unknown";
|
|
132
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
133
|
+
const journalDir = path.join(storePath, project, TEAM_JOURNAL_DIR);
|
|
134
|
+
const journalFile = `${date}-${resolvedActor}.md`;
|
|
135
|
+
const journalPath = path.join(journalDir, journalFile);
|
|
136
|
+
try {
|
|
137
|
+
fs.mkdirSync(journalDir, { recursive: true });
|
|
138
|
+
const entry = `- ${finding}\n`;
|
|
139
|
+
if (fs.existsSync(journalPath)) {
|
|
140
|
+
fs.appendFileSync(journalPath, entry);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
fs.writeFileSync(journalPath, `## ${date} (${resolvedActor})\n\n${entry}`);
|
|
144
|
+
}
|
|
145
|
+
return phrenOk(journalFile);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return phrenErr(`Team journal append failed: ${errorMessage(err)}`, PhrenError.PERMISSION_DENIED);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Read all team journal entries for a project, newest first.
|
|
153
|
+
*/
|
|
154
|
+
export function readTeamJournalEntries(storePath, project) {
|
|
155
|
+
const journalDir = path.join(storePath, project, TEAM_JOURNAL_DIR);
|
|
156
|
+
if (!fs.existsSync(journalDir))
|
|
157
|
+
return [];
|
|
158
|
+
return fs.readdirSync(journalDir)
|
|
159
|
+
.filter((f) => f.endsWith(".md"))
|
|
160
|
+
.sort()
|
|
161
|
+
.reverse()
|
|
162
|
+
.map((file) => {
|
|
163
|
+
const match = file.match(/^(\d{4}-\d{2}-\d{2})-(.+)\.md$/);
|
|
164
|
+
const date = match?.[1] ?? "unknown";
|
|
165
|
+
const actor = match?.[2] ?? "unknown";
|
|
166
|
+
const content = fs.readFileSync(path.join(journalDir, file), "utf8");
|
|
167
|
+
const entries = content.split("\n")
|
|
168
|
+
.filter((line) => line.startsWith("- "))
|
|
169
|
+
.map((line) => line.slice(2).trim());
|
|
170
|
+
return { file, date, actor, entries };
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Materialize FINDINGS.md from team journal entries.
|
|
175
|
+
* Groups by date, includes actor attribution.
|
|
176
|
+
*/
|
|
177
|
+
export function materializeTeamFindings(storePath, project) {
|
|
178
|
+
const journalEntries = readTeamJournalEntries(storePath, project);
|
|
179
|
+
if (journalEntries.length === 0) {
|
|
180
|
+
return phrenErr("No journal entries found", PhrenError.FILE_NOT_FOUND);
|
|
181
|
+
}
|
|
182
|
+
// Group by date, chronological order
|
|
183
|
+
const byDate = new Map();
|
|
184
|
+
for (const entry of [...journalEntries].reverse()) {
|
|
185
|
+
if (!byDate.has(entry.date))
|
|
186
|
+
byDate.set(entry.date, []);
|
|
187
|
+
byDate.get(entry.date).push({ actor: entry.actor, entries: entry.entries });
|
|
188
|
+
}
|
|
189
|
+
const lines = [`# ${project} findings\n`];
|
|
190
|
+
let count = 0;
|
|
191
|
+
for (const [date, actors] of byDate) {
|
|
192
|
+
lines.push(`## ${date}`);
|
|
193
|
+
for (const { actor, entries } of actors) {
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
lines.push(`- ${entry} <!-- author:${actor} -->`);
|
|
196
|
+
count++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
lines.push("");
|
|
200
|
+
}
|
|
201
|
+
const findingsPath = path.join(storePath, project, "FINDINGS.md");
|
|
202
|
+
try {
|
|
203
|
+
atomicWriteText(findingsPath, lines.join("\n"));
|
|
204
|
+
return phrenOk({ entryCount: count });
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
return phrenErr(`Materialize failed: ${errorMessage(err)}`, PhrenError.PERMISSION_DENIED);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -491,39 +491,44 @@ export async function searchKnowledgeRows(db, options) {
|
|
|
491
491
|
return { safeQuery, rows, usedFallback };
|
|
492
492
|
}
|
|
493
493
|
/**
|
|
494
|
-
*
|
|
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
|
-
|
|
512
|
-
|
|
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
|
|
515
|
+
for (const store of nonPrimaryStores) {
|
|
516
516
|
try {
|
|
517
|
-
const federatedDb = await buildIndex(
|
|
518
|
-
const result = await searchKnowledgeRows(federatedDb, { ...options, phrenPath:
|
|
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({
|
|
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
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import { mcpResponse } from "./types.js";
|
|
1
|
+
import { mcpResponse, resolveStoreForProject } from "./types.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
@@ -84,13 +84,45 @@ function withLifecycleMutation(phrenPath, project, writeQueue, updateIndex, hand
|
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
86
|
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
87
|
-
async function handleAddFinding(ctx,
|
|
88
|
-
const {
|
|
87
|
+
async function handleAddFinding(ctx, params) {
|
|
88
|
+
const { finding, citation, sessionId, source, findingType, scope } = params;
|
|
89
|
+
// Resolve store-qualified project names (e.g., "team/arc" → store path + "arc")
|
|
90
|
+
let phrenPath;
|
|
91
|
+
let project;
|
|
92
|
+
try {
|
|
93
|
+
const resolved = resolveStoreForProject(ctx, params.project);
|
|
94
|
+
phrenPath = resolved.phrenPath;
|
|
95
|
+
project = resolved.project;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
99
|
+
}
|
|
100
|
+
const { withWriteQueue, updateFileInIndex } = ctx;
|
|
89
101
|
if (!isValidProjectName(project))
|
|
90
102
|
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
91
103
|
const addFindingDenied = permissionDeniedError(phrenPath, "add_finding", project);
|
|
92
104
|
if (addFindingDenied)
|
|
93
105
|
return mcpResponse({ ok: false, error: addFindingDenied });
|
|
106
|
+
// Team stores: use append-only journal (no FINDINGS.md mutation, no merge conflicts)
|
|
107
|
+
{
|
|
108
|
+
const storeResolved = resolveStoreForProject(ctx, params.project);
|
|
109
|
+
if (storeResolved.storeRole === "team") {
|
|
110
|
+
const { appendTeamJournal } = await import("../finding/journal.js");
|
|
111
|
+
const findings = Array.isArray(finding) ? finding : [finding];
|
|
112
|
+
const added = [];
|
|
113
|
+
for (const f of findings) {
|
|
114
|
+
const taggedFinding = findingType ? `[${findingType}] ${f}` : f;
|
|
115
|
+
const result = appendTeamJournal(phrenPath, project, taggedFinding);
|
|
116
|
+
if (result.ok)
|
|
117
|
+
added.push(taggedFinding);
|
|
118
|
+
}
|
|
119
|
+
return mcpResponse({
|
|
120
|
+
ok: added.length > 0,
|
|
121
|
+
message: `Added ${added.length} finding(s) to ${params.project} journal`,
|
|
122
|
+
data: { project: params.project, added, journalMode: true },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
94
126
|
if (Array.isArray(finding)) {
|
|
95
127
|
const findings = finding;
|
|
96
128
|
if (findings.length > 100)
|
package/mcp/dist/tools/search.js
CHANGED
|
@@ -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";
|
|
@@ -456,8 +458,28 @@ async function handleSearchKnowledge(ctx, { query, limit, project, type, tag, si
|
|
|
456
458
|
}
|
|
457
459
|
}
|
|
458
460
|
async function handleGetProjectSummary(ctx, { name }) {
|
|
461
|
+
// Support store-qualified names (e.g., "team/arc")
|
|
462
|
+
const { parseStoreQualified } = await import("../store-routing.js");
|
|
463
|
+
const { storeName, projectName } = parseStoreQualified(name);
|
|
464
|
+
const lookupName = projectName;
|
|
459
465
|
const db = ctx.db();
|
|
460
|
-
|
|
466
|
+
let docs = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [lookupName]);
|
|
467
|
+
// If not in primary index and store-qualified, try reading from the store's filesystem
|
|
468
|
+
if (!docs && storeName) {
|
|
469
|
+
const store = resolveAllStores(ctx.phrenPath).find((s) => s.name === storeName);
|
|
470
|
+
if (store && fs.existsSync(path.join(store.path, lookupName))) {
|
|
471
|
+
const projDir = path.join(store.path, lookupName);
|
|
472
|
+
const fsDocs = [];
|
|
473
|
+
for (const [file, type] of [["summary.md", "summary"], ["CLAUDE.md", "claude"], ["FINDINGS.md", "findings"], ["tasks.md", "task"]]) {
|
|
474
|
+
const filePath = path.join(projDir, file);
|
|
475
|
+
if (fs.existsSync(filePath)) {
|
|
476
|
+
fsDocs.push({ filename: file, type, content: fs.readFileSync(filePath, "utf8").slice(0, 8000), path: filePath });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (fsDocs.length > 0)
|
|
480
|
+
docs = fsDocs;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
461
483
|
if (!docs) {
|
|
462
484
|
const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
|
|
463
485
|
const names = projectRows ? projectRows.map(row => decodeStringRow(row, 1, "get_project_summary.projects")[0]) : [];
|
|
@@ -492,42 +514,83 @@ async function handleGetProjectSummary(ctx, { name }) {
|
|
|
492
514
|
async function handleListProjects(ctx, { page, page_size }) {
|
|
493
515
|
const { phrenPath, profile } = ctx;
|
|
494
516
|
const db = ctx.db();
|
|
517
|
+
// Gather projects from primary store index
|
|
495
518
|
const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
|
|
496
|
-
|
|
519
|
+
const primaryProjects = projectRows
|
|
520
|
+
? projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0])
|
|
521
|
+
: [];
|
|
522
|
+
// Gather projects from non-primary stores
|
|
523
|
+
const { getNonPrimaryStores } = await import("../store-registry.js");
|
|
524
|
+
const { getProjectDirs } = await import("../phren-paths.js");
|
|
525
|
+
const nonPrimaryStores = getNonPrimaryStores(phrenPath);
|
|
526
|
+
const storeProjects = [];
|
|
527
|
+
for (const store of nonPrimaryStores) {
|
|
528
|
+
if (!fs.existsSync(store.path))
|
|
529
|
+
continue;
|
|
530
|
+
const dirs = getProjectDirs(store.path);
|
|
531
|
+
for (const dir of dirs) {
|
|
532
|
+
const projName = path.basename(dir);
|
|
533
|
+
storeProjects.push({ name: projName, store: store.name });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Combine: primary projects (no store prefix) + non-primary (with store prefix)
|
|
537
|
+
const allProjects = [
|
|
538
|
+
...primaryProjects.map((p) => ({ name: p, store: undefined })),
|
|
539
|
+
...storeProjects,
|
|
540
|
+
];
|
|
541
|
+
if (allProjects.length === 0)
|
|
497
542
|
return mcpResponse({ ok: true, message: "No projects indexed.", data: { projects: [], total: 0 } });
|
|
498
|
-
const projects = projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0]);
|
|
499
543
|
const pageSize = page_size ?? 20;
|
|
500
544
|
const pageNum = page ?? 1;
|
|
501
545
|
const start = Math.max(0, (pageNum - 1) * pageSize);
|
|
502
546
|
const end = start + pageSize;
|
|
503
|
-
const pageProjects =
|
|
504
|
-
const totalPages = Math.max(1, Math.ceil(
|
|
547
|
+
const pageProjects = allProjects.slice(start, end);
|
|
548
|
+
const totalPages = Math.max(1, Math.ceil(allProjects.length / pageSize));
|
|
505
549
|
if (pageNum > totalPages) {
|
|
506
550
|
return mcpResponse({ ok: false, error: `Page ${pageNum} out of range. Total pages: ${totalPages}.` });
|
|
507
551
|
}
|
|
508
552
|
const badgeTypes = ["claude", "findings", "summary", "task"];
|
|
509
553
|
const badgeLabels = { claude: "CLAUDE.md", findings: "FINDINGS", summary: "summary", task: "task" };
|
|
510
|
-
const projectList = pageProjects.map((
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
554
|
+
const projectList = pageProjects.map((entry) => {
|
|
555
|
+
// Primary store projects: query the DB for badge info
|
|
556
|
+
if (!entry.store) {
|
|
557
|
+
const rows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [entry.name]) ?? [];
|
|
558
|
+
const types = rows.map(row => row.type);
|
|
559
|
+
const summaryRow = rows.find(row => row.type === "summary");
|
|
560
|
+
const claudeRow = rows.find(row => row.type === "claude");
|
|
561
|
+
const source = summaryRow?.content ?? claudeRow?.content;
|
|
562
|
+
let brief = "";
|
|
563
|
+
if (source) {
|
|
564
|
+
const firstLine = source.split("\n").find(l => l.trim() && !l.startsWith("#"));
|
|
565
|
+
brief = firstLine?.trim() || "";
|
|
566
|
+
}
|
|
567
|
+
const badges = badgeTypes.filter(t => types.includes(t)).map(t => badgeLabels[t]);
|
|
568
|
+
return { name: entry.name, store: undefined, brief, badges, fileCount: rows.length };
|
|
520
569
|
}
|
|
521
|
-
|
|
522
|
-
|
|
570
|
+
// Non-primary store projects: basic info (no DB query, just check file existence)
|
|
571
|
+
const store = nonPrimaryStores.find((s) => s.name === entry.store);
|
|
572
|
+
const projDir = store ? path.join(store.path, entry.name) : "";
|
|
573
|
+
const badges = [];
|
|
574
|
+
if (projDir) {
|
|
575
|
+
if (fs.existsSync(path.join(projDir, "CLAUDE.md")))
|
|
576
|
+
badges.push("CLAUDE.md");
|
|
577
|
+
if (fs.existsSync(path.join(projDir, "FINDINGS.md")))
|
|
578
|
+
badges.push("FINDINGS");
|
|
579
|
+
if (fs.existsSync(path.join(projDir, "summary.md")))
|
|
580
|
+
badges.push("summary");
|
|
581
|
+
if (fs.existsSync(path.join(projDir, "tasks.md")))
|
|
582
|
+
badges.push("task");
|
|
583
|
+
}
|
|
584
|
+
return { name: entry.name, store: entry.store, brief: "", badges, fileCount: badges.length };
|
|
523
585
|
});
|
|
524
|
-
const lines = [`# Phren Projects (${
|
|
586
|
+
const lines = [`# Phren Projects (${allProjects.length})`];
|
|
525
587
|
if (profile)
|
|
526
588
|
lines.push(`Profile: ${profile}`);
|
|
527
589
|
lines.push(`Page: ${pageNum}/${totalPages} (page_size=${pageSize})`);
|
|
528
590
|
lines.push(`Path: ${phrenPath}\n`);
|
|
529
591
|
for (const p of projectList) {
|
|
530
|
-
|
|
592
|
+
const storeTag = p.store ? ` (${p.store})` : "";
|
|
593
|
+
lines.push(`## ${p.name}${storeTag}`);
|
|
531
594
|
if (p.brief)
|
|
532
595
|
lines.push(p.brief);
|
|
533
596
|
lines.push(`[${p.badges.join(" | ")}] - ${p.fileCount} file(s)\n`);
|
|
@@ -535,7 +598,7 @@ async function handleListProjects(ctx, { page, page_size }) {
|
|
|
535
598
|
return mcpResponse({
|
|
536
599
|
ok: true,
|
|
537
600
|
message: lines.join("\n"),
|
|
538
|
-
data: { projects: projectList, total:
|
|
601
|
+
data: { projects: projectList, total: allProjects.length, page: pageNum, totalPages, pageSize },
|
|
539
602
|
});
|
|
540
603
|
}
|
|
541
604
|
async function handleGetFindings(ctx, { project, limit, include_superseded, include_history, status }) {
|
|
@@ -610,6 +673,40 @@ async function handleGetFindings(ctx, { project, limit, include_superseded, incl
|
|
|
610
673
|
data: { project, findings: capped, total: filteredItems.length, status: status ?? null, include_history: includeHistory, historyCount: hiddenHistoryCount },
|
|
611
674
|
});
|
|
612
675
|
}
|
|
676
|
+
async function handleStoreList(ctx) {
|
|
677
|
+
const { phrenPath } = ctx;
|
|
678
|
+
const stores = resolveAllStores(phrenPath);
|
|
679
|
+
const storeData = stores.map((store) => {
|
|
680
|
+
let health = null;
|
|
681
|
+
try {
|
|
682
|
+
const healthPath = path.join(store.path, ".runtime", "health.json");
|
|
683
|
+
if (fs.existsSync(healthPath)) {
|
|
684
|
+
health = JSON.parse(fs.readFileSync(healthPath, "utf8"))?.lastSync ?? null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch { /* non-critical */ }
|
|
688
|
+
return {
|
|
689
|
+
id: store.id,
|
|
690
|
+
name: store.name,
|
|
691
|
+
path: store.path,
|
|
692
|
+
role: store.role,
|
|
693
|
+
sync: store.sync,
|
|
694
|
+
remote: store.remote ?? null,
|
|
695
|
+
exists: fs.existsSync(store.path),
|
|
696
|
+
projects: store.projects ?? null,
|
|
697
|
+
lastSync: health,
|
|
698
|
+
};
|
|
699
|
+
});
|
|
700
|
+
const lines = storeData.map((s) => {
|
|
701
|
+
const status = s.exists ? "ok" : "MISSING";
|
|
702
|
+
return `- ${s.name} (${s.role}) [${status}] ${s.remote ?? "(local)"}`;
|
|
703
|
+
});
|
|
704
|
+
return mcpResponse({
|
|
705
|
+
ok: true,
|
|
706
|
+
message: `${storeData.length} store(s):\n${lines.join("\n")}`,
|
|
707
|
+
data: { stores: storeData },
|
|
708
|
+
});
|
|
709
|
+
}
|
|
613
710
|
// ── Registration ─────────────────────────────────────────────────────────────
|
|
614
711
|
export function register(server, ctx) {
|
|
615
712
|
server.registerTool("get_memory_detail", {
|
|
@@ -668,4 +765,10 @@ export function register(server, ctx) {
|
|
|
668
765
|
status: z.enum(FINDING_LIFECYCLE_STATUSES).optional().describe("Filter findings by lifecycle status."),
|
|
669
766
|
}),
|
|
670
767
|
}, (params) => handleGetFindings(ctx, params));
|
|
768
|
+
server.registerTool("store_list", {
|
|
769
|
+
title: "◆ phren · stores",
|
|
770
|
+
description: "List all registered phren stores and their sync status. " +
|
|
771
|
+
"Shows the primary store plus any team or readonly stores from the store registry.",
|
|
772
|
+
inputSchema: z.object({}),
|
|
773
|
+
}, () => handleStoreList(ctx));
|
|
671
774
|
}
|
package/mcp/dist/tools/tasks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mcpResponse } from "./types.js";
|
|
1
|
+
import { mcpResponse, resolveStoreForProject } from "./types.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
@@ -207,10 +207,21 @@ export function register(server, ctx) {
|
|
|
207
207
|
]).describe("The task(s) to add. Pass a string for one task, or an array for bulk."),
|
|
208
208
|
scope: z.string().optional().describe("Optional memory scope label. Defaults to 'shared'. Example: 'researcher' or 'builder'."),
|
|
209
209
|
}),
|
|
210
|
-
}, async ({ project, item, scope }) => {
|
|
210
|
+
}, async ({ project: projectInput, item, scope }) => {
|
|
211
|
+
// Resolve store-qualified project names (e.g., "team/arc")
|
|
212
|
+
let targetPhrenPath;
|
|
213
|
+
let project;
|
|
214
|
+
try {
|
|
215
|
+
const resolved = resolveStoreForProject(ctx, projectInput);
|
|
216
|
+
targetPhrenPath = resolved.phrenPath;
|
|
217
|
+
project = resolved.project;
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
221
|
+
}
|
|
211
222
|
if (!isValidProjectName(project))
|
|
212
223
|
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
213
|
-
const addTaskDenied = permissionDeniedError(
|
|
224
|
+
const addTaskDenied = permissionDeniedError(targetPhrenPath, "add_task", project);
|
|
214
225
|
if (addTaskDenied)
|
|
215
226
|
return mcpResponse({ ok: false, error: addTaskDenied });
|
|
216
227
|
const normalizedScope = normalizeMemoryScope(scope ?? "shared");
|
|
@@ -218,20 +229,20 @@ export function register(server, ctx) {
|
|
|
218
229
|
return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` });
|
|
219
230
|
if (Array.isArray(item)) {
|
|
220
231
|
return withWriteQueue(async () => {
|
|
221
|
-
const result = addTasksBatch(
|
|
232
|
+
const result = addTasksBatch(targetPhrenPath, project, item, { scope: normalizedScope });
|
|
222
233
|
if (!result.ok)
|
|
223
234
|
return mcpResponse({ ok: false, error: result.error });
|
|
224
235
|
const { added, errors } = result.data;
|
|
225
236
|
if (added.length > 0)
|
|
226
|
-
refreshTaskIndex(updateFileInIndex,
|
|
237
|
+
refreshTaskIndex(updateFileInIndex, targetPhrenPath, project);
|
|
227
238
|
return mcpResponse({ ok: added.length > 0, ...(added.length === 0 ? { error: `No tasks added: ${errors.join("; ")}` } : {}), message: `Added ${added.length} of ${item.length} tasks to ${project}`, data: { project, added, errors } });
|
|
228
239
|
});
|
|
229
240
|
}
|
|
230
241
|
return withWriteQueue(async () => {
|
|
231
|
-
const result = addTaskStore(
|
|
242
|
+
const result = addTaskStore(targetPhrenPath, project, item, { scope: normalizedScope });
|
|
232
243
|
if (!result.ok)
|
|
233
244
|
return mcpResponse({ ok: false, error: result.error });
|
|
234
|
-
refreshTaskIndex(updateFileInIndex,
|
|
245
|
+
refreshTaskIndex(updateFileInIndex, targetPhrenPath, project);
|
|
235
246
|
return mcpResponse({ ok: true, message: `Task added: ${result.data.line}`, data: { project, item, scope: normalizedScope } });
|
|
236
247
|
});
|
|
237
248
|
});
|
package/mcp/dist/tools/types.js
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
import { parseStoreQualified } from "../store-routing.js";
|
|
2
|
+
import { resolveAllStores } from "../store-registry.js";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the effective phrenPath and bare project name for a project input.
|
|
5
|
+
* Handles store-qualified names ("store/project") by routing to the correct store.
|
|
6
|
+
* Returns the primary store path for bare names.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveStoreForProject(ctx, projectInput) {
|
|
9
|
+
const { storeName, projectName } = parseStoreQualified(projectInput);
|
|
10
|
+
if (!storeName) {
|
|
11
|
+
return { phrenPath: ctx.phrenPath, project: projectName, storeRole: "primary" };
|
|
12
|
+
}
|
|
13
|
+
const stores = resolveAllStores(ctx.phrenPath);
|
|
14
|
+
const store = stores.find((s) => s.name === storeName);
|
|
15
|
+
if (!store) {
|
|
16
|
+
throw new Error(`Store "${storeName}" not found`);
|
|
17
|
+
}
|
|
18
|
+
if (store.role === "readonly") {
|
|
19
|
+
throw new Error(`Store "${storeName}" is read-only`);
|
|
20
|
+
}
|
|
21
|
+
return { phrenPath: store.path, project: projectName, storeRole: store.role };
|
|
22
|
+
}
|
|
1
23
|
/**
|
|
2
24
|
* Convert an McpToolResult into the MCP SDK response format.
|
|
3
25
|
* Single shared implementation — replaces the per-file jsonResponse() duplicates.
|