@phren/cli 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +2 -8
  2. package/mcp/dist/cli-actions.js +5 -5
  3. package/mcp/dist/cli-config.js +334 -127
  4. package/mcp/dist/cli-govern.js +35 -63
  5. package/mcp/dist/cli-graph.js +3 -2
  6. package/mcp/dist/cli-hooks-globs.js +2 -1
  7. package/mcp/dist/cli-hooks-output.js +3 -3
  8. package/mcp/dist/cli-hooks.js +39 -32
  9. package/mcp/dist/cli-namespaces.js +15 -5
  10. package/mcp/dist/cli-search.js +2 -2
  11. package/mcp/dist/content-archive.js +2 -2
  12. package/mcp/dist/content-dedup.js +9 -9
  13. package/mcp/dist/embedding.js +7 -7
  14. package/mcp/dist/entrypoint.js +129 -102
  15. package/mcp/dist/governance-locks.js +6 -5
  16. package/mcp/dist/governance-policy.js +155 -2
  17. package/mcp/dist/governance-scores.js +3 -3
  18. package/mcp/dist/hooks.js +39 -18
  19. package/mcp/dist/index.js +4 -4
  20. package/mcp/dist/init-config.js +3 -24
  21. package/mcp/dist/init-setup.js +5 -5
  22. package/mcp/dist/init.js +170 -23
  23. package/mcp/dist/link-checksums.js +3 -2
  24. package/mcp/dist/link-context.js +1 -1
  25. package/mcp/dist/link-doctor.js +3 -3
  26. package/mcp/dist/link-skills.js +98 -12
  27. package/mcp/dist/link.js +17 -27
  28. package/mcp/dist/machine-identity.js +1 -9
  29. package/mcp/dist/mcp-config.js +247 -42
  30. package/mcp/dist/mcp-data.js +9 -9
  31. package/mcp/dist/mcp-extract-facts.js +1 -1
  32. package/mcp/dist/mcp-extract.js +2 -2
  33. package/mcp/dist/mcp-finding.js +6 -6
  34. package/mcp/dist/mcp-graph.js +11 -11
  35. package/mcp/dist/mcp-ops.js +18 -18
  36. package/mcp/dist/mcp-search.js +8 -8
  37. package/mcp/dist/memory-ui-page.js +23 -0
  38. package/mcp/dist/memory-ui-scripts.js +210 -27
  39. package/mcp/dist/memory-ui-server.js +115 -3
  40. package/mcp/dist/phren-paths.js +7 -7
  41. package/mcp/dist/profile-store.js +2 -2
  42. package/mcp/dist/project-config.js +63 -16
  43. package/mcp/dist/session-utils.js +3 -2
  44. package/mcp/dist/shared-fragment-graph.js +22 -21
  45. package/mcp/dist/shared-index.js +144 -105
  46. package/mcp/dist/shared-retrieval.js +19 -13
  47. package/mcp/dist/shared-search-fallback.js +13 -13
  48. package/mcp/dist/shared-sqljs.js +3 -2
  49. package/mcp/dist/shared.js +3 -3
  50. package/mcp/dist/shell-input.js +1 -1
  51. package/mcp/dist/shell-state-store.js +1 -1
  52. package/mcp/dist/shell-view.js +3 -2
  53. package/mcp/dist/shell.js +1 -1
  54. package/mcp/dist/skill-files.js +4 -10
  55. package/mcp/dist/skill-registry.js +3 -0
  56. package/mcp/dist/status.js +41 -13
  57. package/mcp/dist/task-hygiene.js +1 -1
  58. package/mcp/dist/telemetry.js +5 -4
  59. package/mcp/dist/update.js +1 -1
  60. package/mcp/dist/utils.js +3 -3
  61. package/package.json +2 -2
  62. package/starter/global/skills/audit.md +106 -0
  63. package/mcp/dist/shared-paths.js +0 -1
@@ -5,110 +5,124 @@ import { errorMessage } from "./utils.js";
5
5
  import { defaultPhrenPath, findPhrenPath } from "./shared.js";
6
6
  import { addProjectFromPath } from "./core-project.js";
7
7
  import { PROJECT_OWNERSHIP_MODES, getProjectOwnershipDefault, parseProjectOwnershipMode, } from "./project-config.js";
8
- const HELP_TEXT = `phren - He remembers so your agent doesn't have to
8
+ const HELP_TEXT = `phren - persistent knowledge for your agents
9
9
 
