@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
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Stop hook handler: git commit/push, background sync, auto-capture, governance.
3
+ * Extracted from cli-hooks-session.ts for modularity.
4
+ */
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
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import * as os from "os";
9
+ import { spawnDetachedChild } from "./shared/process.js";
10
+ import { resolveSubprocessArgs as _resolveSubprocessArgs, runBestEffortGit, countUnsyncedCommits, recoverPushConflict, } from "./cli-hooks-git.js";
11
+ import { logger } from "./logger.js";
12
+ const SYNC_LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes
13
+ /** Read JSON from stdin if it's not a TTY. Returns null if stdin is a TTY or parsing fails. */
14
+ export function readStdinJson() {
15
+ if (process.stdin.isTTY)
16
+ return null;
17
+ try {
18
+ return JSON.parse(fs.readFileSync(0, "utf-8"));
19
+ }
20
+ catch (err) {
21
+ logger.debug("readStdinJson", errorMessage(err));
22
+ return null;
23
+ }
24
+ }
25
+ /** Validate that a transcript path points to a safe, expected location.
26
+ * Uses realpathSync to dereference symlinks, preventing traversal attacks
27
+ * where a symlink inside a safe dir points outside it.
28
+ */
29
+ function isSafeTranscriptPath(p) {
30
+ // Resolve symlinks so a link like ~/.claude/evil -> /etc/passwd is caught
31
+ let normalized;
32
+ try {
33
+ normalized = fs.realpathSync.native(p);
34
+ }
35
+ catch {
36
+ // If the file doesn't exist yet, fall back to lexical resolution
37
+ try {
38
+ normalized = fs.realpathSync.native(path.dirname(p));
39
+ normalized = path.join(normalized, path.basename(p));
40
+ }
41
+ catch {
42
+ normalized = path.resolve(p);
43
+ }
44
+ }
45
+ const safePrefixes = [
46
+ path.resolve(os.tmpdir()),
47
+ path.resolve(homePath(".claude")),
48
+ path.resolve(homePath(".config", "claude")),
49
+ ];
50
+ return safePrefixes.some(prefix => normalized.startsWith(prefix + path.sep) || normalized === prefix);
51
+ }
52
+ // ── Q21: Conversation memory capture ─────────────────────────────────────────
53
+ const INSIGHT_KEYWORDS = [
54
+ "always", "never", "important", "pitfall", "gotcha", "trick", "workaround",
55
+ "careful", "caveat", "beware", "note that", "make sure",
56
+ "don't forget", "remember to", "must", "avoid", "prefer",
57
+ ];
58
+ const INSIGHT_KEYWORD_RE = new RegExp(`\\b(${INSIGHT_KEYWORDS.join("|")})\\b`, "i");
59
+ /**
60
+ * Extract potential insights from conversation text using keyword heuristics.
61
+ * Returns lines that contain insight-signal words and look like actionable knowledge.
62
+ */
63
+ export function extractConversationInsights(text) {
64
+ const lines = text.split("\n").filter(l => l.trim().length > 20 && l.trim().length < 300);
65
+ const insights = [];
66
+ const seen = new Set();
67
+ for (const line of lines) {
68
+ const trimmed = line.trim();
69
+ // Skip code-only lines, headers, etc.
70
+ if (trimmed.startsWith("```") || trimmed.startsWith("#") || trimmed.startsWith("//"))
71
+ continue;
72
+ if (trimmed.startsWith("$") || trimmed.startsWith(">"))
73
+ continue;
74
+ if (INSIGHT_KEYWORD_RE.test(trimmed) || hasExplicitFindingSignal(trimmed)) {
75
+ // Normalize for dedup
76
+ const normalized = trimmed.toLowerCase().replace(/\s+/g, " ");
77
+ if (!seen.has(normalized)) {
78
+ seen.add(normalized);
79
+ insights.push(trimmed);
80
+ }
81
+ }
82
+ }
83
+ // Cap to prevent flooding
84
+ return insights.slice(0, 5);
85
+ }
86
+ export function filterConversationInsightsForProactivity(insights, level = getProactivityLevelForFindings(getPhrenPath())) {
87
+ if (level === "high")
88
+ return insights;
89
+ return insights.filter((insight) => shouldAutoCaptureFindingsForLevel(level, insight));
90
+ }
91
+ export function getSessionCap() {
92
+ if (process.env.PHREN_AUTOCAPTURE_SESSION_CAP) {
93
+ return parseInt(process.env.PHREN_AUTOCAPTURE_SESSION_CAP, 10);
94
+ }
95
+ try {
96
+ const policy = getWorkflowPolicy(getPhrenPath());
97
+ const sensitivity = policy.findingSensitivity ?? "balanced";
98
+ return FINDING_SENSITIVITY_CONFIG[sensitivity]?.sessionCap ?? 10;
99
+ }
100
+ catch {
101
+ return 10;
102
+ }
103
+ }
104
+ function scheduleBackgroundSync(phrenPathLocal) {
105
+ const lockPath = runtimeFile(phrenPathLocal, "background-sync.lock");
106
+ const logPath = runtimeFile(phrenPathLocal, "background-sync.log");
107
+ const spawnArgs = _resolveSubprocessArgs("background-sync");
108
+ if (!spawnArgs)
109
+ return false;
110
+ try {
111
+ if (fs.existsSync(lockPath)) {
112
+ const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
113
+ if (ageMs <= SYNC_LOCK_STALE_MS)
114
+ return false;
115
+ fs.unlinkSync(lockPath);
116
+ }
117
+ }
118
+ catch (err) {
119
+ debugLog(`scheduleBackgroundSync: lock check failed: ${errorMessage(err)}`);
120
+ return false;
121
+ }
122
+ try {
123
+ fs.writeFileSync(lockPath, JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid }) + "\n", { flag: "wx" });
124
+ const logFd = fs.openSync(logPath, "a");
125
+ fs.writeSync(logFd, `[${new Date().toISOString()}] spawn ${process.execPath} ${spawnArgs.join(" ")}\n`);
126
+ const child = spawnDetachedChild(spawnArgs, { phrenPath: phrenPathLocal, logFd });
127
+ child.unref();
128
+ fs.closeSync(logFd);
129
+ return true;
130
+ }
131
+ catch (err) {
132
+ try {
133
+ fs.unlinkSync(lockPath);
134
+ }
135
+ catch { }
136
+ debugLog(`scheduleBackgroundSync: spawn failed: ${errorMessage(err)}`);
137
+ return false;
138
+ }
139
+ }
140
+ function scheduleWeeklyGovernance() {
141
+ try {
142
+ const lastGovPath = runtimeFile(getPhrenPath(), "last-governance.txt");
143
+ const lastRun = fs.existsSync(lastGovPath) ? parseInt(fs.readFileSync(lastGovPath, "utf8"), 10) : 0;
144
+ const daysSince = (Date.now() - lastRun) / 86_400_000;
145
+ if (daysSince >= 7) {
146
+ const spawnArgs = _resolveSubprocessArgs("background-maintenance");
147
+ if (spawnArgs) {
148
+ const child = spawnDetachedChild(spawnArgs, { phrenPath: getPhrenPath() });
149
+ child.unref();
150
+ fs.writeFileSync(lastGovPath, Date.now().toString());
151
+ debugLog("hook_stop: scheduled weekly governance run");
152
+ }
153
+ }
154
+ }
155
+ catch (err) {
156
+ debugLog(`hook_stop: governance scheduling failed: ${errorMessage(err)}`);
157
+ }
158
+ }
159
+ export async function handleHookStop() {
160
+ const ctx = buildHookContext();
161
+ const { phrenPath, activeProject, manifest } = ctx;
162
+ const now = new Date().toISOString();
163
+ bootstrapPhrenDotEnv(phrenPath);
164
+ if (!ctx.hooksEnabled) {
165
+ handleGuardSkip(ctx, "hook_stop", "disabled", {
166
+ lastStopAt: now,
167
+ lastAutoSave: { at: now, status: "clean", detail: "hooks disabled by preference" },
168
+ });
169
+ return;
170
+ }
171
+ if (!ctx.toolHookEnabled) {
172
+ handleGuardSkip(ctx, "hook_stop", `tool_disabled tool=${ctx.hookTool}`);
173
+ return;
174
+ }
175
+ if (!isProjectHookEnabled(phrenPath, activeProject, "Stop")) {
176
+ handleGuardSkip(ctx, "hook_stop", `project_disabled project=${activeProject}`, {
177
+ lastStopAt: now,
178
+ lastAutoSave: { at: now, status: "clean", detail: `hooks disabled for project ${activeProject}` },
179
+ });
180
+ return;
181
+ }
182
+ // Read stdin early — it's a stream and can only be consumed once.
183
+ // Needed for auto-capture transcript_path parsing.
184
+ const stdinPayload = readStdinJson();
185
+ const taskSessionId = typeof stdinPayload?.session_id === "string" ? stdinPayload.session_id : undefined;
186
+ const taskLevel = getProactivityLevelForTask(phrenPath);
187
+ if (taskSessionId && taskLevel !== "high") {
188
+ debugLog(`hook-stop task proactivity=${taskLevel}`);
189
+ }
190
+ // Auto-capture BEFORE git operations so captured insights get committed and pushed.
191
+ // Gated behind PHREN_FEATURE_AUTO_CAPTURE=1.
192
+ const findingsLevel = getProactivityLevelForFindings(phrenPath);
193
+ if (isFeatureEnabled("PHREN_FEATURE_AUTO_CAPTURE", false) && findingsLevel !== "low") {
194
+ try {
195
+ let captureInput = process.env.PHREN_CONVERSATION_CONTEXT || "";
196
+ if (!captureInput && stdinPayload?.transcript_path) {
197
+ const transcriptPath = stdinPayload.transcript_path;
198
+ if (!isSafeTranscriptPath(transcriptPath)) {
199
+ debugLog(`auto-capture: skipping unsafe transcript_path: ${transcriptPath}`);
200
+ }
201
+ else if (fs.existsSync(transcriptPath)) {
202
+ // Cap at last 500 lines (~50 KB) to bound memory usage for long sessions
203
+ const raw = fs.readFileSync(transcriptPath, "utf-8");
204
+ const allLines = raw.split("\n").filter(Boolean);
205
+ const lines = allLines.length > 500 ? allLines.slice(-500) : allLines;
206
+ const assistantTexts = [];
207
+ for (const line of lines) {
208
+ try {
209
+ const msg = JSON.parse(line);
210
+ if (msg.role !== "assistant")
211
+ continue;
212
+ if (typeof msg.content === "string")
213
+ assistantTexts.push(msg.content);
214
+ else if (Array.isArray(msg.content)) {
215
+ for (const block of msg.content) {
216
+ if (block.type === "text" && block.text)
217
+ assistantTexts.push(block.text);
218
+ }
219
+ }
220
+ }
221
+ catch (err) {
222
+ logger.debug("hookStop transcriptParse", errorMessage(err));
223
+ }
224
+ }
225
+ captureInput = assistantTexts.join("\n");
226
+ }
227
+ }
228
+ if (captureInput) {
229
+ if (activeProject) {
230
+ // Check session cap before extracting — same guard as PostToolUse hook
231
+ let capReached = false;
232
+ if (taskSessionId) {
233
+ try {
234
+ const capFile = sessionMarker(phrenPath, `tool-findings-${taskSessionId}`);
235
+ let count = 0;
236
+ if (fs.existsSync(capFile)) {
237
+ count = Number.parseInt(fs.readFileSync(capFile, "utf8").trim(), 10) || 0;
238
+ }
239
+ const cap = getSessionCap();
240
+ if (count >= cap) {
241
+ debugLog(`hook-stop: session cap reached (${count}/${cap}), skipping extraction`);
242
+ capReached = true;
243
+ }
244
+ }
245
+ catch (err) {
246
+ logger.debug("hookStop sessionCapCheck", errorMessage(err));
247
+ }
248
+ }
249
+ if (!capReached) {
250
+ const insights = filterConversationInsightsForProactivity(extractConversationInsights(captureInput), findingsLevel);
251
+ for (const insight of insights) {
252
+ appendFindingJournal(phrenPath, activeProject, `[pattern] ${insight}`, {
253
+ source: "hook",
254
+ sessionId: `hook-stop-${Date.now()}`,
255
+ });
256
+ debugLog(`auto-capture: saved insight for ${activeProject}: ${insight.slice(0, 60)}`);
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+ catch (err) {
263
+ debugLog(`auto-capture failed: ${errorMessage(err)}`);
264
+ }
265
+ }
266
+ else if (isFeatureEnabled("PHREN_FEATURE_AUTO_CAPTURE", false)) {
267
+ debugLog("auto-capture: skipped because findings proactivity is low");
268
+ }
269
+ // Wrap git operations in a file lock to prevent concurrent agents from fighting
270
+ const gitOpLockPath = path.join(phrenPath, ".runtime", "git-op");
271
+ await withFileLock(gitOpLockPath, async () => {
272
+ if (manifest?.installMode === "project-local") {
273
+ updateRuntimeHealth(phrenPath, {
274
+ lastStopAt: now,
275
+ lastAutoSave: { at: now, status: "saved-local", detail: "project-local mode writes files only" },
276
+ lastSync: {
277
+ lastPushAt: now,
278
+ lastPushStatus: "saved-local",
279
+ lastPushDetail: "project-local mode does not manage git sync",
280
+ },
281
+ });
282
+ appendAuditLog(phrenPath, "hook_stop", "status=skipped-local");
283
+ return;
284
+ }
285
+ const gitRepo = ensureLocalGitRepo(phrenPath);
286
+ if (!gitRepo.ok) {
287
+ finalizeTaskSession({
288
+ phrenPath,
289
+ sessionId: taskSessionId,
290
+ status: "error",
291
+ detail: gitRepo.detail,
292
+ });
293
+ updateRuntimeHealth(phrenPath, {
294
+ lastStopAt: now,
295
+ lastAutoSave: { at: now, status: "error", detail: gitRepo.detail },
296
+ lastSync: {
297
+ lastPushAt: now,
298
+ lastPushStatus: "error",
299
+ lastPushDetail: gitRepo.detail,
300
+ },
301
+ });
302
+ appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(gitRepo.detail)}`);
303
+ process.stderr.write(`phren: git repo error — ${gitRepo.detail}. Run 'phren doctor --fix' for details.\n`);
304
+ return;
305
+ }
306
+ const status = await runBestEffortGit(["status", "--porcelain"], phrenPath);
307
+ if (!status.ok) {
308
+ finalizeTaskSession({
309
+ phrenPath,
310
+ sessionId: taskSessionId,
311
+ status: "error",
312
+ detail: status.error || "git status failed",
313
+ });
314
+ updateRuntimeHealth(phrenPath, {
315
+ lastStopAt: now,
316
+ lastAutoSave: { at: now, status: "error", detail: status.error || "git status failed" },
317
+ lastSync: {
318
+ lastPushAt: now,
319
+ lastPushStatus: "error",
320
+ lastPushDetail: status.error || "git status failed",
321
+ },
322
+ });
323
+ appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(status.error || "git status failed")}`);
324
+ process.stderr.write(`phren: git status failed — your changes may not be saved. Run 'phren doctor --fix'.\n`);
325
+ return;
326
+ }
327
+ if (!status.output) {
328
+ updateRuntimeHealth(phrenPath, {
329
+ lastStopAt: now,
330
+ lastAutoSave: { at: now, status: "clean", detail: "no changes" },
331
+ lastSync: {
332
+ lastPushAt: now,
333
+ lastPushStatus: "saved-pushed",
334
+ lastPushDetail: "no changes",
335
+ unsyncedCommits: 0,
336
+ },
337
+ });
338
+ appendAuditLog(phrenPath, "hook_stop", "status=clean");
339
+ return;
340
+ }
341
+ // Stage all changes first, then unstage any sensitive files that slipped
342
+ // through. Using pathspec exclusions with `git add -A` can fail when
343
+ // excluded paths are also gitignored (git treats the pathspec as an error).
344
+ let add = await runBestEffortGit(["add", "-A"], phrenPath);
345
+ if (add.ok) {
346
+ // Belt-and-suspenders: unstage sensitive files that .gitignore should
347
+ // already block. Failures here are non-fatal (files may not exist).
348
+ await runBestEffortGit(["reset", "HEAD", "--", ".env", "**/.env", "*.pem", "*.key"], phrenPath);
349
+ }
350
+ let commitMsg = "auto-save phren";
351
+ if (add.ok) {
352
+ const diff = await runBestEffortGit(["diff", "--cached", "--stat", "--no-color"], phrenPath);
353
+ if (diff.ok && diff.output) {
354
+ // Parse "project/file.md | 3 +++" lines into project names and file types
355
+ const changes = new Map();
356
+ for (const line of diff.output.split("\n")) {
357
+ const m = line.match(/^\s*([^/]+)\/([^|]+)\s*\|/);
358
+ if (!m)
359
+ continue;
360
+ const proj = m[1].trim();
361
+ if (proj.startsWith("."))
362
+ continue; // skip .config, .runtime, etc.
363
+ const file = m[2].trim();
364
+ if (!changes.has(proj))
365
+ changes.set(proj, new Set());
366
+ if (/findings/i.test(file))
367
+ changes.get(proj).add("findings");
368
+ else if (/tasks/i.test(file))
369
+ changes.get(proj).add("task");
370
+ else if (/CLAUDE/i.test(file))
371
+ changes.get(proj).add("config");
372
+ else if (/summary/i.test(file))
373
+ changes.get(proj).add("summary");
374
+ else if (/skill/i.test(file))
375
+ changes.get(proj).add("skills");
376
+ else if (/reference/i.test(file))
377
+ changes.get(proj).add("reference");
378
+ else
379
+ changes.get(proj).add("update");
380
+ }
381
+ if (changes.size > 0) {
382
+ const parts = [...changes.entries()].map(([proj, types]) => `${proj}(${[...types].join(",")})`);
383
+ commitMsg = `phren: ${parts.join(" ")}`;
384
+ }
385
+ }
386
+ }
387
+ const commit = add.ok ? await runBestEffortGit(["commit", "-m", commitMsg], phrenPath) : { ok: false, error: add.error };
388
+ if (!add.ok || !commit.ok) {
389
+ finalizeTaskSession({
390
+ phrenPath,
391
+ sessionId: taskSessionId,
392
+ status: "error",
393
+ detail: add.error || commit.error || "git add/commit failed",
394
+ });
395
+ updateRuntimeHealth(phrenPath, {
396
+ lastStopAt: now,
397
+ lastAutoSave: {
398
+ at: now,
399
+ status: "error",
400
+ detail: add.error || commit.error || "git add/commit failed",
401
+ },
402
+ lastSync: {
403
+ lastPushAt: now,
404
+ lastPushStatus: "error",
405
+ lastPushDetail: add.error || commit.error || "git add/commit failed",
406
+ },
407
+ });
408
+ appendAuditLog(phrenPath, "hook_stop", `status=error detail=${JSON.stringify(add.error || commit.error || "git add/commit failed")}`);
409
+ process.stderr.write(`phren: git commit failed — ${add.error || commit.error || "unknown error"}. Changes not saved.\n`);
410
+ return;
411
+ }
412
+ const remotes = await runBestEffortGit(["remote"], phrenPath);
413
+ if (!remotes.ok || !remotes.output) {
414
+ finalizeTaskSession({
415
+ phrenPath,
416
+ sessionId: taskSessionId,
417
+ status: "saved-local",
418
+ detail: "commit created; no remote configured",
419
+ });
420
+ const unsyncedCommits = await countUnsyncedCommits(phrenPath);
421
+ updateRuntimeHealth(phrenPath, {
422
+ lastStopAt: now,
423
+ lastAutoSave: { at: now, status: "saved-local", detail: "commit created; no remote configured" },
424
+ lastSync: {
425
+ lastPushAt: now,
426
+ lastPushStatus: "saved-local",
427
+ lastPushDetail: "commit created; no remote configured",
428
+ unsyncedCommits,
429
+ },
430
+ });
431
+ appendAuditLog(phrenPath, "hook_stop", "status=saved-local");
432
+ if (unsyncedCommits > 3) {
433
+ process.stderr.write(`phren: ${unsyncedCommits} unsynced commits — no git remote configured.\n`);
434
+ }
435
+ return;
436
+ }
437
+ const unsyncedCommits = await countUnsyncedCommits(phrenPath);
438
+ const scheduled = scheduleBackgroundSync(phrenPath);
439
+ const syncDetail = scheduled
440
+ ? "commit saved; background sync scheduled"
441
+ : "commit saved; background sync already running";
442
+ finalizeTaskSession({
443
+ phrenPath,
444
+ sessionId: taskSessionId,
445
+ status: "saved-local",
446
+ detail: syncDetail,
447
+ });
448
+ updateRuntimeHealth(phrenPath, {
449
+ lastStopAt: now,
450
+ lastAutoSave: { at: now, status: "saved-local", detail: syncDetail },
451
+ lastSync: {
452
+ lastPushAt: now,
453
+ lastPushStatus: "saved-local",
454
+ lastPushDetail: syncDetail,
455
+ unsyncedCommits,
456
+ },
457
+ });
458
+ appendAuditLog(phrenPath, "hook_stop", `status=saved-local detail=${JSON.stringify(syncDetail)}`);
459
+ }); // end withFileLock(gitOpLockPath)
460
+ // Auto governance scheduling (non-blocking)
461
+ scheduleWeeklyGovernance();
462
+ }
463
+ export async function handleBackgroundSync() {
464
+ const phrenPathLocal = getPhrenPath();
465
+ const now = new Date().toISOString();
466
+ const lockPath = runtimeFile(phrenPathLocal, "background-sync.lock");
467
+ try {
468
+ const remotes = await runBestEffortGit(["remote"], phrenPathLocal);
469
+ if (!remotes.ok || !remotes.output) {
470
+ const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
471
+ updateRuntimeHealth(phrenPathLocal, {
472
+ lastAutoSave: { at: now, status: "saved-local", detail: "background sync skipped; no remote configured" },
473
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: "background sync skipped; no remote configured", unsyncedCommits }),
474
+ });
475
+ appendAuditLog(phrenPathLocal, "background_sync", "status=saved-local detail=no_remote");
476
+ return;
477
+ }
478
+ const push = await runBestEffortGit(["push"], phrenPathLocal);
479
+ if (push.ok) {
480
+ updateRuntimeHealth(phrenPathLocal, {
481
+ lastAutoSave: { at: now, status: "saved-pushed", detail: "commit pushed by background sync" },
482
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: "commit pushed by background sync", unsyncedCommits: 0 }),
483
+ });
484
+ appendAuditLog(phrenPathLocal, "background_sync", "status=saved-pushed");
485
+ return;
486
+ }
487
+ const recovered = await recoverPushConflict(phrenPathLocal);
488
+ if (recovered.ok) {
489
+ updateRuntimeHealth(phrenPathLocal, {
490
+ lastAutoSave: { at: now, status: "saved-pushed", detail: recovered.detail },
491
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-pushed", pushDetail: recovered.detail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, successfulPullAt: now, unsyncedCommits: 0 }),
492
+ });
493
+ appendAuditLog(phrenPathLocal, "background_sync", `status=saved-pushed detail=${JSON.stringify(recovered.detail)}`);
494
+ return;
495
+ }
496
+ const unsyncedCommits = await countUnsyncedCommits(phrenPathLocal);
497
+ const failDetail = recovered.detail || push.error || "background sync push failed";
498
+ updateRuntimeHealth(phrenPathLocal, {
499
+ lastAutoSave: { at: now, status: "saved-local", detail: failDetail },
500
+ lastSync: buildSyncStatus({ now, pushStatus: "saved-local", pushDetail: failDetail, pullAt: now, pullStatus: recovered.pullStatus, pullDetail: recovered.pullDetail, unsyncedCommits }),
501
+ });
502
+ appendAuditLog(phrenPathLocal, "background_sync", `status=saved-local detail=${JSON.stringify(failDetail)}`);
503
+ // Append to sync-warnings.jsonl so health_check and session_start can surface recent failures
504
+ try {
505
+ const warningsPath = runtimeFile(phrenPathLocal, "sync-warnings.jsonl");
506
+ const entry = JSON.stringify({ at: now, error: failDetail, unsyncedCommits }) + "\n";
507
+ fs.appendFileSync(warningsPath, entry);
508
+ }
509
+ catch (err) {
510
+ debugLog(`background-sync: failed to write sync warning: ${errorMessage(err)}`);
511
+ }
512
+ }
513
+ finally {
514
+ try {
515
+ fs.unlinkSync(lockPath);
516
+ }
517
+ catch { }
518
+ }
519
+ }
@@ -1,11 +1,13 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as crypto from "crypto";
4
- import { debugLog, runtimeFile, phrenOk, phrenErr, PhrenError, appendAuditLog, tryUnlink } from "./shared.js";
5
- import { isValidProjectName, safeProjectPath, errorMessage } from "./utils.js";
6
- import { withFileLock } from "./shared-governance.js";
7
- import { appendArchivedEntriesToTopicDoc, classifyTopicForText, readProjectTopics, topicReferencePath } from "./project-topics.js";
8
- import { isCitationLine, isArchiveStart, isArchiveEnd, stripComments } from "./content-metadata.js";
4
+ import { debugLog, runtimeFile, phrenOk, phrenErr, PhrenError, appendAuditLog, tryUnlink } from "../shared.js";
5
+ import { isValidProjectName, safeProjectPath, errorMessage } from "../utils.js";
6
+ import { withFileLock } from "../shared/governance.js";
7
+ import { walkDirectory } from "../shared/data-utils.js";
8
+ import { appendArchivedEntriesToTopicDoc, classifyTopicForText, readProjectTopics, topicReferencePath } from "../project-topics.js";
9
+ import { isCitationLine, isArchiveStart, isArchiveEnd, stripComments } from "./metadata.js";
10
+ import { logger } from "../logger.js";
9
11
  /**
10
12
  * Count active (non-archived) finding entries in FINDINGS.md content.
11
13
  * Entries inside archive blocks are considered archived.
@@ -80,34 +82,20 @@ function parseActiveEntries(content) {
80
82
  /** Build a Set of normalized bullet strings from all .md files in referenceDir. */
