@pharaoh-so/mcp 0.3.60 → 0.3.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.mcp.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "pharaoh": {
4
4
  "command": "npx",
5
5
  "args": [
6
- "@pharaoh-so/mcp@0.3.60"
6
+ "@pharaoh-so/mcp@0.3.61"
7
7
  ]
8
8
  }
9
9
  }
package/CHANGELOG.md CHANGED
@@ -26,6 +26,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
26
26
  - The `pharaoh` skill's connection URL is `https://mcp.pharaoh.so/mcp` instead of `/sse`. The skill also carries the guidance that was only in the server instructions: one Pharaoh call answers what grep and file reads take many turns to piece together, load Pharaoh's tools before the first code search when your client defers them, summarize a block marked "Trace view available" instead of reproducing it, and check related repos when a change touches shared interfaces.
27
27
  - The server instructions state facts: what Pharaoh maps, which tool answers which question, what the graph leaves to grep, how repos are mapped and what each account tool does. Asking Pharaoh first, not re-checking its answers with grep, surfacing and crediting its ★ blocks and the rest of that guidance now live in the `pharaoh` skill. Claude Code and OpenClaw list the skill in every session and load its body only when they use it. The Grep hook and the optional `~/.claude/CLAUDE.md` line are unchanged and apply from the start of a session.
28
28
 
29
+ ## [0.3.61] - 2026-11-05
30
+
31
+ ### Changed
32
+ - Eligible project memory pilot setup recommends quiet reminders and remembers declines through upgrades. The normal authenticated MCP connection provides memory when hooks are disabled or unavailable.
33
+
34
+ ### Added
35
+ - Optional pilot worktree cues suggest graph tools for structural Bash searches and flag local edits that differ from a previously consulted codebase map revision.
36
+
29
37
  ## [0.3.60] - 2026-11-05
30
38
 
31
39
  ### Added
package/dist/helpers.d.ts CHANGED
@@ -5,7 +5,7 @@ export declare const NPX_COMMAND = "npx @pharaoh-so/mcp";
5
5
  /** Write one or more lines to stderr. */
6
6
  export declare function printLines(...lines: string[]): void;
7
7
  /**
8
- * Ask a yes/no question that defaults to yes: Enter, `y` or `yes` is yes; `n`
8
+ * Ask a yes/no question. Enter uses the default; `y` or `yes` is yes; `n`
9
9
  * or `no` is no; anything else asks again. Ctrl-C exits (130). On a terminal,
10
10
  * keys pressed before the question showed (an Enter while sign-in waited) are
11
11
  * dropped, so only a key pressed after reading it answers.
@@ -13,11 +13,12 @@ export declare function printLines(...lines: string[]): void;
13
13
  * @param question - The prompt, ending in something like `[Y/n] `.
14
14
  * @param input - Where the answer is read (defaults to stdin).
15
15
  * @param output - Where the prompt is written (defaults to stderr, like every other line).
16
+ * @param defaultAnswer - The answer for Enter (defaults to yes).
16
17
  * @returns The answer, or null when input ended before one.
17
18
  */
18
19
  export declare function askYesNo(question: string, input?: NodeJS.ReadableStream & {
19
20
  isTTY?: boolean;
20
- }, output?: NodeJS.WritableStream): Promise<boolean | null>;
21
+ }, output?: NodeJS.WritableStream, defaultAnswer?: boolean): Promise<boolean | null>;
21
22
  /** Parse CLI arguments. */
