UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

319 lines (318 loc) • 15.6 kB
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 { resolveManagementCapabilities } from "../init/management-preset.js"; import { storeAwareProjectPath } from "../store-routing.js"; import { logger } from "../logger.js"; import { sessionFileForId, readSessionStateFile, writeSessionStateFile, } from "../session/utils.js"; import { runBestEffortGit, countUnsyncedCommits, pullAtSessionStart } from "./session-git.js"; import { scheduleBackgroundMaintenance, scheduleBackgroundSync } 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) { // A team-store project keeps its findings and tasks in that store. const projectDir = storeAwareProjectPath(phrenPath, project) ?? 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"), // A failed pull keeps the last success: it is how long the store has been stuck. ...(pull.ok && hasRemote ? { lastSuccessfulPullAt: startedAt } : {}), 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); let teamStoreAhead = false; for (const store of otherStores) { if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git"))) continue; try { const storePull = await pullAtSessionStart(store.path); if (store.role === "team" && (storePull.counts?.ahead ?? 0) > 0) teamStoreAhead = true; } catch (err) { debugLog(`session-start store-pull ${store.name}: ${errorMessage(err)}`); } } // The pull commits local writes; the background sync pushes them. if (teamStoreAhead && resolveManagementCapabilities(phrenPath).lifecycleAutomations) scheduleBackgroundSync(phrenPath); } catch { // store-registry not available or no stores — skip silently } // A store that cannot reach or merge with its remote is said at session // start (the one hook whose output the agent reads), at most once a day. try { const syncPrefs = readInstallPreferences(phrenPath); const noRemote = syncPrefs.syncIntent === "sync" && !hasRemote; if (noRemote || (hasRemote && !pull.ok)) { const syncWarnPath = sessionMarker(phrenPath, SYNC_WARN_MARKER); let warnedAt = Number.NaN; try { warnedAt = Date.parse(fs.readFileSync(syncWarnPath, "utf8").trim()); } catch { /* never warned */ } if (Number.isNaN(warnedAt) || Date.now() - warnedAt > 86_400_000) { process.stdout.write([ "<phren-notice>", noRemote ? "Sync is configured but no git remote is connected. Your phren data is local-only." : `The phren store did not sync with its remote: ${(pull.error || "unknown error").split("\n")[0]}`, noRemote ? `To fix: cd ${phrenPath} && git remote add origin <YOUR_REPO_URL> && git push -u origin main` : "Tell the user. `phren doctor --fix` reconciles the store and says what it could not resolve.", "<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)}`); } }