@phren/cli 0.0.6 → 0.0.7
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.
- package/README.md +3 -3
- package/mcp/dist/cli-hooks-session.js +9 -3
- package/mcp/dist/cli-hooks.js +2 -2
- package/mcp/dist/content-learning.js +3 -3
- package/mcp/dist/data-access.js +36 -3
- package/mcp/dist/finding-lifecycle.js +56 -29
- package/mcp/dist/governance-locks.js +11 -6
- package/mcp/dist/index.js +2 -1
- package/mcp/dist/init-preferences.js +18 -3
- package/mcp/dist/init.js +11 -0
- package/mcp/dist/mcp-config.js +0 -8
- package/mcp/dist/mcp-data.js +22 -3
- package/mcp/dist/mcp-extract.js +1 -0
- package/mcp/dist/mcp-finding.js +1 -1
- package/mcp/dist/mcp-hooks.js +36 -16
- package/mcp/dist/mcp-memory.js +0 -1
- package/mcp/dist/mcp-ops.js +5 -10
- package/mcp/dist/mcp-search.js +7 -1
- package/mcp/dist/mcp-session.js +13 -12
- package/mcp/dist/mcp-skills.js +5 -2
- package/mcp/dist/mcp-tasks.js +7 -4
- package/mcp/dist/memory-ui-assets.js +2 -2
- package/mcp/dist/memory-ui-data.js +2 -2
- package/mcp/dist/memory-ui-graph.js +178 -23
- package/mcp/dist/project-config.js +37 -18
- package/mcp/dist/shared-retrieval.js +4 -4
- package/mcp/dist/shared.js +1 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
|
-
|
|
11
|
+
Every time you start a new session, your AI agent forgets everything it learned. Phren fixes that — findings, decisions, and patterns persist as markdown in a git repo you control. No database, no hosted service, no vendor lock-in. Works across sessions, projects, and machines.
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
---
|
|
@@ -51,8 +51,8 @@ To add a project later, run `phren add` from that directory. To browse what phre
|
|
|
51
51
|
|
|
52
52
|
- [Documentation site](https://alaarab.github.io/phren/)
|
|
53
53
|
- [Whitepaper (PDF)](https://alaarab.github.io/phren/whitepaper.pdf)
|
|
54
|
-
- [Architecture](docs/architecture.md)
|
|
55
|
-
- [Contributing](CONTRIBUTING.md)
|
|
54
|
+
- [Architecture](docs/architecture.md) — how retrieval, governance, and persistence fit together
|
|
55
|
+
- [Contributing](CONTRIBUTING.md) — how to add tools, skills, and tests
|
|
56
56
|
- [Security](SECURITY.md)
|
|
57
57
|
|
|
58
58
|
---
|
|
@@ -867,9 +867,15 @@ export async function handleHookStop() {
|
|
|
867
867
|
appendAuditLog(phrenPath, "hook_stop", "status=clean");
|
|
868
868
|
return;
|
|
869
869
|
}
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
|
|
870
|
+
// Stage all changes first, then unstage any sensitive files that slipped
|
|
871
|
+
// through. Using pathspec exclusions with `git add -A` can fail when
|
|
872
|
+
// excluded paths are also gitignored (git treats the pathspec as an error).
|
|
873
|
+
let add = await runBestEffortGit(["add", "-A"], phrenPath);
|
|
874
|
+
if (add.ok) {
|
|
875
|
+
// Belt-and-suspenders: unstage sensitive files that .gitignore should
|
|
876
|
+
// already block. Failures here are non-fatal (files may not exist).
|
|
877
|
+
await runBestEffortGit(["reset", "HEAD", "--", ".env", "**/.env", "*.pem", "*.key"], phrenPath);
|
|
878
|
+
}
|
|
873
879
|
let commitMsg = "auto-save phren";
|
|
874
880
|
if (add.ok) {
|
|
875
881
|
const diff = await runBestEffortGit(["diff", "--cached", "--stat", "--no-color"], phrenPath);
|
package/mcp/dist/cli-hooks.js
CHANGED
|
@@ -345,7 +345,7 @@ export async function handleHookPrompt() {
|
|
|
345
345
|
parts.push(`Findings ready for consolidation:`);
|
|
346
346
|
parts.push(notices.join("\n"));
|
|
347
347
|
parts.push(`Run phren-consolidate when ready.`);
|
|
348
|
-
parts.push(
|
|
348
|
+
parts.push(`</phren-notice>`);
|
|
349
349
|
}
|
|
350
350
|
if (noticeFile) {
|
|
351
351
|
try {
|
|
@@ -367,7 +367,7 @@ export async function handleHookPrompt() {
|
|
|
367
367
|
}
|
|
368
368
|
catch (err) {
|
|
369
369
|
const msg = errorMessage(err);
|
|
370
|
-
process.stdout.write(`\n<phren-error>phren hook failed: ${msg}. Check ~/.phren/.runtime/debug.log for details
|
|
370
|
+
process.stdout.write(`\n<phren-error>phren hook failed: ${msg}. Check ~/.phren/.runtime/debug.log for details.</phren-error>\n`);
|
|
371
371
|
debugLog(`hook-prompt error: ${msg}`);
|
|
372
372
|
process.exit(0);
|
|
373
373
|
}
|
|
@@ -71,12 +71,12 @@ function detectFindingProvenanceSource(explicitSource) {
|
|
|
71
71
|
return "extract";
|
|
72
72
|
if ((process.env.PHREN_HOOK_TOOL))
|
|
73
73
|
return "hook";
|
|
74
|
-
if (
|
|
74
|
+
if (process.env.PHREN_ACTOR?.trim())
|
|
75
75
|
return "agent";
|
|
76
76
|
return "human";
|
|
77
77
|
}
|
|
78
78
|
function buildFindingSource(sessionId, explicitSource, scope) {
|
|
79
|
-
const actor =
|
|
79
|
+
const actor = process.env.PHREN_ACTOR?.trim() || undefined;
|
|
80
80
|
const source = {
|
|
81
81
|
source: detectFindingProvenanceSource(explicitSource),
|
|
82
82
|
machine: getMachineName(),
|
|
@@ -355,7 +355,7 @@ export function addFindingToFile(phrenPath, project, learning, citationInput, op
|
|
|
355
355
|
if (!newLines[i].startsWith("- "))
|
|
356
356
|
continue;
|
|
357
357
|
if (newLines[i].includes(prepared.finding.bullet.slice(0, 40))) {
|
|
358
|
-
if (!newLines[i].includes("phren:supersedes")
|
|
358
|
+
if (!newLines[i].includes("phren:supersedes")) {
|
|
359
359
|
const supersedesFirst60 = supersedesText.slice(0, 60);
|
|
360
360
|
newLines[i] = `${newLines[i]} <!-- phren:supersedes "${supersedesFirst60}" -->`;
|
|
361
361
|
}
|
package/mcp/dist/data-access.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import
|
|
3
|
+
import * as yaml from "js-yaml";
|
|
4
|
+
import { phrenErr, PhrenError, phrenOk, forwardErr, getProjectDirs, isRecord, } from "./shared.js";
|
|
4
5
|
import { normalizeQueueEntryText, withFileLock as withFileLockRaw, } from "./shared-governance.js";
|
|
5
6
|
import { addFindingToFile, } from "./shared-content.js";
|
|
6
7
|
import { isValidProjectName, queueFilePath, safeProjectPath, errorMessage } from "./utils.js";
|
|
@@ -94,6 +95,29 @@ function findMatchingFindingBullet(bulletLines, needle, match) {
|
|
|
94
95
|
}
|
|
95
96
|
return { kind: "not_found" };
|
|
96
97
|
}
|
|
98
|
+
function validateAggregateQueueProfile(phrenPath, profile) {
|
|
99
|
+
if (!profile)
|
|
100
|
+
return phrenOk(undefined);
|
|
101
|
+
if (!isValidProjectName(profile)) {
|
|
102
|
+
return phrenErr(`Invalid PHREN_PROFILE value: ${profile}`, PhrenError.VALIDATION_ERROR);
|
|
103
|
+
}
|
|
104
|
+
const profilePath = path.join(phrenPath, "profiles", `${profile}.yaml`);
|
|
105
|
+
if (!fs.existsSync(profilePath)) {
|
|
106
|
+
return phrenErr(`Profile file not found: ${profilePath}`, PhrenError.FILE_NOT_FOUND);
|
|
107
|
+
}
|
|
108
|
+
let data;
|
|
109
|
+
try {
|
|
110
|
+
data = yaml.load(fs.readFileSync(profilePath, "utf-8"), { schema: yaml.CORE_SCHEMA });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return phrenErr(`Malformed profile YAML: ${profilePath}`, PhrenError.MALFORMED_YAML);
|
|
114
|
+
}
|
|
115
|
+
const projects = isRecord(data) ? data.projects : undefined;
|
|
116
|
+
if (!Array.isArray(projects)) {
|
|
117
|
+
return phrenErr(`Profile YAML missing valid "projects" array: ${profilePath}`, PhrenError.MALFORMED_YAML);
|
|
118
|
+
}
|
|
119
|
+
return phrenOk(undefined);
|
|
120
|
+
}
|
|
97
121
|
export function readFindings(phrenPath, project, opts = {}) {
|
|
98
122
|
const ensured = ensureProject(phrenPath, project);
|
|
99
123
|
if (!ensured.ok)
|
|
@@ -243,7 +267,10 @@ export function removeFinding(phrenPath, project, match) {
|
|
|
243
267
|
const ensured = ensureProject(phrenPath, project);
|
|
244
268
|
if (!ensured.ok)
|
|
245
269
|
return forwardErr(ensured);
|
|
246
|
-
const findingsPath = path.join(ensured.data, 'FINDINGS.md');
|
|
270
|
+
const findingsPath = path.resolve(path.join(ensured.data, 'FINDINGS.md'));
|
|
271
|
+
if (!findingsPath.startsWith(phrenPath + path.sep) && findingsPath !== phrenPath) {
|
|
272
|
+
return phrenErr(`FINDINGS.md path escapes phren store`, PhrenError.VALIDATION_ERROR);
|
|
273
|
+
}
|
|
247
274
|
const filePath = findingsPath;
|
|
248
275
|
if (!fs.existsSync(filePath))
|
|
249
276
|
return phrenErr(`No FINDINGS.md file found for "${project}". Add a finding first with add_finding or :find add.`, PhrenError.FILE_NOT_FOUND);
|
|
@@ -278,7 +305,10 @@ export function editFinding(phrenPath, project, oldText, newText) {
|
|
|
278
305
|
const newTextTrimmed = newText.trim();
|
|
279
306
|
if (!newTextTrimmed)
|
|
280
307
|
return phrenErr("New finding text cannot be empty.", PhrenError.EMPTY_INPUT);
|
|
281
|
-
const findingsPath = path.join(ensured.data, "FINDINGS.md");
|
|
308
|
+
const findingsPath = path.resolve(path.join(ensured.data, "FINDINGS.md"));
|
|
309
|
+
if (!findingsPath.startsWith(phrenPath + path.sep) && findingsPath !== phrenPath) {
|
|
310
|
+
return phrenErr(`FINDINGS.md path escapes phren store`, PhrenError.VALIDATION_ERROR);
|
|
311
|
+
}
|
|
282
312
|
if (!fs.existsSync(findingsPath))
|
|
283
313
|
return phrenErr(`No FINDINGS.md file found for "${project}".`, PhrenError.FILE_NOT_FOUND);
|
|
284
314
|
return withSafeLock(findingsPath, () => {
|
|
@@ -376,6 +406,9 @@ export function readReviewQueue(phrenPath, project) {
|
|
|
376
406
|
return phrenOk(items);
|
|
377
407
|
}
|
|
378
408
|
export function readReviewQueueAcrossProjects(phrenPath, profile) {
|
|
409
|
+
const validation = validateAggregateQueueProfile(phrenPath, profile);
|
|
410
|
+
if (!validation.ok)
|
|
411
|
+
return validation;
|
|
379
412
|
const projects = getProjectDirs(phrenPath, profile)
|
|
380
413
|
.map((dir) => path.basename(dir))
|
|
381
414
|
.filter((project) => project !== "global")
|
|
@@ -5,7 +5,7 @@ import { PhrenError, phrenErr, phrenOk } from "./phren-core.js";
|
|
|
5
5
|
const LIFECYCLE_PREFIX = "phren";
|
|
6
6
|
import { withFileLock } from "./governance-locks.js";
|
|
7
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";
|
|
8
|
+
import { isArchiveEnd, isArchiveStart, parseCreatedDate as parseCreatedDateMeta, parseStatusField, parseStatus, parseSupersession, parseContradiction, parseFindingId as parseFindingIdMeta, stripLifecycleMetadata, stripRelationMetadata, } from "./content-metadata.js";
|
|
9
9
|
export const FINDING_TYPE_DECAY = {
|
|
10
10
|
'pattern': { maxAgeDays: 365, decayMultiplier: 1.0 }, // Slow decay, long-lived
|
|
11
11
|
'decision': { maxAgeDays: Infinity, decayMultiplier: 1.0 }, // Never decays
|
|
@@ -109,6 +109,49 @@ function normalizeFindingText(value) {
|
|
|
109
109
|
function removeRelationComments(line) {
|
|
110
110
|
return stripRelationMetadata(line);
|
|
111
111
|
}
|
|
112
|
+
function collectFindingBulletLines(lines) {
|
|
113
|
+
const bulletLines = [];
|
|
114
|
+
let inArchiveBlock = false;
|
|
115
|
+
for (let i = 0; i < lines.length; i++) {
|
|
116
|
+
const line = lines[i];
|
|
117
|
+
if (isArchiveStart(line)) {
|
|
118
|
+
inArchiveBlock = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (isArchiveEnd(line)) {
|
|
122
|
+
inArchiveBlock = false;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!line.startsWith("- "))
|
|
126
|
+
continue;
|
|
127
|
+
bulletLines.push({ line, index: i, archived: inArchiveBlock });
|
|
128
|
+
}
|
|
129
|
+
return bulletLines;
|
|
130
|
+
}
|
|
131
|
+
function selectMatchingFinding(bulletLines, needle, match) {
|
|
132
|
+
const fidNeedle = needle.replace(/^fid:/, "");
|
|
133
|
+
const fidMatches = /^[a-z0-9]{8}$/.test(fidNeedle)
|
|
134
|
+
? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`, "i").test(line))
|
|
135
|
+
: [];
|
|
136
|
+
const exactMatches = bulletLines.filter(({ line }) => normalizeFindingText(line) === needle);
|
|
137
|
+
const partialMatches = bulletLines.filter(({ line }) => {
|
|
138
|
+
const clean = normalizeFindingText(line);
|
|
139
|
+
return clean.includes(needle) || line.toLowerCase().includes(needle);
|
|
140
|
+
});
|
|
141
|
+
if (fidMatches.length === 1)
|
|
142
|
+
return phrenOk(fidMatches[0]);
|
|
143
|
+
if (exactMatches.length === 1)
|
|
144
|
+
return phrenOk(exactMatches[0]);
|
|
145
|
+
if (exactMatches.length > 1) {
|
|
146
|
+
return phrenErr(`"${match}" is ambiguous (${exactMatches.length} exact matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
147
|
+
}
|
|
148
|
+
if (partialMatches.length === 1)
|
|
149
|
+
return phrenOk(partialMatches[0]);
|
|
150
|
+
if (partialMatches.length > 1) {
|
|
151
|
+
return phrenErr(`"${match}" is ambiguous (${partialMatches.length} partial matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
152
|
+
}
|
|
153
|
+
return phrenOk(null);
|
|
154
|
+
}
|
|
112
155
|
function applyLifecycle(line, lifecycle, today, opts) {
|
|
113
156
|
let updated = stripLifecycleComments(removeRelationComments(line)).trimEnd();
|
|
114
157
|
if (opts?.supersededBy) {
|
|
@@ -129,35 +172,19 @@ function matchFinding(lines, match) {
|
|
|
129
172
|
if (!needleRaw)
|
|
130
173
|
return phrenErr("Finding text cannot be empty.", PhrenError.EMPTY_INPUT);
|
|
131
174
|
const needle = normalizeFindingText(needleRaw);
|
|
132
|
-
const bulletLines = lines
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`, "i").test(line))
|
|
138
|
-
: [];
|
|
139
|
-
const exactMatches = bulletLines.filter(({ line }) => normalizeFindingText(line) === needle);
|
|
140
|
-
const partialMatches = bulletLines.filter(({ line }) => {
|
|
141
|
-
const clean = normalizeFindingText(line);
|
|
142
|
-
return clean.includes(needle) || line.toLowerCase().includes(needle);
|
|
143
|
-
});
|
|
144
|
-
let selected;
|
|
145
|
-
if (fidMatches.length === 1) {
|
|
146
|
-
selected = fidMatches[0];
|
|
147
|
-
}
|
|
148
|
-
else if (exactMatches.length === 1) {
|
|
149
|
-
selected = exactMatches[0];
|
|
150
|
-
}
|
|
151
|
-
else if (exactMatches.length > 1) {
|
|
152
|
-
return phrenErr(`"${match}" is ambiguous (${exactMatches.length} exact matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
153
|
-
}
|
|
154
|
-
else if (partialMatches.length === 1) {
|
|
155
|
-
selected = partialMatches[0];
|
|
156
|
-
}
|
|
157
|
-
else if (partialMatches.length > 1) {
|
|
158
|
-
return phrenErr(`"${match}" is ambiguous (${partialMatches.length} partial matches). Use a more specific phrase.`, PhrenError.AMBIGUOUS_MATCH);
|
|
159
|
-
}
|
|
175
|
+
const bulletLines = collectFindingBulletLines(lines);
|
|
176
|
+
const activeMatch = selectMatchingFinding(bulletLines.filter(({ archived }) => !archived), needle, match);
|
|
177
|
+
if (!activeMatch.ok)
|
|
178
|
+
return activeMatch;
|
|
179
|
+
const selected = activeMatch.data;
|
|
160
180
|
if (!selected) {
|
|
181
|
+
const archivedMatch = selectMatchingFinding(bulletLines.filter(({ archived }) => archived), needle, match);
|
|
182
|
+
if (!archivedMatch.ok) {
|
|
183
|
+
return phrenErr(`Finding "${match}" is archived and read-only. Restore or re-add it before mutating history.`, PhrenError.VALIDATION_ERROR);
|
|
184
|
+
}
|
|
185
|
+
if (archivedMatch.data) {
|
|
186
|
+
return phrenErr(`Finding "${match}" is archived and read-only. Restore or re-add it before mutating history.`, PhrenError.VALIDATION_ERROR);
|
|
187
|
+
}
|
|
161
188
|
return phrenErr(`No finding matching "${match}".`, PhrenError.NOT_FOUND);
|
|
162
189
|
}
|
|
163
190
|
const stableId = parseFindingIdMeta(selected.line);
|
|
@@ -3,7 +3,7 @@ import * as path from "path";
|
|
|
3
3
|
import { debugLog } from "./shared.js";
|
|
4
4
|
// Acquire the file lock, returning true on success or throwing on timeout.
|
|
5
5
|
function acquireFileLock(lockPath) {
|
|
6
|
-
const maxWait = Number.parseInt(process.env.PHREN_FILE_LOCK_MAX_WAIT_MS ||
|
|
6
|
+
const maxWait = Number.parseInt(process.env.PHREN_FILE_LOCK_MAX_WAIT_MS || "5000", 10) || 5000;
|
|
7
7
|
const pollInterval = Number.parseInt((process.env.PHREN_FILE_LOCK_POLL_MS) || "100", 10) || 100;
|
|
8
8
|
const staleThreshold = Number.parseInt((process.env.PHREN_FILE_LOCK_STALE_MS) || "30000", 10) || 30000;
|
|
9
9
|
const waiter = new Int32Array(new SharedArrayBuffer(4));
|
|
@@ -29,12 +29,17 @@ function acquireFileLock(lockPath) {
|
|
|
29
29
|
const lockContent = fs.readFileSync(lockPath, "utf8");
|
|
30
30
|
const lockPid = Number.parseInt(lockContent.split("\n")[0], 10);
|
|
31
31
|
if (Number.isFinite(lockPid) && lockPid > 0) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
if (process.platform !== 'win32') {
|
|
33
|
+
try {
|
|
34
|
+
process.kill(lockPid, 0);
|
|
35
|
+
ownerDead = false;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
ownerDead = true;
|
|
39
|
+
}
|
|
35
40
|
}
|
|
36
|
-
|
|
37
|
-
ownerDead = true;
|
|
41
|
+
else {
|
|
42
|
+
ownerDead = true;
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
}
|
package/mcp/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { register as registerSkills } from "./mcp-skills.js";
|
|
|
19
19
|
import { register as registerHooks } from "./mcp-hooks.js";
|
|
20
20
|
import { register as registerExtract } from "./mcp-extract.js";
|
|
21
21
|
import { register as registerConfig } from "./mcp-config.js";
|
|
22
|
+
import { mcpResponse } from "./mcp-types.js";
|
|
22
23
|
import { errorMessage } from "./utils.js";
|
|
23
24
|
import { runTopLevelCommand } from "./entrypoint.js";
|
|
24
25
|
import { startEmbeddingWarmup } from "./startup-embedding.js";
|
|
@@ -110,7 +111,7 @@ async function main() {
|
|
|
110
111
|
const message = errorMessage(err);
|
|
111
112
|
if (message.includes("Write timeout") || message.includes("Write queue full")) {
|
|
112
113
|
debugLog(`Write queue timeout: ${message}`);
|
|
113
|
-
return { ok: false, error: `Write queue timeout: ${message}`, errorCode: "TIMEOUT" };
|
|
114
|
+
return mcpResponse({ ok: false, error: `Write queue timeout: ${message}`, errorCode: "TIMEOUT" });
|
|
114
115
|
}
|
|
115
116
|
throw err;
|
|
116
117
|
}
|
|
@@ -6,6 +6,7 @@ import * as path from "path";
|
|
|
6
6
|
import * as crypto from "crypto";
|
|
7
7
|
import { debugLog, installPreferencesFile } from "./phren-paths.js";
|
|
8
8
|
import { errorMessage } from "./utils.js";
|
|
9
|
+
import { withFileLock } from "./governance-locks.js";
|
|
9
10
|
function preferencesFile(phrenPath) {
|
|
10
11
|
return installPreferencesFile(phrenPath);
|
|
11
12
|
}
|
|
@@ -24,7 +25,7 @@ function readPreferencesFile(file) {
|
|
|
24
25
|
return {};
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
|
-
function
|
|
28
|
+
function writePreferencesFileRaw(file, current, patch) {
|
|
28
29
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
30
|
const tmpPath = `${file}.tmp-${crypto.randomUUID()}`;
|
|
30
31
|
fs.writeFileSync(tmpPath, JSON.stringify({
|
|
@@ -34,6 +35,12 @@ function writePreferencesFile(file, current, patch) {
|
|
|
34
35
|
}, null, 2) + "\n");
|
|
35
36
|
fs.renameSync(tmpPath, file);
|
|
36
37
|
}
|
|
38
|
+
function writePreferencesFile(file, patch) {
|
|
39
|
+
withFileLock(file, () => {
|
|
40
|
+
const current = readPreferencesFile(file);
|
|
41
|
+
writePreferencesFileRaw(file, current, patch);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
37
44
|
export function readInstallPreferences(phrenPath) {
|
|
38
45
|
return readPreferencesFile(preferencesFile(phrenPath));
|
|
39
46
|
}
|
|
@@ -41,10 +48,18 @@ export function readGovernanceInstallPreferences(phrenPath) {
|
|
|
41
48
|
return readPreferencesFile(governanceInstallPreferencesFile(phrenPath));
|
|
42
49
|
}
|
|
43
50
|
export function writeInstallPreferences(phrenPath, patch) {
|
|
44
|
-
writePreferencesFile(preferencesFile(phrenPath),
|
|
51
|
+
writePreferencesFile(preferencesFile(phrenPath), patch);
|
|
45
52
|
}
|
|
46
53
|
export function writeGovernanceInstallPreferences(phrenPath, patch) {
|
|
47
|
-
writePreferencesFile(governanceInstallPreferencesFile(phrenPath),
|
|
54
|
+
writePreferencesFile(governanceInstallPreferencesFile(phrenPath), patch);
|
|
55
|
+
}
|
|
56
|
+
/** Atomically read-modify-write install preferences using a patcher function. */
|
|
57
|
+
export function updateInstallPreferences(phrenPath, patcher) {
|
|
58
|
+
const file = preferencesFile(phrenPath);
|
|
59
|
+
withFileLock(file, () => {
|
|
60
|
+
const current = readPreferencesFile(file);
|
|
61
|
+
writePreferencesFileRaw(file, current, patcher(current));
|
|
62
|
+
});
|
|
48
63
|
}
|
|
49
64
|
export function getMcpEnabledPreference(phrenPath) {
|
|
50
65
|
const prefs = readInstallPreferences(phrenPath);
|
package/mcp/dist/init.js
CHANGED
|
@@ -910,6 +910,17 @@ export async function runInit(opts = {}) {
|
|
|
910
910
|
}
|
|
911
911
|
let phrenPath = resolveInitPhrenPath(opts);
|
|
912
912
|
const dryRun = Boolean(opts.dryRun);
|
|
913
|
+
// Migrate ~/.cortex → ~/.phren when upgrading from the old name.
|
|
914
|
+
// Only runs when the resolved phrenPath doesn't exist yet but ~/.cortex does.
|
|
915
|
+
if (!opts._walkthroughStoragePath && !fs.existsSync(phrenPath)) {
|
|
916
|
+
const cortexPath = path.resolve(homePath(".cortex"));
|
|
917
|
+
if (cortexPath !== phrenPath && fs.existsSync(cortexPath) && hasInstallMarkers(cortexPath)) {
|
|
918
|
+
if (!dryRun) {
|
|
919
|
+
fs.renameSync(cortexPath, phrenPath);
|
|
920
|
+
}
|
|
921
|
+
console.log(`Migrated ~/.cortex → ~/.phren`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
913
924
|
let hasExistingInstall = hasInstallMarkers(phrenPath);
|
|
914
925
|
// Interactive walkthrough for first-time installs (skip with --yes or non-TTY)
|
|
915
926
|
if (!hasExistingInstall && !dryRun && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
|
package/mcp/dist/mcp-config.js
CHANGED
|
@@ -5,14 +5,6 @@ import { PROACTIVITY_LEVELS, getProactivityLevel, getProactivityLevelForFindings
|
|
|
5
5
|
import { readGovernanceInstallPreferences, writeGovernanceInstallPreferences, } from "./init-preferences.js";
|
|
6
6
|
import { FINDING_SENSITIVITY_CONFIG } from "./cli-config.js";
|
|
7
7
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
8
|
-
function normalizeProactivityLevel(raw) {
|
|
9
|
-
if (!raw)
|
|
10
|
-
return undefined;
|
|
11
|
-
const normalized = raw.trim().toLowerCase();
|
|
12
|
-
return PROACTIVITY_LEVELS.includes(normalized)
|
|
13
|
-
? normalized
|
|
14
|
-
: undefined;
|
|
15
|
-
}
|
|
16
8
|
function proactivitySnapshot(phrenPath) {
|
|
17
9
|
const prefs = readGovernanceInstallPreferences(phrenPath);
|
|
18
10
|
return {
|
package/mcp/dist/mcp-data.js
CHANGED
|
@@ -215,7 +215,14 @@ export function register(server, ctx) {
|
|
|
215
215
|
}
|
|
216
216
|
catch (indexError) {
|
|
217
217
|
// Index rebuild failed — restore backup if we replaced the project dir
|
|
218
|
-
if (overwrite) {
|
|
218
|
+
if (!overwrite) {
|
|
219
|
+
// Non-overwrite case: no backup to restore — remove the orphaned project dir
|
|
220
|
+
try {
|
|
221
|
+
fs.rmSync(projectDir, { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
catch { /* best-effort */ }
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
219
226
|
// Find the backup dir that was created earlier
|
|
220
227
|
try {
|
|
221
228
|
for (const entry of fs.readdirSync(phrenPath)) {
|
|
@@ -287,7 +294,13 @@ export function register(server, ctx) {
|
|
|
287
294
|
return mcpResponse({ ok: false, error: `Archive "${project}.archived" already exists. Unarchive or remove it first.` });
|
|
288
295
|
}
|
|
289
296
|
fs.renameSync(projectDir, archiveDir);
|
|
290
|
-
|
|
297
|
+
try {
|
|
298
|
+
await rebuildIndex();
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
fs.renameSync(archiveDir, projectDir);
|
|
302
|
+
return mcpResponse({ ok: false, error: `Index rebuild failed after archive rename, rolled back: ${err instanceof Error ? err.message : String(err)}` });
|
|
303
|
+
}
|
|
291
304
|
return mcpResponse({
|
|
292
305
|
ok: true,
|
|
293
306
|
message: `Archived project "${project}". Data preserved at ${archiveDir}.`,
|
|
@@ -304,7 +317,13 @@ export function register(server, ctx) {
|
|
|
304
317
|
return mcpResponse({ ok: false, error: `No archive found for "${project}".`, data: { availableArchives: available } });
|
|
305
318
|
}
|
|
306
319
|
fs.renameSync(archiveDir, projectDir);
|
|
307
|
-
|
|
320
|
+
try {
|
|
321
|
+
await rebuildIndex();
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
fs.renameSync(projectDir, archiveDir);
|
|
325
|
+
return mcpResponse({ ok: false, error: `Index rebuild failed after unarchive rename, rolled back: ${err instanceof Error ? err.message : String(err)}` });
|
|
326
|
+
}
|
|
308
327
|
return mcpResponse({
|
|
309
328
|
ok: true,
|
|
310
329
|
message: `Unarchived project "${project}". It is now active again.`,
|
package/mcp/dist/mcp-extract.js
CHANGED
|
@@ -125,6 +125,7 @@ export function register(server, ctx) {
|
|
|
125
125
|
}
|
|
126
126
|
return mcpResponse({
|
|
127
127
|
ok: added.length > 0,
|
|
128
|
+
...(added.length === 0 ? { error: `All ${findings.length} finding(s) were skipped (duplicates or errors).` } : {}),
|
|
128
129
|
message: `Extracted ${findings.length} finding(s): ${added.length} added, ${allSkipped.length} skipped (duplicates or errors).`,
|
|
129
130
|
data: { project, extracted: findings, added, skipped: allSkipped, dryRun: false },
|
|
130
131
|
});
|
package/mcp/dist/mcp-finding.js
CHANGED
|
@@ -203,7 +203,7 @@ export function register(server, ctx) {
|
|
|
203
203
|
if (err instanceof Error && err.message.includes("Rejected:")) {
|
|
204
204
|
return mcpResponse({ ok: false, error: errorMessage(err), errorCode: "VALIDATION_ERROR" });
|
|
205
205
|
}
|
|
206
|
-
|
|
206
|
+
return mcpResponse({ ok: false, error: `Unexpected error saving finding: ${errorMessage(err)}` });
|
|
207
207
|
}
|
|
208
208
|
});
|
|
209
209
|
});
|
package/mcp/dist/mcp-hooks.js
CHANGED
|
@@ -2,7 +2,7 @@ import { mcpResponse } from "./mcp-types.js";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as path from "path";
|
|
5
|
-
import { readInstallPreferences,
|
|
5
|
+
import { readInstallPreferences, updateInstallPreferences } from "./init-preferences.js";
|
|
6
6
|
import { readCustomHooks, getHookTarget, HOOK_EVENT_VALUES } from "./hooks.js";
|
|
7
7
|
import { hookConfigPath } from "./shared.js";
|
|
8
8
|
import { PROJECT_HOOK_EVENTS, isProjectHookEnabled, readProjectConfig, writeProjectHookConfig } from "./project-config.js";
|
|
@@ -161,16 +161,15 @@ export function register(server, ctx) {
|
|
|
161
161
|
if (!normalized) {
|
|
162
162
|
return mcpResponse({ ok: false, error: `Invalid tool "${tool}". Use: ${HOOK_TOOLS.join(", ")}` });
|
|
163
163
|
}
|
|
164
|
-
|
|
165
|
-
writeInstallPreferences(phrenPath, {
|
|
164
|
+
updateInstallPreferences(phrenPath, (prefs) => ({
|
|
166
165
|
hookTools: {
|
|
167
166
|
...(prefs.hookTools && typeof prefs.hookTools === "object" ? prefs.hookTools : {}),
|
|
168
167
|
[normalized]: enabled,
|
|
169
168
|
},
|
|
170
|
-
});
|
|
169
|
+
}));
|
|
171
170
|
return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} hooks for ${normalized}.`, data: { tool: normalized, enabled } });
|
|
172
171
|
}
|
|
173
|
-
|
|
172
|
+
updateInstallPreferences(phrenPath, () => ({ hooksEnabled: enabled }));
|
|
174
173
|
return mcpResponse({ ok: true, message: `${enabled ? "Enabled" : "Disabled"} hooks globally.`, data: { global: true, enabled } });
|
|
175
174
|
});
|
|
176
175
|
// ── add_custom_hook ──────────────────────────────────────────────────────
|
|
@@ -202,12 +201,26 @@ export function register(server, ctx) {
|
|
|
202
201
|
const { hostname } = new URL(trimmed);
|
|
203
202
|
const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
204
203
|
const ssrfBlocked = h === "localhost" ||
|
|
205
|
-
|
|
204
|
+
// IPv4 private/loopback ranges
|
|
206
205
|
/^127\./.test(h) ||
|
|
207
206
|
/^10\./.test(h) ||
|
|
208
207
|
/^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
|
|
209
208
|
/^192\.168\./.test(h) ||
|
|
210
209
|
/^169\.254\./.test(h) ||
|
|
210
|
+
// IPv6 loopback
|
|
211
|
+
h === "::1" ||
|
|
212
|
+
// IPv6 ULA (fc00::/7 covers fc:: and fd::)
|
|
213
|
+
h.startsWith("fc") ||
|
|
214
|
+
h.startsWith("fd") ||
|
|
215
|
+
// IPv6 link-local (fe80::/10)
|
|
216
|
+
h.startsWith("fe80:") ||
|
|
217
|
+
// IPv4-mapped IPv6 (::ffff:10.x.x.x, ::ffff:127.x.x.x, etc.)
|
|
218
|
+
/^::ffff:/i.test(h) ||
|
|
219
|
+
// Raw numeric IPv4 forms not normalized by all URL parsers:
|
|
220
|
+
// decimal (2130706433), hex (0x7f000001), octal (0177.0.0.1 prefix)
|
|
221
|
+
/^(0x[0-9a-f]+|0\d+)$/i.test(h) ||
|
|
222
|
+
// Pure decimal integer that encodes an IPv4 address (8+ digits covers 0.0.0.0+)
|
|
223
|
+
/^\d{8,10}$/.test(h) ||
|
|
211
224
|
h.endsWith(".local") ||
|
|
212
225
|
h.endsWith(".internal");
|
|
213
226
|
if (ssrfBlocked) {
|
|
@@ -226,10 +239,13 @@ export function register(server, ctx) {
|
|
|
226
239
|
newHook = { event, command: command, ...(timeout !== undefined ? { timeout } : {}) };
|
|
227
240
|
}
|
|
228
241
|
return ctx.withWriteQueue(async () => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
242
|
+
let totalAfter = 0;
|
|
243
|
+
updateInstallPreferences(phrenPath, (prefs) => {
|
|
244
|
+
const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : [];
|
|
245
|
+
totalAfter = existing.length + 1;
|
|
246
|
+
return { customHooks: [...existing, newHook] };
|
|
247
|
+
});
|
|
248
|
+
return mcpResponse({ ok: true, message: `Added custom hook for "${event}": ${"webhook" in newHook ? "[webhook] " : ""}${getHookTarget(newHook)}`, data: { hook: newHook, total: totalAfter } });
|
|
233
249
|
});
|
|
234
250
|
});
|
|
235
251
|
// ── remove_custom_hook ───────────────────────────────────────────────────
|
|
@@ -242,15 +258,19 @@ export function register(server, ctx) {
|
|
|
242
258
|
}),
|
|
243
259
|
}, async ({ event, command }) => {
|
|
244
260
|
return ctx.withWriteQueue(async () => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
261
|
+
let removed = 0;
|
|
262
|
+
let remainingCount = 0;
|
|
263
|
+
updateInstallPreferences(phrenPath, (prefs) => {
|
|
264
|
+
const existing = Array.isArray(prefs.customHooks) ? prefs.customHooks : [];
|
|
265
|
+
const remaining = existing.filter(h => h.event !== event || (command != null && !getHookTarget(h).includes(command)));
|
|
266
|
+
removed = existing.length - remaining.length;
|
|
267
|
+
remainingCount = remaining.length;
|
|
268
|
+
return { customHooks: remaining };
|
|
269
|
+
});
|
|
249
270
|
if (removed === 0) {
|
|
250
271
|
return mcpResponse({ ok: false, error: `No custom hooks matched event="${event}"${command ? ` command containing "${command}"` : ""}.` });
|
|
251
272
|
}
|
|
252
|
-
|
|
253
|
-
return mcpResponse({ ok: true, message: `Removed ${removed} custom hook(s) for "${event}".`, data: { removed, remaining: remaining.length } });
|
|
273
|
+
return mcpResponse({ ok: true, message: `Removed ${removed} custom hook(s) for "${event}".`, data: { removed, remaining: remainingCount } });
|
|
254
274
|
});
|
|
255
275
|
});
|
|
256
276
|
}
|
package/mcp/dist/mcp-memory.js
CHANGED
package/mcp/dist/mcp-ops.js
CHANGED
|
@@ -11,16 +11,9 @@ import { PROJECT_OWNERSHIP_MODES, parseProjectOwnershipMode } from "./project-co
|
|
|
11
11
|
import { resolveRuntimeProfile } from "./runtime-profile.js";
|
|
12
12
|
import { getMachineName } from "./machine-identity.js";
|
|
13
13
|
import { getProjectConsolidationStatus, CONSOLIDATION_ENTRY_THRESHOLD } from "./content-validate.js";
|
|
14
|
-
/** Translate a PhrenResult<string> into a standard McpToolResult shape. */
|
|
15
|
-
function phrenResultToMcp(result) {
|
|
16
|
-
if (result.ok) {
|
|
17
|
-
return { ok: true, message: result.data };
|
|
18
|
-
}
|
|
19
|
-
return { ok: false, error: result.error, errorCode: result.code };
|
|
20
|
-
}
|
|
21
14
|
export function register(server, ctx) {
|
|
22
|
-
const { phrenPath, profile, withWriteQueue
|
|
23
|
-
// ──
|
|
15
|
+
const { phrenPath, profile, withWriteQueue } = ctx;
|
|
16
|
+
// ── add_project ────────────────────────────────────────────────────────────
|
|
24
17
|
server.registerTool("add_project", {
|
|
25
18
|
title: "◆ phren · add project",
|
|
26
19
|
description: "Bootstrap a project into phren from a repo or working directory. " +
|
|
@@ -221,11 +214,13 @@ export function register(server, ctx) {
|
|
|
221
214
|
const { runDoctor } = await import("./link-doctor.js");
|
|
222
215
|
const result = await runDoctor(phrenPath, true, check_data ?? false);
|
|
223
216
|
const lines = result.checks.map((c) => `${c.ok ? "ok" : "FAIL"} ${c.name}: ${c.detail}`);
|
|
217
|
+
const failCount = result.checks.filter((c) => !c.ok).length;
|
|
224
218
|
return mcpResponse({
|
|
225
219
|
ok: result.ok,
|
|
220
|
+
...(result.ok ? {} : { error: `${failCount} check(s) could not be auto-fixed: ${lines.filter((l) => l.startsWith("FAIL")).join("; ")}` }),
|
|
226
221
|
message: result.ok
|
|
227
222
|
? `Doctor fix complete: all ${result.checks.length} checks passed`
|
|
228
|
-
: `Doctor fix complete: ${
|
|
223
|
+
: `Doctor fix complete: ${failCount} issue(s) remain`,
|
|
229
224
|
data: {
|
|
230
225
|
machine: result.machine,
|
|
231
226
|
profile: result.profile,
|
package/mcp/dist/mcp-search.js
CHANGED
|
@@ -131,7 +131,13 @@ export function register(server, ctx) {
|
|
|
131
131
|
}),
|
|
132
132
|
}, async ({ id: rawId }) => {
|
|
133
133
|
// Normalize ID: decode URL encoding and normalize path separators
|
|
134
|
-
|
|
134
|
+
let id;
|
|
135
|
+
try {
|
|
136
|
+
id = normalizeMemoryId(rawId);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return mcpResponse({ ok: false, error: `Invalid memory ID format: "${rawId}" contains malformed URL encoding.` });
|
|
140
|
+
}
|
|
135
141
|
const match = id.match(/^mem:([^/]+)\/(.+)$/);
|
|
136
142
|
if (!match) {
|
|
137
143
|
return mcpResponse({ ok: false, error: `Invalid memory ID format "${rawId}". Expected mem:project/path/to/file.md.` });
|