@phren/cli 0.0.16 → 0.0.18

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.
@@ -1,6 +1,6 @@
1
1
  export const cliManifest = {
2
2
  surface: "cli",
3
- version: "0.0.16",
3
+ version: "0.0.18",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: true, handler: "cli-actions.ts:handleAddFinding" },
@@ -1,6 +1,6 @@
1
1
  export const mcpManifest = {
2
2
  surface: "mcp",
3
- version: "0.0.16",
3
+ version: "0.0.18",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: true, handler: "index.ts:add_finding" },
@@ -1,6 +1,6 @@
1
1
  export const vscodeManifest = {
2
2
  surface: "vscode",
3
- version: "0.0.16",
3
+ version: "0.0.18",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: true, handler: "extension.ts:phren.addFinding" },
@@ -1,6 +1,6 @@
1
1
  export const webUiManifest = {
2
2
  surface: "web-ui",
3
- version: "0.0.16",
3
+ version: "0.0.18",
4
4
  actions: {
5
5
  // Finding management
6
6
  "finding.add": { implemented: false, reason: "Web UI is read-only for findings (review queue only)" },
@@ -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, ".governance");
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, ".governance");
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 .governance, .runtime, etc.
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", ".governance"]);
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]
@@ -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, ".governance", "task-archive", `${project}.md`);
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);
@@ -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, ".governance"))) {
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, ".governance");
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: globalWorkflow.findingSensitivity,
296
- proactivity: {},
297
- taskMode: globalWorkflow.taskMode,
298
- retentionPolicy: globalRetention,
299
- workflowPolicy: globalWorkflow,
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: globalWorkflow.findingSensitivity,
306
- proactivity: {},
307
- taskMode: globalWorkflow.taskMode,
308
- retentionPolicy: globalRetention,
309
- workflowPolicy: globalWorkflow,
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: globalRetention.schemaVersion,
317
- ttlDays: retentionOverride.ttlDays ?? globalRetention.ttlDays,
318
- retentionDays: retentionOverride.retentionDays ?? globalRetention.retentionDays,
319
- autoAcceptThreshold: retentionOverride.autoAcceptThreshold ?? globalRetention.autoAcceptThreshold,
320
- minInjectConfidence: retentionOverride.minInjectConfidence ?? globalRetention.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 ?? globalRetention.decay.d30,
323
- d60: retentionOverride.decay?.d60 ?? globalRetention.decay.d60,
324
- d90: retentionOverride.decay?.d90 ?? globalRetention.decay.d90,
325
- d120: retentionOverride.decay?.d120 ?? globalRetention.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
- : globalRetention;
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: globalWorkflow.schemaVersion,
333
- lowConfidenceThreshold: workflowOverride?.lowConfidenceThreshold ?? globalWorkflow.lowConfidenceThreshold,
376
+ schemaVersion: profileWorkflow.schemaVersion,
377
+ lowConfidenceThreshold: workflowOverride?.lowConfidenceThreshold ?? profileWorkflow.lowConfidenceThreshold,
334
378
  riskySections: workflowOverride?.riskySections?.length
335
379
  ? workflowOverride.riskySections
336
- : globalWorkflow.riskySections,
337
- taskMode: overrides.taskMode ?? globalWorkflow.taskMode,
338
- findingSensitivity: overrides.findingSensitivity ?? globalWorkflow.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, ".governance", "install-preferences.json");
14
+ return path.join(phrenPath, ".config", "install-preferences.json");
15
15
  }
16
16
  function readPreferencesFile(file) {
17
17
  if (!fs.existsSync(file))
@@ -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, ".governance");
402
+ const govDir = path.join(phrenPath, ".config");
403
403
  if (!fs.existsSync(govDir))
404
- created.push(".governance/");
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(".governance/retention-policy.json");
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(".governance/workflow-policy.json");
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(".governance/index-policy.json");
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, ".governance");
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 ? ".governance/ config directory exists" : ".governance/ config directory missing",
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, ".governance")) ||
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}/.governance/ Memory quality settings and config`);
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, ".governance", "file-checksums.json");
12
+ return path.join(phrenPath, ".config", "file-checksums.json");
13
13
  }
14
14
  function loadChecksums(phrenPath) {
15
15
  const file = checksumStorePath(phrenPath);
@@ -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, ".governance", item.file);
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({
@@ -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 .governance/.");
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
  }