@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
302 lines (301 loc) • 14.4 kB
JavaScript
import { moduleEnabled } from "../modules/runtime.js";
/**
* Session start hook handler and onboarding notices.
* Extracted from hooks-session.ts for modularity.
*/
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { buildHookContext, handleGuardSkip, debugLog, appendAuditLog, runtimeFile, sessionMarker, getProjectDirs, findProjectNameCaseInsensitive, projectSlugFromPath, updateRuntimeHealth, withFileLock, detectProject, isProjectHookEnabled, readProjectConfig, getProjectSourcePath, detectProjectDir, ensureLocalGitRepo, isProjectTracked, repairPreexistingInstall, errorMessage, getPhrenPath, resolveRuntimeProfile, } from "./hooks-context.js";
import { TASKS_FILENAME } from "../data/tasks.js";
import { FINDINGS_FILENAME } from "../data/access.js";
import { readInstallPreferences } from "../init/preferences.js";
import { logger } from "../logger.js";
import { sessionFileForId, readSessionStateFile, writeSessionStateFile, } from "../session/utils.js";
import { runBestEffortGit, countUnsyncedCommits, pullAtSessionStart } from "./session-git.js";
import { scheduleBackgroundMaintenance } from "./session-background.js";
import { runDoctor } from "./hooks-context.js";
import { readClaudeHookPayload, registerUnmanagedClaude } from "./claude-unmanaged.js";
const SESSION_START_ONBOARDING_MARKER = "session-start-onboarding-v1";
const SYNC_WARN_MARKER = "sync-broken-warned-v1";
function projectHasBootstrapSignals(phrenPath, project) {
const projectDir = path.join(phrenPath, project);
const findingsPath = path.join(projectDir, FINDINGS_FILENAME);
if (fs.existsSync(findingsPath)) {
const findings = fs.readFileSync(findingsPath, "utf8");
if (/^-\s+/m.test(findings))
return true;
}
const tasksPath = path.join(projectDir, TASKS_FILENAME);
if (moduleEnabled(path.dirname(projectDir), "tasks") && fs.existsSync(tasksPath)) {
const tasks = fs.readFileSync(tasksPath, "utf8");
if (/^-\s+\[(?: |x|X)\]/m.test(tasks))
return true;
}
return false;
}
export function getUntrackedProjectNotice(phrenPath, cwd) {
const profile = resolveRuntimeProfile(phrenPath);
const projectDir = detectProjectDir(cwd, phrenPath);
if (!projectDir)
return null;
const activeProfile = profile || undefined;
// Check the exact current working directory against projects in the active profile.
// This avoids prompting when cwd is already inside a tracked sourcePath.
if (detectProject(phrenPath, cwd, activeProfile))
return null;
if (detectProject(phrenPath, projectDir, activeProfile))
return null;
const projectName = projectSlugFromPath(projectDir);
if (isProjectTracked(phrenPath, projectName, activeProfile)) {
const trackedName = getProjectDirs(phrenPath, activeProfile)
.map((dir) => path.basename(dir))
.find((name) => name.toLowerCase() === projectName)
|| findProjectNameCaseInsensitive(phrenPath, projectName)
|| projectName;
const config = readProjectConfig(phrenPath, trackedName);
const sourcePath = getProjectSourcePath(phrenPath, trackedName, config);
if (!sourcePath)
return null;
const resolvedProjectDir = path.resolve(projectDir);
const sameSource = resolvedProjectDir === sourcePath || resolvedProjectDir.startsWith(sourcePath + path.sep);
if (sameSource)
return null;
}
return [
"<phren-notice>",
"This project directory is not tracked by phren yet.",
"Run `phren add` to track it now.",
`Suggested command: \`phren add "${projectDir}"\``,
"Ask the user whether they want to add it to phren now.",
"If they say no, tell them they can always run `phren add` later.",
"If they say yes, also ask whether phren should manage repo instruction files or leave their existing repo-owned CLAUDE/AGENTS files alone.",
`Then use the \`add_project\` MCP tool with path="${projectDir}" and ownership="phren-managed"|"detached"|"repo-managed", or run \`phren add\` from that directory.`,
"After onboarding, run `phren doctor` if hooks or MCP tools are not responding.",
"<phren-notice>",
"",
].join("\n");
}
export function getSessionStartOnboardingNotice(phrenPath, cwd, activeProject) {
const markerPath = sessionMarker(phrenPath, SESSION_START_ONBOARDING_MARKER);
if (fs.existsSync(markerPath))
return null;
if (getUntrackedProjectNotice(phrenPath, cwd))
return null;
const profile = resolveRuntimeProfile(phrenPath);
const trackedProjects = getProjectDirs(phrenPath, profile).filter((dir) => path.basename(dir) !== "global");
if (trackedProjects.length === 0) {
return [
"<phren-notice>",
"Phren onboarding: no tracked projects are active for this workspace yet.",
"Start in a project repo and run `phren add` so SessionStart can inject project context.",
"Run `phren doctor` to verify hooks and MCP wiring after setup.",
"<phren-notice>",
"",
].join("\n");
}
if (!activeProject)
return null;
if (projectHasBootstrapSignals(phrenPath, activeProject))
return null;
return [
"<phren-notice>",
`Phren onboarding: project "${activeProject}" is tracked but memory is still empty.`,
"Capture one finding with `add_finding` and one task with `add_task` to seed future SessionStart context.",
"Run `phren doctor` if setup seems incomplete.",
"<phren-notice>",
"",
].join("\n");
}
export async function handleHookSessionStart() {
const payload = readClaudeHookPayload();
// A hand-rolled claude -p worker registers under its launcher, then gets the usual context.
try {
registerUnmanagedClaude(payload, getPhrenPath(), process.cwd());
}
catch (err) {
debugLog(`hook-session-start worker registration failed: ${errorMessage(err)}`);
}
const startedAt = new Date().toISOString();
const ctx = buildHookContext();
const { phrenPath, cwd, activeProject, manifest } = ctx;
// Check common guards (hooks enabled, tool enabled)
if (!ctx.hooksEnabled) {
handleGuardSkip(ctx, "hook_session_start", "disabled", { lastSessionStartAt: startedAt });
return;
}
if (!ctx.toolHookEnabled) {
handleGuardSkip(ctx, "hook_session_start", `tool_disabled tool=${ctx.hookTool}`);
return;
}
try {
repairPreexistingInstall(phrenPath);
}
catch (err) {
debugLog(`hook-session-start repair failed: ${errorMessage(err)}`);
}
if (!isProjectHookEnabled(phrenPath, activeProject, "SessionStart")) {
handleGuardSkip(ctx, "hook_session_start", `project_disabled project=${activeProject}`, { lastSessionStartAt: startedAt });
return;
}
if (manifest?.installMode === "project-local") {
updateRuntimeHealth(phrenPath, {
lastSessionStartAt: startedAt,
lastSync: {
lastPullAt: startedAt,
lastPullStatus: "ok",
lastPullDetail: "project-local mode does not manage git sync",
},
});
appendAuditLog(phrenPath, "hook_session_start", "status=skipped-local");
return;
}
const gitRepo = ensureLocalGitRepo(phrenPath);
const remotes = gitRepo.ok ? await runBestEffortGit(["remote"], phrenPath) : { ok: false, error: gitRepo.detail };
const hasRemote = Boolean(remotes.ok && remotes.output && remotes.output.trim());
const pull = !gitRepo.ok
? { ok: false, error: gitRepo.detail }
: hasRemote
? await pullAtSessionStart(phrenPath)
: {
ok: true,
output: gitRepo.initialized
? "initialized local git repo; no remote configured"
: "local-only repo; no remote configured",
};
const doctor = await runDoctor(phrenPath, false);
const maintenanceScheduled = scheduleBackgroundMaintenance(phrenPath);
const unsyncedCommits = hasRemote ? await countUnsyncedCommits(phrenPath) : 0;
try {
const { trackSession } = await import("../telemetry.js");
trackSession(phrenPath);
}
catch (err) {
logger.debug("hooks-session", `hookSessionStart trackSession: ${errorMessage(err)}`);
}
updateRuntimeHealth(phrenPath, {
lastSessionStartAt: startedAt,
lastSync: {
lastPullAt: startedAt,
lastPullStatus: pull.ok ? "ok" : "error",
lastPullDetail: pull.ok ? (pull.output || "pull ok") : (pull.error || "pull failed"),
lastSuccessfulPullAt: pull.ok && hasRemote ? startedAt : undefined,
unsyncedCommits,
...("counts" in pull && pull.counts ? pull.counts : {}),
},
});
appendAuditLog(phrenPath, "hook_session_start", `pull=${hasRemote ? (pull.ok ? "ok" : "fail") : "skipped-local"} doctor=${doctor.ok ? "ok" : "issues"} maintenance=${maintenanceScheduled ? "scheduled" : "skipped"}`);
// Pull non-primary stores from store registry (best-effort, non-blocking)
try {
const { getNonPrimaryStores } = await import("../store-registry.js");
const otherStores = getNonPrimaryStores(phrenPath);
for (const store of otherStores) {
if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git")))
continue;
try {
await pullAtSessionStart(store.path);
}
catch (err) {
debugLog(`session-start store-pull ${store.name}: ${errorMessage(err)}`);
}
}
}
catch {
// store-registry not available or no stores — skip silently
}
// Sync intent warning: if the user intended sync but remote is missing or pull failed, warn once
try {
const syncPrefs = readInstallPreferences(phrenPath);
const syncBroken = syncPrefs.syncIntent === "sync" && (!hasRemote || !pull.ok);
if (syncBroken) {
const syncWarnPath = sessionMarker(phrenPath, SYNC_WARN_MARKER);
if (!fs.existsSync(syncWarnPath)) {
const reason = !hasRemote
? "no git remote is connected"
: `pull failed: ${pull.error || "unknown error"}`;
process.stdout.write([
"<phren-notice>",
`Sync is configured but ${reason}. Your phren data is local-only.`,
`To fix: cd ${phrenPath} && git remote add origin <YOUR_REPO_URL> && git push -u origin main`,
"<phren-notice>",
"",
].join("\n"));
try {
fs.writeFileSync(syncWarnPath, `${startedAt}\n`);
}
catch (err) {
debugLog(`sync-warn marker write failed: ${errorMessage(err)}`);
}
}
}
}
catch (err) {
debugLog(`sync-intent check failed: ${errorMessage(err)}`);
}
// Untracked project detection: suggest `phren add` if CWD looks like a project but isn't tracked
try {
const notice = getUntrackedProjectNotice(phrenPath, cwd);
if (notice) {
process.stdout.write(notice);
debugLog(`untracked project detected at ${cwd}`);
}
const onboarding = getSessionStartOnboardingNotice(phrenPath, cwd, activeProject);
if (onboarding) {
process.stdout.write(onboarding);
try {
fs.writeFileSync(sessionMarker(phrenPath, SESSION_START_ONBOARDING_MARKER), `${startedAt}\n`);
}
catch (err) {
debugLog(`session-start onboarding marker write failed: ${errorMessage(err)}`);
}
}
}
catch (err) {
debugLog(`session-start onboarding detection failed: ${errorMessage(err)}`);
}
// ── Bridge: create a real session record so session_history tracks hook sessions ──
// Uses a file lock to prevent concurrent SessionStart hooks from racing on
// the active-hook-session pointer (read previous ID, end it, write new ID).
try {
const activeSessionFile = runtimeFile(phrenPath, "active-hook-session");
withFileLock(activeSessionFile, () => {
// Retroactively end the previous hook session (if any)
try {
const prevId = fs.readFileSync(activeSessionFile, "utf-8").trim();
if (prevId) {
const prevFile = sessionFileForId(phrenPath, prevId);
const prevState = readSessionStateFile(prevFile);
if (prevState && !prevState.endedAt) {
writeSessionStateFile(prevFile, { ...prevState, endedAt: startedAt });
}
}
}
catch (err) {
// ENOENT is expected on the very first session — only log other errors
if (err.code !== "ENOENT") {
debugLog(`session-bridge end-previous failed: ${errorMessage(err)}`);
}
}
// Create new session record
const sessionId = crypto.randomUUID();
const sessionState = {
sessionId,
project: activeProject || undefined,
startedAt,
findingsAdded: 0,
tasksCompleted: 0,
hookCreated: true,
};
writeSessionStateFile(sessionFileForId(phrenPath, sessionId), sessionState);
// Write active session ID atomically so other hooks (stop, tool) can reference it.
// Plain text format (not JSON) because the reader uses readFileSync + trim.
const tmpPath = `${activeSessionFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpPath, sessionId + "\n");
fs.renameSync(tmpPath, activeSessionFile);
debugLog(`session-bridge created session ${sessionId.slice(0, 8)} for hook-driven session`);
});
}
catch (err) {
debugLog(`session-bridge failed: ${errorMessage(err)}`);
}
}