10
- Usage:
11
- phren Open interactive shell
12
- phren quickstart Quick setup: init + project scaffold
13
- phren add [path] [--ownership <mode>] Add current directory (or path) as a project
14
- phren init [--mode shared|project-local] [--machine <n>] [--profile <n>] [--mcp on|off] [--template <t>] [--dry-run] [-y]
15
- Set up phren and offer to add the current project directory
16
- phren projects list List all tracked projects
10
+ phren Interactive shell
11
+ phren init Set up phren
12
+ phren add [path] Register a project
13
+ phren search <query> Search what phren knows
14
+ phren status Health check
15
+ phren doctor [--fix] Diagnose and repair
16
+ phren web-ui Open the knowledge graph
17
+ phren tasks Cross-project task view
18
+ phren graph Fragment knowledge graph
19
+
20
+ phren help <topic> Detailed help for a topic
21
+
22
+ Topics: projects, skills, hooks, config, maintain, setup, env, all
23
+ `;
24
+ const HELP_TOPICS = {
25
+ projects: `Projects:
26
+ phren add [path] [--ownership <mode>] Register a project
27
+ phren projects list List all tracked projects
17
28
  phren projects configure <name> [--ownership <mode>] [--hooks on|off]
18
- Update per-project enrollment and hook settings
19
- phren projects remove <name> Remove a project (asks for confirmation)
20
- phren detect-skills [--import] Find untracked skills in ~/.claude/skills/
21
- phren skills list List installed skills
22
- phren skills add <project> <path> Link or copy a skill file into one project
23
- phren skills resolve <project|global> Print the resolved skill manifest for one scope
24
- phren skills doctor <project|global> Diagnose resolved skill visibility + mirror state
25
- phren skills sync <project|global> Regenerate the resolved mirror for one scope
26
- phren skills remove <project> <name> Remove a project skill by name
27
- phren hooks list [--project <name>] Show hook tool preferences and optional project overrides
28
- phren hooks enable <tool> Enable hooks for one tool
29
- phren hooks disable <tool> Disable hooks for one tool
30
- phren status Health, active project, stats
31
- phren review [project] Show review queue items with date, confidence, text
32
- phren consolidation-status [project] Check if findings need consolidation
33
- phren session-context Show current session state (project, duration, findings)
34
- phren search <query> [--project <n>] [--type <t>] [--limit <n>]
35
- Search what phren remembers
36
- phren add-finding <project> "..." Tell phren what you learned
37
- phren pin <project> "..." Save a truth
38
- phren tasks Cross-project task view
39
- phren skill-list List installed skills
40
- phren doctor [--fix] [--check-data] [--agents]
41
- Health check and self-heal (--agents: show agent integrations only)
42
- phren web-ui [--port=3499] [--no-open] Memory web UI
43
- phren debug-injection --prompt "..." Preview hook-prompt injection output
44
- phren inspect-index [--project <n>] Inspect FTS index contents for debugging
45
- phren update [--refresh-starter] Update to latest version
46
- phren graph [--project <n>] [--limit <n>]
47
- Show the fragment knowledge graph
48
- phren graph link <project> "finding" "fragment"
49
- Link a finding to a fragment manually
29
+ Update per-project settings
30
+ phren projects remove <name> Remove a project
31
+ `,
32
+ skills: `Skills:
33
+ phren skills list List installed skills
34
+ phren skills add <project> <path> Link a skill into a project
35
+ phren skills show <name> Show skill content
36
+ phren skills resolve <project|global> Print resolved skill manifest
37
+ phren skills doctor <project|global> Diagnose skill visibility
38
+ phren skills sync <project|global> Regenerate skill mirror
39
+ phren skills remove <project> <name> Remove a skill
40
+ phren detect-skills [--import] Find untracked skills in ~/.claude/skills/
41
+ `,
42
+ hooks: `Hooks:
43
+ phren hooks list [--project <name>] Show hook status per tool
44
+ phren hooks enable <tool> Enable hooks for a tool
45
+ phren hooks disable <tool> Disable hooks for a tool
46
+ phren hooks add-custom <event> <cmd> Add a custom hook
47
+ phren hooks remove-custom <event> Remove custom hooks
48
+ phren hooks errors [--limit <n>] Show recent hook errors
49
+ `,
50
+ config: `Configuration:
51
+ phren config show [--project <name>] Show current config
52
+ phren config policy [get|set ...] Retention, TTL, confidence, decay
53
+ phren config workflow [get|set ...] Risky-memory thresholds
54
+ phren config proactivity [level] Set proactivity level
55
+ phren config task-mode [mode] Set task automation mode
56
+ phren config finding-sensitivity [lvl] Set finding capture sensitivity
57
+ phren config index [get|set ...] Indexer include/exclude globs
58
+ phren config synonyms [list|add|remove] Manage learned synonyms
59
+ phren config project-ownership [mode] Default ownership for new projects
60
+ phren config machines Registered machines
61
+ phren config profiles Profiles and projects
62
+ phren config telemetry [on|off] Opt-in usage telemetry
50
63
 
