@pharaoh-so/mcp 0.3.58 → 0.3.60

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.58"
6
+ "@pharaoh-so/mcp@0.3.60"
7
7
  ]
8
8
  }
9
9
  }
package/CHANGELOG.md CHANGED
@@ -26,6 +26,17 @@ 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.60] - 2026-11-05
30
+
31
+ ### Added
32
+ - When it connects, the proxy tells Pharaoh what your agent's instruction files say about it: whether `~/.claude/CLAUDE.md` holds the line setup suggests, mentions Pharaoh another way, doesn't, or is missing, and whether setup's block is still there. In Claude Code started at a git repo's root, the same for the project's `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` or `AGENTS.md`. Only those values are sent, never file text. A file over 64 KB, or one that isn't a regular file or can't be read, counts as unknown. Pharaoh uses this to learn whether the line leads agents to use it (privacy policy § 9, connection records).
33
+
34
+ ## [0.3.59] - 2026-11-04
35
+
36
+ ### Changed
37
+ - The project memory pilot uses Pharaoh's MCP tools by default. Interactive setup removes managed pilot hooks from user settings and from eligible ignored settings in the current checkout. Tracked or linked checkout hooks require manual removal.
38
+ - Use `--memory-hooks` during interactive setup to opt in to a prompt reminder for the MCP tools. Use `--disable-memory-hooks` to remove managed pilot hooks without signing in again.
39
+
29
40
  ## [0.3.58] - 2026-11-04
30
41
 
31
42
  ### Changed
package/README.md CHANGED
@@ -152,10 +152,11 @@ claude mcp add --scope user pharaoh -- npx @pharaoh-so/mcp@<version> --server ht
152
152
  - Credentials are stored with restrictive file permissions (`0600` — owner-read/write only)
153
153
  - Authentication uses the [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) device authorization flow — no secrets are embedded in the package
154
154
  - All communication with the Pharaoh server uses HTTPS/TLS
155
- - The proxy never reads or sends your local files. Pharaoh reads your code from GitHub through its GitHub App, maps it into a knowledge graph of structural metadata (function names, file paths, dependency relationships), and deletes the clone when mapping ends.
155
+ - The proxy never sends your code or the text of any file. Pharaoh reads your code from GitHub through its GitHub App, maps it into a knowledge graph of structural metadata (function names, file paths, dependency relationships), and deletes the clone when mapping ends.
156
+ - From 0.3.60, when it connects, the proxy also tells Pharaoh whether your `~/.claude/CLAUDE.md` holds the line setup suggests, mentions Pharaoh another way, doesn't, or is missing, and whether setup's block is still in it. In Claude Code started at a git repo's root, it does the same for that project's `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` and `AGENTS.md`. Only these values are sent, never the files' text.
156
157
  - Tokens expire after 7 days and are refreshed automatically
157
158
  - Only use trusted server URLs — the proxy sends your auth token to the configured server
158
- - No telemetry. The proxy's only network calls are the device flow, token refresh, the MCP connection, and one registry lookup during interactive setup (below).
159
+ - The proxy's only network calls are the device flow, token refresh, the MCP connection, and one registry lookup during interactive setup (below). Over the MCP connection it tells Pharaoh which client it serves (the name and version that client reports) and whether Pharaoh's Claude Code plugin is enabled, which it reads from `~/.claude/settings.json`. Nothing else about your machine is sent.
159
160
 
160
161
  ### What the installer writes on your machine
161
162
 
package/dist/backend.d.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import type { CallToolResult, ClientCapabilities, Tool } from "@modelcontextprotocol/sdk/types.js";
19
19
  import type { DeviceFlowGateway } from "./gateway.js";
20
+ import type { InstructionState, ManagedBlockState } from "./install-skills.js";
20
21
  /**
21
22
  * Notification that tells Pharaoh which client the proxy serves. Pharaoh sees
22
23
  * the proxy itself as `pharaoh-proxy`; this lands on the session's row.
@@ -32,6 +33,12 @@ export type DownstreamClient = {
32
33
  };
33
34
  /** Pharaoh's Claude Code plugin as ~/.claude/settings.json lists it. */
34
35
  plugin: "enabled" | "disabled" | "absent";
36
+ /** What the client's instruction files say about Pharaoh (install-skills.ts instructionStates); never their text. */
37
+ instructions?: {
38
+ user: InstructionState;
39
+ managed: ManagedBlockState;
40
+ project: InstructionState;
41
+ };
35
42
  };
36
43
  /** The contract ProxyServer uses to delegate tool handling. */
37
44
  export interface Backend {
package/dist/helpers.js CHANGED
@@ -96,7 +96,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
96
96
  return { server, logout };
97
97
  }
