@phren/cli 0.0.41 → 0.0.43

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.
@@ -197,8 +197,8 @@ export async function runFreshInstall(phrenPath, opts, params) {
197
197
  log(`\nNext steps:`);
198
198
  let step = 1;
199
199
  log(` ${step++}. Start a new Claude session in your project directory — phren injects context automatically`);
200
- log(` ${step++}. Run \`npx phren doctor\` to verify everything is wired correctly`);
201
- log(` ${step++}. Change defaults anytime: \`npx phren config project-ownership\`, \`npx phren config workflow\`, \`npx phren config proactivity.findings\`, \`npx phren config proactivity.tasks\``);
200
+ log(` ${step++}. Run \`phren doctor\` to verify everything is wired correctly`);
201
+ log(` ${step++}. Change defaults anytime: \`phren config project-ownership\`, \`phren config workflow\`, \`phren config proactivity.findings\`, \`phren config proactivity.tasks\``);
202
202
  const gh = opts._walkthroughGithub;
203
203
  if (gh) {
204
204
  const remote = gh.username
@@ -223,9 +223,9 @@ export async function runFreshInstall(phrenPath, opts, params) {
223
223
  log(` git remote add origin git@github.com:YOUR_USERNAME/my-phren.git`);
224
224
  log(` git push -u origin main`);
225
225
  }
226
- log(` ${step++}. Add more projects: cd ~/your-project && npx phren add`);
226
+ log(` ${step++}. Add more projects: cd ~/your-project && phren add`);
227
227
  if (!mcpEnabled) {
228
- log(` ${step++}. Turn MCP on: npx phren mcp-mode on`);
228
+ log(` ${step++}. Turn MCP on: phren mcp-mode on`);
229
229
  }
230
230
  log(` ${step++}. After your first week, run phren-discover to surface gaps in your project knowledge`);
231
231
  log(` ${step++}. After working across projects, run phren-consolidate to find cross-project patterns`);
@@ -21,6 +21,6 @@ export function configureHooksIfEnabled(phrenPath, hooksEnabled, verb) {
21
21
  }
22
22
  }
23
23
  else {
24
- log(` Hooks are disabled by preference (run: npx phren hooks-mode on)`);
24
+ log(` Hooks are disabled by preference (run: phren hooks-mode on)`);
25
25
  }
26
26
  }
@@ -24,8 +24,8 @@ export async function runMcpMode(modeArg) {
24
24
  const hooks = getHooksEnabledPreference(phrenPath);
25
25
  log(`MCP mode: ${current ? "on (recommended)" : "off (hooks-only fallback)"}`);
26
26
  log(`Hooks mode: ${hooks ? "on (active)" : "off (disabled)"}`);
27
- log(`Change mode: npx phren mcp-mode on|off`);
28
- log(`Hooks toggle: npx phren hooks-mode on|off`);
27
+ log(`Change mode: phren mcp-mode on|off`);
28
+ log(`Hooks toggle: phren hooks-mode on|off`);
29
29
  return;
30
30
  }
31
31
  const mode = parseMcpMode(normalizedArg);
