@phren/cli 0.0.17 → 0.0.19

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.
@@ -15,6 +15,7 @@ import { appendChildFinding, editFinding as editFindingCore, readFindings } from
15
15
  import { getActiveTaskForSession } from "./task-lifecycle.js";
16
16
  import { FINDING_PROVENANCE_SOURCES } from "./content-citation.js";
17
17
  import { isInactiveFindingLine, supersedeFinding, retractFinding as retractFindingLifecycle, resolveFindingContradiction, } from "./finding-lifecycle.js";
18
+ import { permissionDeniedError } from "./governance-rbac.js";
18
19
  const JACCARD_MAYBE_LOW = 0.30;
19
20
  const JACCARD_MAYBE_HIGH = 0.55; // above this isDuplicateFinding already catches it
20
21
  function findJaccardCandidates(phrenPath, project, finding) {
@@ -112,6 +113,9 @@ export function register(server, ctx) {
112
113
  }, async ({ project, finding, citation, sessionId, source, findingType, scope }) => {
113
114
  if (!isValidProjectName(project))
114
115
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
116
+ const addFindingDenied = permissionDeniedError(phrenPath, "add_finding", project);
117
+ if (addFindingDenied)
118
+ return mcpResponse({ ok: false, error: addFindingDenied });
115
119
  if (finding.length > 5000)
116
120
  return mcpResponse({ ok: false, error: "Finding text exceeds 5000 character limit." });
117
121
  const normalizedScope = normalizeMemoryScope(scope ?? "shared");
@@ -214,6 +218,9 @@ export function register(server, ctx) {
214
218
  }, async ({ project, findings, sessionId }) => {
215
219
  if (!isValidProjectName(project))
216
220
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
221
+ const addFindingsDenied = permissionDeniedError(phrenPath, "add_finding", project);
222
+ if (addFindingsDenied)
223
+ return mcpResponse({ ok: false, error: addFindingsDenied });
217
224
  if (findings.length > 100)
218
225
  return mcpResponse({ ok: false, error: "Bulk add limited to 100 findings per call." });
219
226
  if (findings.some((f) => f.length > 5000))
@@ -399,6 +406,9 @@ export function register(server, ctx) {
399
406
  }, async ({ project, old_text, new_text }) => {
400
407
  if (!isValidProjectName(project))
401
408
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
409
+ const editDenied = permissionDeniedError(phrenPath, "edit_finding", project);
410
+ if (editDenied)
411
+ return mcpResponse({ ok: false, error: editDenied });
402
412
  return withWriteQueue(async () => {
403
413
  const result = editFindingCore(phrenPath, project, old_text, new_text);
404
414
  if (!result.ok)
@@ -424,6 +434,9 @@ export function register(server, ctx) {
424
434
  }, async ({ project, finding }) => {
425
435
  if (!isValidProjectName(project))
426
436
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
437
+ const removeDenied = permissionDeniedError(phrenPath, "remove_finding", project);
438
+ if (removeDenied)
439
+ return mcpResponse({ ok: false, error: removeDenied });
427
440
  return withWriteQueue(async () => {
428
441
  const result = removeFindingCore(phrenPath, project, finding);
429
442
  if (result.ok) {
@@ -446,6 +459,9 @@ export function register(server, ctx) {
446
459
  }, async ({ project, findings }) => {
447
460
  if (!isValidProjectName(project))
448
461
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
462
+ const removeFindingsDenied = permissionDeniedError(phrenPath, "remove_finding", project);
463
+ if (removeFindingsDenied)
464
+ return mcpResponse({ ok: false, error: removeFindingsDenied });
449
465
  return withWriteQueue(async () => {
450
466
  const result = removeFindingsCore(phrenPath, project, findings);
451
467
  if (result.ok) {
@@ -10,7 +10,7 @@ import { decodeStringRow, queryRows, queryDocRows, queryEntityLinks, logEntityMi
10
10
  import { runCustomHooks } from "./hooks.js";
11
11
  import { entryScoreKey, getQualityMultiplier, getRetentionPolicy } from "./shared-governance.js";
12
12
  import { callLlm } from "./content-dedup.js";
13
- import { rankResults, searchKnowledgeRows, applyTrustFilter } from "./shared-retrieval.js";
13
+ import { rankResults, searchKnowledgeRows, applyTrustFilter, searchFederatedStores } from "./shared-retrieval.js";
14
14
  import { parseSourceComment } from "./content-citation.js";
15
15
  import { resolveActiveSessionScope } from "./mcp-session.js";
16
16
  /**
@@ -235,9 +235,35 @@ export function register(server, ctx) {
235
235
  const safeQuery = retrieval.safeQuery;
236
236
  if (!safeQuery)
237
237
  return mcpResponse({ ok: false, error: "Search query is empty after sanitization." });
238
- let rows = retrieval.rows;
238
+ let rows = retrieval.rows ?? [];
239
239
  const usedFallback = retrieval.usedFallback;
240
- if (!rows || rows.length === 0) {
240
+ // Merge federated store results when PHREN_FEDERATION_PATHS is set and no project filter
241
+ // (federation is global by nature — per-project filter only makes sense within a single store)
242
+ if (!filterProject) {
243
+ try {
244
+ const federatedRows = await searchFederatedStores(phrenPath, {
245
+ query,
246
+ maxResults,
247
+ fetchLimit,
248
+ filterType,
249
+ });
250
+ if (federatedRows.length > 0) {
251
+ // Dedup by path to avoid duplicates if stores share files
252
+ const localPaths = new Set(rows.map((r) => r.path || `${r.project}/${r.filename}`));
253
+ const uniqueFederated = federatedRows.filter((r) => {
254
+ const key = r.path || `${r.project}/${r.filename}`;
255
+ return !localPaths.has(key);
256
+ });
257
+ rows = [...rows, ...uniqueFederated];
258
+ }
259
+ }
260
+ catch (err) {
261
+ if (process.env.PHREN_DEBUG) {
262
+ process.stderr.write(`[phren] search_knowledge federation: ${errorMessage(err)}\n`);
263
+ }
264
+ }
265
+ }
266
+ if (rows.length === 0) {
241
267
  logSearchMiss(phrenPath, query, filterProject);
242
268
  return mcpResponse({ ok: true, message: "No results found.", data: { query, results: [] } });
243
269
  }
@@ -368,6 +394,7 @@ export function register(server, ctx) {
368
394
  const results = rows.map((row) => {
369
395
  const snippet = extractSnippet(row.content, query);
370
396
  const lifecycle = row.type === "findings" ? lifecycleByRowKey.get(findingRowKey(row)) : undefined;
397
+ const federationSource = "federationSource" in row ? row.federationSource : undefined;
371
398
  return {
372
399
  project: row.project,
373
400
  filename: row.filename,
@@ -376,6 +403,7 @@ export function register(server, ctx) {
376
403
  path: row.path,
377
404
  status: lifecycle?.primaryStatus,
378
405
  statuses: lifecycle?.statuses,
406
+ ...(federationSource ? { federation_source: federationSource } : {}),
379
407
  };
380
408
  });
381
409
  let relatedFragments = [];
@@ -396,7 +424,10 @@ export function register(server, ctx) {
396
424
  if ((process.env.PHREN_DEBUG))
397
425
  process.stderr.write(`[phren] fragment query: ${errorMessage(err)}\n`);
398
426
  }
399
- const formatted = results.map((r) => `### ${r.project}/${r.filename} (${r.type})\n${r.snippet}\n\n\`${r.path}\``);
427
+ const formatted = results.map((r) => {
428
+ const fedNote = r.federation_source ? ` [from: ${r.federation_source}]` : "";
429
+ return `### ${r.project}/${r.filename} (${r.type})${fedNote}\n${r.snippet}\n\n\`${r.path}\``;
430
+ });
400
431
  // Memory synthesis: generate a concise paragraph from top results when requested
401
432
  let synthesis;
402
433
  if (synthesize && results.length > 0) {
@@ -576,7 +607,15 @@ export function register(server, ctx) {
576
607
  const visibleItems = includeHistory
577
608
  ? allItems
578
609
  : allItems.filter(f => f.tier !== "archived" && !HISTORY_FINDING_STATUSES.has(f.status));
579
- const filteredItems = status ? visibleItems.filter(f => f.status === status) : visibleItems;
610
+ // Apply scope filter: only show findings visible to the active scope
611
+ const activeScope = resolveActiveSessionScope(phrenPath, project);
612
+ const scopedItems = activeScope
613
+ ? visibleItems.filter(f => {
614
+ const itemScope = normalizeMemoryScope(f.scope);
615
+ return isMemoryScopeVisible(itemScope, activeScope);
616
+ })
617
+ : visibleItems;
618
+ const filteredItems = status ? scopedItems.filter(f => f.status === status) : scopedItems;
580
619
  if (!filteredItems.length) {
581
620
  const msg = historyCount > 0 && !includeHistory
582
621
  ? `No findings found for "${project}" with current filters. ${historyCount} historical finding(s) hidden. Pass include_history=true to show history.`
@@ -124,6 +124,10 @@ function findMostRecentSession(phrenPath) {
124
124
  return { file: best.fullPath, state: best.data };
125
125
  }
126
126
  export function resolveActiveSessionScope(phrenPath, project) {
127
+ // PHREN_SCOPE env var takes priority over session-derived scope
128
+ const envScope = normalizeMemoryScope(process.env.PHREN_SCOPE);
129
+ if (envScope)
130
+ return envScope;
127
131
  const dir = sessionsDir(phrenPath);
128
132
  const results = scanSessionFiles(dir, readSessionStateFile, (state) => {
129
133
  if (state.endedAt)
@@ -9,6 +9,7 @@ import { buildTaskIssueBody, createGithubIssueForTask, parseGithubIssueUrl, reso
9
9
  import { clearTaskCheckpoint } from "./session-checkpoints.js";
10
10
  import { incrementSessionTasksCompleted } from "./mcp-session.js";
11
11
  import { normalizeMemoryScope } from "./shared.js";
12
+ import { permissionDeniedError } from "./governance-rbac.js";
12
13
  const TASK_SECTION_ORDER = ["Active", "Queue", "Done"];
13
14
  const DEFAULT_TASK_LIMIT = 20;
14
15
  /** Done items are historical — cap tightly by default to avoid large responses. */
@@ -206,6 +207,9 @@ export function register(server, ctx) {
206
207
  }, async ({ project, item, scope }) => {
207
208
  if (!isValidProjectName(project))
208
209
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
210
+ const addTaskDenied = permissionDeniedError(phrenPath, "add_task", project);
211
+ if (addTaskDenied)
212
+ return mcpResponse({ ok: false, error: addTaskDenied });
209
213
  const normalizedScope = normalizeMemoryScope(scope ?? "shared");
210
214
  if (!normalizedScope)
211
215
  return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` });
@@ -227,6 +231,9 @@ export function register(server, ctx) {
227
231
  }, async ({ project, items }) => {
228
232
  if (!isValidProjectName(project))
229
233
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
234
+ const addTasksDenied = permissionDeniedError(phrenPath, "add_task", project);
235
+ if (addTasksDenied)
236
+ return mcpResponse({ ok: false, error: addTasksDenied });
230
237
  return withWriteQueue(async () => {
231
238
  const result = addTasksBatch(phrenPath, project, items);
232
239
  if (!result.ok)
@@ -248,6 +255,9 @@ export function register(server, ctx) {
248
255
  }, async ({ project, item, sessionId }) => {
249
256
  if (!isValidProjectName(project))
250
257
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
258
+ const completeTaskDenied = permissionDeniedError(phrenPath, "complete_task", project);
259
+ if (completeTaskDenied)
260
+ return mcpResponse({ ok: false, error: completeTaskDenied });
251
261
  return withWriteQueue(async () => {
252
262
  const before = resolveTaskItem(phrenPath, project, item);
253
263
  const result = completeTaskStore(phrenPath, project, item);
@@ -278,6 +288,9 @@ export function register(server, ctx) {
278
288
  }, async ({ project, items, sessionId }) => {
279
289
  if (!isValidProjectName(project))
280
290
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
291
+ const completeTasksDenied = permissionDeniedError(phrenPath, "complete_task", project);
292
+ if (completeTasksDenied)
293
+ return mcpResponse({ ok: false, error: completeTasksDenied });
281
294
  return withWriteQueue(async () => {
282
295
  const resolvedItems = items
283
296
  .map((match) => {
@@ -319,6 +332,9 @@ export function register(server, ctx) {
319
332
  }, async ({ project, item }) => {
320
333
  if (!isValidProjectName(project))
321
334
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
335
+ const removeTaskDenied = permissionDeniedError(phrenPath, "remove_task", project);
336
+ if (removeTaskDenied)
337
+ return mcpResponse({ ok: false, error: removeTaskDenied });
322
338
  return withWriteQueue(async () => {
323
339
  const result = removeTaskStore(phrenPath, project, item);
324
340
  if (!result.ok)
@@ -337,6 +353,9 @@ export function register(server, ctx) {
337
353
  }, async ({ project, items }) => {
338
354
  if (!isValidProjectName(project))
339
355
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
356
+ const removeTasksDenied = permissionDeniedError(phrenPath, "remove_task", project);
357
+ if (removeTasksDenied)
358
+ return mcpResponse({ ok: false, error: removeTasksDenied });
340
359
  return withWriteQueue(async () => {
341
360
  const result = removeTasksBatch(phrenPath, project, items);
342
361
  if (!result.ok)
@@ -367,6 +386,9 @@ export function register(server, ctx) {
367
386
  }, async ({ project, item, updates }) => {
368
387
  if (!isValidProjectName(project))
369
388
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
389
+ const updateTaskDenied = permissionDeniedError(phrenPath, "update_task", project);
390
+ if (updateTaskDenied)
391
+ return mcpResponse({ ok: false, error: updateTaskDenied });
370
392
  return withWriteQueue(async () => {
371
393
  const result = updateTaskStore(phrenPath, project, item, updates);
372
394
  if (!result.ok)