98
98
  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", " --install-skills Reinstall the Claude Code plugin and OpenClaw skills", " --version, -v Print the version", " --help, -h Show this help", "", "Get started:", ` ${NPX_COMMAND}`, "");
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}`, "");
100
100
  }
101
101
  /**
102
102
  * Validate that a server-supplied SSE URL shares the same origin as the configured server.
package/dist/index.js CHANGED
@@ -37,9 +37,11 @@ function reportMemorySetup(options) {
37
37
  const notices = syncMemoryHooks(options);
38
38
  if (notices.length)
39
39
  printLines(...notices);
40
+ return notices.length === 0;
40
41
  }
41
42
  catch {
42
43
  printLines("Pharaoh: project memory setup could not update local activation.");
44
+ return false;
43
45
  }
44
46
  }
45
47
  async function main() {
@@ -65,6 +67,11 @@ async function main() {
65
67
  runInstallSkills();
66
68
  return;
67
69
  }
70
+ if (args.includes("--disable-memory-hooks")) {
71
+ if (!reportMemorySetup())
72
+ process.exitCode = 1;
73
+ return;
74
+ }
68
75
  // Clean mode — remove all pharaoh traces from Claude Code config (MCP entries,
69
76
  // disabled states, project-scoped configs). Useful for resetting broken installs.
70
77
  if (args.includes("--clean")) {
@@ -73,7 +80,7 @@ async function main() {
73
80
  cleanSlate();
74
81
  uninstallClaudePlugin();
75
82
  cleanCodexSetup();
76
- reportMemorySetup({ removeLegacy: true });
83
+ reportMemorySetup();
77
84
  printLines("Pharaoh: local setup cleanup finished.");
78
85
  process.exit(0);
79
86
  }
@@ -86,7 +93,7 @@ async function main() {
86
93
  cleanSlate();
87
94
  uninstallClaudePlugin();
88
95
  cleanCodexSetup();
89
- reportMemorySetup({ removeLegacy: true });
96
+ reportMemorySetup();
90
97
  printLines("Pharaoh: credentials cleared and local setup cleanup finished.");
91
98
  process.exit(0);
92
99
  }
@@ -137,7 +144,7 @@ async function main() {
137
144
  if (claudeMd)
138
145
  printLines(claudeMd);
139
146
  }
140
- reportMemorySetup({ credentials: newCreds, clients });
147
+ reportMemorySetup({ credentials: newCreds, clients, enable: args.includes("--memory-hooks") });
141
148
  // The registration is pinned to this version; upgrading is a deliberate
142
149
  // re-run. Interactive only, and quiet when the registry can't be reached.
143
150
  const latest = await fetchLatestVersion();
@@ -87,6 +87,19 @@ export declare function allowReadTools(home?: string): AllowReadToolsResult;
87
87
  export declare function describeAllowReadTools(result: AllowReadToolsResult): string;
88
88
  /** What the interactive setup asks before writing the line. */
89
89
  export declare const CLAUDE_MD_QUESTION = "Pharaoh: add one line to ~/.claude/CLAUDE.md so Claude checks Pharaoh before searching your code?\n \"In repos Pharaoh has mapped, before searching or reading files, ask Pharaoh where the relevant code is, what calls it, and whether what you need already exists.\"\n[Y/n] ";
90
+ /** What an instruction file says about Pharaoh: the suggested line, another mention, none, no file, or unreadable. */
91
+ export type InstructionState = "exact" | "mention" | "none" | "missing" | "unknown";
92
+ /** Whether the block setup wrote into ~/.claude/CLAUDE.md is there and holds the current line. */
93
+ export type ManagedBlockState = "current" | "stale" | "absent" | "unknown";
94
+ /**
95
+ * The instruction state the proxy reports for its client. Project files only
96
+ * for clients known to start the proxy in the project folder. Never throws.
97
+ */
98
+ export declare function instructionStates(clientName: string | undefined, cwd?: string, home?: string): {
99
+ user: InstructionState;
100
+ managed: ManagedBlockState;
101
+ project: InstructionState;
102
+ };
90
103
  /** What `offerClaudeMdLine` did; `describeClaudeMdOutcome` words it. */
91
104
  export type ClaudeMdOutcome = "added" | "updated" | "declined" | "kept" | "failed" | "current" | "answered" | "cancelled" | "no-terminal" | "no-claude";
92
105
  /**
@@ -6,9 +6,9 @@
6
6
  * and OpenClaw skills in ~/.openclaw/skills/. Also merges the Pharaoh MCP
7
7
  * server config into OpenClaw.
8
8
  */
9
- import { closeSync, constants, cpSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
9
+ import { closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, writeFileSync, } from "node:fs";
10
10
  import { homedir } from "node:os";
11
- import { dirname, join } from "node:path";
11
+ import { dirname, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { ensurePharaohHome } from "./credentials.js";
14
14
  import { CLAUDE_CLI_SETUP_URL, claudeCliFor } from "./setup.js";
@@ -765,6 +765,111 @@ function readClaudeMd(home) {
765
765
  const current = text.slice(block.body, block.to) === `${PHARAOH_CLAUDE_MD_LINE}\n`;
766
766
  return { state: current ? "current" : "stale", path, text, block };
767
767
  }
768
+ /** A CLAUDE.md is a few KB; anything bigger reads as "unknown" instead of slowing initialize. */
769
+ const INSTRUCTION_READ_CAP = 64 * 1024;
770
+ /**
771
+ * A project's instruction files, read only for clients confirmed to start the
772
+ * proxy in the project folder (Claude Code 2.1.310 does: probed 2026-11-04).
773
+ */
774
+ const PROJECT_INSTRUCTION_FILES = [
775
+ "CLAUDE.md",
776
+ "CLAUDE.local.md",
777
+ join(".claude", "CLAUDE.md"),
778
+ "AGENTS.md",
779
+ ];
780
+ const PROJECT_CWD_CLIENTS = new Set(["claude-code"]);
781
+ /**
782
+ * Up to `cap` bytes of a regular file, read from the descriptor that was
783
+ * checked, so a FIFO or device swapped in can't block `initialize`. A symlink
784
+ * is followed: only a state is derived from what it points at.
785
+ *
786
+ * @returns The file's text, wrapped so no content can read as a state;
787
+ * "missing" when there's no file; "unknown" when it's over the cap, not a
788
+ * regular file, or unreadable
789
+ */
790
+ function readBoundedText(path, cap = INSTRUCTION_READ_CAP) {
791
+ let fd;
792
+ try {
793
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
794
+ }
795
+ catch (err) {
796
+ const code = err.code;
797
+ return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown";
798
+ }
799
+ try {
800
+ if (!fstatSync(fd).isFile())
801
+ return "unknown";
802
+ const buf = Buffer.alloc(cap + 1);
803
+ let length = 0;
804
+ while (length <= cap) {
805
+ const n = readSync(fd, buf, length, cap + 1 - length, null);
806
+ if (n === 0)
807
+ break;
808
+ length += n;
809
+ }
810
+ return length > cap ? "unknown" : { text: buf.toString("utf-8", 0, length) };
811
+ }
812
+ catch {
813
+ return "unknown";
814
+ }
815
+ finally {
816
+ closeSync(fd);
817
+ }
818
+ }
819
+ /** Whether `text` holds the suggested line, otherwise mentions Pharaoh, or neither. */
820
+ function classifyInstructionText(text) {
821
+ if (text.includes(PHARAOH_CLAUDE_MD_LINE))
822
+ return "exact";
823
+ return /\bpharaoh\b/i.test(text) ? "mention" : "none";
824
+ }
825
+ /** What ~/.claude/CLAUDE.md says about Pharaoh, and whether setup's block is there and current. */
826
+ function userInstructionState(home = homedir()) {
827
+ const file = readBoundedText(join(home, ".claude", "CLAUDE.md"));
828
+ if (file === "missing")
829
+ return { user: "missing", managed: "absent" };
830
+ if (file === "unknown")
831
+ return { user: "unknown", managed: "unknown" };
832
+ const { text } = file;
833
+ const block = findClaudeMdBlock(text);
834
+ const managed = block === null
835
+ ? "absent"
836
+ : block === "broken"
837
+ ? "unknown"
838
+ : text.slice(block.body, block.to) === `${PHARAOH_CLAUDE_MD_LINE}\n`
839
+ ? "current"
840
+ : "stale";
841
+ return { user: classifyInstructionText(text), managed };
842
+ }
843
+ /**
844
+ * The strongest state across a project's instruction files: any exact line,
845
+ * then any mention, then any unreadable file, then a file without a mention.
846
+ * "unknown" outside a git repository's root or in the home folder.
847
+ */
848
+ function projectInstructionState(cwd, home = homedir()) {
849
+ if (resolve(cwd) === resolve(home) || !existsSync(join(cwd, ".git")))
850
+ return "unknown";
851
+ const states = PROJECT_INSTRUCTION_FILES.map((file) => {
852
+ const read = readBoundedText(join(cwd, file));
853
+ return typeof read === "string" ? read : classifyInstructionText(read.text);
854
+ });
855
+ return (["exact", "mention", "unknown", "none"].find((s) => states.includes(s)) ?? "missing");
856
+ }
857
+ /**
858
+ * The instruction state the proxy reports for its client. Project files only
859
+ * for clients known to start the proxy in the project folder. Never throws.
860
+ */
861
+ export function instructionStates(clientName, cwd, home = homedir()) {
862
+ try {
863
+ // process.cwd() throws when the folder was deleted: inside the try, so it reads as unknown.
864
+ const project = clientName && PROJECT_CWD_CLIENTS.has(clientName)
865
+ ? projectInstructionState(cwd ?? process.cwd(), home)
866
+ : "unknown";
867
+ return { ...userInstructionState(home), project };
868
+ }
869
+ catch {
870
+ return { user: "unknown", managed: "unknown", project: "unknown" };
871
+ }
872
+ }
768
873
  /**
769
874
  * Offer the Pharaoh-first line in ~/.claude/CLAUDE.md. Interactive setup only,
770
875
  * never a proxy start. Asks only when the block is absent and the user hasn't
@@ -27,7 +27,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
27
27
  import { AuthRequiredBackend, createSseBackend, } from "./backend.js";
28
28
  import { loadBundledCatalog } from "./catalog.js";
29
29
  import { createGateway } from "./gateway.js";
30
- import { claudePluginState } from "./install-skills.js";
30
+ import { claudePluginState, instructionStates } from "./install-skills.js";
31
31
  import { TenantSuspendedError, TokenExpiredError } from "./proxy.js";
32
32
  import { registerTraceResource } from "./trace-resource.js";
33
33
  import { getPackageVersion } from "./version.js";
@@ -70,6 +70,7 @@ export class ProxyServer {
70
70
  const downstream = {
71
71
  client: { name: info?.name, version: info?.version },
72
72
  plugin: claudePluginState(),
73
+ instructions: instructionStates(info?.name),
73
74
  };
74
75
  this.downstream = downstream;
75
76
  this.clientCapabilities = this.server.getClientCapabilities() ?? {};
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.58",
4
+ "version": "0.3.60",
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",
@@ -20,15 +20,29 @@ function hookCommand(entry) {
20
20
  return entry?.type === "command" && typeof entry.command === "string" ? entry.command : "";
21
21
  }
22
22
 
23
+ function matchesLegacyHook(client, root) {
24
+ const suffix = ` ${legacyMarker(client, root)}`;
25
+ return (value) => value.includes("pharaoh-memory-hook.mjs") && value.endsWith(suffix);
26
+ }
27
+
28
+ function matchesUserHook(value) {
29
+ return (
30
+ value.includes("pharaoh-memory-hook.mjs") &&
31
+ (value.endsWith(` ${GLOBAL_MARKER}`) || LEGACY_MARKER.test(value))
32
+ );
33
+ }
34
+
23
35
  /** Keep unrelated handlers even when they share an event group with ours. */
24
- function replaceHooks(config, client, matches, command) {
36
+ function replaceHooks(config, client, matches, command, installEvents = EVENTS[client]) {
25
37
  if (!isObject(config) || (config.hooks !== undefined && !isObject(config.hooks))) {
26
38
  throw new Error("The existing hooks configuration is not a JSON object.");
27
39
  }
28
40
  const hooks = { ...config.hooks };
29
41
  for (const event of EVENTS[client]) {
30
42
  const current = hooks[event];
31
- if (current === undefined && !command) continue;
43
+ const install = command && installEvents.includes(event);
44
+ if (!install && (current === undefined || (Array.isArray(current) && !current.length)))
45
+ continue;
32
46
  if (
33
47
  current !== undefined &&
34
48
  (!Array.isArray(current) || current.some((group) => !Array.isArray(group?.hooks)))
@@ -40,7 +54,7 @@ function replaceHooks(config, client, matches, command) {
40
54
  if (rest.length === group.hooks.length) return [group];
41
55
  return rest.length ? [{ ...group, hooks: rest }] : [];
42
56
  });
43
- if (command) kept.push({ hooks: [{ type: "command", command, timeout: 3 }] });
57
+ if (install) kept.push({ hooks: [{ type: "command", command, timeout: 3 }] });
44
58
  if (kept.length) hooks[event] = kept;
45
59
  else delete hooks[event];
46
60
  }
@@ -55,37 +69,33 @@ export function updateHooks(config, client, target, remove, hookPath, node = pro
55
69
  const command = remove
56
70
  ? null
57
71
  : `${[node, hookPath, client, target.root, target.repo].map(shellWord).join(" ")} ${marker}`;
58
- return replaceHooks(config, client, (value) => value.endsWith(` ${marker}`), command);
72
+ return replaceHooks(config, client, matchesLegacyHook(client, target.root), command);
59
73
  }
60
74
 
61
75
  export function updateUserHooks(config, client, hookPath, node = process.execPath) {
62
76
  const command = hookPath
63
77
  ? `${[node, hookPath, client, "--user"].map(shellWord).join(" ")} ${GLOBAL_MARKER}`
64
78
  : null;
65
- return replaceHooks(
66
- config,
67
- client,
68
- (value) =>
69
- value.endsWith(` ${GLOBAL_MARKER}`) ||
70
- (value.includes("pharaoh-memory-hook.mjs") && LEGACY_MARKER.test(value)),
71
- command,
72
- );
79
+ return replaceHooks(config, client, matchesUserHook, command, ["UserPromptSubmit"]);
73
80
  }
74
81
 
75
- export function hasLegacyHooks(config, client, root) {
76
- const suffix = ` ${legacyMarker(client, root)}`;
82
+ function hasHooks(config, client, matches) {
77
83
  return EVENTS[client].some((event) => {
78
84
  const groups = config?.hooks?.[event];
79
85
  return (
80
86
  Array.isArray(groups) &&
81
87
  groups.some(
82
88
  (group) =>
83
- Array.isArray(group?.hooks) &&
84
- group.hooks.some((entry) => {
85
- const command = hookCommand(entry);
86
- return command.includes("pharaoh-memory-hook.mjs") && command.endsWith(suffix);
87
- }),
89
+ Array.isArray(group?.hooks) && group.hooks.some((entry) => matches(hookCommand(entry))),
88
90
  )
89
91
  );
90
92
  });
91
93
  }
94
+
95
+ export function hasLegacyHooks(config, client, root) {
96
+ return hasHooks(config, client, matchesLegacyHook(client, root));
97
+ }
98
+
99
+ export function hasUserHooks(config, client) {
100
+ return hasHooks(config, client, matchesUserHook);
101
+ }
@@ -35,7 +35,7 @@ function promptContext(token, compact, mayCapture) {
35
35
  ].join("\n");
36
36
  }
37
37
 
38
- function skipPrompt(value) {
38
+ export function skipMemoryPrompt(value) {
39
39
  const prompt = typeof value === "string" ? value.trim() : "";
40
40
  return (
41
41
  !prompt ||
@@ -47,6 +47,16 @@ function skipPrompt(value) {
47
47
  );
48
48
  }
49
49
 
50
+ export function memoryReminder(repo) {
51
+ if (!REPO.test(repo)) return;
52
+ return {
53
+ hookSpecificOutput: {
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.`,
56
+ },
57
+ };
58
+ }
59
+
50
60
  /** Hook input can contain raw prompts and transcripts. Neither is persisted or transmitted. */
