@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,181 @@
1
+ import * as crypto from "crypto";
2
+ import * as fs from "fs";
3
+ import { impactLogFile } from "./shared.js";
4
+ import { withFileLock } from "./shared-governance.js";
5
+ let highImpactCache = null;
6
+ function nowIso() {
7
+ return new Date().toISOString();
8
+ }
9
+ function normalizeFindingText(raw) {
10
+ return raw
11
+ .replace(/^-\s+/, "")
12
+ .replace(/<!--.*?-->/g, " ")
13
+ .replace(/\[confidence\s+[01](?:\.\d+)?\]/gi, " ")
14
+ .replace(/\s+/g, " ")
15
+ .trim()
16
+ .toLowerCase();
17
+ }
18
+ export function findingIdFromLine(line) {
19
+ const fid = line.match(/<!--\s*fid:([a-z0-9]{8})\s*-->/i);
20
+ if (fid?.[1])
21
+ return `fid:${fid[1].toLowerCase()}`;
22
+ const normalized = normalizeFindingText(line);
23
+ if (!normalized)
24
+ return "hash:empty";
25
+ const hash = crypto.createHash("sha1").update(normalized).digest("hex").slice(0, 12);
26
+ return `hash:${hash}`;
27
+ }
28
+ export function extractFindingIdsFromSnippet(snippet) {
29
+ const lines = snippet
30
+ .split("\n")
31
+ .map((line) => line.trim())
32
+ .filter((line) => line.length > 0);
33
+ const bulletLines = lines.filter((line) => line.startsWith("- "));
34
+ const candidates = bulletLines.length > 0 ? bulletLines : (lines[0] ? [lines[0]] : []);
35
+ const ids = new Set();
36
+ for (const line of candidates) {
37
+ ids.add(findingIdFromLine(line));
38
+ }
39
+ return [...ids];
40
+ }
41
+ function parseImpactLine(line) {
42
+ const trimmed = line.trim();
43
+ if (!trimmed)
44
+ return null;
45
+ try {
46
+ const parsed = JSON.parse(trimmed);
47
+ if (!parsed
48
+ || typeof parsed.findingId !== "string"
49
+ || typeof parsed.project !== "string"
50
+ || typeof parsed.timestamp !== "string"
51
+ || typeof parsed.sessionId !== "string"
52
+ || typeof parsed.taskCompleted !== "boolean") {
53
+ return null;
54
+ }
55
+ return {
56
+ findingId: parsed.findingId,
57
+ project: parsed.project,
58
+ timestamp: parsed.timestamp,
59
+ sessionId: parsed.sessionId,
60
+ taskCompleted: parsed.taskCompleted,
61
+ };
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function readImpactSummary(phrenPath) {
68
+ const file = impactLogFile(phrenPath);
69
+ const surfaceCountByFinding = new Map();
70
+ const completedByFinding = new Set();
71
+ if (!fs.existsSync(file)) {
72
+ return {
73
+ surfaceCountByFinding,
74
+ completedByFinding,
75
+ };
76
+ }
77
+ const content = fs.readFileSync(file, "utf8");
78
+ for (const line of content.split("\n")) {
79
+ const entry = parseImpactLine(line);
80
+ if (!entry)
81
+ continue;
82
+ surfaceCountByFinding.set(entry.findingId, (surfaceCountByFinding.get(entry.findingId) ?? 0) + 1);
83
+ if (entry.taskCompleted) {
84
+ completedByFinding.add(entry.findingId);
85
+ }
86
+ }
87
+ return {
88
+ surfaceCountByFinding,
89
+ completedByFinding,
90
+ };
91
+ }
92
+ function appendImpact(phrenPath, entries) {
93
+ if (entries.length === 0)
94
+ return;
95
+ const file = impactLogFile(phrenPath);
96
+ withFileLock(file, () => {
97
+ const lines = entries.map((entry) => JSON.stringify(entry));
98
+ fs.appendFileSync(file, lines.join("\n") + "\n");
99
+ });
100
+ }
101
+ export function logImpact(phrenPath, entries) {
102
+ if (entries.length === 0)
103
+ return;
104
+ const timestamp = nowIso();
105
+ appendImpact(phrenPath, entries.map((entry) => ({
106
+ findingId: entry.findingId,
107
+ project: entry.project,
108
+ sessionId: entry.sessionId,
109
+ timestamp,
110
+ taskCompleted: false,
111
+ })));
112
+ }
113
+ export function getHighImpactFindings(phrenPath, minSurfaceCount = 3) {
114
+ const file = impactLogFile(phrenPath);
115
+ let stat = null;
116
+ try {
117
+ stat = fs.existsSync(file) ? fs.statSync(file) : null;
118
+ }
119
+ catch {
120
+ stat = null;
121
+ }
122
+ if (!stat)
123
+ return new Set();
124
+ if (highImpactCache
125
+ && highImpactCache.file === file
126
+ && highImpactCache.mtimeMs === stat.mtimeMs
127
+ && highImpactCache.size === stat.size
128
+ && highImpactCache.minSurfaceCount === minSurfaceCount) {
129
+ return new Set(highImpactCache.ids);
130
+ }
131
+ const summary = readImpactSummary(phrenPath);
132
+ const ids = new Set();
133
+ for (const [findingId, surfaceCount] of summary.surfaceCountByFinding.entries()) {
134
+ if (surfaceCount >= minSurfaceCount && summary.completedByFinding.has(findingId)) {
135
+ ids.add(findingId);
136
+ }
137
+ }
138
+ highImpactCache = {
139
+ file,
140
+ mtimeMs: stat.mtimeMs,
141
+ size: stat.size,
142
+ minSurfaceCount,
143
+ ids,
144
+ };
145
+ return new Set(ids);
146
+ }
147
+ export function markImpactEntriesCompletedForSession(phrenPath, sessionId, project) {
148
+ if (!sessionId)
149
+ return 0;
150
+ const file = impactLogFile(phrenPath);
151
+ if (!fs.existsSync(file))
152
+ return 0;
153
+ const updated = withFileLock(file, () => {
154
+ if (!fs.existsSync(file))
155
+ return 0;
156
+ const lines = fs.readFileSync(file, "utf8").split("\n");
157
+ let updatedCount = 0;
158
+ const rewritten = lines
159
+ .filter((line) => line.trim().length > 0)
160
+ .map((line) => {
161
+ const entry = parseImpactLine(line);
162
+ if (!entry)
163
+ return line;
164
+ if (entry.taskCompleted)
165
+ return line;
166
+ if (entry.sessionId !== sessionId)
167
+ return line;
168
+ if (project && entry.project !== project)
169
+ return line;
170
+ updatedCount += 1;
171
+ return JSON.stringify({ ...entry, taskCompleted: true });
172
+ });
173
+ if (updatedCount > 0) {
174
+ fs.writeFileSync(file, rewritten.join("\n") + "\n");
175
+ }
176
+ return updatedCount;
177
+ });
178
+ if (updated > 0)
179
+ highImpactCache = null;
180
+ return updated;
181
+ }
@@ -0,0 +1,122 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as crypto from "crypto";
4
+ import { runtimeDir, phrenOk, phrenErr, PhrenError } from "./shared.js";
5
+ import { withFileLock } from "./shared-governance.js";
6
+ import { addFindingToFile } from "./shared-content.js";
7
+ import { isValidProjectName, errorMessage } from "./utils.js";
8
+ function journalRoot(phrenPath) {
9
+ const dir = path.join(runtimeDir(phrenPath), "finding-journal");
10
+ fs.mkdirSync(dir, { recursive: true });
11
+ return dir;
12
+ }
13
+ function sanitizeSessionId(sessionId) {
14
+ const raw = (sessionId || `session-${new Date().toISOString().slice(0, 10)}`).trim();
15
+ const safe = raw.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
16
+ return safe || "session";
17
+ }
18
+ function journalFileFor(phrenPath, project, sessionId) {
19
+ const projectDir = path.join(journalRoot(phrenPath), project);
20
+ fs.mkdirSync(projectDir, { recursive: true });
21
+ return path.join(projectDir, `${sanitizeSessionId(sessionId)}.jsonl`);
22
+ }
23
+ function listJournalFiles(phrenPath, project) {
24
+ const root = journalRoot(phrenPath);
25
+ const projects = project ? [project] : fs.readdirSync(root).filter((entry) => fs.statSync(path.join(root, entry)).isDirectory());
26
+ const files = [];
27
+ for (const projectName of projects) {
28
+ const projectDir = path.join(root, projectName);
29
+ if (!fs.existsSync(projectDir))
30
+ continue;
31
+ for (const entry of fs.readdirSync(projectDir)) {
32
+ if (!entry.endsWith(".jsonl"))
33
+ continue;
34
+ files.push(path.join(projectDir, entry));
35
+ }
36
+ }
37
+ return files.sort();
38
+ }
39
+ export function appendFindingJournal(phrenPath, project, text, opts = {}) {
40
+ if (!isValidProjectName(project))
41
+ return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
42
+ const filePath = journalFileFor(phrenPath, project, opts.sessionId);
43
+ const entry = {
44
+ at: new Date().toISOString(),
45
+ project,
46
+ text,
47
+ ...(opts.source ? { source: opts.source } : {}),
48
+ ...(opts.sessionId ? { sessionId: opts.sessionId } : {}),
49
+ ...(opts.repo ? { repo: opts.repo } : {}),
50
+ ...(opts.commit ? { commit: opts.commit } : {}),
51
+ ...(opts.file ? { file: opts.file } : {}),
52
+ };
53
+ try {
54
+ withFileLock(filePath, () => {
55
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
56
+ fs.appendFileSync(filePath, JSON.stringify(entry) + "\n");
57
+ });
58
+ return phrenOk(filePath);
59
+ }
60
+ catch (err) {
61
+ return phrenErr(`Failed to append finding journal: ${errorMessage(err)}`, PhrenError.PERMISSION_DENIED);
62
+ }
63
+ }
64
+ export function compactFindingJournals(phrenPath, project) {
65
+ const result = {
66
+ filesProcessed: 0,
67
+ entriesProcessed: 0,
68
+ added: 0,
69
+ skipped: 0,
70
+ failed: 0,
71
+ };
72
+ for (const filePath of listJournalFiles(phrenPath, project)) {
73
+ const claimed = `${filePath}.${crypto.randomUUID()}.claim`;
74
+ try {
75
+ fs.renameSync(filePath, claimed);
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ result.filesProcessed += 1;
81
+ try {
82
+ const entries = fs.readFileSync(claimed, "utf8")
83
+ .split("\n")
84
+ .filter(Boolean)
85
+ .map((line) => {
86
+ try {
87
+ return JSON.parse(line);
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ })
93
+ .filter((entry) => Boolean(entry && entry.project && entry.text));
94
+ for (const entry of entries) {
95
+ result.entriesProcessed += 1;
96
+ const write = addFindingToFile(phrenPath, entry.project, entry.text, {
97
+ ...(entry.repo ? { repo: entry.repo } : {}),
98
+ ...(entry.commit ? { commit: entry.commit } : {}),
99
+ ...(entry.file ? { file: entry.file } : {}),
100
+ }, {
101
+ source: entry.source,
102
+ sessionId: entry.sessionId,
103
+ });
104
+ if (!write.ok) {
105
+ result.failed += 1;
106
+ continue;
107
+ }
108
+ if (typeof write.data === "string" && write.data.includes("Skipped duplicate"))
109
+ result.skipped += 1;
110
+ else
111
+ result.added += 1;
112
+ }
113
+ }
114
+ finally {
115
+ try {
116
+ fs.unlinkSync(claimed);
117
+ }
118
+ catch { }
119
+ }
120
+ }
121
+ return result;
122
+ }
@@ -0,0 +1,259 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { PhrenError, phrenErr, phrenOk } from "./phren-core.js";
4
+ // Phren lifecycle comment prefix. No backward compat.
5
+ const LIFECYCLE_PREFIX = "phren";
6
+ import { withFileLock } from "./governance-locks.js";
7
+ import { isValidProjectName, safeProjectPath } from "./utils.js";
8
+ import { parseCreatedDate as parseCreatedDateMeta, parseStatusField, parseStatus, parseSupersession, parseContradiction, parseFindingId as parseFindingIdMeta, stripLifecycleMetadata, stripRelationMetadata, } from "./content-metadata.js";
9
+ export const FINDING_LIFECYCLE_STATUSES = [
10
+ "active",
11
+ "superseded",
12
+ "contradicted",
13
+ "stale",
14
+ "invalid_citation",
15
+ "retracted",
16
+ ];
17
+ function cleanCommentValue(value) {
18
+ return value.replace(/\s+/g, " ").trim();
19
+ }
20
+ function serializeCommentValue(value) {
21
+ return cleanCommentValue(value).replace(/"/g, "'");
22
+ }
23
+ function parseCreatedDate(line) {
24
+ const created = parseCreatedDateMeta(line);
25
+ return created ? cleanCommentValue(created) : undefined;
26
+ }
27
+ function matchField(line, field) {
28
+ return parseStatusField(line, field);
29
+ }
30
+ export function parseFindingLifecycle(line) {
31
+ const created = parseCreatedDate(line);
32
+ const normalizedStatus = parseStatus(line);
33
+ const normalized = {
34
+ status: normalizedStatus ?? "active",
35
+ status_updated: matchField(line, "status_updated") ?? created,
36
+ status_reason: matchField(line, "status_reason"),
37
+ status_ref: matchField(line, "status_ref"),
38
+ };
39
+ if (normalizedStatus)
40
+ return normalized;
41
+ const supersession = parseSupersession(line);
42
+ if (supersession) {
43
+ const updated = supersession.date || normalized.status_updated;
44
+ return {
45
+ status: "superseded",
46
+ status_updated: updated ? cleanCommentValue(updated) : undefined,
47
+ status_reason: normalized.status_reason ?? "superseded_by",
48
+ status_ref: normalized.status_ref ?? cleanCommentValue(supersession.ref || ""),
49
+ };
50
+ }
51
+ const contradictionRef = parseContradiction(line);
52
+ if (contradictionRef) {
53
+ return {
54
+ status: "contradicted",
55
+ status_updated: normalized.status_updated,
56
+ status_reason: normalized.status_reason ?? "conflicts_with",
57
+ status_ref: normalized.status_ref ?? cleanCommentValue(contradictionRef),
58
+ };
59
+ }
60
+ return normalized;
61
+ }
62
+ export function buildLifecycleComments(lifecycle, fallbackDate) {
63
+ const status = lifecycle?.status ?? "active";
64
+ const statusUpdated = lifecycle?.status_updated ?? fallbackDate;
65
+ const parts = [`<!-- ${LIFECYCLE_PREFIX}:status "${status}" -->`];
66
+ if (statusUpdated)
67
+ parts.push(`<!-- ${LIFECYCLE_PREFIX}:status_updated "${serializeCommentValue(statusUpdated)}" -->`);
68
+ if (lifecycle?.status_reason)
69
+ parts.push(`<!-- ${LIFECYCLE_PREFIX}:status_reason "${serializeCommentValue(lifecycle.status_reason)}" -->`);
70
+ if (lifecycle?.status_ref)
71
+ parts.push(`<!-- ${LIFECYCLE_PREFIX}:status_ref "${serializeCommentValue(lifecycle.status_ref)}" -->`);
72
+ return parts.join(" ");
73
+ }
74
+ export function stripLifecycleComments(line) {
75
+ return stripLifecycleMetadata(line);
76
+ }
77
+ export function isInactiveFindingLine(line) {
78
+ return parseFindingLifecycle(line).status !== "active";
79
+ }
80
+ function findingTextFromLine(line) {
81
+ return line
82
+ .replace(/^-\s+/, "")
83
+ .replace(/<!--.*?-->/g, "")
84
+ .trim();
85
+ }
86
+ function normalizeFindingText(value) {
87
+ return findingTextFromLine(value)
88
+ .replace(/\s+/g, " ")
89
+ .toLowerCase();
90
+ }
91
+ function removeRelationComments(line) {
92
+ return stripRelationMetadata(line);
93
+ }
94
+ function applyLifecycle(line, lifecycle, today, opts) {
95
+ let updated = stripLifecycleComments(removeRelationComments(line)).trimEnd();
96
+ if (opts?.supersededBy) {
97
+ updated += ` <!-- ${LIFECYCLE_PREFIX}:superseded_by "${serializeCommentValue(opts.supersededBy)}" ${today} -->`;
98
+ }
99
+ if (opts?.supersedes) {
100
+ updated += ` <!-- ${LIFECYCLE_PREFIX}:supersedes "${serializeCommentValue(opts.supersedes)}" -->`;
101
+ }
102
+ if (opts?.contradicts) {
103
+ const contradictionRef = serializeCommentValue(opts.contradicts);
104
+ updated += ` <!-- conflicts_with: "${contradictionRef}" --> <!-- ${LIFECYCLE_PREFIX}:contradicts "${contradictionRef}" -->`;
105
+ }
106
+ updated += ` ${buildLifecycleComments(lifecycle, today)}`;
107
+ return updated;
108
+ }
109
+ function matchFinding(lines, match) {
110
+ const needleRaw = match.trim();
111
+ if (!needleRaw)
112
+ return phrenErr("Finding text cannot be empty.", PhrenError.EMPTY_INPUT);
113
+ const needle = normalizeFindingText(needleRaw);
114
+ const bulletLines = lines
115
+ .map((line, index) => ({ line, index }))
116
+ .filter(({ line }) => line.startsWith("- "));
117
+ const fidNeedle = needle.replace(/^fid:/, "");
118
+ const fidMatches = /^[a-z0-9]{8}$/.test(fidNeedle)
119
+ ? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`, "i").test(line))
120
+ : [];
121
+ const exactMatches = bulletLines.filter(({ line }) => normalizeFindingText(line) === needle);
122
+ const partialMatches = bulletLines.filter(({ line }) => {
123
+ const clean = normalizeFindingText(line);
124
+ return clean.includes(needle) || line.toLowerCase().includes(needle);
125
+ });
126
+ let selected;
127
+ if (fidMatches.length === 1) {
128
+ selected = fidMatches[0];
129
+ }
130
+ else if (exactMatches.length === 1) {
131
+ selected = exactMatches[0];
132
+ }
133
+ else if (exactMatches.length > 1) {
134
+ return phrenErr(`"${match}" is ambiguous (${exactMatches.length} exact matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
135
+ }
136
+ else if (partialMatches.length === 1) {
137
+ selected = partialMatches[0];
138
+ }
139
+ else if (partialMatches.length > 1) {
140
+ return phrenErr(`"${match}" is ambiguous (${partialMatches.length} partial matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
141
+ }
142
+ if (!selected) {
143
+ return phrenErr(`No finding matching "${match}".`, PhrenError.NOT_FOUND);
144
+ }
145
+ const stableId = parseFindingIdMeta(selected.line);
146
+ return phrenOk({
147
+ index: selected.index,
148
+ line: selected.line,
149
+ text: findingTextFromLine(selected.line),
150
+ stableId,
151
+ });
152
+ }
153
+ function findingsPathForProject(phrenPath, project) {
154
+ if (!isValidProjectName(project))
155
+ return phrenErr(`Invalid project name: "${project}"`, PhrenError.INVALID_PROJECT_NAME);
156
+ const projectDir = safeProjectPath(phrenPath, project);
157
+ if (!projectDir)
158
+ return phrenErr(`Invalid project name: "${project}"`, PhrenError.INVALID_PROJECT_NAME);
159
+ if (!fs.existsSync(projectDir))
160
+ return phrenErr(`Project "${project}" not found.`, PhrenError.PROJECT_NOT_FOUND);
161
+ const findingsPath = path.join(projectDir, "FINDINGS.md");
162
+ if (!fs.existsSync(findingsPath))
163
+ return phrenErr(`No FINDINGS.md found for "${project}".`, PhrenError.FILE_NOT_FOUND);
164
+ return phrenOk(findingsPath);
165
+ }
166
+ export function supersedeFinding(phrenPath, project, findingText, supersededBy) {
167
+ const pathResult = findingsPathForProject(phrenPath, project);
168
+ if (!pathResult.ok)
169
+ return pathResult;
170
+ const findingsPath = pathResult.data;
171
+ const ref = supersededBy.trim().slice(0, 60);
172
+ if (!ref)
173
+ return phrenErr("superseded_by cannot be empty.", PhrenError.EMPTY_INPUT);
174
+ return withFileLock(findingsPath, () => {
175
+ const lines = fs.readFileSync(findingsPath, "utf8").split("\n");
176
+ const matched = matchFinding(lines, findingText);
177
+ if (!matched.ok)
178
+ return matched;
179
+ const today = new Date().toISOString().slice(0, 10);
180
+ lines[matched.data.index] = applyLifecycle(lines[matched.data.index], { status: "superseded", status_updated: today, status_reason: "superseded_by", status_ref: ref }, today, { supersededBy: ref });
181
+ const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
182
+ fs.writeFileSync(findingsPath, normalized);
183
+ return phrenOk({ finding: matched.data.text, superseded_by: ref, status: "superseded" });
184
+ });
185
+ }
186
+ export function retractFinding(phrenPath, project, findingText, reason) {
187
+ const pathResult = findingsPathForProject(phrenPath, project);
188
+ if (!pathResult.ok)
189
+ return pathResult;
190
+ const findingsPath = pathResult.data;
191
+ const reasonText = reason.trim();
192
+ if (!reasonText)
193
+ return phrenErr("reason cannot be empty.", PhrenError.EMPTY_INPUT);
194
+ return withFileLock(findingsPath, () => {
195
+ const lines = fs.readFileSync(findingsPath, "utf8").split("\n");
196
+ const matched = matchFinding(lines, findingText);
197
+ if (!matched.ok)
198
+ return matched;
199
+ const today = new Date().toISOString().slice(0, 10);
200
+ lines[matched.data.index] = applyLifecycle(lines[matched.data.index], { status: "retracted", status_updated: today, status_reason: reasonText }, today);
201
+ const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
202
+ fs.writeFileSync(findingsPath, normalized);
203
+ return phrenOk({ finding: matched.data.text, reason: reasonText, status: "retracted" });
204
+ });
205
+ }
206
+ export function resolveFindingContradiction(phrenPath, project, findingA, findingB, resolution) {
207
+ const pathResult = findingsPathForProject(phrenPath, project);
208
+ if (!pathResult.ok)
209
+ return pathResult;
210
+ const findingsPath = pathResult.data;
211
+ return withFileLock(findingsPath, () => {
212
+ const lines = fs.readFileSync(findingsPath, "utf8").split("\n");
213
+ const matchedA = matchFinding(lines, findingA);
214
+ if (!matchedA.ok)
215
+ return matchedA;
216
+ const matchedB = matchFinding(lines, findingB);
217
+ if (!matchedB.ok)
218
+ return matchedB;
219
+ if (matchedA.data.index === matchedB.data.index) {
220
+ return phrenErr("finding_a and finding_b refer to the same finding.", PhrenError.VALIDATION_ERROR);
221
+ }
222
+ const today = new Date().toISOString().slice(0, 10);
223
+ const refA = matchedA.data.text.slice(0, 60);
224
+ const refB = matchedB.data.text.slice(0, 60);
225
+ let statusA = "active";
226
+ let statusB = "active";
227
+ if (resolution === "keep_a") {
228
+ lines[matchedA.data.index] = applyLifecycle(lines[matchedA.data.index], { status: "active", status_updated: today, status_reason: "contradiction_resolved_keep_a", status_ref: refB }, today);
229
+ lines[matchedB.data.index] = applyLifecycle(lines[matchedB.data.index], { status: "superseded", status_updated: today, status_reason: "contradiction_resolved_keep_a", status_ref: refA }, today, { supersededBy: refA });
230
+ statusA = "active";
231
+ statusB = "superseded";
232
+ }
233
+ else if (resolution === "keep_b") {
234
+ lines[matchedA.data.index] = applyLifecycle(lines[matchedA.data.index], { status: "superseded", status_updated: today, status_reason: "contradiction_resolved_keep_b", status_ref: refB }, today, { supersededBy: refB });
235
+ lines[matchedB.data.index] = applyLifecycle(lines[matchedB.data.index], { status: "active", status_updated: today, status_reason: "contradiction_resolved_keep_b", status_ref: refA }, today);
236
+ statusA = "superseded";
237
+ statusB = "active";
238
+ }
239
+ else if (resolution === "keep_both") {
240
+ lines[matchedA.data.index] = applyLifecycle(lines[matchedA.data.index], { status: "active", status_updated: today, status_reason: "contradiction_resolved_keep_both", status_ref: refB }, today);
241
+ lines[matchedB.data.index] = applyLifecycle(lines[matchedB.data.index], { status: "active", status_updated: today, status_reason: "contradiction_resolved_keep_both", status_ref: refA }, today);
242
+ statusA = "active";
243
+ statusB = "active";
244
+ }
245
+ else {
246
+ lines[matchedA.data.index] = applyLifecycle(lines[matchedA.data.index], { status: "retracted", status_updated: today, status_reason: "contradiction_retracted_both", status_ref: refB }, today);
247
+ lines[matchedB.data.index] = applyLifecycle(lines[matchedB.data.index], { status: "retracted", status_updated: today, status_reason: "contradiction_retracted_both", status_ref: refA }, today);
248
+ statusA = "retracted";
249
+ statusB = "retracted";
250
+ }
251
+ const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
252
+ fs.writeFileSync(findingsPath, normalized);
253
+ return phrenOk({
254
+ resolution,
255
+ finding_a: { text: matchedA.data.text, status: statusA },
256
+ finding_b: { text: matchedB.data.text, status: statusB },
257
+ });
258
+ });
259
+ }
@@ -0,0 +1,22 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { debugLog } from "./shared.js";
4
+ import { errorMessage } from "./utils.js";
5
+ export function recordRetrieval(phrenPath, file, section) {
6
+ const dir = path.join(phrenPath, ".runtime");
7
+ fs.mkdirSync(dir, { recursive: true });
8
+ const logPath = path.join(dir, "retrieval-log.jsonl");
9
+ const entry = { file, section, retrievedAt: new Date().toISOString() };
10
+ fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
11
+ try {
12
+ const stat = fs.statSync(logPath);
13
+ if (stat.size > 500_000) {
14
+ const content = fs.readFileSync(logPath, "utf8");
15
+ const lines = content.split("\n").filter(Boolean);
16
+ fs.writeFileSync(logPath, lines.slice(-1000).join("\n") + "\n");
17
+ }
18
+ }
19
+ catch (err) {
20
+ debugLog(`recordRetrieval rotation failed: ${errorMessage(err)}`);
21
+ }
22
+ }
@@ -0,0 +1,96 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { debugLog } from "./shared.js";
4
+ // Acquire the file lock, returning true on success or throwing on timeout.
5
+ function acquireFileLock(lockPath) {
6
+ const maxWait = Number.parseInt(process.env.PHREN_FILE_LOCK_MAX_WAIT_MS || process.env.PHREN_FILE_LOCK_MAX_WAIT_MS || "5000", 10) || 5000;
7
+ const pollInterval = Number.parseInt((process.env.PHREN_FILE_LOCK_POLL_MS) || "100", 10) || 100;
8
+ const staleThreshold = Number.parseInt((process.env.PHREN_FILE_LOCK_STALE_MS) || "30000", 10) || 30000;
9
+ const waiter = new Int32Array(new SharedArrayBuffer(4));
10
+ const sleep = (ms) => Atomics.wait(waiter, 0, 0, ms);
11
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
12
+ let waited = 0;
13
+ let hasLock = false;
14
+ while (waited < maxWait) {
15
+ try {
16
+ fs.writeFileSync(lockPath, `${process.pid}\n${Date.now()}`, { flag: "wx" });
17
+ hasLock = true;
18
+ break;
19
+ }
20
+ catch (err) {
21
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
22
+ process.stderr.write(`[phren] acquireFileLock lockWrite: ${err instanceof Error ? err.message : String(err)}\n`);
23
+ try {
24
+ const stat = fs.statSync(lockPath);
25
+ if (Date.now() - stat.mtimeMs > staleThreshold) {
26
+ // Verify lock owner PID is dead before removing stale lock
27
+ let ownerDead = true;
28
+ try {
29
+ const lockContent = fs.readFileSync(lockPath, "utf8");
30
+ const lockPid = Number.parseInt(lockContent.split("\n")[0], 10);
31
+ if (Number.isFinite(lockPid) && lockPid > 0) {
32
+ try {
33
+ process.kill(lockPid, 0); // signal 0 = check if alive
34
+ ownerDead = false; // PID is still alive, don't steal the lock
35
+ }
36
+ catch {
37
+ ownerDead = true; // PID is dead, safe to remove
38
+ }
39
+ }
40
+ }
41
+ catch {
42
+ ownerDead = true; // Can't read lock file, treat as dead
43
+ }
44
+ if (ownerDead) {
45
+ fs.unlinkSync(lockPath);
46
+ continue;
47
+ }
48
+ }
49
+ }
50
+ catch (statErr) {
51
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
52
+ process.stderr.write(`[phren] acquireFileLock staleStat: ${statErr instanceof Error ? statErr.message : String(statErr)}\n`);
53
+ sleep(pollInterval);
54
+ waited += pollInterval;
55
+ continue;
56
+ }
57
+ sleep(pollInterval);
58
+ waited += pollInterval;
59
+ }
60
+ }
61
+ if (!hasLock) {
62
+ const msg = `withFileLock: could not acquire lock for "${path.basename(lockPath)}" within ${maxWait}ms`;
63
+ debugLog(msg);
64
+ throw new Error(msg);
65
+ }
66
+ }
67
+ function releaseFileLock(lockPath) {
68
+ try {
69
+ fs.unlinkSync(lockPath);
70
+ }
71
+ catch (err) {
72
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
73
+ process.stderr.write(`[phren] releaseFileLock: ${err instanceof Error ? err.message : String(err)}\n`);
74
+ }
75
+ }
76
+ // Q10: withFileLock now accepts both sync and async callbacks.
77
+ // When the callback returns a Promise, the lock file is held until the
78
+ // Promise settles — preventing concurrent processes from seeing partial state.
79
+ export function withFileLock(filePath, fn) {
80
+ const lockPath = filePath + ".lock";
81
+ acquireFileLock(lockPath);
82
+ let result;
83
+ try {
84
+ result = fn();
85
+ }
86
+ catch (err) {
87
+ releaseFileLock(lockPath);
88
+ throw err;
89
+ }
90
+ // If the callback returned a Promise, hold the lock until it settles.
91
+ if (result instanceof Promise) {
92
+ return result.then((value) => { releaseFileLock(lockPath); return value; }, (err) => { releaseFileLock(lockPath); throw err; });
93
+ }
94
+ releaseFileLock(lockPath);
95
+ return result;
96
+ }