@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,1305 @@
1
+ /**
2
+ * Governance files, root file migration, verification, starter templates, bootstrap.
3
+ */
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as os from "os";
7
+ import * as yaml from "js-yaml";
8
+ import { atomicWriteText, debugLog, findProjectNameCaseInsensitive, hookConfigPath, EXEC_TIMEOUT_QUICK_MS, readRootManifest, sessionsDir, runtimeHealthFile, isRecord, } from "./shared.js";
9
+ import { addProjectToProfile, listProfiles, resolveActiveProfile, setMachineProfile } from "./profile-store.js";
10
+ import { getMachineName } from "./machine-identity.js";
11
+ import { execFileSync } from "child_process";
12
+ import { GOVERNANCE_SCHEMA_VERSION, } from "./shared-governance.js";
13
+ import { STOP_WORDS, errorMessage } from "./utils.js";
14
+ import { ROOT, STARTER_DIR, VERSION, resolveEntryScript } from "./init-shared.js";
15
+ import { readInstallPreferences } from "./init-preferences.js";
16
+ import { TASKS_FILENAME } from "./data-tasks.js";
17
+ import { getProjectOwnershipDefault, parseProjectOwnershipMode, readProjectConfig, writeProjectConfig, } from "./project-config.js";
18
+ import { getBuiltinTopicConfig, normalizeBuiltinTopicDomain } from "./project-topics.js";
19
+ import { writeSkillMd } from "./link-skills.js";
20
+ import { syncScopeSkillsToDir } from "./skill-files.js";
21
+ const LEGACY_SAMPLE_PROJECTS = new Set(["my-api", "my-frontend"]);
22
+ function normalizeProjects(raw) {
23
+ if (!Array.isArray(raw))
24
+ return [];
25
+ return raw.map((entry) => String(entry));
26
+ }
27
+ function profileLooksRealProject(project) {
28
+ return project === "global" || !LEGACY_SAMPLE_PROJECTS.has(project);
29
+ }
30
+ function pruneLegacySampleProjectsFromProfiles(phrenPath) {
31
+ const profilesDir = path.join(phrenPath, "profiles");
32
+ if (!fs.existsSync(profilesDir))
33
+ return { filesUpdated: 0, removed: 0 };
34
+ let filesUpdated = 0;
35
+ let removed = 0;
36
+ for (const file of fs.readdirSync(profilesDir)) {
37
+ if (!file.endsWith(".yaml"))
38
+ continue;
39
+ const fullPath = path.join(profilesDir, file);
40
+ try {
41
+ const parsed = yaml.load(fs.readFileSync(fullPath, "utf8"), { schema: yaml.CORE_SCHEMA });
42
+ if (!isRecord(parsed))
43
+ continue;
44
+ const originalProjects = normalizeProjects(parsed.projects);
45
+ const nextProjects = originalProjects.filter(profileLooksRealProject);
46
+ if (nextProjects.length === originalProjects.length)
47
+ continue;
48
+ removed += originalProjects.length - nextProjects.length;
49
+ const nextData = { ...parsed, projects: nextProjects };
50
+ atomicWriteText(fullPath, yaml.dump(nextData, { lineWidth: 1000 }));
51
+ filesUpdated++;
52
+ }
53
+ catch (err) {
54
+ debugLog(`pruneLegacySampleProjectsFromProfiles failed for ${fullPath}: ${errorMessage(err)}`);
55
+ }
56
+ }
57
+ return { filesUpdated, removed };
58
+ }
59
+ function claudeProjectKeyForHome(home) {
60
+ return home.replace(/[/\\:]/g, "-").replace(/^-/, "");
61
+ }
62
+ export function resolvePreferredHomeDir(phrenPath) {
63
+ const scoreAgentFootprint = (candidate) => {
64
+ let score = 0;
65
+ if (fs.existsSync(path.join(candidate, ".claude")))
66
+ score += 1;
67
+ if (fs.existsSync(path.join(candidate, ".claude", "settings.json")))
68
+ score += 2;
69
+ if (fs.existsSync(path.join(candidate, ".claude", "projects")))
70
+ score += 4;
71
+ if (fs.existsSync(path.join(candidate, ".phren-context.md")))
72
+ score += 3;
73
+ return score;
74
+ };
75
+ const resolvedHome = process.env.HOME?.trim() ? path.resolve(process.env.HOME) : undefined;
76
+ const resolvedUserProfile = process.env.USERPROFILE?.trim() ? path.resolve(process.env.USERPROFILE) : undefined;
77
+ // In devcontainers and WSL, HOME can be an ephemeral shim while USERPROFILE is
78
+ // the stable agent home. Prefer USERPROFILE when both differ and USERPROFILE
79
+ // already has an agent footprint.
80
+ if (resolvedHome && resolvedUserProfile && resolvedHome !== resolvedUserProfile) {
81
+ if (scoreAgentFootprint(resolvedUserProfile) > 0)
82
+ return resolvedUserProfile;
83
+ }
84
+ const candidates = [
85
+ resolvedHome,
86
+ resolvedUserProfile,
87
+ path.resolve(os.homedir()),
88
+ path.resolve(path.dirname(phrenPath)),
89
+ ].filter((entry) => Boolean(entry && entry.trim()));
90
+ const unique = [...new Set(candidates)];
91
+ let bestPath = null;
92
+ let bestScore = 0;
93
+ for (const candidate of unique) {
94
+ const score = scoreAgentFootprint(candidate);
95
+ if (score > bestScore) {
96
+ bestScore = score;
97
+ bestPath = candidate;
98
+ }
99
+ }
100
+ if (bestPath)
101
+ return bestPath;
102
+ return unique[0] ?? os.homedir();
103
+ }
104
+ function ensureGeneratedContextFile(home) {
105
+ const contextFile = path.join(home, ".phren-context.md");
106
+ if (fs.existsSync(contextFile))
107
+ return false;
108
+ atomicWriteText(contextFile, [
109
+ "<!-- phren-managed -->",
110
+ "# phren context",
111
+ "Machine/profile context will be refreshed on the next link/init pass.",
112
+ "<!-- phren-managed -->",
113
+ "",
114
+ ].join("\n"));
115
+ return true;
116
+ }
117
+ function ensureGeneratedRootMemory(home) {
118
+ const memoryFile = path.join(home, ".claude", "projects", claudeProjectKeyForHome(home), "memory", "MEMORY.md");
119
+ if (fs.existsSync(memoryFile))
120
+ return false;
121
+ atomicWriteText(memoryFile, [
122
+ "# Root Memory",
123
+ "",
124
+ "## Machine Context",
125
+ "Read `~/.phren-context.md` for profile, active projects, and sync metadata.",
126
+ "",
127
+ "<!-- phren:projects:start -->",
128
+ "<!-- Auto-generated by phren init/doctor repair. -->",
129
+ "",
130
+ "## Active Projects",
131
+ "",
132
+ "| Project | What | Memory |",
133
+ "|---------|------|--------|",
134
+ "",
135
+ "<!-- phren:projects:end -->",
136
+ "",
137
+ ].join("\n"));
138
+ return true;
139
+ }
140
+ function ensureGlobalStarterAssets(phrenPath) {
141
+ const created = [];
142
+ const starterGlobal = path.join(STARTER_DIR, "global");
143
+ if (!fs.existsSync(starterGlobal))
144
+ return created;
145
+ const targetGlobalDir = path.join(phrenPath, "global");
146
+ fs.mkdirSync(targetGlobalDir, { recursive: true });
147
+ const starterClaude = path.join(starterGlobal, "CLAUDE.md");
148
+ const targetClaude = path.join(targetGlobalDir, "CLAUDE.md");
149
+ if (fs.existsSync(starterClaude) && !fs.existsSync(targetClaude)) {
150
+ fs.copyFileSync(starterClaude, targetClaude);
151
+ created.push("global/CLAUDE.md");
152
+ }
153
+ const starterSkillsDir = path.join(starterGlobal, "skills");
154
+ const targetSkillsDir = path.join(targetGlobalDir, "skills");
155
+ if (fs.existsSync(starterSkillsDir)) {
156
+ fs.mkdirSync(targetSkillsDir, { recursive: true });
157
+ for (const entry of fs.readdirSync(starterSkillsDir, { withFileTypes: true })) {
158
+ if (!entry.isFile())
159
+ continue;
160
+ const source = path.join(starterSkillsDir, entry.name);
161
+ const target = path.join(targetSkillsDir, entry.name);
162
+ if (fs.existsSync(target))
163
+ continue;
164
+ fs.copyFileSync(source, target);
165
+ created.push(path.join("global", "skills", entry.name));
166
+ }
167
+ }
168
+ return created;
169
+ }
170
+ function ensureRuntimeAssets(phrenPath) {
171
+ const created = [];
172
+ const runtimeDir = path.join(phrenPath, ".runtime");
173
+ if (!fs.existsSync(runtimeDir)) {
174
+ fs.mkdirSync(runtimeDir, { recursive: true });
175
+ created.push(".runtime/");
176
+ }
177
+ const sessions = sessionsDir(phrenPath);
178
+ if (!fs.existsSync(sessions)) {
179
+ fs.mkdirSync(sessions, { recursive: true });
180
+ created.push(".sessions/");
181
+ }
182
+ return created;
183
+ }
184
+ function ensureDefaultFeatureFlags(phrenPath) {
185
+ const created = [];
186
+ const envPath = path.join(phrenPath, ".env");
187
+ const header = "# phren feature flags — generated by init\n";
188
+ let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : header;
189
+ let changed = !fs.existsSync(envPath);
190
+ const lines = content.split("\n");
191
+ const hasAutoCaptureFlag = lines.some((line) => line.trimStart().startsWith("PHREN_FEATURE_AUTO_CAPTURE="));
192
+ if (!hasAutoCaptureFlag) {
193
+ if (!content.endsWith("\n"))
194
+ content += "\n";
195
+ content += "PHREN_FEATURE_AUTO_CAPTURE=1\n";
196
+ changed = true;
197
+ created.push(".env:PHREN_FEATURE_AUTO_CAPTURE=1");
198
+ }
199
+ if (changed) {
200
+ atomicWriteText(envPath, content);
201
+ }
202
+ return created;
203
+ }
204
+ function ensureGeneratedSkillArtifacts(phrenPath, preferredHome) {
205
+ const created = [];
206
+ const homeClaudeDir = path.join(preferredHome, ".claude");
207
+ const globalSkillsDir = path.join(homeClaudeDir, "skills");
208
+ const manifestPath = path.join(homeClaudeDir, "skill-manifest.json");
209
+ const commandsPath = path.join(homeClaudeDir, "skill-commands.json");
210
+ const hadManifest = fs.existsSync(manifestPath);
211
+ const hadCommands = fs.existsSync(commandsPath);
212
+ try {
213
+ syncScopeSkillsToDir(phrenPath, "global", globalSkillsDir);
214
+ if (!hadManifest && fs.existsSync(manifestPath))
215
+ created.push("~/.claude/skill-manifest.json");
216
+ if (!hadCommands && fs.existsSync(commandsPath))
217
+ created.push("~/.claude/skill-commands.json");
218
+ }
219
+ catch (err) {
220
+ debugLog(`ensureGeneratedSkillArtifacts: global skill mirror sync failed: ${errorMessage(err)}`);
221
+ }
222
+ const skillMdPath = path.join(phrenPath, "phren.SKILL.md");
223
+ if (!fs.existsSync(skillMdPath)) {
224
+ try {
225
+ writeSkillMd(phrenPath);
226
+ if (fs.existsSync(skillMdPath))
227
+ created.push("phren.SKILL.md");
228
+ }
229
+ catch (err) {
230
+ debugLog(`ensureGeneratedSkillArtifacts: writeSkillMd failed: ${errorMessage(err)}`);
231
+ }
232
+ }
233
+ return created;
234
+ }
235
+ export function ensureGitignoreEntry(repoRoot, entry) {
236
+ const gitignorePath = path.join(repoRoot, ".gitignore");
237
+ const normalizedEntry = entry.trim();
238
+ if (!normalizedEntry)
239
+ return false;
240
+ let content = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
241
+ const lines = content.split("\n").map((line) => line.trim());
242
+ if (lines.includes(normalizedEntry))
243
+ return false;
244
+ if (content && !content.endsWith("\n"))
245
+ content += "\n";
246
+ content += `${normalizedEntry}\n`;
247
+ atomicWriteText(gitignorePath, content);
248
+ return true;
249
+ }
250
+ export function upsertProjectEnvVar(repoRoot, key, value) {
251
+ const envPath = path.join(repoRoot, ".env");
252
+ const normalizedKey = key.trim();
253
+ if (!normalizedKey)
254
+ return false;
255
+ const nextLine = `${normalizedKey}=${value}`;
256
+ let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
257
+ const lineRe = new RegExp(`^\\s*${normalizedKey}=.*$`, "m");
258
+ if (lineRe.test(content)) {
259
+ const updated = content.replace(lineRe, nextLine);
260
+ if (updated === content)
261
+ return false;
262
+ atomicWriteText(envPath, updated);
263
+ return true;
264
+ }
265
+ if (content && !content.endsWith("\n"))
266
+ content += "\n";
267
+ content += `${nextLine}\n`;
268
+ atomicWriteText(envPath, content);
269
+ return true;
270
+ }
271
+ export function repairPreexistingInstall(phrenPath) {
272
+ const createdGovernanceAssets = ensureGovernanceFiles(phrenPath);
273
+ const createdGlobalAssets = ensureGlobalStarterAssets(phrenPath);
274
+ const createdRuntimeAssets = [...createdGovernanceAssets, ...ensureRuntimeAssets(phrenPath)];
275
+ const createdFeatureDefaults = ensureDefaultFeatureFlags(phrenPath);
276
+ const profileRepair = pruneLegacySampleProjectsFromProfiles(phrenPath);
277
+ const preferredHome = resolvePreferredHomeDir(phrenPath);
278
+ const createdSkillArtifacts = ensureGeneratedSkillArtifacts(phrenPath, preferredHome);
279
+ return {
280
+ profileFilesUpdated: profileRepair.filesUpdated,
281
+ removedLegacyProjects: profileRepair.removed,
282
+ createdContextFile: ensureGeneratedContextFile(preferredHome),
283
+ createdRootMemory: ensureGeneratedRootMemory(preferredHome),
284
+ createdGlobalAssets,
285
+ createdRuntimeAssets,
286
+ createdFeatureDefaults,
287
+ createdSkillArtifacts,
288
+ };
289
+ }
290
+ function isExpectedVerifyFailure(phrenPath, check) {
291
+ if (check.ok)
292
+ return false;
293
+ if (check.name === "git-remote")
294
+ return true;
295
+ const prefs = readInstallPreferences(phrenPath);
296
+ if (check.name === "mcp-config" && prefs.mcpEnabled === false)
297
+ return true;
298
+ if (check.name === "hooks-registered" && prefs.hooksEnabled === false)
299
+ return true;
300
+ return false;
301
+ }
302
+ export function getVerifyOutcomeNote(phrenPath, checks) {
303
+ const failures = checks.filter((check) => !check.ok);
304
+ if (failures.length === 0)
305
+ return null;
306
+ const expectedFailures = failures.filter((check) => isExpectedVerifyFailure(phrenPath, check));
307
+ if (expectedFailures.length === 0)
308
+ return null;
309
+ if (expectedFailures.length === failures.length) {
310
+ return "Setup looks usable in local-only / hooks-only mode; remaining issues are optional sync or MCP checks.";
311
+ }
312
+ return "Some reported issues are optional for your chosen install mode; review git-remote / MCP failures separately from hard failures.";
313
+ }
314
+ function commandVersion(cmd, args = ["--version"]) {
315
+ const effectiveCmd = process.platform === "win32" && (cmd === "npm" || cmd === "npx") ? `${cmd}.cmd` : cmd;
316
+ try {
317
+ return execFileSync(effectiveCmd, args, {
318
+ encoding: "utf8",
319
+ stdio: ["ignore", "pipe", "ignore"],
320
+ shell: process.platform === "win32" && effectiveCmd.endsWith(".cmd"),
321
+ timeout: EXEC_TIMEOUT_QUICK_MS,
322
+ }).trim();
323
+ }
324
+ catch (err) {
325
+ debugLog(`commandVersion ${effectiveCmd} failed: ${errorMessage(err)}`);
326
+ return null;
327
+ }
328
+ }
329
+ export function getHookEntrypointCheck(deps = {}) {
330
+ const pathExists = deps.pathExists ?? fs.existsSync;
331
+ const versionReader = deps.versionReader ?? commandVersion;
332
+ const distIndex = resolveEntryScript();
333
+ const localEntrypointOk = pathExists(distIndex);
334
+ const hookEntrypointOk = localEntrypointOk || Boolean(versionReader("npx", ["--version"]));
335
+ const detail = localEntrypointOk
336
+ ? "Hook entrypoint available via local dist/index.js"
337
+ : hookEntrypointOk
338
+ ? "Hook entrypoint available via npx fallback"
339
+ : "Hook entrypoint missing and npx unavailable, hooks will fail";
340
+ return {
341
+ name: "hook-entrypoint",
342
+ ok: hookEntrypointOk,
343
+ detail,
344
+ fix: hookEntrypointOk ? undefined : "Rebuild phren: `npm run build` or reinstall the package, and ensure npm/npx is available for hook fallbacks",
345
+ };
346
+ }
347
+ function parseSemverTriple(raw) {
348
+ const match = raw.match(/(\d+)\.(\d+)\.(\d+)/);
349
+ if (!match)
350
+ return null;
351
+ return [Number.parseInt(match[1], 10), Number.parseInt(match[2], 10), Number.parseInt(match[3], 10)];
352
+ }
353
+ function versionAtLeast(raw, major, minor = 0) {
354
+ if (!raw)
355
+ return false;
356
+ const parsed = parseSemverTriple(raw);
357
+ if (!parsed)
358
+ return false;
359
+ const [m, n] = parsed;
360
+ if (m !== major)
361
+ return m > major;
362
+ return n >= minor;
363
+ }
364
+ function nearestWritableTarget(filePath) {
365
+ let probe = fs.existsSync(filePath) ? filePath : path.dirname(filePath);
366
+ while (!fs.existsSync(probe)) {
367
+ const parent = path.dirname(probe);
368
+ if (parent === probe)
369
+ return false;
370
+ probe = parent;
371
+ }
372
+ try {
373
+ fs.accessSync(probe, fs.constants.W_OK);
374
+ return true;
375
+ }
376
+ catch (err) {
377
+ debugLog(`nearestWritableTarget failed for ${filePath}: ${errorMessage(err)}`);
378
+ return false;
379
+ }
380
+ }
381
+ function gitRemoteStatus(phrenPath) {
382
+ try {
383
+ execFileSync("git", ["-C", phrenPath, "rev-parse", "--is-inside-work-tree"], {
384
+ stdio: ["ignore", "ignore", "ignore"],
385
+ timeout: EXEC_TIMEOUT_QUICK_MS,
386
+ });
387
+ }
388
+ catch {
389
+ return { ok: false, detail: "phren path is not a git repository" };
390
+ }
391
+ try {
392
+ const remote = execFileSync("git", ["-C", phrenPath, "remote", "get-url", "origin"], {
393
+ encoding: "utf8",
394
+ stdio: ["ignore", "pipe", "ignore"],
395
+ timeout: EXEC_TIMEOUT_QUICK_MS,
396
+ }).trim();
397
+ return remote ? { ok: true, detail: `origin=${remote}` } : { ok: false, detail: "git origin remote not configured" };
398
+ }
399
+ catch {
400
+ return { ok: false, detail: "git origin remote not configured" };
401
+ }
402
+ }
403
+ function copyStarterFile(phrenPath, src, dest) {
404
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
405
+ if (!fs.existsSync(dest)) {
406
+ fs.copyFileSync(src, dest);
407
+ return dest;
408
+ }
409
+ const existing = fs.readFileSync(dest);
410
+ const incoming = fs.readFileSync(src);
411
+ if (existing.equals(incoming)) {
412
+ return null;
413
+ }
414
+ const relative = path.relative(phrenPath, dest);
415
+ const stagingDir = path.join(phrenPath, ".runtime", "starter-updates", path.dirname(relative));
416
+ fs.mkdirSync(stagingDir, { recursive: true });
417
+ const currentPath = path.join(stagingDir, `${path.basename(dest)}.current`);
418
+ const stagedPath = path.join(stagingDir, `${path.basename(dest)}.new`);
419
+ fs.copyFileSync(dest, currentPath);
420
+ fs.copyFileSync(src, stagedPath);
421
+ return stagedPath;
422
+ }
423
+ export function applyStarterTemplateUpdates(phrenPath) {
424
+ const updates = [];
425
+ const starterGlobal = path.join(STARTER_DIR, "global");
426
+ if (!fs.existsSync(starterGlobal))
427
+ return updates;
428
+ const starterClaude = path.join(starterGlobal, "CLAUDE.md");
429
+ const targetClaude = path.join(phrenPath, "global", "CLAUDE.md");
430
+ if (fs.existsSync(starterClaude)) {
431
+ const written = copyStarterFile(phrenPath, starterClaude, targetClaude);
432
+ if (written)
433
+ updates.push(path.relative(phrenPath, written));
434
+ }
435
+ const starterSkillsDir = path.join(starterGlobal, "skills");
436
+ const targetSkillsDir = path.join(phrenPath, "global", "skills");
437
+ if (fs.existsSync(starterSkillsDir)) {
438
+ fs.mkdirSync(targetSkillsDir, { recursive: true });
439
+ for (const f of fs.readdirSync(starterSkillsDir, { withFileTypes: true })) {
440
+ if (!f.isFile())
441
+ continue;
442
+ const written = copyStarterFile(phrenPath, path.join(starterSkillsDir, f.name), path.join(targetSkillsDir, f.name));
443
+ if (written)
444
+ updates.push(path.relative(phrenPath, written));
445
+ }
446
+ }
447
+ return updates;
448
+ }
449
+ export function ensureGovernanceFiles(phrenPath) {
450
+ const created = [];
451
+ const govDir = path.join(phrenPath, ".governance");
452
+ if (!fs.existsSync(govDir))
453
+ created.push(".governance/");
454
+ fs.mkdirSync(govDir, { recursive: true });
455
+ const sv = GOVERNANCE_SCHEMA_VERSION;
456
+ const policy = path.join(govDir, "retention-policy.json");
457
+ const workflow = path.join(govDir, "workflow-policy.json");
458
+ const indexPolicy = path.join(govDir, "index-policy.json");
459
+ const runtimeHealth = runtimeHealthFile(phrenPath);
460
+ if (!fs.existsSync(policy)) {
461
+ atomicWriteText(policy, JSON.stringify({
462
+ schemaVersion: sv,
463
+ ttlDays: 120,
464
+ retentionDays: 365,
465
+ autoAcceptThreshold: 0.75,
466
+ minInjectConfidence: 0.35,
467
+ decay: { d30: 1.0, d60: 0.85, d90: 0.65, d120: 0.45 },
468
+ }, null, 2) + "\n");
469
+ created.push(".governance/retention-policy.json");
470
+ }
471
+ if (!fs.existsSync(workflow)) {
472
+ atomicWriteText(workflow, JSON.stringify({
473
+ schemaVersion: sv,
474
+ lowConfidenceThreshold: 0.7,
475
+ riskySections: ["Stale", "Conflicts"],
476
+ taskMode: "auto",
477
+ }, null, 2) + "\n");
478
+ created.push(".governance/workflow-policy.json");
479
+ }
480
+ if (!fs.existsSync(indexPolicy)) {
481
+ atomicWriteText(indexPolicy, JSON.stringify({
482
+ schemaVersion: sv,
483
+ includeGlobs: ["**/*.md", "**/skills/**/*.md", ".claude/skills/**/*.md"],
484
+ excludeGlobs: ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/build/**"],
485
+ includeHidden: false,
486
+ }, null, 2) + "\n");
487
+ created.push(".governance/index-policy.json");
488
+ }
489
+ if (!fs.existsSync(runtimeHealth)) {
490
+ atomicWriteText(runtimeHealth, JSON.stringify({ schemaVersion: sv }, null, 2) + "\n");
491
+ created.push(".runtime/runtime-health.json");
492
+ }
493
+ else {
494
+ try {
495
+ const current = JSON.parse(fs.readFileSync(runtimeHealth, "utf8"));
496
+ if (current && typeof current === "object" && !Array.isArray(current)) {
497
+ const existingSchema = typeof current.schemaVersion === "number" ? current.schemaVersion : 0;
498
+ if (existingSchema < sv) {
499
+ atomicWriteText(runtimeHealth, JSON.stringify({ ...current, schemaVersion: sv }, null, 2) + "\n");
500
+ }
501
+ }
502
+ }
503
+ catch (err) {
504
+ debugLog(`ensureGovernanceFiles: malformed runtime health file, leaving untouched: ${errorMessage(err)}`);
505
+ }
506
+ }
507
+ return created;
508
+ }
509
+ const TEMPLATES_DIR = path.join(ROOT, "starter", "templates");
510
+ export function listTemplates() {
511
+ if (!fs.existsSync(TEMPLATES_DIR))
512
+ return [];
513
+ return fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true })
514
+ .filter(d => d.isDirectory())
515
+ .map(d => d.name)
516
+ .sort();
517
+ }
518
+ export function applyTemplate(projectDir, templateName, projectName) {
519
+ const templateDir = path.join(TEMPLATES_DIR, templateName);
520
+ if (!fs.existsSync(templateDir))
521
+ return false;
522
+ fs.mkdirSync(projectDir, { recursive: true });
523
+ function copyTemplateDir(srcDir, destDir) {
524
+ fs.mkdirSync(destDir, { recursive: true });
525
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
526
+ const src = path.join(srcDir, entry.name);
527
+ const dest = path.join(destDir, entry.name);
528
+ if (entry.isDirectory()) {
529
+ copyTemplateDir(src, dest);
530
+ }
531
+ else {
532
+ let content = fs.readFileSync(src, "utf8");
533
+ content = content.replace(/\{\{project\}\}/g, projectName);
534
+ content = content.replace(/\{\{date\}\}/g, new Date().toISOString().slice(0, 10));
535
+ atomicWriteText(dest, content);
536
+ }
537
+ }
538
+ }
539
+ copyTemplateDir(templateDir, projectDir);
540
+ return true;
541
+ }
542
+ const DOMAIN_KEYWORD_HINTS = {
543
+ software: [
544
+ "api", "backend", "frontend", "typescript", "javascript", "python", "rust", "golang", "cli", "sdk", "library", "service",
545
+ "server", "database", "auth", "module", "package", "build", "test", "deploy",
546
+ ],
547
+ music: [
548
+ "music", "audio", "mix", "master", "track", "daw", "synth", "midi", "song", "composition", "arrangement", "producer",
549
+ ],
550
+ game: [
551
+ "game", "gameplay", "level", "shader", "physics", "npc", "engine", "unity", "godot", "unreal", "sprite", "multiplayer",
552
+ ],
553
+ research: [
554
+ "research", "paper", "study", "experiment", "dataset", "analysis", "methodology", "hypothesis", "results", "evaluation",
555
+ ],
556
+ writing: [
557
+ "writing", "manuscript", "chapter", "outline", "narrative", "character", "plot", "draft", "editorial",
558
+ ],
559
+ creative: [
560
+ "creative", "story", "design", "worldbuilding", "script", "concept", "illustration", "art direction",
561
+ ],
562
+ };
563
+ const DOMAIN_CONFIG_HINTS = {
564
+ "package.json": { software: 3 },
565
+ "tsconfig.json": { software: 3 },
566
+ "Cargo.toml": { software: 4, game: 1 },
567
+ "pyproject.toml": { software: 3, research: 1 },
568
+ "requirements.txt": { software: 2, research: 1 },
569
+ "go.mod": { software: 3 },
570
+ "CMakeLists.txt": { software: 3, game: 1 },
571
+ "pom.xml": { software: 3 },
572
+ "build.gradle": { software: 3 },
573
+ "project.godot": { game: 5 },
574
+ ".uproject": { game: 5 },
575
+ "paper.tex": { research: 4, writing: 1 },
576
+ "references.bib": { research: 4 },
577
+ };
578
+ const EXTENSION_DOMAIN_HINTS = {
579
+ ".ts": { software: 1 },
580
+ ".tsx": { software: 1, game: 1 },
581
+ ".js": { software: 1 },
582
+ ".jsx": { software: 1 },
583
+ ".py": { software: 1, research: 1 },
584
+ ".rs": { software: 1, game: 1 },
585
+ ".go": { software: 1 },
586
+ ".java": { software: 1 },
587
+ ".kt": { software: 1 },
588
+ ".swift": { software: 1 },
589
+ ".c": { software: 1, game: 1 },
590
+ ".cc": { software: 1, game: 1 },
591
+ ".cpp": { software: 1, game: 1 },
592
+ ".h": { software: 1, game: 1 },
593
+ ".hpp": { software: 1, game: 1 },
594
+ ".cs": { software: 1, game: 1 },
595
+ ".ipynb": { research: 2 },
596
+ ".tex": { research: 2, writing: 1 },
597
+ ".bib": { research: 2 },
598
+ ".wav": { music: 2 },
599
+ ".mp3": { music: 2 },
600
+ ".flac": { music: 2 },
601
+ ".mid": { music: 2 },
602
+ ".midi": { music: 2 },
603
+ ".als": { music: 2 },
604
+ ".logicx": { music: 2 },
605
+ ".unity": { game: 2 },
606
+ ".gd": { game: 2 },
607
+ ".glsl": { game: 2 },
608
+ };
609
+ function titleCase(text) {
610
+ return text
611
+ .split(/[\s_-]+/)
612
+ .filter(Boolean)
613
+ .map((token) => token.slice(0, 1).toUpperCase() + token.slice(1))
614
+ .join(" ");
615
+ }
616
+ function addTermCount(terms, rawText, weight = 1) {
617
+ const tokens = rawText
618
+ .toLowerCase()
619
+ .replace(/[^\w\s-]/g, " ")
620
+ .split(/\s+/)
621
+ .map((token) => token.trim())
622
+ .filter((token) => token.length >= 3 && token.length <= 48 && !STOP_WORDS.has(token));
623
+ for (const token of tokens) {
624
+ terms.set(token, (terms.get(token) ?? 0) + weight);
625
+ }
626
+ }
627
+ function maybeReadUtf8(filePath, maxBytes = 256_000) {
628
+ try {
629
+ const stat = fs.statSync(filePath);
630
+ if (!stat.isFile() || stat.size > maxBytes)
631
+ return "";
632
+ return fs.readFileSync(filePath, "utf8");
633
+ }
634
+ catch {
635
+ return "";
636
+ }
637
+ }
638
+ function addDomainScores(target, patch, weight = 1) {
639
+ for (const [domain, score] of Object.entries(patch)) {
640
+ const typedDomain = domain;
641
+ target[typedDomain] += (score ?? 0) * weight;
642
+ }
643
+ }
644
+ function scoreTopicsFromTerms(domain, terms, docsText) {
645
+ const baseTopics = getBuiltinTopicConfig(domain);
646
+ const scored = baseTopics
647
+ .filter((topic) => topic.name.toLowerCase() !== "general")
648
+ .map((topic) => {
649
+ const baseTerm = topic.name.toLowerCase();
650
+ let score = terms.get(baseTerm) ?? 0;
651
+ for (const keyword of topic.keywords) {
652
+ const normalized = keyword.toLowerCase().trim();
653
+ if (!normalized)
654
+ continue;
655
+ score += terms.get(normalized) ?? 0;
656
+ if (normalized.includes(" ") && docsText.includes(normalized))
657
+ score += 1;
658
+ }
659
+ return { topic, score };
660
+ });
661
+ const ranked = scored
662
+ .filter((entry) => entry.score > 0)
663
+ .sort((a, b) => b.score - a.score || a.topic.name.localeCompare(b.topic.name));
664
+ const takenNames = new Set(baseTopics.map((topic) => topic.name.toLowerCase()));
665
+ const customTopics = [];
666
+ for (const [term, count] of [...terms.entries()].sort((a, b) => b[1] - a[1])) {
667
+ if (customTopics.length >= 4)
668
+ break;
669
+ if (count < 3)
670
+ break;
671
+ if (term.includes("_"))
672
+ continue;
673
+ const topicName = titleCase(term);
674
+ const normalizedName = topicName.toLowerCase();
675
+ if (takenNames.has(normalizedName))
676
+ continue;
677
+ if (DOMAIN_KEYWORD_HINTS.software.includes(term) || DOMAIN_KEYWORD_HINTS.music.includes(term) || DOMAIN_KEYWORD_HINTS.game.includes(term)) {
678
+ continue;
679
+ }
680
+ takenNames.add(normalizedName);
681
+ customTopics.push({
682
+ name: topicName,
683
+ description: "Suggested from repeated terminology in project docs.",
684
+ keywords: [term],
685
+ });
686
+ }
687
+ if (ranked.length === 0 && customTopics.length === 0)
688
+ return baseTopics;
689
+ const orderedBase = [
690
+ ...ranked.map((entry) => entry.topic),
691
+ ...baseTopics.filter((topic) => topic.name.toLowerCase() !== "general"
692
+ && !ranked.some((entry) => entry.topic.name === topic.name)),
693
+ ];
694
+ const topics = [...orderedBase.slice(0, 8), ...customTopics];
695
+ if (!topics.some((topic) => topic.name.toLowerCase() === "general")) {
696
+ topics.push({ name: "General", description: "Fallback bucket for uncategorized findings.", keywords: [] });
697
+ }
698
+ return topics;
699
+ }
700
+ export function inferInitScaffoldFromRepo(repoRoot, fallbackDomain = "software") {
701
+ const resolvedRoot = path.resolve(repoRoot);
702
+ if (!fs.existsSync(resolvedRoot))
703
+ return null;
704
+ const signal = {
705
+ domainScores: { software: 0, music: 0, game: 0, research: 0, writing: 0, creative: 0, other: 0 },
706
+ terms: new Map(),
707
+ docsText: "",
708
+ referenceHints: [],
709
+ commandHints: [],
710
+ usefulSignals: 0,
711
+ };
712
+ const skipDirs = new Set([".git", ".phren", "node_modules", "dist", "build", "coverage", ".next", ".turbo", "target"]);
713
+ const packageJsonPath = path.join(resolvedRoot, "package.json");
714
+ if (fs.existsSync(packageJsonPath)) {
715
+ signal.usefulSignals++;
716
+ addDomainScores(signal.domainScores, DOMAIN_CONFIG_HINTS["package.json"]);
717
+ try {
718
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
719
+ if (typeof parsed.description === "string") {
720
+ addTermCount(signal.terms, parsed.description, 3);
721
+ signal.docsText += ` ${parsed.description.toLowerCase()}`;
722
+ }
723
+ if (Array.isArray(parsed.keywords)) {
724
+ for (const keyword of parsed.keywords) {
725
+ if (typeof keyword === "string") {
726
+ addTermCount(signal.terms, keyword, 3);
727
+ signal.docsText += ` ${keyword.toLowerCase()}`;
728
+ }
729
+ }
730
+ }
731
+ if (parsed.scripts && typeof parsed.scripts === "object" && !Array.isArray(parsed.scripts)) {
732
+ const scriptObject = parsed.scripts;
733
+ for (const scriptName of ["dev", "start", "build", "test", "lint"]) {
734
+ if (typeof scriptObject[scriptName] === "string") {
735
+ signal.commandHints.push(`npm run ${scriptName}`);
736
+ }
737
+ }
738
+ }
739
+ }
740
+ catch (err) {
741
+ debugLog(`inferInitScaffoldFromRepo package.json parse failed: ${errorMessage(err)}`);
742
+ }
743
+ }
744
+ const topLevelConfigs = Object.keys(DOMAIN_CONFIG_HINTS)
745
+ .filter((fileName) => fs.existsSync(path.join(resolvedRoot, fileName)));
746
+ for (const configName of topLevelConfigs) {
747
+ signal.usefulSignals++;
748
+ const configScore = DOMAIN_CONFIG_HINTS[configName];
749
+ if (configScore)
750
+ addDomainScores(signal.domainScores, configScore);
751
+ }
752
+ const readmeCandidates = [
753
+ path.join(resolvedRoot, "README.md"),
754
+ path.join(resolvedRoot, "readme.md"),
755
+ ];
756
+ for (const readmePath of readmeCandidates) {
757
+ if (!fs.existsSync(readmePath))
758
+ continue;
759
+ const content = maybeReadUtf8(readmePath);
760
+ if (!content)
761
+ continue;
762
+ signal.usefulSignals++;
763
+ signal.referenceHints.push(path.relative(resolvedRoot, readmePath));
764
+ addTermCount(signal.terms, content, 2);
765
+ signal.docsText += ` ${content.toLowerCase()}`;
766
+ break;
767
+ }
768
+ const docsDir = path.join(resolvedRoot, "docs");
769
+ if (fs.existsSync(docsDir) && fs.statSync(docsDir).isDirectory()) {
770
+ signal.referenceHints.push("docs/");
771
+ for (const entry of fs.readdirSync(docsDir, { withFileTypes: true })) {
772
+ if (!entry.isFile())
773
+ continue;
774
+ if (!/\.(md|txt|rst)$/i.test(entry.name))
775
+ continue;
776
+ const docsContent = maybeReadUtf8(path.join(docsDir, entry.name));
777
+ if (!docsContent)
778
+ continue;
779
+ signal.usefulSignals++;
780
+ addTermCount(signal.terms, docsContent, 1);
781
+ signal.docsText += ` ${docsContent.toLowerCase()}`;
782
+ }
783
+ }
784
+ for (const folderName of ["reference", "specs", "design", "architecture", "src", "packages", "apps"]) {
785
+ const fullPath = path.join(resolvedRoot, folderName);
786
+ if (fs.existsSync(fullPath)) {
787
+ signal.referenceHints.push(`${folderName}/`);
788
+ }
789
+ }
790
+ let scannedFiles = 0;
791
+ const maxFiles = 3000;
792
+ const walk = (dir) => {
793
+ if (scannedFiles >= maxFiles)
794
+ return;
795
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
796
+ if (scannedFiles >= maxFiles)
797
+ return;
798
+ const fullPath = path.join(dir, entry.name);
799
+ if (entry.isDirectory()) {
800
+ if (skipDirs.has(entry.name))
801
+ continue;
802
+ walk(fullPath);
803
+ continue;
804
+ }
805
+ scannedFiles++;
806
+ const ext = path.extname(entry.name).toLowerCase();
807
+ if (!ext)
808
+ continue;
809
+ const extScore = EXTENSION_DOMAIN_HINTS[ext];
810
+ if (extScore) {
811
+ addDomainScores(signal.domainScores, extScore);
812
+ signal.usefulSignals++;
813
+ }
814
+ }
815
+ };
816
+ walk(resolvedRoot);
817
+ for (const [domain, hints] of Object.entries(DOMAIN_KEYWORD_HINTS)) {
818
+ for (const hint of hints) {
819
+ const hitCount = signal.terms.get(hint) ?? 0;
820
+ if (hitCount > 0) {
821
+ signal.domainScores[domain] += Math.min(4, hitCount);
822
+ signal.usefulSignals++;
823
+ }
824
+ }
825
+ }
826
+ const rankedDomains = Object.entries(signal.domainScores)
827
+ .sort((a, b) => b[1] - a[1]);
828
+ const [bestDomain, bestScore] = rankedDomains[0] ?? [fallbackDomain, 0];
829
+ const secondScore = rankedDomains[1]?.[1] ?? 0;
830
+ const inferredDomain = bestScore >= 2 ? bestDomain : fallbackDomain;
831
+ const confidence = bestScore <= 0
832
+ ? 0
833
+ : Math.max(0.15, Math.min(0.98, (bestScore - secondScore + 1) / (bestScore + 2)));
834
+ const topics = scoreTopicsFromTerms(inferredDomain, signal.terms, signal.docsText);
835
+ const references = Array.from(new Set(signal.referenceHints)).slice(0, 8);
836
+ const commands = Array.from(new Set(signal.commandHints)).slice(0, 5);
837
+ const reason = bestScore > 0
838
+ ? `inferred from repo files, config, and docs terminology (score ${bestScore})`
839
+ : "fallback defaults";
840
+ if (signal.usefulSignals === 0)
841
+ return null;
842
+ return {
843
+ domain: inferredDomain,
844
+ topics: topics.length > 0 ? topics : getBuiltinTopicConfig(inferredDomain),
845
+ referenceHints: references,
846
+ commandHints: commands,
847
+ confidence: Number(confidence.toFixed(2)),
848
+ reason,
849
+ };
850
+ }
851
+ function appendInferredSections(base, inference) {
852
+ if (!inference)
853
+ return base;
854
+ const lines = [];
855
+ if (inference.referenceHints.length > 0) {
856
+ lines.push("## Reference Structure");
857
+ for (const hint of inference.referenceHints) {
858
+ lines.push(`- ${hint}`);
859
+ }
860
+ lines.push("");
861
+ }
862
+ if (inference.topics.length > 0) {
863
+ lines.push("## Initial Focus Topics");
864
+ for (const topic of inference.topics.slice(0, 6)) {
865
+ lines.push(`- ${topic.name}: ${topic.description}`);
866
+ }
867
+ lines.push("");
868
+ }
869
+ if (inference.commandHints.length > 0) {
870
+ lines.push("## Commands");
871
+ lines.push("```bash");
872
+ for (const cmd of inference.commandHints)
873
+ lines.push(cmd);
874
+ lines.push("```");
875
+ lines.push("");
876
+ }
877
+ return lines.length > 0 ? `${base.trimEnd()}\n\n${lines.join("\n")}` : base;
878
+ }
879
+ function getDomainClaudeTemplate(projectName, domain, inference) {
880
+ if (domain === "software") {
881
+ return appendInferredSections(`# ${projectName}\n\nOne paragraph about what this project is.\n\n## Commands\n\n\`\`\`bash\n# Install:\n# Run:\n# Test:\n\`\`\`\n`, inference);
882
+ }
883
+ if (domain === "music") {
884
+ return appendInferredSections(`# ${projectName}\n\nThis is a music project. Keep notes on composition intent, arrangement choices, production workflow, and mixing/mastering decisions.\n\n## Session Focus\n\n- Capture creative intent before technical tweaks\n- Track instrument/sound-design decisions and why\n- Log mix/master changes with listening context\n`, inference);
885
+ }
886
+ if (domain === "game") {
887
+ return appendInferredSections(`# ${projectName}\n\nThis is a game project. Prioritize clear notes on mechanics, rendering/performance tradeoffs, level and UI decisions, and iteration outcomes.\n\n## Development Focus\n\n- Record gameplay/mechanics decisions with player impact\n- Track rendering/physics/AI issues with repro context\n- Note level-design and networking constraints early\n`, inference);
888
+ }
889
+ if (domain === "research") {
890
+ return appendInferredSections(`# ${projectName}\n\nThis is a research project. Focus on methodology, source quality, analysis assumptions, and review feedback loops.\n\n## Working Approach\n\n- Document hypotheses and evaluation criteria explicitly\n- Track source provenance and confidence level\n- Record analysis decisions and revision rationale\n`, inference);
891
+ }
892
+ if (domain === "writing" || domain === "creative") {
893
+ return appendInferredSections(`# ${projectName}\n\nThis is a creative writing project. Track worldbuilding rules, character arcs, plot structure, style constraints, and revision decisions.\n\n## Writing Workflow\n\n- Keep narrative intent and tone constraints visible\n- Capture character/plot changes with consequences\n- Log revision notes and unresolved questions\n`, inference);
894
+ }
895
+ return appendInferredSections(`# ${projectName}\n\nThis project is not software-first. Keep practical notes, references, and task decisions so future sessions can resume quickly.\n\n## Workflow\n\n- Capture non-obvious lessons and reusable patterns\n- Keep references curated and current\n- Track active tasks and follow-ups\n`, inference);
896
+ }
897
+ export function ensureProjectScaffold(projectDir, projectName, domain = "software", inference) {
898
+ const normalizedDomain = normalizeBuiltinTopicDomain(inference?.domain ?? domain);
899
+ const inferredTopics = Array.isArray(inference?.topics) && inference.topics.length > 0
900
+ ? inference.topics
901
+ : getBuiltinTopicConfig(normalizedDomain);
902
+ fs.mkdirSync(projectDir, { recursive: true });
903
+ if (!fs.existsSync(path.join(projectDir, "summary.md"))) {
904
+ atomicWriteText(path.join(projectDir, "summary.md"), `# ${projectName}\n\n**What:** Replace this with one sentence about what the project does\n**Stack:** The key tech\n**Status:** active\n**Run:** the command you use most\n**Watch out:** the one thing that will bite you if you forget\n`);
905
+ }
906
+ if (!fs.existsSync(path.join(projectDir, "CLAUDE.md"))) {
907
+ atomicWriteText(path.join(projectDir, "CLAUDE.md"), getDomainClaudeTemplate(projectName, inference?.domain ?? domain, inference));
908
+ }
909
+ if (!fs.existsSync(path.join(projectDir, "topic-config.json"))) {
910
+ atomicWriteText(path.join(projectDir, "topic-config.json"), JSON.stringify({ version: 1, domain: normalizedDomain, topics: inferredTopics }, null, 2) + "\n");
911
+ }
912
+ if (!fs.existsSync(path.join(projectDir, "FINDINGS.md"))) {
913
+ atomicWriteText(path.join(projectDir, "FINDINGS.md"), `# ${projectName} FINDINGS\n\n<!-- Findings are captured automatically during sessions and committed on exit -->\n`);
914
+ }
915
+ if (!fs.existsSync(path.join(projectDir, TASKS_FILENAME))) {
916
+ atomicWriteText(path.join(projectDir, TASKS_FILENAME), `# ${projectName} tasks\n\n## Active\n\n## Queue\n\n## Done\n`);
917
+ }
918
+ }
919
+ export function ensureLocalGitRepo(phrenPath) {
920
+ try {
921
+ execFileSync("git", ["-C", phrenPath, "rev-parse", "--is-inside-work-tree"], {
922
+ stdio: ["ignore", "ignore", "ignore"],
923
+ timeout: EXEC_TIMEOUT_QUICK_MS,
924
+ });
925
+ return { ok: true, initialized: false, detail: "existing git repo" };
926
+ }
927
+ catch {
928
+ // Fall through to initialization below.
929
+ }
930
+ try {
931
+ try {
932
+ execFileSync("git", ["-C", phrenPath, "init", "--initial-branch=main"], {
933
+ stdio: ["ignore", "ignore", "ignore"],
934
+ timeout: EXEC_TIMEOUT_QUICK_MS,
935
+ });
936
+ }
937
+ catch {
938
+ execFileSync("git", ["-C", phrenPath, "init"], {
939
+ stdio: ["ignore", "ignore", "ignore"],
940
+ timeout: EXEC_TIMEOUT_QUICK_MS,
941
+ });
942
+ try {
943
+ execFileSync("git", ["-C", phrenPath, "branch", "-M", "main"], {
944
+ stdio: ["ignore", "ignore", "ignore"],
945
+ timeout: EXEC_TIMEOUT_QUICK_MS,
946
+ });
947
+ }
948
+ catch {
949
+ // Older git versions may not support renaming immediately here.
950
+ }
951
+ }
952
+ return { ok: true, initialized: true, detail: "initialized local git repo" };
953
+ }
954
+ catch (err) {
955
+ return { ok: false, initialized: false, detail: `git init failed: ${errorMessage(err)}` };
956
+ }
957
+ }
958
+ /** Bootstrap a phren project from an existing project directory with CLAUDE.md.
959
+ * @param profile - if provided, only this profile YAML is updated (avoids leaking project to unrelated profiles).
960
+ */
961
+ export function bootstrapFromExisting(phrenPath, projectPath, opts = {}) {
962
+ const profile = typeof opts === "string" ? opts : opts.profile;
963
+ const resolvedPath = path.resolve(projectPath);
964
+ if (!fs.existsSync(resolvedPath)) {
965
+ throw new Error(`Path does not exist: ${resolvedPath}`);
966
+ }
967
+ const manifest = readRootManifest(phrenPath);
968
+ const isProjectLocal = manifest?.installMode === "project-local";
969
+ const sourceRoot = isProjectLocal ? path.resolve(manifest.workspaceRoot || resolvedPath) : resolvedPath;
970
+ if (isProjectLocal) {
971
+ const matchesWorkspace = resolvedPath === sourceRoot || resolvedPath.startsWith(sourceRoot + path.sep);
972
+ if (!matchesWorkspace) {
973
+ throw new Error(`Project-local phren can only enroll the owning workspace: ${sourceRoot}`);
974
+ }
975
+ }
976
+ let claudeMdPath = null;
977
+ const candidates = [
978
+ path.join(sourceRoot, "CLAUDE.md"),
979
+ path.join(sourceRoot, ".claude", "CLAUDE.md"),
980
+ ];
981
+ for (const c of candidates) {
982
+ if (fs.existsSync(c)) {
983
+ claudeMdPath = c;
984
+ break;
985
+ }
986
+ }
987
+ const claudeContent = claudeMdPath ? fs.readFileSync(claudeMdPath, "utf8") : null;
988
+ const projectName = isProjectLocal
989
+ ? String(manifest?.primaryProject)
990
+ : path.basename(sourceRoot).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
991
+ const existingProject = findProjectNameCaseInsensitive(phrenPath, projectName);
992
+ if (existingProject && existingProject !== projectName) {
993
+ throw new Error(`Project "${existingProject}" already exists with different casing. Refusing to bootstrap "${projectName}" because it would split the same project on case-sensitive filesystems.`);
994
+ }
995
+ const projDir = path.join(phrenPath, projectName);
996
+ fs.mkdirSync(projDir, { recursive: true });
997
+ const inferredScaffold = inferInitScaffoldFromRepo(sourceRoot);
998
+ const existingConfig = readProjectConfig(phrenPath, projectName);
999
+ const ownership = typeof opts === "string"
1000
+ ? (parseProjectOwnershipMode(existingConfig.ownership) ?? getProjectOwnershipDefault(phrenPath))
1001
+ : (opts.ownership ?? parseProjectOwnershipMode(existingConfig.ownership) ?? getProjectOwnershipDefault(phrenPath));
1002
+ const claudePath = path.join(projDir, "CLAUDE.md");
1003
+ if (ownership !== "repo-managed") {
1004
+ if (claudeContent) {
1005
+ if (!fs.existsSync(claudePath)) {
1006
+ atomicWriteText(claudePath, claudeContent);
1007
+ }
1008
+ }
1009
+ else {
1010
+ // No CLAUDE.md found — create a starter one
1011
+ if (!fs.existsSync(claudePath)) {
1012
+ atomicWriteText(claudePath, getDomainClaudeTemplate(projectName, inferredScaffold?.domain ?? "software", inferredScaffold));
1013
+ }
1014
+ }
1015
+ }
1016
+ const summaryLines = [];
1017
+ if (claudeContent) {
1018
+ const lines = claudeContent.split("\n");
1019
+ let foundHeading = false;
1020
+ for (const line of lines) {
1021
+ if (line.startsWith("# ") && !foundHeading) {
1022
+ foundHeading = true;
1023
+ summaryLines.push(line);
1024
+ continue;
1025
+ }
1026
+ if (foundHeading && line.trim() === "") {
1027
+ if (summaryLines.length > 1)
1028
+ break;
1029
+ continue;
1030
+ }
1031
+ if (foundHeading && summaryLines.length < 10) {
1032
+ summaryLines.push(line);
1033
+ }
1034
+ }
1035
+ }
1036
+ const sourceInfo = claudeMdPath ? `**Source CLAUDE.md:** ${claudeMdPath}` : `**Source:** ${sourceRoot}`;
1037
+ const summaryPath = path.join(projDir, "summary.md");
1038
+ if (!fs.existsSync(summaryPath)) {
1039
+ atomicWriteText(summaryPath, `# ${projectName}\n\n**What:** Bootstrapped from ${sourceRoot}\n${sourceInfo}\n\n${summaryLines.length > 1 ? summaryLines.slice(1).join("\n") : ""}\n`);
1040
+ }
1041
+ if (!fs.existsSync(path.join(projDir, "FINDINGS.md"))) {
1042
+ atomicWriteText(path.join(projDir, "FINDINGS.md"), `# ${projectName} FINDINGS\n\n<!-- Bootstrapped from ${sourceRoot} -->\n`);
1043
+ }
1044
+ if (!fs.existsSync(path.join(projDir, TASKS_FILENAME))) {
1045
+ atomicWriteText(path.join(projDir, TASKS_FILENAME), `# ${projectName} tasks\n\n## Active\n\n## Queue\n\n## Done\n`);
1046
+ }
1047
+ if (!fs.existsSync(path.join(projDir, "topic-config.json"))) {
1048
+ const inferredDomain = normalizeBuiltinTopicDomain(inferredScaffold?.domain ?? "software");
1049
+ const inferredTopics = inferredScaffold?.topics?.length
1050
+ ? inferredScaffold.topics
1051
+ : getBuiltinTopicConfig(inferredDomain);
1052
+ atomicWriteText(path.join(projDir, "topic-config.json"), JSON.stringify({ version: 1, domain: inferredDomain, topics: inferredTopics }, null, 2) + "\n");
1053
+ }
1054
+ const activeProfile = resolveActiveProfile(phrenPath, profile);
1055
+ if (activeProfile.ok && activeProfile.data) {
1056
+ const addResult = addProjectToProfile(phrenPath, activeProfile.data, projectName);
1057
+ if (!addResult.ok) {
1058
+ throw new Error(addResult.error);
1059
+ }
1060
+ }
1061
+ else if (!activeProfile.ok && activeProfile.code !== "FILE_NOT_FOUND") {
1062
+ throw new Error(activeProfile.error);
1063
+ }
1064
+ writeProjectConfig(phrenPath, projectName, { ownership, sourcePath: sourceRoot });
1065
+ return {
1066
+ project: projectName,
1067
+ ownership,
1068
+ claudePath: ownership === "repo-managed"
1069
+ ? (claudeMdPath ?? null)
1070
+ : (fs.existsSync(claudePath) ? claudePath : null),
1071
+ };
1072
+ }
1073
+ export function updateMachinesYaml(phrenPath, machine, profile) {
1074
+ const machinesFile = path.join(phrenPath, "machines.yaml");
1075
+ if (!fs.existsSync(machinesFile))
1076
+ return;
1077
+ const machineName = (machine?.trim() || getMachineName()).trim();
1078
+ if (!machineName)
1079
+ return;
1080
+ const activeProfile = resolveActiveProfile(phrenPath, profile);
1081
+ const profileName = profile?.trim() || (activeProfile.ok ? (activeProfile.data || "") : "") || "personal";
1082
+ let hasExistingMapping = false;
1083
+ try {
1084
+ const loaded = yaml.load(fs.readFileSync(machinesFile, "utf8"), { schema: yaml.CORE_SCHEMA });
1085
+ if (loaded && typeof loaded === "object" && !Array.isArray(loaded)) {
1086
+ hasExistingMapping = Object.prototype.hasOwnProperty.call(loaded, machineName);
1087
+ }
1088
+ }
1089
+ catch (err) {
1090
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
1091
+ process.stderr.write(`[phren] updateMachinesYaml parse: ${err instanceof Error ? err.message : String(err)}\n`);
1092
+ }
1093
+ // Passive init/link refreshes should keep an existing mapping; explicit overrides can remap.
1094
+ if (hasExistingMapping && !machine && !profile)
1095
+ return;
1096
+ const mapping = setMachineProfile(phrenPath, machineName, profileName);
1097
+ if (!mapping.ok && (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG)) {
1098
+ process.stderr.write(`[phren] updateMachinesYaml setMachineProfile: ${mapping.error}\n`);
1099
+ }
1100
+ }
1101
+ /**
1102
+ * Detect if a directory looks like a project that should be bootstrapped.
1103
+ * Returns the path if it qualifies, null otherwise.
1104
+ * A directory qualifies if it:
1105
+ * - Is not the home directory or phren directory
1106
+ * - Has a CLAUDE.md, AGENTS.md, .claude/CLAUDE.md, or .git directory
1107
+ */
1108
+ export function detectProjectDir(dir, phrenPath) {
1109
+ const home = os.homedir();
1110
+ const resolvedPhrenPath = path.resolve(phrenPath);
1111
+ let current = path.resolve(dir);
1112
+ while (true) {
1113
+ if (current === home || current === resolvedPhrenPath)
1114
+ return null;
1115
+ if (current.startsWith(resolvedPhrenPath + path.sep))
1116
+ return null;
1117
+ const hasClaude = fs.existsSync(path.join(current, "CLAUDE.md")) ||
1118
+ fs.existsSync(path.join(current, ".claude", "CLAUDE.md"));
1119
+ const hasAgents = fs.existsSync(path.join(current, "AGENTS.md"));
1120
+ const hasGit = fs.existsSync(path.join(current, ".git"));
1121
+ if (hasClaude || hasAgents || hasGit)
1122
+ return current;
1123
+ const parent = path.dirname(current);
1124
+ if (parent === current || parent === home)
1125
+ break;
1126
+ current = parent;
1127
+ }
1128
+ return null;
1129
+ }
1130
+ /**
1131
+ * Check if a project name is already tracked in any profile.
1132
+ */
1133
+ export function isProjectTracked(phrenPath, projectName, profile) {
1134
+ const profiles = listProfiles(phrenPath);
1135
+ if (profiles.ok) {
1136
+ if (profile) {
1137
+ return profiles.data.some((entry) => entry.name === profile && entry.projects.includes(projectName));
1138
+ }
1139
+ return profiles.data.some((entry) => entry.projects.includes(projectName));
1140
+ }
1141
+ const projDir = path.join(phrenPath, projectName);
1142
+ return fs.existsSync(projDir);
1143
+ }
1144
+ export function runPostInitVerify(phrenPath) {
1145
+ const checks = [];
1146
+ const prefs = readInstallPreferences(phrenPath);
1147
+ const manifest = readRootManifest(phrenPath);
1148
+ const gitVersion = commandVersion("git");
1149
+ const nodeVersion = commandVersion("node");
1150
+ checks.push({
1151
+ name: "git-installed",
1152
+ ok: Boolean(gitVersion),
1153
+ detail: gitVersion || "git not found in PATH",
1154
+ fix: gitVersion ? undefined : "Install git and re-run `phren init`.",
1155
+ });
1156
+ checks.push({
1157
+ name: "node-version",
1158
+ ok: versionAtLeast(nodeVersion, 20),
1159
+ detail: nodeVersion || "node not found in PATH",
1160
+ fix: versionAtLeast(nodeVersion, 20) ? undefined : "Install Node.js 20+ before using phren.",
1161
+ });
1162
+ if (manifest?.installMode === "project-local") {
1163
+ checks.push({
1164
+ name: "workspace-root",
1165
+ ok: Boolean(manifest.workspaceRoot && fs.existsSync(manifest.workspaceRoot)),
1166
+ detail: manifest.workspaceRoot ? `workspace root: ${manifest.workspaceRoot}` : "workspaceRoot missing from phren.root.yaml",
1167
+ fix: manifest.workspaceRoot ? undefined : "Re-run `phren init --mode project-local` to repair the root manifest.",
1168
+ });
1169
+ checks.push({
1170
+ name: "hooks-registered",
1171
+ ok: prefs.hooksEnabled === false,
1172
+ detail: "hooks are unsupported in project-local mode",
1173
+ fix: prefs.hooksEnabled === false ? undefined : "Run `phren hooks-mode off` and keep hooks disabled in project-local mode.",
1174
+ });
1175
+ const workspaceMcp = manifest.workspaceRoot ? path.join(manifest.workspaceRoot, ".vscode", "mcp.json") : "";
1176
+ let workspaceMcpOk = false;
1177
+ try {
1178
+ if (workspaceMcp && fs.existsSync(workspaceMcp)) {
1179
+ const cfg = JSON.parse(fs.readFileSync(workspaceMcp, "utf8"));
1180
+ workspaceMcpOk = Boolean(cfg.servers?.phren);
1181
+ }
1182
+ }
1183
+ catch (err) {
1184
+ debugLog(`doctor local workspace mcp parse failed: ${errorMessage(err)}`);
1185
+ }
1186
+ checks.push({
1187
+ name: "mcp-config",
1188
+ ok: prefs.mcpEnabled === false ? true : workspaceMcpOk,
1189
+ detail: prefs.mcpEnabled === false
1190
+ ? "workspace MCP disabled by preference"
1191
+ : workspaceMcpOk
1192
+ ? "VS Code workspace MCP registered"
1193
+ : "VS Code workspace MCP not found in .vscode/mcp.json",
1194
+ fix: prefs.mcpEnabled === false ? undefined : "Run `phren mcp-mode on` to register the VS Code workspace server.",
1195
+ });
1196
+ }
1197
+ else {
1198
+ const gitRemote = gitRemoteStatus(phrenPath);
1199
+ const gitRemoteDetail = gitRemote.ok
1200
+ ? gitRemote.detail
1201
+ : `${gitRemote.detail} (optional unless you want cross-machine sync)`;
1202
+ checks.push({
1203
+ name: "git-remote",
1204
+ ok: gitRemote.ok,
1205
+ detail: gitRemoteDetail,
1206
+ fix: gitRemote.ok ? undefined : "Optional: initialize a repo and add an origin remote for cross-machine sync.",
1207
+ });
1208
+ const settingsPath = hookConfigPath("claude");
1209
+ const configWritable = nearestWritableTarget(settingsPath);
1210
+ checks.push({
1211
+ name: "config-writable",
1212
+ ok: configWritable,
1213
+ detail: configWritable ? `writable: ${settingsPath}` : `not writable: ${settingsPath}`,
1214
+ fix: configWritable ? undefined : "Fix permissions for ~/.claude or its settings.json before enabling hooks/MCP.",
1215
+ });
1216
+ let mcpOk = false;
1217
+ let hooksOk = false;
1218
+ try {
1219
+ const cfg = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
1220
+ mcpOk = Boolean(cfg.mcpServers?.phren);
1221
+ const hooks = cfg.hooks || {};
1222
+ const hasPrompt = JSON.stringify(hooks.UserPromptSubmit || []).includes("hook-prompt");
1223
+ const hasStop = JSON.stringify(hooks.Stop || []).includes("hook-stop");
1224
+ const hasStart = JSON.stringify(hooks.SessionStart || []).includes("hook-session-start");
1225
+ hooksOk = hasPrompt && hasStop && hasStart;
1226
+ }
1227
+ catch (err) {
1228
+ debugLog(`doctor: settings.json missing or unreadable: ${errorMessage(err)}`);
1229
+ }
1230
+ checks.push({
1231
+ name: "mcp-config",
1232
+ ok: mcpOk,
1233
+ detail: mcpOk
1234
+ ? "MCP server registered in Claude settings"
1235
+ : prefs.mcpEnabled === false
1236
+ ? "MCP server not found in ~/.claude/settings.json (expected while MCP mode is OFF)"
1237
+ : "MCP server not found in ~/.claude/settings.json",
1238
+ fix: mcpOk
1239
+ ? undefined
1240
+ : prefs.mcpEnabled === false
1241
+ ? "Optional: run `phren mcp-mode on` or `phren init` if you want MCP enabled."
1242
+ : "Run `phren init` to register the MCP server",
1243
+ });
1244
+ checks.push({
1245
+ name: "hooks-registered",
1246
+ ok: hooksOk,
1247
+ detail: hooksOk
1248
+ ? "All lifecycle hooks registered"
1249
+ : prefs.hooksEnabled === false
1250
+ ? "One or more hooks missing from ~/.claude/settings.json (expected while hooks mode is OFF)"
1251
+ : "One or more hooks missing from ~/.claude/settings.json",
1252
+ fix: hooksOk
1253
+ ? undefined
1254
+ : prefs.hooksEnabled === false
1255
+ ? "Optional: run `phren hooks-mode on` or `phren init` if you want hooks enabled."
1256
+ : "Run `phren init` to install or refresh hooks",
1257
+ });
1258
+ }
1259
+ const globalClaude = path.join(phrenPath, "global", "CLAUDE.md");
1260
+ const globalOk = fs.existsSync(globalClaude);
1261
+ checks.push({
1262
+ name: "global-claude",
1263
+ ok: globalOk,
1264
+ detail: globalOk ? "global/CLAUDE.md exists" : "global/CLAUDE.md missing",
1265
+ fix: globalOk ? undefined : "Run `phren init` to create starter files",
1266
+ });
1267
+ const govDir = path.join(phrenPath, ".governance");
1268
+ const govOk = fs.existsSync(govDir);
1269
+ checks.push({
1270
+ name: "config",
1271
+ ok: govOk,
1272
+ detail: govOk ? ".governance/ config directory exists" : ".governance/ config directory missing",
1273
+ fix: govOk ? undefined : "Run `phren init` to create governance config",
1274
+ });
1275
+ const installedPrefs = readInstallPreferences(phrenPath);
1276
+ const installedVersion = installedPrefs.installedVersion;
1277
+ const versionOk = !govOk || installedVersion === VERSION;
1278
+ checks.push({
1279
+ name: "installed-version",
1280
+ ok: versionOk,
1281
+ detail: installedVersion
1282
+ ? (versionOk ? `install metadata matches running version (${VERSION})` : `install metadata is ${installedVersion}, runtime is ${VERSION}`)
1283
+ : "install metadata missing installedVersion",
1284
+ fix: versionOk ? undefined : "Run `phren update` or `phren init` to refresh install metadata.",
1285
+ });
1286
+ let ftsOk = false;
1287
+ try {
1288
+ const entries = fs.readdirSync(phrenPath, { withFileTypes: true });
1289
+ ftsOk = entries.some(d => d.isDirectory() && !d.name.startsWith("."));
1290
+ }
1291
+ catch (err) {
1292
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
1293
+ process.stderr.write(`[phren] runPostInitVerify projectScan: ${err instanceof Error ? err.message : String(err)}\n`);
1294
+ ftsOk = false;
1295
+ }
1296
+ checks.push({
1297
+ name: "fts-index",
1298
+ ok: ftsOk,
1299
+ detail: ftsOk ? "Project directories found for indexing" : "No project directories found in phren path",
1300
+ fix: ftsOk ? undefined : "Create a project: `cd ~/your-project && phren add`",
1301
+ });
1302
+ checks.push(getHookEntrypointCheck());
1303
+ const ok = checks.every((c) => c.ok);
1304
+ return { ok, checks };
1305
+ }