@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,1134 @@
1
+ import * as crypto from "crypto";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { debugLog } from "./shared.js";
5
+ import { withFileLock } from "./shared-governance.js";
6
+ import { STOP_WORDS, errorMessage, extractKeywords, isValidProjectName, safeProjectPath } from "./utils.js";
7
+ const TOPIC_CONFIG_FILENAME = "topic-config.json";
8
+ const AUTO_TOPIC_MARKER_RE = /^<!--\s*phren:auto-topic(?:\s+slug=([a-z0-9_-]+))?\s*-->$/;
9
+ const ARCHIVED_SECTION_RE = /^## Archived (\d{4}-\d{2}-\d{2})$/;
10
+ const SOFTWARE_TOPICS = [
11
+ {
12
+ slug: "api",
13
+ label: "API",
14
+ description: "Endpoints, routes, requests, responses, and protocol-level integration details.",
15
+ keywords: ["api", "endpoint", "route", "rest", "graphql", "grpc", "request", "response", "http", "url", "webhook", "cors"],
16
+ },
17
+ {
18
+ slug: "database",
19
+ label: "Database",
20
+ description: "Schemas, queries, indexes, migrations, and storage-engine behavior.",
21
+ keywords: ["database", "db", "sql", "query", "index", "migration", "schema", "table", "column", "postgres", "mysql", "sqlite", "mongo", "redis", "orm"],
22
+ },
23
+ {
24
+ slug: "performance",
25
+ label: "Performance",
26
+ description: "Latency, throughput, profiling, caching, and resource bottlenecks.",
27
+ keywords: ["performance", "speed", "latency", "cache", "optimize", "memory", "cpu", "bottleneck", "profiling", "benchmark", "throughput", "lazy"],
28
+ },
29
+ {
30
+ slug: "security",
31
+ label: "Security",
32
+ description: "Security issues, hardening, encryption, authentication surfaces, and abuse resistance.",
33
+ keywords: ["security", "vulnerability", "xss", "csrf", "injection", "sanitize", "escape", "encrypt", "decrypt", "hash", "salt", "tls", "ssl"],
34
+ },
35
+ {
36
+ slug: "frontend",
37
+ label: "Frontend",
38
+ description: "UI rendering, layout, interaction, browser behavior, and client-side frameworks.",
39
+ keywords: ["frontend", "ui", "ux", "css", "html", "dom", "render", "component", "layout", "responsive", "animation", "browser", "react", "vue", "angular"],
40
+ },
41
+ {
42
+ slug: "testing",
43
+ label: "Testing",
44
+ description: "Test strategy, fixtures, assertions, mocks, and validation workflows.",
45
+ keywords: ["test", "spec", "assert", "mock", "stub", "fixture", "coverage", "jest", "vitest", "playwright", "e2e", "unit", "integration"],
46
+ },
47
+ {
48
+ slug: "devops",
49
+ label: "DevOps",
50
+ description: "Deployment, CI/CD, infrastructure, containers, and operational workflows.",
51
+ keywords: ["deploy", "ci", "cd", "pipeline", "docker", "kubernetes", "container", "infra", "terraform", "aws", "cloud", "monitoring", "logging"],
52
+ },
53
+ {
54
+ slug: "architecture",
55
+ label: "Architecture",
56
+ description: "System shape, module boundaries, design patterns, and structural decisions.",
57
+ keywords: ["architecture", "design", "pattern", "layer", "module", "system", "structure", "microservice", "monolith", "event-driven", "plugin"],
58
+ },
59
+ {
60
+ slug: "debugging",
61
+ label: "Debugging",
62
+ description: "Bugs, errors, traces, workarounds, and debugging techniques.",
63
+ keywords: ["debug", "bug", "error", "crash", "fix", "issue", "stack", "trace", "breakpoint", "log", "workaround", "pitfall", "caveat"],
64
+ },
65
+ {
66
+ slug: "tooling",
67
+ label: "Tooling",
68
+ description: "Build tools, scripts, package management, hooks, and developer tooling.",
69
+ keywords: ["tool", "cli", "script", "build", "webpack", "vite", "eslint", "prettier", "npm", "package", "config", "plugin", "hook", "git"],
70
+ },
71
+ {
72
+ slug: "auth",
73
+ label: "Auth",
74
+ description: "Authentication, sessions, permissions, and access control behavior.",
75
+ keywords: ["auth", "login", "logout", "session", "token", "jwt", "oauth", "sso", "permission", "role", "access", "credential"],
76
+ },
77
+ {
78
+ slug: "data",
79
+ label: "Data",
80
+ description: "Data models, serialization, parsing, validation, and data flow details.",
81
+ keywords: ["data", "model", "schema", "serialize", "deserialize", "json", "csv", "transform", "validate", "parse", "format", "encode"],
82
+ },
83
+ {
84
+ slug: "mobile",
85
+ label: "Mobile",
86
+ description: "Mobile UX, device-specific behavior, native apps, and mobile frameworks.",
87
+ keywords: ["mobile", "ios", "android", "react-native", "flutter", "native", "touch", "gesture", "push-notification", "app-store"],
88
+ },
89
+ {
90
+ slug: "ai_ml",
91
+ label: "AI / ML",
92
+ description: "Models, embeddings, prompts, inference, and ML-specific systems.",
93
+ keywords: ["ai", "ml", "model", "embedding", "vector", "llm", "prompt", "token", "inference", "training", "neural", "gpt", "claude"],
94
+ },
95
+ {
96
+ slug: "general",
97
+ label: "General",
98
+ description: "Fallback bucket for findings that do not fit a project-specific topic yet.",
99
+ keywords: [],
100
+ },
101
+ ];
102
+ const DOMAIN_TOPICS = {
103
+ software: SOFTWARE_TOPICS,
104
+ music: [
105
+ {
106
+ slug: "composition",
107
+ label: "Composition",
108
+ description: "Melody, harmony, rhythm, and songwriting decisions.",
109
+ keywords: ["composition", "songwriting", "melody", "harmony", "chords", "rhythm", "motif"],
110
+ },
111
+ {
112
+ slug: "production",
113
+ label: "Production",
114
+ description: "Session workflow, recording choices, and production techniques.",
115
+ keywords: ["production", "recording", "session", "tracking", "workflow", "arrangement", "producer"],
116
+ },
117
+ {
118
+ slug: "mixing",
119
+ label: "Mixing",
120
+ description: "Balance, EQ, dynamics, and spatial processing choices.",
121
+ keywords: ["mixing", "mix", "eq", "compression", "reverb", "delay", "balance", "panning"],
122
+ },
123
+ {
124
+ slug: "sound-design",
125
+ label: "Sound Design",
126
+ description: "Timbre creation, synthesis, sampling, and texture shaping.",
127
+ keywords: ["sound-design", "sound design", "synthesis", "synth", "patch", "sample", "texture"],
128
+ },
129
+ {
130
+ slug: "instruments",
131
+ label: "Instruments",
132
+ description: "Instrument selection, performance notes, and articulation decisions.",
133
+ keywords: ["instrument", "instruments", "guitar", "piano", "drums", "bass", "performance"],
134
+ },
135
+ {
136
+ slug: "theory",
137
+ label: "Theory",
138
+ description: "Music theory concepts, progressions, and structural analysis.",
139
+ keywords: ["theory", "scale", "mode", "progression", "counterpoint", "voice-leading", "tonality"],
140
+ },
141
+ {
142
+ slug: "arrangement",
143
+ label: "Arrangement",
144
+ description: "Section structure, orchestration, and part distribution.",
145
+ keywords: ["arrangement", "arrange", "structure", "section", "orchestration", "voicing"],
146
+ },
147
+ {
148
+ slug: "mastering",
149
+ label: "Mastering",
150
+ description: "Final loudness, translation checks, and delivery formats.",
151
+ keywords: ["mastering", "master", "loudness", "limiting", "metering", "delivery", "reference"],
152
+ },
153
+ ],
154
+ game: [
155
+ {
156
+ slug: "mechanics",
157
+ label: "Mechanics",
158
+ description: "Core gameplay systems, controls, and player interaction rules.",
159
+ keywords: ["mechanics", "gameplay", "controls", "systems", "player", "loop", "balance"],
160
+ },
161
+ {
162
+ slug: "rendering",
163
+ label: "Rendering",
164
+ description: "Graphics pipeline, shaders, performance, and visual output.",
165
+ keywords: ["rendering", "graphics", "shader", "pipeline", "lighting", "fps", "gpu"],
166
+ },
167
+ {
168
+ slug: "physics",
169
+ label: "Physics",
170
+ description: "Simulation behavior, collisions, and movement dynamics.",
171
+ keywords: ["physics", "collision", "rigidbody", "simulation", "velocity", "forces"],
172
+ },
173
+ {
174
+ slug: "ai",
175
+ label: "AI",
176
+ description: "Agent behavior, decision-making, and pathfinding systems.",
177
+ keywords: ["ai", "npc", "behavior", "pathfinding", "state-machine", "decision", "navigation"],
178
+ },
179
+ {
180
+ slug: "level-design",
181
+ label: "Level Design",
182
+ description: "Map layout, encounter flow, and progression structure.",
183
+ keywords: ["level-design", "level design", "level", "map", "encounter", "pacing", "layout"],
184
+ },
185
+ {
186
+ slug: "audio",
187
+ label: "Audio",
188
+ description: "In-game sound effects, music integration, and audio systems.",
189
+ keywords: ["audio", "sfx", "music", "voice", "spatial-audio", "mix", "implementation"],
190
+ },
191
+ {
192
+ slug: "networking",
193
+ label: "Networking",
194
+ description: "Multiplayer sync, replication, latency handling, and netcode.",
195
+ keywords: ["networking", "multiplayer", "replication", "latency", "netcode", "server", "client"],
196
+ },
197
+ {
198
+ slug: "ui",
199
+ label: "UI",
200
+ description: "HUD, menus, readability, and interaction flows.",
201
+ keywords: ["ui", "hud", "menu", "interface", "ux", "interaction", "readability"],
202
+ },
203
+ ],
204
+ research: [
205
+ {
206
+ slug: "methodology",
207
+ label: "Methodology",
208
+ description: "Research design, protocol choices, and evaluation approach.",
209
+ keywords: ["methodology", "method", "protocol", "design", "experiment", "evaluation"],
210
+ },
211
+ {
212
+ slug: "sources",
213
+ label: "Sources",
214
+ description: "Primary references, citations, provenance, and credibility checks.",
215
+ keywords: ["sources", "citation", "reference", "paper", "provenance", "credibility"],
216
+ },
217
+ {
218
+ slug: "analysis",
219
+ label: "Analysis",
220
+ description: "Interpretation, data analysis, and evidence synthesis.",
221
+ keywords: ["analysis", "data", "interpretation", "evidence", "findings", "synthesis"],
222
+ },
223
+ {
224
+ slug: "writing",
225
+ label: "Writing",
226
+ description: "Drafting, clarity, structure, and argument framing.",
227
+ keywords: ["writing", "draft", "structure", "clarity", "argument", "narrative"],
228
+ },
229
+ {
230
+ slug: "review",
231
+ label: "Review",
232
+ description: "Peer feedback, revision notes, and quality checks.",
233
+ keywords: ["review", "peer-review", "feedback", "revision", "critique", "quality"],
234
+ },
235
+ ],
236
+ creative: [
237
+ {
238
+ slug: "worldbuilding",
239
+ label: "Worldbuilding",
240
+ description: "Setting rules, lore consistency, and environment details.",
241
+ keywords: ["worldbuilding", "setting", "lore", "canon", "environment", "rules"],
242
+ },
243
+ {
244
+ slug: "characters",
245
+ label: "Characters",
246
+ description: "Character goals, arcs, voice, and relationship dynamics.",
247
+ keywords: ["characters", "character", "arc", "motivation", "voice", "relationship"],
248
+ },
249
+ {
250
+ slug: "plot",
251
+ label: "Plot",
252
+ description: "Story beats, pacing, conflict, and narrative structure.",
253
+ keywords: ["plot", "story", "beats", "conflict", "pacing", "structure"],
254
+ },
255
+ {
256
+ slug: "style",
257
+ label: "Style",
258
+ description: "Tone, diction, constraints, and stylistic direction.",
259
+ keywords: ["style", "tone", "voice", "diction", "register", "aesthetic"],
260
+ },
261
+ {
262
+ slug: "research",
263
+ label: "Research",
264
+ description: "Reference gathering, fact checks, and contextual grounding.",
265
+ keywords: ["research", "reference", "fact-check", "context", "source", "notes"],
266
+ },
267
+ {
268
+ slug: "revision",
269
+ label: "Revision",
270
+ description: "Editing passes, rewrite strategy, and quality improvements.",
271
+ keywords: ["revision", "edit", "rewrite", "polish", "draft", "improve"],
272
+ },
273
+ ],
274
+ other: [
275
+ {
276
+ slug: "notes",
277
+ label: "Notes",
278
+ description: "General observations and quick capture items.",
279
+ keywords: ["notes", "observation", "idea", "capture", "context"],
280
+ },
281
+ {
282
+ slug: "reference",
283
+ label: "Reference",
284
+ description: "Supporting references, links, and background material.",
285
+ keywords: ["reference", "links", "docs", "source", "background"],
286
+ },
287
+ {
288
+ slug: "tasks",
289
+ label: "Tasks",
290
+ description: "Action items, follow-ups, and execution checklist details.",
291
+ keywords: ["tasks", "todo", "action", "follow-up", "checklist"],
292
+ },
293
+ ],
294
+ };
295
+ const GENERAL_TOPIC = SOFTWARE_TOPICS.find((topic) => topic.slug === "general");
296
+ const DEFAULT_TOPIC_LIMIT = 8;
297
+ const SUGGESTION_LIMIT = 8;
298
+ function normalizeKeyword(raw) {
299
+ return raw
300
+ .trim()
301
+ .toLowerCase()
302
+ .replace(/[^\w\s-]/g, " ")
303
+ .replace(/\s+/g, " ")
304
+ .trim();
305
+ }
306
+ export function normalizeTopicSlug(raw) {
307
+ return raw
308
+ .trim()
309
+ .toLowerCase()
310
+ .replace(/[^a-z0-9_-]+/g, "-")
311
+ .replace(/-{2,}/g, "-")
312
+ .replace(/^[-_]+|[-_]+$/g, "");
313
+ }
314
+ function titleCaseLabel(raw) {
315
+ return raw
316
+ .split(/[\s_-]+/)
317
+ .filter(Boolean)
318
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
319
+ .join(" ");
320
+ }
321
+ function normalizeTopic(topic) {
322
+ const name = typeof topic.name === "string" ? topic.name : "";
323
+ const labelInput = typeof topic.label === "string" ? topic.label : name;
324
+ const slugInput = typeof topic.slug === "string" ? topic.slug : labelInput;
325
+ const slug = normalizeTopicSlug(slugInput || labelInput);
326
+ const label = (labelInput || titleCaseLabel(slug)).trim() || titleCaseLabel(slug);
327
+ const description = (typeof topic.description === "string" ? topic.description : "").trim();
328
+ const keywords = Array.from(new Set((Array.isArray(topic.keywords) ? topic.keywords : [])
329
+ .map((keyword) => normalizeKeyword(String(keyword)))
330
+ .filter((keyword) => keyword.length > 1)));
331
+ return { slug, label, description, keywords };
332
+ }
333
+ function normalizeTopics(topics) {
334
+ return topics.map(normalizeTopic);
335
+ }
336
+ function dedupeTopics(topics) {
337
+ const seen = new Set();
338
+ const unique = [];
339
+ for (const topic of topics) {
340
+ if (!topic.slug || seen.has(topic.slug))
341
+ continue;
342
+ seen.add(topic.slug);
343
+ unique.push(topic);
344
+ }
345
+ return unique;
346
+ }
347
+ function validateTopics(topics) {
348
+ if (!Array.isArray(topics) || topics.length === 0)
349
+ return "Provide at least one topic.";
350
+ const seen = new Set();
351
+ const keywordOwners = new Map();
352
+ let hasGeneral = false;
353
+ for (const topic of topics) {
354
+ if (!topic.slug || !isValidProjectName(topic.slug)) {
355
+ return `Invalid topic slug: "${topic.slug || "(empty)"}".`;
356
+ }
357
+ if (!topic.label.trim())
358
+ return `Topic "${topic.slug}" is missing a label.`;
359
+ if (seen.has(topic.slug))
360
+ return `Duplicate topic slug: "${topic.slug}".`;
361
+ seen.add(topic.slug);
362
+ for (const keyword of topic.keywords) {
363
+ const owner = keywordOwners.get(keyword);
364
+ if (owner && owner !== topic.slug) {
365
+ return `Duplicate topic keyword: "${keyword}" is used by both "${owner}" and "${topic.slug}".`;
366
+ }
367
+ keywordOwners.set(keyword, topic.slug);
368
+ }
369
+ if (topic.slug === "general")
370
+ hasGeneral = true;
371
+ }
372
+ if (!hasGeneral)
373
+ return "Topics must include the reserved fallback topic \"general\".";
374
+ return null;
375
+ }
376
+ function ensureGeneralTopic(topics) {
377
+ if (topics.some((topic) => topic.slug === "general"))
378
+ return topics;
379
+ return [...topics, { ...GENERAL_TOPIC, keywords: [...GENERAL_TOPIC.keywords] }];
380
+ }
381
+ function topicConfigPath(phrenPath, project) {
382
+ return safeProjectPath(phrenPath, project, TOPIC_CONFIG_FILENAME);
383
+ }
384
+ function projectDirPath(phrenPath, project) {
385
+ return safeProjectPath(phrenPath, project);
386
+ }
387
+ export function topicReferenceDir(phrenPath, project) {
388
+ return safeProjectPath(phrenPath, project, "reference", "topics");
389
+ }
390
+ export function topicReferenceRelativePath(slug) {
391
+ return path.posix.join("reference", "topics", `${slug}.md`);
392
+ }
393
+ export function topicReferencePath(phrenPath, project, slug) {
394
+ return safeProjectPath(phrenPath, project, "reference", "topics", `${normalizeTopicSlug(slug)}.md`);
395
+ }
396
+ function readJsonFile(filePath) {
397
+ try {
398
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
399
+ }
400
+ catch (err) {
401
+ debugLog(`readJsonFile: failed to parse ${filePath}: ${errorMessage(err)}`);
402
+ return null;
403
+ }
404
+ }
405
+ function countByTerm(terms) {
406
+ const counts = new Map();
407
+ for (const term of terms) {
408
+ const current = counts.get(term) ?? 0;
409
+ counts.set(term, current + 1);
410
+ }
411
+ return counts;
412
+ }
413
+ function readTopicInputContent(phrenPath, project) {
414
+ const parts = [];
415
+ for (const file of ["CLAUDE.md", "FINDINGS.md"]) {
416
+ const filePath = safeProjectPath(phrenPath, project, file);
417
+ if (!filePath || !fs.existsSync(filePath))
418
+ continue;
419
+ const content = fs.readFileSync(filePath, "utf8").trim();
420
+ if (content)
421
+ parts.push(content);
422
+ }
423
+ const referenceDir = safeProjectPath(phrenPath, project, "reference");
424
+ if (referenceDir && fs.existsSync(referenceDir)) {
425
+ for (const filePath of readReferenceMarkdownFiles(referenceDir)) {
426
+ try {
427
+ const content = fs.readFileSync(filePath, "utf8").trim();
428
+ if (content)
429
+ parts.push(content);
430
+ }
431
+ catch {
432
+ // Ignore unreadable files and continue.
433
+ }
434
+ }
435
+ }
436
+ return parts;
437
+ }
438
+ function buildTopicContentSignal(phrenPath, project) {
439
+ const parts = readTopicInputContent(phrenPath, project);
440
+ const corpus = parts.join("\n");
441
+ if (!corpus.trim()) {
442
+ return { hasContent: false, corpus: "", corpusLower: "", termCounts: new Map() };
443
+ }
444
+ const keywordSignal = extractKeywords(corpus);
445
+ const termCounts = countByTerm(tokenizeSuggestionTerms(`${corpus}\n${keywordSignal}`));
446
+ return { hasContent: true, corpus, corpusLower: corpus.toLowerCase(), termCounts };
447
+ }
448
+ function termScore(termCounts, term) {
449
+ const normalized = normalizeKeyword(term);
450
+ if (!normalized)
451
+ return 0;
452
+ return termCounts.get(normalized) ?? 0;
453
+ }
454
+ function buildAdaptiveTopicCandidates(signal, catalog) {
455
+ const scored = [];
456
+ for (const topic of catalog) {
457
+ if (topic.slug === "general")
458
+ continue;
459
+ const base = termScore(signal.termCounts, topic.label);
460
+ const keywordScore = topic.keywords.reduce((sum, keyword) => sum + termScore(signal.termCounts, keyword), 0);
461
+ const score = base + keywordScore;
462
+ if (score <= 0)
463
+ continue;
464
+ scored.push({ topic, score });
465
+ }
466
+ return scored.sort((a, b) => b.score - a.score || a.topic.label.localeCompare(b.topic.label));
467
+ }
468
+ function buildHeuristicTopicCandidates(signal, takenSlugs) {
469
+ const entries = [...signal.termCounts.entries()]
470
+ .filter(([term, count]) => count >= 3 && term.length >= 4 && term.length <= 48)
471
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
472
+ const out = [];
473
+ for (const [term, score] of entries) {
474
+ if (out.length >= DEFAULT_TOPIC_LIMIT)
475
+ break;
476
+ if (term.split(" ").length > 2)
477
+ continue;
478
+ const slug = normalizeTopicSlug(term);
479
+ if (!slug || takenSlugs.has(slug) || slug === "general")
480
+ continue;
481
+ takenSlugs.add(slug);
482
+ const keywords = Array.from(new Set(term.split(" ")
483
+ .concat(slug.split("-"))
484
+ .map((keyword) => normalizeKeyword(keyword))
485
+ .filter((keyword) => keyword.length > 2))).slice(0, 6);
486
+ out.push({
487
+ topic: {
488
+ slug,
489
+ label: titleCaseLabel(term),
490
+ description: "Suggested from repeated terminology in project findings and reference docs.",
491
+ keywords,
492
+ },
493
+ score,
494
+ });
495
+ }
496
+ return out;
497
+ }
498
+ function confidenceFromScore(score) {
499
+ const value = Math.min(0.99, 0.2 + Math.log1p(Math.max(0, score)) / 3.2);
500
+ return Number(value.toFixed(2));
501
+ }
502
+ export function normalizeBuiltinTopicDomain(domain) {
503
+ if (!domain)
504
+ return "software";
505
+ const normalized = domain.trim().toLowerCase();
506
+ if (normalized === "writing")
507
+ return "creative";
508
+ if (normalized in DOMAIN_TOPICS)
509
+ return normalized;
510
+ return "software";
511
+ }
512
+ function resolveDomainTopics(domain) {
513
+ return DOMAIN_TOPICS[normalizeBuiltinTopicDomain(domain)];
514
+ }
515
+ export function getBuiltinTopicConfig(domain) {
516
+ return ensureGeneralTopic(resolveDomainTopics(domain))
517
+ .map((topic) => ({
518
+ name: topic.label,
519
+ description: topic.description,
520
+ keywords: [...topic.keywords],
521
+ }));
522
+ }
523
+ function readProjectDomain(phrenPath, project) {
524
+ const configPath = topicConfigPath(phrenPath, project);
525
+ if (!configPath || !fs.existsSync(configPath))
526
+ return undefined;
527
+ const parsed = readJsonFile(configPath);
528
+ return typeof parsed?.domain === "string" ? parsed.domain : undefined;
529
+ }
530
+ export function getBuiltinTopics(phrenPath, project) {
531
+ const domain = (phrenPath && project) ? readProjectDomain(phrenPath, project) : undefined;
532
+ const fallback = ensureGeneralTopic(resolveDomainTopics(domain)).map((topic) => ({ ...topic, keywords: [...topic.keywords] }));
533
+ if (!phrenPath || !project || !isValidProjectName(project))
534
+ return fallback;
535
+ const signal = buildTopicContentSignal(phrenPath, project);
536
+ if (!signal.hasContent)
537
+ return fallback;
538
+ const adaptive = [];
539
+ const taken = new Set();
540
+ for (const candidate of buildAdaptiveTopicCandidates(signal, fallback)) {
541
+ if (adaptive.length >= DEFAULT_TOPIC_LIMIT - 1)
542
+ break;
543
+ if (candidate.score < 2)
544
+ continue;
545
+ if (taken.has(candidate.topic.slug))
546
+ continue;
547
+ taken.add(candidate.topic.slug);
548
+ adaptive.push({ ...candidate.topic, keywords: [...candidate.topic.keywords] });
549
+ }
550
+ for (const candidate of buildHeuristicTopicCandidates(signal, taken)) {
551
+ if (adaptive.length >= DEFAULT_TOPIC_LIMIT - 1)
552
+ break;
553
+ adaptive.push(candidate.topic);
554
+ }
555
+ const merged = ensureGeneralTopic(dedupeTopics(adaptive));
556
+ if (merged.length <= 1)
557
+ return fallback;
558
+ return merged;
559
+ }
560
+ export function readProjectTopics(phrenPath, project) {
561
+ const builtinTopics = getBuiltinTopics(phrenPath, project);
562
+ const configPath = topicConfigPath(phrenPath, project);
563
+ if (!configPath || !fs.existsSync(configPath)) {
564
+ return { source: "default", topics: builtinTopics };
565
+ }
566
+ const parsed = readJsonFile(configPath);
567
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.topics)) {
568
+ return { source: "default", topics: builtinTopics };
569
+ }
570
+ const normalized = ensureGeneralTopic(normalizeTopics(parsed.topics));
571
+ const validationError = validateTopics(normalized);
572
+ if (validationError) {
573
+ debugLog(`readProjectTopics: invalid ${configPath}: ${validationError}`);
574
+ return { source: "default", topics: builtinTopics };
575
+ }
576
+ return { source: "custom", topics: normalized, domain: typeof parsed.domain === "string" ? parsed.domain : undefined };
577
+ }
578
+ export function readPinnedTopics(phrenPath, project) {
579
+ const configPath = topicConfigPath(phrenPath, project);
580
+ if (!configPath || !fs.existsSync(configPath))
581
+ return [];
582
+ const parsed = readJsonFile(configPath);
583
+ if (!parsed || !Array.isArray(parsed.pinnedTopics))
584
+ return [];
585
+ return dedupeTopics(normalizeTopics(parsed.pinnedTopics)).filter((topic) => topic.slug !== "general");
586
+ }
587
+ function writePinnedTopics(phrenPath, project, pinnedTopics) {
588
+ if (!isValidProjectName(project))
589
+ return { ok: false, error: `Invalid project: "${project}".` };
590
+ const configPath = topicConfigPath(phrenPath, project);
591
+ if (!configPath)
592
+ return { ok: false, error: `Invalid project path for "${project}".` };
593
+ const pinned = dedupeTopics(normalizeTopics(pinnedTopics)).filter((topic) => topic.slug !== "general");
594
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
595
+ withFileLock(configPath, () => {
596
+ const existing = readJsonFile(configPath);
597
+ const payload = {
598
+ version: 1,
599
+ topics: ensureGeneralTopic(normalizeTopics(Array.isArray(existing?.topics) ? existing.topics : getBuiltinTopics(phrenPath, project))),
600
+ pinnedTopics: pinned,
601
+ ...(typeof existing?.domain === "string" ? { domain: existing.domain } : {}),
602
+ };
603
+ const tmpPath = `${configPath}.tmp-${crypto.randomUUID()}`;
604
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2) + "\n");
605
+ fs.renameSync(tmpPath, configPath);
606
+ });
607
+ return { ok: true, pinnedTopics: pinned };
608
+ }
609
+ export function pinProjectTopicSuggestion(phrenPath, project, topic) {
610
+ const current = readPinnedTopics(phrenPath, project);
611
+ return writePinnedTopics(phrenPath, project, [...current, topic]);
612
+ }
613
+ export function unpinProjectTopicSuggestion(phrenPath, project, slug) {
614
+ const normalized = normalizeTopicSlug(slug);
615
+ const current = readPinnedTopics(phrenPath, project).filter((topic) => topic.slug !== normalized);
616
+ return writePinnedTopics(phrenPath, project, current);
617
+ }
618
+ export function writeProjectTopics(phrenPath, project, topics) {
619
+ if (!isValidProjectName(project))
620
+ return { ok: false, error: `Invalid project: "${project}".` };
621
+ const configPath = topicConfigPath(phrenPath, project);
622
+ if (!configPath)
623
+ return { ok: false, error: `Invalid project path for "${project}".` };
624
+ const normalized = ensureGeneralTopic(normalizeTopics(topics));
625
+ const validationError = validateTopics(normalized);
626
+ if (validationError)
627
+ return { ok: false, error: validationError };
628
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
629
+ withFileLock(configPath, () => {
630
+ const existing = readJsonFile(configPath);
631
+ const payload = {
632
+ version: 1,
633
+ topics: normalized,
634
+ ...(Array.isArray(existing?.pinnedTopics) ? { pinnedTopics: dedupeTopics(normalizeTopics(existing.pinnedTopics)).filter((topic) => topic.slug !== "general") } : {}),
635
+ ...(typeof existing?.domain === "string" ? { domain: existing.domain } : {}),
636
+ };
637
+ const tmpPath = `${configPath}.tmp-${crypto.randomUUID()}`;
638
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2) + "\n");
639
+ fs.renameSync(tmpPath, configPath);
640
+ });
641
+ return { ok: true, topics: normalized };
642
+ }
643
+ export function classifyTopicForText(text, topics) {
644
+ const lower = text.toLowerCase();
645
+ let bestTopic = topics.find((topic) => topic.slug === "general") ?? topics[topics.length - 1];
646
+ let bestScore = 0;
647
+ for (const topic of topics) {
648
+ if (topic.slug === "general")
649
+ continue;
650
+ let score = 0;
651
+ for (const keyword of topic.keywords) {
652
+ if (keyword && lower.includes(keyword))
653
+ score++;
654
+ }
655
+ if (score > bestScore) {
656
+ bestScore = score;
657
+ bestTopic = topic;
658
+ }
659
+ }
660
+ return bestTopic;
661
+ }
662
+ function topicDocHeader(project, topic) {
663
+ const lines = [
664
+ `# ${project} - ${topic.label}`,
665
+ "",
666
+ `<!-- phren:auto-topic slug=${topic.slug} -->`,
667
+ ];
668
+ if (topic.description)
669
+ lines.push(`<!-- phren:topic-description ${topic.description.replace(/-->/g, "").trim()} -->`);
670
+ lines.push("");
671
+ return lines.join("\n");
672
+ }
673
+ function normalizeBullet(line) {
674
+ return line.replace(/<!--.*?-->/g, "").replace(/^-\s+/, "").trim().toLowerCase();
675
+ }
676
+ function collectArchivedBulletsRecursively(dirPath) {
677
+ const bullets = new Set();
678
+ if (!fs.existsSync(dirPath))
679
+ return bullets;
680
+ const stack = [dirPath];
681
+ while (stack.length > 0) {
682
+ const current = stack.pop();
683
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
684
+ const fullPath = path.join(current, entry.name);
685
+ if (entry.isDirectory()) {
686
+ stack.push(fullPath);
687
+ continue;
688
+ }
689
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
690
+ continue;
691
+ const content = fs.readFileSync(fullPath, "utf8");
692
+ for (const line of content.split("\n")) {
693
+ if (!line.startsWith("- "))
694
+ continue;
695
+ const normalized = normalizeBullet(line);
696
+ if (normalized)
697
+ bullets.add(normalized);
698
+ }
699
+ }
700
+ }
701
+ return bullets;
702
+ }
703
+ export function appendArchivedEntriesToTopicDoc(filePath, project, topic, entries) {
704
+ if (entries.length === 0)
705
+ return;
706
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
707
+ withFileLock(filePath, () => {
708
+ let existing = "";
709
+ if (fs.existsSync(filePath))
710
+ existing = fs.readFileSync(filePath, "utf8");
711
+ else
712
+ existing = topicDocHeader(project, topic);
713
+ const grouped = new Map();
714
+ for (const entry of entries) {
715
+ const bucket = grouped.get(entry.date) ?? [];
716
+ bucket.push(entry);
717
+ grouped.set(entry.date, bucket);
718
+ }
719
+ const sections = [];
720
+ for (const [date, groupedEntries] of [...grouped.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
721
+ sections.push(`## Archived ${date}`, "");
722
+ for (const entry of groupedEntries) {
723
+ sections.push(entry.bullet);
724
+ if (entry.citation)
725
+ sections.push(entry.citation);
726
+ }
727
+ sections.push("");
728
+ }
729
+ const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
730
+ fs.writeFileSync(tmpPath, existing.trimEnd() + "\n\n" + sections.join("\n"));
731
+ fs.renameSync(tmpPath, filePath);
732
+ });
733
+ }
734
+ export function ensureTopicReferenceDoc(phrenPath, project, topic) {
735
+ const filePath = topicReferencePath(phrenPath, project, topic.slug);
736
+ if (!filePath)
737
+ return { ok: false, error: `Invalid topic doc path for "${topic.slug}".` };
738
+ try {
739
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
740
+ if (!fs.existsSync(filePath)) {
741
+ withFileLock(filePath, () => {
742
+ if (fs.existsSync(filePath))
743
+ return;
744
+ const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
745
+ fs.writeFileSync(tmpPath, topicDocHeader(project, topic));
746
+ fs.renameSync(tmpPath, filePath);
747
+ });
748
+ }
749
+ return { ok: true, path: filePath };
750
+ }
751
+ catch (err) {
752
+ return { ok: false, error: errorMessage(err) };
753
+ }
754
+ }
755
+ function parseLegacyTopicEntries(content, project) {
756
+ const lines = content.split("\n");
757
+ const firstNonEmpty = lines.find((line) => line.trim().length > 0);
758
+ if (!firstNonEmpty)
759
+ return { slug: "general", error: "empty file" };
760
+ const headingMatch = firstNonEmpty.match(new RegExp(`^#\\s+${project.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+-\\s+(.+)$`, "i"));
761
+ const fallbackSlug = headingMatch ? normalizeTopicSlug(headingMatch[1]) : "general";
762
+ if (!headingMatch)
763
+ return { slug: fallbackSlug, error: "missing auto-archive heading" };
764
+ const entries = [];
765
+ let currentDate = "";
766
+ for (let i = 0; i < lines.length; i++) {
767
+ const line = lines[i];
768
+ if (line.trim().length === 0 || line === firstNonEmpty)
769
+ continue;
770
+ const markerMatch = line.match(AUTO_TOPIC_MARKER_RE);
771
+ if (markerMatch)
772
+ continue;
773
+ const archivedHeading = line.match(ARCHIVED_SECTION_RE);
774
+ if (archivedHeading) {
775
+ currentDate = archivedHeading[1];
776
+ continue;
777
+ }
778
+ if (!currentDate)
779
+ return { slug: fallbackSlug, error: "content exists before archived sections" };
780
+ if (!line.startsWith("- ")) {
781
+ if (/^\s*<!--\s*phren:topic-description\b/.test(line))
782
+ continue;
783
+ return { slug: fallbackSlug, error: "contains non-archived prose" };
784
+ }
785
+ const next = lines[i + 1] || "";
786
+ const hasCitation = /^\s*<!--\s*phren:cite\s+\{.*\}\s*-->/.test(next);
787
+ entries.push({
788
+ date: currentDate,
789
+ bullet: line,
790
+ citation: hasCitation ? next : undefined,
791
+ });
792
+ if (hasCitation)
793
+ i++;
794
+ }
795
+ if (entries.length === 0)
796
+ return { slug: fallbackSlug, error: "no archived bullet entries" };
797
+ return { slug: fallbackSlug, entries };
798
+ }
799
+ function readReferenceMarkdownFiles(referenceDir) {
800
+ if (!fs.existsSync(referenceDir))
801
+ return [];
802
+ const files = [];
803
+ const stack = [referenceDir];
804
+ while (stack.length > 0) {
805
+ const current = stack.pop();
806
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
807
+ const fullPath = path.join(current, entry.name);
808
+ if (entry.isDirectory()) {
809
+ stack.push(fullPath);
810
+ continue;
811
+ }
812
+ if (entry.isFile() && entry.name.endsWith(".md"))
813
+ files.push(fullPath);
814
+ }
815
+ }
816
+ return files.sort();
817
+ }
818
+ function relativeToProject(projectDir, filePath) {
819
+ return path.relative(projectDir, filePath).replace(/\\/g, "/");
820
+ }
821
+ function docTitleFromContent(filePath, content) {
822
+ const heading = content.split("\n").find((line) => /^#\s+/.test(line));
823
+ if (heading)
824
+ return heading.replace(/^#\s+/, "").trim();
825
+ return path.basename(filePath, ".md");
826
+ }
827
+ function entryCountFromContent(content) {
828
+ return content.split("\n").filter((line) => line.startsWith("- ")).length;
829
+ }
830
+ function safeStatIso(filePath) {
831
+ try {
832
+ return new Date(fs.statSync(filePath).mtimeMs).toISOString();
833
+ }
834
+ catch {
835
+ return "";
836
+ }
837
+ }
838
+ export function listProjectTopicDocs(phrenPath, project, topics) {
839
+ const projectDir = projectDirPath(phrenPath, project);
840
+ if (!projectDir)
841
+ return [];
842
+ const topicList = topics ?? readProjectTopics(phrenPath, project).topics;
843
+ return topicList.map((topic) => {
844
+ const filePath = topicReferencePath(phrenPath, project, topic.slug);
845
+ const exists = Boolean(filePath && fs.existsSync(filePath));
846
+ let entryCount = 0;
847
+ if (filePath && exists) {
848
+ entryCount = entryCountFromContent(fs.readFileSync(filePath, "utf8"));
849
+ }
850
+ return {
851
+ slug: topic.slug,
852
+ label: topic.label,
853
+ file: topicReferenceRelativePath(topic.slug),
854
+ path: filePath || topicReferenceRelativePath(topic.slug),
855
+ exists,
856
+ autoManaged: true,
857
+ entryCount,
858
+ lastModified: filePath && exists ? safeStatIso(filePath) : "",
859
+ };
860
+ });
861
+ }
862
+ export function listProjectReferenceDocs(phrenPath, project, topics) {
863
+ const projectDir = projectDirPath(phrenPath, project);
864
+ if (!projectDir)
865
+ return { topicDocs: [], otherDocs: [] };
866
+ const topicDocs = listProjectTopicDocs(phrenPath, project, topics);
867
+ const referenceDir = safeProjectPath(phrenPath, project, "reference");
868
+ if (!referenceDir || !fs.existsSync(referenceDir))
869
+ return { topicDocs, otherDocs: [] };
870
+ const otherDocs = [];
871
+ for (const filePath of readReferenceMarkdownFiles(referenceDir)) {
872
+ const rel = relativeToProject(projectDir, filePath);
873
+ if (rel.startsWith("reference/topics/"))
874
+ continue;
875
+ const content = fs.readFileSync(filePath, "utf8");
876
+ const marker = content.split("\n").find((line) => AUTO_TOPIC_MARKER_RE.test(line));
877
+ otherDocs.push({
878
+ file: rel,
879
+ path: filePath,
880
+ title: docTitleFromContent(filePath, content),
881
+ autoManaged: Boolean(marker),
882
+ entryCount: entryCountFromContent(content),
883
+ lastModified: safeStatIso(filePath),
884
+ });
885
+ }
886
+ return { topicDocs, otherDocs };
887
+ }
888
+ export function listLegacyTopicDocs(phrenPath, project) {
889
+ const projectDir = projectDirPath(phrenPath, project);
890
+ const referenceDir = safeProjectPath(phrenPath, project, "reference");
891
+ if (!projectDir || !referenceDir || !fs.existsSync(referenceDir))
892
+ return [];
893
+ const files = fs.readdirSync(referenceDir, { withFileTypes: true })
894
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
895
+ .map((entry) => path.join(referenceDir, entry.name))
896
+ .sort();
897
+ return files.map((filePath) => {
898
+ const content = fs.readFileSync(filePath, "utf8");
899
+ const parsed = parseLegacyTopicEntries(content, project);
900
+ return {
901
+ slug: path.basename(filePath, ".md"),
902
+ file: relativeToProject(projectDir, filePath),
903
+ path: filePath,
904
+ title: docTitleFromContent(filePath, content),
905
+ autoManaged: parsed && !("error" in parsed),
906
+ entryCount: entryCountFromContent(content),
907
+ lastModified: safeStatIso(filePath),
908
+ eligible: !("error" in parsed),
909
+ reason: "error" in parsed ? parsed.error : undefined,
910
+ };
911
+ });
912
+ }
913
+ function tokenizeSuggestionTerms(text) {
914
+ const words = text
915
+ .toLowerCase()
916
+ .replace(/[^\w\s-]/g, " ")
917
+ .split(/\s+/)
918
+ .map((word) => word.trim())
919
+ .filter((word) => word.length > 2 && !STOP_WORDS.has(word));
920
+ const terms = [...words];
921
+ for (let i = 0; i < words.length - 1; i++) {
922
+ const bigram = `${words[i]} ${words[i + 1]}`;
923
+ if (!STOP_WORDS.has(words[i]) && !STOP_WORDS.has(words[i + 1]))
924
+ terms.push(bigram);
925
+ }
926
+ return terms;
927
+ }
928
+ function collectSuggestionCorpus(phrenPath, project) {
929
+ const parts = [];
930
+ for (const file of ["CLAUDE.md", "summary.md", "FINDINGS.md"]) {
931
+ const filePath = safeProjectPath(phrenPath, project, file);
932
+ if (!filePath || !fs.existsSync(filePath))
933
+ continue;
934
+ const content = fs.readFileSync(filePath, "utf8");
935
+ if (file === "FINDINGS.md") {
936
+ const recentBullets = content.split("\n").filter((line) => line.startsWith("- ")).slice(-10).join("\n");
937
+ parts.push(recentBullets);
938
+ continue;
939
+ }
940
+ parts.push(content);
941
+ }
942
+ const generalDoc = topicReferencePath(phrenPath, project, "general");
943
+ if (generalDoc && fs.existsSync(generalDoc))
944
+ parts.push(fs.readFileSync(generalDoc, "utf8"));
945
+ for (const legacyDoc of listLegacyTopicDocs(phrenPath, project)) {
946
+ if (!legacyDoc.eligible)
947
+ continue;
948
+ try {
949
+ parts.push(fs.readFileSync(legacyDoc.path, "utf8"));
950
+ }
951
+ catch { }
952
+ }
953
+ return parts.join("\n");
954
+ }
955
+ export function suggestTopics(phrenPath, project, topics) {
956
+ const currentTopics = topics ?? readProjectTopics(phrenPath, project).topics;
957
+ const pinnedTopics = readPinnedTopics(phrenPath, project);
958
+ if (pinnedTopics.length > 0) {
959
+ return pinnedTopics.slice(0, SUGGESTION_LIMIT).map((topic) => ({
960
+ slug: topic.slug,
961
+ label: topic.label,
962
+ description: topic.description,
963
+ keywords: [...topic.keywords],
964
+ source: "pinned",
965
+ reason: "Pinned topic suggestion (manual override).",
966
+ confidence: 1,
967
+ }));
968
+ }
969
+ const taken = new Set();
970
+ const takenKeywords = new Set();
971
+ for (const topic of currentTopics) {
972
+ taken.add(topic.slug);
973
+ taken.add(topic.label.toLowerCase());
974
+ for (const keyword of topic.keywords)
975
+ takenKeywords.add(keyword);
976
+ }
977
+ const signal = buildTopicContentSignal(phrenPath, project);
978
+ const corpus = `${signal.corpus}\n${collectSuggestionCorpus(phrenPath, project)}`;
979
+ const corpusLower = corpus.toLowerCase();
980
+ const keywordSignal = extractKeywords(corpus);
981
+ const scoreMap = new Map();
982
+ for (const term of tokenizeSuggestionTerms(`${corpus}\n${keywordSignal}`)) {
983
+ const normalized = normalizeKeyword(term);
984
+ if (!normalized || normalized.length < 3)
985
+ continue;
986
+ if (taken.has(normalized) || takenKeywords.has(normalized))
987
+ continue;
988
+ const current = scoreMap.get(normalized) ?? 0;
989
+ scoreMap.set(normalized, current + 1);
990
+ }
991
+ const suggestions = [];
992
+ for (const builtin of getBuiltinTopics(phrenPath, project)) {
993
+ if (builtin.slug === "general" || taken.has(builtin.slug))
994
+ continue;
995
+ const score = builtin.keywords.reduce((count, keyword) => count + (corpusLower.includes(keyword) ? 1 : 0), 0);
996
+ if (score <= 0)
997
+ continue;
998
+ suggestions.push({
999
+ slug: builtin.slug,
1000
+ label: builtin.label,
1001
+ description: builtin.description,
1002
+ keywords: [...builtin.keywords.slice(0, 6)],
1003
+ source: "builtin",
1004
+ reason: "Matches repeated project language already present in this project.",
1005
+ confidence: confidenceFromScore(score),
1006
+ });
1007
+ }
1008
+ for (const [term, score] of [...scoreMap.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) {
1009
+ if (suggestions.length >= SUGGESTION_LIMIT)
1010
+ break;
1011
+ if (score < 2)
1012
+ continue;
1013
+ const slug = normalizeTopicSlug(term);
1014
+ if (!slug || taken.has(slug) || takenKeywords.has(term))
1015
+ continue;
1016
+ const keywords = Array.from(new Set(term.split(" ").concat(slug.split("-"))))
1017
+ .map((keyword) => normalizeKeyword(keyword))
1018
+ .filter((keyword) => keyword.length > 2)
1019
+ .slice(0, 6);
1020
+ if (keywords.length === 0)
1021
+ continue;
1022
+ suggestions.unshift({
1023
+ slug,
1024
+ label: titleCaseLabel(term),
1025
+ description: "Suggested from repeated domain language found in project context and archived findings.",
1026
+ keywords,
1027
+ source: "heuristic",
1028
+ reason: "Repeated project-specific language suggests this deserves its own topic.",
1029
+ confidence: confidenceFromScore(score),
1030
+ });
1031
+ taken.add(slug);
1032
+ }
1033
+ const deduped = [];
1034
+ const seen = new Set();
1035
+ for (const suggestion of suggestions) {
1036
+ if (seen.has(suggestion.slug))
1037
+ continue;
1038
+ seen.add(suggestion.slug);
1039
+ deduped.push(suggestion);
1040
+ if (deduped.length >= SUGGESTION_LIMIT)
1041
+ break;
1042
+ }
1043
+ return deduped;
1044
+ }
1045
+ export const suggestProjectTopics = suggestTopics;
1046
+ export function getProjectTopicsResponse(phrenPath, project) {
1047
+ const { source, topics } = readProjectTopics(phrenPath, project);
1048
+ return {
1049
+ source,
1050
+ topics,
1051
+ suggestions: suggestTopics(phrenPath, project, topics),
1052
+ pinnedTopics: readPinnedTopics(phrenPath, project),
1053
+ legacyDocs: listLegacyTopicDocs(phrenPath, project),
1054
+ topicDocs: listProjectTopicDocs(phrenPath, project, topics),
1055
+ };
1056
+ }
1057
+ export function resolveReferenceContentPath(phrenPath, project, file) {
1058
+ if (!isValidProjectName(project) || !file || file.includes("\0"))
1059
+ return null;
1060
+ if (!file.endsWith(".md"))
1061
+ return null;
1062
+ const filePath = safeProjectPath(phrenPath, project, file);
1063
+ if (!filePath)
1064
+ return null;
1065
+ const referenceRoot = safeProjectPath(phrenPath, project, "reference");
1066
+ if (!referenceRoot)
1067
+ return null;
1068
+ const normalizedRoot = referenceRoot + path.sep;
1069
+ if (filePath !== referenceRoot && !filePath.startsWith(normalizedRoot))
1070
+ return null;
1071
+ return filePath;
1072
+ }
1073
+ export function readReferenceContent(phrenPath, project, file) {
1074
+ const filePath = resolveReferenceContentPath(phrenPath, project, file);
1075
+ if (!filePath)
1076
+ return { ok: false, error: "Invalid project or reference file" };
1077
+ if (!fs.existsSync(filePath))
1078
+ return { ok: false, error: `File not found: ${file}` };
1079
+ return { ok: true, content: fs.readFileSync(filePath, "utf8") };
1080
+ }
1081
+ export function reclassifyLegacyTopicDocs(phrenPath, project) {
1082
+ const { topics } = readProjectTopics(phrenPath, project);
1083
+ const referenceDir = safeProjectPath(phrenPath, project, "reference");
1084
+ if (!referenceDir || !fs.existsSync(referenceDir))
1085
+ return { movedFiles: 0, movedEntries: 0, skipped: [] };
1086
+ const skipped = [];
1087
+ const archivedBullets = collectArchivedBulletsRecursively(path.join(referenceDir, "topics"));
1088
+ let movedFiles = 0;
1089
+ let movedEntries = 0;
1090
+ for (const legacyDoc of listLegacyTopicDocs(phrenPath, project)) {
1091
+ const result = readReferenceContent(phrenPath, project, legacyDoc.file);
1092
+ if (!result.ok) {
1093
+ skipped.push({ file: legacyDoc.file, reason: result.error });
1094
+ continue;
1095
+ }
1096
+ const parsed = parseLegacyTopicEntries(result.content, project);
1097
+ if ("error" in parsed) {
1098
+ skipped.push({ file: legacyDoc.file, reason: parsed.error });
1099
+ continue;
1100
+ }
1101
+ const grouped = new Map();
1102
+ for (const entry of parsed.entries) {
1103
+ const normalized = normalizeBullet(entry.bullet);
1104
+ if (normalized && archivedBullets.has(normalized))
1105
+ continue;
1106
+ const targetTopic = classifyTopicForText(entry.bullet, topics);
1107
+ const bucket = grouped.get(targetTopic.slug) ?? [];
1108
+ bucket.push(entry);
1109
+ grouped.set(targetTopic.slug, bucket);
1110
+ if (normalized)
1111
+ archivedBullets.add(normalized);
1112
+ }
1113
+ if (grouped.size === 0) {
1114
+ skipped.push({ file: legacyDoc.file, reason: "all entries already archived elsewhere" });
1115
+ continue;
1116
+ }
1117
+ try {
1118
+ for (const [slug, entries] of grouped) {
1119
+ const topic = topics.find((item) => item.slug === slug) ?? topics.find((item) => item.slug === "general");
1120
+ const targetPath = topicReferencePath(phrenPath, project, slug);
1121
+ if (!targetPath)
1122
+ throw new Error(`Invalid target topic path for "${slug}"`);
1123
+ appendArchivedEntriesToTopicDoc(targetPath, project, topic, entries);
1124
+ movedEntries += entries.length;
1125
+ }
1126
+ fs.unlinkSync(legacyDoc.path);
1127
+ movedFiles++;
1128
+ }
1129
+ catch (err) {
1130
+ skipped.push({ file: legacyDoc.file, reason: errorMessage(err) });
1131
+ }
1132
+ }
1133
+ return { movedFiles, movedEntries, skipped };
1134
+ }