@phren/cli 0.0.43 → 0.0.45
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/actions.js +23 -0
- package/mcp/dist/cli/cli.js +6 -1
- package/mcp/dist/cli/team.js +301 -0
- package/mcp/dist/cli-hooks-session-handlers.js +18 -0
- package/mcp/dist/cli-hooks-stop.js +35 -5
- package/mcp/dist/entrypoint.js +8 -0
- package/mcp/dist/init/setup.js +40 -1
- package/mcp/dist/link/doctor.js +1 -1
- package/mcp/dist/shared/index.js +52 -7
- package/mcp/dist/shared/retrieval.js +9 -3
- package/mcp/dist/store-registry.js +15 -1
- package/mcp/dist/tools/finding.js +112 -10
- package/mcp/dist/tools/memory.js +49 -4
- package/mcp/dist/tools/search.js +10 -1
- package/mcp/dist/tools/types.js +10 -0
- package/package.json +1 -1
package/mcp/dist/cli/actions.js
CHANGED
|
@@ -63,6 +63,29 @@ export async function handlePinCanonical(project, memory) {
|
|
|
63
63
|
const result = upsertCanonical(getPhrenPath(), project, memory);
|
|
64
64
|
console.log(result.ok ? result.data : result.error);
|
|
65
65
|
}
|
|
66
|
+
export async function handleTruths(project) {
|
|
67
|
+
if (!project) {
|
|
68
|
+
console.error("Usage: phren truths <project>");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
const phrenPath = getPhrenPath();
|
|
72
|
+
const truthsPath = path.join(phrenPath, project, "truths.md");
|
|
73
|
+
if (!fs.existsSync(truthsPath)) {
|
|
74
|
+
console.log(`No truths pinned for "${project}" yet.`);
|
|
75
|
+
console.log(`\nPin one: phren pin ${project} "your truth here"`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const content = fs.readFileSync(truthsPath, "utf8");
|
|
79
|
+
const truths = content.split("\n").filter((line) => line.startsWith("- "));
|
|
80
|
+
if (truths.length === 0) {
|
|
81
|
+
console.log(`No truths pinned for "${project}" yet.`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
console.log(`${truths.length} truth(s) for "${project}":\n`);
|
|
85
|
+
for (const truth of truths) {
|
|
86
|
+
console.log(` ${truth}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
66
89
|
export async function handleDoctor(args) {
|
|
67
90
|
const profile = resolveRuntimeProfile(getPhrenPath());
|
|
68
91
|
const fix = args.includes("--fix");
|
package/mcp/dist/cli/cli.js
CHANGED
|
@@ -8,8 +8,9 @@ import { handleGovernMemories, handlePruneMemories, handleConsolidateMemories, h
|
|
|
8
8
|
import { handleConfig, handleIndexPolicy, handleRetentionPolicy, handleWorkflowPolicy, } from "./config.js";
|
|
9
9
|
import { parseSearchArgs } from "./search.js";
|
|
10
10
|
import { handleDetectSkills, handleFindingNamespace, handleHooksNamespace, handleProjectsNamespace, handleSkillsNamespace, handleSkillList, handlePromoteNamespace, handleStoreNamespace, handleTaskNamespace, } from "./namespaces.js";
|
|
11
|
+
import { handleTeamNamespace } from "./team.js";
|
|
11
12
|
import { handleTaskView, handleSessionsView, handleQuickstart, handleDebugInjection, handleInspectIndex, } from "./ops.js";
|
|
12
|
-
import { handleAddFinding, handleDoctor, handleFragmentSearch, handleMemoryUi, handlePinCanonical, handleQualityFeedback, handleRelatedDocs, handleReview, handleConsolidationStatus, handleSessionContext, handleSearch, handleShell, handleStatus, handleUpdate, } from "./actions.js";
|
|
13
|
+
import { handleAddFinding, handleDoctor, handleFragmentSearch, handleMemoryUi, handleTruths, handlePinCanonical, handleQualityFeedback, handleRelatedDocs, handleReview, handleConsolidationStatus, handleSessionContext, handleSearch, handleShell, handleStatus, handleUpdate, } from "./actions.js";
|
|
13
14
|
import { handleGraphNamespace } from "./graph.js";
|
|
14
15
|
import { resolveRuntimeProfile } from "../runtime-profile.js";
|
|
15
16
|
// ── CLI router ───────────────────────────────────────────────────────────────
|
|
@@ -107,8 +108,12 @@ export async function runCliCommand(command, args) {
|
|
|
107
108
|
return handleConsolidationStatus(args);
|
|
108
109
|
case "session-context":
|
|
109
110
|
return handleSessionContext();
|
|
111
|
+
case "truths":
|
|
112
|
+
return handleTruths(args[0]);
|
|
110
113
|
case "store":
|
|
111
114
|
return handleStoreNamespace(args);
|
|
115
|
+
case "team":
|
|
116
|
+
return handleTeamNamespace(args);
|
|
112
117
|
case "promote":
|
|
113
118
|
return handlePromoteNamespace(args);
|
|
114
119
|
default:
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team store CLI commands: init, join, add-project.
|
|
3
|
+
* Creates and manages shared phren stores for team collaboration.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import { execFileSync } from "child_process";
|
|
8
|
+
import { getPhrenPath } from "../shared.js";
|
|
9
|
+
import { isValidProjectName } from "../utils.js";
|
|
10
|
+
import { addStoreToRegistry, findStoreByName, generateStoreId, readTeamBootstrap, updateStoreProjects, } from "../store-registry.js";
|
|
11
|
+
const EXEC_TIMEOUT_MS = 30_000;
|
|
12
|
+
function getOptionValue(args, name) {
|
|
13
|
+
const exactIdx = args.indexOf(name);
|
|
14
|
+
if (exactIdx !== -1)
|
|
15
|
+
return args[exactIdx + 1];
|
|
16
|
+
const prefixed = args.find((arg) => arg.startsWith(`${name}=`));
|
|
17
|
+
return prefixed ? prefixed.slice(name.length + 1) : undefined;
|
|
18
|
+
}
|
|
19
|
+
function getPositionalArgs(args, optionNames) {
|
|
20
|
+
const positions = [];
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
const arg = args[i];
|
|
23
|
+
if (optionNames.includes(arg)) {
|
|
24
|
+
i++;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (optionNames.some((name) => arg.startsWith(`${name}=`)))
|
|
28
|
+
continue;
|
|
29
|
+
if (!arg.startsWith("--"))
|
|
30
|
+
positions.push(arg);
|
|
31
|
+
}
|
|
32
|
+
return positions;
|
|
33
|
+
}
|
|
34
|
+
function atomicWriteText(filePath, content) {
|
|
35
|
+
const dir = path.dirname(filePath);
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
const tmp = filePath + ".tmp." + process.pid;
|
|
38
|
+
fs.writeFileSync(tmp, content);
|
|
39
|
+
fs.renameSync(tmp, filePath);
|
|
40
|
+
}
|
|
41
|
+
// ── phren team init <name> [--remote <url>] [--description <desc>] ──────────
|
|
42
|
+
async function handleTeamInit(args) {
|
|
43
|
+
const phrenPath = getPhrenPath();
|
|
44
|
+
const positional = getPositionalArgs(args, ["--remote", "--description"]);
|
|
45
|
+
const name = positional[0];
|
|
46
|
+
const remote = getOptionValue(args, "--remote");
|
|
47
|
+
const description = getOptionValue(args, "--description");
|
|
48
|
+
if (!name) {
|
|
49
|
+
console.error("Usage: phren team init <name> [--remote <url>] [--description <text>]");
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
if (!isValidProjectName(name)) {
|
|
53
|
+
console.error(`Invalid store name: "${name}". Use lowercase letters, numbers, hyphens.`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
// Check if store already exists
|
|
57
|
+
const existing = findStoreByName(phrenPath, name);
|
|
58
|
+
if (existing) {
|
|
59
|
+
console.error(`Store "${name}" already exists at ${existing.path}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const storesDir = path.join(path.dirname(phrenPath), ".phren-stores");
|
|
63
|
+
const storePath = path.join(storesDir, name);
|
|
64
|
+
if (fs.existsSync(storePath)) {
|
|
65
|
+
console.error(`Directory already exists: ${storePath}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
// Create the team store directory
|
|
69
|
+
fs.mkdirSync(storePath, { recursive: true });
|
|
70
|
+
// Write .phren-team.yaml
|
|
71
|
+
const bootstrap = {
|
|
72
|
+
name,
|
|
73
|
+
description: description || `${name} team knowledge`,
|
|
74
|
+
default_role: "team",
|
|
75
|
+
};
|
|
76
|
+
atomicWriteText(path.join(storePath, ".phren-team.yaml"), `name: ${bootstrap.name}\ndescription: ${bootstrap.description}\ndefault_role: team\n`);
|
|
77
|
+
// Write .gitignore
|
|
78
|
+
atomicWriteText(path.join(storePath, ".gitignore"), ".runtime/\n.sessions/\n*.lock\n");
|
|
79
|
+
// Create global directory with starter files
|
|
80
|
+
const globalDir = path.join(storePath, "global");
|
|
81
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
82
|
+
atomicWriteText(path.join(globalDir, "CLAUDE.md"), `# ${name} Team Store\n\nShared knowledge for the ${name} team.\n`);
|
|
83
|
+
atomicWriteText(path.join(globalDir, "FINDINGS.md"), `# global findings\n`);
|
|
84
|
+
// Initialize git repo
|
|
85
|
+
execFileSync("git", ["init"], {
|
|
86
|
+
cwd: storePath,
|
|
87
|
+
stdio: "pipe",
|
|
88
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
89
|
+
});
|
|
90
|
+
// Initial commit
|
|
91
|
+
execFileSync("git", ["add", "-A"], { cwd: storePath, stdio: "pipe", timeout: EXEC_TIMEOUT_MS });
|
|
92
|
+
execFileSync("git", ["commit", "-m", "phren: initialize team store"], {
|
|
93
|
+
cwd: storePath,
|
|
94
|
+
stdio: "pipe",
|
|
95
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
96
|
+
});
|
|
97
|
+
// Add remote if provided
|
|
98
|
+
if (remote) {
|
|
99
|
+
execFileSync("git", ["remote", "add", "origin", remote], {
|
|
100
|
+
cwd: storePath,
|
|
101
|
+
stdio: "pipe",
|
|
102
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
execFileSync("git", ["push", "-u", "origin", "main"], {
|
|
106
|
+
cwd: storePath,
|
|
107
|
+
stdio: "pipe",
|
|
108
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
109
|
+
});
|
|
110
|
+
console.log(` Pushed to ${remote}`);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Try HEAD branch name
|
|
114
|
+
try {
|
|
115
|
+
execFileSync("git", ["push", "-u", "origin", "HEAD"], {
|
|
116
|
+
cwd: storePath,
|
|
117
|
+
stdio: "pipe",
|
|
118
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
119
|
+
});
|
|
120
|
+
console.log(` Pushed to ${remote}`);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
console.log(` Remote added but push failed. Push manually: cd ${storePath} && git push -u origin main`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Register in primary store's stores.yaml
|
|
128
|
+
const entry = {
|
|
129
|
+
id: generateStoreId(),
|
|
130
|
+
name,
|
|
131
|
+
path: storePath,
|
|
132
|
+
role: "team",
|
|
133
|
+
sync: "managed-git",
|
|
134
|
+
...(remote ? { remote } : {}),
|
|
135
|
+
};
|
|
136
|
+
addStoreToRegistry(phrenPath, entry);
|
|
137
|
+
console.log(`\nCreated team store: ${name}`);
|
|
138
|
+
console.log(` Path: ${storePath}`);
|
|
139
|
+
console.log(` Role: team`);
|
|
140
|
+
console.log(` ID: ${entry.id}`);
|
|
141
|
+
if (!remote) {
|
|
142
|
+
console.log(`\nNext: add a remote and push`);
|
|
143
|
+
console.log(` cd ${storePath}`);
|
|
144
|
+
console.log(` git remote add origin <your-git-url>`);
|
|
145
|
+
console.log(` git push -u origin main`);
|
|
146
|
+
}
|
|
147
|
+
console.log(`\nAdd projects: phren team add-project ${name} <project-name>`);
|
|
148
|
+
}
|
|
149
|
+
// ── phren team join <url> [--name <name>] ───────────────────────────────────
|
|
150
|
+
async function handleTeamJoin(args) {
|
|
151
|
+
const phrenPath = getPhrenPath();
|
|
152
|
+
const positional = getPositionalArgs(args, ["--name"]);
|
|
153
|
+
const remote = positional[0];
|
|
154
|
+
const nameOverride = getOptionValue(args, "--name");
|
|
155
|
+
if (!remote) {
|
|
156
|
+
console.error("Usage: phren team join <git-url> [--name <name>]");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
const storesDir = path.join(path.dirname(phrenPath), ".phren-stores");
|
|
160
|
+
// Infer name from URL if not provided
|
|
161
|
+
const inferredName = nameOverride || path.basename(remote, ".git").toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
|
162
|
+
if (!isValidProjectName(inferredName)) {
|
|
163
|
+
console.error(`Invalid store name: "${inferredName}". Use --name to specify a valid name.`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
const existing = findStoreByName(phrenPath, inferredName);
|
|
167
|
+
if (existing) {
|
|
168
|
+
console.error(`Store "${inferredName}" already exists at ${existing.path}`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
const storePath = path.join(storesDir, inferredName);
|
|
172
|
+
if (fs.existsSync(storePath)) {
|
|
173
|
+
console.error(`Directory already exists: ${storePath}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
// Clone the remote
|
|
177
|
+
console.log(`Cloning ${remote}...`);
|
|
178
|
+
fs.mkdirSync(storesDir, { recursive: true });
|
|
179
|
+
execFileSync("git", ["clone", "--", remote, storePath], {
|
|
180
|
+
stdio: "inherit",
|
|
181
|
+
timeout: 60_000,
|
|
182
|
+
});
|
|
183
|
+
// Read .phren-team.yaml if present
|
|
184
|
+
const bootstrap = readTeamBootstrap(storePath);
|
|
185
|
+
const finalName = bootstrap?.name || inferredName;
|
|
186
|
+
const finalRole = bootstrap?.default_role === "primary" ? "team" : (bootstrap?.default_role || "team");
|
|
187
|
+
const entry = {
|
|
188
|
+
id: generateStoreId(),
|
|
189
|
+
name: finalName,
|
|
190
|
+
path: storePath,
|
|
191
|
+
role: finalRole,
|
|
192
|
+
sync: finalRole === "readonly" ? "pull-only" : "managed-git",
|
|
193
|
+
remote,
|
|
194
|
+
};
|
|
195
|
+
addStoreToRegistry(phrenPath, entry);
|
|
196
|
+
console.log(`\nJoined team store: ${finalName}`);
|
|
197
|
+
console.log(` Path: ${storePath}`);
|
|
198
|
+
console.log(` Role: ${finalRole}`);
|
|
199
|
+
console.log(` ID: ${entry.id}`);
|
|
200
|
+
if (bootstrap?.description) {
|
|
201
|
+
console.log(` Description: ${bootstrap.description}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ── phren team add-project <store> <project> ────────────────────────────────
|
|
205
|
+
async function handleTeamAddProject(args) {
|
|
206
|
+
const phrenPath = getPhrenPath();
|
|
207
|
+
const positional = getPositionalArgs(args, []);
|
|
208
|
+
const storeName = positional[0];
|
|
209
|
+
const projectName = positional[1];
|
|
210
|
+
if (!storeName || !projectName) {
|
|
211
|
+
console.error("Usage: phren team add-project <store-name> <project-name>");
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
if (!isValidProjectName(projectName)) {
|
|
215
|
+
console.error(`Invalid project name: "${projectName}"`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
const store = findStoreByName(phrenPath, storeName);
|
|
219
|
+
if (!store) {
|
|
220
|
+
console.error(`Store "${storeName}" not found. Run 'phren store list' to see available stores.`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
if (store.role === "readonly") {
|
|
224
|
+
console.error(`Store "${storeName}" is read-only. Cannot add projects.`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
// Create project directory in the store
|
|
228
|
+
const projectDir = path.join(store.path, projectName);
|
|
229
|
+
const journalDir = path.join(projectDir, "journal");
|
|
230
|
+
fs.mkdirSync(journalDir, { recursive: true });
|
|
231
|
+
// Scaffold project files
|
|
232
|
+
if (!fs.existsSync(path.join(projectDir, "FINDINGS.md"))) {
|
|
233
|
+
atomicWriteText(path.join(projectDir, "FINDINGS.md"), `# ${projectName} findings\n`);
|
|
234
|
+
}
|
|
235
|
+
if (!fs.existsSync(path.join(projectDir, "tasks.md"))) {
|
|
236
|
+
atomicWriteText(path.join(projectDir, "tasks.md"), `# ${projectName} tasks\n\n## Active\n\n## Queue\n\n## Done\n`);
|
|
237
|
+
}
|
|
238
|
+
if (!fs.existsSync(path.join(projectDir, "summary.md"))) {
|
|
239
|
+
atomicWriteText(path.join(projectDir, "summary.md"), `# ${projectName}\n**What:** \n`);
|
|
240
|
+
}
|
|
241
|
+
// Update store's project claims in registry
|
|
242
|
+
const currentProjects = store.projects || [];
|
|
243
|
+
if (!currentProjects.includes(projectName)) {
|
|
244
|
+
updateStoreProjects(phrenPath, storeName, [...currentProjects, projectName]);
|
|
245
|
+
}
|
|
246
|
+
console.log(`Added project "${projectName}" to team store "${storeName}"`);
|
|
247
|
+
console.log(` Path: ${projectDir}`);
|
|
248
|
+
console.log(` Journal: ${journalDir}`);
|
|
249
|
+
console.log(`\nWrites to "${projectName}" will now route to "${storeName}" automatically.`);
|
|
250
|
+
}
|
|
251
|
+
// ── phren team list ─────────────────────────────────────────────────────────
|
|
252
|
+
async function handleTeamList() {
|
|
253
|
+
const phrenPath = getPhrenPath();
|
|
254
|
+
const { resolveAllStores } = await import("../store-registry.js");
|
|
255
|
+
const stores = resolveAllStores(phrenPath);
|
|
256
|
+
const teamStores = stores.filter((s) => s.role === "team");
|
|
257
|
+
if (teamStores.length === 0) {
|
|
258
|
+
console.log("No team stores registered.");
|
|
259
|
+
console.log("\nCreate one: phren team init <name> [--remote <url>]");
|
|
260
|
+
console.log("Join one: phren team join <git-url>");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
console.log(`${teamStores.length} team store(s):\n`);
|
|
264
|
+
for (const store of teamStores) {
|
|
265
|
+
const exists = fs.existsSync(store.path);
|
|
266
|
+
console.log(` ${store.name} (${store.id}) ${exists ? "" : "[missing]"}`);
|
|
267
|
+
console.log(` path: ${store.path}`);
|
|
268
|
+
if (store.remote)
|
|
269
|
+
console.log(` remote: ${store.remote}`);
|
|
270
|
+
if (store.projects?.length)
|
|
271
|
+
console.log(` projects: ${store.projects.join(", ")}`);
|
|
272
|
+
console.log();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// ── Entry point ─────────────────────────────────────────────────────────────
|
|
276
|
+
export async function handleTeamNamespace(args) {
|
|
277
|
+
const subcommand = args[0];
|
|
278
|
+
const subArgs = args.slice(1);
|
|
279
|
+
switch (subcommand) {
|
|
280
|
+
case "init":
|
|
281
|
+
return handleTeamInit(subArgs);
|
|
282
|
+
case "join":
|
|
283
|
+
return handleTeamJoin(subArgs);
|
|
284
|
+
case "add-project":
|
|
285
|
+
return handleTeamAddProject(subArgs);
|
|
286
|
+
case "list":
|
|
287
|
+
return handleTeamList();
|
|
288
|
+
default:
|
|
289
|
+
console.log(`phren team — manage shared team stores
|
|
290
|
+
|
|
291
|
+
phren team init <name> [--remote <url>] Create a new team store
|
|
292
|
+
phren team join <git-url> [--name <name>] Join an existing team store
|
|
293
|
+
phren team add-project <store> <project> Add a project to a team store
|
|
294
|
+
phren team list List team stores
|
|
295
|
+
`);
|
|
296
|
+
if (subcommand) {
|
|
297
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -273,6 +273,24 @@ export async function handleHookSessionStart() {
|
|
|
273
273
|
catch (err) {
|
|
274
274
|
logger.debug("hookSessionStart trackSession", errorMessage(err));
|
|
275
275
|
}
|
|
276
|
+
// Pull non-primary stores (team + readonly) so session starts with fresh data
|
|
277
|
+
try {
|
|
278
|
+
const { getNonPrimaryStores } = await import("./store-registry.js");
|
|
279
|
+
const otherStores = getNonPrimaryStores(phrenPath);
|
|
280
|
+
for (const store of otherStores) {
|
|
281
|
+
if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git")))
|
|
282
|
+
continue;
|
|
283
|
+
try {
|
|
284
|
+
await runBestEffortGit(["pull", "--rebase", "--quiet"], store.path);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
debugLog(`session-start store-pull ${store.name}: ${errorMessage(err)}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// store-registry not available — skip silently
|
|
293
|
+
}
|
|
276
294
|
updateRuntimeHealth(phrenPath, {
|
|
277
295
|
lastSessionStartAt: startedAt,
|
|
278
296
|
lastSync: {
|
|
@@ -485,18 +485,48 @@ 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
|
-
//
|
|
488
|
+
// Sync non-primary stores: commit+push team stores, pull-only readonly stores
|
|
489
489
|
try {
|
|
490
490
|
const { getNonPrimaryStores } = await import("./store-registry.js");
|
|
491
491
|
const otherStores = getNonPrimaryStores(phrenPath);
|
|
492
492
|
for (const store of otherStores) {
|
|
493
493
|
if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git")))
|
|
494
494
|
continue;
|
|
495
|
-
|
|
496
|
-
|
|
495
|
+
if (store.role === "team" && store.sync !== "pull-only") {
|
|
496
|
+
// Team stores with managed-git sync: stage team-safe files, commit, and push
|
|
497
|
+
try {
|
|
498
|
+
const storeStatus = await runBestEffortGit(["status", "--porcelain"], store.path);
|
|
499
|
+
if (storeStatus.ok && storeStatus.output) {
|
|
500
|
+
// Only stage journal/, tasks.md, truths.md, FINDINGS.md, summary.md — NOT .runtime/
|
|
501
|
+
await runBestEffortGit(["add", "--", "*/journal/*", "*/tasks.md", "*/truths.md", "*/FINDINGS.md", "*/summary.md", ".phren-team.yaml"], store.path);
|
|
502
|
+
const actor = process.env.PHREN_ACTOR || process.env.USER || "unknown";
|
|
503
|
+
const teamCommit = await runBestEffortGit(["commit", "-m", `phren: ${actor} team sync`], store.path);
|
|
504
|
+
if (teamCommit.ok) {
|
|
505
|
+
// Check for remote before pushing
|
|
506
|
+
const storeRemotes = await runBestEffortGit(["remote"], store.path);
|
|
507
|
+
if (storeRemotes.ok && storeRemotes.output?.trim()) {
|
|
508
|
+
const teamPush = await runBestEffortGit(["push"], store.path);
|
|
509
|
+
if (!teamPush.ok) {
|
|
510
|
+
// Try pull-rebase then push
|
|
511
|
+
await runBestEffortGit(["pull", "--rebase", "--quiet"], store.path);
|
|
512
|
+
await runBestEffortGit(["push"], store.path);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
debugLog(`hook-stop team-store-sync ${store.name}: ${errorMessage(err)}`);
|
|
520
|
+
}
|
|
497
521
|
}
|
|
498
|
-
|
|
499
|
-
|
|
522
|
+
else {
|
|
523
|
+
// Readonly stores: pull only
|
|
524
|
+
try {
|
|
525
|
+
await runBestEffortGit(["pull", "--rebase", "--quiet"], store.path);
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
debugLog(`hook-stop store-pull ${store.name}: ${errorMessage(err)}`);
|
|
529
|
+
}
|
|
500
530
|
}
|
|
501
531
|
}
|
|
502
532
|
}
|
package/mcp/dist/entrypoint.js
CHANGED
|
@@ -87,6 +87,12 @@ const HELP_TOPICS = {
|
|
|
87
87
|
phren store add <name> --remote <url> Add a team store
|
|
88
88
|
phren store remove <name> Remove a store (local only)
|
|
89
89
|
phren store sync Pull all stores
|
|
90
|
+
`,
|
|
91
|
+
team: `Team:
|
|
92
|
+
phren team init <name> [--remote <url>] Create a new team store
|
|
93
|
+
phren team join <git-url> [--name <name>] Join an existing team store
|
|
94
|
+
phren team add-project <store> <project> Add a project to a team store
|
|
95
|
+
phren team list List team stores
|
|
90
96
|
`,
|
|
91
97
|
env: `Environment variables:
|
|
92
98
|
PHREN_PATH Override phren directory (default: ~/.phren)
|
|
@@ -174,7 +180,9 @@ const CLI_COMMANDS = [
|
|
|
174
180
|
"review",
|
|
175
181
|
"consolidation-status",
|
|
176
182
|
"session-context",
|
|
183
|
+
"truths",
|
|
177
184
|
"store",
|
|
185
|
+
"team",
|
|
178
186
|
"promote",
|
|
179
187
|
];
|
|
180
188
|
async function flushTopLevelOutput() {
|
package/mcp/dist/init/setup.js
CHANGED
|
@@ -278,6 +278,7 @@ export function repairPreexistingInstall(phrenPath) {
|
|
|
278
278
|
const profileRepair = pruneLegacySampleProjectsFromProfiles(phrenPath);
|
|
279
279
|
const preferredHome = resolvePreferredHomeDir(phrenPath);
|
|
280
280
|
const createdSkillArtifacts = ensureGeneratedSkillArtifacts(phrenPath, preferredHome);
|
|
281
|
+
const repairedGlobalSymlink = repairGlobalClaudeSymlink(phrenPath);
|
|
281
282
|
return {
|
|
282
283
|
profileFilesUpdated: profileRepair.filesUpdated,
|
|
283
284
|
removedLegacyProjects: profileRepair.removed,
|
|
@@ -287,8 +288,46 @@ export function repairPreexistingInstall(phrenPath) {
|
|
|
287
288
|
createdRuntimeAssets,
|
|
288
289
|
createdFeatureDefaults,
|
|
289
290
|
createdSkillArtifacts,
|
|
291
|
+
repairedGlobalSymlink,
|
|
290
292
|
};
|
|
291
293
|
}
|
|
294
|
+
/** Re-create ~/.claude/CLAUDE.md symlink if the source exists but the link is missing/broken. */
|
|
295
|
+
function repairGlobalClaudeSymlink(phrenPath) {
|
|
296
|
+
const src = path.join(phrenPath, "global", "CLAUDE.md");
|
|
297
|
+
if (!fs.existsSync(src))
|
|
298
|
+
return false;
|
|
299
|
+
const dest = homePath(".claude", "CLAUDE.md");
|
|
300
|
+
try {
|
|
301
|
+
const stat = fs.lstatSync(dest);
|
|
302
|
+
if (stat.isSymbolicLink()) {
|
|
303
|
+
const target = path.resolve(path.dirname(dest), fs.readlinkSync(dest));
|
|
304
|
+
if (target === path.resolve(src))
|
|
305
|
+
return false; // already correct
|
|
306
|
+
// Stale symlink pointing elsewhere — managed by phren, safe to replace
|
|
307
|
+
if (target.includes(".phren"))
|
|
308
|
+
fs.unlinkSync(dest);
|
|
309
|
+
else
|
|
310
|
+
return false; // not ours, don't touch
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
return false; // regular file exists, don't clobber
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
if (err.code !== "ENOENT")
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
322
|
+
fs.symlinkSync(src, dest);
|
|
323
|
+
debugLog(`repaired global CLAUDE.md symlink: ${dest} -> ${src}`);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
debugLog(`failed to repair global CLAUDE.md symlink: ${errorMessage(err)}`);
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
292
331
|
function isExpectedVerifyFailure(phrenPath, check) {
|
|
293
332
|
if (check.ok)
|
|
294
333
|
return false;
|
|
@@ -1312,7 +1351,7 @@ export function runPostInitVerify(phrenPath) {
|
|
|
1312
1351
|
ok: true, // always pass — wrapper is optional (global install or npx work too)
|
|
1313
1352
|
detail: cliWrapperOk
|
|
1314
1353
|
? `CLI wrapper exists: ${cliWrapperPath}`
|
|
1315
|
-
: `CLI wrapper not found (optional — use '
|
|
1354
|
+
: `CLI wrapper not found (optional — use 'npx @phren/cli' instead)`,
|
|
1316
1355
|
});
|
|
1317
1356
|
const ok = checks.every((c) => c.ok);
|
|
1318
1357
|
return { ok, checks };
|
package/mcp/dist/link/doctor.js
CHANGED
|
@@ -409,7 +409,7 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
|
|
|
409
409
|
ok: phrenCliActive,
|
|
410
410
|
detail: phrenCliActive
|
|
411
411
|
? "phren CLI wrapper active via ~/.local/bin/phren"
|
|
412
|
-
: "phren CLI wrapper missing — run init to install
|
|
412
|
+
: "phren CLI wrapper missing — run 'npx @phren/cli init' to install",
|
|
413
413
|
});
|
|
414
414
|
if (fix) {
|
|
415
415
|
const repaired = repairPreexistingInstall(phrenPath);
|
package/mcp/dist/shared/index.js
CHANGED
|
@@ -4,6 +4,46 @@ import * as os from "os";
|
|
|
4
4
|
import * as crypto from "crypto";
|
|
5
5
|
import { globSync } from "glob";
|
|
6
6
|
import { debugLog, appendIndexEvent, getProjectDirs, collectNativeMemoryFiles, runtimeFile, homeDir, readRootManifest, } from "../shared.js";
|
|
7
|
+
/**
|
|
8
|
+
* Cached store project dirs to avoid repeated dynamic imports in sync code paths.
|
|
9
|
+
* Populated by `refreshStoreProjectDirs()`, consumed by `getAllStoreProjectDirs()`.
|
|
10
|
+
*/
|
|
11
|
+
let _cachedStoreProjectDirs = null;
|
|
12
|
+
let _cachedStorePhrenPath = null;
|
|
13
|
+
/**
|
|
14
|
+
* Gather project directories from the primary store AND all non-primary stores.
|
|
15
|
+
* This enables the FTS5 index to include team store projects alongside personal ones.
|
|
16
|
+
* Uses a sync cache populated by the async buildIndex path.
|
|
17
|
+
*/
|
|
18
|
+
function getAllStoreProjectDirs(phrenPath, profile) {
|
|
19
|
+
const dirs = [...getProjectDirs(phrenPath, profile)];
|
|
20
|
+
if (_cachedStoreProjectDirs && _cachedStorePhrenPath === phrenPath) {
|
|
21
|
+
dirs.push(..._cachedStoreProjectDirs);
|
|
22
|
+
}
|
|
23
|
+
return dirs;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Refresh the store project dirs cache. Called from async contexts (buildIndex, etc.)
|
|
27
|
+
* before sync code paths that need getAllStoreProjectDirs.
|
|
28
|
+
*/
|
|
29
|
+
async function refreshStoreProjectDirs(phrenPath, profile) {
|
|
30
|
+
try {
|
|
31
|
+
const { getNonPrimaryStores } = await import("../store-registry.js");
|
|
32
|
+
const otherStores = getNonPrimaryStores(phrenPath);
|
|
33
|
+
const dirs = [];
|
|
34
|
+
for (const store of otherStores) {
|
|
35
|
+
if (!fs.existsSync(store.path))
|
|
36
|
+
continue;
|
|
37
|
+
dirs.push(...getProjectDirs(store.path, profile));
|
|
38
|
+
}
|
|
39
|
+
_cachedStoreProjectDirs = dirs;
|
|
40
|
+
_cachedStorePhrenPath = phrenPath;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
_cachedStoreProjectDirs = [];
|
|
44
|
+
_cachedStorePhrenPath = phrenPath;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
7
47
|
import { getIndexPolicy, withFileLock } from "./governance.js";
|
|
8
48
|
import { stripTaskDoneSection } from "./content.js";
|
|
9
49
|
import { isInactiveFindingLine } from "../finding/lifecycle.js";
|
|
@@ -191,7 +231,7 @@ function touchSentinel(phrenPath) {
|
|
|
191
231
|
function computePhrenHash(phrenPath, profile, preGlobbed) {
|
|
192
232
|
const policy = getIndexPolicy(phrenPath);
|
|
193
233
|
const hash = crypto.createHash("sha1");
|
|
194
|
-
const topicConfigEntries =
|
|
234
|
+
const topicConfigEntries = getAllStoreProjectDirs(phrenPath, profile)
|
|
195
235
|
.map((dir) => path.join(dir, "topic-config.json"))
|
|
196
236
|
.filter((configPath) => fs.existsSync(configPath));
|
|
197
237
|
if (preGlobbed) {
|
|
@@ -215,9 +255,9 @@ function computePhrenHash(phrenPath, profile, preGlobbed) {
|
|
|
215
255
|
}
|
|
216
256
|
}
|
|
217
257
|
else {
|
|
218
|
-
const
|
|
258
|
+
const allProjectDirs = getAllStoreProjectDirs(phrenPath, profile);
|
|
219
259
|
const files = [];
|
|
220
|
-
for (const dir of
|
|
260
|
+
for (const dir of allProjectDirs) {
|
|
221
261
|
const projectName = path.basename(dir);
|
|
222
262
|
const config = readProjectConfig(phrenPath, projectName);
|
|
223
263
|
const ownership = getProjectOwnershipMode(phrenPath, projectName, config);
|
|
@@ -399,7 +439,7 @@ function getRepoManagedInstructionEntries(phrenPath, project) {
|
|
|
399
439
|
return entries;
|
|
400
440
|
}
|
|
401
441
|
function globAllFiles(phrenPath, profile) {
|
|
402
|
-
const projectDirs =
|
|
442
|
+
const projectDirs = getAllStoreProjectDirs(phrenPath, profile);
|
|
403
443
|
const indexPolicy = getIndexPolicy(phrenPath);
|
|
404
444
|
const entries = [];
|
|
405
445
|
const allAbsolutePaths = [];
|
|
@@ -826,7 +866,8 @@ function mergeManualLinks(db, phrenPath) {
|
|
|
826
866
|
}
|
|
827
867
|
async function buildIndexImpl(phrenPath, profile) {
|
|
828
868
|
const t0 = Date.now();
|
|
829
|
-
|
|
869
|
+
await refreshStoreProjectDirs(phrenPath, profile);
|
|
870
|
+
const projectDirs = getAllStoreProjectDirs(phrenPath, profile);
|
|
830
871
|
beginUserFragmentBuildCache(phrenPath, projectDirs.map(dir => path.basename(dir)));
|
|
831
872
|
try {
|
|
832
873
|
// ── Cache dir + hash sentinel ─────────────────────────────────────────────
|
|
@@ -1348,12 +1389,16 @@ export function detectProject(phrenPath, cwd, profile) {
|
|
|
1348
1389
|
if (manifest?.installMode === "project-local") {
|
|
1349
1390
|
return manifest.primaryProject || null;
|
|
1350
1391
|
}
|
|
1351
|
-
const projectDirs =
|
|
1392
|
+
const projectDirs = getAllStoreProjectDirs(phrenPath, profile);
|
|
1352
1393
|
const resolvedCwd = path.resolve(cwd);
|
|
1353
1394
|
let bestMatch = null;
|
|
1354
1395
|
for (const dir of projectDirs) {
|
|
1355
1396
|
const projectName = path.basename(dir);
|
|
1356
|
-
|
|
1397
|
+
// Try the project's own store path first (handles team store projects),
|
|
1398
|
+
// then fall back to primary phrenPath
|
|
1399
|
+
const storePhrenPath = path.dirname(dir);
|
|
1400
|
+
const sourcePath = getProjectSourcePath(storePhrenPath, projectName)
|
|
1401
|
+
|| getProjectSourcePath(phrenPath, projectName);
|
|
1357
1402
|
if (!sourcePath)
|
|
1358
1403
|
continue;
|
|
1359
1404
|
const matches = resolvedCwd === sourcePath || resolvedCwd.startsWith(sourcePath + path.sep);
|
|
@@ -627,9 +627,15 @@ export function rankResults(rows, intent, gitCtx, detectedProject, phrenPathLoca
|
|
|
627
627
|
}
|
|
628
628
|
return false;
|
|
629
629
|
});
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
630
|
+
// Always-inject: truths for detected project are prepended regardless of search
|
|
631
|
+
// relevance. Dedup against rows already in the result set.
|
|
632
|
+
const canonicalRows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ? AND type = 'canonical'", [detectedProject]);
|
|
633
|
+
if (canonicalRows) {
|
|
634
|
+
const existingPaths = new Set(ranked.map((r) => r.path));
|
|
635
|
+
const newCanonicals = canonicalRows.filter((r) => !existingPaths.has(r.path));
|
|
636
|
+
if (newCanonicals.length > 0)
|
|
637
|
+
ranked = [...newCanonicals, ...ranked];
|
|
638
|
+
}
|
|
633
639
|
}
|
|
634
640
|
const entityBoost = query ? getFragmentBoostDocs(db, query) : new Set();
|
|
635
641
|
const entityBoostPaths = new Set();
|
|
@@ -165,6 +165,19 @@ export function removeStoreFromRegistry(phrenPath, name) {
|
|
|
165
165
|
return entry;
|
|
166
166
|
});
|
|
167
167
|
}
|
|
168
|
+
/** Update the projects[] claim list for a store. Uses file locking. */
|
|
169
|
+
export function updateStoreProjects(phrenPath, storeName, projects) {
|
|
170
|
+
withFileLock(storesFilePath(phrenPath), () => {
|
|
171
|
+
const registry = readStoreRegistry(phrenPath);
|
|
172
|
+
if (!registry)
|
|
173
|
+
throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`);
|
|
174
|
+
const store = registry.stores.find((s) => s.name === storeName);
|
|
175
|
+
if (!store)
|
|
176
|
+
throw new Error(`${PhrenError.NOT_FOUND}: Store "${storeName}" not found`);
|
|
177
|
+
store.projects = projects.length > 0 ? projects : undefined;
|
|
178
|
+
writeStoreRegistry(phrenPath, registry);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
168
181
|
// ── Validation ───────────────────────────────────────────────────────────────
|
|
169
182
|
function validateRegistry(registry) {
|
|
170
183
|
if (registry.version !== 1)
|
|
@@ -247,8 +260,9 @@ function parseFederationPathsEnv(localPhrenPath) {
|
|
|
247
260
|
const raw = process.env.PHREN_FEDERATION_PATHS ?? "";
|
|
248
261
|
if (!raw.trim())
|
|
249
262
|
return [];
|
|
263
|
+
// Use path.delimiter (';' on Windows, ':' on Unix) so Windows drive letters aren't split
|
|
250
264
|
return raw
|
|
251
|
-
.split(
|
|
265
|
+
.split(path.delimiter)
|
|
252
266
|
.map((p) => p.trim())
|
|
253
267
|
.filter((p) => p.length > 0)
|
|
254
268
|
.map((p) => path.resolve(expandHomePath(p)))
|
|
@@ -263,22 +263,52 @@ async function handleAddFinding(ctx, params) {
|
|
|
263
263
|
}
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
|
-
async function handleSupersedeFinding(ctx, { project, finding_text, superseded_by }) {
|
|
267
|
-
|
|
266
|
+
async function handleSupersedeFinding(ctx, { project: projectInput, finding_text, superseded_by }) {
|
|
267
|
+
let phrenPath;
|
|
268
|
+
let project;
|
|
269
|
+
try {
|
|
270
|
+
const resolved = resolveStoreForProject(ctx, projectInput);
|
|
271
|
+
phrenPath = resolved.phrenPath;
|
|
272
|
+
project = resolved.project;
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
276
|
+
}
|
|
277
|
+
const { withWriteQueue, updateFileInIndex } = ctx;
|
|
268
278
|
return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => supersedeFinding(phrenPath, project, finding_text, superseded_by), (data) => ({
|
|
269
279
|
message: `Marked finding as superseded in ${project}.`,
|
|
270
280
|
data: { project, finding: data.finding, status: data.status, superseded_by: data.superseded_by },
|
|
271
281
|
}));
|
|
272
282
|
}
|
|
273
|
-
async function handleRetractFinding(ctx, { project, finding_text, reason }) {
|
|
274
|
-
|
|
283
|
+
async function handleRetractFinding(ctx, { project: projectInput, finding_text, reason }) {
|
|
284
|
+
let phrenPath;
|
|
285
|
+
let project;
|
|
286
|
+
try {
|
|
287
|
+
const resolved = resolveStoreForProject(ctx, projectInput);
|
|
288
|
+
phrenPath = resolved.phrenPath;
|
|
289
|
+
project = resolved.project;
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
293
|
+
}
|
|
294
|
+
const { withWriteQueue, updateFileInIndex } = ctx;
|
|
275
295
|
return withLifecycleMutation(phrenPath, project, withWriteQueue, updateFileInIndex, () => retractFindingLifecycle(phrenPath, project, finding_text, reason), (data) => ({
|
|
276
296
|
message: `Retracted finding in ${project}.`,
|
|
277
297
|
data: { project, finding: data.finding, status: data.status, reason: data.reason },
|
|
278
298
|
}));
|
|
279
299
|
}
|
|
280
|
-
async function handleResolveContradiction(ctx, { project, finding_text, finding_text_other, finding_a, finding_b, resolution }) {
|
|
281
|
-
|
|
300
|
+
async function handleResolveContradiction(ctx, { project: projectInput, finding_text, finding_text_other, finding_a, finding_b, resolution }) {
|
|
301
|
+
let phrenPath;
|
|
302
|
+
let project;
|
|
303
|
+
try {
|
|
304
|
+
const resolved = resolveStoreForProject(ctx, projectInput);
|
|
305
|
+
phrenPath = resolved.phrenPath;
|
|
306
|
+
project = resolved.project;
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
310
|
+
}
|
|
311
|
+
const { withWriteQueue, updateFileInIndex } = ctx;
|
|
282
312
|
const findingText = (finding_text ?? finding_a)?.trim();
|
|
283
313
|
const findingTextOther = (finding_text_other ?? finding_b)?.trim();
|
|
284
314
|
if (!findingText || !findingTextOther) {
|
|
@@ -342,8 +372,18 @@ async function handleGetContradictions(ctx, { project, finding_text }) {
|
|
|
342
372
|
},
|
|
343
373
|
});
|
|
344
374
|
}
|
|
345
|
-
async function handleEditFinding(ctx, { project, old_text, new_text }) {
|
|
346
|
-
|
|
375
|
+
async function handleEditFinding(ctx, { project: projectInput, old_text, new_text }) {
|
|
376
|
+
let phrenPath;
|
|
377
|
+
let project;
|
|
378
|
+
try {
|
|
379
|
+
const resolved = resolveStoreForProject(ctx, projectInput);
|
|
380
|
+
phrenPath = resolved.phrenPath;
|
|
381
|
+
project = resolved.project;
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
385
|
+
}
|
|
386
|
+
const { withWriteQueue, updateFileInIndex } = ctx;
|
|
347
387
|
if (!isValidProjectName(project))
|
|
348
388
|
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
349
389
|
const editDenied = permissionDeniedError(phrenPath, "edit_finding", project);
|
|
@@ -363,8 +403,18 @@ async function handleEditFinding(ctx, { project, old_text, new_text }) {
|
|
|
363
403
|
});
|
|
364
404
|
});
|
|
365
405
|
}
|
|
366
|
-
async function handleRemoveFinding(ctx, { project, finding }) {
|
|
367
|
-
|
|
406
|
+
async function handleRemoveFinding(ctx, { project: projectInput, finding }) {
|
|
407
|
+
let phrenPath;
|
|
408
|
+
let project;
|
|
409
|
+
try {
|
|
410
|
+
const resolved = resolveStoreForProject(ctx, projectInput);
|
|
411
|
+
phrenPath = resolved.phrenPath;
|
|
412
|
+
project = resolved.project;
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
416
|
+
}
|
|
417
|
+
const { withWriteQueue, updateFileInIndex } = ctx;
|
|
368
418
|
if (!isValidProjectName(project))
|
|
369
419
|
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
370
420
|
const removeDenied = permissionDeniedError(phrenPath, "remove_finding", project);
|
|
@@ -494,6 +544,58 @@ async function handlePushChanges(ctx, { message }) {
|
|
|
494
544
|
catch (err) {
|
|
495
545
|
return mcpResponse({ ok: false, error: `Save failed: ${errorMessage(err)}`, errorCode: "INTERNAL_ERROR" });
|
|
496
546
|
}
|
|
547
|
+
// Sync team stores: commit and push journal/tasks/truths changes
|
|
548
|
+
try {
|
|
549
|
+
const { getNonPrimaryStores } = await import("../store-registry.js");
|
|
550
|
+
const teamStores = getNonPrimaryStores(phrenPath).filter((s) => s.role === "team");
|
|
551
|
+
const teamResults = [];
|
|
552
|
+
for (const store of teamStores) {
|
|
553
|
+
if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git")))
|
|
554
|
+
continue;
|
|
555
|
+
const runStoreGit = (args, opts = {}) => execFileSync("git", args, {
|
|
556
|
+
cwd: store.path,
|
|
557
|
+
encoding: "utf8",
|
|
558
|
+
timeout: opts.timeout ?? EXEC_TIMEOUT_MS,
|
|
559
|
+
env: opts.env,
|
|
560
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
561
|
+
}).trim();
|
|
562
|
+
try {
|
|
563
|
+
const storeStatus = runStoreGit(["status", "--porcelain"]);
|
|
564
|
+
if (!storeStatus) {
|
|
565
|
+
teamResults.push({ store: store.name, pushed: false });
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
// Only stage team-safe files: journal/, tasks.md, truths.md, FINDINGS.md, summary.md
|
|
569
|
+
runStoreGit(["add", "--", "*/journal/*", "*/tasks.md", "*/truths.md", "*/FINDINGS.md", "*/summary.md"]);
|
|
570
|
+
const actor = process.env.PHREN_ACTOR || process.env.USER || "unknown";
|
|
571
|
+
runStoreGit(["commit", "-m", `phren: ${actor} team sync`]);
|
|
572
|
+
try {
|
|
573
|
+
runStoreGit(["push"], { timeout: 15000 });
|
|
574
|
+
teamResults.push({ store: store.name, pushed: true });
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
try {
|
|
578
|
+
runStoreGit(["pull", "--rebase", "--quiet"], { timeout: 15000 });
|
|
579
|
+
runStoreGit(["push"], { timeout: 15000 });
|
|
580
|
+
teamResults.push({ store: store.name, pushed: true });
|
|
581
|
+
}
|
|
582
|
+
catch (retryErr) {
|
|
583
|
+
teamResults.push({ store: store.name, pushed: false, error: errorMessage(retryErr) });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
catch (storeErr) {
|
|
588
|
+
teamResults.push({ store: store.name, pushed: false, error: errorMessage(storeErr) });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Team store results are best-effort — don't fail the primary push for them
|
|
592
|
+
if (teamResults.length > 0) {
|
|
593
|
+
debugLog(`push_changes team stores: ${JSON.stringify(teamResults)}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// store-registry not available — skip silently
|
|
598
|
+
}
|
|
497
599
|
});
|
|
498
600
|
}
|
|
499
601
|
// ── Registration ─────────────────────────────────────────────────────────────
|
package/mcp/dist/tools/memory.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";
|
|
@@ -7,7 +7,7 @@ import { recordFeedback, flushEntryScores, } from "../shared/governance.js";
|
|
|
7
7
|
import { upsertCanonical } from "../shared/content.js";
|
|
8
8
|
import { isValidProjectName } from "../utils.js";
|
|
9
9
|
export function register(server, ctx) {
|
|
10
|
-
const {
|
|
10
|
+
const { withWriteQueue, updateFileInIndex } = ctx;
|
|
11
11
|
server.registerTool("pin_memory", {
|
|
12
12
|
title: "◆ phren · pin memory",
|
|
13
13
|
description: "Write a truth — a high-confidence, always-inject entry in truths.md that never decays.",
|
|
@@ -15,19 +15,63 @@ export function register(server, ctx) {
|
|
|
15
15
|
project: z.string().describe("Project name."),
|
|
16
16
|
memory: z.string().describe("Truth text."),
|
|
17
17
|
}),
|
|
18
|
-
}, async ({ project, memory }) => {
|
|
18
|
+
}, async ({ project: projectInput, memory }) => {
|
|
19
|
+
let phrenPath;
|
|
20
|
+
let project;
|
|
21
|
+
try {
|
|
22
|
+
const resolved = resolveStoreForProject(ctx, projectInput);
|
|
23
|
+
phrenPath = resolved.phrenPath;
|
|
24
|
+
project = resolved.project;
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
28
|
+
}
|
|
19
29
|
if (!isValidProjectName(project))
|
|
20
30
|
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
21
31
|
return withWriteQueue(async () => {
|
|
22
32
|
const result = upsertCanonical(phrenPath, project, memory);
|
|
23
33
|
if (!result.ok)
|
|
24
34
|
return mcpResponse({ ok: false, error: result.error });
|
|
25
|
-
// Update FTS index so newly added truth is immediately searchable
|
|
26
35
|
const canonicalPath = path.join(phrenPath, project, "truths.md");
|
|
27
36
|
updateFileInIndex(canonicalPath);
|
|
28
37
|
return mcpResponse({ ok: true, message: result.data, data: { project, memory } });
|
|
29
38
|
});
|
|
30
39
|
});
|
|
40
|
+
server.registerTool("get_truths", {
|
|
41
|
+
title: "◆ phren · truths",
|
|
42
|
+
description: "Read all pinned truths for a project. Truths are high-confidence entries in truths.md that never decay.",
|
|
43
|
+
inputSchema: z.object({
|
|
44
|
+
project: z.string().describe("Project name."),
|
|
45
|
+
}),
|
|
46
|
+
}, async ({ project: projectInput }) => {
|
|
47
|
+
let phrenPath;
|
|
48
|
+
let project;
|
|
49
|
+
try {
|
|
50
|
+
const resolved = resolveStoreForProject(ctx, projectInput);
|
|
51
|
+
phrenPath = resolved.phrenPath;
|
|
52
|
+
project = resolved.project;
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
56
|
+
}
|
|
57
|
+
if (!isValidProjectName(project))
|
|
58
|
+
return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
|
|
59
|
+
const truthsPath = path.join(phrenPath, project, "truths.md");
|
|
60
|
+
if (!fs.existsSync(truthsPath)) {
|
|
61
|
+
return mcpResponse({ ok: true, message: `No truths pinned for "${project}" yet.`, data: { project, truths: [], count: 0 } });
|
|
62
|
+
}
|
|
63
|
+
const content = fs.readFileSync(truthsPath, "utf8");
|
|
64
|
+
const truths = content.split("\n")
|
|
65
|
+
.filter((line) => line.startsWith("- "))
|
|
66
|
+
.map((line) => line.slice(2).trim());
|
|
67
|
+
return mcpResponse({
|
|
68
|
+
ok: true,
|
|
69
|
+
message: truths.length > 0
|
|
70
|
+
? `${truths.length} truth(s) pinned for "${project}".`
|
|
71
|
+
: `No truths pinned for "${project}" yet.`,
|
|
72
|
+
data: { project, truths, count: truths.length },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
31
75
|
server.registerTool("memory_feedback", {
|
|
32
76
|
title: "◆ phren · feedback",
|
|
33
77
|
description: "Record feedback on whether an injected memory was helpful or noisy/regressive.",
|
|
@@ -36,6 +80,7 @@ export function register(server, ctx) {
|
|
|
36
80
|
feedback: z.enum(["helpful", "reprompt", "regression"]).describe("Feedback type."),
|
|
37
81
|
}),
|
|
38
82
|
}, async ({ key, feedback }) => {
|
|
83
|
+
const phrenPath = ctx.phrenPath;
|
|
39
84
|
return withWriteQueue(async () => {
|
|
40
85
|
recordFeedback(phrenPath, key, feedback);
|
|
41
86
|
flushEntryScores(phrenPath);
|
package/mcp/dist/tools/search.js
CHANGED
|
@@ -470,7 +470,7 @@ async function handleGetProjectSummary(ctx, { name }) {
|
|
|
470
470
|
if (store && fs.existsSync(path.join(store.path, lookupName))) {
|
|
471
471
|
const projDir = path.join(store.path, lookupName);
|
|
472
472
|
const fsDocs = [];
|
|
473
|
-
for (const [file, type] of [["summary.md", "summary"], ["CLAUDE.md", "claude"], ["FINDINGS.md", "findings"], ["tasks.md", "task"]]) {
|
|
473
|
+
for (const [file, type] of [["summary.md", "summary"], ["CLAUDE.md", "claude"], ["FINDINGS.md", "findings"], ["tasks.md", "task"], ["truths.md", "canonical"]]) {
|
|
474
474
|
const filePath = path.join(projDir, file);
|
|
475
475
|
if (fs.existsSync(filePath)) {
|
|
476
476
|
fsDocs.push({ filename: file, type, content: fs.readFileSync(filePath, "utf8").slice(0, 8000), path: filePath });
|
|
@@ -487,6 +487,7 @@ async function handleGetProjectSummary(ctx, { name }) {
|
|
|
487
487
|
}
|
|
488
488
|
const summaryDoc = docs.find(doc => doc.type === "summary");
|
|
489
489
|
const claudeDoc = docs.find(doc => doc.type === "claude");
|
|
490
|
+
const canonicalDoc = docs.find(doc => doc.type === "canonical");
|
|
490
491
|
const indexedFiles = docs.map(doc => ({ filename: doc.filename, type: doc.type, path: doc.path }));
|
|
491
492
|
const parts = [`# ${name}`];
|
|
492
493
|
if (summaryDoc) {
|
|
@@ -498,6 +499,13 @@ async function handleGetProjectSummary(ctx, { name }) {
|
|
|
498
499
|
if (claudeDoc) {
|
|
499
500
|
parts.push(`\n## CLAUDE.md path\n\`${claudeDoc.path}\``);
|
|
500
501
|
}
|
|
502
|
+
// Show truths if they exist
|
|
503
|
+
if (canonicalDoc) {
|
|
504
|
+
const truthLines = canonicalDoc.content.split("\n").filter((l) => l.startsWith("- "));
|
|
505
|
+
if (truthLines.length > 0) {
|
|
506
|
+
parts.push(`\n## Truths (${truthLines.length})\n${truthLines.join("\n")}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
501
509
|
const fileList = indexedFiles.map((f) => `- ${f.filename} (${f.type})`).join("\n");
|
|
502
510
|
parts.push(`\n## Indexed files\n${fileList}`);
|
|
503
511
|
return mcpResponse({
|
|
@@ -507,6 +515,7 @@ async function handleGetProjectSummary(ctx, { name }) {
|
|
|
507
515
|
name,
|
|
508
516
|
summary: summaryDoc?.content ?? null,
|
|
509
517
|
claudeMdPath: claudeDoc?.path ?? null,
|
|
518
|
+
truthsPath: canonicalDoc?.path ?? null,
|
|
510
519
|
files: indexedFiles,
|
|
511
520
|
},
|
|
512
521
|
});
|
package/mcp/dist/tools/types.js
CHANGED
|
@@ -8,6 +8,16 @@ import { resolveAllStores } from "../store-registry.js";
|
|
|
8
8
|
export function resolveStoreForProject(ctx, projectInput) {
|
|
9
9
|
const { storeName, projectName } = parseStoreQualified(projectInput);
|
|
10
10
|
if (!storeName) {
|
|
11
|
+
// Check if any non-readonly store claims this project via projects[] array.
|
|
12
|
+
// This enables automatic write routing: once a project is claimed by a team
|
|
13
|
+
// store (via `phren team add-project`), writes go there without needing the
|
|
14
|
+
// store-qualified prefix.
|
|
15
|
+
const stores = resolveAllStores(ctx.phrenPath);
|
|
16
|
+
for (const store of stores) {
|
|
17
|
+
if (store.role !== "readonly" && store.role !== "primary" && store.projects?.includes(projectName)) {
|
|
18
|
+
return { phrenPath: store.path, project: projectName, storeRole: store.role };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
11
21
|
return { phrenPath: ctx.phrenPath, project: projectName, storeRole: "primary" };
|
|
12
22
|
}
|
|
13
23
|
const stores = resolveAllStores(ctx.phrenPath);
|