@phren/cli 0.0.32 → 0.0.34

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 (59) hide show
  1. package/mcp/dist/cli/actions.js +3 -0
  2. package/mcp/dist/cli/config.js +3 -3
  3. package/mcp/dist/cli/govern.js +18 -8
  4. package/mcp/dist/cli/hooks-context.js +1 -1
  5. package/mcp/dist/cli/hooks-session.js +18 -62
  6. package/mcp/dist/cli/namespaces.js +1 -1
  7. package/mcp/dist/cli/search.js +5 -5
  8. package/mcp/dist/cli-hooks-prompt.js +7 -3
  9. package/mcp/dist/cli-hooks-session-handlers.js +3 -15
  10. package/mcp/dist/cli-hooks-stop.js +10 -48
  11. package/mcp/dist/content/archive.js +8 -20
  12. package/mcp/dist/content/learning.js +29 -8
  13. package/mcp/dist/data/access.js +13 -4
  14. package/mcp/dist/finding/lifecycle.js +9 -3
  15. package/mcp/dist/governance/audit.js +13 -5
  16. package/mcp/dist/governance/policy.js +13 -0
  17. package/mcp/dist/governance/rbac.js +1 -1
  18. package/mcp/dist/governance/scores.js +2 -1
  19. package/mcp/dist/hooks.js +52 -6
  20. package/mcp/dist/index.js +1 -1
  21. package/mcp/dist/init/init.js +66 -45
  22. package/mcp/dist/init/shared.js +1 -1
  23. package/mcp/dist/init-bootstrap.js +0 -47
  24. package/mcp/dist/init-fresh.js +13 -18
  25. package/mcp/dist/init-uninstall.js +22 -0
  26. package/mcp/dist/init-walkthrough.js +19 -24
  27. package/mcp/dist/link/doctor.js +9 -0
  28. package/mcp/dist/package-metadata.js +1 -1
  29. package/mcp/dist/phren-art.js +4 -120
  30. package/mcp/dist/proactivity.js +1 -1
  31. package/mcp/dist/project-topics.js +16 -46
  32. package/mcp/dist/provider-adapters.js +1 -1
  33. package/mcp/dist/runtime-profile.js +1 -1
  34. package/mcp/dist/shared/data-utils.js +25 -0
  35. package/mcp/dist/shared/fragment-graph.js +4 -18
  36. package/mcp/dist/shared/index.js +14 -10
  37. package/mcp/dist/shared/ollama.js +23 -5
  38. package/mcp/dist/shared/process.js +24 -0
  39. package/mcp/dist/shared/retrieval.js +7 -4
  40. package/mcp/dist/shared/search-fallback.js +1 -0
  41. package/mcp/dist/shared.js +2 -1
  42. package/mcp/dist/shell/render.js +1 -1
  43. package/mcp/dist/skill/registry.js +1 -1
  44. package/mcp/dist/skill/state.js +0 -3
  45. package/mcp/dist/task/github.js +1 -0
  46. package/mcp/dist/task/lifecycle.js +1 -6
  47. package/mcp/dist/tools/config.js +415 -400
  48. package/mcp/dist/tools/finding.js +390 -373
  49. package/mcp/dist/tools/ops.js +372 -365
  50. package/mcp/dist/tools/search.js +495 -487
  51. package/mcp/dist/tools/session.js +3 -2
  52. package/mcp/dist/tools/skills.js +9 -0
  53. package/mcp/dist/ui/page.js +1 -1
  54. package/mcp/dist/ui/server.js +645 -1040
  55. package/mcp/dist/utils.js +12 -8
  56. package/package.json +1 -1
  57. package/mcp/dist/init-dryrun.js +0 -55
  58. package/mcp/dist/init-migrate.js +0 -51
  59. package/mcp/dist/init-walkthrough-merge.js +0 -90
@@ -290,6 +290,9 @@ export async function handleUpdate(args) {
290
290
  if (!result.ok) {
291
291
  process.exitCode = 1;
292
292
  }
293
+ else {
294
+ console.log("Run 'npx phren init' to refresh hooks and config.");
295
+ }
293
296
  }
