@pharaoh-so/mcp 0.3.54 → 0.3.56

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 (43) hide show
  1. package/.mcp.json +1 -1
  2. package/CHANGELOG.md +31 -0
  3. package/LICENSE +1 -1
  4. package/NOTICE +28 -0
  5. package/README.md +5 -5
  6. package/commands/plan.md +11 -11
  7. package/commands/review.md +2 -2
  8. package/dist/auth.d.ts +5 -0
  9. package/dist/credentials.d.ts +8 -0
  10. package/dist/gateway.js +1 -1
  11. package/dist/helpers.d.ts +1 -1
  12. package/dist/helpers.js +18 -2
  13. package/dist/index.js +47 -28
  14. package/dist/install-skills.d.ts +4 -3
  15. package/dist/install-skills.js +12 -8
  16. package/dist/refresh.d.ts +8 -2
  17. package/dist/refresh.js +33 -6
  18. package/dist/setup.d.ts +5 -0
  19. package/dist/setup.js +66 -0
  20. package/inspect-tools.json +89 -36
  21. package/{skills → npx-skills}/brainstorm/SKILL.md +2 -1
  22. package/{skills → npx-skills}/debug/SKILL.md +2 -1
  23. package/{skills → npx-skills}/pharaoh/SKILL.md +13 -6
  24. package/{skills → npx-skills}/plan/SKILL.md +13 -14
  25. package/npx-skills/refactor/SKILL.md +38 -0
  26. package/{skills → npx-skills}/review/SKILL.md +4 -5
  27. package/{skills → npx-skills}/tdd/SKILL.md +2 -1
  28. package/{skills → npx-skills}/verify/SKILL.md +2 -1
  29. package/package.json +6 -4
  30. package/project-memory/bundle.mjs +137 -0
  31. package/project-memory/execute-turn.mjs +240 -0
  32. package/project-memory/hook-config.mjs +91 -0
  33. package/project-memory/hook-events.mjs +141 -0
  34. package/project-memory/local.mjs +463 -0
  35. package/project-memory/memory-api.mjs +182 -0
  36. package/project-memory/pharaoh-memory-hook.mjs +33 -0
  37. package/project-memory/pharaoh-memory.mjs +55 -0
  38. package/project-memory/setup.d.mts +14 -0
  39. package/project-memory/setup.mjs +151 -0
  40. package/project-memory/user-target.mjs +221 -0
  41. package/trace-app.html +222 -101
  42. package/skills/.gitkeep +0 -0
  43. package/skills/refactor/SKILL.md +0 -34
