@pharaoh-so/mcp 0.3.54 → 0.3.57
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 +1 -1
- package/CHANGELOG.md +36 -0
- package/LICENSE +1 -1
- package/NOTICE +28 -0
- package/README.md +5 -5
- package/commands/plan.md +11 -11
- package/commands/review.md +2 -2
- package/dist/auth.d.ts +5 -0
- package/dist/credentials.d.ts +8 -0
- package/dist/gateway.js +1 -1
- package/dist/helpers.d.ts +1 -1
- package/dist/helpers.js +15 -2
- package/dist/index.js +47 -28
- package/dist/install-skills.d.ts +4 -3
- package/dist/install-skills.js +12 -8
- package/dist/refresh.d.ts +8 -2
- package/dist/refresh.js +33 -6
- package/dist/setup.d.ts +5 -0
- package/dist/setup.js +66 -0
- package/inspect-tools.json +89 -36
- package/{skills → npx-skills}/brainstorm/SKILL.md +2 -1
- package/{skills → npx-skills}/debug/SKILL.md +2 -1
- package/{skills → npx-skills}/pharaoh/SKILL.md +13 -6
- package/{skills → npx-skills}/plan/SKILL.md +13 -14
- package/npx-skills/refactor/SKILL.md +38 -0
- package/{skills → npx-skills}/review/SKILL.md +4 -5
- package/{skills → npx-skills}/tdd/SKILL.md +2 -1
- package/{skills → npx-skills}/verify/SKILL.md +2 -1
- package/package.json +6 -4
- package/project-memory/bundle.mjs +137 -0
- package/project-memory/execute-turn.mjs +269 -0
- package/project-memory/hook-config.mjs +91 -0
- package/project-memory/hook-events.mjs +117 -0
- package/project-memory/local.mjs +480 -0
- package/project-memory/memory-api.mjs +200 -0
- package/project-memory/pharaoh-memory-hook.mjs +47 -0
- package/project-memory/pharaoh-memory.mjs +60 -0
- package/project-memory/setup.d.mts +14 -0
- package/project-memory/setup.mjs +151 -0
- package/project-memory/user-target.mjs +235 -0
- package/trace-app.html +222 -101
- package/skills/.gitkeep +0 -0
- package/skills/refactor/SKILL.md +0 -34
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { shellWord } from "./local.mjs";
|
|
3
|
+
|
|
4
|
+
const EVENTS = {
|
|
5
|
+
"claude-code": ["UserPromptSubmit", "Stop", "StopFailure", "SessionEnd"],
|
|
6
|
+
codex: ["UserPromptSubmit", "Stop", "Interrupt", "SessionEnd"],
|
|
7
|
+
};
|
|
8
|
+
const GLOBAL_MARKER = "# pharaoh-project-memory:user-v1";
|
|
9
|
+
const LEGACY_MARKER = / # pharaoh-project-memory:[a-f0-9]{64}$/;
|
|
10
|
+
|
|
11
|
+
function isObject(value) {
|
|
12
|
+
return value && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function legacyMarker(client, root) {
|
|
16
|
+
return `# pharaoh-project-memory:${createHash("sha256").update(`${client}\0${root}`).digest("hex")}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hookCommand(entry) {
|
|
20
|
+
return entry?.type === "command" && typeof entry.command === "string" ? entry.command : "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Keep unrelated handlers even when they share an event group with ours. */
|
|
24
|
+
function replaceHooks(config, client, matches, command) {
|
|
25
|
+
if (!isObject(config) || (config.hooks !== undefined && !isObject(config.hooks))) {
|
|
26
|
+
throw new Error("The existing hooks configuration is not a JSON object.");
|
|
27
|
+
}
|
|
28
|
+
const hooks = { ...config.hooks };
|
|
29
|
+
for (const event of EVENTS[client]) {
|
|
30
|
+
const current = hooks[event];
|
|
31
|
+
if (current === undefined && !command) continue;
|
|
32
|
+
if (
|
|
33
|
+
current !== undefined &&
|
|
34
|
+
(!Array.isArray(current) || current.some((group) => !Array.isArray(group?.hooks)))
|
|
35
|
+
) {
|
|
36
|
+
throw new Error(`The existing ${event} configuration is invalid.`);
|
|
37
|
+
}
|
|
38
|
+
const kept = (current ?? []).flatMap((group) => {
|
|
39
|
+
const rest = group.hooks.filter((entry) => !matches(hookCommand(entry)));
|
|
40
|
+
if (rest.length === group.hooks.length) return [group];
|
|
41
|
+
return rest.length ? [{ ...group, hooks: rest }] : [];
|
|
42
|
+
});
|
|
43
|
+
if (command) kept.push({ hooks: [{ type: "command", command, timeout: 3 }] });
|
|
44
|
+
if (kept.length) hooks[event] = kept;
|
|
45
|
+
else delete hooks[event];
|
|
46
|
+
}
|
|
47
|
+
return config.hooks === undefined && Object.keys(hooks).length === 0
|
|
48
|
+
? config
|
|
49
|
+
: { ...config, hooks };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function updateHooks(config, client, target, remove, hookPath, node = process.execPath) {
|
|
53
|
+
const marker = legacyMarker(client, target.root);
|
|
54
|
+
if (!remove && !hookPath) throw new Error("A pinned project memory hook is required.");
|
|
55
|
+
const command = remove
|
|
56
|
+
? null
|
|
57
|
+
: `${[node, hookPath, client, target.root, target.repo].map(shellWord).join(" ")} ${marker}`;
|
|
58
|
+
return replaceHooks(config, client, (value) => value.endsWith(` ${marker}`), command);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function updateUserHooks(config, client, hookPath, node = process.execPath) {
|
|
62
|
+
const command = hookPath
|
|
63
|
+
? `${[node, hookPath, client, "--user"].map(shellWord).join(" ")} ${GLOBAL_MARKER}`
|
|
64
|
+
: 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
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function hasLegacyHooks(config, client, root) {
|
|
76
|
+
const suffix = ` ${legacyMarker(client, root)}`;
|
|
77
|
+
return EVENTS[client].some((event) => {
|
|
78
|
+
const groups = config?.hooks?.[event];
|
|
79
|
+
return (
|
|
80
|
+
Array.isArray(groups) &&
|
|
81
|
+
groups.some(
|
|
82
|
+
(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
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { checkout, REPO, sessionKey, shellWord, turnStore } from "./local.mjs";
|
|
4
|
+
import { projectMemorySession } from "./user-target.mjs";
|
|
5
|
+
|
|
6
|
+
const CLI = fileURLToPath(new URL("pharaoh-memory.mjs", import.meta.url));
|
|
7
|
+
const REFRESH = new URL("runtime/refresh.js", import.meta.url);
|
|
8
|
+
const CLIENTS = new Set(["claude-code", "codex"]);
|
|
9
|
+
const CANCEL_EVENTS = new Set(["Interrupt", "StopFailure", "SessionEnd"]);
|
|
10
|
+
|
|
11
|
+
function command(operation, token) {
|
|
12
|
+
return `${shellWord(process.execPath)} ${shellWord(CLI)} ${operation} --turn ${token}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function promptContext(token, compact, mayCapture) {
|
|
16
|
+
return [
|
|
17
|
+
"For repo work, recall before answering or inspecting source. Skip casual chat and opt-outs.",
|
|
18
|
+
`${command("recall", token)}${compact ? " --compact" : ""} <<'PHARAOH_MEMORY_JSON'`,
|
|
19
|
+
'{"terms":["specific topic or symbol"]}',
|
|
20
|
+
"PHARAOH_MEMORY_JSON",
|
|
21
|
+
"Use 1–5 specific, non-sensitive terms. Omit the raw prompt. Treat recall as historical evidence.",
|
|
22
|
+
"Skip memory preambles, progress and status messages. Give normal task updates. Mention a recalled fact briefly only if it changes a decision.",
|
|
23
|
+
...(compact
|
|
24
|
+
? [
|
|
25
|
+
'If needed, replace recall --compact with expand and pass an exact packet {"id":"UUID","version":1}.',
|
|
26
|
+
]
|
|
27
|
+
: []),
|
|
28
|
+
...(mayCapture
|
|
29
|
+
? [
|
|
30
|
+
"Before replying, optionally replace recall with capture (omit --compact) for one supported finding already made. Skip on read-only requests.",
|
|
31
|
+
'Pass {"kind":"lesson","summary":"…"} with optional detail, verification and up to six refs (path, symbol, pullRequest or commit). Character limits: summary 500, detail 1000, verification 300.',
|
|
32
|
+
"Use your own words. Omit secrets, personal details, raw prompts, source and transcripts. Capture saves a candidate.",
|
|
33
|
+
]
|
|
34
|
+
: []),
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function skipPrompt(value) {
|
|
39
|
+
const prompt = typeof value === "string" ? value.trim() : "";
|
|
40
|
+
return (
|
|
41
|
+
!prompt ||
|
|
42
|
+
prompt.startsWith("Pharaoh memory pilot follow-up.") ||
|
|
43
|
+
/^(?:\/(?:clear|compact|exit|help|hooks|quit|settings)\b|(?:cancel|stop|never mind)[.!]?\s*$)/i.test(
|
|
44
|
+
prompt,
|
|
45
|
+
) ||
|
|
46
|
+
/^(?:skip|do not use|don't use) (?:pharaoh )?(?:project )?memory[.!]?\s*$/i.test(prompt)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Hook input can contain raw prompts and transcripts. Neither is persisted or transmitted. */
|
|
51
|
+
export function handleEvent(
|
|
52
|
+
input,
|
|
53
|
+
{
|
|
54
|
+
client,
|
|
55
|
+
root,
|
|
56
|
+
repo,
|
|
57
|
+
runtime,
|
|
58
|
+
home,
|
|
59
|
+
credentials,
|
|
60
|
+
deadline = Infinity,
|
|
61
|
+
hasBuild = existsSync(REFRESH),
|
|
62
|
+
compact = process.env.PHARAOH_PROJECT_MEMORY_COMPACT !== "false",
|
|
63
|
+
},
|
|
64
|
+
) {
|
|
65
|
+
if (!input || !CLIENTS.has(client) || !REPO.test(repo)) return;
|
|
66
|
+
const event = input.hook_event_name;
|
|
67
|
+
if (event !== "UserPromptSubmit" && event !== "Stop" && !CANCEL_EVENTS.has(event)) return;
|
|
68
|
+
const key = sessionKey(client, input.session_id);
|
|
69
|
+
if (!key) return;
|
|
70
|
+
const checkoutTarget = runtime ? { root, repo } : checkout(input.cwd, deadline);
|
|
71
|
+
if (checkoutTarget?.root !== root || checkoutTarget.repo.toLowerCase() !== repo.toLowerCase())
|
|
72
|
+
return;
|
|
73
|
+
const target = runtime ? { ...checkoutTarget, runtime } : checkoutTarget;
|
|
74
|
+
const nativeTurn =
|
|
75
|
+
client === "codex" && typeof input.turn_id === "string" && input.turn_id.length <= 256
|
|
76
|
+
? input.turn_id
|
|
77
|
+
: null;
|
|
78
|
+
if (client === "codex" && !nativeTurn && event !== "SessionEnd") return;
|
|
79
|
+
const isPrompt = event === "UserPromptSubmit";
|
|
80
|
+
// A legacy checkout hook can still be registered globally during upgrade.
|
|
81
|
+
// Without its pinned runtime it must never touch the active client's turn.
|
|
82
|
+
if (!hasBuild || performance.now() >= deadline) return;
|
|
83
|
+
const readOnly = input.permission_mode === "plan" || input.permission_mode === "read-only";
|
|
84
|
+
const store = turnStore(home);
|
|
85
|
+
if (isPrompt) {
|
|
86
|
+
const memorySession = projectMemorySession(credentials);
|
|
87
|
+
const noCapture =
|
|
88
|
+
readOnly ||
|
|
89
|
+
/\b(?:read[- ]only (?:\w+ )?(?:check|plan|pass|exercise|investigation)|keep (?:this|it) read[- ]only)\b/i.test(
|
|
90
|
+
input.prompt,
|
|
91
|
+
);
|
|
92
|
+
const opened = store.begin(key, target, client, nativeTurn, {
|
|
93
|
+
skip: skipPrompt(input.prompt),
|
|
94
|
+
readOnly: noCapture,
|
|
95
|
+
memorySession,
|
|
96
|
+
});
|
|
97
|
+
if (opened?.token)
|
|
98
|
+
return {
|
|
99
|
+
hookSpecificOutput: {
|
|
100
|
+
hookEventName: event,
|
|
101
|
+
additionalContext: promptContext(
|
|
102
|
+
opened.token,
|
|
103
|
+
compact,
|
|
104
|
+
!noCapture && Boolean(memorySession),
|
|
105
|
+
),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (CANCEL_EVENTS.has(event)) {
|
|
111
|
+
store.cancel(key, target, event === "SessionEnd" ? null : nativeTurn);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// A sibling Stop hook can continue the task. This event only closes
|
|
115
|
+
// eligibility for the current turn and never dispatches a hosted write.
|
|
116
|
+
store.stop(key, target, nativeTurn);
|
|
117
|
+
}
|
|
@@ -0,0 +1,480 @@
|
|
|
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
|
+
return gitBefore(cwd, Infinity, ...command);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function gitBefore(cwd, deadline, ...command) {
|
|
60
|
+
const { args, ...options } = gitProcess(cwd, command);
|
|
61
|
+
const timeout = Math.min(GIT_TIMEOUT_MS, Math.floor(deadline - performance.now()));
|
|
62
|
+
if (timeout < 1) throw new Error("Project memory Git deadline expired.");
|
|
63
|
+
return execFileSync(GIT, args, {
|
|
64
|
+
...options,
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
maxBuffer: 8192,
|
|
67
|
+
timeout,
|
|
68
|
+
killSignal: "SIGKILL",
|
|
69
|
+
}).trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Check one literal path or the entire index for flags that can hide worktree edits. */
|
|
73
|
+
export async function visibleGitIndex(cwd, path) {
|
|
74
|
+
try {
|
|
75
|
+
const paths = path ? [`:(literal)${path}`] : [];
|
|
76
|
+
const { args, ...options } = gitProcess(cwd, ["ls-files", "-v", "-z", "--", ...paths]);
|
|
77
|
+
const signal = AbortSignal.timeout(GIT_TIMEOUT_MS);
|
|
78
|
+
const child = spawn(GIT, args, {
|
|
79
|
+
...options,
|
|
80
|
+
signal,
|
|
81
|
+
killSignal: "SIGKILL",
|
|
82
|
+
});
|
|
83
|
+
return await new Promise((resolve) => {
|
|
84
|
+
let prefix = 0;
|
|
85
|
+
let hasName = false;
|
|
86
|
+
let sawEntry = false;
|
|
87
|
+
let failed = false;
|
|
88
|
+
let ended = false;
|
|
89
|
+
function stop() {
|
|
90
|
+
failed = true;
|
|
91
|
+
child.stdout.destroy();
|
|
92
|
+
child.kill("SIGKILL");
|
|
93
|
+
}
|
|
94
|
+
child.on("error", () => {
|
|
95
|
+
failed = true;
|
|
96
|
+
});
|
|
97
|
+
child.stdout.once("end", () => {
|
|
98
|
+
ended = true;
|
|
99
|
+
});
|
|
100
|
+
child.stdout.on("error", stop);
|
|
101
|
+
child.stdout.on("data", (chunk) => {
|
|
102
|
+
// Only H is visible. Lowercase tags and S can hide changed files.
|
|
103
|
+
// Names can contain newlines. Carry only framing state across chunks.
|
|
104
|
+
for (const byte of chunk) {
|
|
105
|
+
if (prefix < VISIBLE_INDEX_TAG.length) {
|
|
106
|
+
if (byte !== VISIBLE_INDEX_TAG[prefix]) return stop();
|
|
107
|
+
prefix++;
|
|
108
|
+
} else if (byte === 0) {
|
|
109
|
+
if (!hasName) return stop();
|
|
110
|
+
sawEntry = true;
|
|
111
|
+
prefix = 0;
|
|
112
|
+
hasName = false;
|
|
113
|
+
} else {
|
|
114
|
+
hasName = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
child.once("close", (code) =>
|
|
119
|
+
resolve(!failed && !signal.aborted && code === 0 && ended && sawEntry && prefix === 0),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Accept only an unambiguous GitHub origin, independent of the branch name. */
|
|
128
|
+
export function checkout(cwd, deadline = Infinity) {
|
|
129
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd)) return;
|
|
130
|
+
try {
|
|
131
|
+
const root = realpathSync(gitBefore(cwd, deadline, "rev-parse", "--show-toplevel"));
|
|
132
|
+
const origin = gitBefore(root, deadline, "config", "--local", "--get-all", "remote.origin.url");
|
|
133
|
+
const repo = /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^\n]+)$/i
|
|
134
|
+
.exec(origin)?.[1]
|
|
135
|
+
.replace(/\.git$/i, "");
|
|
136
|
+
if (repo && REPO.test(repo)) return { root, repo };
|
|
137
|
+
} catch {
|
|
138
|
+
// Non-repositories and unreadable worktrees are outside the pilot.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function shellWord(value) {
|
|
143
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function readStdin(maxBytes) {
|
|
147
|
+
const chunks = [];
|
|
148
|
+
let bytes = 0;
|
|
149
|
+
for await (const chunk of process.stdin) {
|
|
150
|
+
bytes += chunk.length;
|
|
151
|
+
if (bytes > maxBytes) throw new Error("Input exceeds the size limit.");
|
|
152
|
+
chunks.push(chunk);
|
|
153
|
+
}
|
|
154
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function atomicWrite(path, contents) {
|
|
158
|
+
const temporary = join(dirname(path), `.pharaoh-memory-${randomUUID()}.tmp`);
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(temporary, contents, { flag: "wx", mode: 0o600 });
|
|
161
|
+
renameSync(temporary, path);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(temporary, { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readJson(path) {
|
|
168
|
+
try {
|
|
169
|
+
const file = lstatSync(path);
|
|
170
|
+
if (!file.isFile() || (file.mode & 0o077) !== 0 || file.size > 8192) return;
|
|
171
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
172
|
+
} catch {
|
|
173
|
+
// State can expire or be removed by another event.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function makePrivateDirectory(path) {
|
|
178
|
+
mkdirSync(path, { mode: 0o700, recursive: true });
|
|
179
|
+
const info = lstatSync(path);
|
|
180
|
+
if (!info.isDirectory() || (info.mode & 0o077) !== 0 || info.uid !== process.getuid?.()) {
|
|
181
|
+
throw new Error("Project memory needs a private local state directory.");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function sessionKey(client, sessionId) {
|
|
186
|
+
if (typeof sessionId !== "string" || !sessionId || sessionId.length > 256) return;
|
|
187
|
+
return createHash("sha256").update(`${client}\0${sessionId}`).digest("hex");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function releaseLock(path, owner) {
|
|
191
|
+
try {
|
|
192
|
+
unlinkSync(join(path, owner));
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (error.code === "ENOENT") return;
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
rmdirSync(path);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// A replacement always appears already populated, so rmdir cannot remove it.
|
|
201
|
+
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function acquireLock(directory, path, now) {
|
|
206
|
+
const owner = `owner-${randomUUID()}.json`;
|
|
207
|
+
const candidate = mkdtempSync(join(directory, "lock-candidate-"));
|
|
208
|
+
try {
|
|
209
|
+
writeFileSync(join(candidate, owner), JSON.stringify({ pid: process.pid, createdAt: now }), {
|
|
210
|
+
flag: "wx",
|
|
211
|
+
mode: 0o600,
|
|
212
|
+
});
|
|
213
|
+
// POSIX rename installs the prepared directory only if the destination is
|
|
214
|
+
// absent or empty. No process can see a new lock without its owner marker.
|
|
215
|
+
renameSync(candidate, path);
|
|
216
|
+
return () => releaseLock(path, owner);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(error.code)) throw error;
|
|
219
|
+
} finally {
|
|
220
|
+
rmSync(candidate, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function recoverLock(path, now) {
|
|
225
|
+
let entries;
|
|
226
|
+
try {
|
|
227
|
+
entries = readdirSync(path);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error.code === "ENOENT") return true;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
if (entries.length === 0) return true;
|
|
233
|
+
if (entries.length !== 1 || !LOCK_OWNER.test(entries[0])) return false;
|
|
234
|
+
const [owner] = entries;
|
|
235
|
+
const metadata = readJson(join(path, owner));
|
|
236
|
+
if (
|
|
237
|
+
!Number.isInteger(metadata?.pid) ||
|
|
238
|
+
metadata.pid < 1 ||
|
|
239
|
+
metadata.pid > 2_147_483_647 ||
|
|
240
|
+
!Number.isFinite(metadata.createdAt) ||
|
|
241
|
+
now - metadata.createdAt < LOCK_MIN_AGE
|
|
242
|
+
)
|
|
243
|
+
return false;
|
|
244
|
+
try {
|
|
245
|
+
process.kill(metadata.pid, 0);
|
|
246
|
+
return false;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
// A reused PID, a live owner, or inability to confirm death keeps the lock.
|
|
249
|
+
if (error.code !== "ESRCH") return false;
|
|
250
|
+
}
|
|
251
|
+
releaseLock(path, owner);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Serialize local state transitions only; network operations never hold this lock. */
|
|
256
|
+
export function turnStore(home = homedir(), now = Date.now) {
|
|
257
|
+
const directory = join(home, ".pharaoh", "project-memory-turns-v2");
|
|
258
|
+
makePrivateDirectory(directory);
|
|
259
|
+
const sessionPath = (key) => join(directory, `session-${key}.json`);
|
|
260
|
+
const indexPath = (token) => join(directory, `turn-${token}.json`);
|
|
261
|
+
const overlapPath = (key) => join(directory, `overlap-${key}.json`);
|
|
262
|
+
const receiptPath = (token) => join(directory, `receipt-${token}.json`);
|
|
263
|
+
function current(key) {
|
|
264
|
+
const state = readJson(sessionPath(key));
|
|
265
|
+
if (!state || state.expires > now()) return state;
|
|
266
|
+
remove(key, state);
|
|
267
|
+
}
|
|
268
|
+
function put(key, state) {
|
|
269
|
+
atomicWrite(sessionPath(key), JSON.stringify(state));
|
|
270
|
+
}
|
|
271
|
+
function remove(key, state) {
|
|
272
|
+
if (TOKEN.test(state?.token)) rmSync(indexPath(state.token), { force: true });
|
|
273
|
+
rmSync(sessionPath(key), { force: true });
|
|
274
|
+
}
|
|
275
|
+
function lock(key, fn, { onBusy, canClearFence, onFence } = {}) {
|
|
276
|
+
if (!TOKEN.test(key)) return;
|
|
277
|
+
const path = join(directory, `${key}.lock`);
|
|
278
|
+
const time = now();
|
|
279
|
+
const release =
|
|
280
|
+
acquireLock(directory, path, time) ??
|
|
281
|
+
(recoverLock(path, time) ? acquireLock(directory, path, time) : undefined);
|
|
282
|
+
if (!release) return onBusy?.();
|
|
283
|
+
try {
|
|
284
|
+
const stored = readJson(overlapPath(key));
|
|
285
|
+
const fence = typeof stored === "number" ? { expires: stored } : stored;
|
|
286
|
+
const active = fence?.expires > time;
|
|
287
|
+
if (active && !canClearFence?.(fence)) return onFence?.();
|
|
288
|
+
if (stored !== undefined) rmSync(overlapPath(key), { force: true });
|
|
289
|
+
return fn(current(key), active);
|
|
290
|
+
} finally {
|
|
291
|
+
release();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function fence(key, nativeTurn) {
|
|
295
|
+
atomicWrite(overlapPath(key), JSON.stringify({ nativeTurn, expires: now() + TURN_TTL }));
|
|
296
|
+
}
|
|
297
|
+
function sameCheckout(state, target) {
|
|
298
|
+
return (
|
|
299
|
+
state?.root === target.root &&
|
|
300
|
+
state.repo.toLowerCase() === target.repo.toLowerCase() &&
|
|
301
|
+
state.runtime === target.runtime
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
function withTurn(token, target, fn) {
|
|
305
|
+
if (!TOKEN.test(token)) return;
|
|
306
|
+
const key = readJson(indexPath(token));
|
|
307
|
+
if (typeof key !== "string") return;
|
|
308
|
+
return lock(key, (state) => {
|
|
309
|
+
if (sameCheckout(state, target) && state.token === token) return fn(key, state);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
function close(key, state) {
|
|
313
|
+
if (!state.nativeTurn) return remove(key, state);
|
|
314
|
+
if (TOKEN.test(state.token)) rmSync(indexPath(state.token), { force: true });
|
|
315
|
+
put(key, { ...state, token: undefined, phase: "closed" });
|
|
316
|
+
}
|
|
317
|
+
function saveReceipt(token, target, outcome, record) {
|
|
318
|
+
if (!TOKEN.test(token)) return;
|
|
319
|
+
atomicWrite(
|
|
320
|
+
receiptPath(token),
|
|
321
|
+
JSON.stringify({
|
|
322
|
+
...target,
|
|
323
|
+
outcome,
|
|
324
|
+
record: record && { id: record.id, status: record.status },
|
|
325
|
+
expires: now() + TURN_TTL,
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
begin(key, target, client, nativeTurn, { skip, readOnly, memorySession }) {
|
|
331
|
+
return lock(
|
|
332
|
+
key,
|
|
333
|
+
(previous) => {
|
|
334
|
+
if (previous) {
|
|
335
|
+
if (nativeTurn && previous.nativeTurn === nativeTurn) {
|
|
336
|
+
// Duplicate native events belong to the same task. Keep its
|
|
337
|
+
// token unless the user expressly opted out of this turn.
|
|
338
|
+
if (skip) close(key, previous);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (nativeTurn) {
|
|
342
|
+
// A new native turn cannot be mistaken for an earlier Stop or CLI result.
|
|
343
|
+
remove(key, previous);
|
|
344
|
+
} else {
|
|
345
|
+
if (TOKEN.test(previous.token)) rmSync(indexPath(previous.token), { force: true });
|
|
346
|
+
put(key, { ...previous, phase: "ambiguous" });
|
|
347
|
+
return { degraded: "overlap" };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (skip) return;
|
|
351
|
+
const token = randomBytes(32).toString("hex");
|
|
352
|
+
const state = {
|
|
353
|
+
...target,
|
|
354
|
+
client,
|
|
355
|
+
nativeTurn,
|
|
356
|
+
token,
|
|
357
|
+
operationId: randomUUID(),
|
|
358
|
+
expires: now() + TURN_TTL,
|
|
359
|
+
phase: "pending",
|
|
360
|
+
readOnly,
|
|
361
|
+
memorySession,
|
|
362
|
+
};
|
|
363
|
+
atomicWrite(indexPath(token), JSON.stringify(key));
|
|
364
|
+
put(key, state);
|
|
365
|
+
return { token };
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
onBusy: () => {
|
|
369
|
+
fence(key, nativeTurn);
|
|
370
|
+
return { degraded: "busy" };
|
|
371
|
+
},
|
|
372
|
+
canClearFence: (entry) => Boolean(nativeTurn && entry.nativeTurn !== nativeTurn),
|
|
373
|
+
onFence: () => ({ degraded: "overlap" }),
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
},
|
|
377
|
+
startCapture(token, target, memorySession, send) {
|
|
378
|
+
return withTurn(token, target, (key, state) => {
|
|
379
|
+
if (
|
|
380
|
+
state.phase !== "recalled" ||
|
|
381
|
+
state.readOnly ||
|
|
382
|
+
!memorySession ||
|
|
383
|
+
state.memorySession !== memorySession
|
|
384
|
+
)
|
|
385
|
+
return;
|
|
386
|
+
put(key, { ...state, phase: "capturing" });
|
|
387
|
+
saveReceipt(token, target, "uncertain");
|
|
388
|
+
if (readJson(overlapPath(key))?.expires > now()) {
|
|
389
|
+
rmSync(receiptPath(token), { force: true });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// Begin dispatch under the turn lease. Await the response outside it.
|
|
393
|
+
return send({ repo: state.repo, client: state.client, operationId: state.operationId });
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
stop(key, target, nativeTurn) {
|
|
397
|
+
lock(
|
|
398
|
+
key,
|
|
399
|
+
(state) => {
|
|
400
|
+
if (
|
|
401
|
+
sameCheckout(state, target) &&
|
|
402
|
+
state.nativeTurn === nativeTurn &&
|
|
403
|
+
(state.phase !== "ambiguous" || nativeTurn)
|
|
404
|
+
)
|
|
405
|
+
close(key, state);
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
onBusy: () => fence(key, nativeTurn),
|
|
409
|
+
canClearFence: (entry) => Boolean(nativeTurn && nativeTurn === entry.nativeTurn),
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
},
|
|
413
|
+
cancel(key, target, nativeTurn) {
|
|
414
|
+
lock(
|
|
415
|
+
key,
|
|
416
|
+
(state) => {
|
|
417
|
+
if (sameCheckout(state, target) && (!nativeTurn || state.nativeTurn === nativeTurn)) {
|
|
418
|
+
if (nativeTurn) close(key, state);
|
|
419
|
+
else remove(key, state);
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
onBusy: () => fence(key, nativeTurn),
|
|
424
|
+
canClearFence: (entry) => !nativeTurn || entry.nativeTurn === nativeTurn,
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
},
|
|
428
|
+
transition(token, target, from, to) {
|
|
429
|
+
return withTurn(token, target, (key, state) => {
|
|
430
|
+
if (!from.includes(state.phase)) return;
|
|
431
|
+
put(key, { ...state, phase: to });
|
|
432
|
+
return state;
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
finishRecall(token, target, pairs) {
|
|
436
|
+
return withTurn(token, target, (key, state) => {
|
|
437
|
+
if (state.phase !== "recalling") return;
|
|
438
|
+
put(key, { ...state, phase: "recalled", pairs, expansions: 0 });
|
|
439
|
+
return true;
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
beginExpand(token, target, id, version) {
|
|
443
|
+
return withTurn(token, target, (key, state) => {
|
|
444
|
+
if (
|
|
445
|
+
state.phase !== "recalled" ||
|
|
446
|
+
state.expansions >= 2 ||
|
|
447
|
+
!state.pairs?.some(
|
|
448
|
+
([recordId, recordVersion]) => recordId === id && recordVersion === version,
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return;
|
|
452
|
+
const expansions = state.expansions + 1;
|
|
453
|
+
put(key, { ...state, phase: "expanding", expansions });
|
|
454
|
+
return { repo: state.repo, attempt: expansions };
|
|
455
|
+
});
|
|
456
|
+
},
|
|
457
|
+
finishExpand(token, target, attempt) {
|
|
458
|
+
return withTurn(token, target, (key, state) => {
|
|
459
|
+
if (state.phase !== "expanding" || state.expansions !== attempt) return;
|
|
460
|
+
put(key, { ...state, phase: "recalled" });
|
|
461
|
+
return true;
|
|
462
|
+
});
|
|
463
|
+
},
|
|
464
|
+
saveReceipt,
|
|
465
|
+
readReceipt(token, target) {
|
|
466
|
+
if (!TOKEN.test(token)) return;
|
|
467
|
+
const path = receiptPath(token);
|
|
468
|
+
const receipt = readJson(path);
|
|
469
|
+
if (receipt && receipt.expires <= now()) {
|
|
470
|
+
rmSync(path, { force: true });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (sameCheckout(receipt, target))
|
|
474
|
+
return {
|
|
475
|
+
outcome: receipt.outcome ?? "confirmed",
|
|
476
|
+
...(receipt.record && { record: receipt.record }),
|
|
477
|
+
};
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|