@phren/cli 0.0.32 → 0.0.34
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/mcp/dist/cli/actions.js +3 -0
- package/mcp/dist/cli/config.js +3 -3
- package/mcp/dist/cli/govern.js +18 -8
- package/mcp/dist/cli/hooks-context.js +1 -1
- package/mcp/dist/cli/hooks-session.js +18 -62
- package/mcp/dist/cli/namespaces.js +1 -1
- package/mcp/dist/cli/search.js +5 -5
- package/mcp/dist/cli-hooks-prompt.js +7 -3
- package/mcp/dist/cli-hooks-session-handlers.js +3 -15
- package/mcp/dist/cli-hooks-stop.js +10 -48
- package/mcp/dist/content/archive.js +8 -20
- package/mcp/dist/content/learning.js +29 -8
- package/mcp/dist/data/access.js +13 -4
- package/mcp/dist/finding/lifecycle.js +9 -3
- package/mcp/dist/governance/audit.js +13 -5
- package/mcp/dist/governance/policy.js +13 -0
- package/mcp/dist/governance/rbac.js +1 -1
- package/mcp/dist/governance/scores.js +2 -1
- package/mcp/dist/hooks.js +52 -6
- package/mcp/dist/index.js +1 -1
- package/mcp/dist/init/init.js +66 -45
- package/mcp/dist/init/shared.js +1 -1
- package/mcp/dist/init-bootstrap.js +0 -47
- package/mcp/dist/init-fresh.js +13 -18
- package/mcp/dist/init-uninstall.js +22 -0
- package/mcp/dist/init-walkthrough.js +19 -24
- package/mcp/dist/link/doctor.js +9 -0
- package/mcp/dist/package-metadata.js +1 -1
- package/mcp/dist/phren-art.js +4 -120
- package/mcp/dist/proactivity.js +1 -1
- package/mcp/dist/project-topics.js +16 -46
- package/mcp/dist/provider-adapters.js +1 -1
- package/mcp/dist/runtime-profile.js +1 -1
- package/mcp/dist/shared/data-utils.js +25 -0
- package/mcp/dist/shared/fragment-graph.js +4 -18
- package/mcp/dist/shared/index.js +14 -10
- package/mcp/dist/shared/ollama.js +23 -5
- package/mcp/dist/shared/process.js +24 -0
- package/mcp/dist/shared/retrieval.js +7 -4
- package/mcp/dist/shared/search-fallback.js +1 -0
- package/mcp/dist/shared.js +2 -1
- package/mcp/dist/shell/render.js +1 -1
- package/mcp/dist/skill/registry.js +1 -1
- package/mcp/dist/skill/state.js +0 -3
- package/mcp/dist/task/github.js +1 -0
- package/mcp/dist/task/lifecycle.js +1 -6
- package/mcp/dist/tools/config.js +415 -400
- package/mcp/dist/tools/finding.js +390 -373
- package/mcp/dist/tools/ops.js +372 -365
- package/mcp/dist/tools/search.js +495 -487
- package/mcp/dist/tools/session.js +3 -2
- package/mcp/dist/tools/skills.js +9 -0
- package/mcp/dist/ui/page.js +1 -1
- package/mcp/dist/ui/server.js +645 -1040
- package/mcp/dist/utils.js +12 -8
- package/package.json +1 -1
- package/mcp/dist/init-dryrun.js +0 -55
- package/mcp/dist/init-migrate.js +0 -51
- 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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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;
|
package/mcp/dist/data/access.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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(-
|
|
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
|
-
|
|
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(-
|
|
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
|
@@ -200,6 +200,44 @@ exit $status
|
|
|
200
200
|
return false;
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Install a lightweight `phren` CLI wrapper at ~/.local/bin/phren so the bare
|
|
205
|
+
* `phren` command works without a global npm install. The wrapper simply execs
|
|
206
|
+
* `node <entry_script> "$@"`.
|
|
207
|
+
*/
|
|
208
|
+
export function installPhrenCliWrapper(phrenPath) {
|
|
209
|
+
const entry = resolveCliEntryScript();
|
|
210
|
+
if (!entry)
|
|
211
|
+
return false;
|
|
212
|
+
const localBinDir = homePath(".local", "bin");
|
|
213
|
+
const wrapperPath = path.join(localBinDir, "phren");
|
|
214
|
+
// Don't overwrite a real global install — only our own wrapper
|
|
215
|
+
if (fs.existsSync(wrapperPath)) {
|
|
216
|
+
try {
|
|
217
|
+
const existing = fs.readFileSync(wrapperPath, "utf8");
|
|
218
|
+
if (!existing.includes("PHREN_CLI_WRAPPER"))
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
catch { /* unreadable — skip */ }
|
|
222
|
+
}
|
|
223
|
+
const content = `#!/bin/sh
|
|
224
|
+
# PHREN_CLI_WRAPPER — managed by phren init; safe to delete
|
|
225
|
+
set -u
|
|
226
|
+
PHREN_PATH="\${PHREN_PATH:-${shellEscape(phrenPath)}}"
|
|
227
|
+
export PHREN_PATH
|
|
228
|
+
exec node ${shellEscape(entry)} "$@"
|
|
229
|
+
`;
|
|
230
|
+
try {
|
|
231
|
+
fs.mkdirSync(localBinDir, { recursive: true });
|
|
232
|
+
atomicWriteText(wrapperPath, content);
|
|
233
|
+
fs.chmodSync(wrapperPath, 0o755);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
debugLog(`installPhrenCliWrapper: failed: ${errorMessage(err)}`);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
203
241
|
function validateCopilotConfig(config) {
|
|
204
242
|
return (typeof config.version === "number" &&
|
|
205
243
|
Array.isArray(config.hooks?.sessionStart) &&
|
|
@@ -236,7 +274,14 @@ function cachedReadInstallPrefsJson(phrenPath) {
|
|
|
236
274
|
if (cached && cached.mtimeMs === mtimeMs) {
|
|
237
275
|
return cached.parsed;
|
|
238
276
|
}
|
|
239
|
-
|
|
277
|
+
let parsed;
|
|
278
|
+
try {
|
|
279
|
+
parsed = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
_installPrefsJsonCache.delete(prefsPath);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
240
285
|
_installPrefsJsonCache.set(prefsPath, { mtimeMs, parsed });
|
|
241
286
|
return parsed;
|
|
242
287
|
}
|
|
@@ -272,6 +317,7 @@ export const HOOK_EVENT_VALUES = [
|
|
|
272
317
|
"post-session-end", "post-consolidate",
|
|
273
318
|
];
|
|
274
319
|
const VALID_HOOK_EVENTS = new Set(HOOK_EVENT_VALUES);
|
|
320
|
+
const MAX_HOOK_COMMAND_LENGTH = 1000;
|
|
275
321
|
/** Return the target (URL or shell command) for display or matching. */
|
|
276
322
|
export function getHookTarget(h) {
|
|
277
323
|
return "webhook" in h ? h.webhook : h.command;
|
|
@@ -280,8 +326,8 @@ export function validateCustomHookCommand(command) {
|
|
|
280
326
|
const trimmed = command.trim();
|
|
281
327
|
if (!trimmed)
|
|
282
328
|
return "Command cannot be empty.";
|
|
283
|
-
if (trimmed.length >
|
|
284
|
-
return
|
|
329
|
+
if (trimmed.length > MAX_HOOK_COMMAND_LENGTH)
|
|
330
|
+
return `Command too long (max ${MAX_HOOK_COMMAND_LENGTH} characters).`;
|
|
285
331
|
if (/[`$(){}&|;<>\n\r#]/.test(trimmed)) {
|
|
286
332
|
return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < > # \\n \\r";
|
|
287
333
|
}
|
|
@@ -566,7 +612,7 @@ export function configureAllHooks(phrenPath, options = {}) {
|
|
|
566
612
|
configured.push("Copilot CLI");
|
|
567
613
|
}
|
|
568
614
|
catch (err) {
|
|
569
|
-
|
|
615
|
+
console.warn(`configureAllHooks: copilot hook config failed: ${errorMessage(err)}`);
|
|
570
616
|
}
|
|
571
617
|
if (isToolHookEnabled(phrenPath, "copilot"))
|
|
572
618
|
installSessionWrapper("copilot", phrenPath);
|
|
@@ -598,7 +644,7 @@ export function configureAllHooks(phrenPath, options = {}) {
|
|
|
598
644
|
configured.push("Cursor");
|
|
599
645
|
}
|
|
600
646
|
catch (err) {
|
|
601
|
-
|
|
647
|
+
console.warn(`configureAllHooks: cursor hook config failed: ${errorMessage(err)}`);
|
|
602
648
|
}
|
|
603
649
|
if (isToolHookEnabled(phrenPath, "cursor"))
|
|
604
650
|
installSessionWrapper("cursor", phrenPath);
|
|
@@ -629,7 +675,7 @@ export function configureAllHooks(phrenPath, options = {}) {
|
|
|
629
675
|
configured.push("Codex");
|
|
630
676
|
}
|
|
631
677
|
catch (err) {
|
|
632
|
-
|
|
678
|
+
console.warn(`configureAllHooks: codex hook config failed: ${errorMessage(err)}`);
|
|
633
679
|
}
|
|
634
680
|
if (isToolHookEnabled(phrenPath, "codex"))
|
|
635
681
|
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:",
|
|
75
|
+
console.error("Failed to build phren index at startup:", msg);
|
|
76
76
|
process.exit(1);
|
|
77
77
|
}
|
|
78
78
|
let writeQueue = Promise.resolve();
|
package/mcp/dist/init/init.js
CHANGED
|
@@ -6,7 +6,7 @@ import * as fs from "fs";
|
|
|
6
6
|
import * as path from "path";
|
|
7
7
|
import * as crypto from "crypto";
|
|
8
8
|
import { execFileSync, spawnSync } from "child_process";
|
|
9
|
-
import { configureAllHooks } from "../hooks.js";
|
|
9
|
+
import { configureAllHooks, installPhrenCliWrapper } from "../hooks.js";
|
|
10
10
|
import { getMachineName, machineFilePath, persistMachineName } from "../machine-identity.js";
|
|
11
11
|
import { atomicWriteText, debugLog, isRecord, hookConfigPath, homeDir, homePath, expandHomePath, findPhrenPath, getProjectDirs, readRootManifest, writeRootManifest, } from "../shared.js";
|
|
12
12
|
import { isValidProjectName, errorMessage } from "../utils.js";
|
|
@@ -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 {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
}
|
|
@@ -974,6 +969,15 @@ function configureHooksIfEnabled(phrenPath, hooksEnabled, verb) {
|
|
|
974
969
|
else {
|
|
975
970
|
log(` Hooks are disabled by preference (run: npx phren hooks-mode on)`);
|
|
976
971
|
}
|
|
972
|
+
// Install phren CLI wrapper at ~/.local/bin/phren so the bare command works
|
|
973
|
+
try {
|
|
974
|
+
if (installPhrenCliWrapper(phrenPath)) {
|
|
975
|
+
log(` ${verb} CLI wrapper: ~/.local/bin/phren`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
debugLog(`installPhrenCliWrapper failed: ${errorMessage(err)}`);
|
|
980
|
+
}
|
|
977
981
|
}
|
|
978
982
|
export async function runInit(opts = {}) {
|
|
979
983
|
if ((opts.mode || "shared") === "project-local") {
|
|
@@ -1450,24 +1454,19 @@ export async function runInit(opts = {}) {
|
|
|
1450
1454
|
const walkthroughCoveredOllama = Boolean(process.env._PHREN_WALKTHROUGH_OLLAMA_SKIP) || (!hasExistingInstall && !opts.yes);
|
|
1451
1455
|
if (!walkthroughCoveredOllama) {
|
|
1452
1456
|
try {
|
|
1453
|
-
const {
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
-
}
|
|
1457
|
+
const { checkOllamaStatus } = await import("../shared/ollama.js");
|
|
1458
|
+
const status = await checkOllamaStatus();
|
|
1459
|
+
if (status === "ready") {
|
|
1460
|
+
log("\n Semantic search: Ollama + nomic-embed-text ready.");
|
|
1461
|
+
}
|
|
1462
|
+
else if (status === "no_model") {
|
|
1463
|
+
log("\n Semantic search: Ollama running, but nomic-embed-text not pulled.");
|
|
1464
|
+
log(" Run: ollama pull nomic-embed-text");
|
|
1465
|
+
}
|
|
1466
|
+
else if (status === "not_running") {
|
|
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)");
|
|
1471
1470
|
}
|
|
1472
1471
|
}
|
|
1473
1472
|
catch (err) {
|
|
@@ -1966,9 +1965,9 @@ export async function runUninstall(opts = {}) {
|
|
|
1966
1965
|
catch (err) {
|
|
1967
1966
|
debugLog(`uninstall: cleanup failed for ${codexHooksFile}: ${errorMessage(err)}`);
|
|
1968
1967
|
}
|
|
1969
|
-
// Remove session wrapper scripts (written by installSessionWrapper)
|
|
1968
|
+
// Remove session wrapper scripts (written by installSessionWrapper) and CLI wrapper
|
|
1970
1969
|
const localBinDir = path.join(home, ".local", "bin");
|
|
1971
|
-
for (const tool of ["copilot", "cursor", "codex"]) {
|
|
1970
|
+
for (const tool of ["copilot", "cursor", "codex", "phren"]) {
|
|
1972
1971
|
const wrapperPath = path.join(localBinDir, tool);
|
|
1973
1972
|
try {
|
|
1974
1973
|
if (fs.existsSync(wrapperPath)) {
|
|
@@ -2003,6 +2002,28 @@ export async function runUninstall(opts = {}) {
|
|
|
2003
2002
|
catch (err) {
|
|
2004
2003
|
debugLog(`uninstall: cleanup failed for ${contextFile}: ${errorMessage(err)}`);
|
|
2005
2004
|
}
|
|
2005
|
+
// Remove global CLAUDE.md symlink (created by linkGlobal -> ~/.claude/CLAUDE.md)
|
|
2006
|
+
const globalClaudeLink = homePath(".claude", "CLAUDE.md");
|
|
2007
|
+
try {
|
|
2008
|
+
if (fs.lstatSync(globalClaudeLink).isSymbolicLink()) {
|
|
2009
|
+
fs.unlinkSync(globalClaudeLink);
|
|
2010
|
+
log(` Removed global CLAUDE.md symlink (${globalClaudeLink})`);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
catch {
|
|
2014
|
+
// Does not exist or not a symlink — nothing to do
|
|
2015
|
+
}
|
|
2016
|
+
// Remove copilot-instructions.md symlink (created by linkGlobal -> ~/.github/copilot-instructions.md)
|
|
2017
|
+
const copilotInstrLink = homePath(".github", "copilot-instructions.md");
|
|
2018
|
+
try {
|
|
2019
|
+
if (fs.lstatSync(copilotInstrLink).isSymbolicLink()) {
|
|
2020
|
+
fs.unlinkSync(copilotInstrLink);
|
|
2021
|
+
log(` Removed copilot-instructions.md symlink (${copilotInstrLink})`);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
catch {
|
|
2025
|
+
// Does not exist or not a symlink — nothing to do
|
|
2026
|
+
}
|
|
2006
2027
|
// Sweep agent skill directories for symlinks pointing into the phren store
|
|
2007
2028
|
if (phrenPath) {
|
|
2008
2029
|
try {
|
package/mcp/dist/init/shared.js
CHANGED
|
@@ -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
|
*/
|
package/mcp/dist/init-fresh.js
CHANGED
|
@@ -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 {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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) {
|