@phren/cli 0.1.13 → 0.1.14

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.
Files changed (34) hide show
  1. package/dist/cli/hooks-session.d.ts +18 -36
  2. package/dist/cli/hooks-session.js +21 -1482
  3. package/dist/cli/namespaces-findings.d.ts +1 -0
  4. package/dist/cli/namespaces-findings.js +208 -0
  5. package/dist/cli/namespaces-profile.d.ts +1 -0
  6. package/dist/cli/namespaces-profile.js +76 -0
  7. package/dist/cli/namespaces-projects.d.ts +1 -0
  8. package/dist/cli/namespaces-projects.js +370 -0
  9. package/dist/cli/namespaces-review.d.ts +1 -0
  10. package/dist/cli/namespaces-review.js +45 -0
  11. package/dist/cli/namespaces-skills.d.ts +4 -0
  12. package/dist/cli/namespaces-skills.js +550 -0
  13. package/dist/cli/namespaces-store.d.ts +2 -0
  14. package/dist/cli/namespaces-store.js +367 -0
  15. package/dist/cli/namespaces-tasks.d.ts +1 -0
  16. package/dist/cli/namespaces-tasks.js +369 -0
  17. package/dist/cli/namespaces-utils.d.ts +4 -0
  18. package/dist/cli/namespaces-utils.js +47 -0
  19. package/dist/cli/namespaces.d.ts +7 -11
  20. package/dist/cli/namespaces.js +8 -2011
  21. package/dist/cli/session-background.d.ts +3 -0
  22. package/dist/cli/session-background.js +176 -0
  23. package/dist/cli/session-git.d.ts +17 -0
  24. package/dist/cli/session-git.js +181 -0
  25. package/dist/cli/session-metrics.d.ts +2 -0
  26. package/dist/cli/session-metrics.js +67 -0
  27. package/dist/cli/session-start.d.ts +3 -0
  28. package/dist/cli/session-start.js +289 -0
  29. package/dist/cli/session-stop.d.ts +8 -0
  30. package/dist/cli/session-stop.js +468 -0
  31. package/dist/cli/session-tool-hook.d.ts +18 -0
  32. package/dist/cli/session-tool-hook.js +376 -0
  33. package/dist/tools/search.js +1 -1
  34. package/package.json +1 -1
