@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
242 lines (241 loc) • 12.8 kB
JavaScript
/**
* Config resolver — turns the 3-level precedence chain (default → global/profile
* → project) into a uniform, per-field view that every surface renders the same.
*
* `buildConfigView` is consumed by the `get_config` MCP tool, `phren config show`,
* the Web UI settings tab, and the VS Code settings webview, so the "where did
* this value come from" answer is computed in exactly one place.
*/
import * as fs from "fs";
import * as path from "path";
import { getIndexPolicy } from "../governance/policy.js";
import { getProjectConfigOverrides } from "../governance/policy.js";
import { getActiveProfileDefaults } from "../profile-store.js";
import { readGovernanceInstallPreferences } from "../init/preferences.js";
import { readProjectConfig } from "../project-config.js";
import { isRecord } from "../shared.js";
import { allConfigFields, getConfigField } from "./schema.js";
/** Structural equality good enough for config values (scalars + flat arrays). */
function valuesEqual(a, b) {
if (a === b)
return true;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
return false;
}
/**
* Resolve one field from a precedence-ordered list of levels that actually set
* it. `levels` must be ordered highest-precedence first and must NOT include the
* default tier — the default is appended here.
*
* Redundant levels are collapsed: a level whose value equals the value of the
* level below it is a no-op (e.g. a global policy file that merely mirrors the
* defaults), so the source reflects the first level that genuinely changes the
* value — never a level that just restates what it would already be.
*/
export function resolveConfigField(key, def, levels) {
const chain = [...levels, { tier: "default", value: def }];
const value = chain[0].value;
// The source is the highest level whose value differs from the level below it.
let sourceIdx = chain.length - 1; // default, unless something overrides it
for (let i = 0; i < chain.length - 1; i++) {
if (!valuesEqual(chain[i].value, chain[i + 1].value)) {
sourceIdx = i;
break;
}
}
const winner = chain[sourceIdx];
const inherited = chain[sourceIdx + 1] ?? { tier: "default", value: def };
const source = winner.tier === "project" ? "project" : winner.tier === "default" ? "default" : "global";
return {
key,
value,
source,
inheritedValue: inherited.value,
...(winner.path ? { sourcePath: winner.path } : {}),
};
}
function readRawJson(file) {
try {
if (!fs.existsSync(file))
return {};
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
return isRecord(parsed) ? parsed : {};
}
catch {
return {};
}
}
/** A nested-path getter that returns `undefined` for any missing segment. */
function dig(obj, ...segments) {
let cur = obj;
for (const seg of segments) {
if (!isRecord(cur))
return undefined;
cur = cur[seg];
}
return cur;
}
/** Push a level only when the value is actually set (not undefined/null). */
function pushIfSet(levels, tier, value, path) {
if (value !== undefined && value !== null)
levels.push({ tier, value, path });
}
/**
* Build the full resolved config view for a scope. When `project` is given, the
* view reflects that project's merged config with override provenance; otherwise
* it reflects the global defaults.
*
* Note: the `topic` domain is project-specific and stored separately — it is not
* included here; use the `get_config` topic branch for that.
*/
export function buildConfigView(phrenPath, project) {
const configDir = path.join(phrenPath, ".config");
const retentionPath = path.join(configDir, "retention-policy.json");
const workflowPath = path.join(configDir, "workflow-policy.json");
const indexPath = path.join(configDir, "index-policy.json");
const accessPath = path.join(configDir, "access-control.json");
const proactivityPath = path.join(configDir, "install-preferences.json");
const rawRetention = readRawJson(retentionPath);
const rawWorkflow = readRawJson(workflowPath);
const rawAccess = readRawJson(accessPath);
const govPrefs = readGovernanceInstallPreferences(phrenPath);
const indexPolicy = getIndexPolicy(phrenPath);
let profileDefaults;
try {
profileDefaults = getActiveProfileDefaults(phrenPath);
}
catch {
profileDefaults = undefined;
}
const overrides = project ? getProjectConfigOverrides(phrenPath, project) : null;
const projectAccess = project ? readProjectConfig(phrenPath, project).access : undefined;
const projectConfigPath = project
? path.join(phrenPath, project, "phren.project.yaml")
: undefined;
const fields = {};
// ── proactivity ────────────────────────────────────────────────────────────
const proactivityKeyMap = {
"proactivity.base": "proactivity",
"proactivity.findings": "proactivityFindings",
"proactivity.tasks": "proactivityTask",
};
const baseLevels = [];
pushIfSet(baseLevels, "project", overrides?.proactivity, projectConfigPath);
pushIfSet(baseLevels, "profile", profileDefaults?.proactivity);
pushIfSet(baseLevels, "global", govPrefs.proactivity, proactivityPath);
const baseResolved = resolveConfigField("proactivity.base", "high", baseLevels);
fields["proactivity.base"] = baseResolved;
for (const key of ["proactivity.findings", "proactivity.tasks"]) {
const ovKey = proactivityKeyMap[key];
const levels = [];
pushIfSet(levels, "project", overrides?.[ovKey], projectConfigPath);
pushIfSet(levels, "profile", profileDefaults?.[ovKey]);
pushIfSet(levels, "global", govPrefs[ovKey], proactivityPath);
if (levels.length === 0) {
// No own override at any level: inherit the resolved base verbatim.
fields[key] = {
key,
value: baseResolved.value,
source: baseResolved.source,
inheritedValue: baseResolved.value,
...(baseResolved.sourcePath ? { sourcePath: baseResolved.sourcePath } : {}),
};
}
else {
// Resolve against the base value, so a findings/tasks override only counts
// as a source when it genuinely diverges from the inherited base level.
fields[key] = resolveConfigField(key, baseResolved.value, levels);
}
}
// ── findingSensitivity & taskMode (live in workflow-policy.json) ────────────
const fsLevels = [];
pushIfSet(fsLevels, "project", overrides?.findingSensitivity, projectConfigPath);
pushIfSet(fsLevels, "profile", profileDefaults?.findingSensitivity);
pushIfSet(fsLevels, "global", rawWorkflow.findingSensitivity, workflowPath);
fields.findingSensitivity = resolveConfigField("findingSensitivity", getConfigField("findingSensitivity")?.default, fsLevels);
const tmLevels = [];
pushIfSet(tmLevels, "project", overrides?.taskMode, projectConfigPath);
pushIfSet(tmLevels, "profile", profileDefaults?.taskMode);
pushIfSet(tmLevels, "global", rawWorkflow.taskMode, workflowPath);
fields.taskMode = resolveConfigField("taskMode", getConfigField("taskMode")?.default, tmLevels);
// ── retention ──────────────────────────────────────────────────────────────
const retentionScalars = [
["retention.ttlDays", "ttlDays"],
["retention.retentionDays", "retentionDays"],
["retention.autoAcceptThreshold", "autoAcceptThreshold"],
["retention.minInjectConfidence", "minInjectConfidence"],
];
for (const [fieldKey, prop] of retentionScalars) {
const levels = [];
pushIfSet(levels, "project", dig(overrides?.retentionPolicy, prop), projectConfigPath);
pushIfSet(levels, "profile", dig(profileDefaults?.retentionPolicy, prop));
pushIfSet(levels, "global", rawRetention[prop], retentionPath);
fields[fieldKey] = resolveConfigField(fieldKey, getConfigField(fieldKey)?.default, levels);
}
for (const milestone of ["d30", "d60", "d90", "d120"]) {
const fieldKey = `retention.decay.${milestone}`;
const levels = [];
pushIfSet(levels, "project", dig(overrides?.retentionPolicy, "decay", milestone), projectConfigPath);
pushIfSet(levels, "profile", dig(profileDefaults?.retentionPolicy, "decay", milestone));
pushIfSet(levels, "global", dig(rawRetention, "decay", milestone), retentionPath);
fields[fieldKey] = resolveConfigField(fieldKey, getConfigField(fieldKey)?.default, levels);
}
// ── workflow ───────────────────────────────────────────────────────────────
const lctLevels = [];
pushIfSet(lctLevels, "project", dig(overrides?.workflowPolicy, "lowConfidenceThreshold"), projectConfigPath);
pushIfSet(lctLevels, "profile", dig(profileDefaults?.workflowPolicy, "lowConfidenceThreshold"));
pushIfSet(lctLevels, "global", rawWorkflow.lowConfidenceThreshold, workflowPath);
fields["workflow.lowConfidenceThreshold"] = resolveConfigField("workflow.lowConfidenceThreshold", getConfigField("workflow.lowConfidenceThreshold")?.default, lctLevels);
const rsLevels = [];
const projectRisky = dig(overrides?.workflowPolicy, "riskySections");
pushIfSet(rsLevels, "project", Array.isArray(projectRisky) && projectRisky.length ? projectRisky : undefined, projectConfigPath);
const profileRisky = dig(profileDefaults?.workflowPolicy, "riskySections");
pushIfSet(rsLevels, "profile", Array.isArray(profileRisky) && profileRisky.length ? profileRisky : undefined);
const globalRisky = rawWorkflow.riskySections;
pushIfSet(rsLevels, "global", Array.isArray(globalRisky) && globalRisky.length ? globalRisky : undefined, workflowPath);
fields["workflow.riskySections"] = resolveConfigField("workflow.riskySections", getConfigField("workflow.riskySections")?.default, rsLevels);
// ── index (global-only) ────────────────────────────────────────────────────
const rawIndex = readRawJson(indexPath);
for (const [fieldKey, prop] of [
["index.includeGlobs", "includeGlobs"],
["index.excludeGlobs", "excludeGlobs"],
["index.includeHidden", "includeHidden"],
]) {
const levels = [];
pushIfSet(levels, "global", rawIndex[prop], indexPath);
const resolved = resolveConfigField(fieldKey, getConfigField(fieldKey)?.default, levels);
// Always report the normalized effective value for index.
fields[fieldKey] = { ...resolved, value: indexPolicy[prop] };
}
// ── access ─────────────────────────────────────────────────────────────────
for (const [fieldKey, role] of [
["access.admins", "admins"],
["access.contributors", "contributors"],
["access.readers", "readers"],
]) {
const globalList = Array.isArray(rawAccess[role]) ? rawAccess[role] : undefined;
const projectList = Array.isArray(dig(projectAccess, role))
? dig(projectAccess, role)
: undefined;
const levels = [];
pushIfSet(levels, "project", projectList && projectList.length ? projectList : undefined, projectConfigPath);
pushIfSet(levels, "global", globalList && globalList.length ? globalList : undefined, accessPath);
// Effective value is the union of every level that contributes.
const union = [...new Set([...(globalList ?? []), ...(projectList ?? [])])];
const resolved = resolveConfigField(fieldKey, [], levels);
fields[fieldKey] = { ...resolved, value: union };
}
return {
scope: project ? "project" : "global",
...(project ? { project } : {}),
fields,
};
}
/** Field keys belonging to a domain, in schema order. Excludes `topic`. */
export function fieldKeysForDomain(domain) {
return allConfigFields()
.filter((f) => f.domain === domain && f.domain !== "topic")
.map((f) => f.key);
}