@phren/cli 0.0.32 → 0.0.33

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 (58) hide show
  1. package/mcp/dist/cli/actions.js +3 -0
  2. package/mcp/dist/cli/config.js +3 -3
  3. package/mcp/dist/cli/govern.js +18 -8
  4. package/mcp/dist/cli/hooks-context.js +1 -1
  5. package/mcp/dist/cli/hooks-session.js +18 -62
  6. package/mcp/dist/cli/namespaces.js +1 -1
  7. package/mcp/dist/cli/search.js +5 -5
  8. package/mcp/dist/cli-hooks-prompt.js +7 -3
  9. package/mcp/dist/cli-hooks-session-handlers.js +3 -15
  10. package/mcp/dist/cli-hooks-stop.js +10 -48
  11. package/mcp/dist/content/archive.js +8 -20
  12. package/mcp/dist/content/learning.js +29 -8
  13. package/mcp/dist/data/access.js +13 -4
  14. package/mcp/dist/finding/lifecycle.js +9 -3
  15. package/mcp/dist/governance/audit.js +13 -5
  16. package/mcp/dist/governance/policy.js +13 -0
  17. package/mcp/dist/governance/rbac.js +1 -1
  18. package/mcp/dist/governance/scores.js +2 -1
  19. package/mcp/dist/hooks.js +14 -6
  20. package/mcp/dist/index.js +1 -1
  21. package/mcp/dist/init/init.js +54 -42
  22. package/mcp/dist/init/shared.js +1 -1
  23. package/mcp/dist/init-bootstrap.js +0 -47
  24. package/mcp/dist/init-fresh.js +13 -18
  25. package/mcp/dist/init-uninstall.js +22 -0
  26. package/mcp/dist/init-walkthrough.js +19 -24
  27. package/mcp/dist/package-metadata.js +1 -1
  28. package/mcp/dist/phren-art.js +4 -120
  29. package/mcp/dist/proactivity.js +1 -1
  30. package/mcp/dist/project-topics.js +16 -46
  31. package/mcp/dist/provider-adapters.js +1 -1
  32. package/mcp/dist/runtime-profile.js +1 -1
  33. package/mcp/dist/shared/data-utils.js +25 -0
  34. package/mcp/dist/shared/fragment-graph.js +4 -18
  35. package/mcp/dist/shared/index.js +14 -10
  36. package/mcp/dist/shared/ollama.js +23 -5
  37. package/mcp/dist/shared/process.js +24 -0
  38. package/mcp/dist/shared/retrieval.js +7 -4
  39. package/mcp/dist/shared/search-fallback.js +1 -0
  40. package/mcp/dist/shared.js +2 -1
  41. package/mcp/dist/shell/render.js +1 -1
  42. package/mcp/dist/skill/registry.js +1 -1
  43. package/mcp/dist/skill/state.js +0 -3
  44. package/mcp/dist/task/github.js +1 -0
  45. package/mcp/dist/task/lifecycle.js +1 -6
  46. package/mcp/dist/tools/config.js +415 -400
  47. package/mcp/dist/tools/finding.js +390 -373
  48. package/mcp/dist/tools/ops.js +372 -365
  49. package/mcp/dist/tools/search.js +495 -487
  50. package/mcp/dist/tools/session.js +3 -2
  51. package/mcp/dist/tools/skills.js +9 -0
  52. package/mcp/dist/ui/page.js +1 -1
  53. package/mcp/dist/ui/server.js +645 -1040
  54. package/mcp/dist/utils.js +12 -8
  55. package/package.json +1 -1
  56. package/mcp/dist/init-dryrun.js +0 -55
  57. package/mcp/dist/init-migrate.js +0 -51
  58. package/mcp/dist/init-walkthrough-merge.js +0 -90
@@ -123,7 +123,8 @@ export function autoDetectFindingType(text) {
123
123
  return 'context';
124
124
  return null;
125
125
  }
