@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,305 @@
1
+ import { debugLog, appendAuditLog, EXEC_TIMEOUT_MS, getPhrenPath, } from "./shared.js";
2
+ import { appendReviewQueue, getRetentionPolicy, recordFeedback, flushEntryScores, entryScoreKey, } from "./shared-governance.js";
3
+ import { detectProject } from "./shared-index.js";
4
+ import { commandExists } from "./hooks.js";
5
+ import { runGit as runGitShared, isFeatureEnabled, clampInt, errorMessage, resolveExecCommand } from "./utils.js";
6
+ import { appendFindingJournal, compactFindingJournals } from "./finding-journal.js";
7
+ import { getProactivityLevelForTask, getProactivityLevelForFindings, shouldAutoCaptureFindingsForLevel } from "./proactivity.js";
8
+ import * as fs from "fs";
9
+ import * as os from "os";
10
+ import * as path from "path";
11
+ import * as crypto from "crypto";
12
+ import { execFileSync } from "child_process";
13
+ import { resolveRuntimeProfile } from "./runtime-profile.js";
14
+ function runGit(cwd, args) {
15
+ return runGitShared(cwd, args, EXEC_TIMEOUT_MS, debugLog);
16
+ }
17
+ function shouldRetryGh(err) {
18
+ const message = err instanceof Error
19
+ ? err.message
20
+ : (typeof err === "object" && err !== null && "message" in err && typeof err.message === "string")
21
+ ? err.message
22
+ : String(err ?? "");
23
+ const msg = String(message);
24
+ return /(rate limit|secondary rate limit|timed out|ecconn|network|502|503|504|bad gateway|service unavailable)/i.test(msg);
25
+ }
26
+ function inferProject(arg) {
27
+ if (arg)
28
+ return arg;
29
+ return detectProject(getPhrenPath(), process.cwd(), resolveRuntimeProfile(getPhrenPath()));
30
+ }
31
+ // ── Git log parsing ──────────────────────────────────────────────────────────
32
+ export function parseGitLogRecords(cwd, days) {
33
+ const fmt = "%H%x1f%s%x1f%b%x1e";
34
+ const raw = runGit(cwd, ["log", `--since=${days} days ago`, "--first-parent", `--pretty=format:${fmt}`]) || "";
35
+ const records = [];
36
+ for (const rec of raw.split("\x1e")) {
37
+ const trimmed = rec.trim();
38
+ if (!trimmed)
39
+ continue;
40
+ const [hash, subject, body] = trimmed.split("\x1f");
41
+ if (!hash || !subject)
42
+ continue;
43
+ records.push({ hash, subject, body: body || "" });
44
+ }
45
+ return records;
46
+ }
47
+ export async function runGhJson(cwd, args) {
48
+ if (!commandExists("gh"))
49
+ return null;
50
+ const retries = clampInt((process.env.PHREN_GH_RETRIES), 2, 0, 5);
51
+ const timeoutMs = clampInt((process.env.PHREN_GH_TIMEOUT_MS), 10000, 1000, 60000);
52
+ const ghExec = resolveExecCommand("gh");
53
+ for (let attempt = 0; attempt <= retries; attempt++) {
54
+ try {
55
+ const out = execFileSync(ghExec.command, args, {
56
+ cwd,
57
+ encoding: "utf8",
58
+ stdio: ["ignore", "pipe", "ignore"],
59
+ shell: ghExec.shell,
60
+ timeout: timeoutMs,
61
+ maxBuffer: 4 * 1024 * 1024,
62
+ }).trim();
63
+ if (!out)
64
+ return null;
65
+ return JSON.parse(out);
66
+ }
67
+ catch (err) {
68
+ if (attempt >= retries || !shouldRetryGh(err))
69
+ return null;
70
+ const backoffMs = 750 * (attempt + 1);
71
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+ export function ghCachePath(repoRoot) {
77
+ const absPath = path.resolve(repoRoot);
78
+ const repoHash = crypto.createHash("sha1").update(absPath).digest("hex").slice(0, 12);
79
+ const dateKey = new Date().toISOString().slice(0, 10);
80
+ return path.join(os.tmpdir(), `phren-gh-cache-${repoHash}-${dateKey}.json`);
81
+ }
82
+ const GH_CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
83
+ export async function mineGithubCandidates(repoRoot) {
84
+ const cacheFile = ghCachePath(repoRoot);
85
+ try {
86
+ const stat = fs.statSync(cacheFile);
87
+ if (Date.now() - stat.mtimeMs < GH_CACHE_MAX_AGE_MS) {
88
+ return JSON.parse(fs.readFileSync(cacheFile, "utf8"));
89
+ }
90
+ }
91
+ catch (err) {
92
+ debugLog(`mineGithubCandidates: cache read failed for ${cacheFile}: ${errorMessage(err)}`);
93
+ }
94
+ const candidates = [];
95
+ const prLimit = clampInt((process.env.PHREN_GH_PR_LIMIT), 40, 5, 200);
96
+ const runLimit = clampInt((process.env.PHREN_GH_RUN_LIMIT), 25, 5, 200);
97
+ const issueLimit = clampInt((process.env.PHREN_GH_ISSUE_LIMIT), 25, 5, 200);
98
+ const prs = await runGhJson(repoRoot, [
99
+ "pr",
100
+ "list",
101
+ "--state",
102
+ "merged",
103
+ "--limit",
104
+ String(prLimit),
105
+ "--json",
106
+ "number,title,body,mergeCommit,files,comments,reviews",
107
+ ]) || [];
108
+ for (const pr of prs) {
109
+ const text = `PR #${pr.number}: ${pr.title}`;
110
+ const body = (pr.body || "").toLowerCase();
111
+ const commentBlob = [
112
+ ...(pr.comments || []).map((c) => c.body || ""),
113
+ ...(pr.reviews || []).map((r) => r.body || ""),
114
+ ].join("\n").toLowerCase();
115
+ let score = 0.65;
116
+ if (/(fix|workaround|must|avoid|regression|incident|root cause|migration)/.test(body))
117
+ score += 0.2;
118
+ if (/(review|comment|nit|requested changes)/.test(body + "\n" + commentBlob))
119
+ score += 0.1;
120
+ if (/(must|should|avoid|required|don't|do not)/.test(commentBlob))
121
+ score += 0.08;
122
+ candidates.push({
123
+ text,
124
+ score: Math.min(0.98, score),
125
+ commit: pr.mergeCommit?.oid,
126
+ file: pr.files?.find((f) => f.path)?.path,
127
+ sourceText: [pr.title, pr.body || "", commentBlob].filter(Boolean).join("\n"),
128
+ });
129
+ }
130
+ const runs = await runGhJson(repoRoot, [
131
+ "run",
132
+ "list",
133
+ "--status",
134
+ "failure",
135
+ "--limit",
136
+ String(runLimit),
137
+ "--json",
138
+ "databaseId,displayTitle,workflowName,headSha",
139
+ ]) || [];
140
+ for (const run of runs) {
141
+ const title = run.displayTitle || run.workflowName || "CI failure";
142
+ const text = `CI failure pattern: ${title}`;
143
+ candidates.push({
144
+ text,
145
+ score: 0.62,
146
+ commit: run.headSha,
147
+ sourceText: title,
148
+ });
149
+ }
150
+ const issues = await runGhJson(repoRoot, [
151
+ "issue",
152
+ "list",
153
+ "--state",
154
+ "all",
155
+ "--limit",
156
+ String(issueLimit),
157
+ "--json",
158
+ "number,title,body",
159
+ ]) || [];
160
+ for (const issue of issues) {
161
+ const body = (issue.body || "").toLowerCase();
162
+ if (!/(bug|regression|incident|outage|postmortem|fix)/.test(body) && !/(bug|regression|incident)/.test(issue.title.toLowerCase())) {
163
+ continue;
164
+ }
165
+ const text = `Issue #${issue.number}: ${issue.title}`;
166
+ candidates.push({ text, score: 0.58, sourceText: [issue.title, issue.body || ""].join("\n") });
167
+ }
168
+ try {
169
+ fs.writeFileSync(cacheFile, JSON.stringify(candidates));
170
+ }
171
+ catch (err) {
172
+ debugLog(`mineGithubCandidates: cache write failed for ${cacheFile}: ${errorMessage(err)}`);
173
+ }
174
+ return candidates;
175
+ }
176
+ // ── Memory candidate scoring ─────────────────────────────────────────────────
177
+ // Reject commit-message-style subjects that lack real insight.
178
+ // Matches patterns like "Fix typo", "Add tests", "Update README", etc.
179
+ const COMMIT_MSG_PREFIX = /^(fix|add|update|remove|delete|rename|move|bump|revert|merge|chore|refactor|style|docs|test|ci|build|release|wip)\b/i;
180
+ // Insight keywords that indicate the entry has learning value even if short.
181
+ const INSIGHT_KEYWORDS = /\b(workaround|must|avoid|regression|root cause|postmortem|incident|retry|timeout|pitfall|caveat|breaking|migration|order matters|race condition|deadlock|flaky)\b/i;
182
+ export function scoreFindingCandidate(subject, body) {
183
+ const s = `${subject}\n${body}`.toLowerCase();
184
+ // Reject short commit-message-style entries unless they contain insight keywords
185
+ const combined = `${subject} ${body}`.trim();
186
+ if (combined.length < 50 && !INSIGHT_KEYWORDS.test(combined))
187
+ return null;
188
+ if (COMMIT_MSG_PREFIX.test(subject.trim()) && !INSIGHT_KEYWORDS.test(combined))
189
+ return null;
190
+ const mergedPr = /merge pull request #\d+/.test(s);
191
+ const ci = /(ci|workflow|pipeline|flake|test fail|build fail)/.test(s);
192
+ const review = /(review|requested changes|address comments|nit|follow-up)/.test(s);
193
+ const learningSignal = INSIGHT_KEYWORDS.test(s);
194
+ let score = 0.35;
195
+ if (mergedPr)
196
+ score += 0.2;
197
+ if (ci)
198
+ score += 0.2;
199
+ if (review)
200
+ score += 0.1;
201
+ if (learningSignal)
202
+ score += 0.25;
203
+ if (subject.length > 20)
204
+ score += 0.05;
205
+ if (score < 0.5)
206
+ return null;
207
+ const cleaned = subject
208
+ .replace(/^merge pull request #\d+\s*from\s+\S+\s*/i, "")
209
+ .replace(/^fix:\s*/i, "")
210
+ .trim();
211
+ const text = cleaned ? cleaned[0].toUpperCase() + cleaned.slice(1) : subject;
212
+ return { score: Math.min(score, 0.99), text };
213
+ }
214
+ // ── handleExtractMemories ────────────────────────────────────────────────────
215
+ export async function handleExtractMemories(projectArg, cwdArg, silent = false, sessionId, source = "extract") {
216
+ const project = inferProject(projectArg);
217
+ if (!project) {
218
+ if (!silent)
219
+ console.error("Usage: phren extract-memories <project>");
220
+ if (!silent)
221
+ process.exit(1);
222
+ return;
223
+ }
224
+ const repoRoot = runGit(cwdArg || process.cwd(), ["rev-parse", "--show-toplevel"]);
225
+ if (!repoRoot) {
226
+ if (!silent)
227
+ console.error("extract-memories must run from inside a git repository.");
228
+ if (!silent)
229
+ process.exit(1);
230
+ return;
231
+ }
232
+ const findingsLevel = getProactivityLevelForFindings(getPhrenPath());
233
+ const taskLevel = getProactivityLevelForTask(getPhrenPath());
234
+ if (taskLevel !== "high") {
235
+ debugLog(`extract-memories task proactivity=${taskLevel}`);
236
+ }
237
+ if (findingsLevel === "low") {
238
+ appendAuditLog(getPhrenPath(), "extract_memories", `project=${project} skipped=proactivity_low`);
239
+ if (!silent)
240
+ console.log(`Skipped memory extraction for ${project}: findings proactivity is low.`);
241
+ return;
242
+ }
243
+ const days = Number.parseInt((process.env.PHREN_MEMORY_EXTRACT_WINDOW_DAYS) || "30", 10);
244
+ const threshold = Number.parseFloat((process.env.PHREN_MEMORY_AUTO_ACCEPT) || String(getRetentionPolicy(getPhrenPath()).autoAcceptThreshold));
245
+ const records = parseGitLogRecords(repoRoot, Number.isNaN(days) ? 30 : days);
246
+ const ghCandidates = isFeatureEnabled("PHREN_FEATURE_GH_MINING", false)
247
+ ? await mineGithubCandidates(repoRoot)
248
+ : [];
249
+ let accepted = 0;
250
+ let queued = 0;
251
+ for (const rec of records) {
252
+ if (!shouldAutoCaptureFindingsForLevel(findingsLevel, rec.subject, rec.body))
253
+ continue;
254
+ const candidate = scoreFindingCandidate(rec.subject, rec.body);
255
+ if (!candidate)
256
+ continue;
257
+ const line = `${candidate.text} (source commit ${rec.hash.slice(0, 8)})`;
258
+ if (candidate.score >= threshold) {
259
+ appendFindingJournal(getPhrenPath(), project, line, {
260
+ source,
261
+ sessionId,
262
+ repo: repoRoot,
263
+ commit: rec.hash,
264
+ });
265
+ accepted++;
266
+ }
267
+ else {
268
+ const qr1 = appendReviewQueue(getPhrenPath(), project, "Review", [`[confidence ${candidate.score.toFixed(2)}] ${line}`]);
269
+ if (qr1.ok)
270
+ queued += qr1.data;
271
+ }
272
+ }
273
+ for (const c of ghCandidates) {
274
+ if (!shouldAutoCaptureFindingsForLevel(findingsLevel, c.sourceText ?? c.text))
275
+ continue;
276
+ const line = `${c.text}${c.commit ? ` (source commit ${c.commit.slice(0, 8)})` : ""}`;
277
+ if (c.text.startsWith("CI failure pattern:")) {
278
+ const key = entryScoreKey(project, "FINDINGS.md", line);
279
+ recordFeedback(getPhrenPath(), key, "regression");
280
+ }
281
+ if (c.score >= threshold) {
282
+ appendFindingJournal(getPhrenPath(), project, line, {
283
+ source,
284
+ sessionId,
285
+ repo: repoRoot,
286
+ commit: c.commit,
287
+ file: c.file,
288
+ });
289
+ accepted++;
290
+ }
291
+ else {
292
+ const qr2 = appendReviewQueue(getPhrenPath(), project, "Review", [`[confidence ${c.score.toFixed(2)}] ${line}`]);
293
+ if (qr2.ok)
294
+ queued += qr2.data;
295
+ }
296
+ }
297
+ if (!silent) {
298
+ const compacted = compactFindingJournals(getPhrenPath(), project);
299
+ debugLog(`extract-memories compacted journals for ${project}: added=${compacted.added} skipped=${compacted.skipped} failed=${compacted.failed}`);
300
+ }
301
+ flushEntryScores(getPhrenPath());
302
+ appendAuditLog(getPhrenPath(), "extract_memories", `project=${project} accepted=${accepted} queued=${queued} window_days=${days}`);
303
+ if (!silent)
304
+ console.log(`Extracted memory candidates for ${project}: accepted=${accepted}, queued=${queued}, window=${days}d`);
305
+ }
@@ -0,0 +1,371 @@
1
+ import { appendAuditLog, qualityMarkers, getProjectDirs, getPhrenPath, } from "./shared.js";
2
+ import { appendReviewQueue, getRetentionPolicy, consolidateProjectFindings, updateRuntimeHealth, pruneDeadMemories, } from "./shared-governance.js";
3
+ import { filterTrustedFindingsDetailed, } from "./shared-content.js";
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import { handleExtractMemories } from "./cli-extract.js";
7
+ import { errorMessage } from "./utils.js";
8
+ import { compactFindingJournals } from "./finding-journal.js";
9
+ import { resolveRuntimeProfile } from "./runtime-profile.js";
10
+ // ── Shared helpers ───────────────────────────────────────────────────────────
11
+ function targetProjects(projectArg) {
12
+ const profile = resolveRuntimeProfile(getPhrenPath());
13
+ return projectArg
14
+ ? [projectArg]
15
+ : getProjectDirs(getPhrenPath(), profile).map((p) => path.basename(p)).filter((p) => p !== "global");
16
+ }
17
+ function parseProjectDryRunArgs(args, command, usage) {
18
+ let projectArg;
19
+ let dryRun = false;
20
+ for (const arg of args) {
21
+ if (arg === "--dry-run") {
22
+ dryRun = true;
23
+ continue;
24
+ }
25
+ if (arg.startsWith("-")) {
26
+ console.error(`Unknown ${command} flag: ${arg}`);
27
+ console.error(usage);
28
+ process.exit(1);
29
+ }
30
+ if (projectArg) {
31
+ console.error(`Usage: ${usage}`);
32
+ process.exit(1);
33
+ }
34
+ projectArg = arg;
35
+ }
36
+ return { projectArg, dryRun };
37
+ }
38
+ function captureFindingBackups(projects) {
39
+ const snapshots = new Map();
40
+ for (const project of projects) {
41
+ const backup = path.join(getPhrenPath(), project, "FINDINGS.md.bak");
42
+ if (!fs.existsSync(backup))
43
+ continue;
44
+ snapshots.set(backup, fs.statSync(backup).mtimeMs);
45
+ }
46
+ return snapshots;
47
+ }
48
+ function summarizeBackupChanges(before, projects) {
49
+ const changed = [];
50
+ for (const project of projects) {
51
+ const backup = path.join(getPhrenPath(), project, "FINDINGS.md.bak");
52
+ if (!fs.existsSync(backup))
53
+ continue;
54
+ const current = fs.statSync(backup).mtimeMs;
55
+ const previous = before.get(backup);
56
+ if (previous === undefined || current !== previous) {
57
+ // Normalize to forward slashes for consistent output across platforms
58
+ changed.push(path.relative(getPhrenPath(), backup).replace(/\\/g, "/"));
59
+ }
60
+ }
61
+ return changed.sort();
62
+ }
63
+ export async function handleGovernMemories(projectArg, silent = false, dryRun = false) {
64
+ const profile = resolveRuntimeProfile(getPhrenPath());
65
+ const policy = getRetentionPolicy(getPhrenPath());
66
+ const ttlDays = Number.parseInt((process.env.PHREN_MEMORY_TTL_DAYS) || String(policy.ttlDays), 10);
67
+ const projects = projectArg
68
+ ? [projectArg]
69
+ : getProjectDirs(getPhrenPath(), profile).map((p) => path.basename(p)).filter((p) => p !== "global");
70
+ let staleCount = 0;
71
+ let conflictCount = 0;
72
+ let reviewCount = 0;
73
+ for (const project of projects) {
74
+ const learningsPath = path.join(getPhrenPath(), project, "FINDINGS.md");
75
+ if (!fs.existsSync(learningsPath))
76
+ continue;
77
+ const content = fs.readFileSync(learningsPath, "utf8");
78
+ const trust = filterTrustedFindingsDetailed(content, {
79
+ ttlDays: Number.isNaN(ttlDays) ? policy.ttlDays : ttlDays,
80
+ minConfidence: policy.minInjectConfidence,
81
+ decay: policy.decay,
82
+ });
83
+ const stale = trust.issues.filter((i) => i.reason === "stale").map((i) => i.bullet);
84
+ const conflicts = trust.issues.filter((i) => i.reason === "invalid_citation").map((i) => i.bullet);
85
+ staleCount += stale.length;
86
+ conflictCount += conflicts.length;
87
+ const lowValue = content.split("\n")
88
+ .filter((l) => l.startsWith("- "))
89
+ .filter((l) => /(fixed stuff|updated things|misc|temp|wip|quick note)/i.test(l) || l.length < 16);
90
+ reviewCount += lowValue.length;
91
+ if (!dryRun) {
92
+ appendReviewQueue(getPhrenPath(), project, "Stale", stale);
93
+ appendReviewQueue(getPhrenPath(), project, "Conflicts", conflicts);
94
+ appendReviewQueue(getPhrenPath(), project, "Review", lowValue);
95
+ }
96
+ }
97
+ if (!dryRun) {
98
+ appendAuditLog(getPhrenPath(), "govern_memories", `projects=${projects.length} stale=${staleCount} conflicts=${conflictCount} review=${reviewCount}`);
99
+ for (const project of projects) {
100
+ consolidateProjectFindings(getPhrenPath(), project);
101
+ }
102
+ }
103
+ if (!silent) {
104
+ const prefix = dryRun ? "[dry-run] Would govern" : "Governed";
105
+ console.log(`${prefix} memories: stale=${staleCount}, conflicts=${conflictCount}, review=${reviewCount}`);
106
+ }
107
+ return {
108
+ projects: projects.length,
109
+ staleCount,
110
+ conflictCount,
111
+ reviewCount,
112
+ };
113
+ }
114
+ export async function handlePruneMemories(args = []) {
115
+ const usage = "phren prune-memories [project] [--dry-run]";
116
+ const { projectArg, dryRun } = parseProjectDryRunArgs(args, "prune-memories", usage);
117
+ const projects = targetProjects(projectArg);
118
+ const beforeBackups = dryRun ? new Map() : captureFindingBackups(projects);
119
+ const result = pruneDeadMemories(getPhrenPath(), projectArg, dryRun);
120
+ if (!result.ok) {
121
+ console.log(result.error);
122
+ return;
123
+ }
124
+ console.log(result.data);
125
+ // TTL enforcement: move entries older than ttlDays that haven't been retrieved recently
126
+ const policy = getRetentionPolicy(getPhrenPath());
127
+ const ttlDays = policy.ttlDays;
128
+ const retrievalGraceDays = Math.floor(ttlDays / 2);
129
+ const now = Date.now();
130
+ // Load retrieval log once for all projects
131
+ const retrievalLogPath = path.join(getPhrenPath(), ".runtime", "retrieval-log.jsonl");
132
+ let retrievalEntries = [];
133
+ if (fs.existsSync(retrievalLogPath)) {
134
+ try {
135
+ retrievalEntries = fs.readFileSync(retrievalLogPath, "utf8")
136
+ .split("\n")
137
+ .filter(Boolean)
138
+ .map(line => { try {
139
+ return JSON.parse(line);
140
+ }
141
+ catch {
142
+ return null;
143
+ } }) // null filtered below
144
+ .filter((e) => e !== null);
145
+ }
146
+ catch (err) {
147
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
148
+ process.stderr.write(`[phren] cli-govern retrievalLog readParse: ${errorMessage(err)}\n`);
149
+ }
150
+ }
151
+ // Build map of last retrieval date by file+bullet key
152
+ const lastRetrievalByKey = new Map();
153
+ for (const entry of retrievalEntries) {
154
+ const key = `${entry.file}:${entry.section}`;
155
+ const ts = Date.parse(entry.retrievedAt);
156
+ if (!Number.isNaN(ts)) {
157
+ const existing = lastRetrievalByKey.get(key) || 0;
158
+ if (ts > existing)
159
+ lastRetrievalByKey.set(key, ts);
160
+ }
161
+ }
162
+ let ttlExpired = 0;
163
+ for (const project of projects) {
164
+ const learningsPath = path.join(getPhrenPath(), project, "FINDINGS.md");
165
+ if (!fs.existsSync(learningsPath))
166
+ continue;
167
+ const content = fs.readFileSync(learningsPath, "utf8");
168
+ const lines = content.split("\n");
169
+ const expiredEntries = [];
170
+ for (let i = 0; i < lines.length; i++) {
171
+ const line = lines[i];
172
+ // Look for entries with <!-- created: YYYY-MM-DD --> timestamps
173
+ if (!line.startsWith("- "))
174
+ continue;
175
+ const createdMatch = line.match(/<!--\s*created:\s*(\d{4}-\d{2}-\d{2})\s*-->/);
176
+ if (!createdMatch)
177
+ continue; // No timestamp, skip defensively
178
+ const createdDate = createdMatch[1];
179
+ const createdMs = Date.parse(`${createdDate}T00:00:00Z`);
180
+ if (Number.isNaN(createdMs))
181
+ continue;
182
+ const ageDays = Math.floor((now - createdMs) / 86400000);
183
+ if (ageDays <= ttlDays)
184
+ continue;
185
+ // Check if retrieved within the grace period.
186
+ // Retrieval is logged at document level (project/FINDINGS.md + doc.type), so look up
187
+ // by the document-level key to match the format written by cli-hooks-output recordRetrieval.
188
+ const retrievalKey = `${project}/FINDINGS.md:findings`;
189
+ const lastRetrieval = lastRetrievalByKey.get(retrievalKey) || 0;
190
+ const daysSinceRetrieval = lastRetrieval ? Math.floor((now - lastRetrieval) / 86400000) : Infinity;
191
+ if (daysSinceRetrieval <= retrievalGraceDays)
192
+ continue;
193
+ expiredEntries.push(`[ttl-expired: ${createdDate}] ${line.slice(2).trim()}`);
194
+ ttlExpired++;
195
+ }
196
+ if (expiredEntries.length > 0 && !dryRun) {
197
+ appendReviewQueue(getPhrenPath(), project, "Stale", expiredEntries);
198
+ }
199
+ if (expiredEntries.length > 0 && dryRun) {
200
+ for (const entry of expiredEntries) {
201
+ console.log(`[dry-run] [${project}] Would move to MEMORY_QUEUE: ${entry.slice(0, 120)}`);
202
+ }
203
+ }
204
+ }
205
+ if (ttlExpired > 0) {
206
+ const verb = dryRun ? "Would move" : "Moved";
207
+ console.log(`${verb} ${ttlExpired} TTL-expired entr${ttlExpired === 1 ? "y" : "ies"} to MEMORY_QUEUE.md`);
208
+ }
209
+ if (dryRun)
210
+ return;
211
+ const backups = summarizeBackupChanges(beforeBackups, projects);
212
+ if (!backups.length)
213
+ return;
214
+ console.log(`Updated backups (${backups.length}): ${backups.join(", ")}`);
215
+ }
216
+ export async function handleConsolidateMemories(args = []) {
217
+ const usage = "phren consolidate-memories [project] [--dry-run]";
218
+ const { projectArg, dryRun } = parseProjectDryRunArgs(args, "consolidate-memories", usage);
219
+ const projects = targetProjects(projectArg);
220
+ const beforeBackups = dryRun ? new Map() : captureFindingBackups(projects);
221
+ const results = projects.map((p) => consolidateProjectFindings(getPhrenPath(), p, dryRun));
222
+ console.log(results.map((r) => r.ok ? r.data : r.error).join("\n"));
223
+ if (dryRun)
224
+ return;
225
+ const backups = summarizeBackupChanges(beforeBackups, projects);
226
+ if (!backups.length)
227
+ return;
228
+ console.log(`Updated backups (${backups.length}): ${backups.join(", ")}`);
229
+ }
230
+ // ── Maintain router ──────────────────────────────────────────────────────────
231
+ export async function handleMaintain(args) {
232
+ const sub = args[0];
233
+ const rest = args.slice(1);
234
+ switch (sub) {
235
+ case "govern": {
236
+ const governDryRun = rest.includes("--dry-run");
237
+ const governProject = rest.find((a) => !a.startsWith("-"));
238
+ return handleGovernMemories(governProject, false, governDryRun);
239
+ }
240
+ case "prune":
241
+ return handlePruneMemories(rest);
242
+ case "consolidate":
243
+ return handleConsolidateMemories(rest);
244
+ case "extract":
245
+ return handleExtractMemories(rest[0]);
246
+ case "restore":
247
+ return handleRestoreBackup(rest);
248
+ default:
249
+ console.log(`phren maintain - memory maintenance and governance
250
+
251
+ Subcommands:
252
+ phren maintain govern [project] [--dry-run]
253
+ Queue stale/conflicting/low-value memories for review.
254
+ Run when search results feel noisy or after a long break.
255
+ phren maintain prune [project] [--dry-run]
256
+ Delete expired entries by retention policy
257
+ phren maintain consolidate [project] [--dry-run]
258
+ Deduplicate FINDINGS.md bullets. Run after a burst of work
259
+ when findings feel repetitive, or monthly to keep things clean.
260
+ phren maintain extract [project] Mine git/GitHub signals into memory candidates
261
+ phren maintain restore [project] List and restore from .bak files`);
262
+ if (sub) {
263
+ console.error(`\nUnknown maintain subcommand: "${sub}"`);
264
+ process.exit(1);
265
+ }
266
+ }
267
+ }
268
+ // ── Restore from backup ──────────────────────────────────────────────────────
269
+ function findBackups(projects) {
270
+ const results = [];
271
+ const now = Date.now();
272
+ for (const project of projects) {
273
+ const dir = path.join(getPhrenPath(), project);
274
+ if (!fs.existsSync(dir))
275
+ continue;
276
+ for (const f of fs.readdirSync(dir)) {
277
+ if (!f.endsWith(".bak"))
278
+ continue;
279
+ const fullPath = path.join(dir, f);
280
+ const stat = fs.statSync(fullPath);
281
+ const ageMs = now - stat.mtimeMs;
282
+ const ageHours = Math.floor(ageMs / 3600000);
283
+ const age = ageHours < 24 ? `${ageHours}h ago` : `${Math.floor(ageHours / 24)}d ago`;
284
+ results.push({ project, file: f, fullPath, age });
285
+ }
286
+ }
287
+ return results.sort((a, b) => a.project.localeCompare(b.project) || a.file.localeCompare(b.file));
288
+ }
289
+ async function handleRestoreBackup(args) {
290
+ const projectArg = args.find((a) => !a.startsWith("-"));
291
+ const projects = targetProjects(projectArg);
292
+ const backups = findBackups(projects);
293
+ if (!backups.length) {
294
+ console.log("No backup files found.");
295
+ return;
296
+ }
297
+ if (args.includes("--list") || !args.includes("--apply")) {
298
+ console.log("Available backups:");
299
+ for (const b of backups) {
300
+ console.log(` ${b.project}/${b.file} (${b.age})`);
301
+ }
302
+ console.log("\nTo restore, run: phren maintain restore <project> --apply");
303
+ return;
304
+ }
305
+ if (!projectArg) {
306
+ console.error("Specify a project to restore: phren maintain restore <project> --apply");
307
+ process.exit(1);
308
+ }
309
+ const projectBackups = backups.filter((b) => b.project === projectArg);
310
+ if (!projectBackups.length) {
311
+ console.log(`No backup files found for "${projectArg}".`);
312
+ return;
313
+ }
314
+ for (const b of projectBackups) {
315
+ const target = b.fullPath.replace(/\.bak$/, "");
316
+ fs.copyFileSync(b.fullPath, target);
317
+ console.log(`Restored ${b.project}/${b.file.replace(/\.bak$/, "")} from backup`);
318
+ }
319
+ appendAuditLog(getPhrenPath(), "restore_backup", `project=${projectArg} files=${projectBackups.length}`);
320
+ }
321
+ // ── Background maintenance ───────────────────────────────────────────────────
322
+ export async function handleBackgroundMaintenance(projectArg) {
323
+ const markers = qualityMarkers(getPhrenPath());
324
+ const startedAt = new Date().toISOString();
325
+ try {
326
+ const compacted = compactFindingJournals(getPhrenPath(), projectArg);
327
+ const governance = await handleGovernMemories(projectArg, true);
328
+ const pruneResult = pruneDeadMemories(getPhrenPath(), projectArg);
329
+ const pruneMsg = pruneResult.ok ? pruneResult.data : pruneResult.error;
330
+ if (!pruneResult.ok) {
331
+ updateRuntimeHealth(getPhrenPath(), {
332
+ lastGovernance: {
333
+ at: startedAt,
334
+ status: "error",
335
+ detail: `prune failed: ${pruneMsg}`,
336
+ },
337
+ });
338
+ appendAuditLog(getPhrenPath(), "background_maintenance_failed", `error=prune_failed: ${pruneMsg}`);
339
+ return;
340
+ }
341
+ fs.writeFileSync(markers.done, new Date().toISOString() + "\n");
342
+ updateRuntimeHealth(getPhrenPath(), {
343
+ lastGovernance: {
344
+ at: startedAt,
345
+ status: "ok",
346
+ detail: `journal_added=${compacted.added} journal_skipped=${compacted.skipped} journal_failed=${compacted.failed}; projects=${governance.projects} stale=${governance.staleCount} conflicts=${governance.conflictCount} review=${governance.reviewCount}; ${pruneMsg}`,
347
+ },
348
+ });
349
+ appendAuditLog(getPhrenPath(), "background_maintenance", `status=ok journal_added=${compacted.added} journal_skipped=${compacted.skipped} journal_failed=${compacted.failed} projects=${governance.projects} stale=${governance.staleCount} conflicts=${governance.conflictCount} review=${governance.reviewCount}`);
350
+ }
351
+ catch (err) {
352
+ const errMsg = errorMessage(err);
353
+ updateRuntimeHealth(getPhrenPath(), {
354
+ lastGovernance: {
355
+ at: startedAt,
356
+ status: "error",
357
+ detail: errMsg,
358
+ },
359
+ });
360
+ appendAuditLog(getPhrenPath(), "background_maintenance_failed", `error=${errMsg}`);
361
+ }
362
+ finally {
363
+ try {
364
+ fs.unlinkSync(markers.lock);
365
+ }
366
+ catch (err) {
367
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
368
+ process.stderr.write(`[phren] cli-govern backgroundMaintenance unlockFinal: ${errorMessage(err)}\n`);
369
+ }
370
+ }
371
+ }