@@ -0,0 +1,182 @@
1
+ export const PROJECT_MEMORY_ORIGIN = "https://mcp.pharaoh.so";
2
+ const READY_TIMEOUT_MS = 1_200;
3
+ const BUILD = new URL("runtime/refresh.js", import.meta.url);
4
+ const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
5
+ const STATUS = new Set(["candidate", "approved", "superseded", "retracted"]);
6
+
7
+ export class PilotError extends Error {}
8
+
9
+ async function responseJson(response) {
10
+ if (!response.body) throw new PilotError("Pharaoh returned an incomplete response.");
11
+ const chunks = [];
12
+ let bytes = 0;
13
+ for await (const chunk of response.body) {
14
+ bytes += chunk.length;
15
+ if (bytes > 32_768) throw new PilotError("Pharaoh returned an oversized response.");
16
+ chunks.push(chunk);
17
+ }
18
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
19
+ }
20
+
21
+ export async function refreshHelper() {
22
+ try {
23
+ const helper = await import(BUILD.href);
24
+ if (typeof helper.originBoundRefresh === "function") return helper;
25
+ } catch {
26
+ // Missing or incomplete build.
27
+ }
28
+ throw new PilotError(
29
+ "The installed project memory bundle has no compatible refresh helper. Rerun normal Pharaoh setup.",
30
+ );
31
+ }
32
+
33
+ /** Check only mapping metadata using the current device session. Normal proxy
34
+ * startup handles token refresh; the prompt hook never waits on a refresh lock. */
35
+ export async function requestMemoryReadiness(repo, credentials) {
36
+ const expiresAt = Date.parse(credentials?.expires_at);
37
+ if (typeof credentials?.access_token !== "string" || !(expiresAt > Date.now())) return;
38
+ try {
39
+ if (new URL(credentials.sse_url).origin !== PROJECT_MEMORY_ORIGIN) return;
40
+ const response = await fetch(`${PROJECT_MEMORY_ORIGIN}/api/project-memory/readiness`, {
41
+ method: "POST",
42
+ redirect: "error",
43
+ headers: {
44
+ "Content-Type": "application/json",
45
+ Authorization: `Bearer ${credentials.access_token}`,
46
+ },
47
+ body: JSON.stringify({ repo }),
48
+ signal: AbortSignal.timeout(READY_TIMEOUT_MS),
49
+ });
50
+ if (!response.ok) {
51
+ await response.body?.cancel();
52
+ return;
53
+ }
54
+ const result = await responseJson(response);
55
+ return result?.revision;
56
+ } catch {
57
+ // Pending, denied, unavailable and timed-out lookups leave hooks dormant.
58
+ }
59
+ }
60
+
61
+ export async function requestMemory(operation, payload, { fetcher = fetch, helper } = {}) {
62
+ if (!["recall", "capture", "expand"].includes(operation))
63
+ throw new PilotError("Unknown memory request.");
64
+ const { originBoundRefresh } = helper ?? (await refreshHelper());
65
+ const refresh = originBoundRefresh(PROJECT_MEMORY_ORIGIN);
66
+ const outcome = await refresh.load();
67
+ if (!outcome?.ok)
68
+ throw new PilotError(
69
+ "Connect to Pharaoh with its device sign-in, or retry if sign-in is temporarily unavailable.",
70
+ );
71
+ const send = async (creds) => {
72
+ try {
73
+ return await fetcher(`${PROJECT_MEMORY_ORIGIN}/api/project-memory/${operation}`, {
74
+ method: "POST",
75
+ redirect: "error",
76
+ headers: {
77
+ "Content-Type": "application/json",
78
+ Authorization: `Bearer ${creds.access_token}`,
79
+ },
80
+ body: JSON.stringify(payload),
81
+ signal: AbortSignal.timeout(12_000),
82
+ });
83
+ } catch {
84
+ throw new PilotError(
85
+ operation === "capture"
86
+ ? "Pharaoh did not confirm whether this capture was saved. Check the project memory inspector before retrying."
87
+ : "Pharaoh project memory could not respond before the request ended.",
88
+ );
89
+ }
90
+ };
91
+ const response = await send(outcome.creds);
92
+ if (!response.ok) {
93
+ await response.body?.cancel();
94
+ // The shared credential file can now name a different OAuth session. Do
95
+ // not reuse an already prepared operation under newly loaded credentials.
96
+ if (response.status === 401)
97
+ throw new PilotError("Pharaoh rejected this sign-in. Reconnect before trying again.");
98
+ if (response.status === 404)
99
+ throw new PilotError("Project memory is unavailable for this repository or account.");
100
+ if (response.status === 400 || response.status === 413)
101
+ throw new PilotError(
102
+ "Pharaoh rejected the memory payload. Check its shape, size, and sensitive content.",
103
+ );
104
+ if (response.status === 409)
105
+ throw new PilotError(
106
+ "Pharaoh could not save this candidate because the operation conflicts or the project log is full.",
107
+ );
108
+ if (response.status === 429)
109
+ throw new PilotError("Pharaoh project memory reached its request limit.");
110
+ if (operation === "capture" && response.status >= 500) {
111
+ throw new PilotError(
112
+ "Pharaoh did not confirm whether this capture was saved. Check the project memory inspector before retrying.",
113
+ );
114
+ }
115
+ throw new PilotError("Pharaoh project memory is temporarily unavailable.");
116
+ }
117
+ let result;
118
+ try {
119
+ result = await responseJson(response);
120
+ } catch (error) {
121
+ if (operation === "capture")
122
+ throw new PilotError(
123
+ "Pharaoh did not confirm whether this capture was saved. Check the project memory inspector before retrying.",
124
+ );
125
+ throw error;
126
+ }
127
+ if (
128
+ operation === "recall" &&
129
+ Array.isArray(result?.records) &&
130
+ result.records.length <= 5 &&
131
+ result.records.every((record) => typeof record?.id === "string" && UUID.test(record.id))
132
+ ) {
133
+ if (payload.format === "compact-v1") {
134
+ if (
135
+ result.format === "compact-v1" &&
136
+ typeof result.repo === "string" &&
137
+ result.repo.toLowerCase() === payload.repo.toLowerCase() &&
138
+ typeof result.moreMatches === "boolean" &&
139
+ Number.isInteger(result.omittedRecords) &&
140
+ result.omittedRecords >= 0 &&
141
+ result.records.every(
142
+ (record) =>
143
+ Number.isInteger(record.version) &&
144
+ record.version >= 1 &&
145
+ record.version <= 1_000_000 &&
146
+ STATUS.has(record.status),
147
+ )
148
+ )
149
+ return result;
150
+ throw new PilotError("Pharaoh returned an incomplete compact recall packet.");
151
+ }
152
+ return { records: result.records, graph: result.graph };
153
+ }
154
+ if (
155
+ operation === "expand" &&
156
+ result?.record?.id === payload.id &&
157
+ result.record.version === payload.version &&
158
+ STATUS.has(result.record.status) &&
159
+ Number.isInteger(result.current?.version) &&
160
+ result.current.version >= payload.version &&
161
+ STATUS.has(result.current.status)
162
+ ) {
163
+ const omitted = result.omittedHistoricalEvidence;
164
+ return {
165
+ record: result.record,
166
+ current: result.current,
167
+ ...(Number.isInteger(omitted) && omitted >= 0 && { omittedHistoricalEvidence: omitted }),
168
+ };
169
+ }
170
+ if (
171
+ operation === "capture" &&
172
+ typeof result?.record?.id === "string" &&
173
+ UUID.test(result.record.id)
174
+ ) {
175
+ return { record: { id: result.record.id, status: result.record.status } };
176
+ }
177
+ if (operation === "capture")
178
+ throw new PilotError(
179
+ "Pharaoh did not confirm whether this capture was saved. Check the project memory inspector before retrying.",
180
+ );
181
+ throw new PilotError("Pharaoh returned an incomplete response.");
182
+ }
@@ -0,0 +1,33 @@
1
+ import { dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { handleEvent } from "./hook-events.mjs";
4
+ import { readStdin } from "./local.mjs";
5
+ import {
6
+ activeMemoryRuntime,
7
+ readMemoryCredentials,
8
+ resolveMemoryHookTarget,
9
+ } from "./user-target.mjs";
10
+
11
+ try {
12
+ const [client, root, repo] = process.argv.slice(2);
13
+ const input = await readStdin(1024 * 1024);
14
+ let target = { root, repo };
15
+ if (root === "--user") {
16
+ const runtime = dirname(fileURLToPath(import.meta.url));
17
+ if (!activeMemoryRuntime(client, runtime)) process.exit(0);
18
+ const eligible = await resolveMemoryHookTarget(
19
+ client,
20
+ input?.cwd,
21
+ await readMemoryCredentials(),
22
+ input?.hook_event_name === "UserPromptSubmit" &&
23
+ typeof input?.prompt === "string" &&
24
+ Boolean(input.prompt.trim()),
25
+ );
26
+ if (!eligible) process.exit(0);
27
+ target = { ...eligible, runtime };
28
+ }
29
+ const result = handleEvent(input, { client, ...target });
30
+ if (result) process.stdout.write(JSON.stringify(result));
31
+ } catch {
32
+ // Fail open. handleEvent reports state failures when the prompt is eligible.
33
+ }
@@ -0,0 +1,55 @@
1
+ import { dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { executeTurn } from "./execute-turn.mjs";
4
+ import { checkout, readStdin, turnStore } from "./local.mjs";
5
+ import { PilotError, refreshHelper } from "./memory-api.mjs";
6
+ import {
7
+ activeMemoryRuntime,
8
+ cachedMemoryCheckout,
9
+ readMemoryCredentials,
10
+ } from "./user-target.mjs";
11
+
12
+ async function main(args) {
13
+ const [operation, option, token, compactFlag, ...extra] = args;
14
+ if (operation === "doctor" && args.length === 1) {
15
+ await refreshHelper();
16
+ process.stdout.write("The local Pharaoh refresh helper is ready.\n");
17
+ return;
18
+ }
19
+ if (
20
+ option !== "--turn" ||
21
+ !/^[a-f0-9]{64}$/.test(token) ||
22
+ extra.length > 0 ||
23
+ (compactFlag !== undefined && (compactFlag !== "--compact" || operation !== "recall")) ||
24
+ !["recall", "expand", "capture", "nothing-material", "receipt"].includes(operation)
25
+ ) {
26
+ throw new PilotError(
27
+ "Use doctor, or recall, expand, capture, receipt, or nothing-material with the active hook's --turn token.",
28
+ );
29
+ }
30
+ const cwd = process.cwd();
31
+ const target = checkout(cwd);
32
+ if (!target) throw new PilotError("Run this inside the enrolled GitHub checkout.");
33
+ const runtime = dirname(fileURLToPath(import.meta.url));
34
+ const active =
35
+ activeMemoryRuntime("claude-code", runtime) || activeMemoryRuntime("codex", runtime);
36
+ const eligible = active && (await cachedMemoryCheckout(cwd, await readMemoryCredentials()));
37
+ const result = await executeTurn(operation, token, {
38
+ target: eligible ? { ...target, runtime } : target,
39
+ store: turnStore(),
40
+ read: () => readStdin(8_192),
41
+ compact: compactFlag === "--compact" || process.env.PHARAOH_PROJECT_MEMORY_COMPACT === "true",
42
+ });
43
+ process.stdout.write(`${JSON.stringify(result)}\n`);
44
+ }
45
+
46
+ try {
47
+ await main(process.argv.slice(2));
48
+ } catch (error) {
49
+ const message =
50
+ error instanceof PilotError
51
+ ? error.message
52
+ : "Pharaoh project memory is temporarily unavailable.";
53
+ process.stderr.write(`${message}\n`);
54
+ process.exitCode = 1;
55
+ }
@@ -0,0 +1,14 @@
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
+ home?: string;
9
+ env?: NodeJS.ProcessEnv;
10
+ cwd?: string;
11
+ node?: string;
12
+ removeLegacy?: boolean;
13
+ packageRoot?: URL;
14
+ }): string[];
@@ -0,0 +1,151 @@
1
+ import { lstatSync, mkdirSync, realpathSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { bundledHook, installMemoryBundle, packagedMemoryBundle } from "./bundle.mjs";
5
+ import { hasLegacyHooks, updateHooks, updateUserHooks } from "./hook-config.mjs";
6
+ import { atomicWrite, checkout, git, hasMemoryGit } from "./local.mjs";
7
+ import {
8
+ eligibleCheckout,
9
+ legacySettingsPath,
10
+ memoryClientRoot,
11
+ projectMemoryHint,
12
+ readActiveMemoryClients,
13
+ readHookSettings,
14
+ } from "./user-target.mjs";
15
+
16
+ function userSettings(client, home, env) {
17
+ const codex = client === "codex";
18
+ const directory =
19
+ (codex ? env.CODEX_HOME : env.CLAUDE_CONFIG_DIR) ?? join(home, codex ? ".codex" : ".claude");
20
+ if (!isAbsolute(directory)) throw new Error("Client configuration directory must be absolute.");
21
+ return join(directory, codex ? "hooks.json" : "settings.json");
22
+ }
23
+
24
+ function writeSettings(path, previous, next) {
25
+ if (JSON.stringify(previous) === JSON.stringify(next)) return;
26
+ const directory = dirname(path);
27
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
28
+ const info = lstatSync(directory);
29
+ if (!info.isDirectory() || info.uid !== process.getuid?.())
30
+ throw new Error("Client settings directory is not owner-controlled.");
31
+ atomicWrite(path, `${JSON.stringify(next, null, "\t")}\n`);
32
+ }
33
+
34
+ function migrateCheckout(client, target) {
35
+ 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));
46
+ }
47
+
48
+ /** Run only as part of a deliberate normal setup, upgrade, clean or logout. */
49
+ export function syncMemoryHooks({
50
+ credentials,
51
+ clients = [],
52
+ home = homedir(),
53
+ env = process.env,
54
+ cwd = process.cwd(),
55
+ node = process.execPath,
56
+ removeLegacy = false,
57
+ packageRoot,
58
+ } = {}) {
59
+ const hint = projectMemoryHint(credentials);
60
+ 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 [];
68
+ const notices = [];
69
+ const active = {};
70
+ const configured = [];
71
+ let installed;
72
+ 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) {
87
+ try {
88
+ if (!hasMemoryGit || typeof process.getuid !== "function")
89
+ throw new Error("Project memory needs a POSIX client with system Git.");
90
+ const bundle = packagedMemoryBundle(packageRoot);
91
+ digest = bundle.sha256;
92
+ installed = bundledHook(installMemoryBundle(root, target?.root ?? pendingRoot, bundle));
93
+ } catch (error) {
94
+ return [`Pharaoh: project memory setup unavailable. ${error.message}`];
95
+ }
96
+ }
97
+
98
+ for (const client of ["claude-code", "codex"]) {
99
+ const desired = clients.includes(client) ? installed : undefined;
100
+ if (!desired && !previous[client] && !removeLegacy) continue;
101
+ 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;
107
+ configured.push(client);
108
+ }
109
+ } 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
+ );
115
+ }
116
+ }
117
+
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) {
125
+ notices.push(
126
+ "Pharaoh project memory is set up for your eligible repositories. Start a new local conversation.",
127
+ );
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 {
145
+ notices.push(
146
+ `Pharaoh: the earlier ${client} checkout hook remains in place for this checkout.`,
147
+ );
148
+ }
149
+ }
150
+ return notices;
151
+ }
@@ -0,0 +1,221 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstatSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hasLegacyHooks } from "./hook-config.mjs";
6
+ import { atomicWrite, checkout, git } from "./local.mjs";
7
+ import { PROJECT_MEMORY_ORIGIN, requestMemoryReadiness } from "./memory-api.mjs";
8
+
9
+ const REVISION = /^(?!0{40}$)[a-f0-9]{40}$/;
10
+ const READINESS_RETRY_MS = 15_000;
11
+ const LEGACY_SETTINGS = {
12
+ "claude-code": ".claude/settings.local.json",
13
+ codex: ".codex/hooks.json",
14
+ };
15
+
16
+ export function legacySettingsPath(client, root) {
17
+ return join(root, LEGACY_SETTINGS[client]);
18
+ }
19
+
20
+ export function projectMemoryHint(credentials) {
21
+ if (!credentials || !Array.isArray(credentials.project_memory)) return [];
22
+ try {
23
+ if (new URL(credentials.sse_url).origin !== PROJECT_MEMORY_ORIGIN) return [];
24
+ } catch {
25
+ return [];
26
+ }
27
+ return credentials.project_memory;
28
+ }
29
+
30
+ function matchingHint(target, hints) {
31
+ return hints.find(
32
+ (entry) =>
33
+ typeof entry?.repo === "string" && entry.repo.toLowerCase() === target.repo.toLowerCase(),
34
+ );
35
+ }
36
+
37
+ function hasMappedCommit(target, revision) {
38
+ if (typeof revision !== "string" || !REVISION.test(revision)) return false;
39
+ try {
40
+ // An origin URL is editable. Require an actual GitHub-mapped commit
41
+ // locally, without fetching, consulting replace refs, or running hooks.
42
+ return git(target.root, "cat-file", "-t", revision) === "commit";
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export function eligibleCheckout(cwd, hints) {
49
+ if (!hints.length) return;
50
+ const target = checkout(cwd);
51
+ if (target && hasMappedCommit(target, matchingHint(target, hints)?.revision)) return target;
52
+ }
53
+
54
+ function hash(value) {
55
+ return createHash("sha256").update(value).digest("hex");
56
+ }
57
+
58
+ function readReadiness(path, binding) {
59
+ try {
60
+ const stat = lstatSync(path);
61
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.mode & 0o077 || stat.size > 512)
62
+ return;
63
+ const state = JSON.parse(readFileSync(path, "utf8"));
64
+ if (state.binding === binding) return state;
65
+ } catch {
66
+ // A missing or unreadable cache cannot authorize a checkout.
67
+ }
68
+ }
69
+
70
+ function tryReadinessLease(path, now) {
71
+ try {
72
+ try {
73
+ const stat = lstatSync(path);
74
+ if (
75
+ !stat.isFile() ||
76
+ stat.uid !== process.getuid?.() ||
77
+ now - stat.mtimeMs < READINESS_RETRY_MS
78
+ )
79
+ return;
80
+ rmSync(path, { force: true });
81
+ } catch (error) {
82
+ if (error.code !== "ENOENT") return;
83
+ }
84
+ const owner = randomUUID();
85
+ writeFileSync(path, owner, { flag: "wx", mode: 0o600 });
86
+ return () => {
87
+ try {
88
+ if (readFileSync(path, "utf8") === owner) rmSync(path, { force: true });
89
+ } catch {
90
+ // A stale lease expires without blocking ordinary work.
91
+ }
92
+ };
93
+ } catch {
94
+ // Another hook is already checking or private local state is unavailable.
95
+ }
96
+ }
97
+
98
+ async function resolveRevision(repo, credentials, probe) {
99
+ const session = credentials.project_memory_session;
100
+ if (typeof session !== "string" || !/^[0-9a-f-]{36}$/.test(session)) return;
101
+ const binding = hash(session);
102
+ const path = join(memoryClientRoot(), `ready-${hash(repo.toLowerCase())}.json`);
103
+ const saved = readReadiness(path, binding);
104
+ if (typeof saved?.revision === "string" && REVISION.test(saved.revision)) return saved.revision;
105
+ const now = Date.now();
106
+ if (!probe || now - (saved?.checkedAt ?? 0) < READINESS_RETRY_MS) return;
107
+ const release = tryReadinessLease(`${path}.lock`, now);
108
+ if (!release) return;
109
+ try {
110
+ const latest = readReadiness(path, binding);
111
+ if (typeof latest?.revision === "string" && REVISION.test(latest.revision))
112
+ return latest.revision;
113
+ if (now - (latest?.checkedAt ?? 0) < READINESS_RETRY_MS) return;
114
+ atomicWrite(path, JSON.stringify({ binding, checkedAt: now }));
115
+ const revision = await requestMemoryReadiness(repo, credentials);
116
+ if (typeof revision === "string" && REVISION.test(revision)) {
117
+ atomicWrite(path, JSON.stringify({ binding, checkedAt: now, revision }));
118
+ return revision;
119
+ }
120
+ } catch {
121
+ // A cache failure must not activate memory or block the normal prompt.
122
+ } finally {
123
+ release();
124
+ }
125
+ }
126
+
127
+ async function resolveMappedCheckout(target, credentials, hint, probe) {
128
+ const revision =
129
+ hint.revision === null ? await resolveRevision(hint.repo, credentials, probe) : hint.revision;
130
+ if (hasMappedCommit(target, revision)) return target;
131
+ }
132
+
133
+ export async function cachedMemoryCheckout(cwd, credentials) {
134
+ const hints = projectMemoryHint(credentials);
135
+ if (!hints.length) return;
136
+ const target = checkout(cwd);
137
+ const hint = target && matchingHint(target, hints);
138
+ if (hint) return resolveMappedCheckout(target, credentials, hint, false);
139
+ }
140
+
141
+ export function readHookSettings(path) {
142
+ try {
143
+ const info = lstatSync(path);
144
+ if (!info.isFile() || info.uid !== process.getuid?.() || info.size > 512 * 1024)
145
+ throw new Error("Expected a regular owner-controlled hook settings file.");
146
+ return JSON.parse(readFileSync(path, "utf8"));
147
+ } catch (error) {
148
+ if (error.code === "ENOENT") return {};
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ export async function resolveMemoryHookTarget(client, cwd, credentials, probe = false) {
154
+ if (!Object.hasOwn(LEGACY_SETTINGS, client)) return;
155
+ const hints = projectMemoryHint(credentials);
156
+ if (!hints.length) return;
157
+ const target = checkout(cwd);
158
+ const hint = target && matchingHint(target, hints);
159
+ if (!hint) return;
160
+ try {
161
+ // A checkout enrolled by the original pilot may still have its own hook.
162
+ // Leave that hook in charge until normal setup migrates the checkout.
163
+ if (
164
+ hasLegacyHooks(readHookSettings(legacySettingsPath(client, target.root)), client, target.root)
165
+ )
166
+ return;
167
+ } catch {
168
+ // A client settings file we cannot inspect is not safe to double-enroll.
169
+ return;
170
+ }
171
+ return resolveMappedCheckout(target, credentials, hint, probe);
172
+ }
173
+
174
+ export function memoryClientRoot(home = homedir()) {
175
+ return join(home, ".pharaoh", "project-memory-client");
176
+ }
177
+
178
+ export function readActiveMemoryClients(home = homedir()) {
179
+ try {
180
+ const root = memoryClientRoot(home);
181
+ const directory = lstatSync(root);
182
+ if (!directory.isDirectory() || directory.uid !== process.getuid?.() || directory.mode & 0o077)
183
+ return {};
184
+ const path = join(root, "active.json");
185
+ const file = lstatSync(path);
186
+ if (!file.isFile() || file.uid !== process.getuid?.() || file.mode & 0o077 || file.size > 512)
187
+ return {};
188
+ const active = JSON.parse(readFileSync(path, "utf8"));
189
+ return Object.fromEntries(
190
+ Object.keys(LEGACY_SETTINGS).flatMap((client) => {
191
+ const digest = active?.[client];
192
+ return typeof digest === "string" && /^[a-f0-9]{64}$/.test(digest)
193
+ ? [[client, digest]]
194
+ : [];
195
+ }),
196
+ );
197
+ } catch {
198
+ return {};
199
+ }
200
+ }
201
+
202
+ export function activeMemoryRuntime(client, directory, home = homedir()) {
203
+ const digest = readActiveMemoryClients(home)[client];
204
+ return Boolean(digest && directory === join(realpathSync(memoryClientRoot(home)), digest));
205
+ }
206
+
207
+ export async function readMemoryCredentials() {
208
+ try {
209
+ const { CREDENTIALS_PATH, readCredentials } = await import("./runtime/credentials.js");
210
+ const stat = lstatSync(CREDENTIALS_PATH);
211
+ if (
212
+ stat.isFile() &&
213
+ stat.uid === process.getuid?.() &&
214
+ !(stat.mode & 0o077) &&
215
+ stat.size <= 65_536
216
+ )
217
+ return readCredentials();
218
+ } catch {
219
+ // No current owner-private credential file.
220
+ }
221
+ }