@phren/cli 0.0.28 → 0.0.33

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 (153) hide show
  1. package/mcp/dist/capabilities/cli.js +2 -5
  2. package/mcp/dist/capabilities/mcp.js +5 -8
  3. package/mcp/dist/capabilities/types.js +2 -5
  4. package/mcp/dist/capabilities/vscode.js +2 -5
  5. package/mcp/dist/capabilities/web-ui.js +2 -5
  6. package/mcp/dist/{cli-actions.js → cli/actions.js} +25 -21
  7. package/mcp/dist/{cli.js → cli/cli.js} +13 -13
  8. package/mcp/dist/{cli-config.js → cli/config.js} +12 -12
  9. package/mcp/dist/{cli-extract.js → cli/extract.js} +8 -8
  10. package/mcp/dist/{cli-govern.js → cli/govern.js} +28 -17
  11. package/mcp/dist/{cli-graph.js → cli/graph.js} +10 -9
  12. package/mcp/dist/{cli-hooks-citations.js → cli/hooks-citations.js} +2 -2
  13. package/mcp/dist/{cli-hooks-context.js → cli/hooks-context.js} +23 -23
  14. package/mcp/dist/{cli-hooks-globs.js → cli/hooks-globs.js} +4 -4
  15. package/mcp/dist/{cli-hooks-output.js → cli/hooks-output.js} +9 -10
  16. package/mcp/dist/{cli-hooks-session.js → cli/hooks-session.js} +58 -117
  17. package/mcp/dist/{cli-hooks.js → cli/hooks.js} +27 -26
  18. package/mcp/dist/{cli-namespaces.js → cli/namespaces.js} +25 -24
  19. package/mcp/dist/{cli-ops.js → cli/ops.js} +9 -9
  20. package/mcp/dist/{cli-search.js → cli/search.js} +12 -11
  21. package/mcp/dist/cli-hooks-git.js +243 -0
  22. package/mcp/dist/cli-hooks-prompt.js +323 -0
  23. package/mcp/dist/cli-hooks-session-handlers.js +337 -0
  24. package/mcp/dist/cli-hooks-stop.js +519 -0
  25. package/mcp/dist/{content-archive.js → content/archive.js} +16 -29
  26. package/mcp/dist/{content-citation.js → content/citation.js} +5 -5
  27. package/mcp/dist/{content-dedup.js → content/dedup.js} +9 -12
  28. package/mcp/dist/{content-learning.js → content/learning.js} +41 -20
  29. package/mcp/dist/{content-validate.js → content/validate.js} +5 -5
  30. package/mcp/dist/{core-finding.js → core/finding.js} +4 -4
  31. package/mcp/dist/{core-project.js → core/project.js} +4 -4
  32. package/mcp/dist/{core-search.js → core/search.js} +2 -2
  33. package/mcp/dist/{data-access.js → data/access.js} +142 -15
  34. package/mcp/dist/{data-tasks.js → data/tasks.js} +7 -5
  35. package/mcp/dist/embedding.js +9 -14
  36. package/mcp/dist/entrypoint.js +11 -11
  37. package/mcp/dist/{finding-context.js → finding/context.js} +2 -2
  38. package/mcp/dist/{finding-impact.js → finding/impact.js} +3 -3
  39. package/mcp/dist/{finding-journal.js → finding/journal.js} +4 -4
  40. package/mcp/dist/{finding-lifecycle.js → finding/lifecycle.js} +13 -7
  41. package/mcp/dist/governance/audit.js +30 -0
  42. package/mcp/dist/{governance-locks.js → governance/locks.js} +14 -9
  43. package/mcp/dist/{governance-policy.js → governance/policy.js} +23 -12
  44. package/mcp/dist/{governance-rbac.js → governance/rbac.js} +4 -4
  45. package/mcp/dist/{governance-scores.js → governance/scores.js} +10 -11
  46. package/mcp/dist/hooks.js +53 -37
  47. package/mcp/dist/index-query.js +4 -1
  48. package/mcp/dist/index.js +54 -30
  49. package/mcp/dist/{init-config.js → init/config.js} +6 -6
  50. package/mcp/dist/{init.js → init/init.js} +80 -69
  51. package/mcp/dist/{init-preferences.js → init/preferences.js} +3 -3
  52. package/mcp/dist/{init-setup.js → init/setup.js} +17 -19
  53. package/mcp/dist/{init-shared.js → init/shared.js} +4 -4
  54. package/mcp/dist/init-bootstrap.js +21 -0
  55. package/mcp/dist/init-detect.js +38 -0
  56. package/mcp/dist/init-env.js +114 -0
  57. package/mcp/dist/init-fresh.js +234 -0
  58. package/mcp/dist/init-hooks.js +26 -0
  59. package/mcp/dist/init-mcp.js +65 -0
  60. package/mcp/dist/init-modes.js +135 -0
  61. package/mcp/dist/init-npm.js +37 -0
  62. package/mcp/dist/init-project-local.js +99 -0
  63. package/mcp/dist/init-semantic.js +48 -0
  64. package/mcp/dist/init-types.js +1 -0
  65. package/mcp/dist/init-uninstall.js +504 -0
  66. package/mcp/dist/init-update.js +96 -0
  67. package/mcp/dist/init-walkthrough.js +524 -0
  68. package/mcp/dist/{link-checksums.js → link/checksums.js} +5 -5
  69. package/mcp/dist/{link-context.js → link/context.js} +4 -4
  70. package/mcp/dist/{link-doctor.js → link/doctor.js} +20 -22
  71. package/mcp/dist/{link.js → link/link.js} +26 -31
  72. package/mcp/dist/{link-skills.js → link/skills.js} +10 -10
  73. package/mcp/dist/logger.js +11 -3
  74. package/mcp/dist/package-metadata.js +1 -1
  75. package/mcp/dist/phren-art.js +4 -126
  76. package/mcp/dist/phren-paths.js +30 -12
  77. package/mcp/dist/proactivity.js +3 -3
  78. package/mcp/dist/profile-store.js +5 -6
  79. package/mcp/dist/project-config.js +2 -2
  80. package/mcp/dist/project-topics.js +17 -47
  81. package/mcp/dist/provider-adapters.js +1 -1
  82. package/mcp/dist/query-correlation.js +1 -1
  83. package/mcp/dist/runtime-profile.js +1 -1
  84. package/mcp/dist/{session-checkpoints.js → session/checkpoints.js} +3 -3
  85. package/mcp/dist/{session-utils.js → session/utils.js} +1 -1
  86. package/mcp/dist/{shared-content.js → shared/content.js} +7 -7
  87. package/mcp/dist/{shared-data-utils.js → shared/data-utils.js} +28 -3
  88. package/mcp/dist/{shared-embedding-cache.js → shared/embedding-cache.js} +3 -3
  89. package/mcp/dist/{shared-fragment-graph.js → shared/fragment-graph.js} +19 -42
  90. package/mcp/dist/shared/governance.js +4 -0
  91. package/mcp/dist/{shared-index.js → shared/index.js} +105 -132
  92. package/mcp/dist/{shared-ollama.js → shared/ollama.js} +25 -7
  93. package/mcp/dist/shared/process.js +24 -0
  94. package/mcp/dist/{shared-retrieval.js → shared/retrieval.js} +22 -24
  95. package/mcp/dist/{shared-search-fallback.js → shared/search-fallback.js} +18 -20
  96. package/mcp/dist/{shared-sqljs.js → shared/sqljs.js} +3 -3
  97. package/mcp/dist/{shared-vector-index.js → shared/vector-index.js} +3 -3
  98. package/mcp/dist/shared.js +6 -60
  99. package/mcp/dist/{shell-entry.js → shell/entry.js} +6 -6
  100. package/mcp/dist/{shell-input.js → shell/input.js} +13 -13
  101. package/mcp/dist/{shell-palette.js → shell/palette.js} +3 -3
  102. package/mcp/dist/{shell-render.js → shell/render.js} +2 -2
  103. package/mcp/dist/{shell.js → shell/shell.js} +11 -11
  104. package/mcp/dist/{shell-state-store.js → shell/state-store.js} +5 -5
  105. package/mcp/dist/{shell-view-list.js → shell/view-list.js} +1 -1
  106. package/mcp/dist/{shell-view.js → shell/view.js} +13 -13
  107. package/mcp/dist/{skill-files.js → skill/files.js} +9 -9
  108. package/mcp/dist/{skill-registry.js → skill/registry.js} +5 -5
  109. package/mcp/dist/{skill-state.js → skill/state.js} +1 -4
  110. package/mcp/dist/startup-embedding.js +2 -2
  111. package/mcp/dist/status.js +15 -14
  112. package/mcp/dist/{tasks-github.js → task/github.js} +3 -2
  113. package/mcp/dist/{task-hygiene.js → task/hygiene.js} +4 -4
  114. package/mcp/dist/{task-lifecycle.js → task/lifecycle.js} +8 -13
  115. package/mcp/dist/telemetry.js +3 -4
  116. package/mcp/dist/tool-registry.js +29 -17
  117. package/mcp/dist/tools/config.js +530 -0
  118. package/mcp/dist/{mcp-data.js → tools/data.js} +8 -10
  119. package/mcp/dist/{mcp-extract-facts.js → tools/extract-facts.js} +6 -6
  120. package/mcp/dist/{mcp-extract.js → tools/extract.js} +6 -6
  121. package/mcp/dist/tools/finding.js +584 -0
  122. package/mcp/dist/{mcp-graph.js → tools/graph.js} +11 -14
  123. package/mcp/dist/{mcp-hooks.js → tools/hooks.js} +6 -6
  124. package/mcp/dist/{mcp-memory.js → tools/memory.js} +5 -5
  125. package/mcp/dist/tools/ops.js +468 -0
  126. package/mcp/dist/tools/search.js +672 -0
  127. package/mcp/dist/{mcp-session.js → tools/session.js} +51 -25
  128. package/mcp/dist/{mcp-skills.js → tools/skills.js} +42 -35
  129. package/mcp/dist/{mcp-tasks.js → tools/tasks.js} +155 -282
  130. package/mcp/dist/{memory-ui-data.js → ui/data.js} +31 -17
  131. package/mcp/dist/{memory-ui.js → ui/memory-ui.js} +3 -3
  132. package/mcp/dist/{memory-ui-page.js → ui/page.js} +5 -7
  133. package/mcp/dist/ui/server.js +1024 -0
  134. package/mcp/dist/update.js +2 -2
  135. package/mcp/dist/utils.js +63 -19
  136. package/package.json +2 -2
  137. package/scripts/preuninstall.mjs +31 -0
  138. package/starter/global/CLAUDE.md +3 -2
  139. package/mcp/dist/governance-audit.js +0 -22
  140. package/mcp/dist/mcp-config.js +0 -551
  141. package/mcp/dist/mcp-finding.js +0 -594
  142. package/mcp/dist/mcp-ops.js +0 -363
  143. package/mcp/dist/mcp-search.js +0 -668
  144. package/mcp/dist/memory-ui-server.js +0 -1411
  145. package/mcp/dist/shared-governance.js +0 -4
  146. /package/mcp/dist/{content-metadata.js → content/metadata.js} +0 -0
  147. /package/mcp/dist/{shared-stemmer.js → shared/stemmer.js} +0 -0
  148. /package/mcp/dist/{shell-types.js → shell/types.js} +0 -0
  149. /package/mcp/dist/{mcp-types.js → tools/types.js} +0 -0
  150. /package/mcp/dist/{memory-ui-assets.js → ui/assets.js} +0 -0
  151. /package/mcp/dist/{memory-ui-graph.js → ui/graph.js} +0 -0
  152. /package/mcp/dist/{memory-ui-scripts.js → ui/scripts.js} +0 -0
  153. /package/mcp/dist/{memory-ui-styles.js → ui/styles.js} +0 -0
