@phren/cli 0.0.17 → 0.0.19
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/capabilities/cli.js +1 -1
- package/mcp/dist/capabilities/mcp.js +1 -1
- package/mcp/dist/capabilities/vscode.js +1 -1
- package/mcp/dist/capabilities/web-ui.js +1 -1
- package/mcp/dist/cli-hooks-session.js +3 -3
- package/mcp/dist/cli-namespaces.js +1 -1
- package/mcp/dist/data-tasks.js +1 -1
- package/mcp/dist/entrypoint.js +1 -1
- package/mcp/dist/governance-policy.js +76 -32
- package/mcp/dist/governance-rbac.js +152 -0
- package/mcp/dist/init-preferences.js +1 -1
- package/mcp/dist/init-setup.js +7 -7
- package/mcp/dist/init.js +2 -2
- package/mcp/dist/link-checksums.js +1 -1
- package/mcp/dist/link-doctor.js +1 -1
- package/mcp/dist/mcp-config.js +102 -2
- package/mcp/dist/mcp-finding.js +16 -0
- package/mcp/dist/mcp-search.js +44 -5
- package/mcp/dist/mcp-session.js +4 -0
- package/mcp/dist/mcp-tasks.js +22 -0
- package/mcp/dist/memory-ui-assets.js +2 -2
- package/mcp/dist/memory-ui-graph.js +9 -19
- package/mcp/dist/memory-ui-page.js +26 -43
- package/mcp/dist/memory-ui-scripts.js +198 -76
- package/mcp/dist/memory-ui-server.js +57 -2
- package/mcp/dist/phren-core.js +1 -1
- package/mcp/dist/phren-paths.js +2 -2
- package/mcp/dist/proactivity.js +47 -0
- package/mcp/dist/profile-store.js +100 -1
- package/mcp/dist/shared-index.js +2 -2
- package/mcp/dist/shared-retrieval.js +42 -1
- package/mcp/dist/shell-input.js +1 -1
- package/package.json +1 -1
|
@@ -329,7 +329,7 @@ function scheduleBackgroundMaintenance(phrenPathLocal, project) {
|
|
|
329
329
|
}
|
|
330
330
|
if (project)
|
|
331
331
|
spawnArgs.push(project);
|
|
332
|
-
const logDir = path.join(phrenPathLocal, ".
|
|
332
|
+
const logDir = path.join(phrenPathLocal, ".config");
|
|
333
333
|
fs.mkdirSync(logDir, { recursive: true });
|
|
334
334
|
const logPath = path.join(logDir, "background-maintenance.log");
|
|
335
335
|
const logFd = fs.openSync(logPath, "a");
|
|
@@ -394,7 +394,7 @@ function scheduleBackgroundMaintenance(phrenPathLocal, project) {
|
|
|
394
394
|
catch (err) {
|
|
395
395
|
const errMsg = errorMessage(err);
|
|
396
396
|
try {
|
|
397
|
-
const logDir = path.join(phrenPathLocal, ".
|
|
397
|
+
const logDir = path.join(phrenPathLocal, ".config");
|
|
398
398
|
fs.mkdirSync(logDir, { recursive: true });
|
|
399
399
|
fs.appendFileSync(path.join(logDir, "background-maintenance.log"), `[${new Date().toISOString()}] spawn failed: ${errMsg}\n`);
|
|
400
400
|
}
|
|
@@ -888,7 +888,7 @@ export async function handleHookStop() {
|
|
|
888
888
|
continue;
|
|
889
889
|
const proj = m[1].trim();
|
|
890
890
|
if (proj.startsWith("."))
|
|
891
|
-
continue; // skip .
|
|
891
|
+
continue; // skip .config, .runtime, etc.
|
|
892
892
|
const file = m[2].trim();
|
|
893
893
|
if (!changes.has(proj))
|
|
894
894
|
changes.set(proj, new Set());
|
|
@@ -1440,7 +1440,7 @@ export async function handleFindingNamespace(args) {
|
|
|
1440
1440
|
if (subcommand === "contradictions") {
|
|
1441
1441
|
const project = args[1];
|
|
1442
1442
|
const phrenPath = getPhrenPath();
|
|
1443
|
-
const RESERVED_DIRS = new Set(["global", ".runtime", ".sessions", ".
|
|
1443
|
+
const RESERVED_DIRS = new Set(["global", ".runtime", ".sessions", ".config"]);
|
|
1444
1444
|
const { readFindings } = await import("./data-access.js");
|
|
1445
1445
|
const projects = project
|
|
1446
1446
|
? [project]
|
package/mcp/dist/data-tasks.js
CHANGED
|
@@ -330,7 +330,7 @@ function writeTaskDoc(doc) {
|
|
|
330
330
|
fs.renameSync(tmpPath, doc.path);
|
|
331
331
|
}
|
|
332
332
|
function taskArchivePath(phrenPath, project) {
|
|
333
|
-
return path.join(phrenPath, ".
|
|
333
|
+
return path.join(phrenPath, ".config", "task-archive", `${project}.md`);
|
|
334
334
|
}
|
|
335
335
|
export function readTasks(phrenPath, project) {
|
|
336
336
|
const ensured = ensureProject(phrenPath, project);
|
package/mcp/dist/entrypoint.js
CHANGED
|
@@ -255,7 +255,7 @@ export async function runTopLevelCommand(argv) {
|
|
|
255
255
|
const ownershipArg = getOptionValue(argv.slice(1), "--ownership");
|
|
256
256
|
const phrenPath = defaultPhrenPath();
|
|
257
257
|
const profile = (process.env.PHREN_PROFILE) || undefined;
|
|
258
|
-
if (!fs.existsSync(phrenPath) || !fs.existsSync(path.join(phrenPath, ".
|
|
258
|
+
if (!fs.existsSync(phrenPath) || !fs.existsSync(path.join(phrenPath, ".config"))) {
|
|
259
259
|
console.log("phren is not set up yet. Run: npx phren init");
|
|
260
260
|
return finish(1);
|
|
261
261
|
}
|
|
@@ -5,6 +5,7 @@ import { appendAuditLog, debugLog, getProjectDirs, isRecord, runtimeHealthFile,
|
|
|
5
5
|
import { withFileLock, isFiniteNumber, hasValidSchemaVersion } from "./shared-governance.js";
|
|
6
6
|
import { errorMessage, isValidProjectName, safeProjectPath } from "./utils.js";
|
|
7
7
|
import { readProjectConfig } from "./project-config.js";
|
|
8
|
+
import { getActiveProfileDefaults } from "./profile-store.js";
|
|
8
9
|
import { runCustomHooks } from "./hooks.js";
|
|
9
10
|
import { METADATA_REGEX, isCitationLine, isArchiveStart as isArchiveStartMeta, isArchiveEnd as isArchiveEndMeta, stripLifecycleMetadata as stripLifecycleMetadataMeta, } from "./content-metadata.js";
|
|
10
11
|
export const MAX_QUEUE_ENTRY_LENGTH = 500;
|
|
@@ -39,7 +40,7 @@ const DEFAULT_RUNTIME_HEALTH = {
|
|
|
39
40
|
schemaVersion: GOVERNANCE_SCHEMA_VERSION,
|
|
40
41
|
};
|
|
41
42
|
function governanceDir(phrenPath) {
|
|
42
|
-
return path.join(phrenPath, ".
|
|
43
|
+
return path.join(phrenPath, ".config");
|
|
43
44
|
}
|
|
44
45
|
function govFile(phrenPath, schema) {
|
|
45
46
|
return path.join(governanceDir(phrenPath), GOVERNANCE_REGISTRY[schema].file);
|
|
@@ -283,66 +284,109 @@ function readProjectConfigOverrides(phrenPath, projectName) {
|
|
|
283
284
|
export function getProjectConfigOverrides(phrenPath, projectName) {
|
|
284
285
|
return readProjectConfigOverrides(phrenPath, projectName) ?? null;
|
|
285
286
|
}
|
|
286
|
-
export function mergeConfig(phrenPath, projectName) {
|
|
287
|
+
export function mergeConfig(phrenPath, projectName, profile) {
|
|
287
288
|
const globalRetention = getRetentionPolicyGlobal(phrenPath);
|
|
288
289
|
const globalWorkflow = getWorkflowPolicyGlobal(phrenPath);
|
|
289
290
|
if (projectName && !isValidProjectName(projectName)) {
|
|
290
291
|
debugLog(`mergeConfig: invalid project name "${projectName}", using global defaults`);
|
|
291
292
|
projectName = undefined;
|
|
292
293
|
}
|
|
294
|
+
// Load profile-level defaults (global → profile → project resolution chain)
|
|
295
|
+
let profileDefaults;
|
|
296
|
+
try {
|
|
297
|
+
profileDefaults = getActiveProfileDefaults(phrenPath, profile);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// profile defaults are best-effort
|
|
301
|
+
}
|
|
302
|
+
// Apply profile defaults on top of global to get the profile-level base
|
|
303
|
+
const profileRetention = profileDefaults?.retentionPolicy
|
|
304
|
+
? {
|
|
305
|
+
schemaVersion: globalRetention.schemaVersion,
|
|
306
|
+
ttlDays: profileDefaults.retentionPolicy.ttlDays ?? globalRetention.ttlDays,
|
|
307
|
+
retentionDays: profileDefaults.retentionPolicy.retentionDays ?? globalRetention.retentionDays,
|
|
308
|
+
autoAcceptThreshold: profileDefaults.retentionPolicy.autoAcceptThreshold ?? globalRetention.autoAcceptThreshold,
|
|
309
|
+
minInjectConfidence: profileDefaults.retentionPolicy.minInjectConfidence ?? globalRetention.minInjectConfidence,
|
|
310
|
+
decay: {
|
|
311
|
+
d30: profileDefaults.retentionPolicy.decay?.d30 ?? globalRetention.decay.d30,
|
|
312
|
+
d60: profileDefaults.retentionPolicy.decay?.d60 ?? globalRetention.decay.d60,
|
|
313
|
+
d90: profileDefaults.retentionPolicy.decay?.d90 ?? globalRetention.decay.d90,
|
|
314
|
+
d120: profileDefaults.retentionPolicy.decay?.d120 ?? globalRetention.decay.d120,
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
: globalRetention;
|
|
318
|
+
const profileWorkflow = profileDefaults
|
|
319
|
+
? {
|
|
320
|
+
schemaVersion: globalWorkflow.schemaVersion,
|
|
321
|
+
lowConfidenceThreshold: profileDefaults.workflowPolicy?.lowConfidenceThreshold ?? globalWorkflow.lowConfidenceThreshold,
|
|
322
|
+
riskySections: profileDefaults.workflowPolicy?.riskySections?.length
|
|
323
|
+
? profileDefaults.workflowPolicy.riskySections
|
|
324
|
+
: globalWorkflow.riskySections,
|
|
325
|
+
taskMode: profileDefaults.taskMode ?? globalWorkflow.taskMode,
|
|
326
|
+
findingSensitivity: profileDefaults.findingSensitivity ?? globalWorkflow.findingSensitivity,
|
|
327
|
+
}
|
|
328
|
+
: globalWorkflow;
|
|
293
329
|
if (!projectName) {
|
|
294
330
|
return {
|
|
295
|
-
findingSensitivity:
|
|
296
|
-
proactivity: {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
331
|
+
findingSensitivity: profileWorkflow.findingSensitivity,
|
|
332
|
+
proactivity: {
|
|
333
|
+
base: profileDefaults?.proactivity,
|
|
334
|
+
findings: profileDefaults?.proactivityFindings,
|
|
335
|
+
tasks: profileDefaults?.proactivityTask,
|
|
336
|
+
},
|
|
337
|
+
taskMode: profileWorkflow.taskMode,
|
|
338
|
+
retentionPolicy: profileRetention,
|
|
339
|
+
workflowPolicy: profileWorkflow,
|
|
300
340
|
};
|
|
301
341
|
}
|
|
302
342
|
const overrides = readProjectConfigOverrides(phrenPath, projectName);
|
|
303
343
|
if (!overrides) {
|
|
304
344
|
return {
|
|
305
|
-
findingSensitivity:
|
|
306
|
-
proactivity: {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
345
|
+
findingSensitivity: profileWorkflow.findingSensitivity,
|
|
346
|
+
proactivity: {
|
|
347
|
+
base: profileDefaults?.proactivity,
|
|
348
|
+
findings: profileDefaults?.proactivityFindings,
|
|
349
|
+
tasks: profileDefaults?.proactivityTask,
|
|
350
|
+
},
|
|
351
|
+
taskMode: profileWorkflow.taskMode,
|
|
352
|
+
retentionPolicy: profileRetention,
|
|
353
|
+
workflowPolicy: profileWorkflow,
|
|
310
354
|
};
|
|
311
355
|
}
|
|
312
|
-
// Merge retention policy
|
|
356
|
+
// Merge retention policy: profile as base, project overrides on top
|
|
313
357
|
const retentionOverride = overrides.retentionPolicy;
|
|
314
358
|
const mergedRetention = retentionOverride
|
|
315
359
|
? {
|
|
316
|
-
schemaVersion:
|
|
317
|
-
ttlDays: retentionOverride.ttlDays ??
|
|
318
|
-
retentionDays: retentionOverride.retentionDays ??
|
|
319
|
-
autoAcceptThreshold: retentionOverride.autoAcceptThreshold ??
|
|
320
|
-
minInjectConfidence: retentionOverride.minInjectConfidence ??
|
|
360
|
+
schemaVersion: profileRetention.schemaVersion,
|
|
361
|
+
ttlDays: retentionOverride.ttlDays ?? profileRetention.ttlDays,
|
|
362
|
+
retentionDays: retentionOverride.retentionDays ?? profileRetention.retentionDays,
|
|
363
|
+
autoAcceptThreshold: retentionOverride.autoAcceptThreshold ?? profileRetention.autoAcceptThreshold,
|
|
364
|
+
minInjectConfidence: retentionOverride.minInjectConfidence ?? profileRetention.minInjectConfidence,
|
|
321
365
|
decay: {
|
|
322
|
-
d30: retentionOverride.decay?.d30 ??
|
|
323
|
-
d60: retentionOverride.decay?.d60 ??
|
|
324
|
-
d90: retentionOverride.decay?.d90 ??
|
|
325
|
-
d120: retentionOverride.decay?.d120 ??
|
|
366
|
+
d30: retentionOverride.decay?.d30 ?? profileRetention.decay.d30,
|
|
367
|
+
d60: retentionOverride.decay?.d60 ?? profileRetention.decay.d60,
|
|
368
|
+
d90: retentionOverride.decay?.d90 ?? profileRetention.decay.d90,
|
|
369
|
+
d120: retentionOverride.decay?.d120 ?? profileRetention.decay.d120,
|
|
326
370
|
},
|
|
327
371
|
}
|
|
328
|
-
:
|
|
329
|
-
// Merge workflow policy
|
|
372
|
+
: profileRetention;
|
|
373
|
+
// Merge workflow policy: profile as base, project overrides on top
|
|
330
374
|
const workflowOverride = overrides.workflowPolicy;
|
|
331
375
|
const mergedWorkflow = {
|
|
332
|
-
schemaVersion:
|
|
333
|
-
lowConfidenceThreshold: workflowOverride?.lowConfidenceThreshold ??
|
|
376
|
+
schemaVersion: profileWorkflow.schemaVersion,
|
|
377
|
+
lowConfidenceThreshold: workflowOverride?.lowConfidenceThreshold ?? profileWorkflow.lowConfidenceThreshold,
|
|
334
378
|
riskySections: workflowOverride?.riskySections?.length
|
|
335
379
|
? workflowOverride.riskySections
|
|
336
|
-
:
|
|
337
|
-
taskMode: overrides.taskMode ??
|
|
338
|
-
findingSensitivity: overrides.findingSensitivity ??
|
|
380
|
+
: profileWorkflow.riskySections,
|
|
381
|
+
taskMode: overrides.taskMode ?? profileWorkflow.taskMode,
|
|
382
|
+
findingSensitivity: overrides.findingSensitivity ?? profileWorkflow.findingSensitivity,
|
|
339
383
|
};
|
|
340
384
|
return {
|
|
341
385
|
findingSensitivity: mergedWorkflow.findingSensitivity,
|
|
342
386
|
proactivity: {
|
|
343
|
-
base: overrides.proactivity,
|
|
344
|
-
findings: overrides.proactivityFindings,
|
|
345
|
-
tasks: overrides.proactivityTask,
|
|
387
|
+
base: overrides.proactivity ?? profileDefaults?.proactivity,
|
|
388
|
+
findings: overrides.proactivityFindings ?? profileDefaults?.proactivityFindings,
|
|
389
|
+
tasks: overrides.proactivityTask ?? profileDefaults?.proactivityTask,
|
|
346
390
|
},
|
|
347
391
|
taskMode: mergedWorkflow.taskMode,
|
|
348
392
|
retentionPolicy: mergedRetention,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RBAC enforcement for mutating MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Access-control.json lives at `<phrenPath>/.config/access-control.json` (global).
|
|
5
|
+
* Per-project overrides live in `phren.project.yaml` under the `access` key.
|
|
6
|
+
*
|
|
7
|
+
* Schema:
|
|
8
|
+
* { admins: string[], contributors: string[], readers: string[] }
|
|
9
|
+
*
|
|
10
|
+
* Role hierarchy:
|
|
11
|
+
* admins → all actions
|
|
12
|
+
* contributors → add/edit/remove findings, complete/add/remove tasks
|
|
13
|
+
* readers → read-only (search, get)
|
|
14
|
+
*
|
|
15
|
+
* When no access-control.json exists, all actors are permitted (open mode).
|
|
16
|
+
*
|
|
17
|
+
* The actor is read from the PHREN_ACTOR env var (falls back to open if unset).
|
|
18
|
+
*/
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import { debugLog } from "./shared.js";
|
|
22
|
+
import { errorMessage } from "./utils.js";
|
|
23
|
+
import { readProjectConfig } from "./project-config.js";
|
|
24
|
+
function configDir(phrenPath) {
|
|
25
|
+
return path.join(phrenPath, ".config");
|
|
26
|
+
}
|
|
27
|
+
function readGlobalAccessControl(phrenPath) {
|
|
28
|
+
const filePath = path.join(configDir(phrenPath), "access-control.json");
|
|
29
|
+
if (!fs.existsSync(filePath))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
35
|
+
return null;
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
debugLog(`readGlobalAccessControl: failed to parse ${filePath}: ${errorMessage(err)}`);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function normalizeStringArray(value) {
|
|
44
|
+
if (!Array.isArray(value))
|
|
45
|
+
return [];
|
|
46
|
+
return value.filter((item) => typeof item === "string" && item.trim().length > 0);
|
|
47
|
+
}
|
|
48
|
+
function mergeAccessControl(global, projectAccess) {
|
|
49
|
+
// If neither global nor project access is configured, open mode
|
|
50
|
+
if (!global && !projectAccess)
|
|
51
|
+
return null;
|
|
52
|
+
const base = global ?? {};
|
|
53
|
+
if (!projectAccess)
|
|
54
|
+
return base;
|
|
55
|
+
// Project-level access adds/replaces role lists; global is the baseline
|
|
56
|
+
return {
|
|
57
|
+
admins: [...new Set([...normalizeStringArray(base.admins), ...normalizeStringArray(projectAccess.admins)])],
|
|
58
|
+
contributors: [...new Set([...normalizeStringArray(base.contributors), ...normalizeStringArray(projectAccess.contributors)])],
|
|
59
|
+
readers: [...new Set([...normalizeStringArray(base.readers), ...normalizeStringArray(projectAccess.readers)])],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function resolvedActorRole(actor, ac) {
|
|
63
|
+
const admins = normalizeStringArray(ac.admins);
|
|
64
|
+
const contributors = normalizeStringArray(ac.contributors);
|
|
65
|
+
const readers = normalizeStringArray(ac.readers);
|
|
66
|
+
// If all role lists are empty, treat as open
|
|
67
|
+
if (!admins.length && !contributors.length && !readers.length)
|
|
68
|
+
return "admin";
|
|
69
|
+
if (admins.includes(actor))
|
|
70
|
+
return "admin";
|
|
71
|
+
if (contributors.includes(actor))
|
|
72
|
+
return "contributor";
|
|
73
|
+
if (readers.includes(actor))
|
|
74
|
+
return "reader";
|
|
75
|
+
return "denied";
|
|
76
|
+
}
|
|
77
|
+
const CONTRIBUTOR_ACTIONS = new Set([
|
|
78
|
+
"add_finding",
|
|
79
|
+
"remove_finding",
|
|
80
|
+
"edit_finding",
|
|
81
|
+
"complete_task",
|
|
82
|
+
"add_task",
|
|
83
|
+
"remove_task",
|
|
84
|
+
"update_task",
|
|
85
|
+
]);
|
|
86
|
+
const ADMIN_ONLY_ACTIONS = new Set([
|
|
87
|
+
"manage_config",
|
|
88
|
+
]);
|
|
89
|
+
function rolePermits(role, action) {
|
|
90
|
+
if (role === "denied")
|
|
91
|
+
return false;
|
|
92
|
+
if (role === "admin")
|
|
93
|
+
return true;
|
|
94
|
+
if (ADMIN_ONLY_ACTIONS.has(action))
|
|
95
|
+
return false;
|
|
96
|
+
if (role === "contributor")
|
|
97
|
+
return CONTRIBUTOR_ACTIONS.has(action);
|
|
98
|
+
// readers: no mutating actions
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Check whether the current actor (from PHREN_ACTOR env var) is permitted to
|
|
103
|
+
* perform `action`. When `project` is provided, merges global + per-project ACL.
|
|
104
|
+
*
|
|
105
|
+
* Returns `{ allowed: true }` when permitted, `{ allowed: false, reason }` when denied.
|
|
106
|
+
*/
|
|
107
|
+
export function checkPermission(phrenPath, action, project) {
|
|
108
|
+
const actor = (process.env.PHREN_ACTOR ?? "").trim() || null;
|
|
109
|
+
const globalAc = readGlobalAccessControl(phrenPath);
|
|
110
|
+
const projectAccess = project
|
|
111
|
+
? readProjectConfig(phrenPath, project).access
|
|
112
|
+
: undefined;
|
|
113
|
+
const effectiveAc = mergeAccessControl(globalAc, projectAccess);
|
|
114
|
+
// Open mode: no access control configured at any level
|
|
115
|
+
if (!effectiveAc) {
|
|
116
|
+
return { allowed: true, actor, role: "open" };
|
|
117
|
+
}
|
|
118
|
+
// No actor env var set — check if we're in open mode still
|
|
119
|
+
if (!actor) {
|
|
120
|
+
// If all lists are empty, open
|
|
121
|
+
const allEmpty = !normalizeStringArray(effectiveAc.admins).length &&
|
|
122
|
+
!normalizeStringArray(effectiveAc.contributors).length &&
|
|
123
|
+
!normalizeStringArray(effectiveAc.readers).length;
|
|
124
|
+
if (allEmpty)
|
|
125
|
+
return { allowed: true, actor, role: "open" };
|
|
126
|
+
return {
|
|
127
|
+
allowed: false,
|
|
128
|
+
actor,
|
|
129
|
+
role: "denied",
|
|
130
|
+
reason: "PHREN_ACTOR is not set and access control is configured. Set PHREN_ACTOR to your username.",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const role = resolvedActorRole(actor, effectiveAc);
|
|
134
|
+
const allowed = rolePermits(role, action);
|
|
135
|
+
if (!allowed) {
|
|
136
|
+
const reason = role === "denied"
|
|
137
|
+
? `Actor "${actor}" is not listed in access-control for ${project ? `project "${project}"` : "global config"}.`
|
|
138
|
+
: `Actor "${actor}" has role "${role}" which does not permit "${action}".`;
|
|
139
|
+
return { allowed: false, actor, role, reason };
|
|
140
|
+
}
|
|
141
|
+
return { allowed: true, actor, role };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Convenience wrapper: returns a permission-denied MCP error string,
|
|
145
|
+
* or null if the action is allowed.
|
|
146
|
+
*/
|
|
147
|
+
export function permissionDeniedError(phrenPath, action, project) {
|
|
148
|
+
const result = checkPermission(phrenPath, action, project);
|
|
149
|
+
if (result.allowed)
|
|
150
|
+
return null;
|
|
151
|
+
return `Permission denied: ${result.reason ?? `actor "${result.actor}" cannot perform "${action}".`}`;
|
|
152
|
+
}
|
|
@@ -11,7 +11,7 @@ function preferencesFile(phrenPath) {
|
|
|
11
11
|
return installPreferencesFile(phrenPath);
|
|
12
12
|
}
|
|
13
13
|
export function governanceInstallPreferencesFile(phrenPath) {
|
|
14
|
-
return path.join(phrenPath, ".
|
|
14
|
+
return path.join(phrenPath, ".config", "install-preferences.json");
|
|
15
15
|
}
|
|
16
16
|
function readPreferencesFile(file) {
|
|
17
17
|
if (!fs.existsSync(file))
|
package/mcp/dist/init-setup.js
CHANGED
|
@@ -399,9 +399,9 @@ export function applyStarterTemplateUpdates(phrenPath) {
|
|
|
399
399
|
}
|
|
400
400
|
export function ensureGovernanceFiles(phrenPath) {
|
|
401
401
|
const created = [];
|
|
402
|
-
const govDir = path.join(phrenPath, ".
|
|
402
|
+
const govDir = path.join(phrenPath, ".config");
|
|
403
403
|
if (!fs.existsSync(govDir))
|
|
404
|
-
created.push(".
|
|
404
|
+
created.push(".config/");
|
|
405
405
|
fs.mkdirSync(govDir, { recursive: true });
|
|
406
406
|
const sv = GOVERNANCE_SCHEMA_VERSION;
|
|
407
407
|
const policy = path.join(govDir, "retention-policy.json");
|
|
@@ -417,7 +417,7 @@ export function ensureGovernanceFiles(phrenPath) {
|
|
|
417
417
|
minInjectConfidence: 0.35,
|
|
418
418
|
decay: { d30: 1.0, d60: 0.85, d90: 0.65, d120: 0.45 },
|
|
419
419
|
}, null, 2) + "\n");
|
|
420
|
-
created.push(".
|
|
420
|
+
created.push(".config/retention-policy.json");
|
|
421
421
|
}
|
|
422
422
|
if (!fs.existsSync(workflow)) {
|
|
423
423
|
atomicWriteText(workflow, JSON.stringify({
|
|
@@ -426,7 +426,7 @@ export function ensureGovernanceFiles(phrenPath) {
|
|
|
426
426
|
riskySections: ["Stale", "Conflicts"],
|
|
427
427
|
taskMode: "auto",
|
|
428
428
|
}, null, 2) + "\n");
|
|
429
|
-
created.push(".
|
|
429
|
+
created.push(".config/workflow-policy.json");
|
|
430
430
|
}
|
|
431
431
|
if (!fs.existsSync(indexPolicy)) {
|
|
432
432
|
atomicWriteText(indexPolicy, JSON.stringify({
|
|
@@ -435,7 +435,7 @@ export function ensureGovernanceFiles(phrenPath) {
|
|
|
435
435
|
excludeGlobs: ["**/.git/**", "**/node_modules/**", "**/dist/**", "**/build/**"],
|
|
436
436
|
includeHidden: false,
|
|
437
437
|
}, null, 2) + "\n");
|
|
438
|
-
created.push(".
|
|
438
|
+
created.push(".config/index-policy.json");
|
|
439
439
|
}
|
|
440
440
|
if (!fs.existsSync(runtimeHealth)) {
|
|
441
441
|
atomicWriteText(runtimeHealth, JSON.stringify({ schemaVersion: sv }, null, 2) + "\n");
|
|
@@ -1215,12 +1215,12 @@ export function runPostInitVerify(phrenPath) {
|
|
|
1215
1215
|
detail: globalOk ? "global/CLAUDE.md exists" : "global/CLAUDE.md missing",
|
|
1216
1216
|
fix: globalOk ? undefined : "Run `phren init` to create starter files",
|
|
1217
1217
|
});
|
|
1218
|
-
const govDir = path.join(phrenPath, ".
|
|
1218
|
+
const govDir = path.join(phrenPath, ".config");
|
|
1219
1219
|
const govOk = fs.existsSync(govDir);
|
|
1220
1220
|
checks.push({
|
|
1221
1221
|
name: "config",
|
|
1222
1222
|
ok: govOk,
|
|
1223
|
-
detail: govOk ? ".
|
|
1223
|
+
detail: govOk ? ".config/ config directory exists" : ".config/ config directory missing",
|
|
1224
1224
|
fix: govOk ? undefined : "Run `phren init` to create governance config",
|
|
1225
1225
|
});
|
|
1226
1226
|
const installedPrefs = readInstallPreferences(phrenPath);
|
package/mcp/dist/init.js
CHANGED
|
@@ -105,7 +105,7 @@ function parseRiskySectionsAnswer(raw, fallback) {
|
|
|
105
105
|
}
|
|
106
106
|
function hasInstallMarkers(phrenPath) {
|
|
107
107
|
return fs.existsSync(phrenPath) && (fs.existsSync(path.join(phrenPath, "machines.yaml")) ||
|
|
108
|
-
fs.existsSync(path.join(phrenPath, ".
|
|
108
|
+
fs.existsSync(path.join(phrenPath, ".config")) ||
|
|
109
109
|
fs.existsSync(path.join(phrenPath, "global")));
|
|
110
110
|
}
|
|
111
111
|
function resolveInitPhrenPath(opts) {
|
|
@@ -1374,7 +1374,7 @@ export async function runInit(opts = {}) {
|
|
|
1374
1374
|
log(` ${phrenPath}/global/CLAUDE.md Global instructions loaded in every session`);
|
|
1375
1375
|
log(` ${phrenPath}/global/skills/ Phren slash commands`);
|
|
1376
1376
|
log(` ${phrenPath}/profiles/ Machine-to-project mappings`);
|
|
1377
|
-
log(` ${phrenPath}/.
|
|
1377
|
+
log(` ${phrenPath}/.config/ Memory quality settings and config`);
|
|
1378
1378
|
// Ollama status summary (skip if already covered in walkthrough)
|
|
1379
1379
|
const walkthroughCoveredOllama = Boolean(process.env._PHREN_WALKTHROUGH_OLLAMA_SKIP) || (!hasExistingInstall && !opts.yes);
|
|
1380
1380
|
if (!walkthroughCoveredOllama) {
|
|
@@ -9,7 +9,7 @@ function fileChecksum(filePath) {
|
|
|
9
9
|
return crypto.createHash("sha256").update(content).digest("hex");
|
|
10
10
|
}
|
|
11
11
|
function checksumStorePath(phrenPath) {
|
|
12
|
-
return path.join(phrenPath, ".
|
|
12
|
+
return path.join(phrenPath, ".config", "file-checksums.json");
|
|
13
13
|
}
|
|
14
14
|
function loadChecksums(phrenPath) {
|
|
15
15
|
const file = checksumStorePath(phrenPath);
|
package/mcp/dist/link-doctor.js
CHANGED
|
@@ -433,7 +433,7 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
|
|
|
433
433
|
{ file: "index-policy.json", schema: "index-policy" },
|
|
434
434
|
];
|
|
435
435
|
for (const item of governanceChecks) {
|
|
436
|
-
const filePath = path.join(phrenPath, ".
|
|
436
|
+
const filePath = path.join(phrenPath, ".config", item.file);
|
|
437
437
|
const exists = fs.existsSync(filePath);
|
|
438
438
|
const valid = exists ? validateGovernanceJson(filePath, item.schema) : false;
|
|
439
439
|
checks.push({
|
package/mcp/dist/mcp-config.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
1
3
|
import { mcpResponse } from "./mcp-types.js";
|
|
2
4
|
import { z } from "zod";
|
|
3
5
|
import { getRetentionPolicy, updateRetentionPolicy, getWorkflowPolicy, updateWorkflowPolicy, getIndexPolicy, updateIndexPolicy, mergeConfig, VALID_TASK_MODES, VALID_FINDING_SENSITIVITY, } from "./shared-governance.js";
|
|
@@ -5,7 +7,8 @@ import { PROACTIVITY_LEVELS, } from "./proactivity.js";
|
|
|
5
7
|
import { writeGovernanceInstallPreferences, } from "./init-preferences.js";
|
|
6
8
|
import { FINDING_SENSITIVITY_CONFIG, buildProactivitySnapshot, checkProjectInProfile } from "./cli-config.js";
|
|
7
9
|
import { readProjectConfig, updateProjectConfigOverrides, } from "./project-config.js";
|
|
8
|
-
import { isValidProjectName } from "./utils.js";
|
|
10
|
+
import { isValidProjectName, safeProjectPath } from "./utils.js";
|
|
11
|
+
import { readProjectTopics, writeProjectTopics, } from "./project-topics.js";
|
|
9
12
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
10
13
|
function proactivitySnapshot(phrenPath) {
|
|
11
14
|
const snap = buildProactivitySnapshot(phrenPath);
|
|
@@ -32,7 +35,7 @@ function getProjectOverrides(phrenPath, project) {
|
|
|
32
35
|
function hasOwnOverride(overrides, key) {
|
|
33
36
|
return Object.prototype.hasOwnProperty.call(overrides, key);
|
|
34
37
|
}
|
|
35
|
-
const projectParam = z.string().optional().describe("Project name. When provided, writes to that project's phren.project.yaml instead of global .
|
|
38
|
+
const projectParam = z.string().optional().describe("Project name. When provided, writes to that project's phren.project.yaml instead of global .config/.");
|
|
36
39
|
// ── Registration ────────────────────────────────────────────────────────────
|
|
37
40
|
export function register(server, ctx) {
|
|
38
41
|
const { phrenPath } = ctx;
|
|
@@ -448,4 +451,101 @@ export function register(server, ctx) {
|
|
|
448
451
|
data: result.data,
|
|
449
452
|
});
|
|
450
453
|
});
|
|
454
|
+
// ── get_topic_config ──────────────────────────────────────────────────────
|
|
455
|
+
server.registerTool("get_topic_config", {
|
|
456
|
+
title: "◆ phren · get topic config",
|
|
457
|
+
description: "Read the topic-config.json for a project. Returns the list of topics, domain, " +
|
|
458
|
+
"and pinned topics. When no config exists, returns the built-in default topics for the project.",
|
|
459
|
+
inputSchema: z.object({
|
|
460
|
+
project: z.string().describe("Project name to read topic config from."),
|
|
461
|
+
}),
|
|
462
|
+
}, async ({ project }) => {
|
|
463
|
+
const err = validateProject(project);
|
|
464
|
+
if (err)
|
|
465
|
+
return mcpResponse({ ok: false, error: err });
|
|
466
|
+
const projectDir = safeProjectPath(phrenPath, project);
|
|
467
|
+
if (!projectDir || !fs.existsSync(projectDir)) {
|
|
468
|
+
return mcpResponse({ ok: false, error: `Project "${project}" not found in phren.` });
|
|
469
|
+
}
|
|
470
|
+
const result = readProjectTopics(phrenPath, project);
|
|
471
|
+
const configPath = path.join(projectDir, "topic-config.json");
|
|
472
|
+
const raw = fs.existsSync(configPath)
|
|
473
|
+
? (() => { try {
|
|
474
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return null;
|
|
478
|
+
} })()
|
|
479
|
+
: null;
|
|
480
|
+
return mcpResponse({
|
|
481
|
+
ok: true,
|
|
482
|
+
message: `Topic config for "${project}" (source: ${result.source}).`,
|
|
483
|
+
data: {
|
|
484
|
+
project,
|
|
485
|
+
source: result.source,
|
|
486
|
+
domain: result.domain ?? raw?.domain ?? null,
|
|
487
|
+
topics: result.topics,
|
|
488
|
+
pinnedTopics: raw?.pinnedTopics ?? [],
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
// ── set_topic_config ──────────────────────────────────────────────────────
|
|
493
|
+
server.registerTool("set_topic_config", {
|
|
494
|
+
title: "◆ phren · set topic config",
|
|
495
|
+
description: "Write the topic-config.json for a project. Accepts a list of topics with slug, label, " +
|
|
496
|
+
"description, and keywords. Merges with any existing pinnedTopics and domain.",
|
|
497
|
+
inputSchema: z.object({
|
|
498
|
+
project: z.string().describe("Project name to write topic config for."),
|
|
499
|
+
topics: z.array(z.object({
|
|
500
|
+
slug: z.string().describe("Topic slug (lowercase, hyphens allowed)."),
|
|
501
|
+
label: z.string().describe("Human-readable label."),
|
|
502
|
+
description: z.string().optional().describe("Short description of what goes in this topic."),
|
|
503
|
+
keywords: z.array(z.string()).optional().describe("Keywords used for auto-classification."),
|
|
504
|
+
})).describe("Topic list to write."),
|
|
505
|
+
domain: z.string().optional().describe("Optional domain label (e.g. 'software', 'music')."),
|
|
506
|
+
}),
|
|
507
|
+
}, async ({ project, topics, domain }) => {
|
|
508
|
+
const err = validateProject(project);
|
|
509
|
+
if (err)
|
|
510
|
+
return mcpResponse({ ok: false, error: err });
|
|
511
|
+
const projectDir = safeProjectPath(phrenPath, project);
|
|
512
|
+
if (!projectDir || !fs.existsSync(projectDir)) {
|
|
513
|
+
return mcpResponse({ ok: false, error: `Project "${project}" not found in phren.` });
|
|
514
|
+
}
|
|
515
|
+
const normalized = topics.map((t) => ({
|
|
516
|
+
slug: t.slug,
|
|
517
|
+
label: t.label,
|
|
518
|
+
description: t.description ?? "",
|
|
519
|
+
keywords: t.keywords ?? [],
|
|
520
|
+
}));
|
|
521
|
+
// If a domain is provided, patch it onto the existing file before writing topics
|
|
522
|
+
if (domain) {
|
|
523
|
+
const configPath = path.join(projectDir, "topic-config.json");
|
|
524
|
+
if (fs.existsSync(configPath)) {
|
|
525
|
+
try {
|
|
526
|
+
const existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
527
|
+
if (existing && typeof existing === "object") {
|
|
528
|
+
existing.domain = domain;
|
|
529
|
+
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
// ignore read errors; writeProjectTopics will still succeed
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
538
|
+
fs.writeFileSync(configPath, JSON.stringify({ version: 1, domain, topics: [] }, null, 2) + "\n");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const result = writeProjectTopics(phrenPath, project, normalized);
|
|
542
|
+
if (!result.ok) {
|
|
543
|
+
return mcpResponse({ ok: false, error: result.error });
|
|
544
|
+
}
|
|
545
|
+
return mcpResponse({
|
|
546
|
+
ok: true,
|
|
547
|
+
message: `Topic config written for "${project}" (${result.topics.length} topics).`,
|
|
548
|
+
data: { project, topics: result.topics, domain: domain ?? null },
|
|
549
|
+
});
|
|
550
|
+
});
|
|
451
551
|
}
|