@@ -1,1484 +1,23 @@
1
- import { buildHookContext, handleGuardSkip, debugLog, appendAuditLog, runtimeFile, sessionMarker, EXEC_TIMEOUT_MS, getPhrenPath, getProjectDirs, findProjectNameCaseInsensitive, homePath, updateRuntimeHealth, withFileLock, getWorkflowPolicy, appendReviewQueue, recordFeedback, getQualityMultiplier, detectProject, isProjectHookEnabled, readProjectConfig, getProjectSourcePath, detectProjectDir, ensureLocalGitRepo, isProjectTracked, repairPreexistingInstall, getProactivityLevelForTask, getProactivityLevelForFindings, hasExplicitFindingSignal, shouldAutoCaptureFindingsForLevel, FINDING_SENSITIVITY_CONFIG, isFeatureEnabled, errorMessage, bootstrapPhrenDotEnv, finalizeTaskSession, appendFindingJournal, runDoctor, resolveRuntimeProfile, buildSyncStatus, } from "./hooks-context.js";
2
- import { sessionMetricsFile, qualityMarkers, } from "../shared.js";
3
- import { autoMergeConflicts, mergeTask, mergeFindings, } from "../shared/content.js";
4
- import { runGit } from "../utils.js";
5
- import { readInstallPreferences } from "../init/preferences.js";
6
- import * as fs from "fs";
7
- import * as path from "path";
8
- import * as os from "os";
9
- import { execFileSync } from "child_process";
10
- import { spawnDetachedChild } from "../shared/process.js";
11
- import { fileURLToPath } from "url";
12
- import { isTaskFileName, TASKS_FILENAME } from "../data/tasks.js";
13
- import { buildIndex, queryRows, } from "../shared/index.js";
14
- import { filterTaskByPriority } from "../shared/retrieval.js";
15
- import { logger } from "../logger.js";
16
- import * as crypto from "crypto";
17
- import { sessionFileForId, readSessionStateFile, writeSessionStateFile, } from "../session/utils.js";
18
- const SYNC_LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes
19
- const MAINTENANCE_LOCK_STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
20
- export { buildHookContext, handleGuardSkip } from "./hooks-context.js";
21
- /** Read JSON from stdin if it's not a TTY. Returns null if stdin is a TTY or parsing fails. */
22
- function readStdinJson() {
23
- if (process.stdin.isTTY)
24
- return null;
25
- try {
26
- return JSON.parse(fs.readFileSync(0, "utf-8"));
27
- }
28
- catch (err) {
29
- logger.debug("hooks-session", `readStdinJson: ${errorMessage(err)}`);
30
- return null;
31
- }
32
- }
33
- /** Validate that a transcript path points to a safe, expected location.
34
- * Uses realpathSync to dereference symlinks, preventing traversal attacks
35
- * where a symlink inside a safe dir points outside it.
36
- */
37
- function isSafeTranscriptPath(p) {
38
- // Resolve symlinks so a link like ~/.claude/evil -> /etc/passwd is caught
39
- let normalized;
40
- try {
41
- normalized = fs.realpathSync.native(p);
42
- }
43
- catch {
44
- // If the file doesn't exist yet, fall back to lexical resolution
45
- try {
46
- normalized = fs.realpathSync.native(path.dirname(p));
47
- normalized = path.join(normalized, path.basename(p));
48
- }
49
- catch {
50
- normalized = path.resolve(p);
51
- }
52
- }
53
- const safePrefixes = [
54
- path.resolve(os.tmpdir()),
55
- path.resolve(homePath(".claude")),
56
- path.resolve(homePath(".config", "claude")),
57
- ];
58
- return safePrefixes.some(prefix => normalized.startsWith(prefix + path.sep) || normalized === prefix);
59
- }
60
- export function getUntrackedProjectNotice(phrenPath, cwd) {
61
- const profile = resolveRuntimeProfile(phrenPath);
62
- const projectDir = detectProjectDir(cwd, phrenPath);
63
- if (!projectDir)
64
- return null;
65
- const activeProfile = profile || undefined;
66
- // Check the exact current working directory against projects in the active profile.
67
- // This avoids prompting when cwd is already inside a tracked sourcePath.
68
- if (detectProject(phrenPath, cwd, activeProfile))
69
- return null;
70
- if (detectProject(phrenPath, projectDir, activeProfile))
71
- return null;
72
- const projectName = path.basename(projectDir).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
73
- if (isProjectTracked(phrenPath, projectName, activeProfile)) {
74
- const trackedName = getProjectDirs(phrenPath, activeProfile)
75
- .map((dir) => path.basename(dir))
76
- .find((name) => name.toLowerCase() === projectName)
77
- || findProjectNameCaseInsensitive(phrenPath, projectName)
78
- || projectName;
79
- const config = readProjectConfig(phrenPath, trackedName);
80
- const sourcePath = getProjectSourcePath(phrenPath, trackedName, config);
81
- if (!sourcePath)
82
- return null;
83
- const resolvedProjectDir = path.resolve(projectDir);
84
- const sameSource = resolvedProjectDir === sourcePath || resolvedProjectDir.startsWith(sourcePath + path.sep);
85
- if (sameSource)
86
- return null;
87
- }
88
- return [
89
- "<phren-notice>",
90
- "This project directory is not tracked by phren yet.",
91
- "Run `phren add` to track it now.",
92
- `Suggested command: \`phren add \"${projectDir}\"\``,
93
- "Ask the user whether they want to add it to phren now.",
94
- "If they say no, tell them they can always run `phren add` later.",
95
- "If they say yes, also ask whether phren should manage repo instruction files or leave their existing repo-owned CLAUDE/AGENTS files alone.",
96
- `Then use the \`add_project\` MCP tool with path="${projectDir}" and ownership="phren-managed"|"detached"|"repo-managed", or run \`phren add\` from that directory.`,
97
- "After onboarding, run `phren doctor` if hooks or MCP tools are not responding.",
98
- "<phren-notice>",
99
- "",
100
- ].join("\n");
101
- }
102
- const SESSION_START_ONBOARDING_MARKER = "session-start-onboarding-v1";
103
- const SYNC_WARN_MARKER = "sync-broken-warned-v1";
104
- function projectHasBootstrapSignals(phrenPath, project) {
105
- const projectDir = path.join(phrenPath, project);
106
- const findingsPath = path.join(projectDir, "FINDINGS.md");
107
- if (fs.existsSync(findingsPath)) {
108
- const findings = fs.readFileSync(findingsPath, "utf8");
109
- if (/^-\s+/m.test(findings))
110
- return true;
111
- }
112
- const tasksPath = path.join(projectDir, TASKS_FILENAME);
113
- if (fs.existsSync(tasksPath)) {
114
- const tasks = fs.readFileSync(tasksPath, "utf8");
115
- if (/^-\s+\[(?: |x|X)\]/m.test(tasks))
116
- return true;
117
- }
118
- return false;
119
- }
120
- export function getSessionStartOnboardingNotice(phrenPath, cwd, activeProject) {
121
- const markerPath = sessionMarker(phrenPath, SESSION_START_ONBOARDING_MARKER);
122
- if (fs.existsSync(markerPath))
123
- return null;
124
- if (getUntrackedProjectNotice(phrenPath, cwd))
125
- return null;
126
- const profile = resolveRuntimeProfile(phrenPath);
127
- const trackedProjects = getProjectDirs(phrenPath, profile).filter((dir) => path.basename(dir) !== "global");
128
- if (trackedProjects.length === 0) {
129
- return [
130
- "<phren-notice>",
131
- "Phren onboarding: no tracked projects are active for this workspace yet.",
132
- "Start in a project repo and run `phren add` so SessionStart can inject project context.",
133
- "Run `phren doctor` to verify hooks and MCP wiring after setup.",
134
- "<phren-notice>",
135
- "",
136
- ].join("\n");
137
- }
138
- if (!activeProject)
139
- return null;
140
- if (projectHasBootstrapSignals(phrenPath, activeProject))
141
- return null;
142
- return [
143
- "<phren-notice>",
144
- `Phren onboarding: project "${activeProject}" is tracked but memory is still empty.`,
145
- "Capture one finding with `add_finding` and one task with `add_task` to seed future SessionStart context.",
146
- "Run `phren doctor` if setup seems incomplete.",
147
- "<phren-notice>",
148
- "",
149
- ].join("\n");
150
- }
151
- export function getGitContext(cwd) {
152
- if (!cwd)
153
- return null;
154
- const git = (args) => runGit(cwd, args, EXEC_TIMEOUT_MS, debugLog);
155
- const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
156
- if (!branch)
157
- return null;
158
- const changedFiles = new Set();
159
- for (const changed of [
160
- git(["diff", "--name-only"]),
161
- git(["diff", "--name-only", "--cached"]),
162
- ]) {
163
- if (!changed)
164
- continue;
165
- for (const line of changed.split("\n").map((s) => s.trim()).filter(Boolean)) {
166
- changedFiles.add(line);
167
- const basename = path.basename(line);
168
- if (basename)
169
- changedFiles.add(basename);
170
- }
171
- }
172
- return { branch, changedFiles };
173
- }
174
- function parseSessionMetrics(phrenPathLocal) {
175
- const file = sessionMetricsFile(phrenPathLocal);
176
- if (!fs.existsSync(file))
177
- return {};
178
- try {
179
- return JSON.parse(fs.readFileSync(file, "utf8"));
180
- }
181
- catch (err) {
182
- debugLog(`parseSessionMetrics: failed to read ${file}: ${errorMessage(err)}`);
183
- return {};
184
- }
185
- }
186
- function writeSessionMetrics(phrenPathLocal, data) {
187
- const file = sessionMetricsFile(phrenPathLocal);
188
- fs.mkdirSync(path.dirname(file), { recursive: true });
189
- fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
190
- }
191
- function updateSessionMetrics(phrenPathLocal, updater) {
192
- const file = sessionMetricsFile(phrenPathLocal);
193
- withFileLock(file, () => {
194
- const metrics = parseSessionMetrics(phrenPathLocal);
195
- updater(metrics);
196
- writeSessionMetrics(phrenPathLocal, metrics);
197
- });
198
- }
199
- export function trackSessionMetrics(phrenPathLocal, sessionId, selected) {
200
- updateSessionMetrics(phrenPathLocal, (metrics) => {
201
- if (!metrics[sessionId])
202
- metrics[sessionId] = { prompts: 0, keys: {}, lastChangedCount: 0, lastKeys: [] };
203
- metrics[sessionId].prompts += 1;
204
- const injectedKeys = [];
205
- for (const injected of selected) {
206
- injectedKeys.push(injected.key);
207
- const key = injected.key;
208
- const seen = metrics[sessionId].keys[key] || 0;
209
- metrics[sessionId].keys[key] = seen + 1;
210
- if (seen >= 1)
211
- recordFeedback(phrenPathLocal, key, "reprompt");
212
- }
213
- const relevantCount = selected.filter((s) => getQualityMultiplier(phrenPathLocal, s.key) > 0.5).length;
214
- const prevRelevant = metrics[sessionId].lastChangedCount || 0;
215
- const prevKeys = metrics[sessionId].lastKeys || [];
216
- if (relevantCount > prevRelevant) {
217
- for (const prevKey of prevKeys) {
218
- recordFeedback(phrenPathLocal, prevKey, "helpful");
219
- }
220
- }
221
- metrics[sessionId].lastChangedCount = relevantCount;
222
- metrics[sessionId].lastKeys = injectedKeys;
223
- metrics[sessionId].lastSeen = new Date().toISOString();
224
- const thirtyDaysAgo = Date.now() - 30 * 86400000;
225
- for (const sid of Object.keys(metrics)) {
226
- const seen = metrics[sid].lastSeen;
227
- if (seen && new Date(seen).getTime() < thirtyDaysAgo) {
228
- delete metrics[sid];
229
- }
230
- }
231
- });
232
- }
233
- // ── Background maintenance ───────────────────────────────────────────────────
234
- export function resolveSubprocessArgs(command) {
235
- // Prefer the entry script that started this process
236
- const entry = process.argv[1];
237
- if (entry && fs.existsSync(entry) && /index\.(ts|js)$/.test(entry))
238
- return [entry, command];
239
- // Fallback: look for index.js next to this file or one level up (for subdirectory builds)
240
- const thisDir = path.dirname(fileURLToPath(import.meta.url));
241
- for (const dir of [thisDir, path.dirname(thisDir)]) {
242
- const candidate = path.join(dir, "index.js");
243
- if (fs.existsSync(candidate))
244
- return [candidate, command];
245
- }
246
- return null;
247
- }
248
- function scheduleBackgroundSync(phrenPathLocal) {
249
- const lockPath = runtimeFile(phrenPathLocal, "background-sync.lock");
250
- const logPath = runtimeFile(phrenPathLocal, "background-sync.log");
251
- const spawnArgs = resolveSubprocessArgs("background-sync");
252
- if (!spawnArgs)
253
- return false;
254
- try {
255
- if (fs.existsSync(lockPath)) {
256
- const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
257
- if (ageMs <= SYNC_LOCK_STALE_MS)
258
- return false;
259
- fs.unlinkSync(lockPath);
260
- }
261
- }
262
- catch (err) {
263
- debugLog(`scheduleBackgroundSync: lock check failed: ${errorMessage(err)}`);
264
- return false;
265
- }
266
- try {
267
- fs.writeFileSync(lockPath, JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid }) + "\n", { flag: "wx" });
268
- const logFd = fs.openSync(logPath, "a");
269
- fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
270
- const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
271
- child.unref();
272
- fs.closeSync(logFd);
273
- return true;
274
- }
275
- catch (err) {
276
- try {
277
- fs.unlinkSync(lockPath);
278
- }
279
- catch { }
280
- debugLog(`scheduleBackgroundSync: spawn failed: ${errorMessage(err)}`);
281
- return false;
282
- }
283
- }
284
- function scheduleBackgroundMaintenance(phrenPathLocal, project) {
285
- if (!isFeatureEnabled("PHREN_FEATURE_DAILY_MAINTENANCE", true))
286
- return false;
287
- const markers = qualityMarkers(phrenPathLocal);
288
- if (fs.existsSync(markers.done))
289
- return false;
290
- if (fs.existsSync(markers.lock)) {
291
- try {
292
- const ageMs = Date.now() - fs.statSync(markers.lock).mtimeMs;
293
- if (ageMs <= MAINTENANCE_LOCK_STALE_MS)
294
- return false;
295
- fs.unlinkSync(markers.lock);
296
- }
297
- catch (err) {
298
- debugLog(`maybeRunBackgroundMaintenance: lock check failed: ${errorMessage(err)}`);
299
- return false;
300
- }
301
- }
302
- const spawnArgs = resolveSubprocessArgs("background-maintenance");
303
- if (!spawnArgs)
304
- return false;
305
- try {
306
- // Use exclusive open (O_EXCL) to atomically claim the lock; if another process
307
- // already holds it this throws and we return false without spawning a duplicate.
308
- const lockContent = JSON.stringify({
309
- startedAt: new Date().toISOString(),
310
- project: project || "all",
311
- pid: process.pid,
312
- }) + "\n";
313
- let fd;
314
- try {
315
- fd = fs.openSync(markers.lock, "wx");
316
- }
317
- catch (err) {
318
- // Another process already claimed the lock
319
- logger.debug("hooks-session", `backgroundMaintenance lockClaim: ${errorMessage(err)}`);
320
- return false;
321
- }
322
- try {
323
- fs.writeSync(fd, lockContent);
324
- }
325
- finally {
326
- fs.closeSync(fd);
327
- }
328
- if (project)
329
- spawnArgs.push(project);
330
- const logDir = path.join(phrenPathLocal, ".config");
331
- fs.mkdirSync(logDir, { recursive: true });
332
- const logPath = path.join(logDir, "background-maintenance.log");
333
- const logFd = fs.openSync(logPath, "a");
334
- fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
335
- const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
336
- child.on("exit", (code, signal) => {
337
- const msg = `[${new Date().toISOString()}] exit code=${code ?? "null"} signal=${signal ?? "none"}\n`;
338
- try {
339
- fs.appendFileSync(logPath, msg);
340
- }
341
- catch (err) {
342
- logger.debug("hooks-session", `backgroundMaintenance exitLog: ${errorMessage(err)}`);
343
- }
344
- if (code === 0) {
345
- try {
346
- fs.writeFileSync(markers.done, new Date().toISOString() + "\n");
347
- }
348
- catch (err) {
349
- logger.debug("hooks-session", `backgroundMaintenance doneMarker: ${errorMessage(err)}`);
350
- }
351
- }
352
- try {
353
- fs.unlinkSync(markers.lock);
354
- }
355
- catch (err) {
356
- logger.debug("hooks-session", `backgroundMaintenance unlockOnExit: ${errorMessage(err)}`);
357
- }
358
- });
359
- child.on("error", (spawnErr) => {
360
- const msg = `[${new Date().toISOString()}] spawn error: ${spawnErr.message}\n`;
361
- try {
362
- fs.appendFileSync(logPath, msg);
363
- }
364
- catch (err) {
365
- logger.debug("hooks-session", `backgroundMaintenance errorLog: ${errorMessage(err)}`);
366
- }
367
- try {
368
- fs.unlinkSync(markers.lock);
369
- }
370
- catch (err) {
371
- logger.debug("hooks-session", `backgroundMaintenance unlockOnError: ${errorMessage(err)}`);
372
- }
373
- });
374
- fs.closeSync(logFd);
375
- child.unref();
376
- return true;
377
- }
378
- catch (err) {
379
- const errMsg = errorMessage(err);
380
- try {
381
- const logDir = path.join(phrenPathLocal, ".config");
382
- fs.mkdirSync(logDir, { recursive: true });
383
- fs.appendFileSync(path.join(logDir, "background-maintenance.log"), `[${new Date().toISOString()}] spawn failed: ${errMsg}\n`);
384
- }
385
- catch (err) {
386
- logger.debug("hooks-session", `backgroundMaintenance logSpawnFailure: ${errorMessage(err)}`);
387
- }
388
- try {
389
- fs.unlinkSync(markers.lock);
390
- }
391
- catch (err) {
392
- logger.debug("hooks-session", `backgroundMaintenance unlockOnFailure: ${errorMessage(err)}`);
393
- }
394
- return false;
395
- }
396
- }
397
- // ── Git command helpers for hooks ────────────────────────────────────────────
398
- function isTransientGitError(message) {
399
- return /(timed out|connection|network|could not resolve host|rpc failed|429|502|503|504|service unavailable)/i.test(message);
400
- }
401
- function shouldRetryGitCommand(args) {
402
- const cmd = args[0] || "";
403
- return cmd === "push" || cmd === "pull" || cmd === "fetch";
404
- }
405
- async function runBestEffortGit(args, cwd) {
406
- const retries = shouldRetryGitCommand(args) ? 2 : 0;
407
- for (let attempt = 0; attempt <= retries; attempt++) {
408
- try {
409
- const output = execFileSync("git", args, {
410
- cwd,
411
- encoding: "utf8",
412
- stdio: ["ignore", "pipe", "pipe"],
413
- timeout: EXEC_TIMEOUT_MS,
414
- }).trim();
415
- return { ok: true, output };
416
- }
417
- catch (err) {
418
- const message = errorMessage(err);
419
- if (attempt < retries && isTransientGitError(message)) {
420
- const delayMs = 500 * (attempt + 1);
421
- await new Promise((resolve) => setTimeout(resolve, delayMs));
422
- continue;
423
- }
424
- return { ok: false, error: message };
425
- }
426
- }
427
- return { ok: false, error: "git command failed" };
428
- }
429
- async function countUnsyncedCommits(cwd) {
430
- const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
431
- if (!upstream.ok || !upstream.output) {
432
- const allCommits = await runBestEffortGit(["rev-list", "--count", "HEAD"], cwd);
433
- if (!allCommits.ok || !allCommits.output)
434
- return 0;
435
- const parsed = Number.parseInt(allCommits.output.trim(), 10);
436
- return Number.isNaN(parsed) ? 0 : parsed;
437
- }
438
- const ahead = await runBestEffortGit(["rev-list", "--count", `${upstream.output.trim()}..HEAD`], cwd);
439
- if (!ahead.ok || !ahead.output)
440
- return 0;
441
- const parsed = Number.parseInt(ahead.output.trim(), 10);
442
- return Number.isNaN(parsed) ? 0 : parsed;
443
- }
444
- function isMergeableMarkdown(relPath) {
445
- const filename = path.basename(relPath).toLowerCase();
446
- return filename === "findings.md" || isTaskFileName(filename);
447
- }
448
- async function snapshotLocalMergeableFiles(cwd) {
449
- const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
450
- if (!upstream.ok || !upstream.output)
451
- return new Map();
452
- const changed = await runBestEffortGit(["diff", "--name-only", `${upstream.output.trim()}..HEAD`], cwd);
453
- if (!changed.ok || !changed.output)
454
- return new Map();
455
- const snapshots = new Map();
456
- for (const relPath of changed.output.split("\n").map((line) => line.trim()).filter(Boolean)) {
457
- if (!isMergeableMarkdown(relPath))
458
- continue;
459
- const fullPath = path.join(cwd, relPath);
460
- if (!fs.existsSync(fullPath))
461
- continue;
462
- snapshots.set(relPath, fs.readFileSync(fullPath, "utf8"));
463
- }
464
- return snapshots;
465
- }
466
- async function reconcileMergeableFiles(cwd, snapshots) {
467
- let changedAny = false;
468
- for (const [relPath, localBeforePull] of snapshots.entries()) {
469
- const fullPath = path.join(cwd, relPath);
470
- if (!fs.existsSync(fullPath))
471
- continue;
472
- const current = fs.readFileSync(fullPath, "utf8");
473
- const filename = path.basename(relPath).toLowerCase();
474
- const merged = filename === "findings.md"
475
- ? mergeFindings(current, localBeforePull)
476
- : mergeTask(current, localBeforePull);
477
- if (merged === current)
478
- continue;
479
- fs.writeFileSync(fullPath, merged);
480
- changedAny = true;
481
- }
482
- if (!changedAny)
483
- return false;
484
- const add = await runBestEffortGit(["add", "--", ...snapshots.keys()], cwd);
485
- if (!add.ok)
486
- return false;
487
- const commit = await runBestEffortGit(["commit", "-m", "auto-merge markdown recovery"], cwd);
488
- return commit.ok;
489
- }
490
- async function recoverPushConflict(cwd) {
491
- const localSnapshots = await snapshotLocalMergeableFiles(cwd);
492
- const pull = await runBestEffortGit(["pull", "--rebase", "--quiet"], cwd);
493
- if (pull.ok) {
494
- const reconciled = await reconcileMergeableFiles(cwd, localSnapshots);
495
- const retryPush = await runBestEffortGit(["push"], cwd);
496
- return {
497
- ok: retryPush.ok,
498
- detail: retryPush.ok
499
- ? (reconciled ? "commit pushed after pull --rebase and markdown reconciliation" : "commit pushed after pull --rebase")
500
- : (retryPush.error || "push failed after pull --rebase"),
501
- pullStatus: "ok",
502
- pullDetail: pull.output || "pull --rebase ok",
503
- };
504
- }
505
- const conflicted = await runBestEffortGit(["diff", "--name-only", "--diff-filter=U"], cwd);
506
- const conflictedOutput = conflicted.output?.trim() || "";
507
- if (!conflicted.ok || !conflictedOutput) {
508
- await runBestEffortGit(["rebase", "--abort"], cwd);
509
- return {
510
- ok: false,
511
- detail: pull.error || "pull --rebase failed",
512
- pullStatus: "error",
513
- pullDetail: pull.error || "pull --rebase failed",
514
- };
515
- }
516
- if (!autoMergeConflicts(cwd)) {
517
- await runBestEffortGit(["rebase", "--abort"], cwd);
518
- return {
519
- ok: false,
520
- detail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
521
- pullStatus: "error",
522
- pullDetail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
523
- };
524
- }
525
- const continued = await runBestEffortGit(["-c", "core.editor=true", "rebase", "--continue"], cwd);
526
- if (!continued.ok) {
527
- await runBestEffortGit(["rebase", "--abort"], cwd);
528
- return {
529
- ok: false,
530
- detail: continued.error || "rebase --continue failed",
531
- pullStatus: "error",
532
- pullDetail: continued.error || "rebase --continue failed",
533
- };
534
- }
535
- const retryPush = await runBestEffortGit(["push"], cwd);
536
- return {
537
- ok: retryPush.ok,
538
- detail: retryPush.ok ? "commit pushed after auto-merge recovery" : (retryPush.error || "push failed after auto-merge recovery"),
539
- pullStatus: "ok",
540
- pullDetail: "pull --rebase recovered via auto-merge",
541
- };
542
- }
543
- // ── Hook handlers ────────────────────────────────────────────────────────────
544
- export async function handleHookSessionStart() {
545
- const startedAt = new Date().toISOString();
546
- const ctx = buildHookContext();
547
- const { phrenPath, cwd, activeProject, manifest } = ctx;
548
- // Check common guards (hooks enabled, tool enabled)
549
- if (!ctx.hooksEnabled) {
550
- handleGuardSkip(ctx, "hook_session_start", "disabled", { lastSessionStartAt: startedAt });
551
- return;
552
- }
553
- if (!ctx.toolHookEnabled) {
554
- handleGuardSkip(ctx, "hook_session_start", `tool_disabled tool=${ctx.hookTool}`);
555
- return;
556
- }
557
- try {
558
- repairPreexistingInstall(phrenPath);
559
- }
560
- catch (err) {
561
- debugLog(`hook-session-start repair failed: ${errorMessage(err)}`);
562
- }
563
- if (!isProjectHookEnabled(phrenPath, activeProject, "SessionStart")) {
564
- handleGuardSkip(ctx, "hook_session_start", `project_disabled project=${activeProject}`, { lastSessionStartAt: startedAt });
565
- return;
566
- }
567
- if (manifest?.installMode === "project-local") {
568
- updateRuntimeHealth(phrenPath, {
569
- lastSessionStartAt: startedAt,
570
- lastSync: {
571
- lastPullAt: startedAt,
572
- lastPullStatus: "ok",
573
- lastPullDetail: "project-local mode does not manage git sync",
574
- },
575
- });
576
- appendAuditLog(phrenPath, "hook_session_start", "status=skipped-local");
577
- return;
578
- }
579
- const gitRepo = ensureLocalGitRepo(phrenPath);
580
- const remotes = gitRepo.ok ? await runBestEffortGit(["remote"], phrenPath) : { ok: false, error: gitRepo.detail };
581
- const hasRemote = Boolean(remotes.ok && remotes.output && remotes.output.trim());
582
- const pull = !gitRepo.ok
583
- ? { ok: false, error: gitRepo.detail }
584
- : hasRemote
585
- ? await runBestEffortGit(["pull", "--rebase", "--quiet"], phrenPath)
586
- : {
587
- ok: true,
588
- output: gitRepo.initialized
589
- ? "initialized local git repo; no remote configured"
590
- : "local-only repo; no remote configured",
591
- };
592
- const doctor = await runDoctor(phrenPath, false);
593
- const maintenanceScheduled = scheduleBackgroundMaintenance(phrenPath);
594
- const unsyncedCommits = hasRemote ? await countUnsyncedCommits(phrenPath) : 0;
595
- try {
596
- const { trackSession } = await import("../telemetry.js");
597
- trackSession(phrenPath);
598
- }
599
- catch (err) {
600
- logger.debug("hooks-session", `hookSessionStart trackSession: ${errorMessage(err)}`);
601
- }
602
- updateRuntimeHealth(phrenPath, {
603
- lastSessionStartAt: startedAt,
604
- lastSync: {
605
- lastPullAt: startedAt,
606
- lastPullStatus: pull.ok ? "ok" : "error",
607
- lastPullDetail: pull.ok ? (pull.output || "pull ok") : (pull.error || "pull failed"),
608
- lastSuccessfulPullAt: pull.ok && hasRemote ? startedAt : undefined,
609
- unsyncedCommits,
610
- },
611
- });
612
- appendAuditLog(phrenPath, "hook_session_start", `pull=${hasRemote ? (pull.ok ? "ok" : "fail") : "skipped-local"} doctor=${doctor.ok ? "ok" : "issues"} maintenance=${maintenanceScheduled ? "scheduled" : "skipped"}`);
613
- // Pull non-primary stores from store registry (best-effort, non-blocking)
614
- try {
615
- const { getNonPrimaryStores } = await import("../store-registry.js");
616
- const otherStores = getNonPrimaryStores(phrenPath);
617
- for (const store of otherStores) {
618
- if (!fs.existsSync(store.path) || !fs.existsSync(path.join(store.path, ".git")))
619
- continue;
620
- try {
621
- await runBestEffortGit(["pull", "--rebase", "--quiet"], store.path);
622
- }
623
- catch (err) {
624
- debugLog(`session-start store-pull ${store.name}: ${errorMessage(err)}`);
625
- }
626
- }
627
- }
628
- catch {
629
- // store-registry not available or no stores — skip silently
630
- }
631
- // Sync intent warning: if the user intended sync but remote is missing or pull failed, warn once
632
- try {
633
- const syncPrefs = readInstallPreferences(phrenPath);
634
- const syncBroken = syncPrefs.syncIntent === "sync" && (!hasRemote || !pull.ok);
635
- if (syncBroken) {
636
- const syncWarnPath = sessionMarker(phrenPath, SYNC_WARN_MARKER);
637
- if (!fs.existsSync(syncWarnPath)) {
638
- const reason = !hasRemote
639
- ? "no git remote is connected"
640
- : `pull failed: ${pull.error || "unknown error"}`;
641
- process.stdout.write([
642
- "<phren-notice>",
643
- `Sync is configured but ${reason}. Your phren data is local-only.`,
644
- `To fix: cd ${phrenPath} && git remote add origin <YOUR_REPO_URL> && git push -u origin main`,
645
- "<phren-notice>",
646
- "",
647
- ].join("\n"));
648
- try {
649
- fs.writeFileSync(syncWarnPath, `${startedAt}\n`);
650
- }
651
- catch (err) {
652
- debugLog(`sync-warn marker write failed: ${errorMessage(err)}`);
653
- }
654
- }
655
- }
656
- }
657
- catch (err) {
658
- debugLog(`sync-intent check failed: ${errorMessage(err)}`);
659
- }
660
- // Untracked project detection: suggest `phren add` if CWD looks like a project but isn't tracked
661
- try {
662
- const notice = getUntrackedProjectNotice(phrenPath, cwd);
663
- if (notice) {
664
- process.stdout.write(notice);
665
- debugLog(`untracked project detected at ${cwd}`);
666
- }
667
- const onboarding = getSessionStartOnboardingNotice(phrenPath, cwd, activeProject);
668
- if (onboarding) {
669
- process.stdout.write(onboarding);
670
- try {
671
- fs.writeFileSync(sessionMarker(phrenPath, SESSION_START_ONBOARDING_MARKER), `${startedAt}\n`);
672
- }
673
- catch (err) {
674
- debugLog(`session-start onboarding marker write failed: ${errorMessage(err)}`);
675
- }
676
- }
677
- }
678
- catch (err) {
679
- debugLog(`session-start onboarding detection failed: ${errorMessage(err)}`);
680
- }
681
- // ── Bridge: create a real session record so session_history tracks hook sessions ──
682
- // Uses a file lock to prevent concurrent SessionStart hooks from racing on
683
- // the active-hook-session pointer (read previous ID, end it, write new ID).
684
- try {
685
- const activeSessionFile = runtimeFile(phrenPath, "active-hook-session");
686
- withFileLock(activeSessionFile, () => {
687
- // Retroactively end the previous hook session (if any)
688
- try {
689
- const prevId = fs.readFileSync(activeSessionFile, "utf-8").trim();
690
- if (prevId) {
691
- const prevFile = sessionFileForId(phrenPath, prevId);
692
- const prevState = readSessionStateFile(prevFile);
693
- if (prevState && !prevState.endedAt) {
694
- writeSessionStateFile(prevFile, { ...prevState, endedAt: startedAt });
695
- }
696
- }
697
- }
698
- catch (err) {
699
- // ENOENT is expected on the very first session — only log other errors
700
- if (err.code !== "ENOENT") {
701
- debugLog(`session-bridge end-previous failed: ${errorMessage(err)}`);
702
- }
703
- }
704
- // Create new session record
705
- const sessionId = crypto.randomUUID();
706
- const sessionState = {
707
- sessionId,
708
- project: activeProject || undefined,
709
- startedAt,
710
- findingsAdded: 0,
711
- tasksCompleted: 0,
712
- hookCreated: true,
713
- };
714
- writeSessionStateFile(sessionFileForId(phrenPath, sessionId), sessionState);
715
- // Write active session ID atomically so other hooks (stop, tool) can reference it.
716
- // Plain text format (not JSON) because the reader uses readFileSync + trim.
717
- const tmpPath = `${activeSessionFile}.${process.pid}.${Date.now()}.tmp`;
718
- fs.writeFileSync(tmpPath, sessionId + "\n");
719
- fs.renameSync(tmpPath, activeSessionFile);
720
- debugLog(`session-bridge created session ${sessionId.slice(0, 8)} for hook-driven session`);
721
- });
722
- }
723
- catch (err) {
724
- debugLog(`session-bridge failed: ${errorMessage(err)}`);
725
- }
726
- }
727
- // ── Q21: Conversation memory capture ─────────────────────────────────────────
728
- const INSIGHT_KEYWORDS = [
729
- "always", "never", "important", "pitfall", "gotcha", "trick", "workaround",
730
- "careful", "caveat", "beware", "note that", "make sure",
731
- "don't forget", "remember to", "must", "avoid", "prefer",
732
- ];
733
- const INSIGHT_KEYWORD_RE = new RegExp(`\\b(${INSIGHT_KEYWORDS.join("|")})\\b`, "i");
734
1
  /**
735
- * Extract potential insights from conversation text using keyword heuristics.
736
- * Returns lines that contain insight-signal words and look like actionable knowledge.
2
+ * Session lifecycle hooks orchestrator module.
3
+ *
4
+ * This file re-exports all session hook functionality from the split modules:
5
+ * - session-git.ts — Git context and command helpers
6
+ * - session-metrics.ts — Session metrics tracking
7
+ * - session-background.ts — Background sync/maintenance scheduling
8
+ * - session-start.ts — SessionStart hook handler + onboarding notices
9
+ * - session-stop.ts — Stop hook handler + background sync + conversation capture
10
+ * - session-tool-hook.ts — PostToolUse and context hook handlers + tool finding extraction
737
11
  */
