@phren/cli 0.0.11 → 0.0.13

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 (76) hide show
  1. package/README.md +9 -9
  2. package/mcp/dist/capabilities/cli.js +1 -1
  3. package/mcp/dist/capabilities/mcp.js +1 -1
  4. package/mcp/dist/capabilities/vscode.js +1 -1
  5. package/mcp/dist/capabilities/web-ui.js +1 -1
  6. package/mcp/dist/cli-actions.js +54 -67
  7. package/mcp/dist/cli-config.js +4 -5
  8. package/mcp/dist/cli-extract.js +3 -2
  9. package/mcp/dist/cli-graph.js +17 -3
  10. package/mcp/dist/cli-hooks-output.js +1 -1
  11. package/mcp/dist/cli-hooks-session.js +1 -1
  12. package/mcp/dist/cli-hooks.js +5 -3
  13. package/mcp/dist/cli.js +1 -1
  14. package/mcp/dist/content-archive.js +21 -12
  15. package/mcp/dist/content-citation.js +13 -2
  16. package/mcp/dist/content-learning.js +6 -4
  17. package/mcp/dist/content-metadata.js +10 -0
  18. package/mcp/dist/core-finding.js +1 -1
  19. package/mcp/dist/data-access.js +10 -31
  20. package/mcp/dist/data-tasks.js +5 -26
  21. package/mcp/dist/embedding.js +0 -1
  22. package/mcp/dist/entrypoint.js +4 -0
  23. package/mcp/dist/finding-impact.js +1 -32
  24. package/mcp/dist/finding-journal.js +1 -1
  25. package/mcp/dist/finding-lifecycle.js +2 -7
  26. package/mcp/dist/governance-locks.js +6 -0
  27. package/mcp/dist/governance-policy.js +1 -7
  28. package/mcp/dist/governance-scores.js +1 -7
  29. package/mcp/dist/hooks.js +23 -0
  30. package/mcp/dist/init-config.js +1 -1
  31. package/mcp/dist/init-preferences.js +1 -1
  32. package/mcp/dist/init-setup.js +1 -50
  33. package/mcp/dist/init-shared.js +53 -1
  34. package/mcp/dist/init.js +21 -6
  35. package/mcp/dist/link-context.js +1 -1
  36. package/mcp/dist/link-doctor.js +11 -54
  37. package/mcp/dist/link.js +4 -53
  38. package/mcp/dist/mcp-extract-facts.js +11 -6
  39. package/mcp/dist/mcp-finding.js +10 -14
  40. package/mcp/dist/mcp-graph.js +6 -6
  41. package/mcp/dist/mcp-hooks.js +1 -1
  42. package/mcp/dist/mcp-search.js +3 -8
  43. package/mcp/dist/mcp-session.js +12 -2
  44. package/mcp/dist/memory-ui-assets.js +1 -36
  45. package/mcp/dist/memory-ui-graph.js +152 -50
  46. package/mcp/dist/memory-ui-page.js +7 -5
  47. package/mcp/dist/memory-ui-scripts.js +42 -36
  48. package/mcp/dist/phren-core.js +2 -0
  49. package/mcp/dist/phren-paths.js +1 -2
  50. package/mcp/dist/proactivity.js +5 -5
  51. package/mcp/dist/project-config.js +1 -1
  52. package/mcp/dist/provider-adapters.js +1 -1
  53. package/mcp/dist/query-correlation.js +22 -19
  54. package/mcp/dist/session-checkpoints.js +14 -14
  55. package/mcp/dist/shared-data-utils.js +28 -0
  56. package/mcp/dist/shared-fragment-graph.js +11 -11
  57. package/mcp/dist/shared-governance.js +1 -1
  58. package/mcp/dist/shared-retrieval.js +2 -10
  59. package/mcp/dist/shared-search-fallback.js +2 -12
  60. package/mcp/dist/shared.js +2 -3
  61. package/mcp/dist/shell-entry.js +1 -1
  62. package/mcp/dist/shell-input.js +62 -52
  63. package/mcp/dist/shell-palette.js +6 -1
  64. package/mcp/dist/shell-render.js +9 -5
  65. package/mcp/dist/shell-state-store.js +1 -4
  66. package/mcp/dist/shell-view.js +4 -4
  67. package/mcp/dist/shell.js +4 -54
  68. package/mcp/dist/status.js +2 -8
  69. package/mcp/dist/utils.js +1 -1
  70. package/package.json +1 -2
  71. package/skills/docs.md +11 -11
  72. package/starter/README.md +1 -1
  73. package/starter/global/CLAUDE.md +2 -2
  74. package/starter/global/skills/audit.md +10 -10
  75. package/mcp/dist/cli-hooks-retrieval.js +0 -2
  76. package/mcp/dist/impact-scoring.js +0 -22