81
83
  function buildArchivedBulletSet(referenceDir) {
82
84
  const bulletSet = new Set();
83
- if (!fs.existsSync(referenceDir))
84
- return bulletSet;
85
85
  try {
86
- const stack = [referenceDir];
87
- while (stack.length > 0) {
88
- const current = stack.pop();
89
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
90
- const fullPath = path.join(current, entry.name);
91
- if (entry.isDirectory()) {
92
- 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("- "))
93
90
  continue;
94
- }
95
- if (!entry.isFile() || !entry.name.endsWith(".md"))
96
- continue;
97
- const content = fs.readFileSync(fullPath, "utf8");
98
- for (const line of content.split("\n")) {
99
- if (!line.startsWith("- "))
100
- continue;
101
- const normalizedLine = stripComments(line).replace(/^-\s+/, "").trim().toLowerCase();
102
- if (normalizedLine)
103
- bulletSet.add(normalizedLine);
104
- }
91
+ const normalizedLine = stripComments(line).replace(/^-\s+/, "").trim().toLowerCase();
92
+ if (normalizedLine)
93
+ bulletSet.add(normalizedLine);
105
94
  }
106
95
  }
107
96
  }
108
97
  catch (err) {
109
- if ((process.env.PHREN_DEBUG))
110
- process.stderr.write(`[phren] buildArchivedBulletSet: ${errorMessage(err)}\n`);
98
+ logger.debug("archive", `buildArchivedBulletSet: ${errorMessage(err)}`);
111
99
  }