51
61
  export function handleEvent(
52
62
  input,
@@ -90,7 +100,7 @@ export function handleEvent(
90
100
  input.prompt,
91
101
  );
92
102
  const opened = store.begin(key, target, client, nativeTurn, {
93
- skip: skipPrompt(input.prompt),
103
+ skip: skipMemoryPrompt(input.prompt),
94
104
  readOnly: noCapture,
95
105
  memorySession,
96
106
  });
@@ -1,7 +1,7 @@
1
1
  import { dirname } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { handleEvent } from "./hook-events.mjs";
4
- import { checkout, readStdin } from "./local.mjs";
3
+ import { handleEvent, memoryReminder, skipMemoryPrompt } from "./hook-events.mjs";
4
+ import { readStdin } from "./local.mjs";
5
5
  import {
6
6
  activeMemoryRuntime,
7
7
  readMemoryCredentials,
@@ -15,30 +15,29 @@ const exitQuietly = setTimeout(() => process.exit(0), Math.max(0, deadline - per
15
15
  try {
16
16
  const [client, root, repo] = process.argv.slice(2);
17
17
  const input = await readStdin(1024 * 1024);
18
- const credentials = await readMemoryCredentials();
19
- let target = { root, repo };
18
+ let result;
20
19
  if (root === "--user") {
21
20
  const runtime = dirname(fileURLToPath(import.meta.url));
22
21
  if (!activeMemoryRuntime(client, runtime)) process.exit(0);
23
- const isPrompt = input?.hook_event_name === "UserPromptSubmit";
22
+ if (input?.hook_event_name !== "UserPromptSubmit" || skipMemoryPrompt(input.prompt))
23
+ process.exit(0);
24
24
  const eligible = await resolveMemoryHookTarget(
25
25
  client,
26
26
  input?.cwd,
27
- credentials,
28
- isPrompt && typeof input?.prompt === "string" && Boolean(input.prompt.trim()),
27
+ await readMemoryCredentials(),
28
+ true,
29
29
  deadline,
30
30
  );
31
- // Cleanup must still close the turn after sign-out or loss of eligibility.
32
- const resolved = eligible ?? (!isPrompt && checkout(input?.cwd, deadline));
33
- if (!resolved) process.exit(0);
34
- target = { ...resolved, runtime };
31
+ if (eligible && performance.now() < deadline) result = memoryReminder(eligible.repo);
32
+ } else {
33
+ result = handleEvent(input, {
34
+ client,
35
+ root,
36
+ repo,
37
+ credentials: await readMemoryCredentials(),
38
+ deadline,
39
+ });
35
40
  }
36
- const result = handleEvent(input, {
37
- client,
38
- ...target,
39
- credentials,
40
- deadline,
41
- });
42
41
  if (result) process.stdout.write(JSON.stringify(result));
43
42
  } catch {
44
43
  // Project memory never blocks ordinary work or opens a continuation.
@@ -5,10 +5,10 @@ export function syncMemoryHooks(options?: {
5
5
  project_memory_status?: "unavailable";
6
6
  };
7
7
  clients?: string[];
8
+ enable?: boolean;
8
9
  home?: string;
9
10
  env?: NodeJS.ProcessEnv;
10
11
  cwd?: string;
11
12
  node?: string;
12
- removeLegacy?: boolean;
13
13
  packageRoot?: URL;
14
14
  }): string[];
@@ -1,18 +1,20 @@
1
1
  import { lstatSync, mkdirSync, realpathSync, rmSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
4
4
  import { bundledHook, installMemoryBundle, packagedMemoryBundle } from "./bundle.mjs";
5
- import { hasLegacyHooks, updateHooks, updateUserHooks } from "./hook-config.mjs";
5
+ import { hasLegacyHooks, hasUserHooks, updateHooks, updateUserHooks } from "./hook-config.mjs";
6
6
  import { atomicWrite, checkout, git, hasMemoryGit } from "./local.mjs";
7
7
  import {
8
- eligibleCheckout,
9
8
  legacySettingsPath,
9
+ matchingMemoryHint,
10
10
  memoryClientRoot,
11
11
  projectMemoryHint,
12
12
  readActiveMemoryClients,
13
13
  readHookSettings,
14
14
  } from "./user-target.mjs";
15
15
 
16
+ const CLIENTS = ["claude-code", "codex"];
17
+
16
18
  function userSettings(client, home, env) {
17
19
  const codex = client === "codex";
18
20
  const directory =
@@ -21,131 +23,173 @@ function userSettings(client, home, env) {
21
23
  return join(directory, codex ? "hooks.json" : "settings.json");
22
24
  }
23
25
 
24
- function writeSettings(path, previous, next) {
25
- if (JSON.stringify(previous) === JSON.stringify(next)) return;
26
+ function canonicalSettingsPath(path) {
27
+ try {
28
+ return realpathSync(path);
29
+ } catch (error) {
30
+ const parent = dirname(path);
31
+ if (error.code !== "ENOENT" || parent === path) throw error;
32
+ // Even a missing settings file can be under a linked ancestor.
33
+ return join(canonicalSettingsPath(parent), basename(path));
34
+ }
35
+ }
36
+
37
+ function inspectSettings(path) {
38
+ const resolved = canonicalSettingsPath(path);
39
+ return { path, resolved, config: readHookSettings(resolved) };
40
+ }
41
+
42
+ function writeSettings({ path, resolved, config }, next, target, requireIgnored = false) {
43
+ if (JSON.stringify(config) === JSON.stringify(next)) return;
44
+ if (requireIgnored && path !== resolved)
45
+ throw new Error("The checkout hook settings are linked.");
46
+ if (target) {
47
+ const location = relative(target.root, resolved);
48
+ if (
49
+ location &&
50
+ location !== ".." &&
51
+ !location.startsWith(`..${sep}`) &&
52
+ !isAbsolute(location)
53
+ ) {
54
+ if (git(target.root, "ls-files", "--", `:(literal)${location}`))
55
+ throw new Error("The checkout hook settings are tracked.");
56
+ if (requireIgnored) git(target.root, "check-ignore", "--quiet", "--no-index", "--", location);
57
+ }
58
+ }
26
59
  const directory = dirname(path);
60
+ const file = lstatSync(path, { throwIfNoEntry: false });
61
+ if (file && !file.isFile()) throw new Error("The hook settings are linked.");
27
62
  mkdirSync(directory, { recursive: true, mode: 0o700 });
28
63
  const info = lstatSync(directory);
29
64
  if (!info.isDirectory() || info.uid !== process.getuid?.())
30
65
  throw new Error("Client settings directory is not owner-controlled.");
31
- atomicWrite(path, `${JSON.stringify(next, null, "\t")}\n`);
66
+ atomicWrite(resolved, `${JSON.stringify(next, null, "\t")}\n`);
32
67
  }
33
68
 
34
- function migrateCheckout(client, target) {
69
+ function migrateCheckout(client, target, notices) {
35
70
  if (!target) return;
36
- const path = legacySettingsPath(client, target.root);
37
- const before = readHookSettings(path);
38
- if (!hasLegacyHooks(before, client, target.root)) return;
39
- const relative = path.slice(target.root.length + 1);
40
- if (realpathSync(dirname(path)) !== dirname(path) || git(target.root, "ls-files", "--", relative))
41
- throw new Error("The earlier checkout hook is in tracked or linked settings.");
42
- // The legacy installer always wrote ignored files. Never create or alter
43
- // Git excludes, and never migrate project settings that can be committed.
44
- git(target.root, "check-ignore", "--quiet", "--no-index", "--", relative);
45
- writeSettings(path, before, updateHooks(before, client, target, true));
71
+ let known = false;
72
+ try {
73
+ const settings = inspectSettings(legacySettingsPath(client, target.root));
74
+ known = hasLegacyHooks(settings.config, client, target.root);
75
+ if (known)
76
+ writeSettings(settings, updateHooks(settings.config, client, target, true), target, true);
77
+ } catch {
78
+ if (known)
79
+ notices.push(
80
+ `Pharaoh: the earlier ${client} checkout hook remains in place for this checkout.`,
81
+ );
82
+ }
46
83
  }
47
84
 
48
- /** Run only as part of a deliberate normal setup, upgrade, clean or logout. */
85
+ function syncUserHooks(client, home, env, target, hookPath, node, alreadyActive) {
86
+ let known = Boolean(alreadyActive);
87
+ try {
88
+ const settings = inspectSettings(userSettings(client, home, env));
89
+ known ||= hasUserHooks(settings.config, client);
90
+ if (hookPath || known) {
91
+ const legacy = target && legacySettingsPath(client, target.root);
92
+ const checkoutHook =
93
+ legacy &&
94
+ [settings.path, settings.resolved].includes(legacy) &&
95
+ hasLegacyHooks(settings.config, client, target.root);
96
+ writeSettings(
97
+ settings,
98
+ updateUserHooks(settings.config, client, hookPath, node),
99
+ target,
100
+ checkoutHook,
101
+ );
102
+ }
103
+ } catch (error) {
104
+ if (hookPath || known) throw error;
105
+ }
106
+ }
107
+
108
+ function writeActivation(path, active) {
109
+ if (Object.keys(active).length) atomicWrite(path, `${JSON.stringify(active)}\n`);
110
+ else rmSync(path, { force: true });
111
+ }
112
+
113
+ /** Run only during setup or explicit cleanup of the local memory hooks. */
49
114
  export function syncMemoryHooks({
50
115
  credentials,
51
116
  clients = [],
117
+ enable = false,
52
118
  home = homedir(),
53
119
  env = process.env,
54
120
  cwd = process.cwd(),
55
121
  node = process.execPath,
56
- removeLegacy = false,
57
122
  packageRoot,
58
123
  } = {}) {
59
- const hint = projectMemoryHint(credentials);
124
+ if (typeof process.getuid !== "function")
125
+ return enable ? ["Pharaoh: project memory reminders need a POSIX client."] : [];
60
126
  const root = memoryClientRoot(home);
61
- const previous = readActiveMemoryClients(home);
62
- if (!removeLegacy && credentials?.project_memory_status === "unavailable") {
63
- return [
64
- "Pharaoh: project memory eligibility could not be checked. Existing hook settings were kept. Rerun normal setup to retry.",
65
- ];
66
- }
67
- if (!hint.length && !Object.keys(previous).length && !removeLegacy) return [];
127
+ const activePath = join(root, "active.json");
128
+ let active = readActiveMemoryClients(home);
129
+ const target = checkout(cwd);
68
130
  const notices = [];
69
- const active = {};
70
131
  const configured = [];
132
+
71
133
  let installed;
72
134
  let digest;
73
- const target = removeLegacy ? checkout(cwd) : eligibleCheckout(cwd, hint);
74
- const pendingCheckout =
75
- !target && hint.some((entry) => entry?.revision === null) ? checkout(cwd) : undefined;
76
- const pendingRoot =
77
- pendingCheckout &&
78
- hint.some(
79
- (entry) =>
80
- entry?.revision === null &&
81
- entry.repo?.toLowerCase() === pendingCheckout.repo.toLowerCase(),
82
- )
83
- ? pendingCheckout.root
84
- : undefined;
85
-
86
- if (hint.length) {
135
+ const hint = projectMemoryHint(credentials);
136
+ if (enable) {
137
+ if (credentials?.project_memory_status === "unavailable")
138
+ return [
139
+ "Pharaoh: project memory eligibility could not be checked. Retry setup to enable reminders.",
140
+ ];
141
+ if (!hint.length) return ["Pharaoh: this session has no project memory hook grant."];
87
142
  try {
88
- if (!hasMemoryGit || typeof process.getuid !== "function")
89
- throw new Error("Project memory needs a POSIX client with system Git.");
143
+ if (!hasMemoryGit) throw new Error("Project memory needs a POSIX client with system Git.");
144
+ const enrolledRoot = target && matchingMemoryHint(target, hint) ? target.root : undefined;
90
145
  const bundle = packagedMemoryBundle(packageRoot);
91
146
  digest = bundle.sha256;
92
- installed = bundledHook(installMemoryBundle(root, target?.root ?? pendingRoot, bundle));
147
+ installed = bundledHook(installMemoryBundle(root, enrolledRoot, bundle));
93
148
  } catch (error) {
94
- return [`Pharaoh: project memory setup unavailable. ${error.message}`];
149
+ return [`Pharaoh: project memory reminders unavailable. ${error.message}`];
95
150
  }
96
151
  }
97
152
 
98
- for (const client of ["claude-code", "codex"]) {
99
- const desired = clients.includes(client) ? installed : undefined;
100
- if (!desired && !previous[client] && !removeLegacy) continue;
153
+ for (const client of CLIENTS) {
154
+ if (enable && !clients.includes(client)) continue;
155
+ const next = { ...active, [client]: digest };
101
156
  try {
102
- const path = userSettings(client, home, env);
103
- const before = readHookSettings(path);
104
- writeSettings(path, before, updateUserHooks(before, client, desired, node));
105
- if (desired) {
106
- active[client] = digest;
157
+ // Enable the replacement before swapping any settings, so failed
158
+ // activation cannot strand an existing hook or remove a legacy one.
159
+ const replacing = enable && active[client] !== digest;
160
+ if (replacing) writeActivation(activePath, next);
161
+ try {
162
+ syncUserHooks(client, home, env, target, installed, node, active[client]);
163
+ } catch (error) {
164
+ if (replacing) writeActivation(activePath, active);
165
+ throw error;
166
+ }
167
+ if (enable) {
168
+ active = next;
107
169
  configured.push(client);
108
170
  }
109
171
  } catch {
110
- if (desired && previous[client]) active[client] = previous[client];
111
- if (desired || previous[client])
112
- notices.push(
113
- `Pharaoh: project memory could not update ${client} hooks. Check the client settings and rerun normal setup.`,
114
- );
172
+ notices.push(
173
+ `Pharaoh: could not update ${client} project memory hooks. Check the client settings and retry.`,
174
+ );
115
175
  }
116
176
  }
117
177
 
118
- // This private activation record makes older hook commands inert even if a
119
- // client still has them loaded or its settings could not be updated.
120
- const activePath = join(root, "active.json");
121
- if (Object.keys(active).length) {
122
- if (JSON.stringify(previous) !== JSON.stringify(active))
123
- atomicWrite(activePath, `${JSON.stringify(active)}\n`);
124
- if (configured.length) {
178
+ // 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);
181
+ if (configured.length) {
182
+ notices.push(
183
+ "Pharaoh project memory reminders are set up for your eligible repositories. Start a new local conversation.",
184
+ );
185
+ if (hint.some((entry) => entry?.revision === null))
125
186
  notices.push(
126
- "Pharaoh project memory is set up for your eligible repositories. Start a new local conversation.",
187
+ "A connected repository is still mapping. Its reminder will run after the mapped revision is available.",
127
188
  );
128
- if (hint.some((entry) => entry?.revision === null))
129
- notices.push(
130
- "A connected repository is still mapping. Its hooks will start memory automatically when the mapped revision is available.",
131
- );
132
- if (configured.includes("codex"))
133
- notices.push("In Codex, review and trust the Pharaoh hooks in /hooks or Settings > Hooks.");
134
- }
135
- } else if (Object.keys(previous).length) {
136
- rmSync(activePath, { force: true });
137
- }
138
- // The old checkout hook is a fallback until its replacement settings and
139
- // the private activation record are both in place.
140
- for (const client of ["claude-code", "codex"]) {
141
- if (!removeLegacy && !configured.includes(client)) continue;
142
- try {
143
- migrateCheckout(client, target);
144
- } catch {
189
+ if (configured.includes("codex"))
145
190
  notices.push(
146
- `Pharaoh: the earlier ${client} checkout hook remains in place for this checkout.`,
191
+ "In Codex, review and trust the Pharaoh reminder in /hooks or Settings > Hooks.",
147
192
  );
148
- }
149
193
  }
150
194
  return notices;
151
195
  }
@@ -33,7 +33,7 @@ export function projectMemorySession(credentials) {
33
33
  return session;
34
34
  }
35
35
 
36
- function matchingHint(target, hints) {
36
+ export function matchingMemoryHint(target, hints) {
37
37
  return hints.find(
38
38
  (entry) =>
39
39
  typeof entry?.repo === "string" && entry.repo.toLowerCase() === target.repo.toLowerCase(),
@@ -51,12 +51,6 @@ function hasMappedCommit(target, revision, deadline = Infinity) {
51
51
  }
52
52
  }
53
53
 
54
- export function eligibleCheckout(cwd, hints) {
55
- if (!hints.length) return;
56
- const target = checkout(cwd);
57
- if (target && hasMappedCommit(target, matchingHint(target, hints)?.revision)) return target;
58
- }
59
-
60
54
  function hash(value) {
61
55
  return createHash("sha256").update(value).digest("hex");
62
56
  }
@@ -142,7 +136,7 @@ export async function cachedMemoryCheckout(cwd, credentials) {
142
136
  const hints = projectMemoryHint(credentials);
143
137
  if (!hints.length) return;
144
138
  const target = checkout(cwd);
145
- const hint = target && matchingHint(target, hints);
139
+ const hint = target && matchingMemoryHint(target, hints);
146
140
  if (hint) return resolveMappedCheckout(target, credentials, hint, false);
147
141
  }
148
142
 
@@ -169,7 +163,7 @@ export async function resolveMemoryHookTarget(
169
163
  const hints = projectMemoryHint(credentials);
170
164
  if (!hints.length) return;
171
165
  const target = checkout(cwd, deadline);
172
- const hint = target && matchingHint(target, hints);
166
+ const hint = target && matchingMemoryHint(target, hints);
173
167
  if (!hint) return;
174
168
  try {
175
169
  // A checkout enrolled by the original pilot may still have its own hook.