@pharaoh-so/mcp 0.3.53 → 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 +41 -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 +15 -5
  12. package/dist/helpers.js +71 -2
  13. package/dist/index.js +51 -26
  14. package/dist/install-skills.d.ts +30 -5
  15. package/dist/install-skills.js +210 -11
  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,463 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ renameSync,
12
+ rmdirSync,
13
+ rmSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, isAbsolute, join } from "node:path";
19
+
20
+ const TURN_TTL = 12 * 60 * 60 * 1000;
21
+ const LOCK_MIN_AGE = 15_000;
22
+ const LOCK_OWNER = /^owner-[a-f0-9-]{36}\.json$/;
23
+ const TOKEN = /^[a-f0-9]{64}$/;
24
+ const GIT = ["/usr/bin/git", "/opt/homebrew/bin/git", "/usr/local/bin/git"].find(existsSync);
25
+ export const hasMemoryGit = Boolean(GIT);
26
+ const GIT_TIMEOUT_MS = 750;
27
+ const VISIBLE_INDEX_TAG = Buffer.from("H ");
28
+ export const REPO = /^[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9_.-]{1,100}$/i;
29
+
30
+ function gitProcess(cwd, args) {
31
+ if (!GIT) throw new Error("Project memory could not locate a system Git executable.");
32
+ const env = {
33
+ ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))),
34
+ GIT_NO_LAZY_FETCH: "1",
35
+ GIT_ALLOW_PROTOCOL: "",
36
+ };
37
+ return {
38
+ args: [
39
+ "--no-optional-locks",
40
+ "--no-replace-objects",
41
+ "--no-pager",
42
+ "-c",
43
+ "core.fsmonitor=false",
44
+ "-c",
45
+ "core.untrackedCache=false",
46
+ "-C",
47
+ cwd,
48
+ ...args,
49
+ ],
50
+ env,
51
+ stdio: ["ignore", "pipe", "ignore"],
52
+ };
53
+ }
54
+
55
+ export function git(cwd, ...command) {
56
+ const { args, ...options } = gitProcess(cwd, command);
57
+ return execFileSync(GIT, args, {
58
+ ...options,
59
+ encoding: "utf8",
60
+ maxBuffer: 8192,
61
+ timeout: GIT_TIMEOUT_MS,
62
+ }).trim();
63
+ }
64
+
65
+ /** Check one literal path or the entire index for flags that can hide worktree edits. */
66
+ export async function visibleGitIndex(cwd, path) {
67
+ try {
68
+ const paths = path ? [`:(literal)${path}`] : [];
69
+ const { args, ...options } = gitProcess(cwd, ["ls-files", "-v", "-z", "--", ...paths]);
70
+ const signal = AbortSignal.timeout(GIT_TIMEOUT_MS);
71
+ const child = spawn(GIT, args, {
72
+ ...options,
73
+ signal,
74
+ killSignal: "SIGKILL",
75
+ });
76
+ return await new Promise((resolve) => {
77
+ let prefix = 0;
78
+ let hasName = false;
79
+ let sawEntry = false;
80
+ let failed = false;
81
+ let ended = false;
82
+ function stop() {
83
+ failed = true;
84
+ child.stdout.destroy();
85
+ child.kill("SIGKILL");
86
+ }
87
+ child.on("error", () => {
88
+ failed = true;
89
+ });
90
+ child.stdout.once("end", () => {
91
+ ended = true;
92
+ });
93
+ child.stdout.on("error", stop);
94
+ child.stdout.on("data", (chunk) => {
95
+ // Only H is visible. Lowercase tags and S can hide changed files.
96
+ // Names can contain newlines. Carry only framing state across chunks.
97
+ for (const byte of chunk) {
98
+ if (prefix < VISIBLE_INDEX_TAG.length) {
99
+ if (byte !== VISIBLE_INDEX_TAG[prefix]) return stop();
100
+ prefix++;
101
+ } else if (byte === 0) {
102
+ if (!hasName) return stop();
103
+ sawEntry = true;
104
+ prefix = 0;
105
+ hasName = false;
106
+ } else {
107
+ hasName = true;
108
+ }
109
+ }
110
+ });
111
+ child.once("close", (code) =>
112
+ resolve(!failed && !signal.aborted && code === 0 && ended && sawEntry && prefix === 0),
113
+ );
114
+ });
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /** Accept only an unambiguous GitHub origin, independent of the branch name. */
121
+ export function checkout(cwd) {
122
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) return;
123
+ try {
124
+ const root = realpathSync(git(cwd, "rev-parse", "--show-toplevel"));
125
+ const origin = git(root, "config", "--local", "--get-all", "remote.origin.url");
126
+ const repo = /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^\n]+)$/i
127
+ .exec(origin)?.[1]
128
+ .replace(/\.git$/i, "");
129
+ if (repo && REPO.test(repo)) return { root, repo };
130
+ } catch {
131
+ // Non-repositories and unreadable worktrees are outside the pilot.
132
+ }
133
+ }
134
+
135
+ export function shellWord(value) {
136
+ return `'${value.replaceAll("'", `'\\''`)}'`;
137
+ }
138
+
139
+ export async function readStdin(maxBytes) {
140
+ const chunks = [];
141
+ let bytes = 0;
142
+ for await (const chunk of process.stdin) {
143
+ bytes += chunk.length;
144
+ if (bytes > maxBytes) throw new Error("Input exceeds the size limit.");
145
+ chunks.push(chunk);
146
+ }
147
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
148
+ }
149
+
150
+ export function atomicWrite(path, contents) {
151
+ const temporary = join(dirname(path), `.pharaoh-memory-${randomUUID()}.tmp`);
152
+ try {
153
+ writeFileSync(temporary, contents, { flag: "wx", mode: 0o600 });
154
+ renameSync(temporary, path);
155
+ } finally {
156
+ rmSync(temporary, { force: true });
157
+ }
158
+ }
159
+
160
+ function readJson(path) {
161
+ try {
162
+ const file = lstatSync(path);
163
+ if (!file.isFile() || (file.mode & 0o077) !== 0 || file.size > 8192) return;
164
+ return JSON.parse(readFileSync(path, "utf8"));
165
+ } catch {
166
+ // State can expire or be removed by another event.
167
+ }
168
+ }
169
+
170
+ function makePrivateDirectory(path) {
171
+ mkdirSync(path, { mode: 0o700, recursive: true });
172
+ const info = lstatSync(path);
173
+ if (!info.isDirectory() || (info.mode & 0o077) !== 0 || info.uid !== process.getuid?.()) {
174
+ throw new Error("Project memory needs a private local state directory.");
175
+ }
176
+ }
177
+
178
+ export function sessionKey(client, sessionId) {
179
+ if (typeof sessionId !== "string" || !sessionId || sessionId.length > 256) return;
180
+ return createHash("sha256").update(`${client}\0${sessionId}`).digest("hex");
181
+ }
182
+
183
+ function releaseLock(path, owner) {
184
+ try {
185
+ unlinkSync(join(path, owner));
186
+ } catch (error) {
187
+ if (error.code === "ENOENT") return;
188
+ throw error;
189
+ }
190
+ try {
191
+ rmdirSync(path);
192
+ } catch (error) {
193
+ // A replacement always appears already populated, so rmdir cannot remove it.
194
+ if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
195
+ }
196
+ }
197
+
198
+ function acquireLock(directory, path, now) {
199
+ const owner = `owner-${randomUUID()}.json`;
200
+ const candidate = mkdtempSync(join(directory, "lock-candidate-"));
201
+ try {
202
+ writeFileSync(join(candidate, owner), JSON.stringify({ pid: process.pid, createdAt: now }), {
203
+ flag: "wx",
204
+ mode: 0o600,
205
+ });
206
+ // POSIX rename installs the prepared directory only if the destination is
207
+ // absent or empty. No process can see a new lock without its owner marker.
208
+ renameSync(candidate, path);
209
+ return () => releaseLock(path, owner);
210
+ } catch (error) {
211
+ if (!["EEXIST", "ENOTEMPTY"].includes(error.code)) throw error;
212
+ } finally {
213
+ rmSync(candidate, { recursive: true, force: true });
214
+ }
215
+ }
216
+
217
+ function recoverLock(path, now) {
218
+ let entries;
219
+ try {
220
+ entries = readdirSync(path);
221
+ } catch (error) {
222
+ if (error.code === "ENOENT") return true;
223
+ throw error;
224
+ }
225
+ if (entries.length === 0) return true;
226
+ if (entries.length !== 1 || !LOCK_OWNER.test(entries[0])) return false;
227
+ const [owner] = entries;
228
+ const metadata = readJson(join(path, owner));
229
+ if (
230
+ !Number.isInteger(metadata?.pid) ||
231
+ metadata.pid < 1 ||
232
+ metadata.pid > 2_147_483_647 ||
233
+ !Number.isFinite(metadata.createdAt) ||
234
+ now - metadata.createdAt < LOCK_MIN_AGE
235
+ )
236
+ return false;
237
+ try {
238
+ process.kill(metadata.pid, 0);
239
+ return false;
240
+ } catch (error) {
241
+ // A reused PID, a live owner, or inability to confirm death keeps the lock.
242
+ if (error.code !== "ESRCH") return false;
243
+ }
244
+ releaseLock(path, owner);
245
+ return true;
246
+ }
247
+
248
+ /** Serialize local state transitions only; network operations never hold this lock. */
249
+ export function turnStore(home = homedir(), now = Date.now) {
250
+ const directory = join(home, ".pharaoh", "project-memory-turns-v2");
251
+ makePrivateDirectory(directory);
252
+ const sessionPath = (key) => join(directory, `session-${key}.json`);
253
+ const indexPath = (token) => join(directory, `turn-${token}.json`);
254
+ const overlapPath = (key) => join(directory, `overlap-${key}.json`);
255
+ const receiptPath = (token) => join(directory, `receipt-${token}.json`);
256
+ function current(key) {
257
+ const state = readJson(sessionPath(key));
258
+ if (!state || state.expires > now()) return state;
259
+ remove(key, state);
260
+ }
261
+ function put(key, state) {
262
+ atomicWrite(sessionPath(key), JSON.stringify(state));
263
+ }
264
+ function remove(key, state) {
265
+ if (TOKEN.test(state?.token)) rmSync(indexPath(state.token), { force: true });
266
+ rmSync(sessionPath(key), { force: true });
267
+ }
268
+ function lock(key, fn, { onBusy, canClearFence, onFence } = {}) {
269
+ if (!TOKEN.test(key)) return;
270
+ const path = join(directory, `${key}.lock`);
271
+ const time = now();
272
+ const release =
273
+ acquireLock(directory, path, time) ??
274
+ (recoverLock(path, time) ? acquireLock(directory, path, time) : undefined);
275
+ if (!release) return onBusy?.();
276
+ try {
277
+ const stored = readJson(overlapPath(key));
278
+ const fence = typeof stored === "number" ? { expires: stored } : stored;
279
+ const active = fence?.expires > time;
280
+ if (active && !canClearFence?.(fence)) return onFence?.();
281
+ if (stored !== undefined) rmSync(overlapPath(key), { force: true });
282
+ return fn(current(key), active);
283
+ } finally {
284
+ release();
285
+ }
286
+ }
287
+ function fence(key, nativeTurn) {
288
+ atomicWrite(overlapPath(key), JSON.stringify({ nativeTurn, expires: now() + TURN_TTL }));
289
+ }
290
+ function sameCheckout(state, target) {
291
+ return (
292
+ state?.root === target.root &&
293
+ state.repo.toLowerCase() === target.repo.toLowerCase() &&
294
+ (!state.runtime || state.runtime === target.runtime)
295
+ );
296
+ }
297
+ function withTurn(token, target, fn) {
298
+ if (!TOKEN.test(token)) return;
299
+ const key = readJson(indexPath(token));
300
+ if (typeof key !== "string") return;
301
+ return lock(key, (state) => {
302
+ if (sameCheckout(state, target) && state.token === token) return fn(key, state);
303
+ });
304
+ }
305
+ return {
306
+ begin(key, target, client, nativeTurn, { skip, readOnly }) {
307
+ return lock(
308
+ key,
309
+ (previous) => {
310
+ if (previous) {
311
+ if (nativeTurn && previous.nativeTurn === nativeTurn) {
312
+ // Duplicate native events belong to the same task. Keep its
313
+ // token unless the user expressly opted out of this turn.
314
+ if (skip) remove(key, previous);
315
+ return;
316
+ }
317
+ if (nativeTurn) {
318
+ // A new native turn cannot be mistaken for an earlier Stop or CLI result.
319
+ remove(key, previous);
320
+ } else {
321
+ if (TOKEN.test(previous.token)) rmSync(indexPath(previous.token), { force: true });
322
+ put(key, { ...previous, phase: "ambiguous" });
323
+ return { degraded: "overlap" };
324
+ }
325
+ }
326
+ if (skip) return;
327
+ const token = randomBytes(32).toString("hex");
328
+ const state = {
329
+ ...target,
330
+ client,
331
+ nativeTurn,
332
+ token,
333
+ operationId: randomUUID(),
334
+ expires: now() + TURN_TTL,
335
+ phase: "pending",
336
+ readOnly,
337
+ };
338
+ atomicWrite(indexPath(token), JSON.stringify(key));
339
+ put(key, state);
340
+ return { token };
341
+ },
342
+ {
343
+ onBusy: () => {
344
+ fence(key, nativeTurn);
345
+ return { degraded: "busy" };
346
+ },
347
+ canClearFence: (entry) => Boolean(nativeTurn && entry.nativeTurn !== nativeTurn),
348
+ onFence: () => ({ degraded: "overlap" }),
349
+ },
350
+ );
351
+ },
352
+ stop(key, target, nativeTurn, mayNudge, isContinuation) {
353
+ return lock(
354
+ key,
355
+ (state, fenced) => {
356
+ if (!sameCheckout(state, target)) return;
357
+ if (fenced) {
358
+ if (state.nativeTurn === nativeTurn) remove(key, state);
359
+ return;
360
+ }
361
+ if (
362
+ nativeTurn &&
363
+ state.nativeTurn !== nativeTurn &&
364
+ !(isContinuation && state.continuationTurn === nativeTurn)
365
+ )
366
+ return;
367
+ if (state.phase === "ambiguous" && !nativeTurn) return;
368
+ if (!mayNudge || state.readOnly || !["recalled", "expanding"].includes(state.phase)) {
369
+ remove(key, state);
370
+ return;
371
+ }
372
+ put(key, { ...state, phase: "nudged" });
373
+ return state.token;
374
+ },
375
+ {
376
+ canClearFence: (entry) => Boolean(nativeTurn && nativeTurn === entry.nativeTurn),
377
+ },
378
+ );
379
+ },
380
+ registerContinuation(key, target, nativeTurn, token) {
381
+ lock(key, (state) => {
382
+ if (sameCheckout(state, target) && state.token === token && state.phase === "nudged") {
383
+ put(key, { ...state, continuationTurn: nativeTurn });
384
+ }
385
+ });
386
+ },
387
+ cancel(key, target, nativeTurn) {
388
+ lock(
389
+ key,
390
+ (state) => {
391
+ if (
392
+ sameCheckout(state, target) &&
393
+ (!nativeTurn ||
394
+ state.nativeTurn === nativeTurn ||
395
+ state.continuationTurn === nativeTurn)
396
+ )
397
+ remove(key, state);
398
+ },
399
+ {
400
+ onBusy: () => fence(key, nativeTurn),
401
+ canClearFence: (entry) => !nativeTurn || entry.nativeTurn === nativeTurn,
402
+ },
403
+ );
404
+ },
405
+ transition(token, target, from, to) {
406
+ return withTurn(token, target, (key, state) => {
407
+ if (!from.includes(state.phase)) return;
408
+ put(key, { ...state, phase: to });
409
+ return state;
410
+ });
411
+ },
412
+ finishRecall(token, target, pairs) {
413
+ return withTurn(token, target, (key, state) => {
414
+ if (state.phase !== "recalling") return;
415
+ put(key, { ...state, phase: "recalled", pairs, expansions: 0 });
416
+ return true;
417
+ });
418
+ },
419
+ beginExpand(token, target, id, version) {
420
+ return withTurn(token, target, (key, state) => {
421
+ if (
422
+ state.phase !== "recalled" ||
423
+ state.expansions >= 2 ||
424
+ !state.pairs?.some(
425
+ ([recordId, recordVersion]) => recordId === id && recordVersion === version,
426
+ )
427
+ )
428
+ return;
429
+ const expansions = state.expansions + 1;
430
+ put(key, { ...state, phase: "expanding", expansions });
431
+ return { repo: state.repo, attempt: expansions };
432
+ });
433
+ },
434
+ finishExpand(token, target, attempt) {
435
+ return withTurn(token, target, (key, state) => {
436
+ if (state.phase !== "expanding" || state.expansions !== attempt) return;
437
+ put(key, { ...state, phase: "recalled" });
438
+ return true;
439
+ });
440
+ },
441
+ saveReceipt(token, target, record) {
442
+ if (!TOKEN.test(token)) return;
443
+ atomicWrite(
444
+ receiptPath(token),
445
+ JSON.stringify({
446
+ ...target,
447
+ record: { id: record.id, status: record.status },
448
+ expires: now() + TURN_TTL,
449
+ }),
450
+ );
451
+ },
452
+ readReceipt(token, target) {
453
+ if (!TOKEN.test(token)) return;
454
+ const path = receiptPath(token);
455
+ const receipt = readJson(path);
456
+ if (receipt && receipt.expires <= now()) {
457
+ rmSync(path, { force: true });
458
+ return;
459
+ }
460
+ if (sameCheckout(receipt, target)) return { record: receipt.record };
461
+ },
462
+ };
463
+ }
@@ -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
+ }