@@ -2,38 +2,17 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import * as yaml from "js-yaml";
4
4
  import { phrenErr, PhrenError, phrenOk, forwardErr, getProjectDirs, isRecord, } from "./shared.js";
5
- import { normalizeQueueEntryText, withFileLock as withFileLockRaw, } from "./shared-governance.js";
5
+ import { normalizeQueueEntryText, } from "./shared-governance.js";
6
6
  import { addFindingToFile, } from "./shared-content.js";
7
- import { isValidProjectName, queueFilePath, safeProjectPath, errorMessage } from "./utils.js";
7
+ import { isValidProjectName, queueFilePath, safeProjectPath } from "./utils.js";
8
8
  import { parseCitationComment, parseSourceComment, } from "./content-citation.js";
9
9
  import { parseFindingLifecycle, } from "./finding-lifecycle.js";
10
- import { METADATA_REGEX, isCitationLine, isArchiveStart, isArchiveEnd, parseFindingId, parseAllContradictions, stripComments, } from "./content-metadata.js";
10
+ import { METADATA_REGEX, isCitationLine, isArchiveStart, isArchiveEnd, parseFindingId, parseAllContradictions, stripComments, normalizeFindingText, } from "./content-metadata.js";
11
+ import { withSafeLock, ensureProject } from "./shared-data-utils.js";
11
12
  export { readTasks, readTasksAcrossProjects, resolveTaskItem, addTask, addTasks, completeTasks, completeTask, removeTask, removeTasks, updateTask, linkTaskIssue, pinTask, unpinTask, workNextTask, tidyDoneTasks, taskMarkdown, appendChildFinding, promoteTask, TASKS_FILENAME, TASK_FILE_ALIASES, canonicalTaskFilePath, resolveTaskFilePath, isTaskFileName, } from "./data-tasks.js";
12
13
  export { addProjectToProfile, listMachines, listProfiles, listProjectCards, removeProjectFromProfile, setMachineProfile, } from "./profile-store.js";