126
- function prepareFinding(learning, project, fullHistory, extraAnnotations, citationInput, source, nowIso, inferredRepo, headCommit, phrenPath) {
126
+ function prepareFinding(opts) {
127
+ const { finding: learning, project, fullHistory, extraAnnotations, citationInput, source, nowIso, inferredRepo, headCommit, phrenPath } = opts;
127
128
  const secretType = scanForSecrets(learning);
128
129
  if (secretType) {
129
130
  return { status: "rejected", reason: `Contains ${secretType}` };
@@ -208,7 +209,11 @@ function insertFindingIntoContent(content, today, bullet, citationComment) {
208
209
  // Use positional insertion (not String.replace) to avoid: (1) special $& replacement patterns
209
210
  // if bullet contains $ chars, and (2) inserting inside an archived <details> block when a
210
211
  // duplicate date header exists from a prior consolidation run.
211
- const idx = content.indexOf(todayHeader);
212
+ // Search for todayHeader only after the last </details> close tag so we never
213
+ // insert into an archived block whose date happens to match today.
214
+ const lastDetailsClose = content.lastIndexOf("</details>");
215
+ const searchFrom = lastDetailsClose >= 0 ? lastDetailsClose : 0;
216
+ const idx = content.indexOf(todayHeader, searchFrom);
212
217
  if (idx !== -1) {
213
218
  const insertAt = idx + todayHeader.length;
214
219
  return content.slice(0, insertAt) + `\n\n${bullet}\n${citationComment}` + content.slice(insertAt);
@@ -288,7 +293,10 @@ export function addFindingToFile(phrenPath, project, learning, citationInput, op
288
293
  if (!fs.existsSync(resolvedDir))
289
294
  return phrenErr(`Project "${project}" does not exist.`, PhrenError.INVALID_PROJECT_NAME);
290
295
  const result = withFileLock(learningsPath, () => {
291
- const preparedForNewFile = prepareFinding(learning, project, "", opts?.extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
296
+ const preparedForNewFile = prepareFinding({
297
+ finding: learning, project, fullHistory: "", extraAnnotations: opts?.extraAnnotations,
298
+ citationInput: resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath,
299
+ });
292
300
  if (!fs.existsSync(learningsPath)) {
293
301
  if (preparedForNewFile.status === "rejected") {
294
302
  return phrenErr(`Rejected: finding appears to contain a secret (${preparedForNewFile.reason.replace(/^Contains /, "")}). Strip credentials before saving.`, PhrenError.VALIDATION_ERROR);
@@ -297,7 +305,9 @@ export function addFindingToFile(phrenPath, project, learning, citationInput, op
297
305
  return phrenOk(`Skipped duplicate finding for "${project}": already exists with similar wording.`);
298
306
  }
299
307
  const newContent = `# ${project} Findings\n\n## ${today}\n\n${preparedForNewFile.finding.bullet}\n${preparedForNewFile.finding.citationComment}\n`;
300
- fs.writeFileSync(learningsPath, newContent);
308
+ const tmpPath = learningsPath + ".tmp." + process.pid;
309
+ fs.writeFileSync(tmpPath, newContent);
310
+ fs.renameSync(tmpPath, learningsPath);
301
311
  return phrenOk({
302
312
  content: newContent,
303
313
  citation: buildFindingCitation(resolvedCitationInput, nowIso, inferredRepo, headCommit),
@@ -316,7 +326,10 @@ export function addFindingToFile(phrenPath, project, learning, citationInput, op
316
326
  .filter(line => !line.startsWith("- ") || !line.toLowerCase().includes(supersedesText.slice(0, 40).toLowerCase()))
317
327
  .join("\n")
318
328
  : content;
319
- const prepared = prepareFinding(learning, project, historyForDedup, opts?.extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
329
+ const prepared = prepareFinding({
330
+ finding: learning, project, fullHistory: historyForDedup, extraAnnotations: opts?.extraAnnotations,
331
+ citationInput: resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath,
332
+ });
320
333
  if (prepared.status === "rejected") {
321
334
  return phrenErr(`Rejected: finding appears to contain a secret (${prepared.reason.replace(/^Contains /, "")}). Strip credentials before saving.`, PhrenError.VALIDATION_ERROR);
322
335
  }
@@ -430,7 +443,10 @@ export function addFindingsToFile(phrenPath, project, learnings, opts) {
430
443
  rejected.push({ text: learning, reason: lengthError });
431
444
  continue;
432
445
  }
433
- const prepared = prepareFinding(learning, project, content, extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
446
+ const prepared = prepareFinding({
447
+ finding: learning, project, fullHistory: content, extraAnnotations,
448
+ citationInput: resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath,
449
+ });
434
450
  if (prepared.status === "rejected") {
435
451
  rejected.push({ text: learning, reason: prepared.reason });
436
452
  continue;
@@ -445,7 +461,9 @@ export function addFindingsToFile(phrenPath, project, learnings, opts) {
445
461
  added.push(learning);
446
462
  }
447
463
  if (added.length > 0) {
448
- fs.writeFileSync(learningsPath, content.endsWith("\n") ? content : `${content}\n`);
464
+ const tmpPath = learningsPath + ".tmp." + process.pid;
465
+ fs.writeFileSync(tmpPath, content.endsWith("\n") ? content : `${content}\n`);
466
+ fs.renameSync(tmpPath, learningsPath);
449
467
  }
450
468
  return phrenOk({ content, wrote: added.length > 0 });
451
469
  }
@@ -460,7 +478,10 @@ export function addFindingsToFile(phrenPath, project, learnings, opts) {
460
478
  rejected.push({ text: learning, reason: lengthError });
461
479
  continue;
462
480
  }
463
- const prepared = prepareFinding(learning, project, content, extraAnnotations, resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath);
481
+ const prepared = prepareFinding({
482
+ finding: learning, project, fullHistory: content, extraAnnotations,
483
+ citationInput: resolvedCitationInput, source, nowIso, inferredRepo, headCommit, phrenPath,
484
+ });
464
485
  if (prepared.status === "rejected") {
465
486
  rejected.push({ text: learning, reason: prepared.reason });
466
487
  continue;
@@ -275,7 +275,9 @@ export function removeFinding(phrenPath, project, match) {
275
275
  const matched = lines[idx];
276
276
  lines.splice(idx, removeCount);
277
277
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
278
- fs.writeFileSync(filePath, normalized);
278
+ const tmp = filePath + ".tmp." + process.pid;
279
+ fs.writeFileSync(tmp, normalized);
280
+ fs.renameSync(tmp, filePath);
279
281
  return phrenOk(`Removed from ${project}: ${matched}`);
280
282
  });
281
283
  }
@@ -323,7 +325,9 @@ export function removeFindings(phrenPath, project, matches) {
323
325
  if (removed.length > 0) {
324
326
  const filtered = lines.filter((_, i) => !indicesToRemove.has(i));
325
327
  const normalized = filtered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
326
- fs.writeFileSync(findingsPath, normalized);
328
+ const tmp = findingsPath + ".tmp." + process.pid;
329
+ fs.writeFileSync(tmp, normalized);
330
+ fs.renameSync(tmp, findingsPath);
327
331
  }
328
332
  return phrenOk({ removed, errors });
329
333
  });
@@ -363,7 +367,9 @@ export function editFinding(phrenPath, project, oldText, newText) {
363
367
  const metaSuffix = metaMatch ? " " + metaMatch.join(" ") : "";
364
368
  lines[idx] = `- ${newTextTrimmed}${metaSuffix}`;
365
369
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
366
- fs.writeFileSync(findingsPath, normalized);
370
+ const tmp = findingsPath + ".tmp." + process.pid;
371
+ fs.writeFileSync(tmp, normalized);
372
+ fs.renameSync(tmp, findingsPath);
367
373
  return phrenOk(`Updated finding in ${project}`);
368
374
  });
369
375
  }
@@ -452,7 +458,10 @@ function withQueueLineOp(phrenPath, project, lineText, op) {
452
458
  });
453
459
  }
454
460
  function writeQueueLines(file, lines) {
455
- fs.writeFileSync(file, lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n");
461
+ const content = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
462
+ const tmp = file + ".tmp." + process.pid;
463
+ fs.writeFileSync(tmp, content);
464
+ fs.renameSync(tmp, file);
456
465
  }
457
466
  /** Remove a queue item's line from review.md (finding stays in FINDINGS.md). */
458
467
  export function approveQueueItem(phrenPath, project, lineText) {
@@ -219,7 +219,9 @@ export function supersedeFinding(phrenPath, project, findingText, supersededBy)
219
219
  const today = new Date().toISOString().slice(0, 10);
220
220
  lines[matched.data.index] = applyLifecycle(lines[matched.data.index], { status: "superseded", status_updated: today, status_reason: "superseded_by", status_ref: ref }, today, { supersededBy: ref });
221
221
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
222
- fs.writeFileSync(findingsPath, normalized);
222
+ const tmpPath = findingsPath + ".tmp." + process.pid;
223
+ fs.writeFileSync(tmpPath, normalized);
224
+ fs.renameSync(tmpPath, findingsPath);
223
225
  return phrenOk({ finding: matched.data.text, superseded_by: ref, status: "superseded" });
224
226
  });
225
227
  }
@@ -239,7 +241,9 @@ export function retractFinding(phrenPath, project, findingText, reason) {
239
241
  const today = new Date().toISOString().slice(0, 10);
240
242
  lines[matched.data.index] = applyLifecycle(lines[matched.data.index], { status: "retracted", status_updated: today, status_reason: reasonText }, today);
241
243
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
242
- fs.writeFileSync(findingsPath, normalized);
244
+ const tmpPath = findingsPath + ".tmp." + process.pid;
245
+ fs.writeFileSync(tmpPath, normalized);
246
+ fs.renameSync(tmpPath, findingsPath);
243
247
  return phrenOk({ finding: matched.data.text, reason: reasonText, status: "retracted" });
244
248
  });
245
249
  }
@@ -289,7 +293,9 @@ export function resolveFindingContradiction(phrenPath, project, findingA, findin
289
293
  statusB = "retracted";
290
294
  }
291
295
  const normalized = lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
292
- fs.writeFileSync(findingsPath, normalized);
296
+ const tmpPath = findingsPath + ".tmp." + process.pid;
297
+ fs.writeFileSync(tmpPath, normalized);
298
+ fs.renameSync(tmpPath, findingsPath);
293
299
  return phrenOk({
294
300
  resolution,
295
301
  finding_a: { text: matchedA.data.text, status: statusA },
@@ -2,18 +2,26 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { debugLog } from "../shared.js";
4
4
  import { errorMessage } from "../utils.js";
5
+ const MAX_LOG_LINES = 1000;
5
6
  export function recordRetrieval(phrenPath, file, section) {
6
7
  const dir = path.join(phrenPath, ".runtime");
7
- fs.mkdirSync(dir, { recursive: true });
8
- const logPath = path.join(dir, "retrieval-log.jsonl");
9
- const entry = { file, section, retrievedAt: new Date().toISOString() };
10
- fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
8
+ let logPath;
9
+ try {
10
+ fs.mkdirSync(dir, { recursive: true });
11
+ logPath = path.join(dir, "retrieval-log.jsonl");
12
+ const entry = { file, section, retrievedAt: new Date().toISOString() };
13
+ fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
14
+ }
15
+ catch (err) {
16
+ debugLog(`recordRetrieval write failed: ${errorMessage(err)}`);
17
+ return;
18
+ }
11
19
  try {
12
20
  const stat = fs.statSync(logPath);
13
21
  if (stat.size > 500_000) {
14
22
  const content = fs.readFileSync(logPath, "utf8");
15
23
  const lines = content.split("\n").filter(Boolean);
16
- fs.writeFileSync(logPath, lines.slice(-1000).join("\n") + "\n");
24
+ fs.writeFileSync(logPath, lines.slice(-MAX_LOG_LINES).join("\n") + "\n");
17
25
  }
18
26
  }
19
27
  catch (err) {
@@ -8,7 +8,20 @@ import { readProjectConfig } from "../project-config.js";
8
8
  import { getActiveProfileDefaults } from "../profile-store.js";
9
9
  import { runCustomHooks } from "../hooks.js";
10
10
  import { METADATA_REGEX, isCitationLine, isArchiveStart as isArchiveStartMeta, isArchiveEnd as isArchiveEndMeta, stripLifecycleMetadata as stripLifecycleMetadataMeta, } from "../content/metadata.js";
11
+ /** @internal Exported for tests. */
11
12
  export const MAX_QUEUE_ENTRY_LENGTH = 500;
13
+ export function buildSyncStatus(opts) {
14
+ return {
15
+ ...(opts.pullAt !== undefined ? { lastPullAt: opts.pullAt } : {}),
16
+ ...(opts.pullStatus !== undefined ? { lastPullStatus: opts.pullStatus } : {}),
17
+ ...(opts.pullDetail !== undefined ? { lastPullDetail: opts.pullDetail } : {}),
18
+ ...(opts.successfulPullAt !== undefined ? { lastSuccessfulPullAt: opts.successfulPullAt } : {}),
19
+ lastPushAt: opts.now,
20
+ lastPushStatus: opts.pushStatus,
21
+ ...(opts.pushDetail !== undefined ? { lastPushDetail: opts.pushDetail } : {}),
22
+ ...(opts.unsyncedCommits !== undefined ? { unsyncedCommits: opts.unsyncedCommits } : {}),
23
+ };
24
+ }
12
25
  export const GOVERNANCE_SCHEMA_VERSION = 1;
13
26
  const DEFAULT_POLICY = {
14
27
  schemaVersion: GOVERNANCE_SCHEMA_VERSION,
@@ -104,7 +104,7 @@ function rolePermits(role, action) {
104
104
  *
105
105
  * Returns `{ allowed: true }` when permitted, `{ allowed: false, reason }` when denied.
106
106
  */
107
- export function checkPermission(phrenPath, action, project) {
107
+ function checkPermission(phrenPath, action, project) {
108
108
  const actor = (process.env.PHREN_ACTOR ?? "").trim() || null;
109
109
  const globalAc = readGlobalAccessControl(phrenPath);
110
110
  const projectAccess = project
@@ -5,6 +5,7 @@ import { appendAuditLog, debugLog, isRecord, memoryScoresFile, memoryUsageLogFil
5
5
  import { withFileLock, isFiniteNumber, hasValidSchemaVersion } from "../shared/governance.js";
6
6
  import { errorMessage } from "../utils.js";
7
7
  import { logger } from "../logger.js";
8
+ const MAX_LOG_LINES = 1000;
8
9
  const GOVERNANCE_SCHEMA_VERSION = 1;
9
10
  const DEFAULT_MEMORY_SCORES_FILE = {
10
11
  schemaVersion: GOVERNANCE_SCHEMA_VERSION,
@@ -255,7 +256,7 @@ export function recordInjection(phrenPath, key, sessionId) {
255
256
  if (stat.size > 1_000_000) {
256
257
  const content = fs.readFileSync(logFile, "utf8");
257
258
  const lines = content.split("\n");
258
- fs.writeFileSync(logFile, lines.slice(-500).join("\n"));
259
+ fs.writeFileSync(logFile, lines.slice(-MAX_LOG_LINES).join("\n"));
259
260
  }
260
261
  }
261
262
  catch (err) {
package/mcp/dist/hooks.js CHANGED
@@ -236,7 +236,14 @@ function cachedReadInstallPrefsJson(phrenPath) {
236
236
  if (cached && cached.mtimeMs === mtimeMs) {
237
237
  return cached.parsed;
238
238
  }
239
- const parsed = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
239
+ let parsed;
240
+ try {
241
+ parsed = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
242
+ }
243
+ catch {
244
+ _installPrefsJsonCache.delete(prefsPath);
245
+ return null;
246
+ }
240
247
  _installPrefsJsonCache.set(prefsPath, { mtimeMs, parsed });
241
248
  return parsed;
242
249
  }
@@ -272,6 +279,7 @@ export const HOOK_EVENT_VALUES = [
272
279
  "post-session-end", "post-consolidate",
273
280
  ];
274
281
  const VALID_HOOK_EVENTS = new Set(HOOK_EVENT_VALUES);
282
+ const MAX_HOOK_COMMAND_LENGTH = 1000;
275
283
  /** Return the target (URL or shell command) for display or matching. */
276
284
  export function getHookTarget(h) {
277
285
  return "webhook" in h ? h.webhook : h.command;
@@ -280,8 +288,8 @@ export function validateCustomHookCommand(command) {
280
288
  const trimmed = command.trim();
281
289
  if (!trimmed)
282
290
  return "Command cannot be empty.";
283
- if (trimmed.length > 1000)
284
- return "Command too long (max 1000 characters).";
291
+ if (trimmed.length > MAX_HOOK_COMMAND_LENGTH)
292
+ return `Command too long (max ${MAX_HOOK_COMMAND_LENGTH} characters).`;
285
293
  if (/[`$(){}&|;<>\n\r#]/.test(trimmed)) {
286
294
  return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < > # \\n \\r";
287
295
  }
@@ -566,7 +574,7 @@ export function configureAllHooks(phrenPath, options = {}) {
566
574
  configured.push("Copilot CLI");
567
575
  }
568
576
  catch (err) {
569
- debugLog(`configureAllHooks: copilot failed: ${errorMessage(err)}`);
577
+ console.warn(`configureAllHooks: copilot hook config failed: ${errorMessage(err)}`);
570
578
  }
571
579
  if (isToolHookEnabled(phrenPath, "copilot"))
572
580
  installSessionWrapper("copilot", phrenPath);
@@ -598,7 +606,7 @@ export function configureAllHooks(phrenPath, options = {}) {
598
606
  configured.push("Cursor");
599
607
  }
600
608
  catch (err) {
601
- debugLog(`configureAllHooks: cursor failed: ${errorMessage(err)}`);
609
+ console.warn(`configureAllHooks: cursor hook config failed: ${errorMessage(err)}`);
602
610
  }
603
611
  if (isToolHookEnabled(phrenPath, "cursor"))
604
612
  installSessionWrapper("cursor", phrenPath);
@@ -629,7 +637,7 @@ export function configureAllHooks(phrenPath, options = {}) {
629
637
  configured.push("Codex");
630
638
  }
631
639
  catch (err) {
632
- debugLog(`configureAllHooks: codex failed: ${errorMessage(err)}`);
640
+ console.warn(`configureAllHooks: codex hook config failed: ${errorMessage(err)}`);
633
641
  }
634
642
  if (isToolHookEnabled(phrenPath, "codex"))
635
643
  installSessionWrapper("codex", phrenPath);
package/mcp/dist/index.js CHANGED
@@ -72,7 +72,7 @@ async function main() {
72
72
  catch (error) {
73
73
  const msg = error instanceof Error ? error.message : String(error);
74
74
  structuredLog("error", "startup", `Failed to build phren index: ${msg}`);
75
- console.error("Failed to build phren index at startup:", error);
75
+ console.error("Failed to build phren index at startup:", msg);
76
76
  process.exit(1);
77
77
  }
78
78
  let writeQueue = Promise.resolve();
@@ -435,31 +435,26 @@ async function runWalkthrough(phrenPath) {
435
435
  log(" Change later: set PHREN_OLLAMA_URL=off to disable");
436
436
  let ollamaEnabled = false;
437
437
  try {
438
- const { checkOllamaAvailable, checkModelAvailable, getOllamaUrl } = await import("../shared/ollama.js");
439
- if (getOllamaUrl()) {
440
- const ollamaUp = await checkOllamaAvailable();
441
- if (ollamaUp) {
442
- const modelReady = await checkModelAvailable();
443
- if (modelReady) {
444
- log(" Ollama detected with nomic-embed-text ready.");
445
- ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery?", false);
446
- }
447
- else {
448
- log(" Ollama detected, but nomic-embed-text is not pulled yet.");
449
- ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery? (will pull nomic-embed-text)", false);
450
- if (ollamaEnabled) {
451
- log(" Run after init: ollama pull nomic-embed-text");
452
- }
453
- }
438
+ const { checkOllamaStatus } = await import("../shared/ollama.js");
439
+ const status = await checkOllamaStatus();
440
+ if (status === "ready") {
441
+ log(" Ollama detected with nomic-embed-text ready.");
442
+ ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery?", false);
443
+ }
444
+ else if (status === "no_model") {
445
+ log(" Ollama detected, but nomic-embed-text is not pulled yet.");
446
+ ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery? (will pull nomic-embed-text)", false);
447
+ if (ollamaEnabled) {
448
+ log(" Run after init: ollama pull nomic-embed-text");
454
449
  }
455
- else {
456
- log(" Ollama not detected. Install it to enable semantic search:");
457
- log(" https://ollama.com → then: ollama pull nomic-embed-text");
458
- ollamaEnabled = await prompts.confirm("Enable semantic search (Ollama not installed yet)?", false);
459
- if (ollamaEnabled) {
460
- log(style.success(" Semantic search enabled — will activate once Ollama is running."));
461
- log(" To disable: set PHREN_OLLAMA_URL=off in your shell profile");
462
- }
450
+ }
451
+ else if (status === "not_running") {
452
+ log(" Ollama not detected. Install it to enable semantic search:");
453
+ log(" https://ollama.com → then: ollama pull nomic-embed-text");
454
+ ollamaEnabled = await prompts.confirm("Enable semantic search (Ollama not installed yet)?", false);
455
+ if (ollamaEnabled) {
456
+ log(style.success(" Semantic search enabled will activate once Ollama is running."));
457
+ log(" To disable: set PHREN_OLLAMA_URL=off in your shell profile");
463
458
  }
464
459
  }
465
460
  }
@@ -1450,24 +1445,19 @@ export async function runInit(opts = {}) {
1450
1445
  const walkthroughCoveredOllama = Boolean(process.env._PHREN_WALKTHROUGH_OLLAMA_SKIP) || (!hasExistingInstall && !opts.yes);
1451
1446
  if (!walkthroughCoveredOllama) {
1452
1447
  try {
1453
- const { checkOllamaAvailable, checkModelAvailable, getOllamaUrl } = await import("../shared/ollama.js");
1454
- if (getOllamaUrl()) {
1455
- const ollamaUp = await checkOllamaAvailable();
1456
- if (ollamaUp) {
1457
- const modelReady = await checkModelAvailable();
1458
- if (modelReady) {
1459
- log("\n Semantic search: Ollama + nomic-embed-text ready.");
1460
- }
1461
- else {
1462
- log("\n Semantic search: Ollama running, but nomic-embed-text not pulled.");
1463
- log(" Run: ollama pull nomic-embed-text");
1464
- }
1465
- }
1466
- else {
1467
- log("\n Tip: Install Ollama for semantic search (optional).");
1468
- log(" https://ollama.com → then: ollama pull nomic-embed-text");
1469
- log(" (Set PHREN_OLLAMA_URL=off to hide this message)");
1470
- }
1448
+ const { checkOllamaStatus } = await import("../shared/ollama.js");
1449
+ const status = await checkOllamaStatus();
1450
+ if (status === "ready") {
1451
+ log("\n Semantic search: Ollama + nomic-embed-text ready.");
1452
+ }
1453
+ else if (status === "no_model") {
1454
+ log("\n Semantic search: Ollama running, but nomic-embed-text not pulled.");
1455
+ log(" Run: ollama pull nomic-embed-text");
1456
+ }
1457
+ else if (status === "not_running") {
1458
+ log("\n Tip: Install Ollama for semantic search (optional).");
1459
+ log(" https://ollama.com → then: ollama pull nomic-embed-text");
1460
+ log(" (Set PHREN_OLLAMA_URL=off to hide this message)");
1471
1461
  }
1472
1462
  }
1473
1463
  catch (err) {
@@ -2003,6 +1993,28 @@ export async function runUninstall(opts = {}) {
2003
1993
  catch (err) {
2004
1994
  debugLog(`uninstall: cleanup failed for ${contextFile}: ${errorMessage(err)}`);
2005
1995
  }
1996
+ // Remove global CLAUDE.md symlink (created by linkGlobal -> ~/.claude/CLAUDE.md)
1997
+ const globalClaudeLink = homePath(".claude", "CLAUDE.md");
1998
+ try {
1999
+ if (fs.lstatSync(globalClaudeLink).isSymbolicLink()) {
2000
+ fs.unlinkSync(globalClaudeLink);
2001
+ log(` Removed global CLAUDE.md symlink (${globalClaudeLink})`);
2002
+ }
2003
+ }
2004
+ catch {
2005
+ // Does not exist or not a symlink — nothing to do
2006
+ }
2007
+ // Remove copilot-instructions.md symlink (created by linkGlobal -> ~/.github/copilot-instructions.md)
2008
+ const copilotInstrLink = homePath(".github", "copilot-instructions.md");
2009
+ try {
2010
+ if (fs.lstatSync(copilotInstrLink).isSymbolicLink()) {
2011
+ fs.unlinkSync(copilotInstrLink);
2012
+ log(` Removed copilot-instructions.md symlink (${copilotInstrLink})`);
2013
+ }
2014
+ }
2015
+ catch {
2016
+ // Does not exist or not a symlink — nothing to do
2017
+ }
2006
2018
  // Sweep agent skill directories for symlinks pointing into the phren store
2007
2019
  if (phrenPath) {
2008
2020
  try {
@@ -33,7 +33,7 @@ export function commandVersion(cmd, args = ["--version"]) {
33
33
  return null;
34
34
  }
35
35
  }
36
- export function parseSemverTriple(raw) {
36
+ function parseSemverTriple(raw) {
37
37
  const match = raw.match(/(\d+)\.(\d+)\.(\d+)/);
38
38
  if (!match)
39
39
  return null;
@@ -1,56 +1,9 @@
1
1
  /**
2
2
  * Bootstrap-current-project prompting and execution for init.
3
3
  */
4
- import * as path from "path";
5
4
  import { debugLog } from "./shared.js";
6
5
  import { bootstrapFromExisting, } from "./init/setup.js";
7
- import { PROJECT_OWNERSHIP_MODES, } from "./project-config.js";
8
- import { createWalkthroughPrompts, createWalkthroughStyle, } from "./init-walkthrough.js";
9
- import { getPendingBootstrapTarget } from "./init-detect.js";
10
6
  import { log } from "./init/shared.js";
11
- /**
12
- * Decide whether to bootstrap the CWD project and with what ownership.
13
- * May prompt the user interactively.
14
- */
15
- export async function resolveBootstrapDecision(phrenPath, opts, ownershipDefault, dryRun) {
16
- const pendingBootstrap = getPendingBootstrapTarget(phrenPath, opts);
17
- let shouldBootstrap = opts._walkthroughBootstrapCurrentProject === true;
18
- let ownership = opts._walkthroughBootstrapOwnership ?? ownershipDefault;
19
- if (pendingBootstrap && !dryRun) {
20
- const walkthroughAlreadyHandled = opts._walkthroughBootstrapCurrentProject !== undefined;
21
- if (walkthroughAlreadyHandled) {
22
- shouldBootstrap = opts._walkthroughBootstrapCurrentProject === true;
23
- ownership = opts._walkthroughBootstrapOwnership ?? ownershipDefault;
24
- }
25
- else if (opts.yes || !process.stdin.isTTY || !process.stdout.isTTY) {
26
- shouldBootstrap = true;
27
- ownership = ownershipDefault;
28
- }
29
- else {
30
- const prompts = await createWalkthroughPrompts();
31
- const style = await createWalkthroughStyle();
32
- const detectedProjectName = path.basename(pendingBootstrap.path);
33
- log("");
34
- log(style.header("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
35
- log(style.header("Current Project"));
36
- log(style.header("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
37
- log(`Detected project: ${detectedProjectName}`);
38
- shouldBootstrap = await prompts.confirm("Add this project to phren now?", true);
39
- if (!shouldBootstrap) {
40
- log(style.warning(` Skipped. Later: cd ${pendingBootstrap.path} && npx phren add`));
41
- }
42
- else {
43
- ownership = await prompts.select("Ownership for detected project", [
44
- { value: ownershipDefault, name: `${ownershipDefault} (default)` },
45
- ...PROJECT_OWNERSHIP_MODES
46
- .filter((mode) => mode !== ownershipDefault)
47
- .map((mode) => ({ value: mode, name: mode })),
48
- ], ownershipDefault);
49
- }
50
- }
51
- }
52
- return { shouldBootstrap, ownership };
53
- }
54
7
  /**
55
8
  * Bootstrap a project from an existing directory into phren.
56
9
  */
@@ -162,24 +162,19 @@ export async function runFreshInstall(phrenPath, opts, params) {
162
162
  const walkthroughCoveredOllama = Boolean(process.env._PHREN_WALKTHROUGH_OLLAMA_SKIP) || !opts.yes;
163
163
  if (!walkthroughCoveredOllama) {
164
164
  try {
165
- const { checkOllamaAvailable, checkModelAvailable, getOllamaUrl } = await import("./shared/ollama.js");
166
- if (getOllamaUrl()) {
167
- const ollamaUp = await checkOllamaAvailable();
168
- if (ollamaUp) {
169
- const modelReady = await checkModelAvailable();
170
- if (modelReady) {
171
- log("\n Semantic search: Ollama + nomic-embed-text ready.");
172
- }
173
- else {
174
- log("\n Semantic search: Ollama running, but nomic-embed-text not pulled.");
175
- log(" Run: ollama pull nomic-embed-text");
176
- }
177
- }
178
- else {
179
- log("\n Tip: Install Ollama for semantic search (optional).");
180
- log(" https://ollama.com → then: ollama pull nomic-embed-text");
181
- log(" (Set PHREN_OLLAMA_URL=off to hide this message)");
182
- }
165
+ const { checkOllamaStatus } = await import("./shared/ollama.js");
166
+ const status = await checkOllamaStatus();
167
+ if (status === "ready") {
168
+ log("\n Semantic search: Ollama + nomic-embed-text ready.");
169
+ }
170
+ else if (status === "no_model") {
171
+ log("\n Semantic search: Ollama running, but nomic-embed-text not pulled.");
172
+ log(" Run: ollama pull nomic-embed-text");
173
+ }
174
+ else if (status === "not_running") {
175
+ log("\n Tip: Install Ollama for semantic search (optional).");
176
+ log(" https://ollama.com → then: ollama pull nomic-embed-text");
177
+ log(" (Set PHREN_OLLAMA_URL=off to hide this message)");
183
178
  }
184
179
  }
185
180
  catch (err) {
@@ -428,6 +428,28 @@ export async function runUninstall(opts = {}) {
428
428
  catch (err) {
429
429
  debugLog(`uninstall: cleanup failed for ${contextFile}: ${errorMessage(err)}`);
430
430
  }
431
+ // Remove global CLAUDE.md symlink (created by linkGlobal -> ~/.claude/CLAUDE.md)
432
+ const globalClaudeLink = homePath(".claude", "CLAUDE.md");
433
+ try {
434
+ if (fs.lstatSync(globalClaudeLink).isSymbolicLink()) {
435
+ fs.unlinkSync(globalClaudeLink);
436
+ log(` Removed global CLAUDE.md symlink (${globalClaudeLink})`);
437
+ }
438
+ }
439
+ catch {
440
+ // Does not exist or not a symlink — nothing to do
441
+ }
442
+ // Remove copilot-instructions.md symlink (created by linkGlobal -> ~/.github/copilot-instructions.md)
443
+ const copilotInstrLink = homePath(".github", "copilot-instructions.md");
444
+ try {
445
+ if (fs.lstatSync(copilotInstrLink).isSymbolicLink()) {
446
+ fs.unlinkSync(copilotInstrLink);
447
+ log(` Removed copilot-instructions.md symlink (${copilotInstrLink})`);
448
+ }
449
+ }
450
+ catch {
451
+ // Does not exist or not a symlink — nothing to do
452
+ }
431
453
  // Sweep agent skill directories for symlinks pointing into the phren store
432
454
  if (phrenPath) {
433
455
  try {
@@ -300,31 +300,26 @@ export async function runWalkthrough(phrenPath) {
300
300
  log(" Change later: set PHREN_OLLAMA_URL=off to disable");
301
301
  let ollamaEnabled = false;
302
302
  try {
303
- const { checkOllamaAvailable, checkModelAvailable, getOllamaUrl } = await import("./shared/ollama.js");
304
- if (getOllamaUrl()) {
305
- const ollamaUp = await checkOllamaAvailable();
306
- if (ollamaUp) {
307
- const modelReady = await checkModelAvailable();
308
- if (modelReady) {
309
- log(" Ollama detected with nomic-embed-text ready.");
310
- ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery?", false);
311
- }
312
- else {
313
- log(" Ollama detected, but nomic-embed-text is not pulled yet.");
314
- ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery? (will pull nomic-embed-text)", false);
315
- if (ollamaEnabled) {
316
- log(" Run after init: ollama pull nomic-embed-text");
317
- }
318
- }
303
+ const { checkOllamaStatus } = await import("./shared/ollama.js");
304
+ const status = await checkOllamaStatus();
305
+ if (status === "ready") {
306
+ log(" Ollama detected with nomic-embed-text ready.");
307
+ ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery?", false);
308
+ }
309
+ else if (status === "no_model") {
310
+ log(" Ollama detected, but nomic-embed-text is not pulled yet.");
311
+ ollamaEnabled = await prompts.confirm("Enable semantic search for fuzzy/paraphrase recovery? (will pull nomic-embed-text)", false);
312
+ if (ollamaEnabled) {
313
+ log(" Run after init: ollama pull nomic-embed-text");
319
314
  }
320
- else {
321
- log(" Ollama not detected. Install it to enable semantic search:");
322
- log(" https://ollama.com → then: ollama pull nomic-embed-text");
323
- ollamaEnabled = await prompts.confirm("Enable semantic search (Ollama not installed yet)?", false);
324
- if (ollamaEnabled) {
325
- log(style.success(" Semantic search enabled — will activate once Ollama is running."));
326
- log(" To disable: set PHREN_OLLAMA_URL=off in your shell profile");
327
- }
315
+ }
316
+ else if (status === "not_running") {
317
+ log(" Ollama not detected. Install it to enable semantic search:");
318
+ log(" https://ollama.com → then: ollama pull nomic-embed-text");
319
+ ollamaEnabled = await prompts.confirm("Enable semantic search (Ollama not installed yet)?", false);
320
+ if (ollamaEnabled) {
321
+ log(style.success(" Semantic search enabled will activate once Ollama is running."));
322
+ log(" To disable: set PHREN_OLLAMA_URL=off in your shell profile");
328
323
  }
329
324
  }
330
325
  }