@phren/cli 0.0.1

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 (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/mcp/dist/capabilities/cli.js +61 -0
  4. package/mcp/dist/capabilities/index.js +15 -0
  5. package/mcp/dist/capabilities/mcp.js +61 -0
  6. package/mcp/dist/capabilities/types.js +57 -0
  7. package/mcp/dist/capabilities/vscode.js +61 -0
  8. package/mcp/dist/capabilities/web-ui.js +61 -0
  9. package/mcp/dist/cli-actions.js +302 -0
  10. package/mcp/dist/cli-config.js +580 -0
  11. package/mcp/dist/cli-extract.js +305 -0
  12. package/mcp/dist/cli-govern.js +371 -0
  13. package/mcp/dist/cli-graph.js +169 -0
  14. package/mcp/dist/cli-hooks-citations.js +44 -0
  15. package/mcp/dist/cli-hooks-context.js +56 -0
  16. package/mcp/dist/cli-hooks-globs.js +83 -0
  17. package/mcp/dist/cli-hooks-output.js +130 -0
  18. package/mcp/dist/cli-hooks-retrieval.js +2 -0
  19. package/mcp/dist/cli-hooks-session.js +1402 -0
  20. package/mcp/dist/cli-hooks.js +350 -0
  21. package/mcp/dist/cli-namespaces.js +989 -0
  22. package/mcp/dist/cli-ops.js +253 -0
  23. package/mcp/dist/cli-search.js +407 -0
  24. package/mcp/dist/cli.js +108 -0
  25. package/mcp/dist/content-archive.js +278 -0
  26. package/mcp/dist/content-citation.js +391 -0
  27. package/mcp/dist/content-dedup.js +622 -0
  28. package/mcp/dist/content-learning.js +472 -0
  29. package/mcp/dist/content-metadata.js +186 -0
  30. package/mcp/dist/content-validate.js +462 -0
  31. package/mcp/dist/core-finding.js +54 -0
  32. package/mcp/dist/core-project.js +36 -0
  33. package/mcp/dist/core-search.js +50 -0
  34. package/mcp/dist/data-access.js +400 -0
  35. package/mcp/dist/data-tasks.js +821 -0
  36. package/mcp/dist/embedding.js +344 -0
  37. package/mcp/dist/entrypoint.js +387 -0
  38. package/mcp/dist/finding-context.js +172 -0
  39. package/mcp/dist/finding-impact.js +181 -0
  40. package/mcp/dist/finding-journal.js +122 -0
  41. package/mcp/dist/finding-lifecycle.js +259 -0
  42. package/mcp/dist/governance-audit.js +22 -0
  43. package/mcp/dist/governance-locks.js +96 -0
  44. package/mcp/dist/governance-policy.js +648 -0
  45. package/mcp/dist/governance-scores.js +355 -0
  46. package/mcp/dist/hooks.js +449 -0
  47. package/mcp/dist/impact-scoring.js +22 -0
  48. package/mcp/dist/index-query.js +168 -0
  49. package/mcp/dist/index.js +205 -0
  50. package/mcp/dist/init-config.js +336 -0
  51. package/mcp/dist/init-preferences.js +62 -0
  52. package/mcp/dist/init-setup.js +1305 -0
  53. package/mcp/dist/init-shared.js +29 -0
  54. package/mcp/dist/init.js +1730 -0
  55. package/mcp/dist/link-checksums.js +62 -0
  56. package/mcp/dist/link-context.js +257 -0
  57. package/mcp/dist/link-doctor.js +591 -0
  58. package/mcp/dist/link-skills.js +212 -0
  59. package/mcp/dist/link.js +596 -0
  60. package/mcp/dist/logger.js +15 -0
  61. package/mcp/dist/machine-identity.js +38 -0
  62. package/mcp/dist/mcp-config.js +254 -0
  63. package/mcp/dist/mcp-data.js +315 -0
  64. package/mcp/dist/mcp-extract-facts.js +78 -0
  65. package/mcp/dist/mcp-extract.js +133 -0
  66. package/mcp/dist/mcp-finding.js +557 -0
  67. package/mcp/dist/mcp-graph.js +339 -0
  68. package/mcp/dist/mcp-hooks.js +256 -0
  69. package/mcp/dist/mcp-memory.js +58 -0
  70. package/mcp/dist/mcp-ops.js +328 -0
  71. package/mcp/dist/mcp-search.js +628 -0
  72. package/mcp/dist/mcp-session.js +651 -0
  73. package/mcp/dist/mcp-skills.js +189 -0
  74. package/mcp/dist/mcp-tasks.js +551 -0
  75. package/mcp/dist/mcp-types.js +7 -0
  76. package/mcp/dist/memory-ui-assets.js +6 -0
  77. package/mcp/dist/memory-ui-data.js +513 -0
  78. package/mcp/dist/memory-ui-graph.js +1910 -0
  79. package/mcp/dist/memory-ui-page.js +353 -0
  80. package/mcp/dist/memory-ui-scripts.js +1387 -0
  81. package/mcp/dist/memory-ui-server.js +1218 -0
  82. package/mcp/dist/memory-ui-styles.js +555 -0
  83. package/mcp/dist/memory-ui.js +9 -0
  84. package/mcp/dist/package-metadata.js +13 -0
  85. package/mcp/dist/phren-art.js +52 -0
  86. package/mcp/dist/phren-core.js +108 -0
  87. package/mcp/dist/phren-dotenv.js +67 -0
  88. package/mcp/dist/phren-paths.js +476 -0
  89. package/mcp/dist/proactivity.js +172 -0
  90. package/mcp/dist/profile-store.js +228 -0
  91. package/mcp/dist/project-config.js +85 -0
  92. package/mcp/dist/project-locator.js +25 -0
  93. package/mcp/dist/project-topics.js +1134 -0
  94. package/mcp/dist/provider-adapters.js +176 -0
  95. package/mcp/dist/runtime-profile.js +18 -0
  96. package/mcp/dist/session-checkpoints.js +131 -0
  97. package/mcp/dist/session-utils.js +68 -0
  98. package/mcp/dist/shared-content.js +8 -0
  99. package/mcp/dist/shared-embedding-cache.js +143 -0
  100. package/mcp/dist/shared-fragment-graph.js +456 -0
  101. package/mcp/dist/shared-governance.js +4 -0
  102. package/mcp/dist/shared-index.js +1334 -0
  103. package/mcp/dist/shared-ollama.js +192 -0
  104. package/mcp/dist/shared-paths.js +1 -0
  105. package/mcp/dist/shared-retrieval.js +796 -0
  106. package/mcp/dist/shared-search-fallback.js +375 -0
  107. package/mcp/dist/shared-sqljs.js +42 -0
  108. package/mcp/dist/shared-stemmer.js +171 -0
  109. package/mcp/dist/shared-vector-index.js +199 -0
  110. package/mcp/dist/shared.js +114 -0
  111. package/mcp/dist/shell-entry.js +209 -0
  112. package/mcp/dist/shell-input.js +943 -0
  113. package/mcp/dist/shell-palette.js +119 -0
  114. package/mcp/dist/shell-render.js +252 -0
  115. package/mcp/dist/shell-state-store.js +81 -0
  116. package/mcp/dist/shell-types.js +13 -0
  117. package/mcp/dist/shell-view-list.js +14 -0
  118. package/mcp/dist/shell-view.js +707 -0
  119. package/mcp/dist/shell.js +352 -0
  120. package/mcp/dist/skill-files.js +117 -0
  121. package/mcp/dist/skill-registry.js +279 -0
  122. package/mcp/dist/skill-state.js +28 -0
  123. package/mcp/dist/startup-embedding.js +57 -0
  124. package/mcp/dist/status.js +323 -0
  125. package/mcp/dist/synonyms.json +670 -0
  126. package/mcp/dist/task-hygiene.js +251 -0
  127. package/mcp/dist/task-lifecycle.js +347 -0
  128. package/mcp/dist/tasks-github.js +76 -0
  129. package/mcp/dist/telemetry.js +165 -0
  130. package/mcp/dist/test-global-setup.js +37 -0
  131. package/mcp/dist/tool-registry.js +104 -0
  132. package/mcp/dist/update.js +97 -0
  133. package/mcp/dist/utils.js +543 -0
  134. package/package.json +67 -0
  135. package/skills/README.md +7 -0
  136. package/skills/consolidate/SKILL.md +152 -0
  137. package/skills/discover/SKILL.md +175 -0
  138. package/skills/init/SKILL.md +216 -0
  139. package/skills/profiles/SKILL.md +121 -0
  140. package/skills/sync/SKILL.md +261 -0
  141. package/starter/README.md +74 -0
  142. package/starter/global/CLAUDE.md +89 -0
  143. package/starter/global/skills/humanize.md +30 -0
  144. package/starter/global/skills/pipeline.md +35 -0
  145. package/starter/global/skills/release.md +35 -0
  146. package/starter/machines.yaml +8 -0
  147. package/starter/my-api/.claude/skills/README.md +7 -0
  148. package/starter/my-api/CLAUDE.md +33 -0
  149. package/starter/my-api/FINDINGS.md +9 -0
  150. package/starter/my-api/summary.md +7 -0
  151. package/starter/my-api/tasks.md +7 -0
  152. package/starter/my-first-project/.claude/skills/README.md +7 -0
  153. package/starter/my-first-project/CLAUDE.md +49 -0
  154. package/starter/my-first-project/FINDINGS.md +24 -0
  155. package/starter/my-first-project/summary.md +11 -0
  156. package/starter/my-first-project/tasks.md +25 -0
  157. package/starter/my-frontend/.claude/skills/README.md +7 -0
  158. package/starter/my-frontend/CLAUDE.md +33 -0
  159. package/starter/my-frontend/FINDINGS.md +9 -0
  160. package/starter/my-frontend/summary.md +7 -0
  161. package/starter/my-frontend/tasks.md +7 -0
  162. package/starter/profiles/default.yaml +4 -0
  163. package/starter/profiles/personal.yaml +4 -0
  164. package/starter/profiles/work.yaml +4 -0
  165. package/starter/templates/README.md +7 -0
  166. package/starter/templates/frontend/CLAUDE.md +23 -0
  167. package/starter/templates/frontend/FINDINGS.md +7 -0
  168. package/starter/templates/frontend/reference/README.md +4 -0
  169. package/starter/templates/frontend/summary.md +7 -0
  170. package/starter/templates/frontend/tasks.md +11 -0
  171. package/starter/templates/library/CLAUDE.md +22 -0
  172. package/starter/templates/library/FINDINGS.md +7 -0
  173. package/starter/templates/library/reference/README.md +4 -0
  174. package/starter/templates/library/summary.md +7 -0
  175. package/starter/templates/library/tasks.md +11 -0
  176. package/starter/templates/monorepo/CLAUDE.md +21 -0
  177. package/starter/templates/monorepo/FINDINGS.md +7 -0
  178. package/starter/templates/monorepo/reference/README.md +4 -0
  179. package/starter/templates/monorepo/summary.md +7 -0
  180. package/starter/templates/monorepo/tasks.md +11 -0
  181. package/starter/templates/python-project/CLAUDE.md +21 -0
  182. package/starter/templates/python-project/FINDINGS.md +7 -0
  183. package/starter/templates/python-project/reference/README.md +4 -0
  184. package/starter/templates/python-project/summary.md +7 -0
  185. package/starter/templates/python-project/tasks.md +10 -0
@@ -0,0 +1,651 @@
1
+ import { mcpResponse } from "./mcp-types.js";
2
+ import { z } from "zod";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import * as crypto from "crypto";
6
+ import { execFileSync } from "child_process";
7
+ import { debugLog, isMemoryScopeVisible, normalizeMemoryScope } from "./shared.js";
8
+ import { withFileLock } from "./shared-governance.js";
9
+ import { isValidProjectName, errorMessage } from "./utils.js";
10
+ import { runCustomHooks } from "./hooks.js";
11
+ import { readExtractedFacts } from "./mcp-extract-facts.js";
12
+ import { resolveFindingSessionId } from "./finding-context.js";
13
+ import { readTasks } from "./data-tasks.js";
14
+ import { readFindings } from "./data-access.js";
15
+ import { getProjectDirs } from "./shared.js";
16
+ import { getActiveTaskForSession } from "./task-lifecycle.js";
17
+ import { listTaskCheckpoints, writeTaskCheckpoint } from "./session-checkpoints.js";
18
+ import { markImpactEntriesCompletedForSession } from "./finding-impact.js";
19
+ import { atomicWriteJson, debugError, scanSessionFiles } from "./session-utils.js";
20
+ const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24 hours
21
+ function collectGitStatusSnapshot(cwd) {
22
+ try {
23
+ const output = execFileSync("git", ["status", "--short"], { cwd, encoding: "utf8" }).trim();
24
+ if (!output)
25
+ return { gitStatus: "", editedFiles: [] };
26
+ const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
27
+ const editedFiles = lines.map((line) => line.replace(/^[ MADRCU?!]{1,2}\s+/, "").trim()).filter(Boolean);
28
+ return { gitStatus: output, editedFiles };
29
+ }
30
+ catch (err) {
31
+ debugLog(`session checkpoint git status failed: ${errorMessage(err)}`);
32
+ return { gitStatus: "", editedFiles: [] };
33
+ }
34
+ }
35
+ function extractFailingTests(summary, fallbackContext) {
36
+ const text = [summary || "", fallbackContext || ""].join("\n").trim();
37
+ if (!text)
38
+ return [];
39
+ const tests = new Set();
40
+ for (const line of text.split("\n")) {
41
+ const trimmed = line.trim();
42
+ const failLine = trimmed.match(/^FAIL(?:ED)?\s+(.+)$/i);
43
+ if (failLine?.[1])
44
+ tests.add(failLine[1].trim());
45
+ const vitestLine = trimmed.match(/^(?:\u00d7|\u2716)\s+(.+)$/);
46
+ if (vitestLine?.[1])
47
+ tests.add(vitestLine[1].trim());
48
+ const named = trimmed.match(/(?:failing tests?|failed tests?)\s*:\s*(.+)$/i);
49
+ if (named?.[1]) {
50
+ for (const part of named[1].split(/[;,]/)) {
51
+ const candidate = part.trim();
52
+ if (candidate)
53
+ tests.add(candidate);
54
+ }
55
+ }
56
+ const junitStyle = trimmed.match(/test(?:\s+case)?\s+["'`](.+?)["'`]\s+failed/i);
57
+ if (junitStyle?.[1])
58
+ tests.add(junitStyle[1].trim());
59
+ }
60
+ return [...tests];
61
+ }
62
+ function extractResumptionHint(summary, fallbackNextStep, fallbackLastAttempt) {
63
+ const normalizedSummary = (summary || "").trim();
64
+ if (!normalizedSummary) {
65
+ return {
66
+ lastAttempt: fallbackLastAttempt.trim() || "No prior attempt captured",
67
+ nextStep: fallbackNextStep.trim() || "Resume implementation",
68
+ };
69
+ }
70
+ const lines = normalizedSummary
71
+ .split("\n")
72
+ .map((line) => line.trim())
73
+ .filter(Boolean);
74
+ const nextPattern = /^(?:next(?:\s+step)?|next up|todo|to do|follow-up|follow up|remaining)\s*[:\-]\s*(.+)$/i;
75
+ let nextStep = null;
76
+ const lastAttemptLines = [];
77
+ for (const line of lines) {
78
+ const nextMatch = line.match(nextPattern);
79
+ if (nextMatch?.[1]) {
80
+ if (!nextStep)
81
+ nextStep = nextMatch[1].trim();
82
+ continue;
83
+ }
84
+ lastAttemptLines.push(line);
85
+ }
86
+ const lastAttempt = (lastAttemptLines.join(" ").trim() || normalizedSummary).trim();
87
+ return {
88
+ lastAttempt: lastAttempt || fallbackLastAttempt.trim() || "No prior attempt captured",
89
+ nextStep: nextStep || fallbackNextStep.trim() || "Resume implementation",
90
+ };
91
+ }
92
+ /** Per-connection session map keyed by arbitrary connection ID (if provided). */
93
+ const _sessionMap = new Map();
94
+ function sessionsDir(phrenPath) {
95
+ const dir = path.join(phrenPath, ".runtime", "sessions");
96
+ fs.mkdirSync(dir, { recursive: true });
97
+ return dir;
98
+ }
99
+ function sessionFileForId(phrenPath, sessionId) {
100
+ return path.join(sessionsDir(phrenPath), `session-${sessionId}.json`);
101
+ }
102
+ function readSessionStateFile(file) {
103
+ if (!fs.existsSync(file))
104
+ return null;
105
+ try {
106
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
107
+ }
108
+ catch (err) {
109
+ debugError("readSessionStateFile", err);
110
+ return null;
111
+ }
112
+ }
113
+ function writeSessionStateFile(file, state) {
114
+ atomicWriteJson(file, state);
115
+ }
116
+ /** Find the most recent *active* (not ended) session file by mtime. */
117
+ function findMostRecentSession(phrenPath) {
118
+ const dir = sessionsDir(phrenPath);
119
+ const results = scanSessionFiles(dir, readSessionStateFile, (state) => !state.endedAt, { errorScope: "findMostRecentSession" });
120
+ if (results.length === 0)
121
+ return null;
122
+ const best = results[0]; // already sorted newest-mtime-first
123
+ return { file: best.fullPath, state: best.data };
124
+ }
125
+ export function resolveActiveSessionScope(phrenPath, project) {
126
+ const dir = sessionsDir(phrenPath);
127
+ const results = scanSessionFiles(dir, readSessionStateFile, (state) => {
128
+ if (state.endedAt)
129
+ return false;
130
+ if (project && state.project && state.project !== project)
131
+ return false;
132
+ return true;
133
+ }, { includeMtime: false, errorScope: "resolveActiveSessionScope" });
134
+ let bestState = null;
135
+ let bestStartedAt = 0;
136
+ for (const { data: state } of results) {
137
+ const startedAt = Date.parse(state.startedAt || "");
138
+ const candidate = Number.isNaN(startedAt) ? 0 : startedAt;
139
+ if (!bestState || candidate >= bestStartedAt) {
140
+ bestState = state;
141
+ bestStartedAt = candidate;
142
+ }
143
+ }
144
+ return normalizeMemoryScope(bestState?.agentScope);
145
+ }
146
+ /** Path for the last-summary fast-path file. */
147
+ function lastSummaryPath(phrenPath) {
148
+ return path.join(sessionsDir(phrenPath), "last-summary.json");
149
+ }
150
+ /** Write the last summary for fast retrieval by next session_start. */
151
+ function writeLastSummary(phrenPath, summary, sessionId, project) {
152
+ try {
153
+ const data = { summary, sessionId, project, endedAt: new Date().toISOString() };
154
+ fs.writeFileSync(lastSummaryPath(phrenPath), JSON.stringify(data, null, 2));
155
+ }
156
+ catch (err) {
157
+ debugError("writeLastSummary", err);
158
+ }
159
+ }
160
+ /** Find the most recent session with a summary (including ended sessions). */
161
+ export function findMostRecentSummary(phrenPath) {
162
+ return findMostRecentSummaryWithProject(phrenPath).summary;
163
+ }
164
+ /** Find the most recent session with a summary and project context. */
165
+ function findMostRecentSummaryWithProject(phrenPath) {
166
+ // Fast path: read from dedicated last-summary file
167
+ try {
168
+ const fastPath = lastSummaryPath(phrenPath);
169
+ if (fs.existsSync(fastPath)) {
170
+ const data = JSON.parse(fs.readFileSync(fastPath, "utf-8"));
171
+ if (data.summary)
172
+ return { summary: data.summary, project: data.project };
173
+ }
174
+ }
175
+ catch (err) {
176
+ debugError("findMostRecentSummaryWithProject fastPath", err);
177
+ }
178
+ // Slow path: scan all session files
179
+ const dir = sessionsDir(phrenPath);
180
+ const results = scanSessionFiles(dir, readSessionStateFile, (state) => !!state.summary, { errorScope: "findMostRecentSummaryWithProject" });
181
+ if (results.length === 0)
182
+ return { summary: null };
183
+ const best = results[0]; // already sorted newest-mtime-first
184
+ return { summary: best.data.summary, project: best.data.project };
185
+ }
186
+ /** Resolve session file from an explicit sessionId or a previously-bound connectionId. */
187
+ function resolveSessionFile(phrenPath, sessionId, connectionId) {
188
+ const effectiveId = sessionId ?? (connectionId ? _sessionMap.get(connectionId) : undefined);
189
+ if (effectiveId) {
190
+ const file = sessionFileForId(phrenPath, effectiveId);
191
+ const state = readSessionStateFile(file);
192
+ if (!state)
193
+ return null;
194
+ // Always reject ended sessions — prevents double session_end and stale session_context.
195
+ if (state.endedAt)
196
+ return null;
197
+ return { file, state };
198
+ }
199
+ return null;
200
+ }
201
+ /** Remove session files older than 24 hours. */
202
+ function cleanupStaleSessions(phrenPath) {
203
+ const dir = sessionsDir(phrenPath);
204
+ // Scan all session files (keep all, we'll filter and unlink manually)
205
+ const results = scanSessionFiles(dir, (filePath) => readSessionStateFile(filePath) ?? null, () => true, // accept everything — we decide inside the loop
206
+ { includeMtime: false, errorScope: "cleanupStaleSessions" });
207
+ let cleaned = 0;
208
+ for (const { fullPath, data: state } of results) {
209
+ try {
210
+ // prefer startedAt from the JSON content over mtime (reliable on noatime mounts)
211
+ const ageMs = state?.startedAt
212
+ ? Date.now() - new Date(state.startedAt).getTime()
213
+ : Date.now() - fs.statSync(fullPath).mtimeMs;
214
+ if (ageMs > STALE_SESSION_MS) {
215
+ fs.unlinkSync(fullPath);
216
+ cleaned++;
217
+ }
218
+ }
219
+ catch (err) {
220
+ debugError("cleanupStaleSessions entry", err);
221
+ }
222
+ }
223
+ return cleaned;
224
+ }
225
+ /** Increment the findingsAdded counter for a session. Falls back to the most relevant active session for the project. */
226
+ export function incrementSessionFindings(phrenPath, count = 1, sessionId, project) {
227
+ incrementSessionCounter(phrenPath, "findingsAdded", count, sessionId, project);
228
+ }
229
+ export function incrementSessionTasksCompleted(phrenPath, count = 1, sessionId, project) {
230
+ incrementSessionCounter(phrenPath, "tasksCompleted", count, sessionId, project);
231
+ }
232
+ function incrementSessionCounter(phrenPath, field, count = 1, sessionId, project) {
233
+ try {
234
+ const effectiveSessionId = project
235
+ ? resolveFindingSessionId(phrenPath, project, sessionId)
236
+ : sessionId;
237
+ if (!effectiveSessionId) {
238
+ debugLog(`${field} increment called without a resolvable sessionId — skipping`);
239
+ return;
240
+ }
241
+ const resolved = resolveSessionFile(phrenPath, effectiveSessionId);
242
+ if (!resolved)
243
+ return;
244
+ const { file } = resolved;
245
+ withFileLock(file, () => {
246
+ const current = readSessionStateFile(file);
247
+ if (!current)
248
+ return;
249
+ const nextValue = Number.isFinite(current[field]) ? current[field] + count : count;
250
+ writeSessionStateFile(file, {
251
+ ...current,
252
+ findingsAdded: Number.isFinite(current.findingsAdded) ? current.findingsAdded : 0,
253
+ tasksCompleted: Number.isFinite(current.tasksCompleted) ? current.tasksCompleted : 0,
254
+ [field]: nextValue,
255
+ });
256
+ });
257
+ }
258
+ catch (err) {
259
+ debugError(`incrementSessionCounter(${field})`, err);
260
+ }
261
+ }
262
+ /** List all sessions (both active and ended) from the sessions directory, sorted newest first. */
263
+ export function listAllSessions(phrenPath, limit = 50) {
264
+ const dir = sessionsDir(phrenPath);
265
+ // scanSessionFiles returns results sorted by mtime (newest first)
266
+ const results = scanSessionFiles(dir, readSessionStateFile, () => true, { errorScope: "listAllSessions" });
267
+ const entries = [];
268
+ for (const { data: state } of results) {
269
+ if (entries.length >= limit)
270
+ break;
271
+ const durationMs = state.endedAt
272
+ ? new Date(state.endedAt).getTime() - new Date(state.startedAt).getTime()
273
+ : Date.now() - new Date(state.startedAt).getTime();
274
+ entries.push({
275
+ sessionId: state.sessionId,
276
+ project: state.project,
277
+ agentScope: state.agentScope,
278
+ startedAt: state.startedAt,
279
+ endedAt: state.endedAt,
280
+ durationMins: Math.round(durationMs / 60000),
281
+ summary: state.summary,
282
+ findingsAdded: Number.isFinite(state.findingsAdded) ? state.findingsAdded : 0,
283
+ tasksCompleted: Number.isFinite(state.tasksCompleted) ? state.tasksCompleted : 0,
284
+ status: state.endedAt ? "ended" : "active",
285
+ });
286
+ }
287
+ // Already sorted by mtime, but re-sort by startedAt for accuracy
288
+ entries.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
289
+ return entries;
290
+ }
291
+ export function getSessionArtifacts(phrenPath, sessionId, project) {
292
+ const findings = [];
293
+ const tasks = [];
294
+ const shortId = sessionId.slice(0, 8);
295
+ try {
296
+ const projectDirs = getProjectDirs(phrenPath);
297
+ const targetProjects = project ? [project] : projectDirs;
298
+ for (const proj of targetProjects) {
299
+ // Findings with matching sessionId
300
+ const findingsResult = readFindings(phrenPath, proj);
301
+ if (findingsResult.ok) {
302
+ for (const f of findingsResult.data) {
303
+ if (f.sessionId && (f.sessionId === sessionId || f.sessionId.startsWith(shortId))) {
304
+ findings.push({
305
+ project: proj,
306
+ id: f.id,
307
+ date: f.date,
308
+ text: f.text,
309
+ });
310
+ }
311
+ }
312
+ }
313
+ // Tasks with matching sessionId
314
+ const tasksResult = readTasks(phrenPath, proj);
315
+ if (tasksResult.ok) {
316
+ for (const section of ["Active", "Queue", "Done"]) {
317
+ for (const t of tasksResult.data.items[section]) {
318
+ if (t.sessionId && (t.sessionId === sessionId || t.sessionId.startsWith(shortId))) {
319
+ tasks.push({
320
+ project: proj,
321
+ id: t.id,
322
+ text: t.line,
323
+ section,
324
+ checked: t.checked,
325
+ });
326
+ }
327
+ }
328
+ }
329
+ }
330
+ }
331
+ }
332
+ catch (err) {
333
+ debugLog(`getSessionArtifacts error: ${errorMessage(err)}`);
334
+ }
335
+ return { findings, tasks };
336
+ }
337
+ function hasCompletedTasksInSession(phrenPath, sessionId, project) {
338
+ const artifacts = getSessionArtifacts(phrenPath, sessionId, project);
339
+ return artifacts.tasks.some((task) => task.section === "Done" && task.checked);
340
+ }
341
+ export function register(server, ctx) {
342
+ const { phrenPath } = ctx;
343
+ server.registerTool("session_start", {
344
+ title: "◆ phren · session start",
345
+ description: "Mark the start of a new session and retrieve context from prior sessions. Call this at the start of a conversation when not using hooks. Returns prior session summary and recent project findings. The returned sessionId should be passed to session_end and session_context to avoid cross-client collisions.",
346
+ inputSchema: z.object({
347
+ project: z.string().optional().describe("Project to load context for."),
348
+ agentScope: z.string().optional().describe("Optional memory scope for this agent session (for example 'researcher' or 'builder')."),
349
+ connectionId: z.string().optional().describe("Optional stable identifier for this client connection. When provided, session_end/session_context can resolve the session without an explicit sessionId."),
350
+ }),
351
+ }, async ({ project, agentScope, connectionId }) => {
352
+ // Clean up stale sessions (>24h)
353
+ cleanupStaleSessions(phrenPath);
354
+ const normalizedAgentScope = agentScope === undefined ? undefined : normalizeMemoryScope(agentScope);
355
+ if (agentScope !== undefined && !normalizedAgentScope) {
356
+ return mcpResponse({ ok: false, error: `Invalid agentScope: "${agentScope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars).` });
357
+ }
358
+ // Find most recent prior session for context
359
+ const priorResult = findMostRecentSession(phrenPath);
360
+ const prior = priorResult?.state ?? null;
361
+ // Also check ended sessions for summaries and project context.
362
+ // findMostRecentSession skips ended sessions, so we need a separate lookup
363
+ // to restore project context after a normal session_end.
364
+ const priorEnded = prior ? null : findMostRecentSummaryWithProject(phrenPath);
365
+ const priorSummary = prior?.summary ?? priorEnded?.summary ?? null;
366
+ const priorProject = prior?.project ?? priorEnded?.project;
367
+ // Create new session with unique ID in its own file
368
+ const sessionId = crypto.randomUUID();
369
+ const next = {
370
+ sessionId,
371
+ project: project ?? priorProject,
372
+ agentScope: normalizedAgentScope,
373
+ startedAt: new Date().toISOString(),
374
+ findingsAdded: 0,
375
+ tasksCompleted: 0,
376
+ };
377
+ const newFile = sessionFileForId(phrenPath, sessionId);
378
+ writeSessionStateFile(newFile, next);
379
+ if (connectionId)
380
+ _sessionMap.set(connectionId, sessionId);
381
+ const parts = [];
382
+ if (priorSummary) {
383
+ parts.push(`## Last session\n${priorSummary}`);
384
+ }
385
+ const activeProject = project ?? priorProject;
386
+ const activeScope = normalizedAgentScope;
387
+ if (activeProject && isValidProjectName(activeProject)) {
388
+ try {
389
+ const findings = readFindings(phrenPath, activeProject);
390
+ if (findings.ok) {
391
+ const bullets = findings.data
392
+ .filter((entry) => isMemoryScopeVisible(normalizeMemoryScope(entry.scope), activeScope))
393
+ .slice(-5)
394
+ .map((entry) => `- ${entry.text}`);
395
+ if (bullets.length > 0) {
396
+ parts.push(`## Recent findings (${activeProject})\n${bullets.join("\n")}`);
397
+ }
398
+ }
399
+ }
400
+ catch (err) {
401
+ debugError("session_start findingsRead", err);
402
+ }
403
+ try {
404
+ const tasks = readTasks(phrenPath, activeProject);
405
+ if (tasks.ok) {
406
+ const queueItems = tasks.data.items.Queue
407
+ .filter((entry) => isMemoryScopeVisible(normalizeMemoryScope(entry.scope), activeScope))
408
+ .slice(0, 5)
409
+ .map((entry) => `- [ ] ${entry.line}`);
410
+ if (queueItems.length > 0) {
411
+ parts.push(`## Active task (${activeProject})\n${queueItems.join("\n")}`);
412
+ }
413
+ }
414
+ }
415
+ catch (err) {
416
+ debugError("session_start taskRead", err);
417
+ }
418
+ // Surface extracted preferences/facts for this project
419
+ try {
420
+ const facts = readExtractedFacts(phrenPath, activeProject).slice(-10);
421
+ if (facts.length > 0) {
422
+ parts.push(`## Preferences (${activeProject})\n${facts.map(f => `- ${f.fact}`).join("\n")}`);
423
+ }
424
+ }
425
+ catch (err) {
426
+ debugError("session_start factsRead", err);
427
+ }
428
+ try {
429
+ const checkpoints = listTaskCheckpoints(phrenPath, activeProject).slice(0, 3);
430
+ if (checkpoints.length > 0) {
431
+ const lines = [];
432
+ for (const checkpoint of checkpoints) {
433
+ lines.push(`- ${(checkpoint.taskText || checkpoint.taskLine).trim()} (task: ${checkpoint.taskId})`);
434
+ lines.push(` Last attempt: ${checkpoint.resumptionHint.lastAttempt}`);
435
+ lines.push(` Next step: ${checkpoint.resumptionHint.nextStep}`);
436
+ if (checkpoint.editedFiles.length > 0) {
437
+ lines.push(` Edited files: ${checkpoint.editedFiles.slice(0, 5).join(", ")}${checkpoint.editedFiles.length > 5 ? ", ..." : ""}`);
438
+ }
439
+ if (checkpoint.failingTests.length > 0) {
440
+ lines.push(` Failing tests: ${checkpoint.failingTests.slice(0, 3).join(", ")}${checkpoint.failingTests.length > 3 ? ", ..." : ""}`);
441
+ }
442
+ }
443
+ parts.push(`## Continue where you left off? (${activeProject})\n${lines.join("\n")}`);
444
+ }
445
+ }
446
+ catch (err) {
447
+ debugError("session_start checkpointsRead", err);
448
+ }
449
+ }
450
+ const message = parts.length > 0
451
+ ? `Session started (${sessionId.slice(0, 8)}).\n\n${parts.join("\n\n")}`
452
+ : `Session started (${sessionId.slice(0, 8)}). No prior context found.`;
453
+ return mcpResponse({ ok: true, message, data: { sessionId, project: activeProject, agentScope: activeScope } });
454
+ });
455
+ server.registerTool("session_end", {
456
+ title: "◆ phren · session end",
457
+ description: "Mark the end of a session and save a summary for the next session to pick up. Call this before ending a conversation to preserve context. Pass the sessionId returned by session_start, or a stable connectionId bound at session_start.",
458
+ inputSchema: z.object({
459
+ summary: z.string().optional().describe("What was accomplished this session. Shown at the start of the next session."),
460
+ sessionId: z.string().optional().describe("Session ID to end (returned by session_start)."),
461
+ connectionId: z.string().optional().describe("Connection ID passed to session_start. Used to resolve the session when sessionId is not provided."),
462
+ }),
463
+ }, async ({ summary, sessionId, connectionId }) => {
464
+ if (!sessionId && !connectionId) {
465
+ return mcpResponse({ ok: false, error: "Pass sessionId or connectionId. Implicit process-global session fallback has been removed." });
466
+ }
467
+ const resolved = resolveSessionFile(phrenPath, sessionId, connectionId);
468
+ if (!resolved)
469
+ return mcpResponse({ ok: false, error: "No active session. Call session_start first." });
470
+ const { file, state } = resolved;
471
+ const endedState = withFileLock(file, () => {
472
+ const current = readSessionStateFile(file);
473
+ if (!current)
474
+ return null;
475
+ const next = {
476
+ ...current,
477
+ endedAt: new Date().toISOString(),
478
+ summary: summary ?? current.summary,
479
+ };
480
+ writeSessionStateFile(file, next);
481
+ return next;
482
+ });
483
+ if (!endedState)
484
+ return mcpResponse({ ok: false, error: "No active session. Call session_start first." });
485
+ if (connectionId) {
486
+ _sessionMap.delete(connectionId);
487
+ }
488
+ // Also remove from _sessionMap by value (in case connectionId wasn't provided but was used at start)
489
+ for (const [key, val] of _sessionMap) {
490
+ if (val === state.sessionId)
491
+ _sessionMap.delete(key);
492
+ }
493
+ // Write fast-path summary file for next session_start — also persist project so
494
+ // session_start can restore project context even after a normal session_end.
495
+ const effectiveSummary = endedState.summary;
496
+ if (effectiveSummary) {
497
+ writeLastSummary(phrenPath, effectiveSummary, state.sessionId, endedState.project);
498
+ }
499
+ if (endedState.project && isValidProjectName(endedState.project)) {
500
+ try {
501
+ const trackedActiveTask = getActiveTaskForSession(phrenPath, state.sessionId, endedState.project);
502
+ const activeTask = trackedActiveTask ?? (() => {
503
+ const tasks = readTasks(phrenPath, endedState.project);
504
+ if (!tasks.ok)
505
+ return null;
506
+ return tasks.data.items.Active[0] ?? null;
507
+ })();
508
+ if (activeTask) {
509
+ const taskId = activeTask.stableId || activeTask.id;
510
+ const { gitStatus, editedFiles } = collectGitStatusSnapshot(process.cwd());
511
+ const resumptionHint = extractResumptionHint(effectiveSummary, activeTask.line, activeTask.context || "No prior attempt captured");
512
+ writeTaskCheckpoint(phrenPath, {
513
+ project: endedState.project,
514
+ taskId,
515
+ taskText: activeTask.line,
516
+ taskLine: activeTask.line,
517
+ sessionId: state.sessionId,
518
+ createdAt: new Date().toISOString(),
519
+ resumptionHint,
520
+ gitStatus,
521
+ editedFiles,
522
+ failingTests: extractFailingTests(effectiveSummary, activeTask.context),
523
+ });
524
+ }
525
+ }
526
+ catch (err) {
527
+ debugLog(`session checkpoint write failed: ${errorMessage(err)}`);
528
+ }
529
+ }
530
+ try {
531
+ const tasksCompleted = Number.isFinite(endedState.tasksCompleted) ? endedState.tasksCompleted : 0;
532
+ if (tasksCompleted > 0 || hasCompletedTasksInSession(phrenPath, state.sessionId, endedState.project)) {
533
+ markImpactEntriesCompletedForSession(phrenPath, state.sessionId, endedState.project);
534
+ }
535
+ }
536
+ catch (err) {
537
+ debugLog(`impact scoring update failed: ${errorMessage(err)}`);
538
+ }
539
+ const durationMs = new Date(endedState.endedAt).getTime() - new Date(state.startedAt).getTime();
540
+ const durationMins = Math.round(durationMs / 60000);
541
+ runCustomHooks(phrenPath, "post-session-end", {
542
+ PHREN_SESSION_ID: state.sessionId,
543
+ PHREN_DURATION_MINS: String(durationMins),
544
+ PHREN_FINDINGS_ADDED: String(endedState.findingsAdded),
545
+ PHREN_TASKS_COMPLETED: String(Number.isFinite(endedState.tasksCompleted) ? endedState.tasksCompleted : 0),
546
+ ...(endedState.project ? { PHREN_PROJECT: endedState.project } : {}),
547
+ });
548
+ return mcpResponse({
549
+ ok: true,
550
+ message: `Session ended. Duration: ~${durationMins} min. ${endedState.findingsAdded} finding(s) added, ${Number.isFinite(endedState.tasksCompleted) ? endedState.tasksCompleted : 0} task(s) completed.${summary ? " Summary saved for next session." : ""}`,
551
+ data: {
552
+ sessionId: state.sessionId,
553
+ durationMins,
554
+ findingsAdded: endedState.findingsAdded,
555
+ tasksCompleted: Number.isFinite(endedState.tasksCompleted) ? endedState.tasksCompleted : 0,
556
+ },
557
+ });
558
+ });
559
+ server.registerTool("session_context", {
560
+ title: "◆ phren · session context",
561
+ description: "Get the current session context -- active project, session duration, findings added, and prior session summary. Pass the sessionId returned by session_start, or a stable connectionId bound at session_start.",
562
+ inputSchema: z.object({
563
+ sessionId: z.string().optional().describe("Session ID to query (returned by session_start)."),
564
+ connectionId: z.string().optional().describe("Connection ID passed to session_start. Used to resolve the session when sessionId is not provided."),
565
+ }),
566
+ }, async ({ sessionId, connectionId }) => {
567
+ if (!sessionId && !connectionId) {
568
+ return mcpResponse({ ok: false, error: "Pass sessionId or connectionId. Implicit process-global session fallback has been removed." });
569
+ }
570
+ const resolved = resolveSessionFile(phrenPath, sessionId, connectionId);
571
+ if (!resolved)
572
+ return mcpResponse({ ok: false, error: "No active session. Call session_start first.", data: null });
573
+ const { state } = resolved;
574
+ const durationMs = Date.now() - new Date(state.startedAt).getTime();
575
+ const durationMins = Math.round(durationMs / 60000);
576
+ const parts = [
577
+ `Session: ${state.sessionId.slice(0, 8)}`,
578
+ `Project: ${state.project ?? "none"}`,
579
+ `Agent scope: ${state.agentScope ?? "none"}`,
580
+ `Started: ${state.startedAt}`,
581
+ `Duration: ~${durationMins} min`,
582
+ `Findings added: ${state.findingsAdded}`,
583
+ `Tasks completed: ${Number.isFinite(state.tasksCompleted) ? state.tasksCompleted : 0}`,
584
+ ];
585
+ if (state.summary)
586
+ parts.push(`Prior summary: ${state.summary}`);
587
+ return mcpResponse({ ok: true, message: parts.join("\n"), data: state });
588
+ });
589
+ server.registerTool("session_history", {
590
+ title: "◆ phren · session history",
591
+ description: "List past sessions with their duration, findings count, and summary. Optionally drill into a specific session to see all findings and tasks created during it.",
592
+ inputSchema: z.object({
593
+ limit: z.number().optional().describe("Max sessions to return (default 20)."),
594
+ sessionId: z.string().optional().describe("If provided, return full artifacts (findings + tasks) for this session instead of listing all sessions."),
595
+ project: z.string().optional().describe("Filter sessions and artifacts by project."),
596
+ }),
597
+ }, async ({ limit, sessionId: targetSessionId, project }) => {
598
+ if (targetSessionId) {
599
+ // Drill into a specific session
600
+ const sessions = listAllSessions(phrenPath, 200);
601
+ const session = sessions.find(s => s.sessionId === targetSessionId || s.sessionId.startsWith(targetSessionId));
602
+ if (!session)
603
+ return mcpResponse({ ok: false, error: `Session ${targetSessionId} not found.` });
604
+ const artifacts = getSessionArtifacts(phrenPath, session.sessionId, project);
605
+ const parts = [
606
+ `Session: ${session.sessionId.slice(0, 8)}`,
607
+ `Project: ${session.project ?? "none"}`,
608
+ `Started: ${session.startedAt}`,
609
+ `Status: ${session.status}`,
610
+ `Duration: ~${session.durationMins ?? 0} min`,
611
+ `Findings: ${artifacts.findings.length}`,
612
+ `Tasks: ${artifacts.tasks.length}`,
613
+ ];
614
+ if (session.summary)
615
+ parts.push(`\nSummary: ${session.summary}`);
616
+ if (artifacts.findings.length > 0) {
617
+ parts.push("\n## Findings");
618
+ for (const f of artifacts.findings) {
619
+ parts.push(`- [${f.project}] ${f.text}`);
620
+ }
621
+ }
622
+ if (artifacts.tasks.length > 0) {
623
+ parts.push("\n## Tasks");
624
+ for (const t of artifacts.tasks) {
625
+ parts.push(`- [${t.project}/${t.section}] ${t.text}`);
626
+ }
627
+ }
628
+ return mcpResponse({ ok: true, message: parts.join("\n"), data: { session, ...artifacts } });
629
+ }
630
+ // List sessions
631
+ const sessions = listAllSessions(phrenPath, limit ?? 20);
632
+ const filtered = project ? sessions.filter(s => s.project === project) : sessions;
633
+ if (filtered.length === 0)
634
+ return mcpResponse({ ok: true, message: "No sessions found.", data: [] });
635
+ const lines = filtered.map(s => {
636
+ const id = s.sessionId.slice(0, 8);
637
+ const proj = s.project ?? "—";
638
+ const dur = s.durationMins != null ? `${s.durationMins}m` : "?";
639
+ const status = s.status === "active" ? " ●" : "";
640
+ const findings = s.findingsAdded > 0 ? ` ${s.findingsAdded}f` : "";
641
+ const tasks = s.tasksCompleted > 0 ? ` ${s.tasksCompleted}t` : "";
642
+ const date = s.startedAt.slice(0, 16).replace("T", " ");
643
+ return `${id}${status} ${date} ${dur}${findings}${tasks} ${proj}${s.summary ? " " + s.summary.slice(0, 60) : ""}`;
644
+ });
645
+ return mcpResponse({
646
+ ok: true,
647
+ message: `${filtered.length} session(s):\n\n${lines.join("\n")}`,
648
+ data: filtered,
649
+ });
650
+ });
651
+ }