22
23
  export declare function parseArgs(argv?: string[]): {
23
24
  server: string;
package/dist/helpers.js CHANGED
@@ -15,7 +15,7 @@ export function printLines(...lines) {
15
15
  /** How long to read and drop input that was typed before a question showed. */
16
16
  const TYPE_AHEAD_MS = 50;
17
17
  /**
18
- * Ask a yes/no question that defaults to yes: Enter, `y` or `yes` is yes; `n`
18
+ * Ask a yes/no question. Enter uses the default; `y` or `yes` is yes; `n`
19
19
  * or `no` is no; anything else asks again. Ctrl-C exits (130). On a terminal,
20
20
  * keys pressed before the question showed (an Enter while sign-in waited) are
21
21
  * dropped, so only a key pressed after reading it answers.
@@ -23,9 +23,10 @@ const TYPE_AHEAD_MS = 50;
23
23
  * @param question - The prompt, ending in something like `[Y/n] `.
24
24
  * @param input - Where the answer is read (defaults to stdin).
25
25
  * @param output - Where the prompt is written (defaults to stderr, like every other line).
26
+ * @param defaultAnswer - The answer for Enter (defaults to yes).
26
27
  * @returns The answer, or null when input ended before one.
27
28
  */
28
- export async function askYesNo(question, input = process.stdin, output = process.stderr) {
29
+ export async function askYesNo(question, input = process.stdin, output = process.stderr, defaultAnswer = true) {
29
30
  if (input.isTTY)
30
31
  await dropTypedAhead(input);
31
32
  const rl = createInterface({ input, output });
@@ -39,7 +40,9 @@ export async function askYesNo(question, input = process.stdin, output = process
39
40
  // The iterator ends on EOF; rl.question never settles then.
40
41
  for await (const reply of rl) {
41
42
  const answer = reply.trim().toLowerCase();
42
- if (answer === "" || answer === "y" || answer === "yes")
43
+ if (answer === "")
44
+ return defaultAnswer;
45
+ if (answer === "y" || answer === "yes")
43
46
  return true;
44
47
  if (answer === "n" || answer === "no")
45
48
  return false;
@@ -96,7 +99,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
96
99
  return { server, logout };
97
100
  }
98
101
  export function printUsage() {
99
- printLines("Usage: pharaoh-mcp [options]", "", "Options:", " --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)", " --logout Clear credentials, MCP config, the Claude Code plugin and the CLAUDE.md line, then exit", " --clean Remove Pharaoh setup, including managed local project memory hooks", " --memory-hooks Install optional project memory reminders during interactive setup", " --disable-memory-hooks Remove Pharaoh's local project memory hooks", " --install-skills Reinstall the Claude Code plugin and OpenClaw skills", " --version, -v Print the version", " --help, -h Show this help", "", "Get started:", ` ${NPX_COMMAND}`, "");
102
+ printLines("Usage: pharaoh-mcp [options]", "", "Options:", " --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)", " --logout Clear credentials, MCP config, the Claude Code plugin and the CLAUDE.md line, then exit", " --clean Remove Pharaoh setup, including managed local project memory hooks", " --memory-hooks Accept project memory hooks during eligible interactive setup", " --disable-memory-hooks Remove Pharaoh's memory hooks and remember the choice", " --install-skills Reinstall the Claude Code plugin and OpenClaw skills", " --version, -v Print the version", " --help, -h Show this help", "", "Get started:", ` ${NPX_COMMAND}`, "");
100
103
  }
101
104
  /**
102
105
  * Validate that a server-supplied SSE URL shares the same origin as the configured server.
package/dist/index.js CHANGED
@@ -12,7 +12,8 @@
12
12
  * mode until device flow completes, then swap to SSE in
13
13
  * place — the stdio session never disconnects.
14
14
  */
15
- import { syncMemoryHooks } from "../project-memory/setup.mjs";
15
+ import { resetHookPreferences } from "../project-memory/preferences.mjs";
16
+ import { applyMemoryHookChoices, chooseMemoryHooks, syncMemoryHooks, } from "../project-memory/setup.mjs";
16
17
  import { pollForToken, printActivationPrompt, printAuthSuccess, requestDeviceCode, } from "./auth.js";
17
18
  import { AuthRequiredBackend, createSseBackend } from "./backend.js";
18
19
  import { deleteCredentials, readCredentials, writeCredentials, } from "./credentials.js";
@@ -44,6 +45,34 @@ function reportMemorySetup(options) {
44
45
  return false;
45
46
  }
46
47
  }
48
+ function clearMemoryHookChoices(disable) {
49
+ try {
50
+ resetHookPreferences(disable);
51
+ return true;
52
+ }
53
+ catch {
54
+ printLines("Pharaoh: could not update local project memory choices.");
55
+ return false;
56
+ }
57
+ }
58
+ async function setupMemoryHooks(credentials, clients, requested) {
59
+ try {
60
+ const decision = await chooseMemoryHooks({
61
+ credentials,
62
+ clients,
63
+ requested,
64
+ ask: (question, defaultAnswer) => process.stderr.isTTY
65
+ ? askYesNo(question, process.stdin, process.stderr, defaultAnswer)
66
+ : Promise.resolve(null),
67
+ });
68
+ const notices = applyMemoryHookChoices(decision, { credentials });
69
+ if (notices.length)
70
+ printLines(...notices);
71
+ }
72
+ catch {
73
+ printLines("Pharaoh: could not finish local project memory setup. MCP remains available.");
74
+ }
75
+ }
47
76
  async function main() {
48
77
  const args = process.argv.slice(2);
49
78
  if (args.includes("--help") || args.includes("-h")) {
@@ -68,7 +97,8 @@ async function main() {
68
97
  return;
69
98
  }
70
99
  if (args.includes("--disable-memory-hooks")) {
71
- if (!reportMemorySetup())
100
+ const saved = clearMemoryHookChoices(true);
101
+ if (!reportMemorySetup() || !saved)
72
102
  process.exitCode = 1;
73
103
  return;
74
104
  }
@@ -77,10 +107,11 @@ async function main() {
77
107
  if (args.includes("--clean")) {
78
108
  const { cleanCodexSetup, cleanSlate } = await import("./setup.js");
79
109
  const { uninstallClaudePlugin } = await import("./install-skills.js");
110
+ clearMemoryHookChoices(false);
111
+ reportMemorySetup();
80
112
  cleanSlate();
81
113
  uninstallClaudePlugin();
82
114
  cleanCodexSetup();
83
- reportMemorySetup();
84
115
  printLines("Pharaoh: local setup cleanup finished.");
85
116
  process.exit(0);
86
117
  }
@@ -90,10 +121,11 @@ async function main() {
90
121
  const { uninstallClaudePlugin } = await import("./install-skills.js");
91
122
  // Credentials first: a cleanup step that fails must never leave the tokens behind.
92
123
  deleteCredentials();
124
+ clearMemoryHookChoices(false);
125
+ reportMemorySetup();
93
126
  cleanSlate();
94
127
  uninstallClaudePlugin();
95
128
  cleanCodexSetup();
96
- reportMemorySetup();
97
129
  printLines("Pharaoh: credentials cleared and local setup cleanup finished.");
98
130
  process.exit(0);
99
131
  }
@@ -130,8 +162,10 @@ async function main() {
130
162
  if (runCodexSetup())
131
163
  clients.push("codex");
132
164
  }
133
- if (!clients.length)
165
+ if (!clients.length) {
166
+ await setupMemoryHooks(newCreds, clients, args.includes("--memory-hooks"));
134
167
  process.exit(1);
168
+ }
135
169
  if (clients.includes("claude-code")) {
136
170
  process.stderr.write(" Setting up...");
137
171
  const { keptCommands } = runInstallSkills(undefined, { silent: true });
@@ -144,7 +178,7 @@ async function main() {
144
178
  if (claudeMd)
145
179
  printLines(claudeMd);
146
180
  }
147
- reportMemorySetup({ credentials: newCreds, clients, enable: args.includes("--memory-hooks") });
181
+ await setupMemoryHooks(newCreds, clients, args.includes("--memory-hooks"));
148
182
  // The registration is pinned to this version; upgrading is a deliberate
149
183
  // re-run. Interactive only, and quiet when the registry can't be reached.
150
184
  const latest = await fetchLatestVersion();
@@ -103,6 +103,69 @@ export function routeGrep(input, signedIn, redirected) {
103
103
  ].join(" ");
104
104
  }
105
105
 
106
+ /**
107
+ * Match one plain Bash rg invocation with an explicit declaration or call pattern.
108
+ * @param {unknown} command
109
+ * @returns {{ symbol: string, tool: "search_functions" | "get_blast_radius" } | undefined}
110
+ */
111
+ export function classifyBashGrep(command) {
112
+ if (typeof command !== "string" || command.length > 1024 || /[\r\n]/.test(command)) return;
113
+ const source = command.trim();
114
+ // Only the quoting and flags needed by a simple rg search. Unrecognized
115
+ // shell syntax falls through, including expansions, redirects and pipelines.
116
+ const word = /(?:'([^']*)'|"([^"$`\\]*)"|([A-Za-z0-9_./:-]+))(?:[ \t]+|$)/gy;
117
+ const args = [];
118
+ while (word.lastIndex < source.length) {
119
+ const match = word.exec(source);
120
+ if (!match) return;
121
+ args.push(match[1] ?? match[2] ?? match[3]);
122
+ }
123
+ if (args.shift() !== "rg") return;
124
+
125
+ const scope = {};
126
+ while (args[0]?.startsWith("-")) {
127
+ const option = args.shift();
128
+ if (option === "--") break;
129
+ if (["-n", "--line-number", "--no-heading", "-S", "--smart-case"].includes(option ?? ""))
130
+ continue;
131
+ const value = args.shift();
132
+ if (!value) return;
133
+ if ((option === "-g" || option === "--glob") && scope.glob === undefined) scope.glob = value;
134
+ else if ((option === "-t" || option === "--type") && scope.type === undefined)
135
+ scope.type = value;
136
+ else return;
137
+ }
138
+ const [pattern, path] = args;
139
+ const globExtensions = scope.glob?.match(/^\*\.(?:\{([a-z]+(?:,[a-z]+)*)\}|([a-z]+))$/i);
140
+ if (
141
+ !pattern ||
142
+ args.length > 2 ||
143
+ (path &&
144
+ (!/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(path) ||
145
+ path.startsWith("/") ||
146
+ path.split("/").includes(".."))) ||
147
+ [scope.glob, path].some((value) =>
148
+ /(?:^|[/.\-_])(?:config|settings|env|docs|documentation|fixtures|snapshots)(?:[/.\-_]|$)/i.test(
149
+ value ?? "",
150
+ ),
151
+ ) ||
152
+ (scope.glob &&
153
+ (!globExtensions ||
154
+ !(globExtensions[1] ?? globExtensions[2])
155
+ .toLowerCase()
156
+ .split(",")
157
+ .every((extension) => MAPPED_EXTENSIONS.has(extension)))) ||
158
+ !searchesMappedCode({ type: scope.type }) ||
159
+ !searchesMappedCode({ path })
160
+ )
161
+ return;
162
+ const symbol = symbolSearched(pattern);
163
+ if (!symbol || symbol.length > 80) return;
164
+ if (/\\?\($/.test(pattern)) return { symbol, tool: "get_blast_radius" };
165
+ if (/^(?:(?:export|default|async) +)*(?:function|class|def|interface|type|enum)\b/.test(pattern))
166
+ return { symbol, tool: "search_functions" };
167
+ }
168
+
106
169
  /** Hook entry point: read the payload from stdin, reply with a PreToolUse deny or nothing. */
107
170
  function main() {
108
171
  try {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pharaoh-so/mcp",
3
3
  "mcpName": "so.pharaoh/pharaoh",
4
- "version": "0.3.60",
4
+ "version": "0.3.61",
5
5
  "description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for coding agents. Enables Claude Code in headless environments (VPS, SSH, CI) via device flow auth.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -12,7 +12,7 @@ import {
12
12
  import { dirname, join, relative } from "node:path";
13
13
 
14
14
  const LIMIT = 256 * 1024;
15
- export const MEMORY_RUNTIME_FILES = [
15
+ export const LEGACY_MEMORY_RUNTIME_FILES = [
16
16
  "execute-turn.mjs",
17
17
  "hook-config.mjs",
18
18
  "hook-events.mjs",
@@ -25,6 +25,13 @@ export const MEMORY_RUNTIME_FILES = [
25
25
  "runtime/package.json",
26
26
  "runtime/refresh.js",
27
27
  ];
28
+ export const MEMORY_RUNTIME_FILES = [
29
+ ...LEGACY_MEMORY_RUNTIME_FILES,
30
+ "bundle.mjs",
31
+ "graph-cues.mjs",
32
+ "preferences.mjs",
33
+ "route-grep.mjs",
34
+ ];
28
35
 
29
36
  function digest(files) {
30
37
  return createHash("sha256").update(JSON.stringify(files)).digest("hex");
@@ -51,7 +58,7 @@ export function packagedMemoryBundle(packageRoot = new URL("../", import.meta.ur
51
58
  } else {
52
59
  const location = name.startsWith("runtime/")
53
60
  ? new URL(`dist/${name.slice(8)}`, packageRoot)
54
- : new URL(`project-memory/${name}`, packageRoot);
61
+ : new URL(`${name === "route-grep.mjs" ? "hooks" : "project-memory"}/${name}`, packageRoot);
55
62
  try {
56
63
  bytes = readFileSync(location);
57
64
  } catch {
@@ -0,0 +1,268 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { gitBefore, sessionKey, updateWorktreeCueState } from "./local.mjs";
5
+
6
+ const MAP_TOOL = "mcp__pharaoh__get_codebase_map";
7
+ const SHA = /^(?!0{40}$)[a-f0-9]{40}$/i;
8
+ const DIGEST = /^[a-f0-9]{64}$/;
9
+ const MAPPED_FILE = /\.(?:[cm]?[jt]s|[jt]sx|py)$/i;
10
+ const STATE_TTL = 12 * 60 * 60 * 1000;
11
+ const MAX_SCOPES = 8;
12
+ const MAX_SEARCHES = 2;
13
+ const MAX_WARNED = 8;
14
+ const MAX_EDIT_PATHS = 20;
15
+ const MAX_SOURCE_BYTES = 512 * 1024;
16
+
17
+ function digest(value) {
18
+ return createHash("sha256").update(value).digest("hex");
19
+ }
20
+
21
+ function responseText(value) {
22
+ if (typeof value === "string") {
23
+ if (/^\s*[[{]/.test(value)) {
24
+ try {
25
+ return responseText(JSON.parse(value));
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+ return [value];
31
+ }
32
+ if (value?.isError || value?.is_error) return [];
33
+ const content = Array.isArray(value) ? value : value?.content;
34
+ return Array.isArray(content)
35
+ ? content.flatMap((block) =>
36
+ block?.type === "text" && typeof block.text === "string" ? [block.text] : [],
37
+ )
38
+ : [];
39
+ }
40
+
41
+ function mappedRevision(input, target, repos) {
42
+ const requested = input.tool_input?.repo;
43
+ if (typeof requested !== "string") return;
44
+ const full = target.repo.toLowerCase();
45
+ const short = full.split("/")[1];
46
+ if (requested.toLowerCase() !== full) {
47
+ if (requested.toLowerCase() !== short) return;
48
+ const matches = new Set(
49
+ repos
50
+ .filter((repo) => typeof repo === "string")
51
+ .map((repo) => repo.toLowerCase())
52
+ .filter((repo) => repo.split("/").at(-1) === short),
53
+ );
54
+ if (matches.size !== 1 || !matches.has(full)) return;
55
+ }
56
+ const maps = responseText(input.tool_response).filter((text) =>
57
+ text
58
+ .split(/\r?\n/)
59
+ .some((line) =>
60
+ [`# ${short} codebase map`, `# ${full} codebase map`].includes(line.toLowerCase()),
61
+ ),
62
+ );
63
+ if (maps.length !== 1) return;
64
+ const revisions = [...maps[0].matchAll(/^Indexed commit: ([^\r\n]+)\r?$/gm)];
65
+ const revision = revisions.length === 1 && revisions[0][1];
66
+ if (revision && SHA.test(revision)) return revision.toLowerCase();
67
+ }
68
+
69
+ function editedPaths(input, client, root) {
70
+ let paths;
71
+ if (client === "codex") {
72
+ if (input.tool_name !== "apply_patch") return [];
73
+ const patch = input.tool_input?.command;
74
+ const succeeded = responseText(input.tool_response).some((text) =>
75
+ /^Success\. Updated the following files:/m.test(text),
76
+ );
77
+ if (!succeeded || typeof patch !== "string" || !patch.startsWith("*** Begin Patch\n"))
78
+ return [];
79
+ paths = [
80
+ ...patch.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm),
81
+ ].map((match) => match[1]);
82
+ } else {
83
+ if (!["Edit", "Write", "MultiEdit"].includes(input.tool_name)) return [];
84
+ paths = [input.tool_input?.file_path];
85
+ }
86
+ if (paths.length > MAX_EDIT_PATHS) return [];
87
+ const inCheckout = paths.flatMap((path) => {
88
+ if (typeof path !== "string" || path.length > 512 || !MAPPED_FILE.test(path)) return [];
89
+ const absolute = resolve(input.cwd ?? root, path);
90
+ let parent;
91
+ try {
92
+ parent = realpathSync(dirname(absolute));
93
+ } catch {
94
+ return [];
95
+ }
96
+ const local = relative(root, join(parent, basename(absolute)));
97
+ if (!local || local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local)) return [];
98
+ return [local];
99
+ });
100
+ return [...new Set(inCheckout)];
101
+ }
102
+
103
+ function worktreeBlob(path, remaining) {
104
+ let fd;
105
+ try {
106
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
107
+ } catch (error) {
108
+ return error.code === "ENOENT" ? null : undefined;
109
+ }
110
+ try {
111
+ const before = fstatSync(fd);
112
+ if (!before.isFile() || before.size > remaining) return;
113
+ const bytes = Buffer.alloc(before.size);
114
+ if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) return;
115
+ const after = fstatSync(fd);
116
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs) return;
117
+ return {
118
+ size: bytes.length,
119
+ oid: createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex"),
120
+ };
121
+ } finally {
122
+ closeSync(fd);
123
+ }
124
+ }
125
+
126
+ function changedPaths(root, revision, paths, deadline, git) {
127
+ const comparisonDeadline = Math.min(deadline, performance.now() + 700);
128
+ const run = (...args) =>
129
+ git(
130
+ root,
131
+ Math.min(comparisonDeadline, performance.now() + 300),
132
+ "--literal-pathspecs",
133
+ ...args,
134
+ );
135
+ try {
136
+ // Git reads tree and index metadata only. Hash raw regular-file bytes
137
+ // locally so checkout attributes cannot invoke an executable filter.
138
+ const tree = run("ls-tree", "-rz", revision, "--", ...paths);
139
+ const indexed = new Map(
140
+ tree
141
+ .split("\0")
142
+ .filter(Boolean)
143
+ .map((entry) => {
144
+ const tab = entry.indexOf("\t");
145
+ const [mode, type, oid] = entry.slice(0, tab).split(" ");
146
+ return [
147
+ entry.slice(tab + 1),
148
+ /^100(?:644|755)$/.test(mode) && type === "blob" && SHA.test(oid) ? oid : null,
149
+ ];
150
+ }),
151
+ );
152
+ const added = paths.filter((path) => !indexed.has(path));
153
+ const visible = added.length
154
+ ? new Set(
155
+ run("ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ...added).split(
156
+ "\0",
157
+ ),
158
+ )
159
+ : new Set();
160
+ let remaining = MAX_SOURCE_BYTES;
161
+ const changed = [];
162
+ for (const path of paths) {
163
+ if (performance.now() >= comparisonDeadline) return [];
164
+ const previous = indexed.get(path);
165
+ if (!previous && !visible.has(path)) continue;
166
+ const local = worktreeBlob(join(root, path), remaining);
167
+ if (local === undefined) continue;
168
+ remaining -= local?.size ?? 0;
169
+ if ((local?.oid ?? undefined) !== previous) changed.push(digest(path));
170
+ }
171
+ if (performance.now() >= comparisonDeadline) return [];
172
+ return changed;
173
+ } catch {
174
+ // A missing revision or failed comparison leaves the cue available for a later edit.
175
+ return [];
176
+ }
177
+ }
178
+
179
+ function validEntry(entry, now) {
180
+ return (
181
+ DIGEST.test(entry?.key ?? "") &&
182
+ Number.isFinite(entry.expires) &&
183
+ entry.expires > now &&
184
+ entry.expires <= now + STATE_TTL &&
185
+ (entry.revision === undefined || SHA.test(entry.revision)) &&
186
+ Array.isArray(entry.searches) &&
187
+ entry.searches.length <= MAX_SEARCHES &&
188
+ entry.searches.every((item) => typeof item === "string" && DIGEST.test(item)) &&
189
+ Array.isArray(entry.warned) &&
190
+ entry.warned.length <= MAX_WARNED &&
191
+ entry.warned.every((item) => typeof item === "string" && DIGEST.test(item))
192
+ );
193
+ }
194
+
195
+ /** Process local hook evidence without dispatching tools or retaining their payloads. */
196
+ export function worktreeCue(
197
+ input,
198
+ {
199
+ client,
200
+ target,
201
+ binding,
202
+ repos = [],
203
+ stateDirectory,
204
+ structuralSearch,
205
+ deadline = Infinity,
206
+ git = gitBefore,
207
+ now = Date.now(),
208
+ },
209
+ ) {
210
+ const session = sessionKey(client, input.session_id);
211
+ if (!session || !binding || performance.now() >= deadline) return;
212
+ const event = input.hook_event_name;
213
+ const search = event === "PreToolUse" && structuralSearch;
214
+ const revision =
215
+ event === "PostToolUse" && input.tool_name === MAP_TOOL && mappedRevision(input, target, repos);
216
+ const paths = event === "PostToolUse" ? editedPaths(input, client, target.root) : [];
217
+ if (!search && !revision && !paths.length) return;
218
+ const key = digest(`${binding}\0${session}\0${target.root}\0${target.repo.toLowerCase()}`);
219
+
220
+ const context = updateWorktreeCueState(
221
+ stateDirectory,
222
+ (state) => {
223
+ if (state !== undefined && (state?.version !== 1 || !Array.isArray(state.entries))) return;
224
+ const entries = (state?.entries ?? [])
225
+ .filter((entry) => validEntry(entry, now))
226
+ .slice(0, MAX_SCOPES);
227
+ const existing = entries.find((entry) => entry.key === key);
228
+ const current = existing ? { ...existing } : { key, searches: [], warned: [] };
229
+ let notice;
230
+ if (revision) {
231
+ if (current.revision === revision) return;
232
+ current.revision = revision;
233
+ current.warned = [];
234
+ } else if (search) {
235
+ if (current.revision || current.searches.length >= MAX_SEARCHES) return;
236
+ const head = git(
237
+ target.root,
238
+ Math.min(deadline, performance.now() + 300),
239
+ "rev-parse",
240
+ "HEAD",
241
+ );
242
+ if (!SHA.test(head)) return;
243
+ const requested = digest(`${head}\0${search.tool}\0${search.symbol}`);
244
+ if (current.searches.includes(requested)) return;
245
+ current.searches = [...current.searches, requested];
246
+ notice = `For structural evidence on ${search.symbol}, try Pharaoh's ${search.tool}. The graph covers the indexed default branch. Use the current worktree for local changes.`;
247
+ } else {
248
+ if (!current.revision || current.warned.length >= MAX_WARNED) return;
249
+ const changed = changedPaths(target.root, current.revision, paths, deadline, git);
250
+ const unseen = changed.filter((path) => !current.warned.includes(path));
251
+ if (!unseen.length) return;
252
+ current.warned = [...current.warned, ...unseen].slice(0, MAX_WARNED);
253
+ notice = `The Pharaoh evidence consulted earlier reflects the indexed default branch at ${current.revision.slice(0, 12)}. This edited scope differs from that revision. Check the local diff or source before relying on that evidence.`;
254
+ }
255
+ if (performance.now() >= deadline) return;
256
+ current.expires = now + STATE_TTL;
257
+ return {
258
+ state: {
259
+ version: 1,
260
+ entries: [current, ...entries.filter((entry) => entry.key !== key)].slice(0, MAX_SCOPES),
261
+ },
262
+ context: notice,
263
+ };
264
+ },
265
+ now,
266
+ );
267
+ if (context) return { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
268
+ }
@@ -7,6 +7,7 @@ const EVENTS = {
7
7
  };
8
8
  const GLOBAL_MARKER = "# pharaoh-project-memory:user-v1";
9
9
  const LEGACY_MARKER = / # pharaoh-project-memory:[a-f0-9]{64}$/;
10
+ const TOOL_EVENTS = ["PreToolUse", "PostToolUse"];
10
11
 
11
12
  function isObject(value) {
12
13
  return value && typeof value === "object" && !Array.isArray(value);
@@ -33,14 +34,14 @@ function matchesUserHook(value) {
33
34
  }
34
35
 
35
36
  /** Keep unrelated handlers even when they share an event group with ours. */
36
- function replaceHooks(config, client, matches, command, installEvents = EVENTS[client]) {
37
+ function replaceHooks(config, events, matches, additions) {
37
38
  if (!isObject(config) || (config.hooks !== undefined && !isObject(config.hooks))) {
38
39
  throw new Error("The existing hooks configuration is not a JSON object.");
39
40
  }
40
41
  const hooks = { ...config.hooks };
41
- for (const event of EVENTS[client]) {
42
+ for (const event of events) {
42
43
  const current = hooks[event];
43
- const install = command && installEvents.includes(event);
44
+ const install = additions[event];
44
45
  if (!install && (current === undefined || (Array.isArray(current) && !current.length)))
45
46
  continue;
46
47
  if (
@@ -54,7 +55,7 @@ function replaceHooks(config, client, matches, command, installEvents = EVENTS[c
54
55
  if (rest.length === group.hooks.length) return [group];
55
56
  return rest.length ? [{ ...group, hooks: rest }] : [];
56
57
  });
57
- if (install) kept.push({ hooks: [{ type: "command", command, timeout: 3 }] });
58
+ if (install) kept.push(install);
58
59
  if (kept.length) hooks[event] = kept;
59
60
  else delete hooks[event];
60
61
  }
@@ -63,24 +64,50 @@ function replaceHooks(config, client, matches, command, installEvents = EVENTS[c
63
64
  : { ...config, hooks };
64
65
  }
65
66
 
67
+ function group(command, matcher) {
68
+ return { ...(matcher && { matcher }), hooks: [{ type: "command", command, timeout: 3 }] };
69
+ }
70
+
66
71
  export function updateHooks(config, client, target, remove, hookPath, node = process.execPath) {
67
72
  const marker = legacyMarker(client, target.root);
68
73
  if (!remove && !hookPath) throw new Error("A pinned project memory hook is required.");
69
74
  const command = remove
70
75
  ? null
71
76
  : `${[node, hookPath, client, target.root, target.repo].map(shellWord).join(" ")} ${marker}`;
72
- return replaceHooks(config, client, matchesLegacyHook(client, target.root), command);
77
+ const events = EVENTS[client];
78
+ const additions = command
79
+ ? Object.fromEntries(events.map((event) => [event, group(command)]))
80
+ : {};
81
+ return replaceHooks(config, events, matchesLegacyHook(client, target.root), additions);
73
82
  }
74
83
 
75
- export function updateUserHooks(config, client, hookPath, node = process.execPath) {
76
- const command = hookPath
77
- ? `${[node, hookPath, client, "--user"].map(shellWord).join(" ")} ${GLOBAL_MARKER}`
78
- : null;
79
- return replaceHooks(config, client, matchesUserHook, command, ["UserPromptSubmit"]);
84
+ export function updateUserHooks(
85
+ config,
86
+ client,
87
+ hookPath,
88
+ node = process.execPath,
89
+ graphCues = false,
90
+ ) {
91
+ const additions = {};
92
+ if (hookPath) {
93
+ const command = (mode) =>
94
+ `${[node, hookPath, client, mode].map(shellWord).join(" ")} ${GLOBAL_MARKER}`;
95
+ additions.UserPromptSubmit = group(command("--user"));
96
+ if (graphCues) {
97
+ const graph = command("--graph");
98
+ const edit = client === "codex" ? "apply_patch" : "Edit|Write|MultiEdit";
99
+ additions.PreToolUse = group(graph, "^Bash$");
100
+ additions.PostToolUse = group(graph, `^(?:${edit}|mcp__pharaoh__get_codebase_map)$`);
101
+ }
102
+ }
103
+ const toolEvents = TOOL_EVENTS.filter(
104
+ (event) => additions[event] || hasHooks(config, [event], matchesUserHook),
105
+ );
106
+ return replaceHooks(config, [...EVENTS[client], ...toolEvents], matchesUserHook, additions);
80
107
  }
81
108
 
82
- function hasHooks(config, client, matches) {
83
- return EVENTS[client].some((event) => {
109
+ function hasHooks(config, events, matches) {
110
+ return events.some((event) => {
84
111
  const groups = config?.hooks?.[event];
85
112
  return (
86
113
  Array.isArray(groups) &&
@@ -93,9 +120,9 @@ function hasHooks(config, client, matches) {
93
120
  }
94
121
 
95
122
  export function hasLegacyHooks(config, client, root) {
96
- return hasHooks(config, client, matchesLegacyHook(client, root));
123
+ return hasHooks(config, EVENTS[client], matchesLegacyHook(client, root));
97
124
  }
98
125
 
99
126
  export function hasUserHooks(config, client) {
100
- return hasHooks(config, client, matchesUserHook);
127
+ return hasHooks(config, [...EVENTS[client], ...TOOL_EVENTS], matchesUserHook);
101
128
  }
@@ -52,7 +52,7 @@ export function memoryReminder(repo) {
52
52
  return {
53
53
  hookSpecificOutput: {
54
54
  hookEventName: "UserPromptSubmit",
55
- additionalContext: `For work in ${repo}, use pharaoh_memory_recall if prior decisions may matter. Use pharaoh_memory_expand if you need the full record, and pharaoh_memory_capture for one supported candidate when the task permits. Mention memory only when it changes a decision.`,
55
+ additionalContext: `For work in ${repo}, call pharaoh_memory_recall before source inspection when prior decisions could matter. Use specific, non-sensitive terms. Use pharaoh_memory_expand only for needed evidence and pharaoh_memory_capture for one supported candidate when the user permits a write. Mention memory only if it changes a decision.`,
56
56
  },
57
57
  };
58
58
  }
@@ -252,6 +252,35 @@ function recoverLock(path, now) {
252
252
  return true;
253
253
  }
254
254
 
255
+ function acquireLocalLock(directory, path, now) {
256
+ return (
257
+ acquireLock(directory, path, now) ??
258
+ (recoverLock(path, now) ? acquireLock(directory, path, now) : undefined)
259
+ );
260
+ }
261
+
262
+ /** Update only the bounded worktree cue metadata. Concurrent events can skip. */
263
+ export function updateWorktreeCueState(directory, update, now = Date.now()) {
264
+ makePrivateDirectory(directory);
265
+ const release = acquireLocalLock(directory, join(directory, "worktree-cues.lock"), now);
266
+ if (!release) return;
267
+ try {
268
+ const path = join(directory, "worktree-cues.json");
269
+ const file = lstatSync(path, { throwIfNoEntry: false });
270
+ if (file && file.uid !== process.getuid?.()) return;
271
+ const before = file ? readJson(path) : undefined;
272
+ if (file && before === undefined) return;
273
+ const change = update(before);
274
+ if (!change) return;
275
+ const data = JSON.stringify(change.state);
276
+ if (Buffer.byteLength(data) > 8192) return;
277
+ atomicWrite(path, data);
278
+ return change.context;
279
+ } finally {
280
+ release();
281
+ }
282
+ }
283
+
255
284
  /** Serialize local state transitions only; network operations never hold this lock. */
256
285
  export function turnStore(home = homedir(), now = Date.now) {
257
286
  const directory = join(home, ".pharaoh", "project-memory-turns-v2");
@@ -276,9 +305,7 @@ export function turnStore(home = homedir(), now = Date.now) {
276
305
  if (!TOKEN.test(key)) return;
277
306
  const path = join(directory, `${key}.lock`);
278
307
  const time = now();
279
- const release =
280
- acquireLock(directory, path, time) ??
281
- (recoverLock(path, time) ? acquireLock(directory, path, time) : undefined);
308
+ const release = acquireLocalLock(directory, path, time);
282
309
  if (!release) return onBusy?.();
283
310
  try {
284
311
  const stored = readJson(overlapPath(key));
@@ -4,6 +4,8 @@ import { handleEvent, memoryReminder, skipMemoryPrompt } from "./hook-events.mjs
4
4
  import { readStdin } from "./local.mjs";
5
5
  import {
6
6
  activeMemoryRuntime,
7
+ memoryClientRoot,
8
+ projectMemorySession,
7
9
  readMemoryCredentials,
8
10
  resolveMemoryHookTarget,
9
11
  } from "./user-target.mjs";
@@ -16,19 +18,49 @@ try {
16
18
  const [client, root, repo] = process.argv.slice(2);
17
19
  const input = await readStdin(1024 * 1024);
18
20
  let result;
19
- if (root === "--user") {
21
+ if (root === "--user" || root === "--graph") {
22
+ const reminder = root === "--user";
23
+ const event = input?.hook_event_name;
24
+ if (
25
+ reminder
26
+ ? event !== "UserPromptSubmit" || skipMemoryPrompt(input.prompt)
27
+ : event !== "PreToolUse" && event !== "PostToolUse"
28
+ )
29
+ process.exit(0);
30
+ let structuralSearch;
31
+ if (event === "PreToolUse") {
32
+ if (input.tool_name !== "Bash") process.exit(0);
33
+ const { classifyBashGrep } = await import("./route-grep.mjs");
34
+ structuralSearch = classifyBashGrep(input.tool_input?.command);
35
+ if (!structuralSearch) process.exit(0);
36
+ }
20
37
  const runtime = dirname(fileURLToPath(import.meta.url));
21
38
  if (!activeMemoryRuntime(client, runtime)) process.exit(0);
22
- if (input?.hook_event_name !== "UserPromptSubmit" || skipMemoryPrompt(input.prompt))
23
- process.exit(0);
39
+ const { readHookPreferences } = await import("./preferences.mjs");
40
+ const choice = readHookPreferences()[client];
41
+ if (choice?.memory === false || (!reminder && choice?.graph === false)) process.exit(0);
42
+ const credentials = await readMemoryCredentials();
24
43
  const eligible = await resolveMemoryHookTarget(
25
44
  client,
26
45
  input?.cwd,
27
- await readMemoryCredentials(),
28
- true,
46
+ credentials,
47
+ reminder,
29
48
  deadline,
30
49
  );
31
- if (eligible && performance.now() < deadline) result = memoryReminder(eligible.repo);
50
+ if (!eligible || performance.now() >= deadline) process.exit(0);
51
+ if (reminder) result = memoryReminder(eligible.repo);
52
+ else {
53
+ const { worktreeCue } = await import("./graph-cues.mjs");
54
+ result = worktreeCue(input, {
55
+ client,
56
+ target: eligible,
57
+ binding: projectMemorySession(credentials),
58
+ repos: credentials?.repos,
59
+ stateDirectory: memoryClientRoot(),
60
+ structuralSearch,
61
+ deadline,
62
+ });
63
+ }
32
64
  } else {
33
65
  result = handleEvent(input, {
34
66
  client,
@@ -38,7 +70,7 @@ try {
38
70
  deadline,
39
71
  });
40
72
  }
41
- if (result) process.stdout.write(JSON.stringify(result));
73
+ if (result && performance.now() < deadline) process.stdout.write(JSON.stringify(result));
42
74
  } catch {
43
75
  // Project memory never blocks ordinary work or opens a continuation.
44
76
  } finally {
@@ -0,0 +1,12 @@
1
+ export type MemoryHookClient = "claude-code" | "codex";
2
+ export type HookPreferences = Partial<
3
+ Record<MemoryHookClient, { memory?: boolean; graph?: boolean }>
4
+ >;
5
+ export const MEMORY_HOOK_CLIENTS: readonly MemoryHookClient[];
6
+ export function readHookPreferences(home?: string): HookPreferences;
7
+ export function writeHookPreferences(
8
+ clients: HookPreferences,
9
+ home?: string,
10
+ expected?: HookPreferences,
11
+ ): void;
12
+ export function resetHookPreferences(disable: boolean, home?: string): void;
@@ -0,0 +1,70 @@
1
+ import { mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { privateMemoryPath } from "./bundle.mjs";
5
+ import { atomicWrite } from "./local.mjs";
6
+ import { memoryClientRoot } from "./user-target.mjs";
7
+
8
+ export const MEMORY_HOOK_CLIENTS = ["claude-code", "codex"];
9
+ const FILE = "hook-preferences-v1.json";
10
+
11
+ export function readHookPreferences(home = homedir()) {
12
+ const root = memoryClientRoot(home);
13
+ const path = join(root, FILE);
14
+ try {
15
+ privateMemoryPath(root, true);
16
+ if (privateMemoryPath(path, false).size > 1024)
17
+ throw new Error("Project memory preferences are too large.");
18
+ const { version, clients } = JSON.parse(readFileSync(path, "utf8"));
19
+ if (version !== 1 || !clients || typeof clients !== "object" || Array.isArray(clients))
20
+ throw new Error("Project memory preferences have an unknown format.");
21
+ for (const client of MEMORY_HOOK_CLIENTS) {
22
+ if (!Object.hasOwn(clients, client)) continue;
23
+ const choice = clients[client];
24
+ if (
25
+ !choice ||
26
+ typeof choice !== "object" ||
27
+ Array.isArray(choice) ||
28
+ ["memory", "graph"].some(
29
+ (key) => Object.hasOwn(choice, key) && typeof choice[key] !== "boolean",
30
+ )
31
+ )
32
+ throw new Error("Project memory preferences contain an invalid choice.");
33
+ }
34
+ return clients;
35
+ } catch (error) {
36
+ if (error.code === "ENOENT") return {};
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ export function writeHookPreferences(clients, home = homedir(), expected) {
42
+ const previous = readHookPreferences(home);
43
+ if (expected && JSON.stringify(expected) !== JSON.stringify(previous))
44
+ throw new Error("Project memory preferences changed while setup was asking.");
45
+ if (JSON.stringify(clients) === JSON.stringify(previous)) return;
46
+ const root = memoryClientRoot(home);
47
+ const path = join(root, FILE);
48
+ if (Object.keys(clients).length === 0) {
49
+ rmSync(path, { force: true });
50
+ return;
51
+ }
52
+ mkdirSync(root, { recursive: true, mode: 0o700 });
53
+ privateMemoryPath(root, true);
54
+ atomicWrite(path, `${JSON.stringify({ version: 1, clients })}\n`);
55
+ }
56
+
57
+ export function resetHookPreferences(disable, home = homedir()) {
58
+ if (typeof process.getuid !== "function") return;
59
+ const choices = readHookPreferences(home);
60
+ for (const client of MEMORY_HOOK_CLIENTS) {
61
+ const choice = choices[client];
62
+ if (disable) choices[client] = { memory: false, graph: false };
63
+ else if (choice) {
64
+ if (choice.memory === true) delete choice.memory;
65
+ if (choice.graph === true) delete choice.graph;
66
+ if (Object.keys(choice).length === 0) delete choices[client];
67
+ }
68
+ }
69
+ writeHookPreferences(choices, home);
70
+ }
@@ -1,14 +1,40 @@
1
- export function syncMemoryHooks(options?: {
2
- credentials?: {
3
- sse_url: string;
4
- project_memory?: Array<{ repo: string; revision: string | null }>;
5
- project_memory_status?: "unavailable";
6
- };
7
- clients?: string[];
8
- enable?: boolean;
1
+ type MemoryCredentials = {
2
+ sse_url: string;
3
+ project_memory?: Array<{ repo: string; revision: string | null }>;
4
+ project_memory_status?: "unavailable";
5
+ };
6
+
7
+ type MemoryHookChoices = {
8
+ accepted: string[];
9
+ declined: string[];
10
+ graphClients: string[];
11
+ notices: string[];
12
+ removeUnentitled?: boolean;
13
+ };
14
+
15
+ type MemoryHookSetup = {
16
+ credentials?: MemoryCredentials;
9
17
  home?: string;
10
18
  env?: NodeJS.ProcessEnv;
11
19
  cwd?: string;
12
20
  node?: string;
13
21
  packageRoot?: URL;
14
- }): string[];
22
+ };
23
+
24
+ export function chooseMemoryHooks(options: {
25
+ credentials?: MemoryCredentials;
26
+ clients: string[];
27
+ requested?: boolean;
28
+ ask: (question: string, defaultAnswer: boolean) => Promise<boolean | null>;
29
+ home?: string;
30
+ env?: NodeJS.ProcessEnv;
31
+ }): Promise<MemoryHookChoices>;
32
+
33
+ export function applyMemoryHookChoices(
34
+ decision: MemoryHookChoices,
35
+ options?: MemoryHookSetup,
36
+ ): string[];
37
+
38
+ export function syncMemoryHooks(
39
+ options?: MemoryHookSetup & { clients?: string[]; graphClients?: string[]; enable?: boolean },
40
+ ): string[];
@@ -4,6 +4,7 @@ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
4
4
  import { bundledHook, installMemoryBundle, packagedMemoryBundle } from "./bundle.mjs";
5
5
  import { hasLegacyHooks, hasUserHooks, updateHooks, updateUserHooks } from "./hook-config.mjs";
6
6
  import { atomicWrite, checkout, git, hasMemoryGit } from "./local.mjs";
7
+ import { MEMORY_HOOK_CLIENTS, readHookPreferences, writeHookPreferences } from "./preferences.mjs";
7
8
  import {
8
9
  legacySettingsPath,
9
10
  matchingMemoryHint,
@@ -13,8 +14,6 @@ import {
13
14
  readHookSettings,
14
15
  } from "./user-target.mjs";
15
16
 
16
- const CLIENTS = ["claude-code", "codex"];
17
-
18
17
  function userSettings(client, home, env) {
19
18
  const codex = client === "codex";
20
19
  const directory =
@@ -82,7 +81,7 @@ function migrateCheckout(client, target, notices) {
82
81
  }
83
82
  }
84
83
 
85
- function syncUserHooks(client, home, env, target, hookPath, node, alreadyActive) {
84
+ function syncUserHooks(client, home, env, target, hookPath, node, alreadyActive, graphCues) {
86
85
  let known = Boolean(alreadyActive);
87
86
  try {
88
87
  const settings = inspectSettings(userSettings(client, home, env));
@@ -95,7 +94,7 @@ function syncUserHooks(client, home, env, target, hookPath, node, alreadyActive)
95
94
  hasLegacyHooks(settings.config, client, target.root);
96
95
  writeSettings(
97
96
  settings,
98
- updateUserHooks(settings.config, client, hookPath, node),
97
+ updateUserHooks(settings.config, client, hookPath, node, graphCues),
99
98
  target,
100
99
  checkoutHook,
101
100
  );
@@ -110,10 +109,118 @@ function writeActivation(path, active) {
110
109
  else rmSync(path, { force: true });
111
110
  }
112
111
 
112
+ /** Resolve explicit choices only after a supported client's MCP registration. */
113
+ export async function chooseMemoryHooks({
114
+ credentials,
115
+ clients,
116
+ requested = false,
117
+ ask,
118
+ home = homedir(),
119
+ env = process.env,
120
+ }) {
121
+ const choices = { accepted: [], declined: [], graphClients: [], notices: [] };
122
+ if (typeof process.getuid !== "function") {
123
+ if (requested)
124
+ choices.notices.push("Pharaoh: project memory hooks need a POSIX client with system Git.");
125
+ return choices;
126
+ }
127
+ if (credentials?.project_memory_status === "unavailable") {
128
+ if (requested)
129
+ choices.notices.push(
130
+ "Pharaoh: project memory eligibility could not be checked. Retry setup to enable hooks.",
131
+ );
132
+ return choices;
133
+ }
134
+ if (!projectMemoryHint(credentials).length) {
135
+ if (requested) choices.notices.push("Pharaoh: this session has no project memory hook grant.");
136
+ return credentials ? { ...choices, removeUnentitled: true } : choices;
137
+ }
138
+ if (!clients.length || !hasMemoryGit) {
139
+ if (requested && !hasMemoryGit)
140
+ choices.notices.push("Pharaoh: project memory hooks need a POSIX client with system Git.");
141
+ return choices;
142
+ }
143
+ const previous = readHookPreferences(home);
144
+ const active = readActiveMemoryClients(home);
145
+ const next = { ...previous };
146
+ for (const client of MEMORY_HOOK_CLIENTS) {
147
+ if (!clients.includes(client)) continue;
148
+ const label = client === "codex" ? "Codex" : "Claude Code";
149
+ let config;
150
+ try {
151
+ config = inspectSettings(userSettings(client, home, env)).config;
152
+ } catch {
153
+ // An unreadable settings file cannot establish a previous installation.
154
+ }
155
+ if (client === "claude-code" && config?.disableAllHooks === true) continue;
156
+ const choice = { ...previous[client] };
157
+ const memoryHook =
158
+ active[client] &&
159
+ hasUserHooks({ hooks: { UserPromptSubmit: config?.hooks?.UserPromptSubmit } }, client);
160
+ if (requested) choice.memory = true;
161
+ else if (choice.memory !== false && (choice.memory !== true || !memoryHook)) {
162
+ const answer = await ask(
163
+ `Enable Pharaoh project memory hooks in ${label} (recommended)? [Y/n] `,
164
+ true,
165
+ );
166
+ if (answer === null) continue;
167
+ choice.memory = answer;
168
+ if (!answer && choice.graph === true) delete choice.graph;
169
+ }
170
+ if (choice.memory === true) {
171
+ let graphEnabled =
172
+ choice.graph === true &&
173
+ ["PreToolUse", "PostToolUse"].every((event) =>
174
+ hasUserHooks({ hooks: { [event]: config?.hooks?.[event] } }, client),
175
+ );
176
+ if (requested || choice.graph === undefined || (choice.graph === true && !graphEnabled)) {
177
+ const answer = await ask(
178
+ `Enable advisory Pharaoh worktree cues in ${label}? [y/N] `,
179
+ false,
180
+ );
181
+ if (answer !== null) {
182
+ choice.graph = answer;
183
+ graphEnabled = answer;
184
+ }
185
+ }
186
+ choices.accepted.push(client);
187
+ if (graphEnabled) choices.graphClients.push(client);
188
+ } else {
189
+ choices.declined.push(client);
190
+ }
191
+ next[client] = choice;
192
+ }
193
+ writeHookPreferences(next, home, previous);
194
+ return choices;
195
+ }
196
+
197
+ /** Apply the outcome against the saved choices, which can change after the prompt. */
198
+ export function applyMemoryHookChoices(decision, options = {}) {
199
+ const { accepted, declined, graphClients, removeUnentitled } = decision;
200
+ const notices = [...decision.notices];
201
+ if (removeUnentitled)
202
+ return [...notices, ...syncMemoryHooks({ ...options, clients: MEMORY_HOOK_CLIENTS })];
203
+
204
+ if (declined.length) {
205
+ const saved = readHookPreferences(options.home);
206
+ const clients = declined.filter((client) => saved[client]?.memory === false);
207
+ if (clients.length) notices.push(...syncMemoryHooks({ ...options, clients }));
208
+ }
209
+ if (accepted.length) {
210
+ const saved = readHookPreferences(options.home);
211
+ const clients = accepted.filter((client) => saved[client]?.memory === true);
212
+ const cues = graphClients.filter((client) => saved[client]?.graph === true);
213
+ if (clients.length)
214
+ notices.push(...syncMemoryHooks({ ...options, clients, graphClients: cues, enable: true }));
215
+ }
216
+ return notices;
217
+ }
218
+
113
219
  /** Run only during setup or explicit cleanup of the local memory hooks. */
114
220
  export function syncMemoryHooks({
115
221
  credentials,
116
- clients = [],
222
+ clients = MEMORY_HOOK_CLIENTS,
223
+ graphClients = [],
117
224
  enable = false,
118
225
  home = homedir(),
119
226
  env = process.env,
@@ -150,8 +257,8 @@ export function syncMemoryHooks({
150
257
  }
151
258
  }
152
259
 
153
- for (const client of CLIENTS) {
154
- if (enable && !clients.includes(client)) continue;
260
+ for (const client of MEMORY_HOOK_CLIENTS) {
261
+ if (!clients.includes(client)) continue;
155
262
  const next = { ...active, [client]: digest };
156
263
  try {
157
264
  // Enable the replacement before swapping any settings, so failed
@@ -159,7 +266,16 @@ export function syncMemoryHooks({
159
266
  const replacing = enable && active[client] !== digest;
160
267
  if (replacing) writeActivation(activePath, next);
161
268
  try {
162
- syncUserHooks(client, home, env, target, installed, node, active[client]);
269
+ syncUserHooks(
270
+ client,
271
+ home,
272
+ env,
273
+ target,
274
+ installed,
275
+ node,
276
+ active[client],
277
+ graphClients.includes(client),
278
+ );
163
279
  } catch (error) {
164
280
  if (replacing) writeActivation(activePath, active);
165
281
  throw error;
@@ -176,8 +292,16 @@ export function syncMemoryHooks({
176
292
  }
177
293
 
178
294
  // Deactivate loaded user hooks even if known settings could not be edited.
179
- if (!enable && Object.keys(active).length) writeActivation(activePath, {});
180
- for (const client of enable ? configured : CLIENTS) migrateCheckout(client, target, notices);
295
+ if (!enable) {
296
+ const remaining = Object.fromEntries(
297
+ Object.entries(active).filter(([client]) => !clients.includes(client)),
298
+ );
299
+ if (Object.keys(remaining).length !== Object.keys(active).length)
300
+ writeActivation(activePath, remaining);
301
+ }
302
+ for (const client of enable ? configured : clients) {
303
+ if (MEMORY_HOOK_CLIENTS.includes(client)) migrateCheckout(client, target, notices);
304
+ }
181
305
  if (configured.length) {
182
306
  notices.push(
183
307
  "Pharaoh project memory reminders are set up for your eligible repositories. Start a new local conversation.",