51
- Configuration:
52
- phren config policy [get|set ...] Retention, TTL, confidence, decay
53
- phren config workflow [get|set ...] Risky-memory thresholds
54
- phren config index [get|set ...] Indexer include/exclude globs
55
- phren config synonyms [list|add|remove] ...
56
- Manage project learned synonyms
57
- phren config project-ownership [mode] Default ownership for future project enrollments
58
- phren config machines Registered machines
59
- phren config profiles Profiles and projects
64
+ All config subcommands accept --project <name> for per-project overrides.
65
+ `,
66
+ maintain: `Maintenance:
67
+ phren maintain govern [project] Queue stale memories for review
68
+ phren maintain prune [project] Delete expired entries
69
+ phren maintain consolidate [project] Deduplicate findings
70
+ phren maintain extract [project] Mine git/GitHub signals
71
+ `,
72
+ setup: `Setup:
73
+ phren init [--mode shared|project-local] [--machine <n>] [--profile <n>] [--dry-run] [-y]
74
+ phren quickstart Quick setup: init + project scaffold
75
+ phren mcp-mode [on|off|status] Toggle MCP integration
76
+ phren hooks-mode [on|off|status] Toggle hook execution
77
+ phren verify Check init completed OK
78
+ phren uninstall Remove phren config and hooks
79
+ phren update [--refresh-starter] Update to latest version
80
+ `,
81
+ env: `Environment variables:
82
+ PHREN_PATH Override phren directory (default: ~/.phren)
83
+ PHREN_PROFILE Active profile name
84
+ PHREN_DEBUG Enable debug logging (set to 1)
60
85
 