294
297
  export async function handleReview(args) {
295
298
  const phrenPath = getPhrenPath();
@@ -8,7 +8,7 @@ import { PROACTIVITY_LEVELS, getProactivityLevel, getProactivityLevelForTask, ge
8
8
  import { PROJECT_OWNERSHIP_MODES, getProjectOwnershipDefault, parseProjectOwnershipMode, updateProjectConfigOverrides, } from "../project-config.js";
9
9
  import { isValidProjectName, learnedSynonymsPath, learnSynonym, loadLearnedSynonyms, removeLearnedSynonym, } from "../utils.js";
10
10
  // ── Shared helpers ────────────────────────────────────────────────────────────
11
- export function parseProjectArg(args) {
11
+ function parseProjectArg(args) {
12
12
  const project = args.find((a) => a.startsWith("--project="))?.slice("--project=".length)
13
13
  ?? (args.indexOf("--project") !== -1 ? args[args.indexOf("--project") + 1] : undefined);
14
14
  const rest = args.filter((a, i) => a !== "--project" && !a.startsWith("--project=") && args[i - 1] !== "--project");
@@ -53,7 +53,7 @@ function formatConfigAsTable(label, rows) {
53
53
  console.log(` ${k.padEnd(maxKey)} ${v}`);
54
54
  }
55
55
  }
56
- export function handleConfigShow(args) {
56
+ function handleConfigShow(args) {
57
57
  const phrenPath = getPhrenPath();
58
58
  const { project: projectArg } = parseProjectArg(args);
59
59
  if (projectArg) {
@@ -461,7 +461,7 @@ function handleConfigFindingSensitivity(args) {
461
461
  // ── LLM config ───────────────────────────────────────────────────────────────
462
462
  const EXPENSIVE_MODEL_RE = /opus|sonnet|gpt-4(?!o-mini)/i;
463
463
  const DEFAULT_LLM_MODEL = "gpt-4o-mini / claude-haiku-4-5-20251001";
464
- export function printSemanticCostNotice(model) {
464
+ function printSemanticCostNotice(model) {
465
465
  const effectiveModel = model || process.env.PHREN_LLM_MODEL || DEFAULT_LLM_MODEL;
466
466
  console.log(` Note: Each semantic check is ~80 input + ~5 output tokens (one call per 'maybe' pair, cached 24h).`);
467
467
  console.log(` Current model: ${effectiveModel}`);
@@ -228,10 +228,10 @@ export async function handleConsolidateMemories(args = []) {
228
228
  return;
229
229
  console.log(`Updated backups (${backups.length}): ${backups.join(", ")}`);
230
230
  }
231
- export async function handleGcMaintain(args = []) {
231
+ async function handleGcMaintain(args = []) {
232
232
  const dryRun = args.includes("--dry-run");
233
233
  const phrenPath = getPhrenPath();
234
- const { execSync } = await import("child_process");
234
+ const { execFileSync } = await import("child_process");
235
235
  const report = {
236
236
  gitGcRan: false,
237
237
  commitsSquashed: 0,
@@ -244,7 +244,7 @@ export async function handleGcMaintain(args = []) {
244
244
  }
245
245
  else {
246
246
  try {
247
- execSync("git gc --aggressive --quiet", { cwd: phrenPath, stdio: "pipe" });
247
+ execFileSync("git", ["gc", "--aggressive", "--quiet"], { cwd: phrenPath, stdio: "pipe" });
248
248
  report.gitGcRan = true;
249
249
  console.log("git gc --aggressive: done");
250
250
  }
@@ -256,7 +256,10 @@ export async function handleGcMaintain(args = []) {
256
256
  const sevenDaysAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
257
257
  let oldCommits = [];
258
258
  try {
259
- const raw = execSync(`git log --before="${sevenDaysAgo}" --format="%H %ci %s"`, { cwd: phrenPath, encoding: "utf8" }).trim();
259
+ const raw = execFileSync("git", ["log", `--before=${sevenDaysAgo}`, "--format=%H %ci %s"], {
260
+ cwd: phrenPath,
261
+ encoding: "utf8",
262
+ }).trim();
260
263
  if (raw) {
261
264
  oldCommits = raw.split("\n").filter((l) => l.includes("auto-save:") || l.includes("[auto]"));
262
265
  }
@@ -295,8 +298,11 @@ export async function handleGcMaintain(args = []) {
295
298
  try {
296
299
  const oldest = hashes[hashes.length - 1];
297
300
  const newest = hashes[0];
298
- // Use git rebase --onto to squash: squash all into the oldest parent
299
- const parentOfOldest = execSync(`git rev-parse ${oldest}^`, { cwd: phrenPath, encoding: "utf8" }).trim();
301
+ // Use git rev-parse to get the parent of the oldest commit
302
+ const parentOfOldest = execFileSync("git", ["rev-parse", `${oldest}^`], {
303
+ cwd: phrenPath,
304
+ encoding: "utf8",
305
+ }).trim();
300
306
  // Build rebase script via env variable to squash all but first to "squash"
301
307
  const rebaseScript = hashes
302
308
  .map((h, i) => `${i === hashes.length - 1 ? "pick" : "squash"} ${h} auto-save`)
@@ -304,8 +310,12 @@ export async function handleGcMaintain(args = []) {
304
310
  .join("\n");
305
311
  const scriptPath = path.join(phrenPath, ".runtime", `gc-rebase-${weekKey}.sh`);
306
312
  fs.writeFileSync(scriptPath, rebaseScript);
307
- // Use GIT_SEQUENCE_EDITOR to feed our script
308
- execSync(`GIT_SEQUENCE_EDITOR="cat ${scriptPath} >" git rebase -i ${parentOfOldest}`, { cwd: phrenPath, stdio: "pipe" });
313
+ // Use GIT_SEQUENCE_EDITOR env var to feed our script to git rebase
314
+ execFileSync("git", ["rebase", "-i", parentOfOldest], {
315
+ cwd: phrenPath,
316
+ stdio: "pipe",
317
+ env: { ...process.env, GIT_SEQUENCE_EDITOR: `cat ${scriptPath} >` },
318
+ });
309
319
  fs.unlinkSync(scriptPath);
310
320
  report.commitsSquashed += hashes.length - 1;
311
321
  console.log(`Squashed ${hashes.length} auto-save commits for week of ${weekKey} (${newest.slice(0, 7)}..${oldest.slice(0, 7)}).`);
@@ -39,7 +39,7 @@ export function handleGuardSkip(ctx, hookName, reason, healthUpdate) {
39
39
  }
40
40
  // Re-export frequently used functions so hook handlers can import from one place
41
41
  export { debugLog, appendAuditLog, getPhrenPath, readRootManifest, sessionMarker, runtimeFile, EXEC_TIMEOUT_MS, getProjectDirs, findProjectNameCaseInsensitive, homePath, } from "../shared.js";
42
- export { updateRuntimeHealth, getWorkflowPolicy, withFileLock, appendReviewQueue, recordFeedback, getQualityMultiplier, } from "../shared/governance.js";
42
+ export { updateRuntimeHealth, buildSyncStatus, getWorkflowPolicy, withFileLock, appendReviewQueue, recordFeedback, getQualityMultiplier, } from "../shared/governance.js";
43
43
  export { detectProject } from "../shared/index.js";
44
44
  export { isProjectHookEnabled, readProjectConfig, getProjectSourcePath } from "../project-config.js";
45
45
  export { resolveRuntimeProfile } from "../runtime-profile.js";
@@ -1,4 +1,4 @@
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, } from "./hooks-context.js";
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
2
  import { sessionMetricsFile, qualityMarkers, } from "../shared.js";
3
3
  import { autoMergeConflicts, mergeTask, mergeFindings, } from "../shared/content.js";
4
4
  import { runGit } from "../utils.js";
@@ -6,16 +6,16 @@ import { readInstallPreferences } from "../init/preferences.js";
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import * as os from "os";
9
- import { execFileSync, spawn } from "child_process";
9
+ import { execFileSync } from "child_process";
10
+ import { spawnDetachedChild } from "../shared/process.js";
10
11
  import { fileURLToPath } from "url";
11
12
  import { isTaskFileName, TASKS_FILENAME } from "../data/tasks.js";
12
13
  import { buildIndex, queryRows, } from "../shared/index.js";
13
14
  import { filterTaskByPriority } from "../shared/retrieval.js";
14
15
  import { logger } from "../logger.js";
15
- function getRuntimeProfile() {
16
- return resolveRuntimeProfile(getPhrenPath());
17
- }
18
- export { buildHookContext, checkHookGuard, handleGuardSkip } from "./hooks-context.js";
16
+ const SYNC_LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes
17
+ const MAINTENANCE_LOCK_STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
18
+ export { buildHookContext, handleGuardSkip } from "./hooks-context.js";
19
19
  /** Read JSON from stdin if it's not a TTY. Returns null if stdin is a TTY or parsing fails. */
20
20
  function readStdinJson() {
21
21
  if (process.stdin.isTTY)
@@ -252,7 +252,7 @@ function scheduleBackgroundSync(phrenPathLocal) {
252
252
  try {
253
253
  if (fs.existsSync(lockPath)) {
254
254
  const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
255
- if (ageMs <= 10 * 60 * 1000)
255
+ if (ageMs <= SYNC_LOCK_STALE_MS)
256
256
  return false;
257
257
  fs.unlinkSync(lockPath);
258
258
  }
@@ -265,16 +265,7 @@ function scheduleBackgroundSync(phrenPathLocal) {
265
265
  fs.writeFileSync(lockPath, JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid }) + "\n", { flag: "wx" });
266
266
  const logFd = fs.openSync(logPath, "a");
267
267
  fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
268
- const child = spawn(process.execPath, spawnArgs, {
269
- cwd: process.cwd(),
270
- detached: true,
271
- stdio: ["ignore", logFd, logFd],
272
- env: {
273
- ...process.env,
274
- PHREN_PATH: phrenPathLocal,
275
- PHREN_PROFILE: getRuntimeProfile(),
276
- },
277
- });
268
+ const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
278
269
  child.unref();
279
270
  fs.closeSync(logFd);
280
271
  return true;
@@ -297,7 +288,7 @@ function scheduleBackgroundMaintenance(phrenPathLocal, project) {
297
288
  if (fs.existsSync(markers.lock)) {
298
289
  try {
299
290
  const ageMs = Date.now() - fs.statSync(markers.lock).mtimeMs;
300
- if (ageMs <= 2 * 60 * 60 * 1000)
291
+ if (ageMs <= MAINTENANCE_LOCK_STALE_MS)
301
292
  return false;
302
293
  fs.unlinkSync(markers.lock);
303
294
  }
@@ -339,16 +330,7 @@ function scheduleBackgroundMaintenance(phrenPathLocal, project) {
339
330
  const logPath = path.join(logDir, "background-maintenance.log");
340
331
  const logFd = fs.openSync(logPath, "a");
341
332
  fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
342
- const child = spawn(process.execPath, spawnArgs, {
343
- cwd: process.cwd(),
344
- detached: true,
345
- stdio: ["ignore", logFd, logFd],
346
- env: {
347
- ...process.env,
348
- PHREN_PATH: phrenPathLocal,
349
- PHREN_PROFILE: getRuntimeProfile(),
350
- },
351
- });
333
+ const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
352
334
  child.on("exit", (code, signal) => {
353
335
  const msg = `[${new Date().toISOString()}] exit code=${code ?? "null"} signal=${signal ?? "none"}\n`;
354
336
  try {
@@ -1019,12 +1001,7 @@ export async function handleBackgroundSync() {
1019
1001
  const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
1020
1002
  updateRuntimeHealth(phrenPathLocal, {
1021
1003
  lastAutoSave: { at: now, status: "saved-local", detail: "background sync skipped; no remote configured" },
1022
- lastSync: {
1023
- lastPushAt: now,
1024
- lastPushStatus: "saved-local",
1025
- lastPushDetail: "background sync skipped; no remote configured",
1026
- unsyncedCommits,
1027
- },
1004
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: "background sync skipped; no remote configured", unsyncedCommits }),
1028
1005
  });
1029
1006
  appendAuditLog(phrenPathLocal, "background_sync", "status=saved-local detail=no_remote");
1030
1007
  return;
@@ -1033,12 +1010,7 @@ export async function handleBackgroundSync() {
1033
1010
  if (push.ok) {
1034
1011
  updateRuntimeHealth(phrenPathLocal, {
1035
1012
  lastAutoSave: { at: now, status: "saved-pushed", detail: "commit pushed by background sync" },
1036
- lastSync: {
1037
- lastPushAt: now,
1038
- lastPushStatus: "saved-pushed",
1039
- lastPushDetail: "commit pushed by background sync",
1040
- unsyncedCommits: 0,
1041
- },
1013
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: "commit pushed by background sync", unsyncedCommits: 0 }),
1042
1014
  });
1043
1015
  appendAuditLog(phrenPathLocal, "background_sync", "status=saved-pushed");
1044
1016
  return;
@@ -1047,34 +1019,18 @@ export async function handleBackgroundSync() {
1047
1019
  if (recovered.ok) {
1048
1020
  updateRuntimeHealth(phrenPathLocal, {
1049
1021
  lastAutoSave: { at: now, status: "saved-pushed", detail: recovered.detail },
1050
- lastSync: {
1051
- lastPullAt: now,
1052
- lastPullStatus: recovered.pullStatus,
1053
- lastPullDetail: recovered.pullDetail,
1054
- lastSuccessfulPullAt: now,
1055
- lastPushAt: now,
1056
- lastPushStatus: "saved-pushed",
1057
- lastPushDetail: recovered.detail,
1058
- unsyncedCommits: 0,
1059
- },
1022
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: recovered.detail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, successfulPullAt: now, unsyncedCommits: 0 }),
1060
1023
  });
1061
1024
  appendAuditLog(phrenPathLocal, "background_sync", `status=saved-pushed detail=${JSON.stringify(recovered.detail)}`);
1062
1025
  return;
1063
1026
  }
1064
1027
  const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
1028
+ const failDetail = recovered.detail || push.error || "background sync push failed";
1065
1029
  updateRuntimeHealth(phrenPathLocal, {
1066
- lastAutoSave: { at: now, status: "saved-local", detail: recovered.detail || push.error || "background sync push failed" },
1067
- lastSync: {
1068
- lastPullAt: now,
1069
- lastPullStatus: recovered.pullStatus,
1070
- lastPullDetail: recovered.pullDetail,
1071
- lastPushAt: now,
1072
- lastPushStatus: "saved-local",
1073
- lastPushDetail: recovered.detail || push.error || "background sync push failed",
1074
- unsyncedCommits,
1075
- },
1030
+ lastAutoSave: { at: now, status: "saved-local", detail: failDetail },
1031
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: failDetail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, unsyncedCommits }),
1076
1032
  });
1077
- appendAuditLog(phrenPathLocal, "background_sync", `status=saved-local detail=${JSON.stringify(recovered.detail || push.error || "background sync push failed")}`);
1033
+ appendAuditLog(phrenPathLocal, "background_sync", `status=saved-local detail=${JSON.stringify(failDetail)}`);
1078
1034
  }
1079
1035
  finally {
1080
1036
  try {
@@ -1091,7 +1047,7 @@ function scheduleWeeklyGovernance() {
1091
1047
  if (daysSince >= 7) {
1092
1048
  const spawnArgs = resolveSubprocessArgs("background-maintenance");
1093
1049
  if (spawnArgs) {
1094
- const child = spawn(process.execPath, spawnArgs, { detached: true, stdio: "ignore" });
1050
+ const child = spawnDetachedChild(spawnArgs, { phrenPath: getPhrenPath() });
1095
1051
  child.unref();
1096
1052
  fs.writeFileSync(lastGovPath, Date.now().toString());
1097
1053
  debugLog("hook_stop: scheduled weekly governance run");
@@ -667,7 +667,7 @@ export async function handleProjectsNamespace(args, profile) {
667
667
  console.error(`Project "${name}" not found.`);
668
668
  process.exit(1);
669
669
  }
670
- const { readFindings, readTasks, resolveTaskFilePath, TASKS_FILENAME } = await import("../data/access.js");
670
+ const { readFindings, readTasks, resolveTaskFilePath } = await import("../data/access.js");
671
671
  const exported = { project: name, exportedAt: new Date().toISOString(), version: 1 };
672
672
  const summaryPath = path.join(projectDir, "summary.md");
673
673
  if (fs.existsSync(summaryPath))
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { runtimeFile } from "../shared.js";
4
- import { buildIndex, extractSnippet, queryDocRows, queryRows, queryEntityLinks, queryDocBySourceKey, logEntityMiss } from "../shared/index.js";
4
+ import { buildIndex, extractSnippet, queryDocRows, queryRows, queryFragmentLinks, queryDocBySourceKey, logFragmentMiss } from "../shared/index.js";
5
5
  import { buildFtsQueryVariants, errorMessage, isValidProjectName } from "../utils.js";
6
6
  import { logger } from "../logger.js";
7
7
  import { keywordFallbackSearch } from "../core/search.js";
@@ -24,7 +24,7 @@ const SEARCH_TYPES = new Set([
24
24
  function historyFile(phrenPath) {
25
25
  return runtimeFile(phrenPath, "search-history.jsonl");
26
26
  }
27
- export function readSearchHistory(phrenPath) {
27
+ function readSearchHistory(phrenPath) {
28
28
  const file = historyFile(phrenPath);
29
29
  if (!fs.existsSync(file))
30
30
  return [];
@@ -319,7 +319,7 @@ export async function runFragmentSearch(query, phrenPath, profile, opts) {
319
319
  }
320
320
  const rows = queryRows(db, sql, params);
321
321
  if (!rows || rows.length === 0) {
322
- logEntityMiss(phrenPath, query, "cli_search_fragments", opts.project);
322
+ logFragmentMiss(phrenPath, query, "cli_search_fragments", opts.project);
323
323
  return { lines: [`No fragments matching "${query}".`], exitCode: 0 };
324
324
  }
325
325
  const lines = [`Fragments matching "${query}" (${rows.length} result(s)):\n`];
@@ -362,14 +362,14 @@ export async function runRelatedDocs(entity, phrenPath, profile, opts) {
362
362
  }
363
363
  const db = await buildIndex(phrenPath, profile);
364
364
  const max = opts.limit ?? 10;
365
- const links = queryEntityLinks(db, entity.toLowerCase());
365
+ const links = queryFragmentLinks(db, entity.toLowerCase());
366
366
  let relatedDocs = links.related.filter(r => r.includes("/"));
367
367
  if (opts.project) {
368
368
  relatedDocs = relatedDocs.filter(d => d.startsWith(`${opts.project}/`));
369
369
  }
370
370
  relatedDocs = relatedDocs.slice(0, max);
371
371
  if (relatedDocs.length === 0) {
372
- logEntityMiss(phrenPath, entity, "cli_related_docs", opts.project);
372
+ logFragmentMiss(phrenPath, entity, "cli_related_docs", opts.project);
373
373
  return { lines: [`No docs found referencing fragment "${entity}".`], exitCode: 0 };
374
374
  }
375
375
  const lines = [`Docs referencing "${entity}" (${relatedDocs.length} result(s)):\n`];
@@ -36,7 +36,10 @@ export async function handleHookContext() {
36
36
  const findingsRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'findings'", [project]);
37
37
  if (findingsRow) {
38
38
  const content = findingsRow[0][0];
39
- const bullets = content.split("\n").filter(l => l.startsWith("- ")).slice(0, 10);
39
+ const bullets = content.split("\n")
40
+ .filter(l => l.startsWith("- "))
41
+ .filter(l => !(/<!--\s*superseded_by:/.test(l) || /<!--\s*(?:phren:)?retract/.test(l) || /status\s+"retracted"/.test(l) || /status\s+"superseded"/.test(l)))
42
+ .slice(0, 10);
40
43
  if (bullets.length > 0) {
41
44
  parts.push("## Recent findings");
42
45
  parts.push(bullets.join("\n"));
@@ -72,6 +75,7 @@ export async function handleHookContext() {
72
75
  // ── PostToolUse hook ─────────────────────────────────────────────────────────
73
76
  const INTERESTING_TOOLS = new Set(["Read", "Write", "Edit", "Bash", "Glob", "Grep"]);
74
77
  const COOLDOWN_MS = parseInt(process.env.PHREN_AUTOCAPTURE_COOLDOWN_MS ?? "30000", 10);
78
+ const MAX_TOOL_COMMAND_LENGTH = 200;
75
79
  function flattenToolResponseText(value, maxChars = 4000) {
76
80
  if (typeof value === "string")
77
81
  return value;
@@ -144,7 +148,7 @@ export async function handleHookTool() {
144
148
  else if (toolName === "Bash") {
145
149
  const cmd = input.command ?? undefined;
146
150
  if (cmd)
147
- entry.command = String(cmd).slice(0, 200);
151
+ entry.command = String(cmd).slice(0, MAX_TOOL_COMMAND_LENGTH);
148
152
  }
149
153
  else if (toolName === "Glob") {
150
154
  const pattern = input.pattern ?? undefined;
@@ -155,7 +159,7 @@ export async function handleHookTool() {
155
159
  const pattern = input.pattern ?? undefined;
156
160
  const searchPath = input.path ?? undefined;
157
161
  if (pattern)
158
- entry.command = `grep ${pattern}${searchPath ? ` in ${searchPath}` : ""}`.slice(0, 200);
162
+ entry.command = `grep ${pattern}${searchPath ? ` in ${searchPath}` : ""}`.slice(0, MAX_TOOL_COMMAND_LENGTH);
159
163
  }
160
164
  const responseStr = flattenToolResponseText(data.tool_response ?? "");
161
165
  if (/(error|exception|failed|no such file|ENOENT)/i.test(responseStr)) {
@@ -2,18 +2,15 @@
2
2
  * SessionStart handler and onboarding helpers.
3
3
  * Extracted from cli-hooks-session.ts for modularity.
4
4
  */
5
- import { buildHookContext, handleGuardSkip, debugLog, sessionMarker, getPhrenPath, getProjectDirs, findProjectNameCaseInsensitive, updateRuntimeHealth, appendAuditLog, detectProject, isProjectHookEnabled, readProjectConfig, getProjectSourcePath, detectProjectDir, ensureLocalGitRepo, isProjectTracked, repairPreexistingInstall, isFeatureEnabled, errorMessage, runDoctor, resolveRuntimeProfile, } from "./cli/hooks-context.js";
5
+ import { buildHookContext, handleGuardSkip, debugLog, sessionMarker, getProjectDirs, findProjectNameCaseInsensitive, updateRuntimeHealth, appendAuditLog, detectProject, isProjectHookEnabled, readProjectConfig, getProjectSourcePath, detectProjectDir, ensureLocalGitRepo, isProjectTracked, repairPreexistingInstall, isFeatureEnabled, errorMessage, runDoctor, resolveRuntimeProfile, } from "./cli/hooks-context.js";
6
6
  import { qualityMarkers, } from "./shared.js";
7
7
  import { readInstallPreferences } from "./init/preferences.js";
8
8
  import { logger } from "./logger.js";
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
11
- import { spawn } from "child_process";
11
+ import { spawnDetachedChild } from "./shared/process.js";
12
12
  import { TASKS_FILENAME } from "./data/tasks.js";
13
13
  import { resolveSubprocessArgs as _resolveSubprocessArgs, runBestEffortGit, countUnsyncedCommits, } from "./cli-hooks-git.js";
14
- function getRuntimeProfile() {
15
- return resolveRuntimeProfile(getPhrenPath());
16
- }
17
14
  const SESSION_START_ONBOARDING_MARKER = "session-start-onboarding-v1";
18
15
  const SYNC_WARN_MARKER = "sync-broken-warned-v1";
19
16
  function projectHasBootstrapSignals(phrenPath, project) {
@@ -156,16 +153,7 @@ function scheduleBackgroundMaintenance(phrenPathLocal, project) {
156
153
  const logPath = path.join(logDir, "background-maintenance.log");
157
154
  const logFd = fs.openSync(logPath, "a");
158
155
  fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
159
- const child = spawn(process.execPath, spawnArgs, {
160
- cwd: process.cwd(),
161
- detached: true,
162
- stdio: ["ignore", logFd, logFd],
163
- env: {
164
- ...process.env,
165
- PHREN_PATH: phrenPathLocal,
166
- PHREN_PROFILE: getRuntimeProfile(),
167
- },
168
- });
156
+ const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
169
157
  child.on("exit", (code, signal) => {
170
158
  const msg = `[${new Date().toISOString()}] exit code=${code ?? "null"} signal=${signal ?? "none"}\n`;
171
159
  try {
@@ -2,16 +2,14 @@
2
2
  * Stop hook handler: git commit/push, background sync, auto-capture, governance.
3
3
  * Extracted from cli-hooks-session.ts for modularity.
4
4
  */
5
- import { buildHookContext, handleGuardSkip, debugLog, runtimeFile, sessionMarker, getPhrenPath, updateRuntimeHealth, appendAuditLog, withFileLock, getWorkflowPolicy, isProjectHookEnabled, ensureLocalGitRepo, getProactivityLevelForTask, getProactivityLevelForFindings, hasExplicitFindingSignal, shouldAutoCaptureFindingsForLevel, FINDING_SENSITIVITY_CONFIG, isFeatureEnabled, errorMessage, bootstrapPhrenDotEnv, finalizeTaskSession, appendFindingJournal, homePath, resolveRuntimeProfile, } from "./cli/hooks-context.js";
5
+ import { buildHookContext, handleGuardSkip, debugLog, runtimeFile, sessionMarker, getPhrenPath, updateRuntimeHealth, buildSyncStatus, appendAuditLog, withFileLock, getWorkflowPolicy, isProjectHookEnabled, ensureLocalGitRepo, getProactivityLevelForTask, getProactivityLevelForFindings, hasExplicitFindingSignal, shouldAutoCaptureFindingsForLevel, FINDING_SENSITIVITY_CONFIG, isFeatureEnabled, errorMessage, bootstrapPhrenDotEnv, finalizeTaskSession, appendFindingJournal, homePath, } from "./cli/hooks-context.js";
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import * as os from "os";
9
- import { spawn } from "child_process";
9
+ import { spawnDetachedChild } from "./shared/process.js";
10
10
  import { resolveSubprocessArgs as _resolveSubprocessArgs, runBestEffortGit, countUnsyncedCommits, recoverPushConflict, } from "./cli-hooks-git.js";
11
11
  import { logger } from "./logger.js";
12
- function getRuntimeProfile() {
13
- return resolveRuntimeProfile(getPhrenPath());
14
- }
12
+ const SYNC_LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes
15
13
  /** Read JSON from stdin if it's not a TTY. Returns null if stdin is a TTY or parsing fails. */
16
14
  export function readStdinJson() {
17
15
  if (process.stdin.isTTY)
@@ -112,7 +110,7 @@ function scheduleBackgroundSync(phrenPathLocal) {
112
110
  try {
113
111
  if (fs.existsSync(lockPath)) {
114
112
  const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
115
- if (ageMs <= 10 * 60 * 1000)
113
+ if (ageMs <= SYNC_LOCK_STALE_MS)
116
114
  return false;
117
115
  fs.unlinkSync(lockPath);
118
116
  }
@@ -125,16 +123,7 @@ function scheduleBackgroundSync(phrenPathLocal) {
125
123
  fs.writeFileSync(lockPath, JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid }) + "\n", { flag: "wx" });
126
124
  const logFd = fs.openSync(logPath, "a");
127
125
  fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
128
- const child = spawn(process.execPath, spawnArgs, {
129
- cwd: process.cwd(),
130
- detached: true,
131
- stdio: ["ignore", logFd, logFd],
132
- env: {
133
- ...process.env,
134
- PHREN_PATH: phrenPathLocal,
135
- PHREN_PROFILE: getRuntimeProfile(),
136
- },
137
- });
126
+ const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
138
127
  child.unref();
139
128
  fs.closeSync(logFd);
140
129
  return true;
@@ -156,7 +145,7 @@ function scheduleWeeklyGovernance() {
156
145
  if (daysSince >= 7) {
157
146
  const spawnArgs = _resolveSubprocessArgs("background-maintenance");
158
147
  if (spawnArgs) {
159
- const child = spawn(process.execPath, spawnArgs, { detached: true, stdio: "ignore" });
148
+ const child = spawnDetachedChild(spawnArgs, { phrenPath: getPhrenPath() });
160
149
  child.unref();
161
150
  fs.writeFileSync(lastGovPath, Date.now().toString());
162
151
  debugLog("hook_stop: scheduled weekly governance run");
@@ -481,12 +470,7 @@ export async function handleBackgroundSync() {
481
470
  const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
482
471
  updateRuntimeHealth(phrenPathLocal, {
483
472
  lastAutoSave: { at: now, status: "saved-local", detail: "background sync skipped; no remote configured" },
484
- lastSync: {
485
- lastPushAt: now,
486
- lastPushStatus: "saved-local",
487
- lastPushDetail: "background sync skipped; no remote configured",
488
- unsyncedCommits,
489
- },
473
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: "background sync skipped; no remote configured", unsyncedCommits }),
490
474
  });
491
475
  appendAuditLog(phrenPathLocal, "background_sync", "status=saved-local detail=no_remote");
492
476
  return;
@@ -495,12 +479,7 @@ export async function handleBackgroundSync() {
495
479
  if (push.ok) {
496
480
  updateRuntimeHealth(phrenPathLocal, {
497
481
  lastAutoSave: { at: now, status: "saved-pushed", detail: "commit pushed by background sync" },
498
- lastSync: {
499
- lastPushAt: now,
500
- lastPushStatus: "saved-pushed",
501
- lastPushDetail: "commit pushed by background sync",
502
- unsyncedCommits: 0,
503
- },
482
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: "commit pushed by background sync", unsyncedCommits: 0 }),
504
483
  });
505
484
  appendAuditLog(phrenPathLocal, "background_sync", "status=saved-pushed");
506
485
  return;
@@ -509,16 +488,7 @@ export async function handleBackgroundSync() {
509
488
  if (recovered.ok) {
510
489
  updateRuntimeHealth(phrenPathLocal, {
511
490
  lastAutoSave: { at: now, status: "saved-pushed", detail: recovered.detail },
512
- lastSync: {
513
- lastPullAt: now,
514
- lastPullStatus: recovered.pullStatus,
515
- lastPullDetail: recovered.pullDetail,
516
- lastSuccessfulPullAt: now,
517
- lastPushAt: now,
518
- lastPushStatus: "saved-pushed",
519
- lastPushDetail: recovered.detail,
520
- unsyncedCommits: 0,
521
- },
491
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: recovered.detail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, successfulPullAt: now, unsyncedCommits: 0 }),
522
492
  });
523
493
  appendAuditLog(phrenPathLocal, "background_sync", `status=saved-pushed detail=${JSON.stringify(recovered.detail)}`);
524
494
  return;
@@ -527,15 +497,7 @@ export async function handleBackgroundSync() {
527
497
  const failDetail = recovered.detail || push.error || "background sync push failed";
528
498
  updateRuntimeHealth(phrenPathLocal, {
529
499
  lastAutoSave: { at: now, status: "saved-local", detail: failDetail },
530
- lastSync: {
531
- lastPullAt: now,
532
- lastPullStatus: recovered.pullStatus,
533
- lastPullDetail: recovered.pullDetail,
534
- lastPushAt: now,
535
- lastPushStatus: "saved-local",
536
- lastPushDetail: failDetail,
537
- unsyncedCommits,
538
- },
500
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: failDetail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, unsyncedCommits }),
539
501
  });
540
502
  appendAuditLog(phrenPathLocal, "background_sync", `status=saved-local detail=${JSON.stringify(failDetail)}`);
541
503
  // Append to sync-warnings.jsonl so health_check and session_start can surface recent failures
@@ -4,6 +4,7 @@ import * as crypto from "crypto";
4
4
  import { debugLog, runtimeFile, phrenOk, phrenErr, PhrenError, appendAuditLog, tryUnlink } from "../shared.js";
5
5
  import { isValidProjectName, safeProjectPath, errorMessage } from "../utils.js";
6
6
  import { withFileLock } from "../shared/governance.js";
7
+ import { walkDirectory } from "../shared/data-utils.js";
7
8
  import { appendArchivedEntriesToTopicDoc, classifyTopicForText, readProjectTopics, topicReferencePath } from "../project-topics.js";
8
9
  import { isCitationLine, isArchiveStart, isArchiveEnd, stripComments } from "./metadata.js";
9
10
  import { logger } from "../logger.js";
@@ -81,28 +82,15 @@ function parseActiveEntries(content) {
81
82
  /** Build a Set of normalized bullet strings from all .md files in referenceDir. */
82
83
  function buildArchivedBulletSet(referenceDir) {
83
84
  const bulletSet = new Set();
84
- if (!fs.existsSync(referenceDir))
85
- return bulletSet;
86
85
  try {
87
- const stack = [referenceDir];
88
- while (stack.length > 0) {
89
- const current = stack.pop();
90
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
91
- const fullPath = path.join(current, entry.name);
92
- if (entry.isDirectory()) {
93
- stack.push(fullPath);
86
+ for (const filePath of walkDirectory(referenceDir)) {
87
+ const content = fs.readFileSync(filePath, "utf8");
88
+ for (const line of content.split("\n")) {
89
+ if (!line.startsWith("- "))
94
90
  continue;
95
- }
96
- if (!entry.isFile() || !entry.name.endsWith(".md"))
97
- continue;
98
- const content = fs.readFileSync(fullPath, "utf8");
99
- for (const line of content.split("\n")) {
100
- if (!line.startsWith("- "))
101
- continue;
102
- const normalizedLine = stripComments(line).replace(/^-\s+/, "").trim().toLowerCase();
103
- if (normalizedLine)
104
- bulletSet.add(normalizedLine);
105
- }
91
+ const normalizedLine = stripComments(line).replace(/^-\s+/, "").trim().toLowerCase();
92
+ if (normalizedLine)
93
+ bulletSet.add(normalizedLine);
106
94
  }
107
95
  }
108
96
  }