@phren/cli 0.0.50 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  * tools (React, Django, Unity, JUCE, Ansible, ...) are learned dynamically from
8
8
  * each project's FINDINGS.md via extractDynamicEntities().
9
9
  */
10
- export const UNIVERSAL_TECH_TERMS_RE = /\b(Python|Rust|Go|Java|TypeScript|JavaScript|Docker|Kubernetes|AWS|GCP|Azure|SQL|Git)\b/gi;
10
+ export const UNIVERSAL_TECH_TERMS_RE = /\b(Python|Rust|Go|Java|TypeScript|JavaScript|Docker|Kubernetes|AWS|GCP|Azure|SQL|Git|React|Vue|Angular|Svelte|Next\.?js|Nuxt|Vite|esbuild|Webpack|Rollup|Babel|ESLint|Biome|Prettier|Jest|Vitest|Playwright|Cypress|Node\.?js|Deno|Bun|Express|Fastify|Hono|Koa|NestJS|Prisma|Drizzle|Sequelize|TypeORM|Postgres|PostgreSQL|MySQL|SQLite|MongoDB|Redis|Elasticsearch|GraphQL|REST|gRPC|tRPC|Zod|Pydantic|FastAPI|Django|Flask|Rails|Spring|Laravel|Tailwind|Bootstrap|MUI|Material|Fluent|Chakra|Radix|shadcn|SharePoint|SPFx|Teams|OneDrive|Power\s*Apps|Deltek|VantagePoint|Hangfire|ASP\.?NET|MVC|Blazor|MAUI|Electron|VSCode|GitHub|GitLab|Bitbucket|Vercel|Netlify|Railway|Fly\.io|Cloudflare|Lambda|S3|EC2|RDS|DynamoDB|Stripe|Twilio|SendGrid|OpenAI|Anthropic|Claude|LLM|MCP|FTS5|SQLCipher|Sigma|ForceAtlas|Graphology|Playwright|Puppeteer|Selenium|Turbo|Turborepo|Lerna|nx|pnpm|yarn|npm|Bun)\b/gi;
11
11
  /**
12
12
  * Additional fragment patterns beyond CamelCase and acronyms.
13
13
  * Each pattern has a named group so callers can identify the fragment type.
@@ -8,6 +8,7 @@ import { errorMessage, isValidProjectName } from "./utils.js";
8
8
  import { TASK_FILE_ALIASES } from "./data/tasks.js";
9
9
  import { withSafeLock } from "./shared/data-utils.js";
10
10
  import { logger } from "./logger.js";
11
+ import { getNonPrimaryStores, getStoreProjectDirs } from "./store-registry.js";
11
12
  export function resolveActiveProfile(phrenPath, requestedProfile) {
12
13
  const manifest = readRootManifest(phrenPath);
13
14
  if (manifest?.installMode === "project-local") {
@@ -305,6 +306,24 @@ function buildProjectCard(dir) {
305
306
  export function listProjectCards(phrenPath, profile) {
306
307
  const dirs = getProjectDirs(phrenPath, profile).sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
307
308
  const cards = dirs.map(buildProjectCard);
309
+ const seen = new Set(dirs.map((d) => path.basename(d)));
310
+ // Include projects from team stores
311
+ try {
312
+ for (const store of getNonPrimaryStores(phrenPath)) {
313
+ if (!fs.existsSync(store.path))
314
+ continue;
315
+ for (const dir of getStoreProjectDirs(store)) {
316
+ const name = path.basename(dir);
317
+ if (seen.has(name) || name === "global")
318
+ continue;
319
+ seen.add(name);
320
+ cards.push(buildProjectCard(dir));
321
+ }
322
+ }
323
+ }
324
+ catch {
325
+ // store-registry not available or error loading, continue with primary only
326
+ }
308
327
  // Prepend global as a pinned entry so it's always accessible from the shell
309
328
  const globalDir = path.join(phrenPath, "global");
310
329
  if (fs.existsSync(globalDir)) {
@@ -130,6 +130,32 @@ export function isProjectHookEnabled(phrenPath, project, event, config) {
130
130
  return hooks.enabled;
131
131
  return true;
132
132
  }
133
+ /**
134
+ * Remove a per-project hook override, restoring inheritance from global config.
135
+ * Pass event to clear a specific event override; omit to clear the whole hooks block.
136
+ */
137
+ export function clearProjectHookOverride(phrenPath, project, event) {
138
+ const configPath = resolveProjectConfigPath(phrenPath, project);
139
+ if (!configPath)
140
+ throw new Error("Project config path escapes phren store");
141
+ return withFileLock(configPath, () => {
142
+ const current = readProjectConfig(phrenPath, project);
143
+ const existingHooks = normalizeHookConfig(current);
144
+ let nextHooks;
145
+ if (event && PROJECT_HOOK_EVENTS.includes(event)) {
146
+ // Delete just this event key
147
+ const { [event]: _removed, ...rest } = existingHooks;
148
+ nextHooks = rest;
149
+ }
150
+ else {
151
+ // Clear all overrides
152
+ nextHooks = {};
153
+ }
154
+ const next = { ...current, hooks: nextHooks };
155
+ writeProjectConfigFile(configPath, next);
156
+ return next;
157
+ });
158
+ }
133
159
  export function writeProjectHookConfig(phrenPath, project, patch) {
134
160
  // Move read+merge inside the lock so concurrent writers cannot clobber each other.
135
161
  const configPath = resolveProjectConfigPath(phrenPath, project);
@@ -28,13 +28,13 @@ function getAllStoreProjectDirs(phrenPath, profile) {
28
28
  */
29
29
  async function refreshStoreProjectDirs(phrenPath, profile) {
30
30
  try {
31
- const { getNonPrimaryStores } = await import("../store-registry.js");
31
+ const { getNonPrimaryStores, getStoreProjectDirs } = await import("../store-registry.js");
32
32
  const otherStores = getNonPrimaryStores(phrenPath);
33
33
  const dirs = [];
34
34
  for (const store of otherStores) {
35
35
  if (!fs.existsSync(store.path))
36
36
  continue;
37
- dirs.push(...getProjectDirs(store.path, profile));
37
+ dirs.push(...getStoreProjectDirs(store));
38
38
  }
39
39
  _cachedStoreProjectDirs = dirs;
40
40
  _cachedStorePhrenPath = phrenPath;
@@ -5,6 +5,7 @@
5
5
  import * as fs from "fs";
6
6
  import * as path from "path";
7
7
  import { canonicalTaskFilePath, listProjectCards, readTasks, readFindings, readReviewQueue, readRuntimeHealth, resolveTaskFilePath, } from "../data/access.js";
8
+ import { getNonPrimaryStores } from "../store-registry.js";
8
9
  import { style, badge, separator, stripAnsi, truncateLine, renderWidth, wrapSegments, lineViewport, shellHelpText, gradient, } from "./render.js";
9
10
  import { formatSelectableLine, viewportWithStatus, } from "./view-list.js";
10
11
  import { SUB_VIEWS, TAB_ICONS, } from "./types.js";
@@ -15,6 +16,19 @@ import { isProjectHookEnabled, readProjectConfig } from "../project-config.js";
15
16
  import { getScopedSkills } from "../skill/registry.js";
16
17
  import { errorMessage } from "../utils.js";
17
18
  import { logger } from "../logger.js";
19
+ /** Resolve which store (primary or team) contains a project */
20
+ function resolveProjectStorePath(phrenPath, project) {
21
+ if (fs.existsSync(path.join(phrenPath, project)))
22
+ return phrenPath;
23
+ try {
24
+ for (const store of getNonPrimaryStores(phrenPath)) {
25
+ if (fs.existsSync(path.join(store.path, project)))
26
+ return store.path;
27
+ }
28
+ }
29
+ catch { /* fall through */ }
30
+ return phrenPath;
31
+ }
18
32
  // ── Tab bar ────────────────────────────────────────────────────────────────
19
33
  function renderTabBar(state) {
20
34
  const cols = renderWidth();
@@ -108,9 +122,10 @@ function collectProjectDashboardEntries(ctx) {
108
122
  reviewCount: 0,
109
123
  };
110
124
  }
111
- const task = readTasks(ctx.phrenPath, card.name);
112
- const findings = readFindings(ctx.phrenPath, card.name);
113
- const review = readReviewQueue(ctx.phrenPath, card.name);
125
+ const storePath = resolveProjectStorePath(ctx.phrenPath, card.name);
126
+ const task = readTasks(storePath, card.name);
127
+ const findings = readFindings(storePath, card.name);
128
+ const review = readReviewQueue(storePath, card.name);
114
129
  return {
115
130
  ...card,
116
131
  activeCount: task.ok ? task.data.items.Active.length : 0,
@@ -262,16 +277,17 @@ function renderTaskView(ctx, cursor, height, subsectionsCache) {
262
277
  if (!project) {
263
278
  return { lines: [style.dim(" No project selected — navigate to Projects (← →) and press ↵")], subsectionsCache };
264
279
  }
265
- const result = readTasks(ctx.phrenPath, project);
280
+ const storePath = resolveProjectStorePath(ctx.phrenPath, project);
281
+ const result = readTasks(storePath, project);
266
282
  if (!result.ok)
267
283
  return { lines: [result.error], subsectionsCache };
268
284
  const parsed = result.data;
269
285
  const warnings = parsed.issues.length
270
286
  ? [` ${style.yellow("⚠")} ${style.yellow(parsed.issues.join("; "))}`, ""]
271
287
  : [];
272
- const taskFile = resolveTaskFilePath(ctx.phrenPath, project)
273
- ?? canonicalTaskFilePath(ctx.phrenPath, project)
274
- ?? path.join(ctx.phrenPath, project, "tasks.md");
288
+ const taskFile = resolveTaskFilePath(storePath, project)
289
+ ?? canonicalTaskFilePath(storePath, project)
290
+ ?? path.join(storePath, project, "tasks.md");
275
291
  const subsResult = parseSubsections(taskFile, project, subsectionsCache);
276
292
  const subsections = subsResult.map;
277
293
  const newCache = subsResult.cache;
@@ -341,7 +357,8 @@ function renderFindingsView(ctx, cursor, height) {
341
357
  const project = ctx.state.project;
342
358
  if (!project)
343
359
  return [style.dim(" No project selected.")];
344
- const result = readFindings(ctx.phrenPath, project);
360
+ const storePath = resolveProjectStorePath(ctx.phrenPath, project);
361
+ const result = readFindings(storePath, project);
345
362
  if (!result.ok)
346
363
  return [result.error];
347
364
  const all = result.data;
@@ -390,7 +407,8 @@ function renderMemoryQueueView(ctx, cursor, height) {
390
407
  const project = ctx.state.project;
391
408
  if (!project)
392
409
  return [style.dim(" No project selected.")];
393
- const result = readReviewQueue(ctx.phrenPath, project);
410
+ const storePath = resolveProjectStorePath(ctx.phrenPath, project);
411
+ const result = readReviewQueue(storePath, project);
394
412
  if (!result.ok)
395
413
  return [result.error];
396
414
  const filtered = ctx.state.filter
@@ -439,7 +457,8 @@ function renderMemoryQueueView(ctx, cursor, height) {
439
457
  return vp.lines;
440
458
  }
441
459
  export function getProjectSkills(phrenPath, project) {
442
- return getScopedSkills(phrenPath, "", project).map((skill) => ({
460
+ const storePath = resolveProjectStorePath(phrenPath, project);
461
+ return getScopedSkills(storePath, "", project).map((skill) => ({
443
462
  name: skill.name,
444
463
  path: skill.path,
445
464
  enabled: skill.enabled,
@@ -523,10 +542,11 @@ const LIFECYCLE_HOOKS = [
523
542
  export function getHookEntries(phrenPath, project) {
524
543
  const prefs = readInstallPreferences(phrenPath);
525
544
  const hooksEnabled = prefs.hooksEnabled !== false;
526
- const projectConfig = project ? readProjectConfig(phrenPath, project) : undefined;
545
+ const storePath = project ? resolveProjectStorePath(phrenPath, project) : phrenPath;
546
+ const projectConfig = project ? readProjectConfig(storePath, project) : undefined;
527
547
  return LIFECYCLE_HOOKS.map((h) => ({
528
548
  ...h,
529
- enabled: hooksEnabled && isProjectHookEnabled(phrenPath, project, h.event, projectConfig),
549
+ enabled: hooksEnabled && isProjectHookEnabled(storePath, project, h.event, projectConfig),
530
550
  }));
531
551
  }
532
552
  function renderHooksView(ctx, cursor, height) {
@@ -5,6 +5,7 @@ import * as yaml from "js-yaml";
5
5
  import { expandHomePath, atomicWriteText } from "./phren-paths.js";
6
6
  import { withFileLock } from "./governance/locks.js";
7
7
  import { isRecord, PhrenError } from "./phren-core.js";
8
+ import { getProjectDirs } from "./shared.js";
8
9
  // ── Constants ────────────────────────────────────────────────────────────────
9
10
  const STORES_FILENAME = "stores.yaml";
10
11
  const TEAM_BOOTSTRAP_FILENAME = ".phren-team.yaml";
@@ -110,6 +111,14 @@ export function getNonPrimaryStores(phrenPath) {
110
111
  export function findStoreByName(phrenPath, name) {
111
112
  return resolveAllStores(phrenPath).find((s) => s.name === name);
112
113
  }
114
+ /** Get project directories for a store, filtered by the store's subscription list (if set). */
115
+ export function getStoreProjectDirs(store) {
116
+ const allDirs = getProjectDirs(store.path);
117
+ if (!store.projects || store.projects.length === 0)
118
+ return allDirs;
119
+ const allowed = new Set(store.projects);
120
+ return allDirs.filter(dir => path.basename(dir) !== "global" && allowed.has(path.basename(dir)));
121
+ }
113
122
  // ── Team bootstrap ───────────────────────────────────────────────────────────
114
123
  export function readTeamBootstrap(storePath) {
115
124
  const filePath = path.join(storePath, TEAM_BOOTSTRAP_FILENAME);
@@ -178,6 +187,38 @@ export function updateStoreProjects(phrenPath, storeName, projects) {
178
187
  writeStoreRegistry(phrenPath, registry);
179
188
  });
180
189
  }
190
+ /** Add projects to a store's subscription list. Deduplicates. Uses file locking. */
191
+ export function subscribeStoreProjects(phrenPath, storeName, projects) {
192
+ withFileLock(storesFilePath(phrenPath), () => {
193
+ const registry = readStoreRegistry(phrenPath);
194
+ if (!registry)
195
+ throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`);
196
+ const store = registry.stores.find((s) => s.name === storeName);
197
+ if (!store)
198
+ throw new Error(`${PhrenError.NOT_FOUND}: Store "${storeName}" not found`);
199
+ const existing = new Set(store.projects || []);
200
+ for (const project of projects) {
201
+ existing.add(project);
202
+ }
203
+ store.projects = Array.from(existing).sort().length > 0 ? Array.from(existing).sort() : undefined;
204
+ writeStoreRegistry(phrenPath, registry);
205
+ });
206
+ }
207
+ /** Remove projects from a store's subscription list. Uses file locking. */
208
+ export function unsubscribeStoreProjects(phrenPath, storeName, projects) {
209
+ withFileLock(storesFilePath(phrenPath), () => {
210
+ const registry = readStoreRegistry(phrenPath);
211
+ if (!registry)
212
+ throw new Error(`${PhrenError.FILE_NOT_FOUND}: No stores.yaml found`);
213
+ const store = registry.stores.find((s) => s.name === storeName);
214
+ if (!store)
215
+ throw new Error(`${PhrenError.NOT_FOUND}: Store "${storeName}" not found`);
216
+ const toRemove = new Set(projects);
217
+ const remaining = (store.projects || []).filter((p) => !toRemove.has(p));
218
+ store.projects = remaining.length > 0 ? remaining : undefined;
219
+ writeStoreRegistry(phrenPath, registry);
220
+ });
221
+ }
181
222
  // ── Validation ───────────────────────────────────────────────────────────────
182
223
  function validateRegistry(registry) {
183
224
  if (registry.version !== 1)
@@ -85,7 +85,7 @@ export function listAllProjects(phrenPath, profile) {
85
85
  const stores = resolveAllStores(phrenPath);
86
86
  const results = [];
87
87
  for (const store of stores) {
88
- const dirs = getProjectDirs(store.path, profile);
88
+ const dirs = getProjectDirs(store.path, store.role === "primary" ? profile : undefined);
89
89
  for (const dir of dirs) {
90
90
  const projectName = path.basename(dir);
91
91
  results.push({ store, projectName, projectDir: dir });
@@ -101,7 +101,7 @@ function isValidStoreName(name) {
101
101
  function findProjectInStore(store, projectName, profile) {
102
102
  if (!fs.existsSync(store.path))
103
103
  return null;
104
- const dirs = getProjectDirs(store.path, profile);
104
+ const dirs = getProjectDirs(store.path, store.role === "primary" ? profile : undefined);
105
105
  for (const dir of dirs) {
106
106
  if (path.basename(dir) === projectName)
107
107
  return dir;
@@ -9,6 +9,12 @@ import { incrementSessionTasksCompleted } from "../tools/session.js";
9
9
  const ACTION_PREFIX_RE = /^(?:please\s+|can you\s+|could you\s+|would you\s+|i want you to\s+|i want to\s+|let(?:'|’)s\s+|lets\s+|help me\s+)/i;
10
10
  const EXPLICIT_TASK_PREFIX_RE = /^(?:add(?:\s+(?:this|that|it))?\s+(?:to\s+(?:the\s+)?)?(?:task|todo(?:\s+list)?|task(?:\s+list)?)|add\s+(?:a\s+)?task|put(?:\s+(?:this|that|it))?\s+(?:in|on)\s+(?:the\s+)?(?:task|todo(?:\s+list)?|task(?:\s+list)?))\s*(?::|-|,)?\s*/i;
11
11
  const NON_ACTIONABLE_RE = /\b(brainstorm|idea|ideas|maybe|what if|should we|could we|would it make sense|question|explain|why is|how does)\b/i;
12
+ // Conversational noise: only matches when the ENTIRE prompt is a short ack/reaction (under 40 chars).
13
+ // This avoids rejecting "sure, go ahead and fix the build" or "great, now update the docs".
14
+ const CONVERSATIONAL_NOISE_RE = /^(ok|okay|yeah|yep|nah|nope|hi|hey|ss|bro|lol|lmao|got it|sounds good|perfect|great|sure|thanks|thank you|ty|np|no problem|alright|cool|nice|damn|wtf|omg|fok)[\s!.?,]*$/i;
15
+ // Raw system/SQL error fragment signals — patterns that only appear in error output, never real task requests.
16
+ // Intentionally does NOT include "line \d+" or "incorrect syntax" alone (too broad — they appear in dev prompts).
17
+ const RAW_MESSAGE_SIGNALS_RE = /\b(msg \d+, level \d+|cannot insert the value null|insufficient result space|uniqueidentifier value to char|pgevision-prod|task-notification|tool-use-id|toolu_0[a-z0-9])\b/i;
12
18
  const ACTIONABLE_RE = /\b(add|build|change|complete|continue|create|delete|fix|implement|improve|investigate|make|move|refactor|remove|rename|repair|ship|start|update|wire)\b/i;
13
19
  const CONTINUE_RE = /\b(continue|keep going|finish|resume|pick up|work on that|that task)\b/i;
14
20
  const GITHUB_URL_RE = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/\d+(?:[?#][^\s]*)?/g;
@@ -89,6 +95,11 @@ function isActionablePrompt(prompt, intent) {
89
95
  return false;
90
96
  if (NON_ACTIONABLE_RE.test(normalized))
91
97
  return false;
98
+ // Always reject conversational noise and raw system/SQL fragments regardless of intent.
99
+ if (CONVERSATIONAL_NOISE_RE.test(normalized))
100
+ return false;
101
+ if (RAW_MESSAGE_SIGNALS_RE.test(normalized))
102
+ return false;
92
103
  if (intent === "general")
93
104
  return ACTIONABLE_RE.test(normalized);
94
105
  return true;
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { mcpResponse } from "./types.js";
3
+ import { mcpResponse, resolveStoreForProject } from "./types.js";
4
4
  import { z } from "zod";
5
5
  import { getRetentionPolicy, updateRetentionPolicy, getWorkflowPolicy, updateWorkflowPolicy, getIndexPolicy, updateIndexPolicy, mergeConfig, VALID_TASK_MODES, VALID_FINDING_SENSITIVITY, } from "../shared/governance.js";
6
6
  import { PROACTIVITY_LEVELS, } from "../proactivity.js";
@@ -75,7 +75,16 @@ async function handleGetConfig(ctx, { domain, project }) {
75
75
  const err = validateProject(project);
76
76
  if (err)
77
77
  return mcpResponse({ ok: false, error: err });
78
- const topicResult = getTopicConfigData(phrenPath, project);
78
+ // Resolve store-qualified project names for team stores
79
+ let resolvedPhrenPath = phrenPath;
80
+ let resolvedProject = project;
81
+ try {
82
+ const resolved = resolveStoreForProject(ctx, project);
83
+ resolvedPhrenPath = resolved.phrenPath;
84
+ resolvedProject = resolved.project;
85
+ }
86
+ catch { /* fall back to primary */ }
87
+ const topicResult = getTopicConfigData(resolvedPhrenPath, resolvedProject);
79
88
  if (!topicResult.ok)
80
89
  return mcpResponse({ ok: false, error: topicResult.error });
81
90
  return mcpResponse({
@@ -445,9 +454,18 @@ async function handleSetConfig(ctx, { domain, settings, project }) {
445
454
  const err = validateProject(project);
446
455
  if (err)
447
456
  return mcpResponse({ ok: false, error: err });
448
- const projectDir = safeProjectPath(phrenPath, project);
457
+ // Resolve store-qualified project names for team stores
458
+ let topicPhrenPath = phrenPath;
459
+ let topicProject = project;
460
+ try {
461
+ const resolved = resolveStoreForProject(ctx, project);
462
+ topicPhrenPath = resolved.phrenPath;
463
+ topicProject = resolved.project;
464
+ }
465
+ catch { /* fall back to primary */ }
466
+ const projectDir = safeProjectPath(topicPhrenPath, topicProject);
449
467
  if (!projectDir || !fs.existsSync(projectDir)) {
450
- return mcpResponse({ ok: false, error: `Project "${project}" not found in phren.` });
468
+ return mcpResponse({ ok: false, error: `Project "${topicProject}" not found in phren.` });
451
469
  }
452
470
  const topics = settings.topics;
453
471
  if (!topics || !Array.isArray(topics)) {
@@ -480,7 +498,7 @@ async function handleSetConfig(ctx, { domain, settings, project }) {
480
498
  fs.writeFileSync(configPath, JSON.stringify({ version: 1, domain: topicDomain, topics: [] }, null, 2) + "\n");
481
499
  }
482
500
  }
483
- const result = writeProjectTopics(phrenPath, project, normalized);
501
+ const result = writeProjectTopics(topicPhrenPath, topicProject, normalized);
484
502
  if (!result.ok) {
485
503
  return mcpResponse({ ok: false, error: result.error });
486
504
  }
@@ -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";
@@ -35,10 +35,21 @@ export function register(server, ctx) {
35
35
  inputSchema: z.object({
36
36
  project: z.string().describe("Project name to export."),
37
37
  }),
38
- }, async ({ project }) => {
38
+ }, async ({ project: projectInput }) => {
39
+ // Resolve store-qualified project names (e.g. "qualus-shared/arc")
40
+ let resolvedPhrenPath;
41
+ let project;
42
+ try {
43
+ const resolved = resolveStoreForProject(ctx, projectInput);
44
+ resolvedPhrenPath = resolved.phrenPath;
45
+ project = resolved.project;
46
+ }
47
+ catch (err) {
48
+ return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
49
+ }
39
50
  if (!isValidProjectName(project))
40
51
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
41
- const projectDir = safeProjectPath(phrenPath, project);
52
+ const projectDir = safeProjectPath(resolvedPhrenPath, project);
42
53
  if (!projectDir || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) {
43
54
  return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
44
55
  }
@@ -46,17 +57,16 @@ export function register(server, ctx) {
46
57
  const summaryPath = safeProjectPath(projectDir, "summary.md");
47
58
  if (summaryPath && fs.existsSync(summaryPath))
48
59
  exported.summary = fs.readFileSync(summaryPath, "utf8");
49
- const learningsResult = readFindings(phrenPath, project);
60
+ const learningsResult = readFindings(resolvedPhrenPath, project);
50
61
  if (learningsResult.ok)
51
62
  exported.learnings = learningsResult.data;
52
63
  const findingsPath = safeProjectPath(projectDir, "FINDINGS.md");
53
64
  if (findingsPath && fs.existsSync(findingsPath))
54
65
  exported.findingsRaw = fs.readFileSync(findingsPath, "utf8");
55
- const taskResult = readTasks(phrenPath, project);
66
+ const taskResult = readTasks(resolvedPhrenPath, project);
56
67
  if (taskResult.ok) {
57
68
  exported.task = taskResult.data.items;
58
- // Also export the raw task file string for lossless round-trip (preserves priority/pinned/stable IDs)
59
- const taskRawPath = resolveTaskFilePath(phrenPath, project);
69
+ const taskRawPath = resolveTaskFilePath(resolvedPhrenPath, project);
60
70
  if (taskRawPath && fs.existsSync(taskRawPath))
61
71
  exported.taskRaw = fs.readFileSync(taskRawPath, "utf8");
62
72
  }
@@ -272,15 +282,16 @@ export function register(server, ctx) {
272
282
  }, async ({ project, action }) => {
273
283
  if (!isValidProjectName(project))
274
284
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
285
+ const resolved = resolveStoreForProject(ctx, project);
275
286
  return withWriteQueue(async () => {
276
- const activeProject = findProjectNameCaseInsensitive(phrenPath, project);
277
- const archivedProject = findArchivedProjectNameCaseInsensitive(phrenPath, project);
287
+ const activeProject = findProjectNameCaseInsensitive(resolved.phrenPath, project);
288
+ const archivedProject = findArchivedProjectNameCaseInsensitive(resolved.phrenPath, project);
278
289
  if (action === "archive") {
279
290
  if (!activeProject) {
280
291
  return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
281
292
  }
282
- const projectDir = path.join(phrenPath, activeProject);
283
- const archiveDir = path.join(phrenPath, `${activeProject}.archived`);
293
+ const projectDir = path.join(resolved.phrenPath, activeProject);
294
+ const archiveDir = path.join(resolved.phrenPath, `${activeProject}.archived`);
284
295
  if (!fs.existsSync(projectDir)) {
285
296
  return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
286
297
  }
@@ -306,12 +317,12 @@ export function register(server, ctx) {
306
317
  return mcpResponse({ ok: false, error: `Project "${activeProject}" already exists as an active project.` });
307
318
  }
308
319
  if (!archivedProject) {
309
- const entries = fs.readdirSync(phrenPath).filter((e) => e.endsWith(".archived"));
320
+ const entries = fs.readdirSync(resolved.phrenPath).filter((e) => e.endsWith(".archived"));
310
321
  const available = entries.map((e) => e.replace(/\.archived$/, ""));
311
322
  return mcpResponse({ ok: false, error: `No archive found for "${project}".`, data: { availableArchives: available } });
312
323
  }
313
- const projectDir = path.join(phrenPath, archivedProject);
314
- const archiveDir = path.join(phrenPath, `${archivedProject}.archived`);
324
+ const projectDir = path.join(resolved.phrenPath, archivedProject);
325
+ const archiveDir = path.join(resolved.phrenPath, `${archivedProject}.archived`);
315
326
  fs.renameSync(archiveDir, projectDir);
316
327
  try {
317
328
  await rebuildIndex();
@@ -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 { isValidProjectName, safeProjectPath, errorMessage } from "../utils.js";
4
4
  import { addFindingsToFile } from "../shared/content.js";
@@ -39,7 +39,7 @@ function parseFindings(raw) {
39
39
  return [];
40
40
  }
41
41
  export function register(server, ctx) {
42
- const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
42
+ const { withWriteQueue, updateFileInIndex } = ctx;
43
43
  server.registerTool("auto_extract_findings", {
44
44
  title: "◆ phren · auto-extract findings",
45
45
  description: "Use a local Ollama LLM to automatically extract non-obvious findings from text. " +
@@ -53,7 +53,10 @@ export function register(server, ctx) {
53
53
  model: z.string().optional().describe("Ollama model to use (overrides PHREN_EXTRACT_MODEL env var)."),
54
54
  dryRun: z.boolean().optional().describe("If true, return what would be extracted without saving."),
55
55
  }),
56
- }, async ({ project, text, model, dryRun }) => {
56
+ }, async ({ project: projectInput, text, model, dryRun }) => {
57
+ const resolved = resolveStoreForProject(ctx, projectInput);
58
+ const project = resolved.project;
59
+ const targetPath = resolved.phrenPath;
57
60
  if (!isValidProjectName(project)) {
58
61
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
59
62
  }
@@ -112,14 +115,14 @@ export function register(server, ctx) {
112
115
  return withWriteQueue(async () => {
113
116
  // Use addFindingsToFile so extracted findings go through the full pipeline:
114
117
  // secret scan, dedup check, validation, and index update.
115
- const result = addFindingsToFile(phrenPath, project, findings, { source: "extract" });
118
+ const result = addFindingsToFile(targetPath, project, findings, { source: "extract" });
116
119
  if (!result.ok) {
117
120
  return mcpResponse({ ok: false, error: result.error });
118
121
  }
119
122
  const { added, skipped, rejected } = result.data;
120
123
  const allSkipped = [...skipped, ...rejected.map(r => r.text)];
121
124
  // Update index for the findings file
122
- const resolvedDir = safeProjectPath(phrenPath, project);
125
+ const resolvedDir = safeProjectPath(targetPath, project);
123
126
  if (resolvedDir) {
124
127
  updateFileInIndex(path.join(resolvedDir, "FINDINGS.md"));
125
128
  }
@@ -5,7 +5,7 @@ import * as path from "path";
5
5
  import { logger } from "../logger.js";
6
6
  import { isValidProjectName, safeProjectPath, errorMessage } from "../utils.js";
7
7
  import { removeFinding as removeFindingCore, removeFindings as removeFindingsCore, } from "../core/finding.js";
8
- import { debugLog, EXEC_TIMEOUT_MS, FINDING_TYPES, normalizeMemoryScope, RESERVED_PROJECT_DIR_NAMES, } from "../shared.js";
8
+ import { debugLog, EXEC_TIMEOUT_MS, FINDING_TYPES, normalizeMemoryScope, } from "../shared.js";
9
9
  import { addFindingToFile, addFindingsToFile, checkSemanticConflicts, autoMergeConflicts, } from "../shared/content.js";
10
10
  import { jaccardTokenize, jaccardSimilarity, stripMetadata } from "../content/dedup.js";
11
11
  import { runCustomHooks } from "../hooks.js";
@@ -330,17 +330,22 @@ async function handleResolveContradiction(ctx, { project: projectInput, finding_
330
330
  }));
331
331
  }
332
332
  async function handleGetContradictions(ctx, { project, finding_text }) {
333
- const { phrenPath } = ctx;
334
333
  if (project && !isValidProjectName(project))
335
334
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
336
- const projects = project
337
- ? [project]
338
- : fs.readdirSync(phrenPath, { withFileTypes: true })
339
- .filter((entry) => entry.isDirectory() && !RESERVED_PROJECT_DIR_NAMES.has(entry.name) && isValidProjectName(entry.name))
340
- .map((entry) => entry.name);
335
+ // Build project list from all stores (primary + team)
336
+ let projectsWithPaths;
337
+ if (project) {
338
+ const resolved = resolveStoreForProject(ctx, project);
339
+ projectsWithPaths = [{ project: resolved.project, storePath: resolved.phrenPath }];
340
+ }
341
+ else {
342
+ const { listAllProjects } = await import("../store-routing.js");
343
+ projectsWithPaths = listAllProjects(ctx.phrenPath, ctx.profile)
344
+ .map((p) => ({ project: p.projectName, storePath: p.store.path }));
345
+ }
341
346
  const contradictions = [];
342
- for (const p of projects) {
343
- const result = readFindings(phrenPath, p);
347
+ for (const { project: p, storePath } of projectsWithPaths) {
348
+ const result = readFindings(storePath, p);
344
349
  if (!result.ok)
345
350
  continue;
346
351
  for (const finding of result.data) {
@@ -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 crypto from "crypto";
@@ -85,7 +85,13 @@ export function register(server, ctx) {
85
85
  }
86
86
  const results = [];
87
87
  for (const doc of relatedDocs) {
88
- const docRow = queryDocBySourceKey(db, ctx.phrenPath, doc);
88
+ const docProject = doc.split("/")[0];
89
+ let docPhrenPath = ctx.phrenPath;
90
+ try {
91
+ docPhrenPath = resolveStoreForProject(ctx, docProject).phrenPath;
92
+ }
93
+ catch { /* fall back to primary */ }
94
+ const docRow = queryDocBySourceKey(db, docPhrenPath, doc);
89
95
  const snippet = docRow?.content ? docRow.content.slice(0, 200) : "";
90
96
  results.push({ sourceDoc: doc, snippet });
91
97
  }
@@ -255,7 +261,10 @@ export function register(server, ctx) {
255
261
  catch (err) {
256
262
  logger.debug("graph", `link_findings globalFragments: ${errorMessage(err)}`);
257
263
  }
258
- // 4b. Persist manual link so it survives index rebuilds (mandatory — failure aborts the operation)
264
+ // 4b. Persist manual link so it survives index rebuilds (mandatory — failure aborts the operation).
265
+ // Note: manual-links.json is intentionally written to the primary phrenPath's runtime directory.
266
+ // During index rebuild, it's read and merged into the unified index (which includes team store docs),
267
+ // so team store projects' manual links persist and are queryable cross-store. Writes are always to primary.
259
268
  const manualLinksPath = runtimeFile(ctx.phrenPath, "manual-links.json");
260
269
  try {
261
270
  withFileLock(manualLinksPath, () => {
@@ -5,7 +5,7 @@ import * as path from "path";
5
5
  import { readInstallPreferences, updateInstallPreferences } from "../init/preferences.js";
6
6
  import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES, validateCustomHookCommand, validateCustomWebhookUrl } from "../hooks.js";
7
7
  import { hookConfigPath } from "../shared.js";
8
- import { PROJECT_HOOK_EVENTS, isProjectHookEnabled, readProjectConfig, writeProjectHookConfig } from "../project-config.js";
8
+ import { PROJECT_HOOK_EVENTS, isProjectHookEnabled, readProjectConfig, writeProjectHookConfig, clearProjectHookOverride } from "../project-config.js";
9
9
  import { isValidProjectName } from "../utils.js";
10
10
  const HOOK_TOOLS = ["claude", "copilot", "cursor", "codex"];
11
11
  const VALID_CUSTOM_EVENTS = HOOK_EVENT_VALUES;
@@ -97,14 +97,27 @@ export function register(server, ctx) {
97
97
  description: "Enable or disable hooks globally, for a specific tool, or for a tracked project.",
98
98
  inputSchema: z.object({
99
99
  enabled: z.boolean().describe("true to enable, false to disable."),
100
+ clear: z.boolean().optional().describe("When true and project is set, removes the per-project override and restores inheritance from global. Ignores enabled."),
100
101
  tool: z.string().optional().describe("Specific tool. Omit to toggle globally."),
101
102
  project: z.string().optional().describe("Tracked project name for project-level lifecycle hook overrides."),
102
103
  event: z.string().optional().describe("Optional lifecycle event for project-level overrides: UserPromptSubmit, Stop, SessionStart, PostToolUse."),
103
104
  }),
104
- }, async ({ enabled, tool, project, event }) => {
105
+ }, async ({ enabled, clear, tool, project, event }) => {
105
106
  if (tool && project) {
106
107
  return mcpResponse({ ok: false, error: "Pass either tool or project, not both." });
107
108
  }
109
+ // Clear per-project override (restore inheritance)
110
+ if (clear && project) {
111
+ if (!isValidProjectName(project) || !fs.existsSync(path.join(phrenPath, project))) {
112
+ return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
113
+ }
114
+ clearProjectHookOverride(phrenPath, project, event);
115
+ const scope = event ? `${event} hook` : "hooks";
116
+ return mcpResponse({ ok: true, message: `Cleared ${scope} override for ${project} — now inheriting from global.`, data: { project, event, cleared: true } });
117
+ }
118
+ if (clear) {
119
+ return mcpResponse({ ok: false, error: "clear requires project." });
120
+ }
108
121
  if (event && !project) {
109
122
  return mcpResponse({ ok: false, error: "event requires project." });
110
123
  }