61
- Maintenance:
62
- phren maintain govern [project] Queue stale/low-value memories for review
63
- phren maintain prune [project] Delete expired entries
64
- phren maintain consolidate [project] Deduplicate FINDINGS.md
65
- phren maintain extract [project] Mine git/GitHub signals
86
+ Embeddings:
87
+ PHREN_OLLAMA_URL Ollama base URL (default: http://localhost:11434, 'off' to disable)
88
+ PHREN_EMBEDDING_API_URL OpenAI-compatible /embeddings endpoint
89
+ PHREN_EMBEDDING_API_KEY API key for embedding endpoint
90
+ PHREN_EMBEDDING_MODEL Embedding model (default: nomic-embed-text)
66
91
 
67
- Setup:
68
- phren mcp-mode [on|off|status] Toggle MCP integration
69
- phren hooks-mode [on|off|status] Toggle hook execution
70
- phren verify Check init completed OK
71
- phren uninstall Remove phren config and hooks
92
+ Context injection:
93
+ PHREN_CONTEXT_TOKEN_BUDGET Max tokens injected per prompt (default: 550)
94
+ PHREN_MAX_INJECT_TOKENS Hard cap on total injected tokens (default: 2000)
95
+ PHREN_HOOK_TIMEOUT_MS Hook subprocess timeout in ms (default: 14000)
72
96
 
73
- Environment:
74
- PHREN_PATH Override phren directory (default: ~/.phren)
75
- PHREN_PROFILE Active profile name (otherwise phren uses machines.yaml when available)
76
- PHREN_DEBUG Enable debug logging (set to 1)
77
- PHREN_OLLAMA_URL Ollama base URL (default: http://localhost:11434; set to 'off' to disable)
78
- PHREN_EMBEDDING_API_URL OpenAI-compatible /embeddings endpoint (cloud alternative to Ollama)
79
- PHREN_EMBEDDING_API_KEY API key for PHREN_EMBEDDING_API_URL
80
- PHREN_EMBEDDING_MODEL Embedding model (default: nomic-embed-text)
81
- PHREN_EXTRACT_MODEL Ollama model for memory extraction (default: llama3.2)
82
- PHREN_EMBEDDING_PROVIDER Set to 'api' to use OpenAI API for search_knowledge embeddings
83
- PHREN_FEATURE_AUTO_CAPTURE=1 Extract insights from conversations at session end
84
- PHREN_FEATURE_SEMANTIC_DEDUP=1 LLM-based dedup on add_finding
85
- PHREN_FEATURE_SEMANTIC_CONFLICT=1 LLM-based conflict detection on add_finding
86
- PHREN_FEATURE_HYBRID_SEARCH=0 Disable TF-IDF cosine fallback in search_knowledge
87
- PHREN_FEATURE_AUTO_EXTRACT=0 Disable automatic memory extraction on each prompt
88
- PHREN_FEATURE_PROGRESSIVE_DISCLOSURE=1 Compact memory index injection
89
- PHREN_LLM_MODEL LLM model for semantic dedup/conflict (default: gpt-4o-mini)
90
- PHREN_LLM_ENDPOINT OpenAI-compatible /chat/completions base URL for dedup/conflict
91
- PHREN_LLM_KEY API key for PHREN_LLM_ENDPOINT
92
- PHREN_CONTEXT_TOKEN_BUDGET Max tokens injected per hook-prompt (default: 550)
93
- PHREN_CONTEXT_SNIPPET_LINES Max lines per injected snippet (default: 6)
94
- PHREN_CONTEXT_SNIPPET_CHARS Max chars per injected snippet (default: 520)
95
- PHREN_MAX_INJECT_TOKENS Hard cap on total injected tokens (default: 2000)
96
- PHREN_TASK_PRIORITY Priorities to include in task injection: high,medium,low (default: high,medium)
97
- PHREN_MEMORY_TTL_DAYS Override memory TTL for trust filtering
98
- PHREN_HOOK_TIMEOUT_MS Hook subprocess timeout in ms (default: 14000)
99
- PHREN_FINDINGS_CAP Max findings per date section before consolidation (default: 20)
100
- PHREN_GH_PR_LIMIT/RUN_LIMIT/ISSUE_LIMIT GitHub extraction limits (defaults: 40/25/25)
97
+ Feature flags:
98
+ PHREN_FEATURE_AUTO_EXTRACT=0 Disable auto memory extraction
99
+ PHREN_FEATURE_AUTO_CAPTURE=1 Extract insights from conversations
100
+ PHREN_FEATURE_SEMANTIC_DEDUP=1 LLM-based dedup on add_finding
101
+ PHREN_FEATURE_HYBRID_SEARCH=0 Disable TF-IDF cosine fallback
101
102
 
102
- Examples:
103
- phren search "rate limiting" Search across all projects
104
- phren search "auth" --project my-api Search within one project
105
- phren add-finding my-app "Redis connections need explicit close in finally blocks"
106
- phren doctor --fix Fix common config issues
107
- phren config policy set --ttlDays=90 Change memory retention to 90 days
108
- phren config project-ownership detached
109
- phren maintain govern my-app Queue stale memories for review
110
- phren status Quick health check
111
- `;
103
+ Run 'phren help all' to see everything.
104
+ `,
105
+ };
106
+ function buildFullHelp() {
107
+ return `phren - persistent knowledge for your agents
108
+
109
+ Usage:
110
+ phren Interactive shell
111
+ phren init Set up phren
112
+ phren add [path] Register a project
113
+ phren search <query> Search what phren knows
114
+ phren status Health check
115
+ phren doctor [--fix] Diagnose and repair
116
+ phren web-ui Open the knowledge graph
117
+ phren tasks Cross-project task view
118
+ phren graph Fragment knowledge graph
119
+ phren add-finding <p> "." Tell phren what you learned
120
+ phren pin <p> "..." Save a truth
121
+ phren review [project] Show review queue
122
+ phren session-context Current session state
123
+
124
+ ${Object.values(HELP_TOPICS).join("\n")}`;
125
+ }
112
126
  const CLI_COMMANDS = [
113
127
  "search",
114
128
  "shell",
@@ -216,7 +230,19 @@ async function promptProjectOwnership(phrenPath, fallback) {
216
230
  export async function runTopLevelCommand(argv) {
217
231
  const argvCommand = argv[0];
218
232
  if (argvCommand === "--help" || argvCommand === "-h" || argvCommand === "help") {
219
- console.log(HELP_TEXT);
233
+ const topic = argv[1]?.toLowerCase();
234
+ if (topic === "all") {
235
+ console.log(buildFullHelp());
236
+ }
237
+ else if (topic && HELP_TOPICS[topic]) {
238
+ console.log(HELP_TOPICS[topic]);
239
+ }
240
+ else if (topic) {
241
+ console.log(`Unknown topic: ${topic}\nAvailable: ${Object.keys(HELP_TOPICS).join(", ")}, all`);
242
+ }
243
+ else {
244
+ console.log(HELP_TEXT);
245
+ }
220
246
  return finish();
221
247
  }
222
248
  if (argvCommand === "add") {
@@ -313,7 +339,8 @@ export async function runTopLevelCommand(argv) {
313
339
  }
314
340
  if (argvCommand === "uninstall") {
315
341
  const { runUninstall } = await import("./init.js");
316
- await runUninstall();
342
+ const skipConfirm = argv.includes("--yes") || argv.includes("-y");
343
+ await runUninstall({ yes: skipConfirm });
317
344
  return finish();
318
345
  }
319
346
  if (argvCommand === "status") {
@@ -349,7 +376,7 @@ export async function runTopLevelCommand(argv) {
349
376
  return finish();
350
377
  }
351
378
  catch (err) {
352
- console.error(err instanceof Error ? err.message : String(err));
379
+ console.error(errorMessage(err));
353
380
  return finish(1);
354
381
  }
355
382
  }
@@ -360,7 +387,7 @@ export async function runTopLevelCommand(argv) {
360
387
  return finish();
361
388
  }
362
389
  catch (err) {
363
- console.error(err instanceof Error ? err.message : String(err));
390
+ console.error(errorMessage(err));
364
391
  return finish(1);
365
392
  }
366
393
  }
@@ -383,7 +410,7 @@ export async function runTopLevelCommand(argv) {
383
410
  trackCliCommand(defaultPhrenPath(), argvCommand);
384
411
  }
385
412
  catch (err) {
386
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
413
+ if ((process.env.PHREN_DEBUG))
387
414
  process.stderr.write(`[phren] cli trackCliCommand: ${errorMessage(err)}\n`);
388
415
  }
389
416
  await runCliCommand(argvCommand, argv.slice(1));
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { debugLog } from "./shared.js";
4
+ import { errorMessage } from "./utils.js";
4
5
  // Acquire the file lock, returning true on success or throwing on timeout.
5
6
  function acquireFileLock(lockPath) {
6
7
  const maxWait = Number.parseInt(process.env.PHREN_FILE_LOCK_MAX_WAIT_MS || "5000", 10) || 5000;
@@ -18,8 +19,8 @@ function acquireFileLock(lockPath) {
18
19
  break;
19
20
  }
20
21
  catch (err) {
21
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
22
- process.stderr.write(`[phren] acquireFileLock lockWrite: ${err instanceof Error ? err.message : String(err)}\n`);
22
+ if ((process.env.PHREN_DEBUG))
23
+ process.stderr.write(`[phren] acquireFileLock lockWrite: ${errorMessage(err)}\n`);
23
24
  try {
24
25
  const stat = fs.statSync(lockPath);
25
26
  if (Date.now() - stat.mtimeMs > staleThreshold) {
@@ -53,7 +54,7 @@ function acquireFileLock(lockPath) {
53
54
  }
54
55
  }
55
56
  catch (statErr) {
56
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
57
+ if ((process.env.PHREN_DEBUG))
57
58
  process.stderr.write(`[phren] acquireFileLock staleStat: ${statErr instanceof Error ? statErr.message : String(statErr)}\n`);
58
59
  sleep(pollInterval);
59
60
  waited += pollInterval;
@@ -74,8 +75,8 @@ function releaseFileLock(lockPath) {
74
75
  fs.unlinkSync(lockPath);
75
76
  }
76
77
  catch (err) {
77
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
78
- process.stderr.write(`[phren] releaseFileLock: ${err instanceof Error ? err.message : String(err)}\n`);
78
+ if ((process.env.PHREN_DEBUG))
79
+ process.stderr.write(`[phren] releaseFileLock: ${errorMessage(err)}\n`);
79
80
  }
80
81
  }
81
82
  // Q10: withFileLock now accepts both sync and async callbacks.
@@ -4,6 +4,7 @@ import * as path from "path";
4
4
  import { appendAuditLog, debugLog, getProjectDirs, isRecord, runtimeHealthFile, withDefaults, phrenErr, PhrenError, phrenOk, resolveFindingsPath } from "./shared.js";
5
5
  import { withFileLock } from "./governance-locks.js";
6
6
  import { errorMessage, isValidProjectName, safeProjectPath } from "./utils.js";
7
+ import { readProjectConfig } from "./project-config.js";
7
8
  import { runCustomHooks } from "./hooks.js";
8
9
  import { METADATA_REGEX, isCitationLine, isArchiveStart as isArchiveStartMeta, isArchiveEnd as isArchiveEndMeta, stripLifecycleMetadata as stripLifecycleMetadataMeta, } from "./content-metadata.js";
9
10
  export const MAX_QUEUE_ENTRY_LENGTH = 500;
@@ -212,6 +213,148 @@ function normalizeIndexPolicy(data) {
212
213
  includeHidden: pickBoolean(data.includeHidden, DEFAULT_INDEX_POLICY.includeHidden),
213
214
  };
214
215
  }
216
+ const VALID_PROACTIVITY_LEVELS = ["high", "medium", "low"];
217
+ export const VALID_TASK_MODES = ["off", "manual", "suggest", "auto"];
218
+ export const VALID_FINDING_SENSITIVITY = ["minimal", "conservative", "balanced", "aggressive"];
219
+ const VALID_RISKY_SECTIONS = ["Review", "Stale", "Conflicts"];
220
+ function pickEnum(value, allowed) {
221
+ return typeof value === "string" && allowed.includes(value) ? value : undefined;
222
+ }
223
+ function pickPositiveInt(value) {
224
+ return Number.isInteger(value) && typeof value === "number" && value > 0 ? value : undefined;
225
+ }
226
+ function pickUnitInterval(value) {
227
+ return isFiniteNumber(value) && value >= 0 && value <= 1 ? value : undefined;
228
+ }
229
+ function normalizeProjectConfigOverrides(raw) {
230
+ if (!isRecord(raw))
231
+ return undefined;
232
+ const retentionRaw = isRecord(raw.retentionPolicy) ? raw.retentionPolicy : undefined;
233
+ const decayRaw = retentionRaw && isRecord(retentionRaw.decay) ? retentionRaw.decay : undefined;
234
+ const retentionPolicy = retentionRaw
235
+ ? {
236
+ ttlDays: pickPositiveInt(retentionRaw.ttlDays),
237
+ retentionDays: pickPositiveInt(retentionRaw.retentionDays),
238
+ autoAcceptThreshold: pickUnitInterval(retentionRaw.autoAcceptThreshold),
239
+ minInjectConfidence: pickUnitInterval(retentionRaw.minInjectConfidence),
240
+ decay: decayRaw
241
+ ? {
242
+ d30: pickUnitInterval(decayRaw.d30),
243
+ d60: pickUnitInterval(decayRaw.d60),
244
+ d90: pickUnitInterval(decayRaw.d90),
245
+ d120: pickUnitInterval(decayRaw.d120),
246
+ }
247
+ : undefined,
248
+ }
249
+ : undefined;
250
+ if (retentionPolicy && retentionPolicy.decay && Object.values(retentionPolicy.decay).every((value) => value === undefined)) {
251
+ delete retentionPolicy.decay;
252
+ }
253
+ const workflowRaw = isRecord(raw.workflowPolicy) ? raw.workflowPolicy : undefined;
254
+ const workflowPolicy = workflowRaw
255
+ ? {
256
+ lowConfidenceThreshold: pickUnitInterval(workflowRaw.lowConfidenceThreshold),
257
+ riskySections: Array.isArray(workflowRaw.riskySections)
258
+ ? workflowRaw.riskySections.filter((section) => typeof section === "string" && VALID_RISKY_SECTIONS.includes(section))
259
+ : undefined,
260
+ }
261
+ : undefined;
262
+ if (workflowPolicy && workflowPolicy.riskySections && workflowPolicy.riskySections.length === 0) {
263
+ delete workflowPolicy.riskySections;
264
+ }
265
+ const overrides = {
266
+ findingSensitivity: pickEnum(raw.findingSensitivity, VALID_FINDING_SENSITIVITY),
267
+ proactivity: pickEnum(raw.proactivity, VALID_PROACTIVITY_LEVELS),
268
+ proactivityFindings: pickEnum(raw.proactivityFindings, VALID_PROACTIVITY_LEVELS),
269
+ proactivityTask: pickEnum(raw.proactivityTask, VALID_PROACTIVITY_LEVELS),
270
+ taskMode: pickEnum(raw.taskMode, VALID_TASK_MODES),
271
+ retentionPolicy: retentionPolicy && Object.values(retentionPolicy).some((value) => value !== undefined)
272
+ ? retentionPolicy
273
+ : undefined,
274
+ workflowPolicy: workflowPolicy && Object.values(workflowPolicy).some((value) => value !== undefined)
275
+ ? workflowPolicy
276
+ : undefined,
277
+ };
278
+ return overrides;
279
+ }
280
+ function readProjectConfigOverrides(phrenPath, projectName) {
281
+ try {
282
+ const config = readProjectConfig(phrenPath, projectName);
283
+ return normalizeProjectConfigOverrides(config.config);
284
+ }
285
+ catch {
286
+ return undefined;
287
+ }
288
+ }
289
+ export function getProjectConfigOverrides(phrenPath, projectName) {
290
+ return readProjectConfigOverrides(phrenPath, projectName) ?? null;
291
+ }
292
+ export function mergeConfig(phrenPath, projectName) {
293
+ const globalRetention = getRetentionPolicyGlobal(phrenPath);
294
+ const globalWorkflow = getWorkflowPolicyGlobal(phrenPath);
295
+ if (projectName && !isValidProjectName(projectName)) {
296
+ debugLog(`mergeConfig: invalid project name "${projectName}", using global defaults`);
297
+ projectName = undefined;
298
+ }
299
+ if (!projectName) {
300
+ return {
301
+ findingSensitivity: globalWorkflow.findingSensitivity,
302
+ proactivity: {},
303
+ taskMode: globalWorkflow.taskMode,
304
+ retentionPolicy: globalRetention,
305
+ workflowPolicy: globalWorkflow,
306
+ };
307
+ }
308
+ const overrides = readProjectConfigOverrides(phrenPath, projectName);
309
+ if (!overrides) {
310
+ return {
311
+ findingSensitivity: globalWorkflow.findingSensitivity,
312
+ proactivity: {},
313
+ taskMode: globalWorkflow.taskMode,
314
+ retentionPolicy: globalRetention,
315
+ workflowPolicy: globalWorkflow,
316
+ };
317
+ }
318
+ // Merge retention policy
319
+ const retentionOverride = overrides.retentionPolicy;
320
+ const mergedRetention = retentionOverride
321
+ ? {
322
+ schemaVersion: globalRetention.schemaVersion,
323
+ ttlDays: retentionOverride.ttlDays ?? globalRetention.ttlDays,
324
+ retentionDays: retentionOverride.retentionDays ?? globalRetention.retentionDays,
325
+ autoAcceptThreshold: retentionOverride.autoAcceptThreshold ?? globalRetention.autoAcceptThreshold,
326
+ minInjectConfidence: retentionOverride.minInjectConfidence ?? globalRetention.minInjectConfidence,
327
+ decay: {
328
+ d30: retentionOverride.decay?.d30 ?? globalRetention.decay.d30,
329
+ d60: retentionOverride.decay?.d60 ?? globalRetention.decay.d60,
330
+ d90: retentionOverride.decay?.d90 ?? globalRetention.decay.d90,
331
+ d120: retentionOverride.decay?.d120 ?? globalRetention.decay.d120,
332
+ },
333
+ }
334
+ : globalRetention;
335
+ // Merge workflow policy
336
+ const workflowOverride = overrides.workflowPolicy;
337
+ const mergedWorkflow = {
338
+ schemaVersion: globalWorkflow.schemaVersion,
339
+ lowConfidenceThreshold: workflowOverride?.lowConfidenceThreshold ?? globalWorkflow.lowConfidenceThreshold,
340
+ riskySections: workflowOverride?.riskySections?.length
341
+ ? workflowOverride.riskySections
342
+ : globalWorkflow.riskySections,
343
+ taskMode: overrides.taskMode ?? globalWorkflow.taskMode,
344
+ findingSensitivity: overrides.findingSensitivity ?? globalWorkflow.findingSensitivity,
345
+ };
346
+ return {
347
+ findingSensitivity: mergedWorkflow.findingSensitivity,
348
+ proactivity: {
349
+ base: overrides.proactivity,
350
+ findings: overrides.proactivityFindings,
351
+ tasks: overrides.proactivityTask,
352
+ },
353
+ taskMode: mergedWorkflow.taskMode,
354
+ retentionPolicy: mergedRetention,
355
+ workflowPolicy: mergedWorkflow,
356
+ };
357
+ }
215
358
  function readJsonFile(filePath, fallback) {
216
359
  try {
217
360
  if (!fs.existsSync(filePath))
@@ -246,10 +389,15 @@ function writeJsonFile(filePath, data) {
246
389
  writeJsonFileUnlocked(filePath, data);
247
390
  });
248
391
  }
249
- export function getRetentionPolicy(phrenPath) {
392
+ function getRetentionPolicyGlobal(phrenPath) {
250
393
  const parsed = readJsonFile(govFile(phrenPath, "retention-policy"), {});
251
394
  return withDefaults(parsed, DEFAULT_POLICY);
252
395
  }
396
+ export function getRetentionPolicy(phrenPath, projectName) {
397
+ if (projectName)
398
+ return mergeConfig(phrenPath, projectName).retentionPolicy;
399
+ return getRetentionPolicyGlobal(phrenPath);
400
+ }
253
401
  export function updateRetentionPolicy(phrenPath, patch) {
254
402
  const current = getRetentionPolicy(phrenPath);
255
403
  const next = {
@@ -264,7 +412,7 @@ export function updateRetentionPolicy(phrenPath, patch) {
264
412
  appendAuditLog(phrenPath, "update_policy", JSON.stringify(next));
265
413
  return phrenOk(next);
266
414
  }
267
- export function getWorkflowPolicy(phrenPath) {
415
+ function getWorkflowPolicyGlobal(phrenPath) {
268
416
  const parsed = readJsonFile(govFile(phrenPath, "workflow-policy"), {});
269
417
  const merged = withDefaults(parsed, DEFAULT_WORKFLOW_POLICY);
270
418
  const validSections = new Set(["Review", "Stale", "Conflicts"]);
@@ -279,6 +427,11 @@ export function getWorkflowPolicy(phrenPath) {
279
427
  }
280
428
  return merged;
281
429
  }
430
+ export function getWorkflowPolicy(phrenPath, projectName) {
431
+ if (projectName)
432
+ return mergeConfig(phrenPath, projectName).workflowPolicy;
433
+ return getWorkflowPolicyGlobal(phrenPath);
434
+ }
282
435
  export function updateWorkflowPolicy(phrenPath, patch) {
283
436
  const current = getWorkflowPolicy(phrenPath);
284
437
  const riskySections = Array.isArray(patch.riskySections)
@@ -120,7 +120,7 @@ function readScoreJournal(phrenPath) {
120
120
  return JSON.parse(line);
121
121
  }
122
122
  catch (err) {
123
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
123
+ if ((process.env.PHREN_DEBUG))
124
124
  process.stderr.write(`[phren] readScoreJournal parseLine: ${errorMessage(err)}\n`);
125
125
  return null;
126
126
  }
@@ -153,7 +153,7 @@ function claimScoreJournal(phrenPath) {
153
153
  return JSON.parse(line);
154
154
  }
155
155
  catch (err) {
156
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
156
+ if ((process.env.PHREN_DEBUG))
157
157
  process.stderr.write(`[phren] claimScoreJournal parseLine: ${errorMessage(err)}\n`);
158
158
  return null;
159
159
  }
@@ -169,7 +169,7 @@ function claimScoreJournal(phrenPath) {
169
169
  fs.unlinkSync(claimedFile);
170
170
  }
171
171
  catch (err) {
172
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
172
+ if ((process.env.PHREN_DEBUG))
173
173
  process.stderr.write(`[phren] claimScoreJournal unlinkClaim: ${errorMessage(err)}\n`);
174
174
  }
175
175
  }
package/mcp/dist/hooks.js CHANGED
@@ -1,18 +1,12 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { createHmac, randomUUID } from "crypto";
3
+ import { createHmac } from "crypto";
4
4
  import { execFileSync } from "child_process";
5
5
  import { fileURLToPath } from "url";
6
- import { EXEC_TIMEOUT_QUICK_MS, PhrenError, debugLog, runtimeFile, homePath, installPreferencesFile } from "./shared.js";
6
+ import { EXEC_TIMEOUT_QUICK_MS, PhrenError, debugLog, runtimeFile, homePath, installPreferencesFile, atomicWriteText } from "./shared.js";
7
7
  import { errorMessage } from "./utils.js";
8
8
  import { hookConfigPath } from "./provider-adapters.js";
9
9
  import { PACKAGE_SPEC } from "./package-metadata.js";
10
- function atomicWriteText(filePath, content) {
11
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
12
- const tmpPath = `${filePath}.tmp-${randomUUID()}`;
13
- fs.writeFileSync(tmpPath, content);
14
- fs.renameSync(tmpPath, filePath);
15
- }
16
10
  export function commandExists(cmd) {
17
11
  try {
18
12
  const whichCmd = process.platform === "win32" ? "where.exe" : "which";
@@ -215,10 +209,34 @@ function validateCodexConfig(config) {
215
209
  Array.isArray(config.hooks?.UserPromptSubmit) &&
216
210
  Array.isArray(config.hooks?.Stop));
217
211
  }
212
+ // ── mtime-based install-preferences cache (shared by readHookPreferences + readCustomHooks) ──
213
+ const _installPrefsJsonCache = new Map();
214
+ export function clearHookPrefsCache() {
215
+ _installPrefsJsonCache.clear();
216
+ }
217
+ function cachedReadInstallPrefsJson(phrenPath) {
218
+ const prefsPath = installPreferencesFile(phrenPath);
219
+ let mtimeMs;
220
+ try {
221
+ mtimeMs = fs.statSync(prefsPath).mtimeMs;
222
+ }
223
+ catch {
224
+ _installPrefsJsonCache.delete(prefsPath);
225
+ return null;
226
+ }
227
+ const cached = _installPrefsJsonCache.get(prefsPath);
228
+ if (cached && cached.mtimeMs === mtimeMs) {
229
+ return cached.parsed;
230
+ }
231
+ const parsed = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
232
+ _installPrefsJsonCache.set(prefsPath, { mtimeMs, parsed });
233
+ return parsed;
234
+ }
218
235
  function readHookPreferences(phrenPath) {
219
236
  try {
220
- const prefsPath = installPreferencesFile(phrenPath);
221
- const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
237
+ const prefs = cachedReadInstallPrefsJson(phrenPath);
238
+ if (!prefs)
239
+ return { enabled: true, toolPrefs: {} };
222
240
  const enabled = prefs.hooksEnabled !== false;
223
241
  const toolPrefs = prefs.hookTools && typeof prefs.hookTools === "object"
224
242
  ? prefs.hookTools
@@ -255,15 +273,18 @@ const HOOK_TIMEOUT_MS = parseInt(process.env.PHREN_HOOK_TIMEOUT_MS || '14000', 1
255
273
  const HOOK_ERROR_LOG_MAX_LINES = 1000;
256
274
  export function readCustomHooks(phrenPath) {
257
275
  try {
258
- const prefsPath = installPreferencesFile(phrenPath);
259
- const prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
260
- if (!Array.isArray(prefs.customHooks))
276
+ const prefs = cachedReadInstallPrefsJson(phrenPath);
277
+ if (!prefs || !Array.isArray(prefs.customHooks))
261
278
  return [];
262
- return prefs.customHooks.filter((h) => h &&
263
- typeof h.event === "string" &&
264
- VALID_HOOK_EVENTS.has(h.event) &&
265
- ((typeof h.command === "string" && h.command.trim().length > 0) ||
266
- (typeof h.webhook === "string" && h.webhook.trim().length > 0)));
279
+ return prefs.customHooks.filter((h) => {
280
+ if (!h || typeof h !== "object")
281
+ return false;
282
+ const rec = h;
283
+ return (typeof rec.event === "string" &&
284
+ VALID_HOOK_EVENTS.has(rec.event) &&
285
+ ((typeof rec.command === "string" && rec.command.trim().length > 0) ||
286
+ (typeof rec.webhook === "string" && rec.webhook.trim().length > 0)));
287
+ });
267
288
  }
268
289
  catch (err) {
269
290
  debugLog(`readCustomHooks: ${errorMessage(err)}`);