@@ -93,7 +93,7 @@ export async function runHooksMode(modeArg) {
93
93
  if (!normalizedArg || normalizedArg === "status") {
94
94
  const current = getHooksEnabledPreference(phrenPath);
95
95
  log(`Hooks mode: ${current ? "on (active)" : "off (disabled)"}`);
96
- log(`Change mode: npx phren hooks-mode on|off`);
96
+ log(`Change mode: phren hooks-mode on|off`);
97
97
  return;
98
98
  }
99
99
  const mode = parseMcpMode(normalizedArg);
@@ -53,7 +53,7 @@ export async function runExistingInstallUpdate(phrenPath, opts, params) {
53
53
  const previousVersion = prefs.installedVersion;
54
54
  if (isVersionNewer(VERSION, previousVersion)) {
55
55
  log(`\n Starter template update available: v${previousVersion} -> v${VERSION}`);
56
- log(` Run \`npx phren init --apply-starter-update\` to refresh global/CLAUDE.md and global skills.`);
56
+ log(` Run \`phren init --apply-starter-update\` to refresh global/CLAUDE.md and global skills.`);
57
57
  }
58
58
  if (opts.applyStarterUpdate) {
59
59
  const updated = applyStarterTemplateUpdates(phrenPath);
@@ -88,8 +88,8 @@ export async function runExistingInstallUpdate(phrenPath, opts, params) {
88
88
  log(`\n\x1b[95m◆\x1b[0m phren updated successfully`);
89
89
  log(`\nNext steps:`);
90
90
  log(` 1. Start a new Claude session in your project directory — phren injects context automatically`);
91
- log(` 2. Run \`npx phren doctor\` to verify everything is wired correctly`);
92
- log(` 3. Change defaults anytime: \`npx phren config project-ownership\`, \`npx phren config workflow\`, \`npx phren config proactivity.findings\`, \`npx phren config proactivity.tasks\``);
91
+ log(` 2. Run \`phren doctor\` to verify everything is wired correctly`);
92
+ log(` 3. Change defaults anytime: \`phren config project-ownership\`, \`phren config workflow\`, \`phren config proactivity.findings\`, \`phren config proactivity.tasks\``);
93
93
  log(` 4. After your first week, run phren-discover to surface gaps in your project knowledge`);
94
94
  log(` 5. After working across projects, run phren-consolidate to find cross-project patterns`);
95
95
  log(``);
@@ -269,7 +269,7 @@ export async function runWalkthrough(phrenPath) {
269
269
  log(" phren-managed: Phren may mirror CLAUDE.md / AGENTS.md into the repo");
270
270
  log(" detached: Phren keeps its own docs but does not write into the repo");
271
271
  log(" repo-managed: keep the repo's existing CLAUDE/AGENTS files as canonical");
272
- log(" Change later: npx phren config project-ownership <mode>");
272
+ log(" Change later: phren config project-ownership <mode>");
273
273
  const projectOwnershipDefault = await prompts.select("Default project ownership", [
274
274
  { value: "detached", name: "detached (default)" },
275
275
  { value: "phren-managed", name: "phren-managed" },
@@ -280,7 +280,7 @@ export async function runWalkthrough(phrenPath) {
280
280
  log("directly: search memory, manage tasks, save findings, etc.");
281
281
  log(" Recommended for: Claude Code, Cursor, Copilot CLI, Codex");
282
282
  log(" Alternative: hooks-only mode (read-only context injection, any agent)");
283
- log(" Change later: npx phren mcp-mode on|off");
283
+ log(" Change later: phren mcp-mode on|off");
284
284
  const mcp = (await prompts.confirm("Enable MCP?", true)) ? "on" : "off";
285
285
  printSection("Hooks");
286
286
  log("Hooks run shell commands at session start, prompt submit, and session end.");
@@ -288,7 +288,7 @@ export async function runWalkthrough(phrenPath) {
288
288
  log(" - UserPromptSubmit: searches phren and injects relevant context");
289
289
  log(" - Stop: commits and pushes any new findings after each response");
290
290
  log(" What they touch: ~/.claude/settings.json (hooks section only)");
291
- log(" Change later: npx phren hooks-mode on|off");
291
+ log(" Change later: phren hooks-mode on|off");
292
292
  const hooks = (await prompts.confirm("Enable hooks?", true)) ? "on" : "off";
293
293
  printSection("Semantic Search (Optional)");
294
294
  log("Phren can use a local embedding model for semantic (fuzzy) search via Ollama.");
@@ -336,7 +336,7 @@ export async function runWalkthrough(phrenPath) {
336
336
  let findingsProactivity = "high";
337
337
  if (autoCaptureEnabled) {
338
338
  log(" Findings capture level controls how eager phren is to save lessons automatically.");
339
- log(" Change later: npx phren config proactivity.findings <high|medium|low>");
339
+ log(" Change later: phren config proactivity.findings <high|medium|low>");
340
340
  findingsProactivity = await prompts.select("Findings capture level", [
341
341
  { value: "high", name: "high (recommended)" },
342
342
  { value: "medium", name: "medium" },
@@ -352,7 +352,7 @@ export async function runWalkthrough(phrenPath) {
352
352
  log(" suggest: proposes tasks but waits for approval before writing");
353
353
  log(" manual: tasks are fully manual — you add them yourself");
354
354
  log(" off: never touch tasks automatically");
355
- log(" Change later: npx phren config workflow set --taskMode=<mode>");
355
+ log(" Change later: phren config workflow set --taskMode=<mode>");
356
356
  const taskMode = await prompts.select("Task mode", [
357
357
  { value: "auto", name: "auto (recommended)" },
358
358
  { value: "suggest", name: "suggest" },
@@ -365,7 +365,7 @@ export async function runWalkthrough(phrenPath) {
365
365
  log(" high (recommended): captures tasks as they come up naturally");
366
366
  log(" medium: only when you explicitly mention a task");
367
367
  log(" low: minimal auto-capture");
368
- log(" Change later: npx phren config proactivity.tasks <high|medium|low>");
368
+ log(" Change later: phren config proactivity.tasks <high|medium|low>");
369
369
  taskProactivity = await prompts.select("Task proactivity", [
370
370
  { value: "high", name: "high (recommended)" },
371
371
  { value: "medium", name: "medium" },
@@ -376,7 +376,7 @@ export async function runWalkthrough(phrenPath) {
376
376
  log("Choose how strict review gates should be for risky or low-confidence writes.");
377
377
  log(" lowConfidenceThreshold: confidence cutoff used to mark writes as risky");
378
378
  log(" riskySections: sections always treated as risky");
379
- log(" Change later: npx phren config workflow set --lowConfidenceThreshold=0.7 --riskySections=Stale,Conflicts");
379
+ log(" Change later: phren config workflow set --lowConfidenceThreshold=0.7 --riskySections=Stale,Conflicts");
380
380
  const thresholdAnswer = await prompts.input("Low-confidence threshold [0.0-1.0]", "0.7");
381
381
  const lowConfidenceThreshold = parseLowConfidenceThreshold(thresholdAnswer, 0.7);
382
382
  const riskySectionsAnswer = await prompts.input("Risky sections [Review,Stale,Conflicts]", "Stale,Conflicts");
@@ -426,7 +426,7 @@ export async function runWalkthrough(phrenPath) {
426
426
  log(" conservative — decisions and pitfalls only");
427
427
  log(" balanced — non-obvious patterns, decisions, pitfalls, bugs (recommended)");
428
428
  log(" aggressive — everything worth remembering, err on the side of capturing");
429
- log(" Change later: npx phren config finding-sensitivity <level>");
429
+ log(" Change later: phren config finding-sensitivity <level>");
430
430
  const findingSensitivity = await prompts.select("Finding sensitivity", [
431
431
  { value: "balanced", name: "balanced (recommended)" },
432
432
  { value: "conservative", name: "conservative" },
@@ -455,7 +455,7 @@ export async function runWalkthrough(phrenPath) {
455
455
  bootstrapCurrentProject = await prompts.confirm("Add this project to phren now?", true);
456
456
  if (!bootstrapCurrentProject) {
457
457
  bootstrapCurrentProject = false;
458
- log(style.warning(` Skipped. Later: cd ${detectedProject} && npx phren add`));
458
+ log(style.warning(` Skipped. Later: cd ${detectedProject} && phren add`));
459
459
  }
460
460
  else {
461
461
  bootstrapOwnership = await prompts.select("Ownership for detected project", [
@@ -76,7 +76,7 @@ function maybeOfferStarterTemplateUpdate(phrenPath) {
76
76
  const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
77
77
  if (isVersionNewer(current, prefs.installedVersion)) {
78
78
  log(` Starter template update available: v${prefs.installedVersion} -> v${current}`);
79
- log(` Run \`npx phren init --apply-starter-update\` to refresh global/CLAUDE.md and global skills.`);
79
+ log(` Run \`phren init --apply-starter-update\` to refresh global/CLAUDE.md and global skills.`);
80
80
  }
81
81
  }
82
82
  catch (err) {
@@ -209,7 +209,7 @@ export function findPhrenPathWithArg(arg) {
209
209
  const existing = findPhrenPath();
210
210
  if (existing)
211
211
  return existing;
212
- throw new Error(`${PhrenError.NOT_FOUND}: phren root not found. Run 'npx phren init'.`);
212
+ throw new Error(`${PhrenError.NOT_FOUND}: phren root not found. Run 'phren init'.`);
213
213
  }
214
214
  export function isProjectLocalMode(phrenPath) {
215
215
  try {
@@ -513,7 +513,7 @@ export function getPhrenPath() {
513
513
  if (!lazyPhrenPath) {
514
514
  const existing = findPhrenPath();
515
515
  if (!existing)
516
- throw new Error(`${PhrenError.NOT_FOUND}: phren root not found. Run 'npx phren init'.`);
516
+ throw new Error(`${PhrenError.NOT_FOUND}: phren root not found. Run 'phren init'.`);
517
517
  lazyPhrenPath = existing;
518
518
  }
519
519
  return lazyPhrenPath;
@@ -45,7 +45,7 @@ export function resolveActiveProfile(phrenPath, requestedProfile) {
45
45
  export function listMachines(phrenPath) {
46
46
  const machinesPath = path.join(phrenPath, "machines.yaml");
47
47
  if (!fs.existsSync(machinesPath))
48
- return phrenErr(`machines.yaml not found. Run 'npx phren init' to set up your phren.`, PhrenError.FILE_NOT_FOUND);
48
+ return phrenErr(`machines.yaml not found. Run 'phren init' to set up your phren.`, PhrenError.FILE_NOT_FOUND);
49
49
  try {
50
50
  const raw = fs.readFileSync(machinesPath, "utf8");
51
51
  const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
@@ -205,7 +205,7 @@ export function getActiveProfileDefaults(phrenPath, profile) {
205
205
  export function listProfiles(phrenPath) {
206
206
  const profilesDir = path.join(phrenPath, "profiles");
207
207
  if (!fs.existsSync(profilesDir))
208
- return phrenErr(`No profiles/ directory found. Run 'npx phren init' to set up your phren.`, PhrenError.FILE_NOT_FOUND);
208
+ return phrenErr(`No profiles/ directory found. Run 'phren init' to set up your phren.`, PhrenError.FILE_NOT_FOUND);
209
209
  const files = fs.readdirSync(profilesDir).filter((file) => file.endsWith(".yaml")).sort();
210
210
  const profiles = [];
211
211
  for (const file of files) {
@@ -53,7 +53,7 @@ function hasCommandHook(value) {
53
53
  export async function runStatus() {
54
54
  const phrenPath = findPhrenPath();
55
55
  if (!phrenPath) {
56
- console.log(`${RED}phren not found${RESET}. Run ${CYAN}npx phren init${RESET} to set up.`);
56
+ console.log(`${RED}phren not found${RESET}. Run ${CYAN}npx @phren/cli init${RESET} to set up.`);
57
57
  process.exit(1);
58
58
  }
59
59
  const cwd = process.cwd();
@@ -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";
@@ -17,8 +17,8 @@ import { getActiveTaskForSession } from "../task/lifecycle.js";
17
17
  import { FINDING_PROVENANCE_SOURCES } from "../content/citation.js";
18
18
  import { isInactiveFindingLine, supersedeFinding, retractFinding as retractFindingLifecycle, resolveFindingContradiction, } from "../finding/lifecycle.js";
19
19
  import { permissionDeniedError } from "../governance/rbac.js";
20
- const JACCARD_MAYBE_LOW = 0.30;
21
- const JACCARD_MAYBE_HIGH = 0.55; // above this isDuplicateFinding already catches it
20
+ const JACCARD_MAYBE_LOW = 0.25;
21
+ const JACCARD_MAYBE_HIGH = 0.40; // above this isDuplicateFinding already catches it
22
22
  function findJaccardCandidates(phrenPath, project, finding) {
23
23
  try {
24
24
  const findingsPath = path.join(phrenPath, project, "FINDINGS.md");
@@ -84,13 +84,45 @@ function withLifecycleMutation(phrenPath, project, writeQueue, updateIndex, hand
84
84
  });
85
85
  }
86
86
  // ── Handlers ─────────────────────────────────────────────────────────────────
87
- async function handleAddFinding(ctx, { project, finding, citation, sessionId, source, findingType, scope }) {
88
- const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
87
+ async function handleAddFinding(ctx, params) {
88
+ const { finding, citation, sessionId, source, findingType, scope } = params;
89
+ // Resolve store-qualified project names (e.g., "team/arc" → store path + "arc")
90
+ let phrenPath;
91
+ let project;
92
+ try {
93
+ const resolved = resolveStoreForProject(ctx, params.project);
94
+ phrenPath = resolved.phrenPath;
95
+ project = resolved.project;
96
+ }
97
+ catch (err) {
98
+ return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
99
+ }
100
+ const { withWriteQueue, updateFileInIndex } = ctx;
89
101
  if (!isValidProjectName(project))
90
102
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
91
103
  const addFindingDenied = permissionDeniedError(phrenPath, "add_finding", project);
92
104
  if (addFindingDenied)
93
105
  return mcpResponse({ ok: false, error: addFindingDenied });
106
+ // Team stores: use append-only journal (no FINDINGS.md mutation, no merge conflicts)
107
+ {
108
+ const storeResolved = resolveStoreForProject(ctx, params.project);
109
+ if (storeResolved.storeRole === "team") {
110
+ const { appendTeamJournal } = await import("../finding/journal.js");
111
+ const findings = Array.isArray(finding) ? finding : [finding];
112
+ const added = [];
113
+ for (const f of findings) {
114
+ const taggedFinding = findingType ? `[${findingType}] ${f}` : f;
115
+ const result = appendTeamJournal(phrenPath, project, taggedFinding);
116
+ if (result.ok)
117
+ added.push(taggedFinding);
118
+ }
119
+ return mcpResponse({
120
+ ok: added.length > 0,
121
+ message: `Added ${added.length} finding(s) to ${params.project} journal`,
122
+ data: { project: params.project, added, journalMode: true },
123
+ });
124
+ }
125
+ }
94
126
  if (Array.isArray(finding)) {
95
127
  const findings = finding;
96
128
  if (findings.length > 100)
@@ -458,8 +458,28 @@ async function handleSearchKnowledge(ctx, { query, limit, project, type, tag, si
458
458
  }
459
459
  }
460
460
  async function handleGetProjectSummary(ctx, { name }) {
461
+ // Support store-qualified names (e.g., "team/arc")
462
+ const { parseStoreQualified } = await import("../store-routing.js");
463
+ const { storeName, projectName } = parseStoreQualified(name);
464
+ const lookupName = projectName;
461
465
  const db = ctx.db();
462
- const docs = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [name]);
466
+ let docs = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [lookupName]);
467
+ // If not in primary index and store-qualified, try reading from the store's filesystem
468
+ if (!docs && storeName) {
469
+ const store = resolveAllStores(ctx.phrenPath).find((s) => s.name === storeName);
470
+ if (store && fs.existsSync(path.join(store.path, lookupName))) {
471
+ const projDir = path.join(store.path, lookupName);
472
+ const fsDocs = [];
473
+ for (const [file, type] of [["summary.md", "summary"], ["CLAUDE.md", "claude"], ["FINDINGS.md", "findings"], ["tasks.md", "task"]]) {
474
+ const filePath = path.join(projDir, file);
475
+ if (fs.existsSync(filePath)) {
476
+ fsDocs.push({ filename: file, type, content: fs.readFileSync(filePath, "utf8").slice(0, 8000), path: filePath });
477
+ }
478
+ }
479
+ if (fsDocs.length > 0)
480
+ docs = fsDocs;
481
+ }
482
+ }
463
483
  if (!docs) {
464
484
  const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
465
485
  const names = projectRows ? projectRows.map(row => decodeStringRow(row, 1, "get_project_summary.projects")[0]) : [];
@@ -494,42 +514,83 @@ async function handleGetProjectSummary(ctx, { name }) {
494
514
  async function handleListProjects(ctx, { page, page_size }) {
495
515
  const { phrenPath, profile } = ctx;
496
516
  const db = ctx.db();
517
+ // Gather projects from primary store index
497
518
  const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
498
- if (!projectRows)
519
+ const primaryProjects = projectRows
520
+ ? projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0])
521
+ : [];
522
+ // Gather projects from non-primary stores
523
+ const { getNonPrimaryStores } = await import("../store-registry.js");
524
+ const { getProjectDirs } = await import("../phren-paths.js");
525
+ const nonPrimaryStores = getNonPrimaryStores(phrenPath);
526
+ const storeProjects = [];
527
+ for (const store of nonPrimaryStores) {
528
+ if (!fs.existsSync(store.path))
529
+ continue;
530
+ const dirs = getProjectDirs(store.path);
531
+ for (const dir of dirs) {
532
+ const projName = path.basename(dir);
533
+ storeProjects.push({ name: projName, store: store.name });
534
+ }
535
+ }
536
+ // Combine: primary projects (no store prefix) + non-primary (with store prefix)
537
+ const allProjects = [
538
+ ...primaryProjects.map((p) => ({ name: p, store: undefined })),
539
+ ...storeProjects,
540
+ ];
541
+ if (allProjects.length === 0)
499
542
  return mcpResponse({ ok: true, message: "No projects indexed.", data: { projects: [], total: 0 } });
500
- const projects = projectRows.map(row => decodeStringRow(row, 1, "list_projects.projects")[0]);
501
543
  const pageSize = page_size ?? 20;
502
544
  const pageNum = page ?? 1;
503
545
  const start = Math.max(0, (pageNum - 1) * pageSize);
504
546
  const end = start + pageSize;
505
- const pageProjects = projects.slice(start, end);
506
- const totalPages = Math.max(1, Math.ceil(projects.length / pageSize));
547
+ const pageProjects = allProjects.slice(start, end);
548
+ const totalPages = Math.max(1, Math.ceil(allProjects.length / pageSize));
507
549
  if (pageNum > totalPages) {
508
550
  return mcpResponse({ ok: false, error: `Page ${pageNum} out of range. Total pages: ${totalPages}.` });
509
551
  }
510
552
  const badgeTypes = ["claude", "findings", "summary", "task"];
511
553
  const badgeLabels = { claude: "CLAUDE.md", findings: "FINDINGS", summary: "summary", task: "task" };
512
- const projectList = pageProjects.map((proj) => {
513
- const rows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [proj]) ?? [];
514
- const types = rows.map(row => row.type);
515
- const summaryRow = rows.find(row => row.type === "summary");
516
- const claudeRow = rows.find(row => row.type === "claude");
517
- const source = summaryRow?.content ?? claudeRow?.content;
518
- let brief = "";
519
- if (source) {
520
- const firstLine = source.split("\n").find(l => l.trim() && !l.startsWith("#"));
521
- brief = firstLine?.trim() || "";
554
+ const projectList = pageProjects.map((entry) => {
555
+ // Primary store projects: query the DB for badge info
556
+ if (!entry.store) {
557
+ const rows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE project = ?", [entry.name]) ?? [];
558
+ const types = rows.map(row => row.type);
559
+ const summaryRow = rows.find(row => row.type === "summary");
560
+ const claudeRow = rows.find(row => row.type === "claude");
561
+ const source = summaryRow?.content ?? claudeRow?.content;
562
+ let brief = "";
563
+ if (source) {
564
+ const firstLine = source.split("\n").find(l => l.trim() && !l.startsWith("#"));
565
+ brief = firstLine?.trim() || "";
566
+ }
567
+ const badges = badgeTypes.filter(t => types.includes(t)).map(t => badgeLabels[t]);
568
+ return { name: entry.name, store: undefined, brief, badges, fileCount: rows.length };
569
+ }
570
+ // Non-primary store projects: basic info (no DB query, just check file existence)
571
+ const store = nonPrimaryStores.find((s) => s.name === entry.store);
572
+ const projDir = store ? path.join(store.path, entry.name) : "";
573
+ const badges = [];
574
+ if (projDir) {
575
+ if (fs.existsSync(path.join(projDir, "CLAUDE.md")))
576
+ badges.push("CLAUDE.md");
577
+ if (fs.existsSync(path.join(projDir, "FINDINGS.md")))
578
+ badges.push("FINDINGS");
579
+ if (fs.existsSync(path.join(projDir, "summary.md")))
580
+ badges.push("summary");
581
+ if (fs.existsSync(path.join(projDir, "tasks.md")))
582
+ badges.push("task");
522
583
  }
523
- const badges = badgeTypes.filter(t => types.includes(t)).map(t => badgeLabels[t]);
524
- return { name: proj, brief, badges, fileCount: rows.length };
584
+ return { name: entry.name, store: entry.store, brief: "", badges, fileCount: badges.length };
525
585
  });
526
- const lines = [`# Phren Projects (${projects.length})`];
586
+ const lines = [`# Phren Projects (${allProjects.length})`];
527
587
  if (profile)
528
588
  lines.push(`Profile: ${profile}`);
529
589
  lines.push(`Page: ${pageNum}/${totalPages} (page_size=${pageSize})`);
530
590
  lines.push(`Path: ${phrenPath}\n`);
531
591
  for (const p of projectList) {
532
- lines.push(`## ${p.name}`);
592
+ const storeTag = p.store ? ` (${p.store})` : "";
593
+ lines.push(`## ${p.name}${storeTag}`);
533
594
  if (p.brief)
534
595
  lines.push(p.brief);
535
596
  lines.push(`[${p.badges.join(" | ")}] - ${p.fileCount} file(s)\n`);
@@ -537,7 +598,7 @@ async function handleListProjects(ctx, { page, page_size }) {
537
598
  return mcpResponse({
538
599
  ok: true,
539
600
  message: lines.join("\n"),
540
- data: { projects: projectList, total: projects.length, page: pageNum, totalPages, pageSize },
601
+ data: { projects: projectList, total: allProjects.length, page: pageNum, totalPages, pageSize },
541
602
  });
542
603
  }
543
604
  async function handleGetFindings(ctx, { project, limit, include_superseded, include_history, status }) {
@@ -18,6 +18,7 @@ import { listTaskCheckpoints, writeTaskCheckpoint } from "../session/checkpoints
18
18
  import { markImpactEntriesCompletedForSession } from "../finding/impact.js";
19
19
  import { atomicWriteJson, debugError, scanSessionFiles } from "../session/utils.js";
20
20
  import { getRuntimeHealth } from "../governance/policy.js";
21
+ import { getProjectSourcePath, readProjectConfig } from "../project-config.js";
21
22
  const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24 hours
22
23
  function collectGitStatusSnapshot(cwd) {
23
24
  try {
@@ -221,9 +222,11 @@ function cleanupStaleSessions(phrenPath) {
221
222
  // (no endedAt) should never be removed regardless of age.
222
223
  if (state && !state.endedAt)
223
224
  continue;
224
- // prefer startedAt from the JSON content over mtime (reliable on noatime mounts)
225
- const ageMs = state?.startedAt
226
- ? Date.now() - new Date(state.startedAt).getTime()
225
+ // For ended sessions, age out by end time rather than start time so
226
+ // long-running sessions do not disappear immediately after they finish.
227
+ const expirationAnchor = state?.endedAt || state?.startedAt;
228
+ const ageMs = expirationAnchor
229
+ ? Date.now() - new Date(expirationAnchor).getTime()
227
230
  : Date.now() - fs.statSync(fullPath).mtimeMs;
228
231
  if (ageMs > STALE_SESSION_MS) {
229
232
  fs.unlinkSync(fullPath);
@@ -607,7 +610,10 @@ export function register(server, ctx) {
607
610
  })();
608
611
  if (activeTask) {
609
612
  const taskId = activeTask.stableId || activeTask.id;
610
- const { gitStatus, editedFiles } = collectGitStatusSnapshot(process.cwd());
613
+ const projectConfig = readProjectConfig(phrenPath, endedState.project);
614
+ const snapshotRoot = getProjectSourcePath(phrenPath, endedState.project, projectConfig) ||
615
+ path.join(phrenPath, endedState.project);
616
+ const { gitStatus, editedFiles } = collectGitStatusSnapshot(snapshotRoot);
611
617
  const resumptionHint = extractResumptionHint(effectiveSummary, activeTask.line, activeTask.context || "No prior attempt captured");
612
618
  writeTaskCheckpoint(phrenPath, {
613
619
  project: endedState.project,
@@ -1,11 +1,11 @@
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";
5
5
  import { isValidProjectName } from "../utils.js";
6
6
  import { addTask as addTaskStore, addTasks as addTasksBatch, taskMarkdown, completeTask as completeTaskStore, completeTasks as completeTasksBatch, removeTask as removeTaskStore, removeTasks as removeTasksBatch, linkTaskIssue, pinTask, workNextTask, tidyDoneTasks, readTasks, readTasksAcrossProjects, resolveTaskItem, TASKS_FILENAME, updateTask as updateTaskStore, promoteTask, } from "../data/access.js";
7
7
  import { applyGravity } from "../data/tasks.js";
8
- import { parseGithubIssueUrl, } from "../task/github.js";
8
+ import { buildTaskIssueBody, createGithubIssueForTask, parseGithubIssueUrl, resolveProjectGithubRepo, } from "../task/github.js";
9
9
  import { clearTaskCheckpoint } from "../session/checkpoints.js";
10
10
  import { incrementSessionTasksCompleted } from "./session.js";
11
11
  import { normalizeMemoryScope } from "../shared.js";
@@ -207,10 +207,21 @@ export function register(server, ctx) {
207
207
  ]).describe("The task(s) to add. Pass a string for one task, or an array for bulk."),
208
208
  scope: z.string().optional().describe("Optional memory scope label. Defaults to 'shared'. Example: 'researcher' or 'builder'."),
209
209
  }),
210
- }, async ({ project, item, scope }) => {
210
+ }, async ({ project: projectInput, item, scope }) => {
211
+ // Resolve store-qualified project names (e.g., "team/arc")
212
+ let targetPhrenPath;
213
+ let project;
214
+ try {
215
+ const resolved = resolveStoreForProject(ctx, projectInput);
216
+ targetPhrenPath = resolved.phrenPath;
217
+ project = resolved.project;
218
+ }
219
+ catch (err) {
220
+ return mcpResponse({ ok: false, error: err instanceof Error ? err.message : String(err) });
221
+ }
211
222
  if (!isValidProjectName(project))
212
223
  return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
213
- const addTaskDenied = permissionDeniedError(phrenPath, "add_task", project);
224
+ const addTaskDenied = permissionDeniedError(targetPhrenPath, "add_task", project);
214
225
  if (addTaskDenied)
215
226
  return mcpResponse({ ok: false, error: addTaskDenied });
216
227
  const normalizedScope = normalizeMemoryScope(scope ?? "shared");
@@ -218,20 +229,20 @@ export function register(server, ctx) {
218
229
  return mcpResponse({ ok: false, error: `Invalid scope: "${scope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars), e.g. "researcher".` });
219
230
  if (Array.isArray(item)) {
220
231
  return withWriteQueue(async () => {
221
- const result = addTasksBatch(phrenPath, project, item, { scope: normalizedScope });
232
+ const result = addTasksBatch(targetPhrenPath, project, item, { scope: normalizedScope });
222
233
  if (!result.ok)
223
234
  return mcpResponse({ ok: false, error: result.error });
224
235
  const { added, errors } = result.data;
225
236
  if (added.length > 0)
226
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
237
+ refreshTaskIndex(updateFileInIndex, targetPhrenPath, project);
227
238
  return mcpResponse({ ok: added.length > 0, ...(added.length === 0 ? { error: `No tasks added: ${errors.join("; ")}` } : {}), message: `Added ${added.length} of ${item.length} tasks to ${project}`, data: { project, added, errors } });
228
239
  });
229
240
  }
230
241
  return withWriteQueue(async () => {
231
- const result = addTaskStore(phrenPath, project, item, { scope: normalizedScope });
242
+ const result = addTaskStore(targetPhrenPath, project, item, { scope: normalizedScope });
232
243
  if (!result.ok)
233
244
  return mcpResponse({ ok: false, error: result.error });
234
- refreshTaskIndex(updateFileInIndex, phrenPath, project);
245
+ refreshTaskIndex(updateFileInIndex, targetPhrenPath, project);
235
246
  return mcpResponse({ ok: true, message: `Task added: ${result.data.line}`, data: { project, item, scope: normalizedScope } });
236
247
  });
237
248
  });
@@ -355,6 +366,7 @@ export function register(server, ctx) {
355
366
  github_issue: z.union([z.number().int().positive(), z.string()]).optional().describe("GitHub issue number (for example 14 or '#14')."),
356
367
  github_url: z.string().optional().describe("GitHub issue URL to associate with the task item."),
357
368
  unlink_github: z.boolean().optional().describe("If true, remove any linked GitHub issue metadata from the item."),
369
+ create_issue: z.boolean().optional().describe("If true, create a GitHub issue for this task and link it."),
358
370
  pin: z.boolean().optional().describe("If true, pin the task so it floats to the top of its section."),
359
371
  promote: z.boolean().optional().describe("If true, clear the speculative flag on this task (confirm the user wants it)."),
360
372
  move_to_active: z.boolean().optional().describe("Used with promote: also move the task to the Active section."),
@@ -371,6 +383,25 @@ export function register(server, ctx) {
371
383
  if (!updates.work_next && !item) {
372
384
  return mcpResponse({ ok: false, error: "item is required unless updates.work_next is true." });
373
385
  }
386
+ if (updates.create_issue) {
387
+ const extraUpdates = [
388
+ updates.text,
389
+ updates.priority,
390
+ updates.context,
391
+ updates.section,
392
+ updates.github_issue,
393
+ updates.github_url,
394
+ updates.unlink_github,
395
+ updates.pin,
396
+ updates.promote,
397
+ updates.move_to_active,
398
+ updates.work_next,
399
+ updates.replace_context,
400
+ ].some((value) => value !== undefined);
401
+ if (extraUpdates) {
402
+ return mcpResponse({ ok: false, error: "create_issue must be used by itself." });
403
+ }
404
+ }
374
405
  // Cross-validate github_issue and github_url
375
406
  if (updates.github_url) {
376
407
  const parsed = parseGithubIssueUrl(updates.github_url);
@@ -412,6 +443,45 @@ export function register(server, ctx) {
412
443
  data: { project, item: result.data },
413
444
  });
414
445
  }
446
+ if (updates.create_issue) {
447
+ const resolved = resolveTaskItem(phrenPath, project, item);
448
+ if (!resolved.ok)
449
+ return mcpResponse({ ok: false, error: resolved.error });
450
+ const repo = resolveProjectGithubRepo(phrenPath, project);
451
+ if (!repo) {
452
+ return mcpResponse({
453
+ ok: false,
454
+ error: "Could not infer a GitHub repo. Add a GitHub URL to CLAUDE.md or summary.md, or link an existing issue instead.",
455
+ });
456
+ }
457
+ const created = createGithubIssueForTask({
458
+ repo,
459
+ title: resolved.data.line.replace(/\s*\[(high|medium|low)\]\s*$/i, "").trim(),
460
+ body: buildTaskIssueBody(project, resolved.data),
461
+ });
462
+ if (!created.ok)
463
+ return mcpResponse({ ok: false, error: created.error, errorCode: created.code });
464
+ const linked = linkTaskIssue(phrenPath, project, item, {
465
+ github_issue: created.data.issueNumber,
466
+ github_url: created.data.url,
467
+ });
468
+ if (!linked.ok)
469
+ return mcpResponse({ ok: false, error: linked.error, errorCode: linked.code });
470
+ refreshTaskIndex(updateFileInIndex, phrenPath, project);
471
+ return mcpResponse({
472
+ ok: true,
473
+ message: `Created GitHub issue ${created.data.issueNumber ? `#${created.data.issueNumber}` : created.data.url} for ${project} task.`,
474
+ data: {
475
+ project,
476
+ item,
477
+ issue_number: created.data.issueNumber ?? null,
478
+ issue_url: created.data.url,
479
+ githubIssue: linked.data.githubIssue ?? null,
480
+ githubUrl: linked.data.githubUrl || null,
481
+ stableId: linked.data.stableId || null,
482
+ },
483
+ });
484
+ }
415
485
  // Handle github issue linking via update_task when github_issue or github_url is set (and no other field updates)
416
486
  if ((updates.github_issue !== undefined || updates.github_url || updates.unlink_github) && !updates.text && !updates.priority && !updates.context && !updates.section) {
417
487
  if (updates.unlink_github && (updates.github_issue !== undefined || updates.github_url)) {
@@ -1,3 +1,25 @@
1
+ import { parseStoreQualified } from "../store-routing.js";
2
+ import { resolveAllStores } from "../store-registry.js";
3
+ /**
4
+ * Resolve the effective phrenPath and bare project name for a project input.
5
+ * Handles store-qualified names ("store/project") by routing to the correct store.
6
+ * Returns the primary store path for bare names.
7
+ */
8
+ export function resolveStoreForProject(ctx, projectInput) {
9
+ const { storeName, projectName } = parseStoreQualified(projectInput);
10
+ if (!storeName) {
11
+ return { phrenPath: ctx.phrenPath, project: projectName, storeRole: "primary" };
12
+ }
13
+ const stores = resolveAllStores(ctx.phrenPath);
14
+ const store = stores.find((s) => s.name === storeName);
15
+ if (!store) {
16
+ throw new Error(`Store "${storeName}" not found`);
17
+ }
18
+ if (store.role === "readonly") {
19
+ throw new Error(`Store "${storeName}" is read-only`);
20
+ }
21
+ return { phrenPath: store.path, project: projectName, storeRole: store.role };
22
+ }
1
23
  /**
2
24
  * Convert an McpToolResult into the MCP SDK response format.
3
25
  * Single shared implementation — replaces the per-file jsonResponse() duplicates.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/cli",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "description": "Knowledge layer for AI agents. Phren learns and recalls.",
5
5
  "type": "module",
6
6
  "bin": {