112
100
  return bulletSet;
113
101
  }
@@ -280,8 +268,7 @@ export function autoArchiveToReference(phrenPath, project, keepCount) {
280
268
  fs.unlinkSync(lockFile);
281
269
  }
282
270
  catch (err) {
283
- if ((process.env.PHREN_DEBUG))
284
- process.stderr.write(`[phren] autoArchiveToReference unlockFile: ${errorMessage(err)}\n`);
271
+ logger.debug("archive", `autoArchiveToReference unlockFile: ${errorMessage(err)}`);
285
272
  }
286
273
  }
287
274
  }
@@ -1,10 +1,10 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { debugLog, EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS } from "./shared.js";
4
- import { errorMessage, runGitOrThrow } from "./utils.js";
5
- import { findingIdFromLine } from "./finding-impact.js";
6
- import { METADATA_REGEX, isArchiveStart, isArchiveEnd } from "./content-metadata.js";
7
- import { FINDING_TYPE_DECAY, extractFindingType } from "./finding-lifecycle.js";
3
+ import { debugLog, EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS } from "../shared.js";
4
+ import { errorMessage, runGitOrThrow } from "../utils.js";
5
+ import { findingIdFromLine } from "../finding/impact.js";
6
+ import { METADATA_REGEX, isArchiveStart, isArchiveEnd } from "./metadata.js";
7
+ import { FINDING_TYPE_DECAY, extractFindingType } from "../finding/lifecycle.js";
8
8
  export const FINDING_PROVENANCE_SOURCES = [
9
9
  "human",
10
10
  "agent",
@@ -1,10 +1,11 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as crypto from "crypto";
4
- import { debugLog, runtimeFile, KNOWN_OBSERVATION_TAGS } from "./shared.js";
5
- import { isFeatureEnabled, safeProjectPath, errorMessage } from "./utils.js";
6
- import { UNIVERSAL_TECH_TERMS_RE, EXTRA_ENTITY_PATTERNS } from "./phren-core.js";
7
- import { isInactiveFindingLine } from "./finding-lifecycle.js";
4
+ import { debugLog, runtimeFile, KNOWN_OBSERVATION_TAGS } from "../shared.js";
5
+ import { isFeatureEnabled, safeProjectPath, errorMessage } from "../utils.js";
6
+ import { UNIVERSAL_TECH_TERMS_RE, EXTRA_ENTITY_PATTERNS } from "../phren-core.js";
7
+ import { isInactiveFindingLine } from "../finding/lifecycle.js";
8
+ import { logger } from "../logger.js";
8
9
  // ── LLM provider abstraction ────────────────────────────────────────────────
9
10
  const MAX_CACHE_ENTRIES = 500;
10
11
  function loadCache(cachePath) {
@@ -50,8 +51,7 @@ async function withCache(cachePath, key, ttlMs, compute) {
50
51
  }
51
52
  }
52
53
  catch (err) {
53
- if ((process.env.PHREN_DEBUG))
54
- process.stderr.write(`[phren] withCache load (${path.basename(cachePath)}): ${errorMessage(err)}\n`);
54
+ logger.debug("dedup", `withCache load (${path.basename(cachePath)}): ${errorMessage(err)}`);
55
55
  }
56
56
  const result = await compute();
57
57
  // Persist result
@@ -61,8 +61,7 @@ async function withCache(cachePath, key, ttlMs, compute) {
61
61
  persistCache(cachePath, cache);
62
62
  }
63
63
  catch (err) {
64
- if ((process.env.PHREN_DEBUG))
65
- process.stderr.write(`[phren] withCache persist (${path.basename(cachePath)}): ${errorMessage(err)}\n`);
64
+ logger.debug("dedup", `withCache persist (${path.basename(cachePath)}): ${errorMessage(err)}`);
66
65
  }
67
66
  return result;
68
67
  }
@@ -562,8 +561,7 @@ export async function checkSemanticConflicts(phrenPath, project, newFinding, sig
562
561
  return { name: e.name, mtime: fs.statSync(fp).mtimeMs, fp };
563
562
  }
564
563
  catch (err) {
565
- if ((process.env.PHREN_DEBUG))
566
- process.stderr.write(`[phren] crossProjectScan stat: ${errorMessage(err)}\n`);
564
+ logger.debug("dedup", `crossProjectScan stat: ${errorMessage(err)}`);
567
565
  return null;
568
566
  }
569
567
  })
@@ -577,8 +575,7 @@ export async function checkSemanticConflicts(phrenPath, project, newFinding, sig
577
575
  }
578
576
  }
579
577
  catch (err) {
580
- if ((process.env.PHREN_DEBUG))
581
- process.stderr.write(`[phren] crossProjectScan: ${errorMessage(err)}\n`);
578
+ logger.debug("dedup", `crossProjectScan: ${errorMessage(err)}`);
582
579
  }
583
580
  const annotations = [];
584
581
  const deadline = Date.now() + CONFLICT_CHECK_TOTAL_TIMEOUT_MS;