@@ -1,9 +1,10 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { runtimeFile } from "./shared.js";
4
- import { buildIndex, extractSnippet, queryDocRows, queryRows, queryEntityLinks, queryDocBySourceKey, logEntityMiss } from "./shared-index.js";
5
- import { buildFtsQueryVariants, errorMessage, isValidProjectName } from "./utils.js";
6
- import { keywordFallbackSearch } from "./core-search.js";
3
+ import { runtimeFile } from "../shared.js";
4
+ import { buildIndex, extractSnippet, queryDocRows, queryRows, queryFragmentLinks, queryDocBySourceKey, logFragmentMiss } from "../shared/index.js";
5
+ import { buildFtsQueryVariants, errorMessage, isValidProjectName } from "../utils.js";
6
+ import { logger } from "../logger.js";
7
+ import { keywordFallbackSearch } from "../core/search.js";
7
8
  const MAX_HISTORY = 20;
8
9
  const SEARCH_TYPE_ALIASES = {
9
10
  skills: "skill",
@@ -23,7 +24,7 @@ const SEARCH_TYPES = new Set([
23
24
  function historyFile(phrenPath) {
24
25
  return runtimeFile(phrenPath, "search-history.jsonl");
25
26
  }
26
- export function readSearchHistory(phrenPath) {
27
+ function readSearchHistory(phrenPath) {
27
28
  const file = historyFile(phrenPath);
28
29
  if (!fs.existsSync(file))
29
30
  return [];
@@ -35,7 +36,7 @@ export function readSearchHistory(phrenPath) {
35
36
  }
36
37
  catch (err) {
37
38
  if ((process.env.PHREN_DEBUG))
38
- process.stderr.write(`[phren] readSearchHistory: ${errorMessage(err)}\n`);
39
+ logger.debug("cli-search", `readSearchHistory: ${errorMessage(err)}`);
39
40
  return [];
40
41
  }
41
42
  }
@@ -249,12 +250,12 @@ export async function runSearch(opts, phrenPath, profile) {
249
250
  if (!rows) {
250
251
  if (opts.query) {
251
252
  try {
252
- const { logSearchMiss } = await import("./mcp-search.js");
253
+ const { logSearchMiss } = await import("../tools/search.js");
253
254
  logSearchMiss(phrenPath, opts.query, opts.project);
254
255
  }
255
256
  catch (err) {
256
257
  if ((process.env.PHREN_DEBUG))
257
- process.stderr.write(`[phren] search logSearchMiss: ${errorMessage(err)}\n`);
258
+ logger.debug("cli-search", `search logSearchMiss: ${errorMessage(err)}`);
258
259
  }
259
260
  }
260
261
  const scope = [
@@ -318,7 +319,7 @@ export async function runFragmentSearch(query, phrenPath, profile, opts) {
318
319
  }
319
320
  const rows = queryRows(db, sql, params);
320
321
  if (!rows || rows.length === 0) {
321
- logEntityMiss(phrenPath, query, "cli_search_fragments", opts.project);
322
+ logFragmentMiss(phrenPath, query, "cli_search_fragments", opts.project);
322
323
  return { lines: [`No fragments matching "${query}".`], exitCode: 0 };
323
324
  }
324
325
  const lines = [`Fragments matching "${query}" (${rows.length} result(s)):\n`];
@@ -361,14 +362,14 @@ export async function runRelatedDocs(entity, phrenPath, profile, opts) {
361
362
  }
362
363
  const db = await buildIndex(phrenPath, profile);
363
364
  const max = opts.limit ?? 10;
364
- const links = queryEntityLinks(db, entity.toLowerCase());
365
+ const links = queryFragmentLinks(db, entity.toLowerCase());
365
366
  let relatedDocs = links.related.filter(r => r.includes("/"));
366
367
  if (opts.project) {
367
368
  relatedDocs = relatedDocs.filter(d => d.startsWith(`${opts.project}/`));
368
369
  }
369
370
  relatedDocs = relatedDocs.slice(0, max);
370
371
  if (relatedDocs.length === 0) {
371
- logEntityMiss(phrenPath, entity, "cli_related_docs", opts.project);
372
+ logFragmentMiss(phrenPath, entity, "cli_related_docs", opts.project);
372
373
  return { lines: [`No docs found referencing fragment "${entity}".`], exitCode: 0 };
373
374
  }
374
375
  const lines = [`Docs referencing "${entity}" (${relatedDocs.length} result(s)):\n`];
@@ -0,0 +1,243 @@
1
+ import { debugLog, EXEC_TIMEOUT_MS, withFileLock, recordFeedback, getQualityMultiplier, errorMessage, } from "./cli/hooks-context.js";
2
+ import { sessionMetricsFile, } from "./shared.js";
3
+ import { autoMergeConflicts, mergeTask, mergeFindings, } from "./shared/content.js";
4
+ import { runGit } from "./utils.js";
5
+ import { isTaskFileName } from "./data/tasks.js";
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import { execFileSync } from "child_process";
9
+ import { fileURLToPath } from "url";
10
+ export function getGitContext(cwd) {
11
+ if (!cwd)
12
+ return null;
13
+ const git = (args) => runGit(cwd, args, EXEC_TIMEOUT_MS, debugLog);
14
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]);
15
+ if (!branch)
16
+ return null;
17
+ const changedFiles = new Set();
18
+ for (const changed of [
19
+ git(["diff", "--name-only"]),
20
+ git(["diff", "--name-only", "--cached"]),
21
+ ]) {
22
+ if (!changed)
23
+ continue;
24
+ for (const line of changed.split("\n").map((s) => s.trim()).filter(Boolean)) {
25
+ changedFiles.add(line);
26
+ const basename = path.basename(line);
27
+ if (basename)
28
+ changedFiles.add(basename);
29
+ }
30
+ }
31
+ return { branch, changedFiles };
32
+ }
33
+ export function parseSessionMetrics(phrenPathLocal) {
34
+ const file = sessionMetricsFile(phrenPathLocal);
35
+ if (!fs.existsSync(file))
36
+ return {};
37
+ try {
38
+ return JSON.parse(fs.readFileSync(file, "utf8"));
39
+ }
40
+ catch (err) {
41
+ debugLog(`parseSessionMetrics: failed to read ${file}: ${errorMessage(err)}`);
42
+ return {};
43
+ }
44
+ }
45
+ export function writeSessionMetrics(phrenPathLocal, data) {
46
+ const file = sessionMetricsFile(phrenPathLocal);
47
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
49
+ }
50
+ export function updateSessionMetrics(phrenPathLocal, updater) {
51
+ const file = sessionMetricsFile(phrenPathLocal);
52
+ withFileLock(file, () => {
53
+ const metrics = parseSessionMetrics(phrenPathLocal);
54
+ updater(metrics);
55
+ writeSessionMetrics(phrenPathLocal, metrics);
56
+ });
57
+ }
58
+ export function trackSessionMetrics(phrenPathLocal, sessionId, selected) {
59
+ updateSessionMetrics(phrenPathLocal, (metrics) => {
60
+ if (!metrics[sessionId])
61
+ metrics[sessionId] = { prompts: 0, keys: {}, lastChangedCount: 0, lastKeys: [] };
62
+ metrics[sessionId].prompts += 1;
63
+ const injectedKeys = [];
64
+ for (const injected of selected) {
65
+ injectedKeys.push(injected.key);
66
+ const key = injected.key;
67
+ const seen = metrics[sessionId].keys[key] || 0;
68
+ metrics[sessionId].keys[key] = seen + 1;
69
+ if (seen >= 1)
70
+ recordFeedback(phrenPathLocal, key, "reprompt");
71
+ }
72
+ const relevantCount = selected.filter((s) => getQualityMultiplier(phrenPathLocal, s.key) > 0.5).length;
73
+ const prevRelevant = metrics[sessionId].lastChangedCount || 0;
74
+ const prevKeys = metrics[sessionId].lastKeys || [];
75
+ if (relevantCount > prevRelevant) {
76
+ for (const prevKey of prevKeys) {
77
+ recordFeedback(phrenPathLocal, prevKey, "helpful");
78
+ }
79
+ }
80
+ metrics[sessionId].lastChangedCount = relevantCount;
81
+ metrics[sessionId].lastKeys = injectedKeys;
82
+ metrics[sessionId].lastSeen = new Date().toISOString();
83
+ const thirtyDaysAgo = Date.now() - 30 * 86400000;
84
+ for (const sid of Object.keys(metrics)) {
85
+ const seen = metrics[sid].lastSeen;
86
+ if (seen && new Date(seen).getTime() < thirtyDaysAgo) {
87
+ delete metrics[sid];
88
+ }
89
+ }
90
+ });
91
+ }
92
+ // ── Git command helpers for hooks ────────────────────────────────────────────
93
+ export function isTransientGitError(message) {
94
+ return /(timed out|connection|network|could not resolve host|rpc failed|429|502|503|504|service unavailable)/i.test(message);
95
+ }
96
+ export function shouldRetryGitCommand(args) {
97
+ const cmd = args[0] || "";
98
+ return cmd === "push" || cmd === "pull" || cmd === "fetch";
99
+ }
100
+ export async function runBestEffortGit(args, cwd) {
101
+ const retries = shouldRetryGitCommand(args) ? 2 : 0;
102
+ for (let attempt = 0; attempt <= retries; attempt++) {
103
+ try {
104
+ const output = execFileSync("git", args, {
105
+ cwd,
106
+ encoding: "utf8",
107
+ stdio: ["ignore", "pipe", "pipe"],
108
+ timeout: EXEC_TIMEOUT_MS,
109
+ }).trim();
110
+ return { ok: true, output };
111
+ }
112
+ catch (err) {
113
+ const message = errorMessage(err);
114
+ if (attempt < retries && isTransientGitError(message)) {
115
+ const delayMs = 500 * (attempt + 1);
116
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
117
+ continue;
118
+ }
119
+ return { ok: false, error: message };
120
+ }
121
+ }
122
+ return { ok: false, error: "git command failed" };
123
+ }
124
+ export async function countUnsyncedCommits(cwd) {
125
+ const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
126
+ if (!upstream.ok || !upstream.output)
127
+ return 0;
128
+ const ahead = await runBestEffortGit(["rev-list", "--count", `${upstream.output.trim()}..HEAD`], cwd);
129
+ if (!ahead.ok || !ahead.output)
130
+ return 0;
131
+ const parsed = Number.parseInt(ahead.output.trim(), 10);
132
+ return Number.isNaN(parsed) ? 0 : parsed;
133
+ }
134
+ export function isMergeableMarkdown(relPath) {
135
+ const filename = path.basename(relPath).toLowerCase();
136
+ return filename === "findings.md" || isTaskFileName(filename);
137
+ }
138
+ export async function snapshotLocalMergeableFiles(cwd) {
139
+ const upstream = await runBestEffortGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
140
+ if (!upstream.ok || !upstream.output)
141
+ return new Map();
142
+ const changed = await runBestEffortGit(["diff", "--name-only", `${upstream.output.trim()}..HEAD`], cwd);
143
+ if (!changed.ok || !changed.output)
144
+ return new Map();
145
+ const snapshots = new Map();
146
+ for (const relPath of changed.output.split("\n").map((line) => line.trim()).filter(Boolean)) {
147
+ if (!isMergeableMarkdown(relPath))
148
+ continue;
149
+ const fullPath = path.join(cwd, relPath);
150
+ if (!fs.existsSync(fullPath))
151
+ continue;
152
+ snapshots.set(relPath, fs.readFileSync(fullPath, "utf8"));
153
+ }
154
+ return snapshots;
155
+ }
156
+ export async function reconcileMergeableFiles(cwd, snapshots) {
157
+ let changedAny = false;
158
+ for (const [relPath, localBeforePull] of snapshots.entries()) {
159
+ const fullPath = path.join(cwd, relPath);
160
+ if (!fs.existsSync(fullPath))
161
+ continue;
162
+ const current = fs.readFileSync(fullPath, "utf8");
163
+ const filename = path.basename(relPath).toLowerCase();
164
+ const merged = filename === "findings.md"
165
+ ? mergeFindings(current, localBeforePull)
166
+ : mergeTask(current, localBeforePull);
167
+ if (merged === current)
168
+ continue;
169
+ fs.writeFileSync(fullPath, merged);
170
+ changedAny = true;
171
+ }
172
+ if (!changedAny)
173
+ return false;
174
+ const add = await runBestEffortGit(["add", "--", ...snapshots.keys()], cwd);
175
+ if (!add.ok)
176
+ return false;
177
+ const commit = await runBestEffortGit(["commit", "-m", "auto-merge markdown recovery"], cwd);
178
+ return commit.ok;
179
+ }
180
+ export async function recoverPushConflict(cwd) {
181
+ const localSnapshots = await snapshotLocalMergeableFiles(cwd);
182
+ const pull = await runBestEffortGit(["pull", "--rebase", "--quiet"], cwd);
183
+ if (pull.ok) {
184
+ const reconciled = await reconcileMergeableFiles(cwd, localSnapshots);
185
+ const retryPush = await runBestEffortGit(["push"], cwd);
186
+ return {
187
+ ok: retryPush.ok,
188
+ detail: retryPush.ok
189
+ ? (reconciled ? "commit pushed after pull --rebase and markdown reconciliation" : "commit pushed after pull --rebase")
190
+ : (retryPush.error || "push failed after pull --rebase"),
191
+ pullStatus: "ok",
192
+ pullDetail: pull.output || "pull --rebase ok",
193
+ };
194
+ }
195
+ const conflicted = await runBestEffortGit(["diff", "--name-only", "--diff-filter=U"], cwd);
196
+ const conflictedOutput = conflicted.output?.trim() || "";
197
+ if (!conflicted.ok || !conflictedOutput) {
198
+ await runBestEffortGit(["rebase", "--abort"], cwd);
199
+ return {
200
+ ok: false,
201
+ detail: pull.error || "pull --rebase failed",
202
+ pullStatus: "error",
203
+ pullDetail: pull.error || "pull --rebase failed",
204
+ };
205
+ }
206
+ if (!autoMergeConflicts(cwd)) {
207
+ await runBestEffortGit(["rebase", "--abort"], cwd);
208
+ return {
209
+ ok: false,
210
+ detail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
211
+ pullStatus: "error",
212
+ pullDetail: `rebase conflicts require manual resolution: ${conflictedOutput}`,
213
+ };
214
+ }
215
+ const continued = await runBestEffortGit(["-c", "core.editor=true", "rebase", "--continue"], cwd);
216
+ if (!continued.ok) {
217
+ await runBestEffortGit(["rebase", "--abort"], cwd);
218
+ return {
219
+ ok: false,
220
+ detail: continued.error || "rebase --continue failed",
221
+ pullStatus: "error",
222
+ pullDetail: continued.error || "rebase --continue failed",
223
+ };
224
+ }
225
+ const retryPush = await runBestEffortGit(["push"], cwd);
226
+ return {
227
+ ok: retryPush.ok,
228
+ detail: retryPush.ok ? "commit pushed after auto-merge recovery" : (retryPush.error || "push failed after auto-merge recovery"),
229
+ pullStatus: "ok",
230
+ pullDetail: "pull --rebase recovered via auto-merge",
231
+ };
232
+ }
233
+ export function resolveSubprocessArgs(command) {
234
+ // Prefer the entry script from process.argv[1] (the index.js that started this process)
235
+ const entry = process.argv[1];
236
+ if (entry && fs.existsSync(entry) && /index\.(ts|js)$/.test(entry))
237
+ return [entry, command];
238
+ // Fallback: look for index.js next to this file
239
+ const distEntry = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
240
+ if (fs.existsSync(distEntry))
241
+ return [distEntry, command];
242
+ return null;
243
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * handleHookContext (SessionStart context injection) and handleHookTool (PostToolUse)
3
+ * with tool finding extraction helpers.
4
+ * Extracted from cli-hooks-session.ts for modularity.
5
+ */
6
+ import { buildHookContext, debugLog, runtimeFile, sessionMarker, getPhrenPath, appendAuditLog, appendReviewQueue, detectProject, isProjectHookEnabled, getProactivityLevelForFindings, errorMessage, } from "./cli/hooks-context.js";
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import { buildIndex, queryRows, } from "./shared/index.js";
10
+ import { filterTaskByPriority } from "./shared/retrieval.js";
11
+ import { readStdinJson, getSessionCap } from "./cli-hooks-stop.js";
12
+ import { logger } from "./logger.js";
13
+ export async function handleHookContext() {
14
+ const ctx = buildHookContext();
15
+ if (!ctx.hooksEnabled) {
16
+ process.exit(0);
17
+ }
18
+ let cwd = ctx.cwd;
19
+ const ctxStdin = readStdinJson();
20
+ if (ctxStdin?.cwd)
21
+ cwd = ctxStdin.cwd;
22
+ const project = cwd !== ctx.cwd ? detectProject(ctx.phrenPath, cwd, ctx.profile) : ctx.activeProject;
23
+ if (!isProjectHookEnabled(ctx.phrenPath, project, "UserPromptSubmit")) {
24
+ process.exit(0);
25
+ }
26
+ const db = await buildIndex(ctx.phrenPath, ctx.profile);
27
+ const contextLabel = project ? `\u25c6 phren \u00b7 ${project} \u00b7 context` : `\u25c6 phren \u00b7 context`;
28
+ const parts = [contextLabel, "<phren-context>"];
29
+ if (project) {
30
+ const summaryRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'summary'", [project]);
31
+ if (summaryRow) {
32
+ parts.push(`# ${project}`);
33
+ parts.push(summaryRow[0][0]);
34
+ parts.push("");
35
+ }
36
+ const findingsRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'findings'", [project]);
37
+ if (findingsRow) {
38
+ const content = findingsRow[0][0];
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);
43
+ if (bullets.length > 0) {
44
+ parts.push("## Recent findings");
45
+ parts.push(bullets.join("\n"));
46
+ parts.push("");
47
+ }
48
+ }
49
+ const taskRow = queryRows(db, "SELECT content FROM docs WHERE project = ? AND type = 'task'", [project]);
50
+ if (taskRow) {
51
+ const content = taskRow[0][0];
52
+ const activeItems = content.split("\n").filter(l => l.startsWith("- "));
53
+ const filtered = filterTaskByPriority(activeItems);
54
+ const trimmed = filtered.slice(0, 5);
55
+ if (trimmed.length > 0) {
56
+ parts.push("## Active tasks");
57
+ parts.push(trimmed.join("\n"));
58
+ parts.push("");
59
+ }
60
+ }
61
+ }
62
+ else {
63
+ const projectRows = queryRows(db, "SELECT DISTINCT project FROM docs ORDER BY project", []);
64
+ if (projectRows) {
65
+ parts.push("# Phren projects");
66
+ parts.push(projectRows.map(r => `- ${r[0]}`).join("\n"));
67
+ parts.push("");
68
+ }
69
+ }
70
+ parts.push("</phren-context>");
71
+ if (parts.length > 2) {
72
+ console.log(parts.join("\n"));
73
+ }
74
+ }
75
+ // ── PostToolUse hook ─────────────────────────────────────────────────────────
76
+ const INTERESTING_TOOLS = new Set(["Read", "Write", "Edit", "Bash", "Glob", "Grep"]);
77
+ const COOLDOWN_MS = parseInt(process.env.PHREN_AUTOCAPTURE_COOLDOWN_MS ?? "30000", 10);
78
+ const MAX_TOOL_COMMAND_LENGTH = 200;
79
+ function flattenToolResponseText(value, maxChars = 4000) {
80
+ if (typeof value === "string")
81
+ return value;
82
+ const queue = [value];
83
+ const parts = [];
84
+ let length = 0;
85
+ while (queue.length > 0 && length < maxChars) {
86
+ const current = queue.shift();
87
+ if (typeof current === "string") {
88
+ const trimmed = current.trim();
89
+ if (!trimmed)
90
+ continue;
91
+ parts.push(trimmed);
92
+ length += trimmed.length + 1;
93
+ continue;
94
+ }
95
+ if (Array.isArray(current)) {
96
+ queue.unshift(...current);
97
+ continue;
98
+ }
99
+ if (current && typeof current === "object") {
100
+ queue.unshift(...Object.values(current));
101
+ }
102
+ }
103
+ if (parts.length > 0)
104
+ return parts.join("\n").slice(0, maxChars);
105
+ return JSON.stringify(value ?? "").slice(0, maxChars);
106
+ }
107
+ export async function handleHookTool() {
108
+ const ctx = buildHookContext();
109
+ if (!ctx.hooksEnabled) {
110
+ process.exit(0);
111
+ }
112
+ try {
113
+ const start = Date.now();
114
+ let raw = "";
115
+ if (!process.stdin.isTTY) {
116
+ try {
117
+ raw = fs.readFileSync(0, "utf-8");
118
+ }
119
+ catch (err) {
120
+ logger.debug("hookTool stdinRead", errorMessage(err));
121
+ process.exit(0);
122
+ }
123
+ }
124
+ let data;
125
+ try {
126
+ data = JSON.parse(raw);
127
+ }
128
+ catch (err) {
129
+ logger.debug("hookTool stdinParse", errorMessage(err));
130
+ process.exit(0);
131
+ }
132
+ const toolName = String(data.tool_name ?? data.tool ?? "");
133
+ if (!INTERESTING_TOOLS.has(toolName)) {
134
+ process.exit(0);
135
+ }
136
+ const sessionId = data.session_id;
137
+ const input = (data.tool_input ?? {});
138
+ const entry = {
139
+ at: new Date().toISOString(),
140
+ session_id: sessionId,
141
+ tool: toolName,
142
+ };
143
+ if (toolName === "Read" || toolName === "Write" || toolName === "Edit") {
144
+ const filePath = input.file_path ?? input.path ?? undefined;
145
+ if (filePath)
146
+ entry.file = String(filePath);
147
+ }
148
+ else if (toolName === "Bash") {
149
+ const cmd = input.command ?? undefined;
150
+ if (cmd)
151
+ entry.command = String(cmd).slice(0, MAX_TOOL_COMMAND_LENGTH);
152
+ }
153
+ else if (toolName === "Glob") {
154
+ const pattern = input.pattern ?? undefined;
155
+ if (pattern)
156
+ entry.file = String(pattern);
157
+ }
158
+ else if (toolName === "Grep") {
159
+ const pattern = input.pattern ?? undefined;
160
+ const searchPath = input.path ?? undefined;
161
+ if (pattern)
162
+ entry.command = `grep ${pattern}${searchPath ? ` in ${searchPath}` : ""}`.slice(0, MAX_TOOL_COMMAND_LENGTH);
163
+ }
164
+ const responseStr = flattenToolResponseText(data.tool_response ?? "");
165
+ if (/(error|exception|failed|no such file|ENOENT)/i.test(responseStr)) {
166
+ entry.error = responseStr.slice(0, 300);
167
+ }
168
+ const cwd = (data.cwd ?? input.cwd ?? undefined);
169
+ let activeProject = cwd ? detectProject(ctx.phrenPath, cwd, ctx.profile) : null;
170
+ if (!isProjectHookEnabled(ctx.phrenPath, activeProject, "PostToolUse")) {
171
+ appendAuditLog(ctx.phrenPath, "hook_tool", `status=project_disabled project=${activeProject}`);
172
+ process.exit(0);
173
+ }
174
+ try {
175
+ const logFile = runtimeFile(ctx.phrenPath, "tool-log.jsonl");
176
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
177
+ fs.appendFileSync(logFile, JSON.stringify(entry) + "\n");
178
+ }
179
+ catch (err) {
180
+ logger.debug("hookTool toolLog", errorMessage(err));
181
+ }
182
+ const cooldownFile = runtimeFile(ctx.phrenPath, "hook-tool-cooldown");
183
+ try {
184
+ if (fs.existsSync(cooldownFile)) {
185
+ const age = Date.now() - fs.statSync(cooldownFile).mtimeMs;
186
+ if (age < COOLDOWN_MS) {
187
+ debugLog(`hook-tool: cooldown active (${Math.round(age / 1000)}s < ${Math.round(COOLDOWN_MS / 1000)}s), skipping extraction`);
188
+ activeProject = null;
189
+ }
190
+ }
191
+ }
192
+ catch (err) {
193
+ logger.debug("hookTool cooldownStat", errorMessage(err));
194
+ }
195
+ if (activeProject && sessionId) {
196
+ try {
197
+ const capFile = sessionMarker(ctx.phrenPath, `tool-findings-${sessionId}`);
198
+ let count = 0;
199
+ if (fs.existsSync(capFile)) {
200
+ count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
201
+ }
202
+ const sessionCap = getSessionCap();
203
+ if (count >= sessionCap) {
204
+ debugLog(`hook-tool: session cap reached (${count}/${sessionCap}), skipping extraction`);
205
+ activeProject = null;
206
+ }
207
+ }
208
+ catch (err) {
209
+ logger.debug("hookTool sessionCapCheck", errorMessage(err));
210
+ }
211
+ }
212
+ const findingsLevelForTool = getProactivityLevelForFindings(ctx.phrenPath);
213
+ if (activeProject && findingsLevelForTool !== "low") {
214
+ try {
215
+ const candidates = filterToolFindingsForProactivity(extractToolFindings(toolName, input, responseStr), findingsLevelForTool);
216
+ for (const { text, confidence } of candidates) {
217
+ appendReviewQueue(ctx.phrenPath, activeProject, "Review", [text]);
218
+ debugLog(`hook-tool: queued candidate for review (conf=${confidence}): ${text.slice(0, 60)}`);
219
+ }
220
+ if (candidates.length > 0) {
221
+ try {
222
+ fs.writeFileSync(cooldownFile, Date.now().toString());
223
+ }
224
+ catch (err) {
225
+ logger.debug("hookTool cooldownWrite", errorMessage(err));
226
+ }
227
+ if (sessionId) {
228
+ try {
229
+ const capFile = sessionMarker(ctx.phrenPath, `tool-findings-${sessionId}`);
230
+ let count = 0;
231
+ try {
232
+ count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
233
+ }
234
+ catch (err) {
235
+ logger.debug("hookTool capFileRead", errorMessage(err));
236
+ }
237
+ count += candidates.length;
238
+ fs.writeFileSync(capFile, count.toString());
239
+ }
240
+ catch (err) {
241
+ logger.debug("hookTool capFileWrite", errorMessage(err));
242
+ }
243
+ }
244
+ }
245
+ }
246
+ catch (err) {
247
+ debugLog(`hook-tool: finding extraction failed: ${errorMessage(err)}`);
248
+ }
249
+ }
250
+ else if (activeProject) {
251
+ debugLog("hook-tool: skipped because findings proactivity is low");
252
+ }
253
+ const elapsed = Date.now() - start;
254
+ debugLog(`hook-tool: ${toolName} logged in ${elapsed}ms`);
255
+ process.exit(0);
256
+ }
257
+ catch (err) {
258
+ debugLog(`hook-tool: unhandled error: ${err instanceof Error ? err.stack || err.message : String(err)}`);
259
+ process.exit(0);
260
+ }
261
+ }
262
+ const EXPLICIT_TAG_PATTERN = /\[(pitfall|decision|pattern|tradeoff|architecture|bug)\]\s*(.+)/i;
263
+ export function filterToolFindingsForProactivity(candidates, level = getProactivityLevelForFindings(getPhrenPath())) {
264
+ if (level === "high")
265
+ return candidates;
266
+ if (level === "low")
267
+ return [];
268
+ return candidates.filter((candidate) => candidate.explicit === true);
269
+ }
270
+ export function extractToolFindings(toolName, input, responseStr) {
271
+ const candidates = [];
272
+ const changedContent = (toolName === "Edit" || toolName === "Write")
273
+ ? String(input.new_string ?? input.content ?? "")
274
+ : "";
275
+ const explicitSource = changedContent || responseStr;
276
+ const tagMatches = explicitSource.matchAll(new RegExp(EXPLICIT_TAG_PATTERN.source, "gi"));
277
+ for (const m of tagMatches) {
278
+ const tag = m[1].toLowerCase();
279
+ const content = m[2].replace(/\s+/g, " ").trim().slice(0, 200);
280
+ if (content) {
281
+ candidates.push({ text: `[${tag}] ${content}`, confidence: 0.85, explicit: true });
282
+ }
283
+ }
284
+ if (toolName === "Edit" || toolName === "Write") {
285
+ const filePath = String(input.file_path ?? input.path ?? "unknown");
286
+ const filename = path.basename(filePath);
287
+ if (/\b(TODO|FIXME)\b/.test(changedContent)) {
288
+ const firstLine = changedContent.split("\n").find((l) => /\b(TODO|FIXME)\b/.test(l));
289
+ if (firstLine) {
290
+ candidates.push({
291
+ text: `[pitfall] ${filename}: ${firstLine.trim().slice(0, 150)}`,
292
+ confidence: 0.45,
293
+ explicit: false,
294
+ });
295
+ }
296
+ }
297
+ if (/\btry\s*\{[\s\S]*?\bcatch\b/.test(changedContent)) {
298
+ const meaningfulLine = changedContent.split("\n").find((l) => l.trim().length > 10 && !/^\s*(try|catch|\{|\})/.test(l));
299
+ if (meaningfulLine) {
300
+ candidates.push({
301
+ text: `[pitfall] ${filename}: error handling added near "${meaningfulLine.trim().slice(0, 100)}"`,
302
+ confidence: 0.45,
303
+ explicit: false,
304
+ });
305
+ }
306
+ }
307
+ }
308
+ if (toolName === "Bash") {
309
+ const cmd = String(input.command ?? "").slice(0, 30);
310
+ const hasError = /(error|exception|failed|ENOENT|command not found|permission denied)/i.test(responseStr);
311
+ if (hasError && cmd) {
312
+ const firstErrorLine = responseStr.split("\n").find((l) => /(error|exception|failed|ENOENT|command not found|permission denied)/i.test(l));
313
+ if (firstErrorLine) {
314
+ candidates.push({
315
+ text: `[bug] command '${cmd}' failed: ${firstErrorLine.trim().slice(0, 150)}`,
316
+ confidence: 0.55,
317
+ explicit: false,
318
+ });
319
+ }
320
+ }
321
+ }
322
+ return candidates;
323
+ }