@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
574 lines (573 loc) • 27.2 kB
JavaScript
/**
* Project registration and scaffold shared by `phren add`, `phren init` and
* the phone Hook's "Add project". Kept free of the init walkthrough, the
* agent-hooks installer and the skill/provision machinery so the Hook does not
* have to bundle `phren init`.
*/
import * as fs from "fs";
import * as path from "path";
import { atomicWriteText, debugLog, findProjectNameCaseInsensitive, findProjectNamesByCanonicalKey, projectSlugFromPath, readRootManifest, } from "../phren-paths.js";
import { resolveWorktreeParent } from "../git-worktree.js";
import { moduleEnabled } from "../modules/runtime.js";
import { STOP_WORDS, errorMessage } from "../utils.js";
import { FINDINGS_FILENAME, TASKS_FILENAME } from "../filenames.js";
import { getProjectOwnershipDefault, getProjectSourcePath, parseProjectOwnershipMode, readProjectConfig, recordProjectSourcePath, } from "../project-config.js";
import { getBuiltinTopicConfig, normalizeBuiltinTopicDomain } from "../project-topics.js";
import { addProjectToProfile, resolveActiveProfile } from "../profile-store.js";
const DOMAIN_KEYWORD_HINTS = {
software: [
"api", "backend", "frontend", "typescript", "javascript", "python", "rust", "golang", "cli", "sdk", "library", "service",
"server", "database", "auth", "module", "package", "build", "test", "deploy",
],
music: [
"music", "audio", "mix", "master", "track", "daw", "synth", "midi", "song", "composition", "arrangement", "producer",
],
game: [
"game", "gameplay", "level", "shader", "physics", "npc", "engine", "unity", "godot", "unreal", "sprite", "multiplayer",
],
research: [
"research", "paper", "study", "experiment", "dataset", "analysis", "methodology", "hypothesis", "results", "evaluation",
],
writing: [
"writing", "manuscript", "chapter", "outline", "narrative", "character", "plot", "draft", "editorial",
],
creative: [
"creative", "story", "design", "worldbuilding", "script", "concept", "illustration", "art direction",
],
};
const DOMAIN_CONFIG_HINTS = {
"package.json": { software: 3 },
"tsconfig.json": { software: 3 },
"Cargo.toml": { software: 4, game: 1 },
"pyproject.toml": { software: 3, research: 1 },
"requirements.txt": { software: 2, research: 1 },
"go.mod": { software: 3 },
"CMakeLists.txt": { software: 3, game: 1 },
"pom.xml": { software: 3 },
"build.gradle": { software: 3 },
"project.godot": { game: 5 },
".uproject": { game: 5 },
"paper.tex": { research: 4, writing: 1 },
"references.bib": { research: 4 },
};
const EXTENSION_DOMAIN_HINTS = {
".ts": { software: 1 },
".tsx": { software: 1, game: 1 },
".js": { software: 1 },
".jsx": { software: 1 },
".py": { software: 1, research: 1 },
".rs": { software: 1, game: 1 },
".go": { software: 1 },
".java": { software: 1 },
".kt": { software: 1 },
".swift": { software: 1 },
".c": { software: 1, game: 1 },
".cc": { software: 1, game: 1 },
".cpp": { software: 1, game: 1 },
".h": { software: 1, game: 1 },
".hpp": { software: 1, game: 1 },
".cs": { software: 1, game: 1 },
".ipynb": { research: 2 },
".tex": { research: 2, writing: 1 },
".bib": { research: 2 },
".wav": { music: 2 },
".mp3": { music: 2 },
".flac": { music: 2 },
".mid": { music: 2 },
".midi": { music: 2 },
".als": { music: 2 },
".logicx": { music: 2 },
".unity": { game: 2 },
".gd": { game: 2 },
".glsl": { game: 2 },
};
function titleCase(text) {
return text
.split(/[\s_-]+/)
.filter(Boolean)
.map((token) => token.slice(0, 1).toUpperCase() + token.slice(1))
.join(" ");
}
function addTermCount(terms, rawText, weight = 1) {
const tokens = rawText
.toLowerCase()
.replace(/[^\w\s-]/g, " ")
.split(/\s+/)
.map((token) => token.trim())
.filter((token) => token.length >= 3 && token.length <= 48 && !STOP_WORDS.has(token));
for (const token of tokens) {
terms.set(token, (terms.get(token) ?? 0) + weight);
}
}
function maybeReadUtf8(filePath, maxBytes = 256_000) {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size > maxBytes)
return "";
return fs.readFileSync(filePath, "utf8");
}
catch {
return "";
}
}
function addDomainScores(target, patch, weight = 1) {
for (const [domain, score] of Object.entries(patch)) {
const typedDomain = domain;
target[typedDomain] += (score ?? 0) * weight;
}
}
function scoreTopicsFromTerms(domain, terms, docsText) {
const baseTopics = getBuiltinTopicConfig(domain);
const scored = baseTopics
.filter((topic) => topic.name.toLowerCase() !== "general")
.map((topic) => {
const baseTerm = topic.name.toLowerCase();
let score = terms.get(baseTerm) ?? 0;
for (const keyword of topic.keywords) {
const normalized = keyword.toLowerCase().trim();
if (!normalized)
continue;
score += terms.get(normalized) ?? 0;
if (normalized.includes(" ") && docsText.includes(normalized))
score += 1;
}
return { topic, score };
});
const ranked = scored
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || a.topic.name.localeCompare(b.topic.name));
const takenNames = new Set(baseTopics.map((topic) => topic.name.toLowerCase()));
const customTopics = [];
for (const [term, count] of [...terms.entries()].sort((a, b) => b[1] - a[1])) {
if (customTopics.length >= 4)
break;
if (count < 3)
break;
if (term.includes("_"))
continue;
const topicName = titleCase(term);
const normalizedName = topicName.toLowerCase();
if (takenNames.has(normalizedName))
continue;
if (DOMAIN_KEYWORD_HINTS.software.includes(term) || DOMAIN_KEYWORD_HINTS.music.includes(term) || DOMAIN_KEYWORD_HINTS.game.includes(term)) {
continue;
}
takenNames.add(normalizedName);
customTopics.push({
name: topicName,
description: "Suggested from repeated terminology in project docs.",
keywords: [term],
});
}
if (ranked.length === 0 && customTopics.length === 0)
return baseTopics;
const orderedBase = [
...ranked.map((entry) => entry.topic),
...baseTopics.filter((topic) => topic.name.toLowerCase() !== "general"
&& !ranked.some((entry) => entry.topic.name === topic.name)),
];
const topics = [...orderedBase.slice(0, 8), ...customTopics];
if (!topics.some((topic) => topic.name.toLowerCase() === "general")) {
topics.push({ name: "General", description: "Fallback bucket for uncategorized findings.", keywords: [] });
}
return topics;
}
export function inferInitScaffoldFromRepo(repoRoot, fallbackDomain = "software") {
const resolvedRoot = path.resolve(repoRoot);
if (!fs.existsSync(resolvedRoot))
return null;
const signal = {
domainScores: { software: 0, music: 0, game: 0, research: 0, writing: 0, creative: 0, other: 0 },
terms: new Map(),
docsText: "",
referenceHints: [],
commandHints: [],
usefulSignals: 0,
};
const skipDirs = new Set([".git", ".phren", "node_modules", "dist", "build", "coverage", ".next", ".turbo", "target"]);
const packageJsonPath = path.join(resolvedRoot, "package.json");
if (fs.existsSync(packageJsonPath)) {
signal.usefulSignals++;
addDomainScores(signal.domainScores, DOMAIN_CONFIG_HINTS["package.json"]);
try {
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
if (typeof parsed.description === "string") {
addTermCount(signal.terms, parsed.description, 3);
signal.docsText += ` ${parsed.description.toLowerCase()}`;
}
if (Array.isArray(parsed.keywords)) {
for (const keyword of parsed.keywords) {
if (typeof keyword === "string") {
addTermCount(signal.terms, keyword, 3);
signal.docsText += ` ${keyword.toLowerCase()}`;
}
}
}
if (parsed.scripts && typeof parsed.scripts === "object" && !Array.isArray(parsed.scripts)) {
const scriptObject = parsed.scripts;
for (const scriptName of ["dev", "start", "build", "test", "lint"]) {
if (typeof scriptObject[scriptName] === "string") {
signal.commandHints.push(`npm run ${scriptName}`);
}
}
}
}
catch (err) {
debugLog(`inferInitScaffoldFromRepo package.json parse failed: ${errorMessage(err)}`);
}
}
const topLevelConfigs = Object.keys(DOMAIN_CONFIG_HINTS)
.filter((fileName) => fs.existsSync(path.join(resolvedRoot, fileName)));
for (const configName of topLevelConfigs) {
signal.usefulSignals++;
const configScore = DOMAIN_CONFIG_HINTS[configName];
if (configScore)
addDomainScores(signal.domainScores, configScore);
}
const readmeCandidates = [
path.join(resolvedRoot, "README.md"),
path.join(resolvedRoot, "readme.md"),
];
for (const readmePath of readmeCandidates) {
if (!fs.existsSync(readmePath))
continue;
const content = maybeReadUtf8(readmePath);
if (!content)
continue;
signal.usefulSignals++;
signal.referenceHints.push(path.relative(resolvedRoot, readmePath));
addTermCount(signal.terms, content, 2);
signal.docsText += ` ${content.toLowerCase()}`;
break;
}
const docsDir = path.join(resolvedRoot, "docs");
if (fs.existsSync(docsDir) && fs.statSync(docsDir).isDirectory()) {
signal.referenceHints.push("docs/");
for (const entry of fs.readdirSync(docsDir, { withFileTypes: true })) {
if (!entry.isFile())
continue;
if (!/\.(md|txt|rst)$/i.test(entry.name))
continue;
const docsContent = maybeReadUtf8(path.join(docsDir, entry.name));
if (!docsContent)
continue;
signal.usefulSignals++;
addTermCount(signal.terms, docsContent, 1);
signal.docsText += ` ${docsContent.toLowerCase()}`;
}
}
for (const folderName of ["reference", "specs", "design", "architecture", "src", "packages", "apps"]) {
const fullPath = path.join(resolvedRoot, folderName);
if (fs.existsSync(fullPath)) {
signal.referenceHints.push(`${folderName}/`);
}
}
let scannedFiles = 0;
const maxFiles = 3000;
const walk = (dir) => {
if (scannedFiles >= maxFiles)
return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (scannedFiles >= maxFiles)
return;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (skipDirs.has(entry.name))
continue;
walk(fullPath);
continue;
}
scannedFiles++;
const ext = path.extname(entry.name).toLowerCase();
if (!ext)
continue;
const extScore = EXTENSION_DOMAIN_HINTS[ext];
if (extScore) {
addDomainScores(signal.domainScores, extScore);
signal.usefulSignals++;
}
}
};
walk(resolvedRoot);
for (const [domain, hints] of Object.entries(DOMAIN_KEYWORD_HINTS)) {
for (const hint of hints) {
const hitCount = signal.terms.get(hint) ?? 0;
if (hitCount > 0) {
signal.domainScores[domain] += Math.min(4, hitCount);
signal.usefulSignals++;
}
}
}
const rankedDomains = Object.entries(signal.domainScores)
.sort((a, b) => b[1] - a[1]);
const [bestDomain, bestScore] = rankedDomains[0] ?? [fallbackDomain, 0];
const secondScore = rankedDomains[1]?.[1] ?? 0;
const inferredDomain = bestScore >= 2 ? bestDomain : fallbackDomain;
const confidence = bestScore <= 0
? 0
: Math.max(0.15, Math.min(0.98, (bestScore - secondScore + 1) / (bestScore + 2)));
const topics = scoreTopicsFromTerms(inferredDomain, signal.terms, signal.docsText);
const references = Array.from(new Set(signal.referenceHints)).slice(0, 8);
const commands = Array.from(new Set(signal.commandHints)).slice(0, 5);
const reason = bestScore > 0
? `inferred from repo files, config, and docs terminology (score ${bestScore})`
: "fallback defaults";
if (signal.usefulSignals === 0)
return null;
return {
domain: inferredDomain,
topics: topics.length > 0 ? topics : getBuiltinTopicConfig(inferredDomain),
referenceHints: references,
commandHints: commands,
confidence: Number(confidence.toFixed(2)),
reason,
};
}
function appendInferredSections(base, inference) {
if (!inference)
return base;
const lines = [];
if (inference.referenceHints.length > 0) {
lines.push("## Reference Structure");
for (const hint of inference.referenceHints) {
lines.push(`- ${hint}`);
}
lines.push("");
}
if (inference.topics.length > 0) {
lines.push("## Initial Focus Topics");
for (const topic of inference.topics.slice(0, 6)) {
lines.push(`- ${topic.name}: ${topic.description}`);
}
lines.push("");
}
if (inference.commandHints.length > 0) {
lines.push("## Commands");
lines.push("```bash");
for (const cmd of inference.commandHints)
lines.push(cmd);
lines.push("```");
lines.push("");
}
return lines.length > 0 ? `${base.trimEnd()}\n\n${lines.join("\n")}` : base;
}
function getDomainClaudeTemplate(projectName, domain, inference) {
if (domain === "software") {
return appendInferredSections(`# ${projectName}\n\nOne paragraph about what this project is.\n\n## Commands\n\n\`\`\`bash\n# Install:\n# Run:\n# Test:\n\`\`\`\n`, inference);
}
if (domain === "music") {
return appendInferredSections(`# ${projectName}\n\nThis is a music project. Keep notes on composition intent, arrangement choices, production workflow, and mixing/mastering decisions.\n\n## Session Focus\n\n- Capture creative intent before technical tweaks\n- Track instrument/sound-design decisions and why\n- Log mix/master changes with listening context\n`, inference);
}
if (domain === "game") {
return appendInferredSections(`# ${projectName}\n\nThis is a game project. Prioritize clear notes on mechanics, rendering/performance tradeoffs, level and UI decisions, and iteration outcomes.\n\n## Development Focus\n\n- Record gameplay/mechanics decisions with player impact\n- Track rendering/physics/AI issues with repro context\n- Note level-design and networking constraints early\n`, inference);
}
if (domain === "research") {
return appendInferredSections(`# ${projectName}\n\nThis is a research project. Focus on methodology, source quality, analysis assumptions, and review feedback loops.\n\n## Working Approach\n\n- Document hypotheses and evaluation criteria explicitly\n- Track source provenance and confidence level\n- Record analysis decisions and revision rationale\n`, inference);
}
if (domain === "writing" || domain === "creative") {
return appendInferredSections(`# ${projectName}\n\nThis is a creative writing project. Track worldbuilding rules, character arcs, plot structure, style constraints, and revision decisions.\n\n## Writing Workflow\n\n- Keep narrative intent and tone constraints visible\n- Capture character/plot changes with consequences\n- Log revision notes and unresolved questions\n`, inference);
}
return appendInferredSections(`# ${projectName}\n\nThis project is not software-first. Keep practical notes, references, and task decisions so future sessions can resume quickly.\n\n## Workflow\n\n- Capture non-obvious lessons and reusable patterns\n- Keep references curated and current\n- Track active tasks and follow-ups\n`, inference);
}
export function ensureProjectScaffold(projectDir, projectName, domain = "software", inference) {
const normalizedDomain = normalizeBuiltinTopicDomain(inference?.domain ?? domain);
const inferredTopics = Array.isArray(inference?.topics) && inference.topics.length > 0
? inference.topics
: getBuiltinTopicConfig(normalizedDomain);
fs.mkdirSync(projectDir, { recursive: true });
if (!fs.existsSync(path.join(projectDir, "summary.md"))) {
atomicWriteText(path.join(projectDir, "summary.md"), `# ${projectName}\n\n**What:** Replace this with one sentence about what the project does\n**Stack:** The key tech\n**Status:** active\n**Run:** the command you use most\n**Watch out:** the one thing that will bite you if you forget\n`);
}
if (!fs.existsSync(path.join(projectDir, "AGENTS.md"))) {
atomicWriteText(path.join(projectDir, "AGENTS.md"), getDomainClaudeTemplate(projectName, inference?.domain ?? domain, inference));
}
if (!fs.existsSync(path.join(projectDir, "topic-config.json"))) {
atomicWriteText(path.join(projectDir, "topic-config.json"), JSON.stringify({ version: 1, domain: normalizedDomain, topics: inferredTopics }, null, 2) + "\n");
}
if (!fs.existsSync(path.join(projectDir, FINDINGS_FILENAME))) {
atomicWriteText(path.join(projectDir, FINDINGS_FILENAME), `# ${projectName} FINDINGS\n\n<!-- Findings are captured automatically during sessions and committed on exit -->\n`);
}
if (moduleEnabled(path.dirname(projectDir), "tasks") && !fs.existsSync(path.join(projectDir, TASKS_FILENAME))) {
atomicWriteText(path.join(projectDir, TASKS_FILENAME), `# ${projectName} tasks\n\n## Active\n\n## Queue\n\n## Done\n`);
}
}
/** Bootstrap a phren project from an existing project directory with AGENTS.md.
* @param profile - if provided, only this profile YAML is updated (avoids leaking project to unrelated profiles).
*/
/**
* Find an already-registered project that `sourceRoot` should reuse rather than
* getting a near-duplicate directory of its own. Two signals, strongest first:
*
* 1. **Same source path.** Another project already points at this exact
* directory — unambiguous, reuse it whatever it is called.
* 2. **Same canonical slug.** `max4liveplugins` vs `max4live-plugins`: the same
* repo spelled two ways. Only reused when the existing project has no source
* path recorded, or records this one — if it points somewhere else it is a
* genuinely different project that merely slugs alike, and both are kept.
*
* Returns `null` when nothing matches, i.e. create the project normally.
*/
function findExistingProjectForSource(phrenPath, sourceRoot, derivedName) {
let candidates;
try {
candidates = fs.readdirSync(phrenPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => entry.name);
}
catch (err) {
debugLog(`findExistingProjectForSource readdir: ${errorMessage(err)}`);
return null;
}
const sourceOf = (project) => {
try {
return getProjectSourcePath(phrenPath, project) ?? null;
}
catch (err) {
debugLog(`findExistingProjectForSource config ${project}: ${errorMessage(err)}`);
return null;
}
};
const resolvedSource = path.resolve(sourceRoot);
for (const project of candidates) {
if (sourceOf(project) === resolvedSource)
return project;
}
for (const project of findProjectNamesByCanonicalKey(phrenPath, derivedName)) {
if (project === derivedName)
return project; // exact match; nothing to reconcile
const existingSource = sourceOf(project);
if (existingSource === null || existingSource === resolvedSource)
return project;
}
return null;
}
export function bootstrapFromExisting(phrenPath, projectPath, opts = {}) {
const profile = typeof opts === "string" ? opts : opts.profile;
const profilePhrenPath = typeof opts === "string" ? phrenPath : (opts.profilePhrenPath ?? phrenPath);
const resolvedPath = path.resolve(projectPath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Path does not exist: ${resolvedPath}`);
}
const manifest = readRootManifest(phrenPath);
const isProjectLocal = manifest?.installMode === "project-local";
// A git worktree — notably the agent-managed ones under `.claude/worktrees/`
// — is a checkout of a repository we may already track. Attribute it to that
// repository instead of registering the worktree's throwaway codename as a
// brand-new top-level project.
const worktree = isProjectLocal ? null : resolveWorktreeParent(resolvedPath);
const worktreeRepoRoot = worktree && fs.existsSync(worktree.repoRoot) ? worktree.repoRoot : null;
if (worktreeRepoRoot) {
debugLog(`bootstrapFromExisting: ${resolvedPath} is a ${worktree.reason}; attributing to ${worktreeRepoRoot}`);
}
const sourceRoot = isProjectLocal
? path.resolve(manifest.workspaceRoot || resolvedPath)
: (worktreeRepoRoot ?? resolvedPath);
if (isProjectLocal) {
const matchesWorkspace = resolvedPath === sourceRoot || resolvedPath.startsWith(sourceRoot + path.sep);
if (!matchesWorkspace) {
throw new Error(`Project-local phren can only enroll the owning workspace: ${sourceRoot}`);
}
}
let claudeMdPath = null;
const candidates = [
path.join(sourceRoot, "AGENTS.md"),
path.join(sourceRoot, ".claude", "CLAUDE.md"),
];
for (const c of candidates) {
if (fs.existsSync(c)) {
claudeMdPath = c;
break;
}
}
const claudeContent = claudeMdPath ? fs.readFileSync(claudeMdPath, "utf8") : null;
const derivedName = isProjectLocal
? String(manifest?.primaryProject)
: projectSlugFromPath(sourceRoot);
const projectName = isProjectLocal
? derivedName
: (findExistingProjectForSource(phrenPath, sourceRoot, derivedName) ?? derivedName);
if (projectName !== derivedName) {
debugLog(`bootstrapFromExisting: reusing existing project "${projectName}" for ${sourceRoot} (derived "${derivedName}")`);
}
const existingProject = findProjectNameCaseInsensitive(phrenPath, projectName);
if (existingProject && existingProject !== projectName) {
throw new Error(`Project "${existingProject}" already exists with different casing. Refusing to bootstrap "${projectName}" because it would split the same project on case-sensitive filesystems.`);
}
const projDir = path.join(phrenPath, projectName);
fs.mkdirSync(projDir, { recursive: true });
const inferredScaffold = inferInitScaffoldFromRepo(sourceRoot);
const existingConfig = readProjectConfig(phrenPath, projectName);
const ownership = typeof opts === "string"
? (parseProjectOwnershipMode(existingConfig.ownership) ?? getProjectOwnershipDefault(phrenPath))
: (opts.ownership ?? parseProjectOwnershipMode(existingConfig.ownership) ?? getProjectOwnershipDefault(phrenPath));
const claudePath = path.join(projDir, "AGENTS.md");
if (ownership !== "repo-managed") {
if (claudeContent) {
if (!fs.existsSync(claudePath)) {
atomicWriteText(claudePath, claudeContent);
}
}
else {
// No AGENTS.md found — create a starter one
if (!fs.existsSync(claudePath)) {
atomicWriteText(claudePath, getDomainClaudeTemplate(projectName, inferredScaffold?.domain ?? "software", inferredScaffold));
}
}
}
const summaryLines = [];
if (claudeContent) {
const lines = claudeContent.split("\n");
let foundHeading = false;
for (const line of lines) {
if (line.startsWith("# ") && !foundHeading) {
foundHeading = true;
summaryLines.push(line);
continue;
}
if (foundHeading && line.trim() === "") {
if (summaryLines.length > 1)
break;
continue;
}
if (foundHeading && summaryLines.length < 10) {
summaryLines.push(line);
}
}
}
const sourceInfo = claudeMdPath ? `**Source AGENTS.md:** ${claudeMdPath}` : `**Source:** ${sourceRoot}`;
const summaryPath = path.join(projDir, "summary.md");
if (!fs.existsSync(summaryPath)) {
atomicWriteText(summaryPath, `# ${projectName}\n\n**What:** Bootstrapped from ${sourceRoot}\n${sourceInfo}\n\n${summaryLines.length > 1 ? summaryLines.slice(1).join("\n") : ""}\n`);
}
if (!fs.existsSync(path.join(projDir, FINDINGS_FILENAME))) {
atomicWriteText(path.join(projDir, FINDINGS_FILENAME), `# ${projectName} FINDINGS\n\n<!-- Bootstrapped from ${sourceRoot} -->\n`);
}
if (moduleEnabled(phrenPath, "tasks") && !fs.existsSync(path.join(projDir, TASKS_FILENAME))) {
atomicWriteText(path.join(projDir, TASKS_FILENAME), `# ${projectName} tasks\n\n## Active\n\n## Queue\n\n## Done\n`);
}
if (!fs.existsSync(path.join(projDir, "topic-config.json"))) {
const inferredDomain = normalizeBuiltinTopicDomain(inferredScaffold?.domain ?? "software");
const inferredTopics = inferredScaffold?.topics?.length
? inferredScaffold.topics
: getBuiltinTopicConfig(inferredDomain);
atomicWriteText(path.join(projDir, "topic-config.json"), JSON.stringify({ version: 1, domain: inferredDomain, topics: inferredTopics }, null, 2) + "\n");
}
const activeProfile = resolveActiveProfile(profilePhrenPath, profile);
if (activeProfile.ok && activeProfile.data) {
const addResult = addProjectToProfile(profilePhrenPath, activeProfile.data, projectName);
if (!addResult.ok) {
throw new Error(addResult.error);
}
}
else if (!activeProfile.ok && activeProfile.code !== "FILE_NOT_FOUND") {
throw new Error(activeProfile.error);
}
recordProjectSourcePath(phrenPath, projectName, sourceRoot, { ownership });
return {
project: projectName,
ownership,
claudePath: ownership === "repo-managed"
? (claudeMdPath ?? null)
: (fs.existsSync(claudePath) ? claudePath : null),
};
}