13
- export { loadShellState, readRuntimeHealth, resetShellState, saveShellState, } from "./shell-state-store.js";
14
- function withSafeLock(filePath, fn) {
15
- try {
16
- return withFileLockRaw(filePath, fn);
17
- }
18
- catch (err) {
19
- const msg = errorMessage(err);
20
- if (msg.includes("could not acquire lock")) {
21
- return phrenErr(`Could not acquire write lock for "${path.basename(filePath)}". Another write may be in progress; please retry.`, PhrenError.LOCK_TIMEOUT);
22
- }
23
- throw err;
24
- }
25
- }
26
- function ensureProject(phrenPath, project) {
27
- if (!isValidProjectName(project))
28
- return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
29
- const dir = safeProjectPath(phrenPath, project);
30
- if (!dir)
31
- return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
32
- if (!fs.existsSync(dir)) {
33
- return phrenErr(`No project "${project}" found. Add it with 'cd ~/your-project && phren add'.`, PhrenError.PROJECT_NOT_FOUND);
34
- }
35
- return phrenOk(dir);
36
- }
14
+ export { loadShellState, resetShellState, saveShellState, } from "./shell-state-store.js";
15
+ export { getRuntimeHealth as readRuntimeHealth } from "./shared-governance.js";
37
16
  function extractDateHeading(line) {
38
17
  const heading = line.match(/^##\s+(.+)$/);
39
18
  if (!heading)
@@ -79,8 +58,8 @@ function findMatchingFindingBullet(bulletLines, needle, match) {
79
58
  const fidMatch = /^[a-z0-9]{8}$/.test(fidNeedle)
80
59
  ? bulletLines.filter(({ line }) => new RegExp(`<!--\\s*fid:${fidNeedle}\\s*-->`).test(line))
81
60
  : [];
82
- const exactMatches = bulletLines.filter(({ line }) => line.replace(/^-\s+/, "").replace(/<!--.*?-->/g, "").trim().toLowerCase() === needle);
83
- const partialMatches = bulletLines.filter(({ line }) => line.toLowerCase().includes(needle));
61
+ const exactMatches = bulletLines.filter(({ line }) => normalizeFindingText(line) === needle);
62
+ const partialMatches = bulletLines.filter(({ line }) => normalizeFindingText(line).includes(needle));
84
63
  if (fidMatch.length === 1)
85
64
  return { kind: "found", idx: fidMatch[0].i };
86
65
  if (exactMatches.length === 1)
@@ -276,7 +255,7 @@ export function removeFinding(phrenPath, project, match) {
276
255
  return phrenErr(`No FINDINGS.md file found for "${project}". Add a finding first with add_finding or :find add.`, PhrenError.FILE_NOT_FOUND);
277
256
  return withSafeLock(filePath, () => {
278
257
  const lines = fs.readFileSync(filePath, "utf8").split("\n");
279
- const needle = match.trim().toLowerCase();
258
+ const needle = normalizeFindingText(match);
280
259
  const bulletLines = collectFindingBulletLines(lines);
281
260
  const activeMatch = findMatchingFindingBullet(bulletLines.filter(({ archived }) => !archived), needle, match);
282
261
  if (activeMatch.kind === "ambiguous") {
@@ -313,7 +292,7 @@ export function editFinding(phrenPath, project, oldText, newText) {
313
292
  return phrenErr(`No FINDINGS.md file found for "${project}".`, PhrenError.FILE_NOT_FOUND);
314
293
  return withSafeLock(findingsPath, () => {
315
294
  const lines = fs.readFileSync(findingsPath, "utf8").split("\n");
316
- const needle = oldText.trim().toLowerCase();
295
+ const needle = normalizeFindingText(oldText);
317
296
  const bulletLines = collectFindingBulletLines(lines);
318
297
  const activeMatch = findMatchingFindingBullet(bulletLines.filter(({ archived }) => !archived), needle, oldText);
319
298
  if (activeMatch.kind === "ambiguous") {
@@ -2,21 +2,9 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { randomBytes, randomUUID } from "crypto";
4
4
  import { phrenErr, PhrenError, phrenOk, forwardErr, getProjectDirs, } from "./shared.js";
5
- import { withFileLock as withFileLockRaw } from "./shared-governance.js";
6
5
  import { validateTaskFormat } from "./shared-content.js";
7
- import { isValidProjectName, safeProjectPath, errorMessage } from "./utils.js";
8
- function withSafeLock(filePath, fn) {
9
- try {
10
- return withFileLockRaw(filePath, fn);
11
- }
12
- catch (err) {
13
- const msg = errorMessage(err);
14
- if (msg.includes("could not acquire lock")) {
15
- return phrenErr(`Could not acquire write lock for "${path.basename(filePath)}". Another write may be in progress; please retry.`, PhrenError.LOCK_TIMEOUT);
16
- }
17
- throw err;
18
- }
19
- }
6
+ import { safeProjectPath } from "./utils.js";
7
+ import { withSafeLock, ensureProject } from "./shared-data-utils.js";
20
8
  const ACTIVE_HEADINGS = new Set(["active", "in progress", "in-progress", "current", "wip"]);
21
9
  const QUEUE_HEADINGS = new Set(["queue", "queued", "task", "todo", "upcoming", "next"]);
22
10
  const DONE_HEADINGS = new Set(["done", "completed", "finished", "archived"]);
@@ -108,17 +96,6 @@ function parseContinuation(lines, idx) {
108
96
  }
109
97
  return { context, githubIssue, githubUrl, linesToSkip };
110
98
  }
111
- function ensureProject(phrenPath, project) {
112
- if (!isValidProjectName(project))
113
- return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
114
- const dir = safeProjectPath(phrenPath, project);
115
- if (!dir)
116
- return phrenErr(`Project name "${project}" is not valid. Use lowercase letters, numbers, and hyphens (e.g. "my-project").`, PhrenError.INVALID_PROJECT_NAME);
117
- if (!fs.existsSync(dir)) {
118
- return phrenErr(`No project "${project}" found. Add it with 'cd ~/your-project && phren add'.`, PhrenError.PROJECT_NOT_FOUND);
119
- }
120
- return phrenOk(dir);
121
- }
122
99
  /** Pattern that matches the task metadata comment embedded in task item lines.
123
100
  * Format: <!-- bid:HASH [rank:N] [lastActivity:ISO] -->
124
101
  */
@@ -808,7 +785,9 @@ export function tidyDoneTasks(phrenPath, project, keep = 30, dryRun) {
808
785
  const lines = archived.map((item) => `- [x] ${item.line}${item.context ? `\n Context: ${item.context}` : ""}`);
809
786
  const block = `## ${stamp}\n\n${lines.join("\n")}\n\n`;
810
787
  const prior = fs.existsSync(archiveFile) ? fs.readFileSync(archiveFile, "utf8") : `# ${project} tasks archive\n\n`;
811
- fs.writeFileSync(archiveFile, prior + block);
788
+ const tmpPath = `${archiveFile}.tmp-${randomUUID()}`;
789
+ fs.writeFileSync(tmpPath, prior + block);
790
+ fs.renameSync(tmpPath, archiveFile);
812
791
  writeTaskDoc(parsed.data);
813
792
  return phrenOk(`Tidied ${project}: archived ${archived.length} done item(s), kept ${safeKeep}.`);
814
793
  });
@@ -340,5 +340,4 @@ export function cosineSimilarity(a, b) {
340
340
  const denom = Math.sqrt(normA) * Math.sqrt(normB);
341
341
  return denom === 0 ? 0 : dot / denom;
342
342
  }
343
- // Export helpers for testing
344
343
  export { encodeEmbedding, decodeEmbedding, openCacheDb };
@@ -9,6 +9,7 @@ const HELP_TEXT = `phren - persistent knowledge for your agents
9
9
 
10
10
  phren Interactive shell
11
11
  phren init Set up phren
12
+ phren quickstart Quick setup: init + project scaffold
12
13
  phren add [path] Register a project
13
14
  phren search <query> Search what phren knows
14
15
  phren status Health check
@@ -36,6 +37,8 @@ const HELP_TOPICS = {
36
37
  phren skills resolve <project|global> Print resolved skill manifest
37
38
  phren skills doctor <project|global> Diagnose skill visibility
38
39
  phren skills sync <project|global> Regenerate skill mirror
40
+ phren skills enable <project|global> <name> Enable a disabled skill
41
+ phren skills disable <project|global> <name> Disable a skill without deleting
39
42
  phren skills remove <project> <name> Remove a skill
40
43
  phren detect-skills [--import] Find untracked skills in ~/.claude/skills/
41
44
  `,
@@ -109,6 +112,7 @@ function buildFullHelp() {
109
112
  Usage:
110
113
  phren Interactive shell
111
114
  phren init Set up phren
115
+ phren quickstart Quick setup: init + project scaffold
112
116
  phren add [path] Register a project
113
117
  phren search <query> Search what phren knows
114
118
  phren status Health check
@@ -2,19 +2,11 @@ import * as crypto from "crypto";
2
2
  import * as fs from "fs";
3
3
  import { impactLogFile } from "./shared.js";
4
4
  import { withFileLock } from "./shared-governance.js";
5
+ import { normalizeFindingText } from "./content-metadata.js";
5
6
  let highImpactCache = null;
6
7
  function nowIso() {
7
8
  return new Date().toISOString();
8
9
  }
9
- function normalizeFindingText(raw) {
10
- return raw
11
- .replace(/^-\s+/, "")
12
- .replace(/<!--.*?-->/g, " ")
13
- .replace(/\[confidence\s+[01](?:\.\d+)?\]/gi, " ")
14
- .replace(/\s+/g, " ")
15
- .trim()
16
- .toLowerCase();
17
- }
18
10
  export function findingIdFromLine(line) {
19
11
  const fid = line.match(/<!--\s*fid:([a-z0-9]{8})\s*-->/i);
20
12
  if (fid?.[1])
@@ -144,29 +136,6 @@ export function getHighImpactFindings(phrenPath, minSurfaceCount = 3) {
144
136
  };
145
137
  return new Set(ids);
146
138
  }
147
- export function getImpactSurfaceCounts(phrenPath, minSurfaces = 1) {
148
- const file = impactLogFile(phrenPath);
149
- if (!fs.existsSync(file))
150
- return new Map();
151
- const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
152
- const counts = new Map();
153
- for (const line of lines) {
154
- try {
155
- const entry = JSON.parse(line);
156
- if (entry.findingId) {
157
- counts.set(entry.findingId, (counts.get(entry.findingId) ?? 0) + 1);
158
- }
159
- }
160
- catch { }
161
- }
162
- // Filter by minimum
163
- const filtered = new Map();
164
- for (const [id, count] of counts) {
165
- if (count >= minSurfaces)
166
- filtered.set(id, count);
167
- }
168
- return filtered;
169
- }
170
139
  export function markImpactEntriesCompletedForSession(phrenPath, sessionId, project) {
171
140
  if (!sessionId)
172
141
  return 0;
@@ -105,7 +105,7 @@ export function compactFindingJournals(phrenPath, project) {
105
105
  result.failed += 1;
106
106
  continue;
107
107
  }
108
- if (typeof write.data === "string" && write.data.includes("Skipped duplicate"))
108
+ if (write.data.status === "skipped")
109
109
  result.skipped += 1;
110
110
  else
111
111
  result.added += 1;
@@ -3,9 +3,9 @@ import * as path from "path";
3
3
  import { PhrenError, phrenErr, phrenOk } from "./phren-core.js";
4
4
  // Phren lifecycle comment prefix. No backward compat.
5
5
  const LIFECYCLE_PREFIX = "phren";
6
- import { withFileLock } from "./governance-locks.js";
6
+ import { withFileLock } from "./shared-governance.js";
7
7
  import { isValidProjectName, safeProjectPath } from "./utils.js";
8
- import { isArchiveEnd, isArchiveStart, parseCreatedDate as parseCreatedDateMeta, parseStatusField, parseStatus, parseSupersession, parseContradiction, parseFindingId as parseFindingIdMeta, stripLifecycleMetadata, stripRelationMetadata, } from "./content-metadata.js";
8
+ import { isArchiveEnd, isArchiveStart, parseCreatedDate as parseCreatedDateMeta, parseStatusField, parseStatus, parseSupersession, parseContradiction, parseFindingId as parseFindingIdMeta, stripLifecycleMetadata, stripRelationMetadata, normalizeFindingText, } from "./content-metadata.js";
9
9
  export const FINDING_TYPE_DECAY = {
10
10
  'pattern': { maxAgeDays: 365, decayMultiplier: 1.0 }, // Slow decay, long-lived
11
11
  'decision': { maxAgeDays: Infinity, decayMultiplier: 1.0 }, // Never decays
@@ -101,11 +101,6 @@ function findingTextFromLine(line) {
101
101
  .replace(/<!--.*?-->/g, "")
102
102
  .trim();
103
103
  }
104
- function normalizeFindingText(value) {
105
- return findingTextFromLine(value)
106
- .replace(/\s+/g, " ")
107
- .toLowerCase();
108
- }
109
104
  function removeRelationComments(line) {
110
105
  return stripRelationMetadata(line);
111
106
  }
@@ -100,3 +100,9 @@ export function withFileLock(filePath, fn) {
100
100
  releaseFileLock(lockPath);
101
101
  return result;
102
102
  }
103
+ export function isFiniteNumber(value) {
104
+ return typeof value === "number" && Number.isFinite(value);
105
+ }
106
+ export function hasValidSchemaVersion(data) {
107
+ return !("schemaVersion" in data) || typeof data.schemaVersion === "number";
108
+ }
@@ -2,7 +2,7 @@ import * as crypto from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { appendAuditLog, debugLog, getProjectDirs, isRecord, runtimeHealthFile, withDefaults, phrenErr, PhrenError, phrenOk, resolveFindingsPath } from "./shared.js";
5
- import { withFileLock } from "./governance-locks.js";
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
8
  import { runCustomHooks } from "./hooks.js";
@@ -44,15 +44,9 @@ function governanceDir(phrenPath) {
44
44
  function govFile(phrenPath, schema) {
45
45
  return path.join(governanceDir(phrenPath), GOVERNANCE_REGISTRY[schema].file);
46
46
  }
47
- function hasValidSchemaVersion(data) {
48
- return !("schemaVersion" in data) || typeof data.schemaVersion === "number";
49
- }
50
47
  function isStringArray(value) {
51
48
  return Array.isArray(value) && value.every((item) => typeof item === "string");
52
49
  }
53
- function isFiniteNumber(value) {
54
- return typeof value === "number" && Number.isFinite(value);
55
- }
56
50
  function pickNumber(value, fallback) {
57
51
  return isFiniteNumber(value) ? value : fallback;
58
52
  }
@@ -2,7 +2,7 @@ import * as crypto from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { appendAuditLog, debugLog, isRecord, memoryScoresFile, memoryUsageLogFile, runtimeFile } from "./shared.js";
5
- import { withFileLock } from "./governance-locks.js";
5
+ import { withFileLock, isFiniteNumber, hasValidSchemaVersion } from "./shared-governance.js";
6
6
  import { errorMessage } from "./utils.js";
7
7
  const GOVERNANCE_SCHEMA_VERSION = 1;
8
8
  const DEFAULT_MEMORY_SCORES_FILE = {
@@ -15,12 +15,6 @@ function usageLogFile(phrenPath) {
15
15
  function scoresJournalFile(phrenPath) {
16
16
  return runtimeFile(phrenPath, "scores.jsonl");
17
17
  }
18
- function hasValidSchemaVersion(data) {
19
- return !("schemaVersion" in data) || typeof data.schemaVersion === "number";
20
- }
21
- function isFiniteNumber(value) {
22
- return typeof value === "number" && Number.isFinite(value);
23
- }
24
18
  function isEntryScore(value) {
25
19
  if (!isRecord(value))
26
20
  return false;
package/mcp/dist/hooks.js CHANGED
@@ -268,6 +268,21 @@ const VALID_HOOK_EVENTS = new Set(HOOK_EVENT_VALUES);
268
268
  export function getHookTarget(h) {
269
269
  return "webhook" in h ? h.webhook : h.command;
270
270
  }
271
+ /** Re-validate a command hook at execution time (mirrors mcp-hooks.ts validateHookCommand). */
272
+ function validateCommandAtExecution(command) {
273
+ const trimmed = command.trim();
274
+ if (!trimmed)
275
+ return "Command cannot be empty.";
276
+ if (trimmed.length > 1000)
277
+ return "Command too long (max 1000 characters).";
278
+ if (/[`$(){}&|;<>]/.test(trimmed))
279
+ return "Command contains disallowed shell characters.";
280
+ if (/\b(eval|source)\b/.test(trimmed))
281
+ return "eval and source are not permitted in hook commands.";
282
+ if (!/^[\w./~"'"]/.test(trimmed))
283
+ return "Command must begin with an executable name or path.";
284
+ return null;
285
+ }
271
286
  const DEFAULT_CUSTOM_HOOK_TIMEOUT = 5000;
272
287
  const HOOK_TIMEOUT_MS = parseInt(process.env.PHREN_HOOK_TIMEOUT_MS || '14000', 10);
273
288
  const HOOK_ERROR_LOG_MAX_LINES = 1000;
@@ -342,6 +357,14 @@ export function runCustomHooks(phrenPath, event, env = {}) {
342
357
  });
343
358
  continue;
344
359
  }
360
+ const cmdErr = validateCommandAtExecution(hook.command);
361
+ if (cmdErr) {
362
+ const message = `${event}: skipped hook (re-validation failed): ${cmdErr}`;
363
+ debugLog(`runCustomHooks: ${message}`);
364
+ errors.push({ code: PhrenError.VALIDATION_ERROR, message });
365
+ appendHookErrorLog(phrenPath, event, message);
366
+ continue;
367
+ }
345
368
  const shellArgs = isWindows ? ["/c", hook.command] : ["-c", hook.command];
346
369
  try {
347
370
  execFileSync(shellCmd, shellArgs, {
@@ -207,7 +207,7 @@ export function configureClaude(phrenPath, opts = {}) {
207
207
  eventHooks.push({ matcher: "", hooks: [hookBody] });
208
208
  }
209
209
  };
210
- const toolHookEnabled = hooksEnabled && (isFeatureEnabled("PHREN_FEATURE_TOOL_HOOK", false) || isFeatureEnabled("PHREN_FEATURE_TOOL_HOOK", false));
210
+ const toolHookEnabled = hooksEnabled && isFeatureEnabled("PHREN_FEATURE_TOOL_HOOK", false);
211
211
  if (hooksEnabled) {
212
212
  upsertPhrenHook("UserPromptSubmit", {
213
213
  type: "command",
@@ -6,7 +6,7 @@ import * as path from "path";
6
6
  import * as crypto from "crypto";
7
7
  import { debugLog, installPreferencesFile } from "./phren-paths.js";
8
8
  import { errorMessage } from "./utils.js";
9
- import { withFileLock } from "./governance-locks.js";
9
+ import { withFileLock } from "./shared-governance.js";
10
10
  function preferencesFile(phrenPath) {
11
11
  return installPreferencesFile(phrenPath);
12
12
  }
@@ -11,7 +11,7 @@ import { getMachineName } from "./machine-identity.js";
11
11
  import { execFileSync } from "child_process";
12
12
  import { GOVERNANCE_SCHEMA_VERSION, } from "./shared-governance.js";
13
13
  import { STOP_WORDS, errorMessage } from "./utils.js";
14
- import { ROOT, STARTER_DIR, VERSION, resolveEntryScript } from "./init-shared.js";
14
+ import { ROOT, STARTER_DIR, VERSION, resolveEntryScript, commandVersion, versionAtLeast, nearestWritableTarget } from "./init-shared.js";
15
15
  import { readInstallPreferences } from "./init-preferences.js";
16
16
  import { TASKS_FILENAME } from "./data-tasks.js";
17
17
  import { getProjectOwnershipDefault, parseProjectOwnershipMode, readProjectConfig, writeProjectConfig, } from "./project-config.js";
@@ -311,21 +311,6 @@ export function getVerifyOutcomeNote(phrenPath, checks) {
311
311
  }
312
312
  return "Some reported issues are optional for your chosen install mode; review git-remote / MCP failures separately from hard failures.";
313
313
  }
314
- function commandVersion(cmd, args = ["--version"]) {
315
- const effectiveCmd = process.platform === "win32" && (cmd === "npm" || cmd === "npx") ? `${cmd}.cmd` : cmd;
316
- try {
317
- return execFileSync(effectiveCmd, args, {
318
- encoding: "utf8",
319
- stdio: ["ignore", "pipe", "ignore"],
320
- shell: process.platform === "win32" && effectiveCmd.endsWith(".cmd"),
321
- timeout: EXEC_TIMEOUT_QUICK_MS,
322
- }).trim();
323
- }
324
- catch (err) {
325
- debugLog(`commandVersion ${effectiveCmd} failed: ${errorMessage(err)}`);
326
- return null;
327
- }
328
- }
329
314
  export function getHookEntrypointCheck(deps = {}) {
330
315
  const pathExists = deps.pathExists ?? fs.existsSync;
331
316
  const versionReader = deps.versionReader ?? commandVersion;
@@ -344,40 +329,6 @@ export function getHookEntrypointCheck(deps = {}) {
344
329
  fix: hookEntrypointOk ? undefined : "Rebuild phren: `npm run build` or reinstall the package, and ensure npm/npx is available for hook fallbacks",
345
330
  };
346
331
  }
347
- function parseSemverTriple(raw) {
348
- const match = raw.match(/(\d+)\.(\d+)\.(\d+)/);
349
- if (!match)
350
- return null;
351
- return [Number.parseInt(match[1], 10), Number.parseInt(match[2], 10), Number.parseInt(match[3], 10)];
352
- }
353
- function versionAtLeast(raw, major, minor = 0) {
354
- if (!raw)
355
- return false;
356
- const parsed = parseSemverTriple(raw);
357
- if (!parsed)
358
- return false;
359
- const [m, n] = parsed;
360
- if (m !== major)
361
- return m > major;
362
- return n >= minor;
363
- }
364
- function nearestWritableTarget(filePath) {
365
- let probe = fs.existsSync(filePath) ? filePath : path.dirname(filePath);
366
- while (!fs.existsSync(probe)) {
367
- const parent = path.dirname(probe);
368
- if (parent === probe)
369
- return false;
370
- probe = parent;
371
- }
372
- try {
373
- fs.accessSync(probe, fs.constants.W_OK);
374
- return true;
375
- }
376
- catch (err) {
377
- debugLog(`nearestWritableTarget failed for ${filePath}: ${errorMessage(err)}`);
378
- return false;
379
- }
380
- }
381
332
  function gitRemoteStatus(phrenPath) {
382
333
  try {
383
334
  execFileSync("git", ["-C", phrenPath, "rev-parse", "--is-inside-work-tree"], {
@@ -2,8 +2,11 @@
2
2
  * Shared constants and utilities for init modules.
3
3
  * Kept separate to avoid circular dependencies between init-config and init-setup.
4
4
  */
5
+ import * as fs from "fs";
5
6
  import * as path from "path";
6
- import { homePath } from "./shared.js";
7
+ import { execFileSync } from "child_process";
8
+ import { homePath, EXEC_TIMEOUT_QUICK_MS, debugLog } from "./shared.js";
9
+ import { errorMessage } from "./utils.js";
7
10
  import { ROOT as PACKAGE_ROOT, VERSION } from "./package-metadata.js";
8
11
  export const ROOT = PACKAGE_ROOT;
9
12
  export { VERSION };
@@ -15,6 +18,55 @@ export function resolveEntryScript() {
15
18
  export function log(msg) {
16
19
  process.stdout.write(msg + "\n");
17
20
  }
21
+ export function commandVersion(cmd, args = ["--version"]) {
22
+ const effectiveCmd = process.platform === "win32" && (cmd === "npm" || cmd === "npx") ? `${cmd}.cmd` : cmd;
23
+ try {
24
+ return execFileSync(effectiveCmd, args, {
25
+ encoding: "utf8",
26
+ stdio: ["ignore", "pipe", "ignore"],
27
+ shell: process.platform === "win32" && effectiveCmd.endsWith(".cmd"),
28
+ timeout: EXEC_TIMEOUT_QUICK_MS,
29
+ }).trim();
30
+ }
31
+ catch (err) {
32
+ debugLog(`commandVersion ${effectiveCmd} failed: ${errorMessage(err)}`);
33
+ return null;
34
+ }
35
+ }
36
+ export function parseSemverTriple(raw) {
37
+ const match = raw.match(/(\d+)\.(\d+)\.(\d+)/);
38
+ if (!match)
39
+ return null;
40
+ return [Number.parseInt(match[1], 10), Number.parseInt(match[2], 10), Number.parseInt(match[3], 10)];
41
+ }
42
+ export function versionAtLeast(raw, major, minor = 0) {
43
+ if (!raw)
44
+ return false;
45
+ const parsed = parseSemverTriple(raw);
46
+ if (!parsed)
47
+ return false;
48
+ const [m, n] = parsed;
49
+ if (m !== major)
50
+ return m > major;
51
+ return n >= minor;
52
+ }
53
+ export function nearestWritableTarget(filePath) {
54
+ let probe = fs.existsSync(filePath) ? filePath : path.dirname(filePath);
55
+ while (!fs.existsSync(probe)) {
56
+ const parent = path.dirname(probe);
57
+ if (parent === probe)
58
+ return false;
59
+ probe = parent;
60
+ }
61
+ try {
62
+ fs.accessSync(probe, fs.constants.W_OK);
63
+ return true;
64
+ }
65
+ catch (err) {
66
+ debugLog(`nearestWritableTarget failed for ${filePath}: ${errorMessage(err)}`);
67
+ return false;
68
+ }
69
+ }
18
70
  export async function confirmPrompt(message) {
19
71
  if (process.env.CI === "true" || !process.stdin.isTTY)
20
72
  return true;
package/mcp/dist/init.js CHANGED
@@ -845,9 +845,11 @@ async function runProjectLocalInit(opts = {}) {
845
845
  * Configure MCP for all detected AI coding tools (Claude, VS Code, Cursor, Copilot, Codex).
846
846
  * @param verb - label used in log messages, e.g. "Updated" or "Configured"
847
847
  */
848
- function configureMcpTargets(phrenPath, opts, verb) {
848
+ export function configureMcpTargets(phrenPath, opts, verb = "Configured") {
849
+ let claudeStatus = "no_settings";
849
850
  try {
850
851
  const status = configureClaude(phrenPath, { mcpEnabled: opts.mcpEnabled, hooksEnabled: opts.hooksEnabled });
852
+ claudeStatus = status ?? "installed";
851
853
  if (status === "disabled" || status === "already_disabled") {
852
854
  log(` ${verb} Claude Code hooks (MCP disabled)`);
853
855
  }
@@ -858,31 +860,44 @@ function configureMcpTargets(phrenPath, opts, verb) {
858
860
  catch (e) {
859
861
  log(` Could not configure Claude Code settings (${e}), add manually`);
860
862
  }
863
+ let vsStatus = "no_vscode";
861
864
  try {
862
- const vscodeResult = configureVSCode(phrenPath, { mcpEnabled: opts.mcpEnabled });
863
- logMcpTargetStatus("VS Code", vscodeResult, verb);
865
+ vsStatus = configureVSCode(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_vscode";
866
+ logMcpTargetStatus("VS Code", vsStatus, verb);
864
867
  }
865
868
  catch (err) {
866
869
  debugLog(`configureVSCode failed: ${errorMessage(err)}`);
867
870
  }
871
+ let cursorStatus = "no_cursor";
868
872
  try {
869
- logMcpTargetStatus("Cursor", configureCursorMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }), verb);
873
+ cursorStatus = configureCursorMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_cursor";
874
+ logMcpTargetStatus("Cursor", cursorStatus, verb);
870
875
  }
871
876
  catch (err) {
872
877
  debugLog(`configureCursorMcp failed: ${errorMessage(err)}`);
873
878
  }
879
+ let copilotStatus = "no_copilot";
874
880
  try {
875
- logMcpTargetStatus("Copilot CLI", configureCopilotMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }), verb);
881
+ copilotStatus = configureCopilotMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_copilot";
882
+ logMcpTargetStatus("Copilot CLI", copilotStatus, verb);
876
883
  }
877
884
  catch (err) {
878
885
  debugLog(`configureCopilotMcp failed: ${errorMessage(err)}`);
879
886
  }
887
+ let codexStatus = "no_codex";
880
888
  try {
881
- logMcpTargetStatus("Codex", configureCodexMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }), verb);
889
+ codexStatus = configureCodexMcp(phrenPath, { mcpEnabled: opts.mcpEnabled }) ?? "no_codex";
890
+ logMcpTargetStatus("Codex", codexStatus, verb);
882
891
  }
883
892
  catch (err) {
884
893
  debugLog(`configureCodexMcp failed: ${errorMessage(err)}`);
885
894
  }
895
+ const allStatuses = [claudeStatus, vsStatus, cursorStatus, copilotStatus, codexStatus];
896
+ if (allStatuses.some((s) => s === "installed" || s === "already_configured"))
897
+ return "installed";
898
+ if (allStatuses.some((s) => s === "disabled" || s === "already_disabled"))
899
+ return "disabled";
900
+ return claudeStatus;
886
901
  }
887
902
  /**
888
903
  * Configure hooks if enabled, or log a disabled message.
@@ -38,7 +38,7 @@ function writeContextFile(managedContent) {
38
38
  const existing = fs.readFileSync(contextFile, "utf8");
39
39
  if (existing.includes("<!-- phren-managed -->")) {
40
40
  const startIdx = existing.indexOf("<!-- phren-managed -->");
41
- const endIdx = existing.indexOf("<!-- phren-managed -->");
41
+ const endIdx = existing.indexOf("<!-- phren-managed -->", startIdx + "<!-- phren-managed -->".length);
42
42
  const before = startIdx > 0 ? existing.slice(0, startIdx).trimEnd() : "";
43
43
  const after = endIdx !== -1 ? existing.slice(endIdx + "<!-- phren-managed -->".length).trimStart() : "";
44
44
  const parts = [before, wrapped, after].filter(Boolean);