738
- export function extractConversationInsights(text) {
739
- const lines = text.split("\n").filter(l => l.trim().length > 20 && l.trim().length < 300);
740
- const insights = [];
741
- const seen = new Set();
742
- for (const line of lines) {
743
- const trimmed = line.trim();
744
- // Skip code-only lines, headers, etc.
745
- if (trimmed.startsWith("```") || trimmed.startsWith("#") || trimmed.startsWith("//"))
746
- continue;
747
- if (trimmed.startsWith("$") || trimmed.startsWith(">"))
748
- continue;
749
- if (INSIGHT_KEYWORD_RE.test(trimmed) || hasExplicitFindingSignal(trimmed)) {
750
- // Normalize for dedup
751
- const normalized = trimmed.toLowerCase().replace(/\s+/g, " ");
752
- if (!seen.has(normalized)) {
753
- seen.add(normalized);
754
- insights.push(trimmed);
755
- }
756
- }
757
- }
758
- // Cap to prevent flooding
759
- return insights.slice(0, 5);
760
- }
761
- export function filterConversationInsightsForProactivity(insights, level = getProactivityLevelForFindings(getPhrenPath())) {
762
- if (level === "high")
763
- return insights;
764
- return insights.filter((insight) => shouldAutoCaptureFindingsForLevel(level, insight));
765
- }
766
- export async function handleHookStop() {
767
- const ctx = buildHookContext();
768
- const { phrenPath, activeProject, manifest } = ctx;
769
- const now = new Date().toISOString();
770
- bootstrapPhrenDotEnv(phrenPath);
771
- if (!ctx.hooksEnabled) {
772
- handleGuardSkip(ctx, "hook_stop", "disabled", {
773
- lastStopAt: now,
774
- lastAutoSave: { at: now, status: "clean", detail: "hooks disabled by preference" },
775
- });
776
- return;
777
- }
778
- if (!ctx.toolHookEnabled) {
779
- handleGuardSkip(ctx, "hook_stop", `tool_disabled tool=${ctx.hookTool}`);
780
- return;
781
- }
782
- if (!isProjectHookEnabled(phrenPath, activeProject, "Stop")) {
783
- handleGuardSkip(ctx, "hook_stop", `project_disabled project=${activeProject}`, {
784
- lastStopAt: now,
785
- lastAutoSave: { at: now, status: "clean", detail: `hooks disabled for project ${activeProject}` },
786
- });
787
- return;
788
- }
789
- // Read stdin early — it's a stream and can only be consumed once.
790
- // Needed for auto-capture transcript_path parsing.
791
- const stdinPayload = readStdinJson();
792
- const taskSessionId = typeof stdinPayload?.session_id === "string" ? stdinPayload.session_id : undefined;
793
- const taskLevel = getProactivityLevelForTask(phrenPath);
794
- if (taskSessionId && taskLevel !== "high") {
795
- debugLog(`hook-stop task proactivity=${taskLevel}`);
796
- }
797
- // Auto-capture BEFORE git operations so captured insights get committed and pushed.
798
- // Gated behind PHREN_FEATURE_AUTO_CAPTURE=1.
799
- const findingsLevel = getProactivityLevelForFindings(phrenPath);
800
- if (isFeatureEnabled("PHREN_FEATURE_AUTO_CAPTURE", false) && findingsLevel !== "low") {
801
- try {
802
- let captureInput = process.env.PHREN_CONVERSATION_CONTEXT || "";
803
- if (!captureInput && stdinPayload?.transcript_path) {
804
- const transcriptPath = stdinPayload.transcript_path;
805
- if (!isSafeTranscriptPath(transcriptPath)) {
806
- debugLog(`auto-capture: skipping unsafe transcript_path: ${transcriptPath}`);
807
- }
808
- else if (fs.existsSync(transcriptPath)) {
809
- // Cap at last 500 lines (~50 KB) to bound memory usage for long sessions
810
- const raw = fs.readFileSync(transcriptPath, "utf-8");
811
- const allLines = raw.split("\n").filter(Boolean);
812
- const lines = allLines.length > 500 ? allLines.slice(-500) : allLines;
813
- const assistantTexts = [];
814
- for (const line of lines) {
815
- try {
816
- const msg = JSON.parse(line);
817
- if (msg.role !== "assistant")
818
- continue;
819
- if (typeof msg.content === "string")
820
- assistantTexts.push(msg.content);
821
- else if (Array.isArray(msg.content)) {
822
- for (const block of msg.content) {
823
- if (block.type === "text" && block.text)
824
- assistantTexts.push(block.text);
825
- }
826
- }
827
- }
828
- catch (err) {
829
- logger.debug("hooks-session", `hookStop transcriptParse: ${errorMessage(err)}`);
830
- }
831
- }
832
- captureInput = assistantTexts.join("\n");
833
- }
834
- }
835
- if (captureInput) {
836
- if (activeProject) {
837
- // Check session cap before extracting — same guard as PostToolUse hook
838
- let capReached = false;
839
- if (taskSessionId) {
840
- try {
841
- const capFile = sessionMarker(phrenPath, `tool-findings-${taskSessionId}`);
842
- let count = 0;
843
- if (fs.existsSync(capFile)) {
844
- count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
845
- }
846
- const sessionCap = getSessionCap();
847
- if (count >= sessionCap) {
848
- debugLog(`hook-stop: session cap reached (${count}/${sessionCap}), skipping extraction`);
849
- capReached = true;
850
- }
851
- }
852
- catch (err) {
853
- logger.debug("hooks-session", `hookStop sessionCapCheck: ${errorMessage(err)}`);
854
- }
855
- }
856
- if (!capReached) {
857
- const insights = filterConversationInsightsForProactivity(extractConversationInsights(captureInput), findingsLevel);
858
- for (const insight of insights) {
859
- appendFindingJournal(phrenPath, activeProject, `[pattern] ${insight}`, {
860
- source: "hook",
861
- sessionId: `hook-stop-${Date.now()}`,
862
- });
863
- debugLog(`auto-capture: saved insight for ${activeProject}: ${insight.slice(0, 60)}`);
864
- }
865
- }
866
- }
867
- }
868
- }
869
- catch (err) {
870
- debugLog(`auto-capture failed: ${errorMessage(err)}`);
871
- }
872
- }
873
- else if (isFeatureEnabled("PHREN_FEATURE_AUTO_CAPTURE", false)) {
874
- debugLog("auto-capture: skipped because findings proactivity is low");
875
- }
876
- // Wrap git operations in a file lock to prevent concurrent agents from fighting
877
- const gitOpLockPath = path.join(phrenPath, ".runtime", "git-op");
878
- await withFileLock(gitOpLockPath, async () => {
879
- if (manifest?.installMode === "project-local") {
880
- updateRuntimeHealth(phrenPath, {
881
- lastStopAt: now,
882
- lastAutoSave: { at: now, status: "saved-local", detail: "project-local mode writes files only" },
883
- lastSync: {
884
- lastPushAt: now,
885
- lastPushStatus: "saved-local",
886
- lastPushDetail: "project-local mode does not manage git sync",
887
- },
888
- });
889
- appendAuditLog(phrenPath, "hook_stop", "status=skipped-local");
890
- return;
891
- }
892
- const gitRepo = ensureLocalGitRepo(phrenPath);
893
- if (!gitRepo.ok) {
894
- finalizeTaskSession({
895
- phrenPath,
896
- sessionId: taskSessionId,
897
- status: "error",
898
- detail: gitRepo.detail,
899
- });
900
- updateRuntimeHealth(phrenPath, {
901
- lastStopAt: now,
902
- lastAutoSave: { at: now, status: "error", detail: gitRepo.detail },
903
- lastSync: {
904
- lastPushAt: now,
905
- lastPushStatus: "error",
906
- lastPushDetail: gitRepo.detail,
907
- },
908
- });
909
- appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(gitRepo.detail)}`);
910
- return;
911
- }
912
- const status = await runBestEffortGit(["status", "--porcelain"], phrenPath);
913
- if (!status.ok) {
914
- finalizeTaskSession({
915
- phrenPath,
916
- sessionId: taskSessionId,
917
- status: "error",
918
- detail: status.error || "git status failed",
919
- });
920
- updateRuntimeHealth(phrenPath, {
921
- lastStopAt: now,
922
- lastAutoSave: { at: now, status: "error", detail: status.error || "git status failed" },
923
- lastSync: {
924
- lastPushAt: now,
925
- lastPushStatus: "error",
926
- lastPushDetail: status.error || "git status failed",
927
- },
928
- });
929
- appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(status.error || "git status failed")}`);
930
- return;
931
- }
932
- if (!status.output) {
933
- updateRuntimeHealth(phrenPath, {
934
- lastStopAt: now,
935
- lastAutoSave: { at: now, status: "clean", detail: "no changes" },
936
- lastSync: {
937
- lastPushAt: now,
938
- lastPushStatus: "saved-pushed",
939
- lastPushDetail: "no changes",
940
- unsyncedCommits: 0,
941
- },
942
- });
943
- appendAuditLog(phrenPath, "hook_stop", "status=clean");
944
- return;
945
- }
946
- // Stage all changes first, then unstage any sensitive files that slipped
947
- // through. Using pathspec exclusions with `git add -A` can fail when
948
- // excluded paths are also gitignored (git treats the pathspec as an error).
949
- let add = await runBestEffortGit(["add", "-A"], phrenPath);
950
- if (add.ok) {
951
- // Belt-and-suspenders: unstage sensitive files that .gitignore should
952
- // already block. Failures here are non-fatal (files may not exist).
953
- await runBestEffortGit(["reset", "HEAD", "--", ".env", "**/.env", "*.pem", "*.key"], phrenPath);
954
- }
955
- let commitMsg = "auto-save phren";
956
- if (add.ok) {
957
- const diff = await runBestEffortGit(["diff", "--cached", "--stat", "--no-color"], phrenPath);
958
- if (diff.ok && diff.output) {
959
- // Parse "project/file.md | 3 +++" lines into project names and file types
960
- const changes = new Map();
961
- for (const line of diff.output.split("\n")) {
962
- const m = line.match(/^\s*([^/]+)\/([^|]+)\s*\|/);
963
- if (!m)
964
- continue;
965
- const proj = m[1].trim();
966
- if (proj.startsWith("."))
967
- continue; // skip .config, .runtime, etc.
968
- const file = m[2].trim();
969
- if (!changes.has(proj))
970
- changes.set(proj, new Set());
971
- if (/findings/i.test(file))
972
- changes.get(proj).add("findings");
973
- else if (/tasks/i.test(file))
974
- changes.get(proj).add("task");
975
- else if (/CLAUDE/i.test(file))
976
- changes.get(proj).add("config");
977
- else if (/summary/i.test(file))
978
- changes.get(proj).add("summary");
979
- else if (/skill/i.test(file))
980
- changes.get(proj).add("skills");
981
- else if (/reference/i.test(file))
982
- changes.get(proj).add("reference");
983
- else
984
- changes.get(proj).add("update");
985
- }
986
- if (changes.size > 0) {
987
- const parts = [...changes.entries()].map(([proj, types]) => `${proj}(${[...types].join(",")})`);
988
- commitMsg = `phren: ${parts.join(" ")}`;
989
- }
990
- }
991
- }
992
- const commit = add.ok ? await runBestEffortGit(["commit", "-m", commitMsg], phrenPath) : { ok: false, error: add.error };
993
- if (!add.ok || !commit.ok) {
994
- finalizeTaskSession({
995
- phrenPath,
996
- sessionId: taskSessionId,
997
- status: "error",
998
- detail: add.error || commit.error || "git add/commit failed",
999
- });
1000
- updateRuntimeHealth(phrenPath, {
1001
- lastStopAt: now,
1002
- lastAutoSave: {
1003
- at: now,
1004
- status: "error",
1005
- detail: add.error || commit.error || "git add/commit failed",
1006
- },
1007
- lastSync: {
1008
- lastPushAt: now,
1009
- lastPushStatus: "error",
1010
- lastPushDetail: add.error || commit.error || "git add/commit failed",
1011
- },
1012
- });
1013
- appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(add.error || commit.error || "git add/commit failed")}`);
1014
- return;
1015
- }
1016
- const remotes = await runBestEffortGit(["remote"], phrenPath);
1017
- if (!remotes.ok || !remotes.output) {
1018
- finalizeTaskSession({
1019
- phrenPath,
1020
- sessionId: taskSessionId,
1021
- status: "saved-local",
1022
- detail: "commit created; no remote configured",
1023
- });
1024
- const unsyncedCommits = await countUnsyncedCommits(phrenPath);
1025
- updateRuntimeHealth(phrenPath, {
1026
- lastStopAt: now,
1027
- lastAutoSave: { at: now, status: "saved-local", detail: "commit created; no remote configured" },
1028
- lastSync: {
1029
- lastPushAt: now,
1030
- lastPushStatus: "saved-local",
1031
- lastPushDetail: "commit created; no remote configured",
1032
- unsyncedCommits,
1033
- },
1034
- });
1035
- appendAuditLog(phrenPath, "hook_stop", "status=saved-local");
1036
- return;
1037
- }
1038
- const unsyncedCommits = await countUnsyncedCommits(phrenPath);
1039
- const scheduled = scheduleBackgroundSync(phrenPath);
1040
- const syncDetail = scheduled
1041
- ? "commit saved; background sync scheduled"
1042
- : "commit saved; background sync already running";
1043
- finalizeTaskSession({
1044
- phrenPath,
1045
- sessionId: taskSessionId,
1046
- status: "saved-local",
1047
- detail: syncDetail,
1048
- });
1049
- updateRuntimeHealth(phrenPath, {
1050
- lastStopAt: now,
1051
- lastAutoSave: { at: now, status: "saved-local", detail: syncDetail },
1052
- lastSync: {
1053
- lastPushAt: now,
1054
- lastPushStatus: "saved-local",
1055
- lastPushDetail: syncDetail,
1056
- unsyncedCommits,
1057
- },
1058
- });
1059
- appendAuditLog(phrenPath, "hook_stop", `status=saved-local detail=${JSON.stringify(syncDetail)}`);
1060
- }); // end withFileLock(gitOpLockPath)
1061
- // Auto governance scheduling (non-blocking)
1062
- scheduleWeeklyGovernance();
1063
- }
1064
- export async function handleBackgroundSync() {
1065
- const phrenPathLocal = getPhrenPath();
1066
- const now = new Date().toISOString();
1067
- const lockPath = runtimeFile(phrenPathLocal, "background-sync.lock");
1068
- try {
1069
- const remotes = await runBestEffortGit(["remote"], phrenPathLocal);
1070
- if (!remotes.ok || !remotes.output) {
1071
- const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
1072
- updateRuntimeHealth(phrenPathLocal, {
1073
- lastAutoSave: { at: now, status: "saved-local", detail: "background sync skipped; no remote configured" },
1074
- lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: "background sync skipped; no remote configured", unsyncedCommits }),
1075
- });
1076
- appendAuditLog(phrenPathLocal, "background_sync", "status=saved-local detail=no_remote");
1077
- return;
1078
- }
1079
- const push = await runBestEffortGit(["push"], phrenPathLocal);
1080
- if (push.ok) {
1081
- updateRuntimeHealth(phrenPathLocal, {
1082
- lastAutoSave: { at: now, status: "saved-pushed", detail: "commit pushed by background sync" },
1083
- lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: "commit pushed by background sync", unsyncedCommits: 0 }),
1084
- });
1085
- appendAuditLog(phrenPathLocal, "background_sync", "status=saved-pushed");
1086
- return;
1087
- }
1088
- const recovered = await recoverPushConflict(phrenPathLocal);
1089
- if (recovered.ok) {
1090
- updateRuntimeHealth(phrenPathLocal, {
1091
- lastAutoSave: { at: now, status: "saved-pushed", detail: recovered.detail },
1092
- lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: recovered.detail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, successfulPullAt: now, unsyncedCommits: 0 }),
1093
- });
1094
- appendAuditLog(phrenPathLocal, "background_sync", `status=saved-pushed detail=${JSON.stringify(recovered.detail)}`);
1095
- return;
1096
- }
1097
- const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
1098
- const failDetail = recovered.detail || push.error || "background sync push failed";
1099
- updateRuntimeHealth(phrenPathLocal, {
1100
- lastAutoSave: { at: now, status: "saved-local", detail: failDetail },
1101
- lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: failDetail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, unsyncedCommits }),
1102
- });
1103
- appendAuditLog(phrenPathLocal, "background_sync", `status=saved-local detail=${JSON.stringify(failDetail)}`);
1104
- }
1105
- finally {
1106
- try {
1107
- fs.unlinkSync(lockPath);
1108
- }
1109
- catch { }
1110
- }
1111
- }
1112
- function scheduleWeeklyGovernance() {
1113
- try {
1114
- const lastGovPath = runtimeFile(getPhrenPath(), "last-governance.txt");
1115
- const lastRun = fs.existsSync(lastGovPath) ? parseInt(fs.readFileSync(lastGovPath, "utf8"), 10) : 0;
1116
- const daysSince = (Date.now() - lastRun) / 86_400_000;
1117
- if (daysSince >= 7) {
1118
- const spawnArgs = resolveSubprocessArgs("background-maintenance");
1119
- if (spawnArgs) {
1120
- const child = spawnDetachedChild(spawnArgs, { phrenPath: getPhrenPath() });
1121
- child.unref();
1122
- fs.writeFileSync(lastGovPath, Date.now().toString());
1123
- debugLog("hook_stop: scheduled weekly governance run");
1124
- }
1125
- }
1126
- }
1127
- catch (err) {
1128
- debugLog(`hook_stop: governance scheduling failed: ${errorMessage(err)}`);
1129
- }
1130
- }
1131
- export async function handleHookContext() {
1132
- const ctx = buildHookContext();
1133
- if (!ctx.hooksEnabled) {
1134
- process.exit(0);
1135
- }
1136
- let cwd = ctx.cwd;
1137
- const ctxStdin = readStdinJson();
1138
- if (ctxStdin?.cwd)
1139
- cwd = ctxStdin.cwd;
1140
- const project = cwd !== ctx.cwd ? detectProject(ctx.phrenPath, cwd, ctx.profile) : ctx.activeProject;
1141
- if (!isProjectHookEnabled(ctx.phrenPath, project, "UserPromptSubmit")) {
1142
- process.exit(0);
1143
- }
1144
- const db = await buildIndex(ctx.phrenPath, ctx.profile);
1145
- const contextLabel = project ? `\u25c6 phren \u00b7 ${project} \u00b7 context` : `\u25c6 phren \u00b7 context`;
1146
- const parts = [contextLabel, "<phren-context>"];
1147
- if (project) {
1148
- const summaryRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'summary'", [project]);
1149
- if (summaryRow) {
1150
- parts.push(`# ${project}`);
1151
- parts.push(summaryRow[0][0]);
1152
- parts.push("");
1153
- }
1154
- const findingsRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'findings'", [project]);
1155
- if (findingsRow) {
1156
- const content = findingsRow[0][0];
1157
- const bullets = content.split("\n").filter(l => l.startsWith("- ")).slice(0, 10);
1158
- if (bullets.length > 0) {
1159
- parts.push("## Recent findings");
1160
- parts.push(bullets.join("\n"));
1161
- parts.push("");
1162
- }
1163
- }
1164
- // Collect pinned tasks across ALL projects (excluding Done)
1165
- const allTaskRows = queryRows(db, "SELECT project, content FROM docs WHERE type = 'task'", []);
1166
- const pinnedFromOtherProjects = [];
1167
- if (allTaskRows) {
1168
- for (const row of allTaskRows) {
1169
- const taskProject = row[0];
1170
- if (taskProject === project)
1171
- continue;
1172
- const content = row[1];
1173
- const pinned = content.split("\n")
1174
- .filter(l => l.startsWith("- [ ] ") && /\[pinned\]/i.test(l))
1175
- .map(l => `[${taskProject}] ${l}`);
1176
- pinnedFromOtherProjects.push(...pinned);
1177
- }
1178
- }
1179
- // Active project tasks — pinned float to top, exclude Done
1180
- const taskRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'task'", [project]);
1181
- const pinnedItems = [];
1182
- const otherItems = [];
1183
- if (taskRow) {
1184
- const content = taskRow[0][0];
1185
- const allItems = content.split("\n").filter(l => l.startsWith("- [ ] "));
1186
- for (const item of allItems) {
1187
- if (/\[pinned\]/i.test(item)) {
1188
- pinnedItems.push(item);
1189
- }
1190
- else {
1191
- otherItems.push(item);
1192
- }
1193
- }
1194
- }
1195
- const filteredOther = filterTaskByPriority(otherItems);
1196
- const allPinned = [...pinnedItems, ...pinnedFromOtherProjects].slice(0, 10);
1197
- const remaining = Math.max(0, 5 - allPinned.length);
1198
- const trimmedOther = filteredOther.slice(0, remaining);
1199
- if (allPinned.length > 0 || trimmedOther.length > 0) {
1200
- if (allPinned.length > 0) {
1201
- parts.push("## Pinned tasks");
1202
- parts.push(allPinned.join("\n"));
1203
- }
1204
- if (trimmedOther.length > 0) {
1205
- parts.push("## Active tasks");
1206
- parts.push(trimmedOther.join("\n"));
1207
- }
1208
- parts.push("");
1209
- }
1210
- }
1211
- else {
1212
- const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
1213
- if (projectRows) {
1214
- parts.push("# Phren projects");
1215
- parts.push(projectRows.map(r => `- ${r[0]}`).join("\n"));
1216
- parts.push("");
1217
- }
1218
- }
1219
- parts.push("<phren-context>");
1220
- if (parts.length > 2) {
1221
- console.log(parts.join("\n"));
1222
- }
1223
- }
1224
- // ── PostToolUse hook ─────────────────────────────────────────────────────────
1225
- const INTERESTING_TOOLS = new Set(["Read", "Write", "Edit", "Bash", "Glob", "Grep"]);
1226
- const COOLDOWN_MS = parseInt(process.env.PHREN_AUTOCAPTURE_COOLDOWN_MS ?? "30000", 10);
1227
- function getSessionCap() {
1228
- if (process.env.PHREN_AUTOCAPTURE_SESSION_CAP) {
1229
- return parseInt(process.env.PHREN_AUTOCAPTURE_SESSION_CAP, 10);
1230
- }
1231
- try {
1232
- const policy = getWorkflowPolicy(getPhrenPath());
1233
- const sensitivity = policy.findingSensitivity ?? "balanced";
1234
- return FINDING_SENSITIVITY_CONFIG[sensitivity]?.sessionCap ?? 10;
1235
- }
1236
- catch {
1237
- return 10;
1238
- }
1239
- }
1240
- function flattenToolResponseText(value, maxChars = 4000) {
1241
- if (typeof value === "string")
1242
- return value;
1243
- const queue = [value];
1244
- const parts = [];
1245
- let length = 0;
1246
- while (queue.length > 0 && length < maxChars) {
1247
- const current = queue.shift();
1248
- if (typeof current === "string") {
1249
- const trimmed = current.trim();
1250
- if (!trimmed)
1251
- continue;
1252
- parts.push(trimmed);
1253
- length += trimmed.length + 1;
1254
- continue;
1255
- }
1256
- if (Array.isArray(current)) {
1257
- queue.unshift(...current);
1258
- continue;
1259
- }
1260
- if (current && typeof current === "object") {
1261
- queue.unshift(...Object.values(current));
1262
- }
1263
- }
1264
- if (parts.length > 0)
1265
- return parts.join("\n").slice(0, maxChars);
1266
- return JSON.stringify(value ?? "").slice(0, maxChars);
1267
- }
1268
- export async function handleHookTool() {
1269
- const ctx = buildHookContext();
1270
- if (!ctx.hooksEnabled) {
1271
- process.exit(0);
1272
- }
1273
- try {
1274
- const start = Date.now();
1275
- let raw = "";
1276
- if (!process.stdin.isTTY) {
1277
- try {
1278
- raw = fs.readFileSync(0, "utf-8");
1279
- }
1280
- catch (err) {
1281
- logger.debug("hooks-session", `hookTool stdinRead: ${errorMessage(err)}`);
1282
- process.exit(0);
1283
- }
1284
- }
1285
- let data;
1286
- try {
1287
- data = JSON.parse(raw);
1288
- }
1289
- catch (err) {
1290
- logger.debug("hooks-session", `hookTool stdinParse: ${errorMessage(err)}`);
1291
- process.exit(0);
1292
- }
1293
- const toolName = String(data.tool_name ?? data.tool ?? "");
1294
- if (!INTERESTING_TOOLS.has(toolName)) {
1295
- process.exit(0);
1296
- }
1297
- const sessionId = data.session_id;
1298
- const input = (data.tool_input ?? {});
1299
- const entry = {
1300
- at: new Date().toISOString(),
1301
- session_id: sessionId,
1302
- tool: toolName,
1303
- };
1304
- if (toolName === "Read" || toolName === "Write" || toolName === "Edit") {
1305
- const filePath = input.file_path ?? input.path ?? undefined;
1306
- if (filePath)
1307
- entry.file = String(filePath);
1308
- }
1309
- else if (toolName === "Bash") {
1310
- const cmd = input.command ?? undefined;
1311
- if (cmd)
1312
- entry.command = String(cmd).slice(0, 200);
1313
- }
1314
- else if (toolName === "Glob") {
1315
- const pattern = input.pattern ?? undefined;
1316
- if (pattern)
1317
- entry.file = String(pattern);
1318
- }
1319
- else if (toolName === "Grep") {
1320
- const pattern = input.pattern ?? undefined;
1321
- const searchPath = input.path ?? undefined;
1322
- if (pattern)
1323
- entry.command = `grep ${pattern}${searchPath ? ` in ${searchPath}` : ""}`.slice(0, 200);
1324
- }
1325
- const responseStr = flattenToolResponseText(data.tool_response ?? "");
1326
- if (/(error|exception|failed|no such file|ENOENT)/i.test(responseStr)) {
1327
- entry.error = responseStr.slice(0, 300);
1328
- }
1329
- const cwd = (data.cwd ?? input.cwd ?? undefined);
1330
- let activeProject = cwd ? detectProject(ctx.phrenPath, cwd, ctx.profile) : null;
1331
- if (!isProjectHookEnabled(ctx.phrenPath, activeProject, "PostToolUse")) {
1332
- appendAuditLog(ctx.phrenPath, "hook_tool", `status=project_disabled project=${activeProject}`);
1333
- process.exit(0);
1334
- }
1335
- try {
1336
- const logFile = runtimeFile(ctx.phrenPath, "tool-log.jsonl");
1337
- fs.mkdirSync(path.dirname(logFile), { recursive: true });
1338
- fs.appendFileSync(logFile, JSON.stringify(entry) + "\n");
1339
- }
1340
- catch (err) {
1341
- logger.debug("hooks-session", `hookTool toolLog: ${errorMessage(err)}`);
1342
- }
1343
- const cooldownFile = runtimeFile(ctx.phrenPath, "hook-tool-cooldown");
1344
- try {
1345
- if (fs.existsSync(cooldownFile)) {
1346
- const age = Date.now() - fs.statSync(cooldownFile).mtimeMs;
1347
- if (age < COOLDOWN_MS) {
1348
- debugLog(`hook-tool: cooldown active (${Math.round(age / 1000)}s < ${Math.round(COOLDOWN_MS / 1000)}s), skipping extraction`);
1349
- activeProject = null;
1350
- }
1351
- }
1352
- }
1353
- catch (err) {
1354
- logger.debug("hooks-session", `hookTool cooldownStat: ${errorMessage(err)}`);
1355
- }
1356
- if (activeProject && sessionId) {
1357
- try {
1358
- const capFile = sessionMarker(ctx.phrenPath, `tool-findings-${sessionId}`);
1359
- let count = 0;
1360
- if (fs.existsSync(capFile)) {
1361
- count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
1362
- }
1363
- const sessionCap = getSessionCap();
1364
- if (count >= sessionCap) {
1365
- debugLog(`hook-tool: session cap reached (${count}/${sessionCap}), skipping extraction`);
1366
- activeProject = null;
1367
- }
1368
- }
1369
- catch (err) {
1370
- logger.debug("hooks-session", `hookTool sessionCapCheck: ${errorMessage(err)}`);
1371
- }
1372
- }
1373
- const findingsLevelForTool = getProactivityLevelForFindings(ctx.phrenPath);
1374
- if (activeProject && findingsLevelForTool !== "low") {
1375
- try {
1376
- const candidates = filterToolFindingsForProactivity(extractToolFindings(toolName, input, responseStr), findingsLevelForTool);
1377
- for (const { text, confidence } of candidates) {
1378
- appendReviewQueue(ctx.phrenPath, activeProject, "Review", [text]);
1379
- debugLog(`hook-tool: queued candidate for review (conf=${confidence}): ${text.slice(0, 60)}`);
1380
- }
1381
- if (candidates.length > 0) {
1382
- try {
1383
- fs.writeFileSync(cooldownFile, Date.now().toString());
1384
- }
1385
- catch (err) {
1386
- logger.debug("hooks-session", `hookTool cooldownWrite: ${errorMessage(err)}`);
1387
- }
1388
- if (sessionId) {
1389
- try {
1390
- const capFile = sessionMarker(ctx.phrenPath, `tool-findings-${sessionId}`);
1391
- let count = 0;
1392
- try {
1393
- count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
1394
- }
1395
- catch (err) {
1396
- logger.debug("hooks-session", `hookTool capFileRead: ${errorMessage(err)}`);
1397
- }
1398
- count += candidates.length;
1399
- fs.writeFileSync(capFile, count.toString());
1400
- }
1401
- catch (err) {
1402
- logger.debug("hooks-session", `hookTool capFileWrite: ${errorMessage(err)}`);
1403
- }
1404
- }
1405
- }
1406
- }
1407
- catch (err) {
1408
- debugLog(`hook-tool: finding extraction failed: ${errorMessage(err)}`);
1409
- }
1410
- }
1411
- else if (activeProject) {
1412
- debugLog("hook-tool: skipped because findings proactivity is low");
1413
- }
1414
- const elapsed = Date.now() - start;
1415
- debugLog(`hook-tool: ${toolName} logged in ${elapsed}ms`);
1416
- process.exit(0);
1417
- }
1418
- catch (err) {
1419
- debugLog(`hook-tool: unhandled error: ${err instanceof Error ? err.stack || err.message : String(err)}`);
1420
- process.exit(0);
1421
- }
1422
- }
1423
- const EXPLICIT_TAG_PATTERN = /\[(pitfall|decision|pattern|tradeoff|architecture|bug)\]\s*(.+)/i;
1424
- export function filterToolFindingsForProactivity(candidates, level = getProactivityLevelForFindings(getPhrenPath())) {
1425
- if (level === "high")
1426
- return candidates;
1427
- if (level === "low")
1428
- return [];
1429
- return candidates.filter((candidate) => candidate.explicit === true);
1430
- }
1431
- export function extractToolFindings(toolName, input, responseStr) {
1432
- const candidates = [];
1433
- const changedContent = (toolName === "Edit" || toolName === "Write")
1434
- ? String(input.new_string ?? input.content ?? "")
1435
- : "";
1436
- const explicitSource = changedContent || responseStr;
1437
- const tagMatches = explicitSource.matchAll(new RegExp(EXPLICIT_TAG_PATTERN.source, "gi"));
1438
- for (const m of tagMatches) {
1439
- const tag = m[1].toLowerCase();
1440
- const content = m[2].replace(/\s+/g, " ").trim().slice(0, 200);
1441
- if (content) {
1442
- candidates.push({ text: `[${tag}] ${content}`, confidence: 0.85, explicit: true });
1443
- }
1444
- }
1445
- if (toolName === "Edit" || toolName === "Write") {
1446
- const filePath = String(input.file_path ?? input.path ?? "unknown");
1447
- const filename = path.basename(filePath);
1448
- if (/\b(TODO|FIXME)\b/.test(changedContent)) {
1449
- const firstLine = changedContent.split("\n").find((l) => /\b(TODO|FIXME)\b/.test(l));
1450
- if (firstLine) {
1451
- candidates.push({
1452
- text: `[pitfall] ${filename}: ${firstLine.trim().slice(0, 150)}`,
1453
- confidence: 0.45,
1454
- explicit: false,
1455
- });
1456
- }
1457
- }
1458
- if (/\btry\s*\{[\s\S]*?\bcatch\b/.test(changedContent)) {
1459
- const meaningfulLine = changedContent.split("\n").find((l) => l.trim().length > 10 && !/^\s*(try|catch|\{|\})/.test(l));
1460
- if (meaningfulLine) {
1461
- candidates.push({
1462
- text: `[pitfall] ${filename}: error handling added near "${meaningfulLine.trim().slice(0, 100)}"`,
1463
- confidence: 0.45,
1464
- explicit: false,
1465
- });
1466
- }
1467
- }
1468
- }
1469
- if (toolName === "Bash") {
1470
- const cmd = String(input.command ?? "").slice(0, 30);
1471
- const hasError = /(error|exception|failed|ENOENT|command not found|permission denied)/i.test(responseStr);
1472
- if (hasError && cmd) {
1473
- const firstErrorLine = responseStr.split("\n").find((l) => /(error|exception|failed|ENOENT|command not found|permission denied)/i.test(l));
1474
- if (firstErrorLine) {
1475
- candidates.push({
1476
- text: `[bug] command '${cmd}' failed: ${firstErrorLine.trim().slice(0, 150)}`,
1477
- confidence: 0.55,
1478
- explicit: false,
1479
- });
1480
- }
1481
- }
1482
- }
1483
- return candidates;
1484
- }
12
+ export { buildHookContext, handleGuardSkip } from "./hooks-context.js";
13
+ export { getGitContext } from "./session-git.js";
14
+ // ── Session metrics ─────────────────────────────────────────────────────────
15
+ export { trackSessionMetrics } from "./session-metrics.js";
16
+ // ── Background scheduling ───────────────────────────────────────────────────
17
+ export { resolveSubprocessArgs } from "./session-background.js";
18
+ // ── Session start ───────────────────────────────────────────────────────────
19
+ export { getUntrackedProjectNotice, getSessionStartOnboardingNotice, handleHookSessionStart, } from "./session-start.js";
20
+ // ── Session stop + background sync ──────────────────────────────────────────
21
+ export { extractConversationInsights, filterConversationInsightsForProactivity, handleHookStop, handleBackgroundSync, } from "./session-stop.js";
22
+ // ── Tool + context hooks ────────────────────────────────────────────────────
23
+ export { handleHookContext, handleHookTool, extractToolFindings, filterToolFindingsForProactivity, } from "./session-tool-hook.js";