@phren/cli 0.0.6 → 0.0.8

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 CHANGED
@@ -8,7 +8,7 @@
8
8
  </p>
9
9
 
10
10
  <p align="center">
11
- Phren is a git-backed knowledge layer that gives AI agents persistent memory across sessions, projects, and machines. Findings, decisions, and patterns are captured as markdown in a repo you control no hosted service, no vendor lock-in.
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
- // Exclude sensitive files from staging: .env files and private keys should
871
- // never be committed to the phren git repository.
872
- const add = await runBestEffortGit(["add", "-A", "--", ":(exclude).env", ":(exclude)**/.env", ":(exclude)*.pem", ":(exclude)*.key"], phrenPath);
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);
@@ -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(`<phren-notice>`);
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.<phren-error>\n`);
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 ((process.env.PHREN_ACTOR || process.env.PHREN_ACTOR)?.trim())
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 = (process.env.PHREN_ACTOR || process.env.PHREN_ACTOR)?.trim() || undefined;
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") && !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
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { phrenErr, PhrenError, phrenOk, forwardErr, getProjectDirs, } from "./shared.js";
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
- .map((line, index) => ({ line, index }))
134
- .filter(({ line }) => line.startsWith("- "));
135
- const fidNeedle = needle.replace(/^fid:/, "");
136
- const fidMatches = /^[a-z0-9]{8}$/.test(fidNeedle)
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 || process.env.PHREN_FILE_LOCK_MAX_WAIT_MS || "5000", 10) || 5000;
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
- try {
33
- process.kill(lockPid, 0); // signal 0 = check if alive
34
- ownerDead = false; // PID is still alive, don't steal the lock
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
- catch {
37
- ownerDead = true; // PID is dead, safe to remove
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
  }
@@ -162,15 +162,15 @@ export function removeMcpServerAtPath(filePath) {
162
162
  return removed;
163
163
  }
164
164
  export function isPhrenCommand(command) {
165
- // Detect PHREN_PATH= or legacy PHREN_PATH= env var prefix (present in all lifecycle hook commands)
166
- if (/\b(?:PHREN_PATH|PHREN_PATH)=/.test(command))
165
+ // Detect PHREN_PATH= env var prefix (present in all lifecycle hook commands)
166
+ if (/\bPHREN_PATH=/.test(command))
167
167
  return true;
168
- // Detect npx phren/phren package references
169
- if (command.includes("phren") || command.includes("phren"))
168
+ // Detect npx phren package references
169
+ if (command.includes("phren"))
170
170
  return true;
171
- // Detect bare "phren" or "phren" executable segment
171
+ // Detect bare "phren" executable segment
172
172
  const segments = command.split(/[/\\\s]+/);
173
- if (segments.some(seg => seg === "phren" || seg.startsWith("phren@") || seg === "phren" || seg.startsWith("phren@")))
173
+ if (segments.some(seg => seg === "phren" || seg.startsWith("phren@")))
174
174
  return true;
175
175
  // Also match commands that include hook subcommands (used when installed via absolute path)
176
176
  const HOOK_MARKERS = ["hook-prompt", "hook-stop", "hook-session-start", "hook-tool"];
@@ -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 writePreferencesFile(file, current, patch) {
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), readInstallPreferences(phrenPath), patch);
51
+ writePreferencesFile(preferencesFile(phrenPath), patch);
45
52
  }
46
53
  export function writeGovernanceInstallPreferences(phrenPath, patch) {
47
- writePreferencesFile(governanceInstallPreferencesFile(phrenPath), readGovernanceInstallPreferences(phrenPath), patch);
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
@@ -109,7 +109,7 @@ function hasInstallMarkers(phrenPath) {
109
109
  fs.existsSync(path.join(phrenPath, "global")));
110
110
  }
111
111
  function resolveInitPhrenPath(opts) {
112
- const raw = opts._walkthroughStoragePath || (process.env.PHREN_PATH || process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
112
+ const raw = opts._walkthroughStoragePath || (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
113
113
  return path.resolve(expandHomePath(raw));
114
114
  }
115
115
  function detectRepoRootForStorage(phrenPath) {
@@ -910,6 +910,39 @@ 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
+ }
924
+ // Rename stale cortex-*.md skill files left over from the rebrand.
925
+ // Runs on every init so users who already migrated the directory still get the fix.
926
+ const skillsMigrateDir = path.join(phrenPath, "global", "skills");
927
+ if (!dryRun && fs.existsSync(skillsMigrateDir)) {
928
+ for (const entry of fs.readdirSync(skillsMigrateDir)) {
929
+ if (!entry.endsWith(".md"))
930
+ continue;
931
+ if (entry === "cortex.md") {
932
+ const dest = path.join(skillsMigrateDir, "phren.md");
933
+ if (!fs.existsSync(dest)) {
934
+ fs.renameSync(path.join(skillsMigrateDir, entry), dest);
935
+ }
936
+ }
937
+ else if (entry.startsWith("cortex-")) {
938
+ const newName = entry.replace(/^cortex-/, "phren-");
939
+ const dest = path.join(skillsMigrateDir, newName);
940
+ if (!fs.existsSync(dest)) {
941
+ fs.renameSync(path.join(skillsMigrateDir, entry), dest);
942
+ }
943
+ }
944
+ }
945
+ }
913
946
  let hasExistingInstall = hasInstallMarkers(phrenPath);
914
947
  // Interactive walkthrough for first-time installs (skip with --yes or non-TTY)
915
948
  if (!hasExistingInstall && !dryRun && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
@@ -1404,7 +1437,7 @@ export async function runInit(opts = {}) {
1404
1437
  log(``);
1405
1438
  }
1406
1439
  export async function runMcpMode(modeArg) {
1407
- const phrenPath = findPhrenPath() || (process.env.PHREN_PATH || process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
1440
+ const phrenPath = findPhrenPath() || (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
1408
1441
  const manifest = readRootManifest(phrenPath);
1409
1442
  const normalizedArg = modeArg?.trim().toLowerCase();
1410
1443
  if (!normalizedArg || normalizedArg === "status") {
@@ -1475,7 +1508,7 @@ export async function runMcpMode(modeArg) {
1475
1508
  log(`Restart your agent to apply changes.`);
1476
1509
  }
1477
1510
  export async function runHooksMode(modeArg) {
1478
- const phrenPath = findPhrenPath() || (process.env.PHREN_PATH || process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
1511
+ const phrenPath = findPhrenPath() || (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
1479
1512
  const manifest = readRootManifest(phrenPath);
1480
1513
  const normalizedArg = modeArg?.trim().toLowerCase();
1481
1514
  if (!normalizedArg || normalizedArg === "status") {
@@ -1633,7 +1666,7 @@ export async function runUninstall() {
1633
1666
  catch (err) {
1634
1667
  debugLog(`uninstall: cleanup failed for ${codexToml}: ${errorMessage(err)}`);
1635
1668
  }
1636
- const codexCandidates = codexJsonCandidates((process.env.PHREN_PATH || process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
1669
+ const codexCandidates = codexJsonCandidates((process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
1637
1670
  for (const mcpFile of codexCandidates) {
1638
1671
  try {
1639
1672
  if (removeMcpServerAtPath(mcpFile)) {
@@ -1645,7 +1678,7 @@ export async function runUninstall() {
1645
1678
  }
1646
1679
  }
1647
1680
  // Remove Copilot hooks file (written by configureAllHooks)
1648
- const copilotHooksFile = hookConfigPath("copilot", (process.env.PHREN_PATH || process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
1681
+ const copilotHooksFile = hookConfigPath("copilot", (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
1649
1682
  try {
1650
1683
  if (fs.existsSync(copilotHooksFile)) {
1651
1684
  fs.unlinkSync(copilotHooksFile);
@@ -1656,7 +1689,7 @@ export async function runUninstall() {
1656
1689
  debugLog(`uninstall: cleanup failed for ${copilotHooksFile}: ${errorMessage(err)}`);
1657
1690
  }
1658
1691
  // Remove phren entries from Cursor hooks file (may contain non-phren entries)
1659
- const cursorHooksFile = hookConfigPath("cursor", (process.env.PHREN_PATH || process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
1692
+ const cursorHooksFile = hookConfigPath("cursor", (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH);
1660
1693
  try {
1661
1694
  if (fs.existsSync(cursorHooksFile)) {
1662
1695
  const raw = JSON.parse(fs.readFileSync(cursorHooksFile, "utf8"));
@@ -1677,7 +1710,7 @@ export async function runUninstall() {
1677
1710
  debugLog(`uninstall: cleanup failed for ${cursorHooksFile}: ${errorMessage(err)}`);
1678
1711
  }
1679
1712
  // Remove Codex hooks file in phren path
1680
- const uninstallPhrenPath = (process.env.PHREN_PATH || process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
1713
+ const uninstallPhrenPath = (process.env.PHREN_PATH) || DEFAULT_PHREN_PATH;
1681
1714
  const codexHooksFile = hookConfigPath("codex", uninstallPhrenPath);
1682
1715
  try {
1683
1716
  if (fs.existsSync(codexHooksFile)) {
@@ -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 {
@@ -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
- await rebuildIndex();
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
- await rebuildIndex();
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.`,
@@ -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
  });
@@ -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
- throw err;
206
+ return mcpResponse({ ok: false, error: `Unexpected error saving finding: ${errorMessage(err)}` });
207
207
  }
208
208
  });
209
209
  });