@phren/cli 0.0.43 → 0.0.44

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.
@@ -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");
@@ -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
- // Pull non-primary stores (best-effort, non-blocking)
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
- try {
496
- await runBestEffortGit(["pull", "--rebase", "--quiet"], store.path);
495
+ if (store.role === "team") {
496
+ // Team stores: 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
- catch (err) {
499
- debugLog(`hook-stop store-pull ${store.name}: ${errorMessage(err)}`);
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
  }
@@ -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() {
@@ -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 'npm i -g @phren/cli' or 'npx @phren/cli' instead)`,
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 };
@@ -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, or npm i -g @phren/cli",
412
+ : "phren CLI wrapper missing — run 'npx @phren/cli init' to install",
413
413
  });
414
414
  if (fix) {
415
415
  const repaired = repairPreexistingInstall(phrenPath);
@@ -627,9 +627,15 @@ export function rankResults(rows, intent, gitCtx, detectedProject, phrenPathLoca
627
627
  }
628
628
  return false;
629
629
  });
630
- const canonicalRows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ? AND type = 'canonical' LIMIT 1", [detectedProject]);
631
- if (canonicalRows)
632
- ranked = [...canonicalRows, ...ranked];
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
- const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
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
- const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
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
- const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
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
- const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
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
- const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
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 ─────────────────────────────────────────────────────────────
@@ -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 { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
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);
@@ -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
  });
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/cli",
3
- "version": "0.0.43",
3
+ "version": "0.0.44",
4
4
  "description": "Knowledge layer for AI agents. Phren learns and recalls.",
5
5
  "type": "module",
6
6
  "bin": {