@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,472 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as crypto from "crypto";
4
+ import { debugLog, appendAuditLog, phrenOk, phrenErr, PhrenError } from "./shared.js";
5
+ import { normalizeMemoryScope } from "./shared.js";
6
+ import { withFileLock } from "./shared-governance.js";
7
+ import { isValidProjectName, safeProjectPath } from "./utils.js";
8
+ import { getMachineName } from "./machine-identity.js";
9
+ import { buildCitationComment, buildSourceComment, getHeadCommit, getRepoRoot, inferCitationLocation, isFindingProvenanceSource, } from "./content-citation.js";
10
+ import { isDuplicateFinding, scanForSecrets, normalizeObservationTags, resolveCoref, detectConflicts, extractDynamicEntities } from "./content-dedup.js";
11
+ import { validateFindingsFormat, validateFinding } from "./content-validate.js";
12
+ import { countActiveFindings, autoArchiveToReference } from "./content-archive.js";
13
+ import { resolveAutoFindingTaskItem, resolveFindingTaskReference, resolveFindingSessionId, } from "./finding-context.js";
14
+ import { buildLifecycleComments, parseFindingLifecycle, stripLifecycleComments, } from "./finding-lifecycle.js";
15
+ import { METADATA_REGEX, } from "./content-metadata.js";
16
+ /** Default cap for active findings before auto-archiving is triggered. */
17
+ const DEFAULT_FINDINGS_CAP = 20;
18
+ const LIFECYCLE_ANNOTATION_RE = METADATA_REGEX.lifecycleAnnotation;
19
+ function buildFindingCitation(citationInput, nowIso, inferredRepo, headCommit) {
20
+ const citation = {
21
+ created_at: nowIso ?? new Date().toISOString(),
22
+ repo: citationInput?.repo || inferredRepo,
23
+ file: citationInput?.file,
24
+ line: citationInput?.line,
25
+ commit: citationInput?.commit || (citationInput?.repo || inferredRepo ? headCommit ?? getHeadCommit(citationInput?.repo || inferredRepo || "") : undefined),
26
+ supersedes: citationInput?.supersedes,
27
+ task_item: citationInput?.task_item,
28
+ };
29
+ if (citation.repo && citation.commit && (!citation.file || !citation.line)) {
30
+ const inferred = inferCitationLocation(citation.repo, citation.commit);
31
+ citation.file = citation.file || inferred.file;
32
+ citation.line = citation.line || inferred.line;
33
+ }
34
+ return citation;
35
+ }
36
+ function resolveInferredCitationRepo(citationInput) {
37
+ if (citationInput?.repo)
38
+ return citationInput.repo;
39
+ if (citationInput?.file) {
40
+ const fileDir = path.dirname(citationInput.file);
41
+ return getRepoRoot(fileDir);
42
+ }
43
+ return undefined;
44
+ }
45
+ function detectFindingModel() {
46
+ const candidates = [
47
+ (process.env.PHREN_MODEL),
48
+ process.env.OPENAI_MODEL,
49
+ process.env.CLAUDE_MODEL,
50
+ (process.env.PHREN_LLM_MODEL),
51
+ process.env.MODEL,
52
+ ];
53
+ return candidates.find((value) => typeof value === "string" && value.trim())?.trim();
54
+ }
55
+ function detectFindingTool() {
56
+ const candidates = [
57
+ (process.env.PHREN_TOOL),
58
+ (process.env.PHREN_HOOK_TOOL),
59
+ ];
60
+ return candidates.find((value) => typeof value === "string" && value.trim())?.trim();
61
+ }
62
+ function detectFindingProvenanceSource(explicitSource) {
63
+ if (explicitSource)
64
+ return explicitSource;
65
+ const envSource = (process.env.PHREN_FINDING_SOURCE)?.trim().toLowerCase();
66
+ if (isFindingProvenanceSource(envSource))
67
+ return envSource;
68
+ if ((process.env.PHREN_CONSOLIDATION) === "1")
69
+ return "consolidation";
70
+ if ((process.env.PHREN_AUTO_EXTRACT) === "1")
71
+ return "extract";
72
+ if ((process.env.PHREN_HOOK_TOOL))
73
+ return "hook";
74
+ if ((process.env.PHREN_ACTOR || process.env.PHREN_ACTOR)?.trim())
75
+ return "agent";
76
+ return "human";
77
+ }
78
+ function buildFindingSource(sessionId, explicitSource, scope) {
79
+ const actor = (process.env.PHREN_ACTOR || process.env.PHREN_ACTOR)?.trim() || undefined;
80
+ const source = {
81
+ source: detectFindingProvenanceSource(explicitSource),
82
+ machine: getMachineName(),
83
+ actor,
84
+ tool: detectFindingTool(),
85
+ model: detectFindingModel(),
86
+ session_id: sessionId,
87
+ scope: normalizeMemoryScope(scope),
88
+ };
89
+ return source;
90
+ }
91
+ function resolveFindingCitationInput(phrenPath, project, citationInput) {
92
+ const resolved = citationInput ? { ...citationInput } : {};
93
+ if (citationInput?.task_item) {
94
+ const taskResolution = resolveFindingTaskReference(phrenPath, project, citationInput.task_item);
95
+ if (taskResolution.error) {
96
+ return phrenErr(taskResolution.error, PhrenError.VALIDATION_ERROR);
97
+ }
98
+ if (taskResolution.stableId) {
99
+ resolved.task_item = taskResolution.stableId;
100
+ }
101
+ }
102
+ else {
103
+ const taskItem = resolveAutoFindingTaskItem(phrenPath, project);
104
+ if (taskItem) {
105
+ resolved.task_item = taskItem;
106
+ }
107
+ }
108
+ return phrenOk(Object.keys(resolved).length > 0 ? resolved : undefined);
109
+ }
110
+ function prepareFinding(learning, project, fullHistory, extraAnnotations, citationInput, source, nowIso, inferredRepo, headCommit, phrenPath) {
111
+ const secretType = scanForSecrets(learning);
112
+ if (secretType) {
113
+ return { status: "rejected", reason: `Contains ${secretType}` };
114
+ }
115
+ const today = (nowIso ?? new Date().toISOString()).slice(0, 10);
116
+ const { text: tagNormalized, warning: tagWarning } = normalizeObservationTags(learning);
117
+ const normalizedLearning = resolveCoref(tagNormalized, {
118
+ project,
119
+ file: citationInput?.file,
120
+ });
121
+ const fid = crypto.randomBytes(4).toString("hex");
122
+ const fidComment = `<!-- fid:${fid} -->`;
123
+ const createdComment = `<!-- created: ${today} -->`;
124
+ const sourceComment = source ? buildSourceComment(source) : "";
125
+ let lifecycle = { status: "active", status_updated: today };
126
+ let bullet = `${normalizedLearning.startsWith("- ") ? normalizedLearning : `- ${normalizedLearning}`} ${fidComment} ${createdComment}`;
127
+ if (sourceComment)
128
+ bullet += ` ${sourceComment}`;
129
+ if (isDuplicateFinding(fullHistory, bullet)) {
130
+ return { status: "duplicate" };
131
+ }
132
+ const existingBullets = fullHistory.split("\n").filter((l) => l.startsWith("- "));
133
+ const dynamicEntities = phrenPath ? extractDynamicEntities(phrenPath, project) : undefined;
134
+ const conflicts = detectConflicts(normalizedLearning, existingBullets, dynamicEntities);
135
+ if (conflicts.length > 0) {
136
+ const snippet = conflicts[0].replace(/^-\s+/, "").replace(/<!--.*?-->/g, "").trim().slice(0, 80);
137
+ lifecycle = {
138
+ status: "contradicted",
139
+ status_updated: today,
140
+ status_reason: "conflicts_with",
141
+ status_ref: snippet,
142
+ };
143
+ bullet += ` <!-- conflicts_with: "${snippet}" --> <!-- phren:contradicts "${snippet}" -->`;
144
+ debugLog(`add_finding: conflict detected for "${project}": ${snippet}`);
145
+ }
146
+ if (extraAnnotations && extraAnnotations.length > 0) {
147
+ const lifecycleFromExtra = parseFindingLifecycle(`- lifecycle ${extraAnnotations.join(" ")}`);
148
+ if (lifecycleFromExtra.status !== "active" ||
149
+ lifecycleFromExtra.status_reason ||
150
+ lifecycleFromExtra.status_ref ||
151
+ lifecycleFromExtra.status_updated) {
152
+ lifecycle = {
153
+ ...lifecycle,
154
+ ...lifecycleFromExtra,
155
+ status_updated: lifecycleFromExtra.status_updated ?? lifecycle.status_updated ?? today,
156
+ };
157
+ }
158
+ const existing = new Set([...bullet.matchAll(METADATA_REGEX.conflictsWithAll)].map((m) => m[0]));
159
+ for (const annotation of extraAnnotations) {
160
+ if (!annotation.startsWith("<!--"))
161
+ continue;
162
+ if (LIFECYCLE_ANNOTATION_RE.test(annotation))
163
+ continue;
164
+ if (existing.has(annotation))
165
+ continue;
166
+ bullet += ` ${annotation}`;
167
+ existing.add(annotation);
168
+ }
169
+ }
170
+ bullet += ` ${buildLifecycleComments(lifecycle, today)}`;
171
+ const citation = buildFindingCitation(citationInput, nowIso, inferredRepo, headCommit);
172
+ return {
173
+ status: "added",
174
+ finding: {
175
+ original: learning,
176
+ normalized: normalizedLearning,
177
+ bullet,
178
+ citationComment: ` ${buildCitationComment(citation)}`,
179
+ tagWarning,
180
+ },
181
+ };
182
+ }
183
+ function insertFindingIntoContent(content, today, bullet, citationComment) {
184
+ const todayHeader = `## ${today}`;
185
+ // Use positional insertion (not String.replace) to avoid: (1) special $& replacement patterns
186
+ // if bullet contains $ chars, and (2) inserting inside an archived <details> block when a
187
+ // duplicate date header exists from a prior consolidation run.
188
+ const idx = content.indexOf(todayHeader);
189
+ if (idx !== -1) {
190
+ const insertAt = idx + todayHeader.length;
191
+ return content.slice(0, insertAt) + `\n\n${bullet}\n${citationComment}` + content.slice(insertAt);
192
+ }
193
+ const firstHeadingMatch = content.match(/^## \d{4}-\d{2}-\d{2}/m);
194
+ if (firstHeadingMatch?.index != null) {
195
+ return (content.slice(0, firstHeadingMatch.index) +
196
+ `${todayHeader}\n\n${bullet}\n${citationComment}\n\n` +
197
+ content.slice(firstHeadingMatch.index));
198
+ }
199
+ return content.trimEnd() + `\n\n## ${today}\n\n${bullet}\n${citationComment}\n`;
200
+ }
201
+ export function upsertCanonical(phrenPath, project, memory) {
202
+ if (!isValidProjectName(project))
203
+ return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
204
+ const resolvedDir = safeProjectPath(phrenPath, project);
205
+ if (!resolvedDir || !fs.existsSync(resolvedDir))
206
+ return phrenErr(`Project "${project}" not found in phren.`, PhrenError.PROJECT_NOT_FOUND);
207
+ const canonicalPath = path.join(resolvedDir, "CANONICAL_MEMORIES.md");
208
+ const today = new Date().toISOString().slice(0, 10);
209
+ const bullet = memory.startsWith("- ") ? memory : `- ${memory}`;
210
+ withFileLock(canonicalPath, () => {
211
+ if (!fs.existsSync(canonicalPath)) {
212
+ fs.writeFileSync(canonicalPath, `# ${project} Canonical Memories\n\n## Pinned\n\n${bullet} _(pinned ${today})_\n`);
213
+ }
214
+ else {
215
+ const existing = fs.readFileSync(canonicalPath, "utf8");
216
+ const line = `${bullet} _(pinned ${today})_`;
217
+ if (!existing.includes(bullet)) {
218
+ const updated = existing.includes("## Pinned")
219
+ ? existing.replace("## Pinned", `## Pinned\n\n${line}`)
220
+ : `${existing.trimEnd()}\n\n## Pinned\n\n${line}\n`;
221
+ const content = updated.endsWith("\n") ? updated : updated + "\n";
222
+ const tmpPath = canonicalPath + `.tmp-${crypto.randomUUID()}`;
223
+ fs.writeFileSync(tmpPath, content);
224
+ fs.renameSync(tmpPath, canonicalPath);
225
+ }
226
+ }
227
+ });
228
+ appendAuditLog(phrenPath, "pin_memory", `project=${project} memory=${JSON.stringify(memory)}`);
229
+ return phrenOk(`Pinned canonical memory in ${project}.`);
230
+ }
231
+ export function addFindingToFile(phrenPath, project, learning, citationInput, opts) {
232
+ const findingError = validateFinding(learning);
233
+ if (findingError)
234
+ return phrenErr(findingError, PhrenError.EMPTY_INPUT);
235
+ if (!isValidProjectName(project))
236
+ return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
237
+ const resolvedDir = safeProjectPath(phrenPath, project);
238
+ if (!resolvedDir)
239
+ return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
240
+ const learningsPath = path.join(resolvedDir, "FINDINGS.md");
241
+ // Secret/PII scan — reject before anything else (before existence check, before lock)
242
+ const nowIso = new Date().toISOString();
243
+ const today = nowIso.slice(0, 10);
244
+ const resolvedCitationInputResult = resolveFindingCitationInput(phrenPath, project, citationInput);
245
+ if (!resolvedCitationInputResult.ok)
246
+ return resolvedCitationInputResult;
247
+ const resolvedCitationInput = resolvedCitationInputResult.data;
248
+ const effectiveSessionId = resolveFindingSessionId(phrenPath, project, opts?.sessionId);
249
+ const source = buildFindingSource(effectiveSessionId, opts?.source, opts?.scope);
250
+ const inferredRepo = resolveInferredCitationRepo(resolvedCitationInput);
251
+ const headCommit = inferredRepo ? getHeadCommit(inferredRepo) : undefined;
252
+ const supersedesText = resolvedCitationInput?.supersedes;
253
+ const normalizedForSupersedes = supersedesText
254
+ ? resolveCoref(normalizeObservationTags(learning).text, {
255
+ project,
256
+ file: resolvedCitationInput?.file,
257
+ })
258
+ : undefined;
259
+ // Reject secrets before anything else — even if project doesn't exist yet
260
+ const earlySecretType = scanForSecrets(learning);
261
+ if (earlySecretType) {
262
+ return phrenErr(`Rejected: finding appears to contain a secret (${earlySecretType}). Strip credentials before saving.`, PhrenError.VALIDATION_ERROR);
263
+ }
264
+ // Check project dir existence before withFileLock (which would create the dir via mkdirSync)
265
+ if (!fs.existsSync(resolvedDir))
266
+ return phrenErr(`Project "${project}" does not exist.`, PhrenError.INVALID_PROJECT_NAME);
267
+ const result = withFileLock(learningsPath, () => {
268
+ const preparedForNewFile = prepareFinding(learning, project, "", opts?.extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
269
+ if (!fs.existsSync(learningsPath)) {
270
+ if (preparedForNewFile.status === "rejected") {
271
+ return phrenErr(`Rejected: finding appears to contain a secret (${preparedForNewFile.reason.replace(/^Contains /, "")}). Strip credentials before saving.`, PhrenError.VALIDATION_ERROR);
272
+ }
273
+ if (preparedForNewFile.status === "duplicate") {
274
+ return phrenOk(`Skipped duplicate finding for "${project}": already exists with similar wording.`);
275
+ }
276
+ const newContent = `# ${project} Findings\n\n## ${today}\n\n${preparedForNewFile.finding.bullet}\n${preparedForNewFile.finding.citationComment}\n`;
277
+ fs.writeFileSync(learningsPath, newContent);
278
+ return phrenOk({
279
+ content: newContent,
280
+ citation: buildFindingCitation(resolvedCitationInput, nowIso, inferredRepo, headCommit),
281
+ tagWarning: preparedForNewFile.finding.tagWarning,
282
+ created: true,
283
+ bullet: preparedForNewFile.finding.bullet,
284
+ });
285
+ }
286
+ const content = fs.readFileSync(learningsPath, "utf8");
287
+ // When superseding, strip the old finding from history so dedup doesn't block the intentionally similar replacement.
288
+ // Skip the strip if new finding is identical to the superseded one (self-supersession should still be blocked by dedup).
289
+ const isSelfSupersession = supersedesText &&
290
+ learning.trim().toLowerCase().slice(0, 60) === supersedesText.trim().toLowerCase().slice(0, 60);
291
+ const historyForDedup = (supersedesText && !isSelfSupersession)
292
+ ? content.split("\n")
293
+ .filter(line => !line.startsWith("- ") || !line.toLowerCase().includes(supersedesText.slice(0, 40).toLowerCase()))
294
+ .join("\n")
295
+ : content;
296
+ const prepared = prepareFinding(learning, project, historyForDedup, opts?.extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
297
+ if (prepared.status === "rejected") {
298
+ return phrenErr(`Rejected: finding appears to contain a secret (${prepared.reason.replace(/^Contains /, "")}). Strip credentials before saving.`, PhrenError.VALIDATION_ERROR);
299
+ }
300
+ if (prepared.status === "duplicate") {
301
+ debugLog(`add_finding: skipped duplicate for "${project}": ${learning.slice(0, 80)}`);
302
+ return phrenOk(`Skipped duplicate finding for "${project}": already exists with similar wording.`);
303
+ }
304
+ const issues = validateFindingsFormat(content);
305
+ if (issues.length > 0) {
306
+ debugLog(`FINDINGS.md format warnings for "${project}": ${issues.join("; ")}`);
307
+ }
308
+ let updated = insertFindingIntoContent(content, today, prepared.finding.bullet, prepared.finding.citationComment);
309
+ if (supersedesText && normalizedForSupersedes) {
310
+ const lines = updated.split("\n");
311
+ const needle = supersedesText.slice(0, 60).toLowerCase().replace(/\s+/g, " ").trim();
312
+ for (let i = 0; i < lines.length; i++) {
313
+ if (!lines[i].startsWith("- "))
314
+ continue;
315
+ const lineText = lines[i].replace(/<!--.*?-->/g, "").replace(/^-\s+/, "").replace(/^\[[^\]]+\]\s+/, "").slice(0, 60).toLowerCase().replace(/\s+/g, " ").trim();
316
+ if (lineText === needle) {
317
+ // Remove any legacy and normalized lifecycle supersession metadata before re-appending.
318
+ lines[i] = lines[i].replace(METADATA_REGEX.stripSupersededByLegacy, "");
319
+ lines[i] = lines[i].replace(METADATA_REGEX.stripSupersededBy, "");
320
+ lines[i] = stripLifecycleComments(lines[i]);
321
+ const newFirst60 = normalizedForSupersedes.replace(/^-\s+/, "").slice(0, 60);
322
+ lines[i] =
323
+ `${lines[i]} <!-- phren:superseded_by "${newFirst60}" ${today} --> ` +
324
+ `${buildLifecycleComments({ status: "superseded", status_updated: today, status_reason: "superseded_by", status_ref: newFirst60 }, today)}`;
325
+ updated = lines.join("\n");
326
+ break;
327
+ }
328
+ }
329
+ // Also annotate the new finding bullet with phren:supersedes
330
+ const newLines = updated.split("\n");
331
+ for (let i = 0; i < newLines.length; i++) {
332
+ if (!newLines[i].startsWith("- "))
333
+ continue;
334
+ if (newLines[i].includes(prepared.finding.bullet.slice(0, 40))) {
335
+ if (!newLines[i].includes("phren:supersedes") && !newLines[i].includes("phren:supersedes")) {
336
+ const supersedesFirst60 = supersedesText.slice(0, 60);
337
+ newLines[i] = `${newLines[i]} <!-- phren:supersedes "${supersedesFirst60}" -->`;
338
+ }
339
+ updated = newLines.join("\n");
340
+ break;
341
+ }
342
+ }
343
+ }
344
+ const tmpPath = learningsPath + `.tmp-${crypto.randomUUID()}`;
345
+ fs.writeFileSync(tmpPath, updated);
346
+ fs.renameSync(tmpPath, learningsPath);
347
+ return phrenOk({
348
+ content: updated,
349
+ citation: buildFindingCitation(resolvedCitationInput, nowIso, inferredRepo, headCommit),
350
+ tagWarning: prepared.finding.tagWarning,
351
+ created: false,
352
+ bullet: prepared.finding.bullet,
353
+ });
354
+ });
355
+ if (!result.ok)
356
+ return result;
357
+ if (typeof result.data === "string")
358
+ return phrenOk(result.data);
359
+ appendAuditLog(phrenPath, "add_finding", `project=${project}${result.data.created ? " created=true" : ""} citation_commit=${result.data.citation.commit ?? "none"} citation_file=${result.data.citation.file ?? "none"}`);
360
+ const cap = Number.parseInt((process.env.PHREN_FINDINGS_CAP) || "", 10) || DEFAULT_FINDINGS_CAP;
361
+ const activeCount = countActiveFindings(result.data.content);
362
+ if (activeCount > cap) {
363
+ const archiveResult = autoArchiveToReference(phrenPath, project, cap);
364
+ if (archiveResult.ok && archiveResult.data > 0) {
365
+ debugLog(`Size cap: archived ${archiveResult.data} oldest entries for "${project}" (cap=${cap})`);
366
+ }
367
+ }
368
+ if (result.data.created) {
369
+ const createdMsg = `Created FINDINGS.md for "${project}" and added insight.`;
370
+ return phrenOk(result.data.tagWarning ? `${createdMsg} Warning: ${result.data.tagWarning}` : createdMsg);
371
+ }
372
+ const addedMsg = `Added finding to ${project}: ${result.data.bullet} (with citation metadata)`;
373
+ return phrenOk(result.data.tagWarning ? `${addedMsg} Warning: ${result.data.tagWarning}` : addedMsg);
374
+ }
375
+ export function addFindingsToFile(phrenPath, project, learnings, opts) {
376
+ if (!isValidProjectName(project))
377
+ return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
378
+ const resolvedDir = safeProjectPath(phrenPath, project);
379
+ if (!resolvedDir)
380
+ return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
381
+ const learningsPath = path.join(resolvedDir, "FINDINGS.md");
382
+ const today = new Date().toISOString().slice(0, 10);
383
+ const nowIso = new Date().toISOString();
384
+ const resolvedCitationInputResult = resolveFindingCitationInput(phrenPath, project);
385
+ if (!resolvedCitationInputResult.ok)
386
+ return resolvedCitationInputResult;
387
+ const resolvedCitationInput = resolvedCitationInputResult.data;
388
+ const effectiveSessionId = resolveFindingSessionId(phrenPath, project, opts?.sessionId);
389
+ const source = buildFindingSource(effectiveSessionId, opts?.source, opts?.scope);
390
+ const inferredRepo = resolveInferredCitationRepo(resolvedCitationInput);
391
+ const headCommit = inferredRepo ? getHeadCommit(inferredRepo) : undefined;
392
+ const added = [];
393
+ const skipped = [];
394
+ const rejected = [];
395
+ // Check project dir existence before withFileLock (which would create the dir via mkdirSync)
396
+ if (!fs.existsSync(resolvedDir))
397
+ return phrenErr(`Project "${project}" not found in phren.`, PhrenError.PROJECT_NOT_FOUND);
398
+ const contentResult = withFileLock(learningsPath, () => {
399
+ if (!fs.existsSync(learningsPath)) {
400
+ let content = `# ${project} Findings\n\n## ${today}\n`;
401
+ for (const [index, learning] of learnings.entries()) {
402
+ const extraAnnotations = opts?.extraAnnotationsByFinding?.[index];
403
+ const lengthError = validateFinding(learning);
404
+ if (lengthError) {
405
+ rejected.push({ text: learning, reason: lengthError });
406
+ continue;
407
+ }
408
+ const prepared = prepareFinding(learning, project, content, extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
409
+ if (prepared.status === "rejected") {
410
+ rejected.push({ text: learning, reason: prepared.reason });
411
+ continue;
412
+ }
413
+ if (prepared.status === "duplicate") {
414
+ skipped.push(learning);
415
+ continue;
416
+ }
417
+ content = insertFindingIntoContent(content, today, prepared.finding.bullet, prepared.finding.citationComment);
418
+ if (prepared.finding.tagWarning)
419
+ debugLog(`add_findings: ${prepared.finding.tagWarning}`);
420
+ added.push(learning);
421
+ }
422
+ if (added.length > 0) {
423
+ fs.writeFileSync(learningsPath, content.endsWith("\n") ? content : `${content}\n`);
424
+ }
425
+ return phrenOk({ content, wrote: added.length > 0 });
426
+ }
427
+ let content = fs.readFileSync(learningsPath, "utf8");
428
+ const issues = validateFindingsFormat(content);
429
+ if (issues.length > 0)
430
+ debugLog(`FINDINGS.md format warnings for "${project}": ${issues.join("; ")}`);
431
+ for (const [index, learning] of learnings.entries()) {
432
+ const extraAnnotations = opts?.extraAnnotationsByFinding?.[index];
433
+ const lengthError = validateFinding(learning);
434
+ if (lengthError) {
435
+ rejected.push({ text: learning, reason: lengthError });
436
+ continue;
437
+ }
438
+ const prepared = prepareFinding(learning, project, content, extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
439
+ if (prepared.status === "rejected") {
440
+ rejected.push({ text: learning, reason: prepared.reason });
441
+ continue;
442
+ }
443
+ if (prepared.status === "duplicate") {
444
+ skipped.push(learning);
445
+ continue;
446
+ }
447
+ content = insertFindingIntoContent(content, today, prepared.finding.bullet, prepared.finding.citationComment);
448
+ if (prepared.finding.tagWarning)
449
+ debugLog(`add_findings: ${prepared.finding.tagWarning}`);
450
+ added.push(learning);
451
+ }
452
+ if (added.length > 0) {
453
+ const tmpPath = learningsPath + `.tmp-${crypto.randomUUID()}`;
454
+ fs.writeFileSync(tmpPath, content);
455
+ fs.renameSync(tmpPath, learningsPath);
456
+ }
457
+ return phrenOk({ content, wrote: added.length > 0 });
458
+ });
459
+ if (!contentResult.ok)
460
+ return contentResult;
461
+ if (contentResult.data.wrote) {
462
+ appendAuditLog(phrenPath, "add_finding", `project=${project} count=${added.length} batch=true`);
463
+ const cap = Number.parseInt((process.env.PHREN_FINDINGS_CAP) || "", 10) || DEFAULT_FINDINGS_CAP;
464
+ if (countActiveFindings(contentResult.data.content) > cap) {
465
+ const archiveResult = autoArchiveToReference(phrenPath, project, cap);
466
+ if (archiveResult.ok && archiveResult.data > 0) {
467
+ debugLog(`Size cap: archived ${archiveResult.data} oldest entries for "${project}" (cap=${cap})`);
468
+ }
469
+ }
470
+ }
471
+ return phrenOk({ added, skipped, rejected });
472
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Consolidated metadata regex patterns and parsing helpers for HTML comment
3
+ * metadata embedded in FINDINGS.md and related files.
4
+ *
5
+ * `phren:` prefixes are used for HTML comment metadata.
6
+ */
7
+ // ---------------------------------------------------------------------------
8
+ // Prefix pattern (shared across all metadata types)
9
+ // ---------------------------------------------------------------------------
10
+ /** Matches `phren` as a comment prefix. */
11
+ const PREFIX = `phren`;
12
+ // ---------------------------------------------------------------------------
13
+ // Raw regex patterns — exported for direct use when the helpers don't fit
14
+ // ---------------------------------------------------------------------------
15
+ export const METADATA_REGEX = {
16
+ /** Matches `<!-- phren:status "active" -->` or `<!-- phren:status "superseded" -->` etc. */
17
+ status: new RegExp(`<!--\\s*${PREFIX}:status\\s+"?(active|superseded|contradicted|stale|invalid_citation|retracted)"?\\s*-->`, "i"),
18
+ /** Matches `<!-- phren:status_updated "2025-01-01" -->` */
19
+ statusUpdated: new RegExp(`<!--\\s*${PREFIX}:status_updated\\s+"([^"]+)"\\s*-->`, "i"),
20
+ /** Matches `<!-- phren:status_reason "superseded_by" -->` */
21
+ statusReason: new RegExp(`<!--\\s*${PREFIX}:status_reason\\s+"([^"]+)"\\s*-->`, "i"),
22
+ /** Matches `<!-- phren:status_ref "some ref" -->` */
23
+ statusRef: new RegExp(`<!--\\s*${PREFIX}:status_ref\\s+"([^"]+)"\\s*-->`, "i"),
24
+ /** Generic field matcher factory for status_updated / status_reason / status_ref. */
25
+ statusField(field) {
26
+ return new RegExp(`<!--\\s*${PREFIX}:${field}\\s+"([^"]+)"\\s*-->`, "i");
27
+ },
28
+ /** Raw (unquoted) fallback for status fields: `<!-- phren:status_ref some text -->` */
29
+ statusFieldRaw(field) {
30
+ return new RegExp(`<!--\\s*${PREFIX}:${field}\\s+([^>]+?)\\s*-->`, "i");
31
+ },
32
+ /** Matches `<!-- phren:superseded_by "text" 2025-01-01 -->` */
33
+ supersededBy: new RegExp(`<!--\\s*${PREFIX}:superseded_by\\s+"([^"]+)"(?:\\s+([0-9]{4}-[0-9]{2}-[0-9]{2}))?\\s*-->`, "i"),
34
+ /** Legacy `<!-- superseded_by: "text" -->` */
35
+ supersededByLegacy: /<!--\s*superseded_by:\s*"([^"]+)"\s*-->/i,
36
+ /** Matches `<!-- phren:supersedes "text" -->` */
37
+ supersedes: new RegExp(`<!--\\s*${PREFIX}:supersedes\\s+"([^"]+)"\\s*-->`, "i"),
38
+ /** Matches `<!-- phren:contradicts "text" -->` */
39
+ contradicts: new RegExp(`<!--\\s*${PREFIX}:contradicts\\s+"([^"]+)"\\s*-->`, "i"),
40
+ /** Global version for matchAll */
41
+ contradictsAll: new RegExp(`<!--\\s*phren:contradicts\\s+"([^"]+)"\\s*-->`, "g"),
42
+ /** Legacy `<!-- conflicts_with: "text" -->` or `<!-- conflicts_with: "text" (from project: foo) -->` */
43
+ conflictsWith: /<!--\s*conflicts_with:\s*"([^"]+)"(?:\s*\(from project:\s*[^)]+\))?\s*-->/i,
44
+ /** Global version for matchAll on conflicts_with */
45
+ conflictsWithAll: /<!--\s*conflicts_with:\s*"([^"]+)"(?:\s*\(from project:\s*[^)]+\))?\s*-->/g,
46
+ /** Matches `<!-- phren:cite {...} -->` or `<!-- phren:cite {...} -->` on a full line. */
47
+ citation: /^\s*<!--\s*phren:cite\s+\{.*\}\s*-->\s*$/,
48
+ /** Matches the opening marker (not line-anchored) for extracting JSON payload. */
49
+ citationMarker: /<!--\s*phren:cite\s+/,
50
+ /** Matches `<!-- phren:archive:start -->` or `<!-- phren:archive:start -->` */
51
+ archiveStart: new RegExp(`<!--\\s*${PREFIX}:archive:start\\s*-->`),
52
+ /** Matches `<!-- phren:archive:end -->` or `<!-- phren:archive:end -->` */
53
+ archiveEnd: new RegExp(`<!--\\s*${PREFIX}:archive:end\\s*-->`),
54
+ /** Matches `<!-- fid:abcd1234 -->` */
55
+ findingId: /<!--\s*fid:([a-z0-9]{8})\s*-->/i,
56
+ /** Matches `<!-- created: 2025-01-01 -->` */
57
+ createdDate: /<!--\s*created:\s*([0-9]{4}-[0-9]{2}-[0-9]{2})\s*-->/i,
58
+ /** Matches any lifecycle annotation: status, status_updated, status_reason, status_ref */
59
+ lifecycleAnnotation: new RegExp(`<!--\\s*${PREFIX}:status(?:_updated|_reason|_ref)?\\b[^>]*-->`, "i"),
60
+ /** Matches `<!-- source:... -->` */
61
+ source: /<!--\s*source:\s*(.*?)\s*-->/,
62
+ /** Matches any HTML comment `<!-- ... -->` (non-greedy). */
63
+ anyComment: /<!--.*?-->/g,
64
+ // Strip patterns (with leading optional whitespace for clean removal)
65
+ /** Strip status comment */
66
+ stripStatus: new RegExp(`\\s*<!--\\s*${PREFIX}:status\\s+"?(?:active|superseded|contradicted|stale|invalid_citation|retracted)"?\\s*-->`, "gi"),
67
+ /** Strip status_updated comment */
68
+ stripStatusUpdated: new RegExp(`\\s*<!--\\s*${PREFIX}:status_updated\\s+"[^"]+"\\s*-->`, "gi"),
69
+ /** Strip status_reason comment */
70
+ stripStatusReason: new RegExp(`\\s*<!--\\s*${PREFIX}:status_reason\\s+"[^"]+"\\s*-->`, "gi"),
71
+ /** Strip status_ref comment */
72
+ stripStatusRef: new RegExp(`\\s*<!--\\s*${PREFIX}:status_ref\\s+"[^"]+"\\s*-->`, "gi"),
73
+ /** Strip legacy `<!-- superseded_by: "..." -->` */
74
+ stripSupersededByLegacy: /\s*<!--\s*superseded_by:\s*"[^"]+"\s*-->/gi,
75
+ /** Strip `<!-- phren:superseded_by "..." ... -->` */
76
+ stripSupersededBy: new RegExp(`\\s*<!--\\s*${PREFIX}:superseded_by\\s+"[^"]+"(?:\\s+[0-9]{4}-[0-9]{2}-[0-9]{2})?\\s*-->`, "gi"),
77
+ /** Strip `<!-- phren:supersedes "..." -->` */
78
+ stripSupersedes: new RegExp(`\\s*<!--\\s*${PREFIX}:supersedes\\s+"[^"]+"\\s*-->`, "gi"),
79
+ /** Strip legacy `<!-- conflicts_with: "..." -->` */
80
+ stripConflictsWith: /\s*<!--\s*conflicts_with:\s*"[^"]+"(?:\s*\(from project:\s*[^)]+\))?\s*-->/gi,
81
+ /** Strip `<!-- phren:contradicts "..." -->` */
82
+ stripContradicts: new RegExp(`\\s*<!--\\s*${PREFIX}:contradicts\\s+"[^"]+"\\s*-->`, "gi"),
83
+ };
84
+ // ---------------------------------------------------------------------------
85
+ // Parsing helpers
86
+ // ---------------------------------------------------------------------------
87
+ /** Parse `<!-- phren:status "active" -->` from a line. Returns the status string or undefined. */
88
+ export function parseStatus(line) {
89
+ return line.match(METADATA_REGEX.status)?.[1]?.toLowerCase();
90
+ }
91
+ /** Parse a quoted status field (status_updated, status_reason, status_ref) from a line. */
92
+ export function parseStatusField(line, field) {
93
+ const quoted = line.match(METADATA_REGEX.statusField(field))?.[1];
94
+ if (quoted)
95
+ return quoted.replace(/\s+/g, " ").trim();
96
+ const raw = line.match(METADATA_REGEX.statusFieldRaw(field))?.[1];
97
+ return raw ? raw.replace(/\s+/g, " ").trim() : undefined;
98
+ }
99
+ /** Parse supersession metadata: returns `{ ref, date }` or null. Checks both prefixed and legacy forms. */
100
+ export function parseSupersession(line) {
101
+ const prefixed = line.match(METADATA_REGEX.supersededBy);
102
+ if (prefixed)
103
+ return { ref: prefixed[1], date: prefixed[2] };
104
+ const legacy = line.match(METADATA_REGEX.supersededByLegacy);
105
+ if (legacy)
106
+ return { ref: legacy[1] };
107
+ return null;
108
+ }
109
+ /** Parse `<!-- phren:supersedes "..." -->` from a line. Returns the ref or undefined. */
110
+ export function parseSupersedesRef(line) {
111
+ return line.match(METADATA_REGEX.supersedes)?.[1];
112
+ }
113
+ /** Parse contradiction metadata. Checks both prefixed `contradicts` and legacy `conflicts_with`. */
114
+ export function parseContradiction(line) {
115
+ const prefixed = line.match(METADATA_REGEX.contradicts);
116
+ if (prefixed)
117
+ return prefixed[1];
118
+ const legacy = line.match(METADATA_REGEX.conflictsWith);
119
+ if (legacy)
120
+ return legacy[1];
121
+ return null;
122
+ }
123
+ /** Parse all contradiction refs from a line using matchAll. */
124
+ export function parseAllContradictions(line) {
125
+ return [...line.matchAll(METADATA_REGEX.contradictsAll)].map((m) => m[1]);
126
+ }
127
+ /** Parse `<!-- fid:XXXXXXXX -->` from a line. Returns the 8-char hex ID or undefined. */
128
+ export function parseFindingId(line) {
129
+ return line.match(METADATA_REGEX.findingId)?.[1];
130
+ }
131
+ /** Parse `<!-- created: YYYY-MM-DD -->` from a line. Returns the date string or undefined. */
132
+ export function parseCreatedDate(line) {
133
+ return line.match(METADATA_REGEX.createdDate)?.[1];
134
+ }
135
+ /** Check if a line (or next line) contains a citation comment. */
136
+ export function isCitationLine(line) {
137
+ return METADATA_REGEX.citation.test(line.trim());
138
+ }
139
+ /** Check if a line marks the start of an archive block. */
140
+ export function isArchiveStart(line) {
141
+ return METADATA_REGEX.archiveStart.test(line) || /^<details(?:\s|>)/i.test(line.trim());
142
+ }
143
+ /** Check if a line marks the end of an archive block. */
144
+ export function isArchiveEnd(line) {
145
+ return METADATA_REGEX.archiveEnd.test(line) || /^<\/details>/i.test(line.trim());
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Strip helpers — remove metadata comments from a line
149
+ // ---------------------------------------------------------------------------
150
+ /** Strip all lifecycle status comments (status, status_updated, status_reason, status_ref). */
151
+ export function stripLifecycleMetadata(line) {
152
+ return line
153
+ .replace(METADATA_REGEX.stripStatus, "")
154
+ .replace(METADATA_REGEX.stripStatusUpdated, "")
155
+ .replace(METADATA_REGEX.stripStatusReason, "")
156
+ .replace(METADATA_REGEX.stripStatusRef, "");
157
+ }
158
+ /** Strip all relation comments (superseded_by, supersedes, conflicts_with, contradicts). */
159
+ export function stripRelationMetadata(line) {
160
+ return line
161
+ .replace(METADATA_REGEX.stripSupersededByLegacy, "")
162
+ .replace(METADATA_REGEX.stripSupersededBy, "")
163
+ .replace(METADATA_REGEX.stripSupersedes, "")
164
+ .replace(METADATA_REGEX.stripConflictsWith, "")
165
+ .replace(METADATA_REGEX.stripContradicts, "");
166
+ }
167
+ /** Strip all phren/phren metadata comments from a line. */
168
+ export function stripAllMetadata(line) {
169
+ return stripRelationMetadata(stripLifecycleMetadata(line));
170
+ }
171
+ /** Strip all HTML comments from text. */
172
+ export function stripComments(text) {
173
+ return text.replace(METADATA_REGEX.anyComment, "").trim();
174
+ }
175
+ // ---------------------------------------------------------------------------
176
+ // Add helpers — append metadata comments to a line
177
+ // ---------------------------------------------------------------------------
178
+ /** Build a metadata comment string. */
179
+ export function addMetadata(type, value, extra) {
180
+ const prefix = "phren";
181
+ const escaped = value.replace(/"/g, "'");
182
+ if (extra) {
183
+ return `<!-- ${prefix}:${type} "${escaped}" ${extra} -->`;
184
+ }
185
+ return `<!-- ${prefix}:${type} "${escaped}" -->`;
186
+ }