@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,648 @@
1
+ import * as crypto from "crypto";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { appendAuditLog, debugLog, getProjectDirs, isRecord, runtimeHealthFile, withDefaults, phrenErr, PhrenError, phrenOk, resolveFindingsPath } from "./shared.js";
5
+ import { withFileLock } from "./governance-locks.js";
6
+ import { errorMessage, isValidProjectName, safeProjectPath } from "./utils.js";
7
+ import { runCustomHooks } from "./hooks.js";
8
+ import { METADATA_REGEX, isCitationLine, isArchiveStart as isArchiveStartMeta, isArchiveEnd as isArchiveEndMeta, stripLifecycleMetadata as stripLifecycleMetadataMeta, } from "./content-metadata.js";
9
+ export const MAX_QUEUE_ENTRY_LENGTH = 500;
10
+ export const GOVERNANCE_SCHEMA_VERSION = 1;
11
+ const DEFAULT_POLICY = {
12
+ schemaVersion: GOVERNANCE_SCHEMA_VERSION,
13
+ ttlDays: 120,
14
+ retentionDays: 365,
15
+ autoAcceptThreshold: 0.75,
16
+ minInjectConfidence: 0.35,
17
+ decay: {
18
+ d30: 1.0,
19
+ d60: 0.85,
20
+ d90: 0.65,
21
+ d120: 0.45,
22
+ },
23
+ };
24
+ const DEFAULT_WORKFLOW_POLICY = {
25
+ schemaVersion: GOVERNANCE_SCHEMA_VERSION,
26
+ lowConfidenceThreshold: 0.7,
27
+ riskySections: ["Stale", "Conflicts"],
28
+ taskMode: "auto",
29
+ findingSensitivity: "balanced",
30
+ };
31
+ const DEFAULT_INDEX_POLICY = {
32
+ schemaVersion: GOVERNANCE_SCHEMA_VERSION,
33
+ includeGlobs: ["**/*.md", "**/skills/**/*.md", ".claude/skills/**/*.md"],
34
+ excludeGlobs: ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/build/**"],
35
+ includeHidden: false,
36
+ };
37
+ const DEFAULT_RUNTIME_HEALTH = {
38
+ schemaVersion: GOVERNANCE_SCHEMA_VERSION,
39
+ };
40
+ function governanceDir(phrenPath) {
41
+ return path.join(phrenPath, ".governance");
42
+ }
43
+ function govFile(phrenPath, schema) {
44
+ return path.join(governanceDir(phrenPath), GOVERNANCE_REGISTRY[schema].file);
45
+ }
46
+ function hasValidSchemaVersion(data) {
47
+ return !("schemaVersion" in data) || typeof data.schemaVersion === "number";
48
+ }
49
+ function isStringArray(value) {
50
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
51
+ }
52
+ function isFiniteNumber(value) {
53
+ return typeof value === "number" && Number.isFinite(value);
54
+ }
55
+ function pickNumber(value, fallback) {
56
+ return isFiniteNumber(value) ? value : fallback;
57
+ }
58
+ function pickBoolean(value, fallback) {
59
+ return typeof value === "boolean" ? value : fallback;
60
+ }
61
+ function cleanStringArray(value, fallback) {
62
+ if (!Array.isArray(value))
63
+ return [...fallback];
64
+ const cleaned = value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
65
+ return cleaned.length ? cleaned : [...fallback];
66
+ }
67
+ const GOVERNANCE_VALIDATORS = {
68
+ "retention-policy": (data) => hasValidSchemaVersion(data)
69
+ && ["ttlDays", "retentionDays", "autoAcceptThreshold", "minInjectConfidence"].every((key) => !(key in data) || isFiniteNumber(data[key]))
70
+ && (!("decay" in data) || (() => {
71
+ if (!isRecord(data.decay))
72
+ return false;
73
+ const decay = data.decay;
74
+ return ["d30", "d60", "d90", "d120"].every((key) => !(key in decay) || isFiniteNumber(decay[key]));
75
+ })()),
76
+ "workflow-policy": (data) => hasValidSchemaVersion(data)
77
+ && (!("lowConfidenceThreshold" in data) || isFiniteNumber(data.lowConfidenceThreshold))
78
+ && (!("riskySections" in data) || isStringArray(data.riskySections))
79
+ && (!("taskMode" in data) || ["off", "manual", "suggest", "auto"].includes(String(data.taskMode))),
80
+ "index-policy": (data) => hasValidSchemaVersion(data)
81
+ && ["includeGlobs", "excludeGlobs"].every((key) => !(key in data) || isStringArray(data[key]))
82
+ && (!("includeHidden" in data) || typeof data.includeHidden === "boolean"),
83
+ };
84
+ const GOVERNANCE_REGISTRY = {
85
+ "retention-policy": {
86
+ file: "retention-policy.json",
87
+ validate: GOVERNANCE_VALIDATORS["retention-policy"],
88
+ defaults: () => ({ ...DEFAULT_POLICY }),
89
+ normalize: (data) => normalizeRetentionPolicy(data),
90
+ },
91
+ "workflow-policy": {
92
+ file: "workflow-policy.json",
93
+ validate: GOVERNANCE_VALIDATORS["workflow-policy"],
94
+ defaults: () => ({ ...DEFAULT_WORKFLOW_POLICY }),
95
+ normalize: (data) => normalizeWorkflowPolicy(data),
96
+ },
97
+ "index-policy": {
98
+ file: "index-policy.json",
99
+ validate: GOVERNANCE_VALIDATORS["index-policy"],
100
+ defaults: () => ({ ...DEFAULT_INDEX_POLICY }),
101
+ normalize: (data) => normalizeIndexPolicy(data),
102
+ },
103
+ };
104
+ const GOVERNANCE_FILE_SCHEMAS = Object.fromEntries(Object.entries(GOVERNANCE_REGISTRY).map(([schema, entry]) => [entry.file, schema]));
105
+ export function validateGovernanceJson(filePath, schema) {
106
+ try {
107
+ if (!fs.existsSync(filePath))
108
+ return true;
109
+ const raw = fs.readFileSync(filePath, "utf8");
110
+ const data = JSON.parse(raw);
111
+ if (!isRecord(data)) {
112
+ debugLog(`validateGovernanceJson: ${filePath} is not a JSON object`);
113
+ return false;
114
+ }
115
+ if (!GOVERNANCE_REGISTRY[schema].validate(data)) {
116
+ debugLog(`validateGovernanceJson: ${filePath} failed ${schema} schema check`);
117
+ return false;
118
+ }
119
+ return true;
120
+ }
121
+ catch (err) {
122
+ debugLog(`validateGovernanceJson parse error for ${filePath}: ${errorMessage(err)}`);
123
+ return false;
124
+ }
125
+ }
126
+ function extractGovernanceVersion(_schema, data) {
127
+ return typeof data.schemaVersion === "number" ? data.schemaVersion : 0;
128
+ }
129
+ function normalizeRuntimeHealth(data) {
130
+ const normalized = { schemaVersion: GOVERNANCE_SCHEMA_VERSION };
131
+ if (typeof data.lastSessionStartAt === "string")
132
+ normalized.lastSessionStartAt = data.lastSessionStartAt;
133
+ if (typeof data.lastPromptAt === "string")
134
+ normalized.lastPromptAt = data.lastPromptAt;
135
+ if (typeof data.lastStopAt === "string")
136
+ normalized.lastStopAt = data.lastStopAt;
137
+ if (isRecord(data.lastAutoSave) && typeof data.lastAutoSave.at === "string" && ["clean", "saved-local", "saved-pushed", "error"].includes(String(data.lastAutoSave.status))) {
138
+ normalized.lastAutoSave = {
139
+ at: data.lastAutoSave.at,
140
+ status: data.lastAutoSave.status,
141
+ detail: typeof data.lastAutoSave.detail === "string" ? data.lastAutoSave.detail : undefined,
142
+ };
143
+ }
144
+ if (isRecord(data.lastGovernance) && typeof data.lastGovernance.at === "string" && ["ok", "error"].includes(String(data.lastGovernance.status)) && typeof data.lastGovernance.detail === "string") {
145
+ normalized.lastGovernance = {
146
+ at: data.lastGovernance.at,
147
+ status: data.lastGovernance.status,
148
+ detail: data.lastGovernance.detail,
149
+ };
150
+ }
151
+ if (isRecord(data.lastSync)) {
152
+ normalized.lastSync = {};
153
+ if (typeof data.lastSync.lastPullAt === "string")
154
+ normalized.lastSync.lastPullAt = data.lastSync.lastPullAt;
155
+ if (["ok", "error"].includes(String(data.lastSync.lastPullStatus)))
156
+ normalized.lastSync.lastPullStatus = data.lastSync.lastPullStatus;
157
+ if (typeof data.lastSync.lastPullDetail === "string")
158
+ normalized.lastSync.lastPullDetail = data.lastSync.lastPullDetail;
159
+ if (typeof data.lastSync.lastSuccessfulPullAt === "string")
160
+ normalized.lastSync.lastSuccessfulPullAt = data.lastSync.lastSuccessfulPullAt;
161
+ if (typeof data.lastSync.lastPushAt === "string")
162
+ normalized.lastSync.lastPushAt = data.lastSync.lastPushAt;
163
+ if (["saved-local", "saved-pushed", "error"].includes(String(data.lastSync.lastPushStatus)))
164
+ normalized.lastSync.lastPushStatus = data.lastSync.lastPushStatus;
165
+ if (typeof data.lastSync.lastPushDetail === "string")
166
+ normalized.lastSync.lastPushDetail = data.lastSync.lastPushDetail;
167
+ if (isFiniteNumber(data.lastSync.unsyncedCommits))
168
+ normalized.lastSync.unsyncedCommits = data.lastSync.unsyncedCommits;
169
+ }
170
+ return normalized;
171
+ }
172
+ function normalizeRetentionPolicy(data) {
173
+ const decay = isRecord(data.decay) ? data.decay : {};
174
+ return {
175
+ schemaVersion: GOVERNANCE_SCHEMA_VERSION,
176
+ ttlDays: pickNumber(data.ttlDays, DEFAULT_POLICY.ttlDays),
177
+ retentionDays: pickNumber(data.retentionDays, DEFAULT_POLICY.retentionDays),
178
+ autoAcceptThreshold: pickNumber(data.autoAcceptThreshold, DEFAULT_POLICY.autoAcceptThreshold),
179
+ minInjectConfidence: pickNumber(data.minInjectConfidence, DEFAULT_POLICY.minInjectConfidence),
180
+ decay: {
181
+ d30: pickNumber(decay.d30, DEFAULT_POLICY.decay.d30),
182
+ d60: pickNumber(decay.d60, DEFAULT_POLICY.decay.d60),
183
+ d90: pickNumber(decay.d90, DEFAULT_POLICY.decay.d90),
184
+ d120: pickNumber(decay.d120, DEFAULT_POLICY.decay.d120),
185
+ },
186
+ };
187
+ }
188
+ function normalizeWorkflowPolicy(data) {
189
+ const validSections = new Set(["Review", "Stale", "Conflicts"]);
190
+ const taskMode = ["off", "manual", "suggest", "auto"].includes(String(data.taskMode))
191
+ ? String(data.taskMode)
192
+ : DEFAULT_WORKFLOW_POLICY.taskMode;
193
+ const riskySections = Array.isArray(data.riskySections)
194
+ ? data.riskySections.filter((section) => validSections.has(String(section)))
195
+ : [];
196
+ const findingSensitivity = ["minimal", "conservative", "balanced", "aggressive"].includes(String(data.findingSensitivity))
197
+ ? String(data.findingSensitivity)
198
+ : DEFAULT_WORKFLOW_POLICY.findingSensitivity;
199
+ return {
200
+ schemaVersion: GOVERNANCE_SCHEMA_VERSION,
201
+ lowConfidenceThreshold: pickNumber(data.lowConfidenceThreshold, DEFAULT_WORKFLOW_POLICY.lowConfidenceThreshold),
202
+ riskySections: riskySections.length ? riskySections : [...DEFAULT_WORKFLOW_POLICY.riskySections],
203
+ taskMode,
204
+ findingSensitivity,
205
+ };
206
+ }
207
+ function normalizeIndexPolicy(data) {
208
+ return {
209
+ schemaVersion: GOVERNANCE_SCHEMA_VERSION,
210
+ includeGlobs: cleanStringArray(data.includeGlobs, DEFAULT_INDEX_POLICY.includeGlobs),
211
+ excludeGlobs: cleanStringArray(data.excludeGlobs, DEFAULT_INDEX_POLICY.excludeGlobs),
212
+ includeHidden: pickBoolean(data.includeHidden, DEFAULT_INDEX_POLICY.includeHidden),
213
+ };
214
+ }
215
+ function readJsonFile(filePath, fallback) {
216
+ try {
217
+ if (!fs.existsSync(filePath))
218
+ return fallback;
219
+ const basename = path.basename(filePath);
220
+ const schema = GOVERNANCE_FILE_SCHEMAS[basename];
221
+ if (schema && !validateGovernanceJson(filePath, schema)) {
222
+ debugLog(`readJsonFile: ${filePath} failed validation, using defaults`);
223
+ return fallback;
224
+ }
225
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
226
+ const fileVersion = schema ? extractGovernanceVersion(schema, parsed) : (typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 0);
227
+ if (fileVersion > GOVERNANCE_SCHEMA_VERSION) {
228
+ debugLog(`Warning: ${filePath} has schemaVersion ${fileVersion}, expected <= ${GOVERNANCE_SCHEMA_VERSION}. Consider updating phren.`);
229
+ }
230
+ return parsed;
231
+ }
232
+ catch (err) {
233
+ debugLog(`readJsonFile failed for ${filePath}: ${errorMessage(err)}`);
234
+ return fallback;
235
+ }
236
+ }
237
+ function writeJsonFileUnlocked(filePath, data) {
238
+ const dir = path.dirname(filePath);
239
+ fs.mkdirSync(dir, { recursive: true });
240
+ const tmpPath = path.join(dir, `.tmp-${crypto.randomUUID()}`);
241
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
242
+ fs.renameSync(tmpPath, filePath);
243
+ }
244
+ function writeJsonFile(filePath, data) {
245
+ withFileLock(filePath, () => {
246
+ writeJsonFileUnlocked(filePath, data);
247
+ });
248
+ }
249
+ export function getRetentionPolicy(phrenPath) {
250
+ const parsed = readJsonFile(govFile(phrenPath, "retention-policy"), {});
251
+ return withDefaults(parsed, DEFAULT_POLICY);
252
+ }
253
+ export function updateRetentionPolicy(phrenPath, patch) {
254
+ const current = getRetentionPolicy(phrenPath);
255
+ const next = {
256
+ ...current,
257
+ ...patch,
258
+ decay: {
259
+ ...current.decay,
260
+ ...(patch.decay || {}),
261
+ },
262
+ };
263
+ writeJsonFile(govFile(phrenPath, "retention-policy"), next);
264
+ appendAuditLog(phrenPath, "update_policy", JSON.stringify(next));
265
+ return phrenOk(next);
266
+ }
267
+ export function getWorkflowPolicy(phrenPath) {
268
+ const parsed = readJsonFile(govFile(phrenPath, "workflow-policy"), {});
269
+ const merged = withDefaults(parsed, DEFAULT_WORKFLOW_POLICY);
270
+ const validSections = new Set(["Review", "Stale", "Conflicts"]);
271
+ merged.riskySections = merged.riskySections.filter((section) => validSections.has(section));
272
+ if (!merged.riskySections.length)
273
+ merged.riskySections = DEFAULT_WORKFLOW_POLICY.riskySections;
274
+ if (!["off", "manual", "suggest", "auto"].includes(merged.taskMode)) {
275
+ merged.taskMode = DEFAULT_WORKFLOW_POLICY.taskMode;
276
+ }
277
+ if (!["minimal", "conservative", "balanced", "aggressive"].includes(merged.findingSensitivity)) {
278
+ merged.findingSensitivity = DEFAULT_WORKFLOW_POLICY.findingSensitivity;
279
+ }
280
+ return merged;
281
+ }
282
+ export function updateWorkflowPolicy(phrenPath, patch) {
283
+ const current = getWorkflowPolicy(phrenPath);
284
+ const riskySections = Array.isArray(patch.riskySections)
285
+ ? patch.riskySections.filter((section) => ["Review", "Stale", "Conflicts"].includes(String(section)))
286
+ : current.riskySections;
287
+ const taskMode = patch.taskMode && ["off", "manual", "suggest", "auto"].includes(String(patch.taskMode))
288
+ ? patch.taskMode
289
+ : current.taskMode;
290
+ const findingSensitivity = patch.findingSensitivity && ["minimal", "conservative", "balanced", "aggressive"].includes(String(patch.findingSensitivity))
291
+ ? patch.findingSensitivity
292
+ : current.findingSensitivity;
293
+ const next = {
294
+ schemaVersion: current.schemaVersion ?? GOVERNANCE_SCHEMA_VERSION,
295
+ lowConfidenceThreshold: patch.lowConfidenceThreshold ?? current.lowConfidenceThreshold,
296
+ riskySections: riskySections.length ? riskySections : current.riskySections,
297
+ taskMode,
298
+ findingSensitivity,
299
+ };
300
+ writeJsonFile(govFile(phrenPath, "workflow-policy"), next);
301
+ appendAuditLog(phrenPath, "update_workflow_policy", JSON.stringify(next));
302
+ return phrenOk(next);
303
+ }
304
+ export function getIndexPolicy(phrenPath) {
305
+ const parsed = readJsonFile(govFile(phrenPath, "index-policy"), {});
306
+ const merged = withDefaults(parsed, DEFAULT_INDEX_POLICY);
307
+ merged.includeGlobs = merged.includeGlobs.filter((glob) => typeof glob === "string" && glob.trim().length > 0);
308
+ merged.excludeGlobs = merged.excludeGlobs.filter((glob) => typeof glob === "string" && glob.trim().length > 0);
309
+ if (!merged.includeGlobs.length)
310
+ merged.includeGlobs = DEFAULT_INDEX_POLICY.includeGlobs;
311
+ if (!merged.excludeGlobs.length)
312
+ merged.excludeGlobs = DEFAULT_INDEX_POLICY.excludeGlobs;
313
+ return merged;
314
+ }
315
+ export function updateIndexPolicy(phrenPath, patch) {
316
+ const current = getIndexPolicy(phrenPath);
317
+ const next = {
318
+ schemaVersion: current.schemaVersion ?? GOVERNANCE_SCHEMA_VERSION,
319
+ includeGlobs: Array.isArray(patch.includeGlobs)
320
+ ? patch.includeGlobs.filter((glob) => typeof glob === "string" && glob.trim().length > 0)
321
+ : current.includeGlobs,
322
+ excludeGlobs: Array.isArray(patch.excludeGlobs)
323
+ ? patch.excludeGlobs.filter((glob) => typeof glob === "string" && glob.trim().length > 0)
324
+ : current.excludeGlobs,
325
+ includeHidden: patch.includeHidden ?? current.includeHidden,
326
+ };
327
+ writeJsonFile(govFile(phrenPath, "index-policy"), next);
328
+ appendAuditLog(phrenPath, "update_index_policy", JSON.stringify(next));
329
+ return phrenOk(next);
330
+ }
331
+ export function getRuntimeHealth(phrenPath) {
332
+ const parsed = readJsonFile(runtimeHealthFile(phrenPath), {});
333
+ if (!isRecord(parsed))
334
+ return { ...DEFAULT_RUNTIME_HEALTH };
335
+ return normalizeRuntimeHealth(parsed);
336
+ }
337
+ export function updateRuntimeHealth(phrenPath, patch) {
338
+ const file = runtimeHealthFile(phrenPath);
339
+ return withFileLock(file, () => {
340
+ const parsed = readJsonFile(file, {});
341
+ const current = isRecord(parsed) ? normalizeRuntimeHealth(parsed) : { ...DEFAULT_RUNTIME_HEALTH };
342
+ const next = {
343
+ schemaVersion: current.schemaVersion ?? GOVERNANCE_SCHEMA_VERSION,
344
+ ...current,
345
+ ...patch,
346
+ lastAutoSave: patch.lastAutoSave ?? current.lastAutoSave,
347
+ lastGovernance: patch.lastGovernance ?? current.lastGovernance,
348
+ lastSync: patch.lastSync ? { ...(current.lastSync ?? {}), ...patch.lastSync } : current.lastSync,
349
+ };
350
+ writeJsonFileUnlocked(file, next);
351
+ return next;
352
+ });
353
+ }
354
+ function normalizeBulletForQueue(line) {
355
+ return line.startsWith("- ") ? line.slice(2).trim() : line.trim();
356
+ }
357
+ function cleanQueueEntryText(raw) {
358
+ return String(raw ?? "")
359
+ .replace(/\r\n?/g, "\n")
360
+ .replace(/\0/g, " ")
361
+ .replace(/<!--[\s\S]*?-->/g, " ")
362
+ .replace(/\\[nrt]/g, " ")
363
+ .replace(/\\"/g, "\"")
364
+ .replace(/\\\\/g, "\\")
365
+ .replace(/\n+/g, " ")
366
+ .replace(/\s+/g, " ")
367
+ .trim();
368
+ }
369
+ export function normalizeQueueEntryText(raw, opts = {}) {
370
+ const cleaned = cleanQueueEntryText(raw);
371
+ if (!cleaned)
372
+ return phrenErr("Memory text cannot be empty.", PhrenError.EMPTY_INPUT);
373
+ if (cleaned.length <= MAX_QUEUE_ENTRY_LENGTH) {
374
+ return phrenOk({ text: cleaned, truncated: false });
375
+ }
376
+ if (!opts.truncate) {
377
+ return phrenErr(`Memory text exceeds maximum length of ${MAX_QUEUE_ENTRY_LENGTH} characters (got ${cleaned.length}). Shorten it before saving.`, PhrenError.VALIDATION_ERROR);
378
+ }
379
+ return phrenOk({
380
+ text: cleaned.slice(0, MAX_QUEUE_ENTRY_LENGTH - 1).trimEnd() + "…",
381
+ truncated: true,
382
+ });
383
+ }
384
+ export function appendReviewQueue(phrenPath, project, section, entries) {
385
+ if (!isValidProjectName(project))
386
+ return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
387
+ const resolvedDir = safeProjectPath(phrenPath, project);
388
+ if (!resolvedDir || !fs.existsSync(resolvedDir))
389
+ return phrenErr(`Project "${project}" not found in phren.`, PhrenError.PROJECT_NOT_FOUND);
390
+ const queuePath = path.join(resolvedDir, "MEMORY_QUEUE.md");
391
+ const today = new Date().toISOString().slice(0, 10);
392
+ const normalized = [];
393
+ for (const entry of entries) {
394
+ const sanitized = normalizeQueueEntryText(normalizeBulletForQueue(entry), { truncate: true });
395
+ if (!sanitized.ok)
396
+ continue;
397
+ if (sanitized.data.truncated) {
398
+ debugLog(`appendReviewQueue: truncated oversized queue entry for ${project}`);
399
+ }
400
+ normalized.push(sanitized.data.text);
401
+ }
402
+ if (normalized.length === 0)
403
+ return phrenOk(0);
404
+ return withFileLock(queuePath, () => {
405
+ let content = "";
406
+ if (fs.existsSync(queuePath)) {
407
+ content = fs.readFileSync(queuePath, "utf8");
408
+ }
409
+ else {
410
+ content = `# ${project} Review Queue\n\n## Review\n\n## Stale\n\n## Conflicts\n`;
411
+ }
412
+ const lines = content.split("\n");
413
+ const secHeader = `## ${section}`;
414
+ let secIdx = lines.findIndex((line) => line.trim() === secHeader);
415
+ if (secIdx === -1) {
416
+ lines.push("", secHeader, "");
417
+ secIdx = lines.length - 2;
418
+ }
419
+ let insertAt = secIdx + 1;
420
+ while (insertAt < lines.length && !lines[insertAt].startsWith("## "))
421
+ insertAt++;
422
+ const existing = new Set(lines.map((line) => line.trim()));
423
+ const toInsert = [];
424
+ for (const entry of normalized) {
425
+ const line = `- [${today}] ${entry}`;
426
+ if (!existing.has(line))
427
+ toInsert.push(line);
428
+ }
429
+ if (!toInsert.length)
430
+ return phrenOk(0);
431
+ lines.splice(insertAt, 0, ...toInsert, "");
432
+ fs.writeFileSync(queuePath, lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n");
433
+ return phrenOk(toInsert.length);
434
+ });
435
+ }
436
+ export function pruneDeadMemories(phrenPath, project, dryRun) {
437
+ const policy = getRetentionPolicy(phrenPath);
438
+ if (project && !isValidProjectName(project))
439
+ return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
440
+ const dirs = project
441
+ ? (() => {
442
+ const resolvedProject = safeProjectPath(phrenPath, project);
443
+ return resolvedProject ? [resolvedProject] : [];
444
+ })()
445
+ : getProjectDirs(phrenPath).filter((dir) => path.basename(dir) !== "global");
446
+ let pruned = 0;
447
+ const cutoffDays = policy.retentionDays;
448
+ const dryRunDetails = [];
449
+ for (const dir of dirs) {
450
+ const file = resolveFindingsPath(dir);
451
+ if (!file)
452
+ continue;
453
+ // Q23: wrap read-modify-write in per-file lock to prevent races with concurrent finding writers
454
+ withFileLock(file, () => {
455
+ const lines = fs.readFileSync(file, "utf8").split("\n");
456
+ let currentDate = null;
457
+ const next = [];
458
+ let inArchive = false;
459
+ for (let index = 0; index < lines.length; index++) {
460
+ const line = lines[index];
461
+ // Detect archive block start (both <details> and phren:archive:start markers)
462
+ if (isArchiveStartMeta(line)) {
463
+ inArchive = true;
464
+ next.push(line);
465
+ continue;
466
+ }
467
+ // Detect archive block end
468
+ if (isArchiveEndMeta(line)) {
469
+ inArchive = false;
470
+ next.push(line);
471
+ continue;
472
+ }
473
+ const heading = line.match(/^## (\d{4}-\d{2}-\d{2})$/);
474
+ if (heading) {
475
+ currentDate = heading[1];
476
+ next.push(line);
477
+ continue;
478
+ }
479
+ if (line.startsWith("- ") && !inArchive && currentDate) {
480
+ const age = Math.floor((Date.now() - Date.parse(`${currentDate}T00:00:00Z`)) / 86_400_000);
481
+ if (!Number.isNaN(age) && age > cutoffDays) {
482
+ pruned++;
483
+ if (dryRun)
484
+ dryRunDetails.push(`[${path.basename(dir)}] ${line.slice(0, 80)}`);
485
+ const nextLine = lines[index + 1] || "";
486
+ if (isCitationLine(nextLine)) {
487
+ index++;
488
+ }
489
+ continue;
490
+ }
491
+ }
492
+ if (isCitationLine(line)) {
493
+ const previous = next.length ? next[next.length - 1] : "";
494
+ if (!previous.startsWith("- "))
495
+ continue;
496
+ }
497
+ next.push(line);
498
+ }
499
+ if (!dryRun) {
500
+ const tmpFile = file + `.tmp-${crypto.randomUUID()}`;
501
+ fs.writeFileSync(tmpFile, next.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n");
502
+ fs.renameSync(tmpFile, file);
503
+ }
504
+ });
505
+ }
506
+ if (dryRun) {
507
+ const summary = `[dry-run] Would prune ${pruned} stale memory entr${pruned === 1 ? "y" : "ies"}.`;
508
+ return phrenOk(dryRunDetails.length ? `${summary}\n${dryRunDetails.join("\n")}` : summary);
509
+ }
510
+ appendAuditLog(phrenPath, "prune_memories", `project=${project || "all"} pruned=${pruned}`);
511
+ return phrenOk(`Pruned ${pruned} stale memory entr${pruned === 1 ? "y" : "ies"}.`);
512
+ }
513
+ function mergeLifecycleAndIdComments(primary, fallback) {
514
+ const extract = (line, pattern) => line.match(pattern)?.[0];
515
+ const strip = (line) => {
516
+ let result = line.replace(/\s*<!--\s*fid:[a-z0-9]{8}\s*-->/gi, "");
517
+ result = stripLifecycleMetadataMeta(result);
518
+ return result;
519
+ };
520
+ const fid = extract(primary, METADATA_REGEX.findingId) ?? extract(fallback, METADATA_REGEX.findingId);
521
+ const status = extract(primary, METADATA_REGEX.status) ?? extract(fallback, METADATA_REGEX.status);
522
+ const statusUpdated = extract(primary, METADATA_REGEX.statusUpdated) ?? extract(fallback, METADATA_REGEX.statusUpdated);
523
+ const statusReason = extract(primary, METADATA_REGEX.statusReason) ?? extract(fallback, METADATA_REGEX.statusReason);
524
+ const statusRef = extract(primary, METADATA_REGEX.statusRef) ?? extract(fallback, METADATA_REGEX.statusRef);
525
+ const base = strip(primary).trimEnd();
526
+ const suffix = [fid, status, statusUpdated, statusReason, statusRef].filter((part) => Boolean(part));
527
+ return suffix.length > 0 ? `${base} ${suffix.join(" ")}` : base;
528
+ }
529
+ export function consolidateProjectFindings(phrenPath, project, dryRun) {
530
+ if (!isValidProjectName(project))
531
+ return phrenErr(`Invalid project name: "${project}".`, PhrenError.INVALID_PROJECT_NAME);
532
+ const file = resolveFindingsPath(path.join(phrenPath, project));
533
+ if (!file)
534
+ return phrenErr(`No FINDINGS.md found for "${project}".`, PhrenError.FILE_NOT_FOUND);
535
+ // Q23: wrap entire read-modify-write in per-file lock to prevent races with concurrent finding writers
536
+ const result = withFileLock(file, () => {
537
+ const raw = fs.readFileSync(file, "utf8");
538
+ const lines = raw.split("\n");
539
+ // Q12: Separate the file into "active" lines and verbatim archive/details blocks.
540
+ // Archive blocks (<!-- phren:archive:start/end --> and <details>...</details>) are
541
+ // collected verbatim and appended unchanged after the consolidated active section.
542
+ const archiveBlocks = [];
543
+ const activeLines = [];
544
+ let inArchive = false;
545
+ let currentArchiveBlock = [];
546
+ for (const line of lines) {
547
+ const archiveStart = isArchiveStartMeta(line);
548
+ const archiveEnd = isArchiveEndMeta(line);
549
+ if (!inArchive && archiveStart) {
550
+ inArchive = true;
551
+ currentArchiveBlock = [line];
552
+ // If the start and end are on the same line, close immediately
553
+ if (archiveEnd && isArchiveStartMeta(line) && isArchiveEndMeta(line)) {
554
+ archiveBlocks.push(...currentArchiveBlock);
555
+ currentArchiveBlock = [];
556
+ inArchive = false;
557
+ }
558
+ continue;
559
+ }
560
+ if (inArchive) {
561
+ currentArchiveBlock.push(line);
562
+ if (archiveEnd) {
563
+ archiveBlocks.push(...currentArchiveBlock);
564
+ currentArchiveBlock = [];
565
+ inArchive = false;
566
+ }
567
+ continue;
568
+ }
569
+ activeLines.push(line);
570
+ }
571
+ // Any unclosed archive block goes to archive verbatim
572
+ if (currentArchiveBlock.length)
573
+ archiveBlocks.push(...currentArchiveBlock);
574
+ // Process only the active section: deduplicate bullets within each date group
575
+ const byDate = new Map();
576
+ let currentDate = null;
577
+ const title = activeLines.find((line) => line.startsWith("# ")) || `# ${project} Findings`;
578
+ let totalBullets = 0;
579
+ let uniqueBullets = 0;
580
+ for (let index = 0; index < activeLines.length; index++) {
581
+ const line = activeLines[index];
582
+ const heading = line.match(/^## (\d{4}-\d{2}-\d{2})$/);
583
+ if (heading) {
584
+ const date = heading[1];
585
+ currentDate = date;
586
+ if (!byDate.has(date))
587
+ byDate.set(date, new Map());
588
+ continue;
589
+ }
590
+ if (line.startsWith("- ") && currentDate) {
591
+ totalBullets++;
592
+ const key = line.trim().toLowerCase().replace(METADATA_REGEX.findingId, "").replace(/\s+/g, " ");
593
+ const nextLine = activeLines[index + 1] || "";
594
+ const citation = isCitationLine(nextLine) ? nextLine : undefined;
595
+ const trimmedBullet = line.trimEnd();
596
+ const existing = byDate.get(currentDate)?.get(key);
597
+ if (!existing) {
598
+ byDate.get(currentDate)?.set(key, { bullet: trimmedBullet, citation });
599
+ uniqueBullets++;
600
+ }
601
+ else {
602
+ existing.bullet = mergeLifecycleAndIdComments(existing.bullet, trimmedBullet);
603
+ if (!existing.citation && citation)
604
+ existing.citation = citation;
605
+ }
606
+ if (citation)
607
+ index++;
608
+ }
609
+ }
610
+ const dates = [...byDate.keys()].sort().reverse();
611
+ const duplicatesRemoved = totalBullets - uniqueBullets;
612
+ if (dryRun) {
613
+ return phrenOk(`[dry-run] ${project}: ${totalBullets} bullets, ${duplicatesRemoved} duplicate(s) would be removed, ${dates.length} date section(s).`);
614
+ }
615
+ // Reconstruct: consolidated active section first, then verbatim archive blocks
616
+ const out = [title, ""];
617
+ for (const date of dates) {
618
+ const items = [...(byDate.get(date)?.values() || [])];
619
+ if (!items.length)
620
+ continue;
621
+ out.push(`## ${date}`, "");
622
+ for (const item of items) {
623
+ out.push(item.bullet);
624
+ if (item.citation)
625
+ out.push(item.citation);
626
+ }
627
+ out.push("");
628
+ }
629
+ // Append archive blocks verbatim (separated by a blank line if there's active content)
630
+ if (archiveBlocks.length) {
631
+ if (out.length && out[out.length - 1] !== "")
632
+ out.push("");
633
+ out.push(...archiveBlocks);
634
+ }
635
+ fs.copyFileSync(file, file + ".bak");
636
+ const tmpFile = file + `.tmp-${crypto.randomUUID()}`;
637
+ fs.writeFileSync(tmpFile, out.join("\n").trimEnd() + "\n");
638
+ fs.renameSync(tmpFile, file);
639
+ appendAuditLog(phrenPath, "consolidate_project", `project=${project} dates=${dates.length}`);
640
+ return phrenOk(`Consolidated findings for ${project}.`);
641
+ });
642
+ // Fire post-consolidate hook outside the file lock to avoid deadlock
643
+ // if the hook command reads or writes FINDINGS.md.
644
+ if (result.ok) {
645
+ runCustomHooks(phrenPath, "post-consolidate", { PHREN_PROJECT: project });
646
+ }
647
+ return result;
648
+ }