@phren/cli 0.0.41 → 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 +3 -1
- package/mcp/dist/cli/namespaces.js +108 -0
- package/mcp/dist/entrypoint.js +1 -0
- package/mcp/dist/finding/journal.js +88 -1
- package/mcp/dist/tools/finding.js +35 -3
- package/mcp/dist/tools/search.js +81 -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, handleStoreNamespace, 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";
|
|
@@ -109,6 +109,8 @@ export async function runCliCommand(command, args) {
|
|
|
109
109
|
return handleSessionContext();
|
|
110
110
|
case "store":
|
|
111
111
|
return handleStoreNamespace(args);
|
|
112
|
+
case "promote":
|
|
113
|
+
return handlePromoteNamespace(args);
|
|
112
114
|
default:
|
|
113
115
|
console.error(`Unknown command: ${command}\nRun 'phren --help' for available commands.`);
|
|
114
116
|
process.exit(1);
|
|
@@ -1509,6 +1509,7 @@ function printStoreUsage() {
|
|
|
1509
1509
|
console.log(" phren store add <name> --remote <url> Add a team store");
|
|
1510
1510
|
console.log(" phren store remove <name> Remove a store (local only)");
|
|
1511
1511
|
console.log(" phren store sync Pull all stores");
|
|
1512
|
+
console.log(" phren store activity [--limit N] Recent team findings");
|
|
1512
1513
|
}
|
|
1513
1514
|
export async function handleStoreNamespace(args) {
|
|
1514
1515
|
const subcommand = args[0];
|
|
@@ -1630,6 +1631,47 @@ export async function handleStoreNamespace(args) {
|
|
|
1630
1631
|
}
|
|
1631
1632
|
return;
|
|
1632
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
|
+
}
|
|
1633
1675
|
if (subcommand === "sync") {
|
|
1634
1676
|
const stores = resolveAllStores(phrenPath);
|
|
1635
1677
|
let hasErrors = false;
|
|
@@ -1665,6 +1707,72 @@ export async function handleStoreNamespace(args) {
|
|
|
1665
1707
|
printStoreUsage();
|
|
1666
1708
|
process.exit(1);
|
|
1667
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
|
+
}
|
|
1668
1776
|
function countStoreProjects(store) {
|
|
1669
1777
|
if (!fs.existsSync(store.path))
|
|
1670
1778
|
return 0;
|
package/mcp/dist/entrypoint.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -458,8 +458,28 @@ async function handleSearchKnowledge(ctx, { query, limit, project, type, tag, si
|
|
|
458
458
|
}
|
|
459
459
|
}
|
|
460
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;
|
|
461
465
|
const db = ctx.db();
|
|
462
|
-
|
|
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
|
+
}
|
|
463
483
|
if (!docs) {
|
|
464
484
|
const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
|
|
465
485
|
const names = projectRows ? projectRows.map(row => decodeStringRow(row, 1, "get_project_summary.projects")[0]) : [];
|
|
@@ -494,42 +514,83 @@ async function handleGetProjectSummary(ctx, { name }) {
|
|
|
494
514
|
async function handleListProjects(ctx, { page, page_size }) {
|
|
495
515
|
const { phrenPath, profile } = ctx;
|
|
496
516
|
const db = ctx.db();
|
|
517
|
+
// Gather projects from primary store index
|
|
497
518
|
const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
|
|
498
|
-
|
|
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)
|
|
499
542
|
return mcpResponse({ ok: true, message: "No projects indexed.", data: { projects: [], total: 0 } });
|
|
500
|
-
const projects = projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0]);
|
|
501
543
|
const pageSize = page_size ?? 20;
|
|
502
544
|
const pageNum = page ?? 1;
|
|
503
545
|
const start = Math.max(0, (pageNum - 1) * pageSize);
|
|
504
546
|
const end = start + pageSize;
|
|
505
|
-
const pageProjects =
|
|
506
|
-
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));
|
|
507
549
|
if (pageNum > totalPages) {
|
|
508
550
|
return mcpResponse({ ok: false, error: `Page ${pageNum} out of range. Total pages: ${totalPages}.` });
|
|
509
551
|
}
|
|
510
552
|
const badgeTypes = ["claude", "findings", "summary", "task"];
|
|
511
553
|
const badgeLabels = { claude: "CLAUDE.md", findings: "FINDINGS", summary: "summary", task: "task" };
|
|
512
|
-
const projectList = pageProjects.map((
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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 };
|
|
569
|
+
}
|
|
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");
|
|
522
583
|
}
|
|
523
|
-
|
|
524
|
-
return { name: proj, brief, badges, fileCount: rows.length };
|
|
584
|
+
return { name: entry.name, store: entry.store, brief: "", badges, fileCount: badges.length };
|
|
525
585
|
});
|
|
526
|
-
const lines = [`# Phren Projects (${
|
|
586
|
+
const lines = [`# Phren Projects (${allProjects.length})`];
|
|
527
587
|
if (profile)
|
|
528
588
|
lines.push(`Profile: ${profile}`);
|
|
529
589
|
lines.push(`Page: ${pageNum}/${totalPages} (page_size=${pageSize})`);
|
|
530
590
|
lines.push(`Path: ${phrenPath}\n`);
|
|
531
591
|
for (const p of projectList) {
|
|
532
|
-
|
|
592
|
+
const storeTag = p.store ? ` (${p.store})` : "";
|
|
593
|
+
lines.push(`## ${p.name}${storeTag}`);
|
|
533
594
|
if (p.brief)
|
|
534
595
|
lines.push(p.brief);
|
|
535
596
|
lines.push(`[${p.badges.join(" | ")}] - ${p.fileCount} file(s)\n`);
|
|
@@ -537,7 +598,7 @@ async function handleListProjects(ctx, { page, page_size }) {
|
|
|
537
598
|
return mcpResponse({
|
|
538
599
|
ok: true,
|
|
539
600
|
message: lines.join("\n"),
|
|
540
|
-
data: { projects: projectList, total:
|
|
601
|
+
data: { projects: projectList, total: allProjects.length, page: pageNum, totalPages, pageSize },
|
|
541
602
|
});
|
|
542
603
|
}
|
|
543
604
|
async function handleGetFindings(ctx, { project, limit, include_superseded, include_history, status }) {
|
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.
|