@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.
- package/.mcp.json +1 -1
- package/CHANGELOG.md +31 -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 +18 -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 +240 -0
- package/project-memory/hook-config.mjs +91 -0
- package/project-memory/hook-events.mjs +141 -0
- package/project-memory/local.mjs +463 -0
- package/project-memory/memory-api.mjs +182 -0
- package/project-memory/pharaoh-memory-hook.mjs +33 -0
- package/project-memory/pharaoh-memory.mjs +55 -0
- package/project-memory/setup.d.mts +14 -0
- package/project-memory/setup.mjs +151 -0
- package/project-memory/user-target.mjs +221 -0
- package/trace-app.html +222 -101
- package/skills/.gitkeep +0 -0
- package/skills/refactor/SKILL.md +0 -34
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plan
|
|
3
3
|
prompt-name: plan-with-pharaoh
|
|
4
|
-
description:
|
|
5
|
-
version: 0.
|
|
4
|
+
description: Review a plan or spec against the codebase before implementing it. Pharaoh reconnaissance, a BIG or SMALL change choice, then architecture, code quality, wiring, tests, performance and security sections with numbered decisions.
|
|
5
|
+
version: 0.7.1
|
|
6
6
|
homepage: https://pharaoh.so
|
|
7
|
-
user-invocable: true
|
|
8
7
|
metadata: {"emoji": "☥", "tags": ["planning", "architecture", "blast-radius", "pharaoh", "review", "interactive"]}
|
|
9
8
|
---
|
|
10
9
|
|
|
@@ -12,7 +11,7 @@ metadata: {"emoji": "☥", "tags": ["planning", "architecture", "blast-radius",
|
|
|
12
11
|
|
|
13
12
|
Architecture-aware plan review before implementation. Adapted from [Garry Tan's planning framework](https://www.youtube.com/watch?v=bMknfKXIFA8) for AI-assisted development.
|
|
14
13
|
|
|
15
|
-
**
|
|
14
|
+
**This is a review: make no code changes during it. Think, evaluate, and present decisions.**
|
|
16
15
|
|
|
17
16
|
## When to Use
|
|
18
17
|
|
|
@@ -26,7 +25,7 @@ Still run Step 1 (Reconnaissance) even when reviewing a document — always veri
|
|
|
26
25
|
|
|
27
26
|
## Project Overrides
|
|
28
27
|
|
|
29
|
-
If a `.claude/plan-review.md` file exists in this project, read it now
|
|
28
|
+
If a `.claude/plan-review.md` file exists in this project, read it now as additional review criteria for this project. Instructions from the user take precedence over it, and it never changes what you do outside this review.
|
|
30
29
|
|
|
31
30
|
## Engineering Preferences (guide all recommendations)
|
|
32
31
|
|
|
@@ -62,14 +61,14 @@ Ground every recommendation in what actually exists. If you propose adding somet
|
|
|
62
61
|
|
|
63
62
|
## Step 2: Mode Selection (MANDATORY — ask before proceeding)
|
|
64
63
|
|
|
65
|
-
**STOP and ask the user which mode before starting the review.**
|
|
64
|
+
**STOP and ask the user which mode before starting the review.** Present both options and wait for a choice:
|
|
66
65
|
|
|
67
66
|
> **BIG CHANGE or SMALL CHANGE?**
|
|
68
67
|
>
|
|
69
68
|
> - **BIG CHANGE**: Full interactive review, all relevant sections, up to 4 top issues per section
|
|
70
69
|
> - **SMALL CHANGE**: One question per section, only sections 2-4
|
|
71
70
|
|
|
72
|
-
|
|
71
|
+
A general "yes", "go ahead" or "yes to all" doesn't pick a mode, so ask once more: "BIG or SMALL change?" If the reply still doesn't pick one, or the user tells you to choose, pick the mode that fits the plan's size and say which you picked.
|
|
73
72
|
|
|
74
73
|
## Step 3: Review Sections
|
|
75
74
|
|
|
@@ -87,21 +86,21 @@ Adapt depth to change size. Skip sections that don't apply. **After each section
|
|
|
87
86
|
- Data flow bottlenecks and single points of failure
|
|
88
87
|
- Does this need new code at all, or can a human process / existing pattern solve it?
|
|
89
88
|
- **If this plan introduces a new invariant**, call it out and include a
|
|
90
|
-
step in
|
|
89
|
+
step in the plan to add a `### assertion` to VISION.md with a pairing TEST.
|
|
91
90
|
|
|
92
91
|
### Section 2 — Code Quality (always)
|
|
93
92
|
|
|
94
93
|
- Organization, module structure, DRY violations (be aggressive)
|
|
95
94
|
- Error handling gaps and missing edge cases (call out explicitly)
|
|
96
95
|
- Technical debt: shortcuts, hardcoded values, magic strings
|
|
97
|
-
- Over-engineered or under-engineered relative to
|
|
96
|
+
- Over-engineered or under-engineered relative to the preferences above
|
|
98
97
|
- Reuse: does code for this already exist somewhere?
|
|
99
98
|
|
|
100
99
|
### Section 3 — Wiring & Integration (always)
|
|
101
100
|
|
|
102
101
|
- Are all new exports called from a production entry point?
|
|
103
|
-
- Run `get_blast_radius` on
|
|
104
|
-
-
|
|
102
|
+
- Run `get_blast_radius` on changed functions that already exist
|
|
103
|
+
- The graph has no functions the plan adds, so does the plan declare WHERE new code gets called from? If not, flag it — no named caller = not done
|
|
105
104
|
- Integration points: how does this connect to what already exists?
|
|
106
105
|
|
|
107
106
|
### Section 4 — Tests (always)
|
|
@@ -133,16 +132,16 @@ For every specific issue (bug, smell, design concern, risk, missing wiring):
|
|
|
133
132
|
1. **Describe concretely** — file, line/function reference, what's wrong
|
|
134
133
|
2. **Present 2-3 options** including "do nothing" where reasonable
|
|
135
134
|
3. **For each option** — implementation effort, risk, blast radius, maintenance burden
|
|
136
|
-
4. **Recommend one** mapped to
|
|
135
|
+
4. **Recommend one** mapped to the preferences above, and say why
|
|
137
136
|
5. **Ask** whether I agree or want a different direction
|
|
138
137
|
|
|
139
|
-
Number each issue (1, 2, 3...) and letter each option (A, B, C...). Recommended option is always listed first.
|
|
138
|
+
Number each issue (1, 2, 3...) and letter each option (A, B, C...). Recommended option is always listed first. If your client has a question tool (AskUserQuestion in Claude Code), use it with clear labels like "Issue 1 Option A", "Issue 1 Option B".
|
|
140
139
|
|
|
141
140
|
## Pharaoh Checkpoints (use throughout, not just at the end)
|
|
142
141
|
|
|
143
142
|
- **Before reviewing**: recon (Step 1 above)
|
|
144
143
|
- **During review**: `get_blast_radius` when evaluating impact of changes; `search_functions` before suggesting new code
|
|
145
|
-
- **Final sweep**: `get_blast_radius`
|
|
144
|
+
- **Final sweep**: every new export has a caller named in the plan, and `get_blast_radius` covers every changed export that already exists — zero callers on non-entry-points = plan is incomplete
|
|
146
145
|
|
|
147
146
|
## Workflow Rules
|
|
148
147
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: refactor
|
|
3
|
+
prompt-name: safe-refactor
|
|
4
|
+
description: "Safe refactoring workflow using Pharaoh codebase knowledge graph. Six-step process: module context, blast radius of detected downstream callers, reachability verification, dependency mapping, naming conflict detection, and a search for the affected tests. Produces a refactoring plan with detected callers listed, test files identified, unreachable code flagged, and high-risk paths warned. Use when refactoring, renaming, moving or splitting code in a repo Pharaoh maps, with Pharaoh's tools connected."
|
|
5
|
+
version: 0.3.1
|
|
6
|
+
homepage: https://pharaoh.so
|
|
7
|
+
metadata: {"emoji": "☥", "tags": ["refactoring", "blast-radius", "architecture", "pharaoh", "safe-refactor", "test-coverage"]}
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Safe Refactor
|
|
11
|
+
|
|
12
|
+
Step-by-step workflow to safely refactor a function or module with full blast radius awareness. Six steps map the detected callers, find the affected tests, flag unreachable code, and warn about high-risk downstream paths before a single line changes.
|
|
13
|
+
|
|
14
|
+
## When to Use
|
|
15
|
+
|
|
16
|
+
Invoke before refactoring a function, module, or file in a repo Pharaoh maps — especially shared utilities, exports used across multiple modules, or anything with an unclear caller graph. Use it whenever the blast radius of a change is unknown.
|
|
17
|
+
|
|
18
|
+
**Stop rule:** if Pharaoh's tools aren't connected, or they report that this repo isn't mapped, stop and tell the user. Steps 1-5 read the graph and have no fallback.
|
|
19
|
+
|
|
20
|
+
## Workflow
|
|
21
|
+
|
|
22
|
+
1. **Module context:** call `get_module_context` for the module containing the target to understand its current structure.
|
|
23
|
+
2. **Blast radius:** call `get_blast_radius` for the target to identify its detected downstream callers and affected modules.
|
|
24
|
+
3. **Reachability:** call `get_unused_code` with `module` set to the target's module to find dead code the refactor can delete. When the target is a function, also call it with `functions` set to the target to confirm an entry point reaches it.
|
|
25
|
+
4. **Dependencies:** call `pharaoh_recon` with `include_map` false and `dependencies` pairs between the target's module and the modules its callers live in, to see the coupling the refactor has to keep or break.
|
|
26
|
+
5. **Naming conflicts:** call `search_functions` to check if the refactored version's name already exists elsewhere.
|
|
27
|
+
6. **Test coverage:** search the test files for the target's name. `get_blast_radius` leaves out test-file callers, so this is how you find the tests to change. If you can't read the repo's files, say so and list the tests as unknown.
|
|
28
|
+
|
|
29
|
+
Do not propose a refactoring plan until all 6 steps are complete.
|
|
30
|
+
|
|
31
|
+
## Output
|
|
32
|
+
|
|
33
|
+
A refactoring plan containing:
|
|
34
|
+
- **Callers to update:** the detected functions and files that call the target, with update requirements. Dynamic behavior and framework configuration can leave gaps, so say the list may be incomplete
|
|
35
|
+
- **Tests to change:** test files covering the refactored code, with required modifications
|
|
36
|
+
- **Dead code:** what `get_unused_code` calls Dead can be deleted during the refactor; check anything Possibly unused before deleting it
|
|
37
|
+
- **High-risk paths:** downstream modules with wide blast radius or high complexity scores
|
|
38
|
+
- **Naming conflicts:** existing functions whose names would conflict with the refactored version
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: review
|
|
3
3
|
prompt-name: review-with-pharaoh
|
|
4
|
-
description: "
|
|
5
|
-
version: 0.
|
|
4
|
+
description: "Five-phase workflow: git context detection (worktree, branch, base, changed files), Pharaoh recon (architecture map, module context, blast radius in one call, with a fallback when the repo isn't mapped), parallel specialized agent dispatch (code quality, security against the project's own rules, error handling, test coverage, type design), adversarial review by the other model (Codex from Claude Code, Claude from Codex) for security-sensitive changes, and a final synthesized verdict of SHIP / SHIP WITH CHANGES / BLOCK. Architecture-aware wherever Pharaoh maps the repo. Use when reviewing a branch or pull request before merging, or when the user asks for a code review."
|
|
5
|
+
version: 0.6.1
|
|
6
6
|
homepage: https://pharaoh.so
|
|
7
|
-
user-invocable: true
|
|
8
7
|
metadata: {"emoji": "☥", "tags": ["code-review", "pull-request", "architecture", "pharaoh", "multi-agent", "adversarial-review", "cross-model", "regression-risk", "spec-alignment"]}
|
|
9
8
|
---
|
|
10
9
|
|
|
11
10
|
# Review with Pharaoh
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
Architecture-aware, branch-aware, multi-agent, adversarial code review. Five phases that combine Pharaoh's knowledge graph with parallel specialized reviewers and an independent second opinion from a different model. Every phase works without Pharaoh too; Pharaoh makes the architecture picture exact and cheap where it maps the repo.
|
|
14
13
|
|
|
15
14
|
A review always reaches a verdict. When a phase needs something your client doesn't have (Pharaoh, a shell, subagents, a second model), it says how to carry on without it: do that, and name what was missing in the review output. Ask the user only when someone is there to answer.
|
|
16
15
|
|
|
@@ -36,7 +35,7 @@ No shell, or no git? Review the diff you were given (a file, a pasted patch, a p
|
|
|
36
35
|
git log --oneline -1 # latest commit
|
|
37
36
|
```
|
|
38
37
|
|
|
39
|
-
2. Determine the base (where this branch diverged from the branch it merges into). The target is the first of these that resolves:
|
|
38
|
+
2. Determine the base (where this branch diverged from the branch it merges into). The target is the first of these that resolves (`gh pr view` asks GitHub, with your own gh login, for the open PR's target branch):
|
|
40
39
|
```bash
|
|
41
40
|
TARGET=$(gh pr view --json baseRefName --jq '"origin/" + .baseRefName' 2>/dev/null) # the open PR's target branch
|
|
42
41
|
[ -n "$TARGET" ] || TARGET=$(git symbolic-ref -q --short refs/remotes/origin/HEAD) # the default branch, set by git clone
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
name: tdd
|
|
3
3
|
prompt-name: test-driven-development
|
|
4
4
|
description: "Test-driven development discipline. Write the failing test first, watch it fail, write minimal code to pass, refactor. No production code without a failing test. No exceptions without explicit permission. Covers red-green-refactor cycle, common rationalizations, and when to start over."
|
|
5
|
-
|
|
5
|
+
license: MIT. Adapted from obra/superpowers, Copyright (c) 2025 Jesse Vincent; see NOTICE in @pharaoh-so/mcp
|
|
6
|
+
version: 0.2.1
|
|
6
7
|
homepage: https://pharaoh.so
|
|
7
8
|
user-invocable: true
|
|
8
9
|
metadata: {"emoji": "☥", "tags": ["tdd", "testing", "red-green-refactor", "quality", "discipline"]}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
name: verify
|
|
3
3
|
prompt-name: verification
|
|
4
4
|
description: "Evidence before assertions. Run verification commands and confirm output before claiming work is complete, fixed, or passing. No 'should work now' — run the command, read the output, then claim the result. Covers tests, builds, linting, agent delegation, and requirement checklists."
|
|
5
|
-
|
|
5
|
+
license: MIT. Adapted from obra/superpowers, Copyright (c) 2025 Jesse Vincent; see NOTICE in @pharaoh-so/mcp
|
|
6
|
+
version: 0.2.1
|
|
6
7
|
homepage: https://pharaoh.so
|
|
7
8
|
user-invocable: true
|
|
8
9
|
metadata: {"emoji": "☥", "tags": ["verification", "quality", "completion", "evidence", "discipline"]}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pharaoh-so/mcp",
|
|
3
3
|
"mcpName": "so.pharaoh/pharaoh",
|
|
4
|
-
"version": "0.3.
|
|
5
|
-
"description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for
|
|
4
|
+
"version": "0.3.56",
|
|
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",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
@@ -17,16 +17,18 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
20
|
-
"skills",
|
|
20
|
+
"npx-skills",
|
|
21
21
|
"commands",
|
|
22
22
|
"hooks",
|
|
23
|
+
"project-memory",
|
|
23
24
|
".claude-plugin",
|
|
24
25
|
".mcp.json",
|
|
25
26
|
"inspect-tools.json",
|
|
26
27
|
"trace-app.html",
|
|
27
28
|
"README.md",
|
|
28
29
|
"CHANGELOG.md",
|
|
29
|
-
"LICENSE"
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"NOTICE"
|
|
30
32
|
],
|
|
31
33
|
"keywords": [
|
|
32
34
|
"mcp",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { dirname, join, relative } from "node:path";
|
|
13
|
+
|
|
14
|
+
const LIMIT = 256 * 1024;
|
|
15
|
+
export const MEMORY_RUNTIME_FILES = [
|
|
16
|
+
"execute-turn.mjs",
|
|
17
|
+
"hook-config.mjs",
|
|
18
|
+
"hook-events.mjs",
|
|
19
|
+
"local.mjs",
|
|
20
|
+
"memory-api.mjs",
|
|
21
|
+
"pharaoh-memory-hook.mjs",
|
|
22
|
+
"pharaoh-memory.mjs",
|
|
23
|
+
"user-target.mjs",
|
|
24
|
+
"runtime/credentials.js",
|
|
25
|
+
"runtime/package.json",
|
|
26
|
+
"runtime/refresh.js",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function digest(files) {
|
|
30
|
+
return createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function privateMemoryPath(path, directory) {
|
|
34
|
+
const stat = lstatSync(path);
|
|
35
|
+
if (
|
|
36
|
+
(directory ? !stat.isDirectory() : !stat.isFile()) ||
|
|
37
|
+
stat.uid !== process.getuid?.() ||
|
|
38
|
+
(stat.mode & 0o077) !== 0
|
|
39
|
+
) {
|
|
40
|
+
throw new Error("Project memory needs a regular owner-private bundle path.");
|
|
41
|
+
}
|
|
42
|
+
return stat;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read only the executable closure from the installed npm package. */
|
|
46
|
+
export function packagedMemoryBundle(packageRoot = new URL("../", import.meta.url)) {
|
|
47
|
+
const files = MEMORY_RUNTIME_FILES.map((name) => {
|
|
48
|
+
let bytes;
|
|
49
|
+
if (name === "runtime/package.json") {
|
|
50
|
+
bytes = Buffer.from('{"type":"module"}\n');
|
|
51
|
+
} else {
|
|
52
|
+
const location = name.startsWith("runtime/")
|
|
53
|
+
? new URL(`dist/${name.slice(8)}`, packageRoot)
|
|
54
|
+
: new URL(`project-memory/${name}`, packageRoot);
|
|
55
|
+
try {
|
|
56
|
+
bytes = readFileSync(location);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error("The Pharaoh package is missing its project memory runtime.");
|
|
59
|
+
}
|
|
60
|
+
if (name === "runtime/refresh.js" && !bytes.includes("function originBoundRefresh(")) {
|
|
61
|
+
throw new Error("The packaged refresh helper is incompatible with project memory.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [name, bytes.toString("base64")];
|
|
65
|
+
});
|
|
66
|
+
const sha256 = digest(files);
|
|
67
|
+
if (Buffer.byteLength(JSON.stringify(files)) >= LIMIT)
|
|
68
|
+
throw new Error("Project memory bundle is oversized.");
|
|
69
|
+
return { sha256, files };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function installMemoryBundle(root, target, bundle) {
|
|
73
|
+
// Check the nearest existing parent before creating anything, including a
|
|
74
|
+
// requested path whose parent is a symlink into the checkout.
|
|
75
|
+
let parent = root;
|
|
76
|
+
for (;;) {
|
|
77
|
+
try {
|
|
78
|
+
const resolved = join(realpathSync(parent), relative(parent, root));
|
|
79
|
+
const within = target ? relative(target, resolved) : "..";
|
|
80
|
+
if (!within || (!within.startsWith("../") && within !== "..")) {
|
|
81
|
+
throw new Error("Install the project memory bundle outside the enrolled checkout.");
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code !== "ENOENT" || parent === dirname(parent)) throw error;
|
|
86
|
+
parent = dirname(parent);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
90
|
+
privateMemoryPath(root, true);
|
|
91
|
+
const destination = join(realpathSync(root), bundle.sha256);
|
|
92
|
+
function verifyExisting() {
|
|
93
|
+
try {
|
|
94
|
+
privateMemoryPath(destination, true);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error.code === "ENOENT") return false;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
privateMemoryPath(join(destination, "runtime"), true);
|
|
100
|
+
for (const [name, content] of bundle.files) {
|
|
101
|
+
const path = join(destination, name);
|
|
102
|
+
if (
|
|
103
|
+
privateMemoryPath(path, false).size > LIMIT ||
|
|
104
|
+
readFileSync(path).toString("base64") !== content
|
|
105
|
+
) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"Installed project memory bundle was modified. Remove it before reinstalling.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (verifyExisting()) return destination;
|
|
114
|
+
const staging = mkdtempSync(join(root, ".memory-client-"));
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(join(staging, "runtime"), { mode: 0o700 });
|
|
117
|
+
for (const [name, content] of bundle.files) {
|
|
118
|
+
writeFileSync(join(staging, name), Buffer.from(content, "base64"), {
|
|
119
|
+
flag: "wx",
|
|
120
|
+
mode: 0o600,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
renameSync(staging, destination);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (!["EEXIST", "ENOTEMPTY"].includes(error.code) || !verifyExisting()) throw error;
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
rmSync(staging, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
return destination;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Derive a hook executable path from the reviewed installation, never from cwd. */
|
|
135
|
+
export function bundledHook(root) {
|
|
136
|
+
return join(root, "pharaoh-memory-hook.mjs");
|
|
137
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { git, visibleGitIndex } from "./local.mjs";
|
|
2
|
+
import { PilotError, requestMemory } from "./memory-api.mjs";
|
|
3
|
+
|
|
4
|
+
const SHA = /^[a-f0-9]{40}$/i;
|
|
5
|
+
const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
|
|
6
|
+
const TERMS = /^[\p{L}\p{N}._+@ $/#:-]+$/u;
|
|
7
|
+
const SECRET =
|
|
8
|
+
/\b(?:[a-f0-9]{32,}|ph(?:at|rt)_[a-z0-9_-]{12,})\b|:\/\/|\b[\w.-]+@[a-z0-9.-]+\.[a-z]{2,}\b/i;
|
|
9
|
+
const UNSAFE_RECAP =
|
|
10
|
+
/\x60{3}|~~~|-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:(?:phat|phrt|pss|dev)_[a-f0-9]{64}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|sk-[A-Za-z0-9_-]{20,}|(?:AKIA|ASIA)[A-Z0-9]{16})\b|\bBearer[ \t]+[A-Za-z0-9_.~+/=-]{12,}|\b(?:password|secret|api[_ -]?key|access[_ -]?token)\s*[:=]\s*\S{6,}|(?:^|\n)\s*(?:assistant|user|system|tool)\s*:/i;
|
|
11
|
+
const REF_PATH = /^[\p{L}\p{N}._+@ -]+(?:\/[\p{L}\p{N}._+@ -]+)*$/u;
|
|
12
|
+
|
|
13
|
+
function hasKeys(input, allowed) {
|
|
14
|
+
return (
|
|
15
|
+
input &&
|
|
16
|
+
typeof input === "object" &&
|
|
17
|
+
!Array.isArray(input) &&
|
|
18
|
+
Object.keys(input).every((key) => allowed.includes(key))
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeTerm(value) {
|
|
23
|
+
if (typeof value !== "string" || value.length > 100 || /[\r\n]/.test(value)) return;
|
|
24
|
+
const term = value.replace(/^[ \t]+|[ \t]+$/g, "").replace(/[ \t]+/g, " ");
|
|
25
|
+
if (
|
|
26
|
+
term &&
|
|
27
|
+
term.split(" ").length <= 8 &&
|
|
28
|
+
TERMS.test(term) &&
|
|
29
|
+
!SECRET.test(term) &&
|
|
30
|
+
!UNSAFE_RECAP.test(term)
|
|
31
|
+
) {
|
|
32
|
+
return term;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function recallTerms(input) {
|
|
37
|
+
if (
|
|
38
|
+
hasKeys(input, ["terms"]) &&
|
|
39
|
+
Array.isArray(input.terms) &&
|
|
40
|
+
input.terms.length >= 1 &&
|
|
41
|
+
input.terms.length <= 5
|
|
42
|
+
) {
|
|
43
|
+
const terms = input.terms.map(normalizeTerm);
|
|
44
|
+
if (terms.every(Boolean)) return [...new Set(terms)];
|
|
45
|
+
}
|
|
46
|
+
throw new PilotError(
|
|
47
|
+
"Use JSON with 1–5 short, non-sensitive keywords, phrases, paths, or symbols in terms.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validRef(ref) {
|
|
52
|
+
return (
|
|
53
|
+
hasKeys(ref, ["path", "symbol", "pullRequest", "commit"]) &&
|
|
54
|
+
Object.keys(ref).length > 0 &&
|
|
55
|
+
(ref.path === undefined ||
|
|
56
|
+
(typeof ref.path === "string" &&
|
|
57
|
+
ref.path.length <= 180 &&
|
|
58
|
+
REF_PATH.test(ref.path) &&
|
|
59
|
+
!ref.path.split("/").some((part) => part === "." || part === "..") &&
|
|
60
|
+
!UNSAFE_RECAP.test(ref.path))) &&
|
|
61
|
+
(ref.symbol === undefined ||
|
|
62
|
+
(typeof ref.symbol === "string" &&
|
|
63
|
+
ref.symbol.length <= 100 &&
|
|
64
|
+
/^[\w$][\w$.#:-]*$/u.test(ref.symbol) &&
|
|
65
|
+
!UNSAFE_RECAP.test(ref.symbol))) &&
|
|
66
|
+
(ref.pullRequest === undefined || (Number.isInteger(ref.pullRequest) && ref.pullRequest > 0)) &&
|
|
67
|
+
(ref.commit === undefined || (typeof ref.commit === "string" && SHA.test(ref.commit)))
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validRecap(text, limit) {
|
|
72
|
+
return (
|
|
73
|
+
typeof text === "string" &&
|
|
74
|
+
text.trim().length > 0 &&
|
|
75
|
+
text.length <= limit &&
|
|
76
|
+
!UNSAFE_RECAP.test(text)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function captureDraft(input) {
|
|
81
|
+
if (
|
|
82
|
+
!hasKeys(input, ["kind", "summary", "detail", "refs", "verification"]) ||
|
|
83
|
+
!["observation", "lesson", "decision"].includes(input.kind) ||
|
|
84
|
+
!validRecap(input.summary, 500) ||
|
|
85
|
+
(input.detail !== undefined && !validRecap(input.detail, 1000)) ||
|
|
86
|
+
(input.verification !== undefined && !validRecap(input.verification, 300)) ||
|
|
87
|
+
(input.refs !== undefined &&
|
|
88
|
+
(!Array.isArray(input.refs) || input.refs.length > 6 || !input.refs.every(validRef)))
|
|
89
|
+
) {
|
|
90
|
+
throw new PilotError(
|
|
91
|
+
"Use a bounded JSON candidate with kind, summary, and optional detail, refs, and verification.",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return input;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function worktreeEvidence(root, rev) {
|
|
98
|
+
try {
|
|
99
|
+
if (git(root, "status", "--porcelain=v1", "--untracked-files=normal")) return true;
|
|
100
|
+
return !git(
|
|
101
|
+
root,
|
|
102
|
+
"for-each-ref",
|
|
103
|
+
`--contains=${rev}`,
|
|
104
|
+
"--count=1",
|
|
105
|
+
"--format=%(objectname)",
|
|
106
|
+
"refs/remotes/origin/",
|
|
107
|
+
);
|
|
108
|
+
} catch {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pathAtRevision(root, rev, path) {
|
|
114
|
+
try {
|
|
115
|
+
git(root, "cat-file", "-e", `${rev}:${path}`);
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function citeRefs(root, rev, refs) {
|
|
123
|
+
const unverified = worktreeEvidence(root, rev);
|
|
124
|
+
const needsFullIndex = refs.some((ref) => !ref.commit && !ref.path && ref.symbol);
|
|
125
|
+
const fullIndex = !unverified && needsFullIndex && visibleGitIndex(root);
|
|
126
|
+
return Promise.all(
|
|
127
|
+
refs.map(async (ref) => {
|
|
128
|
+
if (ref.commit || !(ref.path || ref.symbol)) return ref;
|
|
129
|
+
const visible =
|
|
130
|
+
!unverified && (await (ref.path ? visibleGitIndex(root, ref.path) : fullIndex));
|
|
131
|
+
if (!visible || (ref.path && !pathAtRevision(root, rev, ref.path)))
|
|
132
|
+
return { ...ref, provenance: "worktree" };
|
|
133
|
+
return ref;
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validPair(id, version) {
|
|
139
|
+
return (
|
|
140
|
+
typeof id === "string" &&
|
|
141
|
+
UUID.test(id) &&
|
|
142
|
+
Number.isInteger(version) &&
|
|
143
|
+
version > 0 &&
|
|
144
|
+
version <= 1_000_000
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function executeTurn(
|
|
149
|
+
operation,
|
|
150
|
+
token,
|
|
151
|
+
{ target, store, read, request = requestMemory, compact = false },
|
|
152
|
+
) {
|
|
153
|
+
const change = (from, to) => {
|
|
154
|
+
const state = store.transition(token, target, from, to);
|
|
155
|
+
if (!state) throw new PilotError("This project memory turn is stale or not eligible.");
|
|
156
|
+
return state;
|
|
157
|
+
};
|
|
158
|
+
if (operation === "nothing-material") {
|
|
159
|
+
change(["nudged"], "acknowledged");
|
|
160
|
+
return { acknowledged: true };
|
|
161
|
+
}
|
|
162
|
+
if (operation === "receipt") {
|
|
163
|
+
const receipt = store.readReceipt(token, target);
|
|
164
|
+
if (!receipt) throw new PilotError("No confirmed capture receipt is available for this turn.");
|
|
165
|
+
return receipt;
|
|
166
|
+
}
|
|
167
|
+
let input;
|
|
168
|
+
try {
|
|
169
|
+
input = await read();
|
|
170
|
+
} catch {
|
|
171
|
+
throw new PilotError("Provide a bounded JSON object on standard input.");
|
|
172
|
+
}
|
|
173
|
+
if (operation === "expand") {
|
|
174
|
+
if (!hasKeys(input, ["id", "version"]) || !validPair(input.id, input.version))
|
|
175
|
+
throw new PilotError("Provide the exact record ID and version from the compact packet.");
|
|
176
|
+
const expanding = store.beginExpand(token, target, input.id, input.version);
|
|
177
|
+
if (!expanding)
|
|
178
|
+
throw new PilotError(
|
|
179
|
+
"This record version is outside the active packet, the turn is stale, or its two expansions are used.",
|
|
180
|
+
);
|
|
181
|
+
let result;
|
|
182
|
+
try {
|
|
183
|
+
result = await request(operation, {
|
|
184
|
+
repo: expanding.repo,
|
|
185
|
+
id: input.id,
|
|
186
|
+
version: input.version,
|
|
187
|
+
});
|
|
188
|
+
} catch (error) {
|
|
189
|
+
store.finishExpand(token, target, expanding.attempt);
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
if (!store.finishExpand(token, target, expanding.attempt))
|
|
193
|
+
throw new PilotError("This project memory turn ended before its expansion returned.");
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
const recall = operation === "recall";
|
|
197
|
+
const draft = recall ? { terms: recallTerms(input) } : captureDraft(input);
|
|
198
|
+
if (JSON.stringify(draft).includes(token))
|
|
199
|
+
throw new PilotError("The local turn token cannot appear in a memory request.");
|
|
200
|
+
const gitRev = recall ? null : git(target.root, "rev-parse", "HEAD");
|
|
201
|
+
if (!recall && !SHA.test(gitRev))
|
|
202
|
+
throw new PilotError("The checkout has no full Git commit to cite.");
|
|
203
|
+
const refs = !recall && draft.refs ? await citeRefs(target.root, gitRev, draft.refs) : undefined;
|
|
204
|
+
const running = recall ? "recalling" : "capturing";
|
|
205
|
+
const state = change([recall ? "pending" : "nudged"], running);
|
|
206
|
+
const payload = {
|
|
207
|
+
repo: state.repo,
|
|
208
|
+
...draft,
|
|
209
|
+
...(recall && compact && { format: "compact-v1" }),
|
|
210
|
+
...(!recall && { gitRev, client: state.client, operationId: state.operationId }),
|
|
211
|
+
...(refs && { refs }),
|
|
212
|
+
};
|
|
213
|
+
let result;
|
|
214
|
+
try {
|
|
215
|
+
result = await request(operation, payload);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
store.transition(token, target, [running], "failed");
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
if (recall) {
|
|
221
|
+
const pairs =
|
|
222
|
+
result?.format === "compact-v1" ? result.records.map(({ id, version }) => [id, version]) : [];
|
|
223
|
+
if (
|
|
224
|
+
pairs.length > 5 ||
|
|
225
|
+
!pairs.every(([id, version]) => validPair(id, version)) ||
|
|
226
|
+
!store.finishRecall(token, target, pairs)
|
|
227
|
+
) {
|
|
228
|
+
throw new PilotError("This project memory turn ended before recall returned.");
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
try {
|
|
232
|
+
store.saveReceipt(token, target, result.record);
|
|
233
|
+
store.transition(token, target, [running], "acknowledged");
|
|
234
|
+
} catch {
|
|
235
|
+
// The service already committed. Local disk or a stale turn cannot turn a confirmed record into a failed write.
|
|
236
|
+
return { ...result, localReceiptUnavailable: true };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|