hippocamp 0.2.11 → 0.2.13

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.
@@ -1,86 +0,0 @@
1
- const assert = require("node:assert/strict");
2
- const { execFile } = require("node:child_process");
3
- const fs = require("node:fs/promises");
4
- const os = require("node:os");
5
- const path = require("node:path");
6
- const { promisify } = require("node:util");
7
- const test = require("node:test");
8
- const memory = require("../scripts/hippocamp-memory.cjs");
9
-
10
- const exec = promisify(execFile);
11
-
12
- test("Dream runner publishes first summaries and skips an unchanged repeat", async (t) => {
13
- const root = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-runner-test-"));
14
- const seed = path.join(root, "seed");
15
- const remote = path.join(root, "remote.git");
16
- const bin = path.join(root, "bin");
17
- const calls = path.join(root, "prs");
18
- const preload = path.join(root, "model.cjs");
19
- const previousRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
20
- t.after(async () => {
21
- if (previousRoot === undefined) delete process.env.HIPPOCAMP_GLOBAL_ROOT;
22
- else process.env.HIPPOCAMP_GLOBAL_ROOT = previousRoot;
23
- await fs.rm(root, { recursive: true, force: true });
24
- });
25
- await fs.mkdir(seed);
26
- await fs.mkdir(bin);
27
- process.env.HIPPOCAMP_GLOBAL_ROOT = seed;
28
- await memory.appendEvent({ projectRoot: "/tmp/new-project", scope: "project", sync: false, title: "Confirmed decision", keywords: ["constraint"], content: "Keep production access read-only." });
29
- const env = {
30
- ...process.env,
31
- PATH: `${bin}${path.delimiter}${process.env.PATH}`,
32
- GIT_CONFIG_GLOBAL: path.join(root, "gitconfig"),
33
- GIT_CONFIG_NOSYSTEM: "1",
34
- GITHUB_TOKEN: "fixture",
35
- LAGOON_REPOSITORY: "fixture/lagoon",
36
- MANIFEST_API_KEY: "fixture",
37
- MANIFEST_BASE_URL: "https://model.invalid",
38
- HIPPOCAMP_DREAM_AUTO_MERGE: "false",
39
- NODE_OPTIONS: `--require=${preload}`,
40
- FIXTURE_REMOTE: remote,
41
- FIXTURE_CALLS: calls,
42
- };
43
- await fs.writeFile(env.GIT_CONFIG_GLOBAL, "[user]\nname = Fixture\nemail = fixture@localhost\n");
44
- await fs.writeFile(preload, `
45
- global.fetch = async () => {
46
- const fs = require("node:fs");
47
- const path = require("node:path");
48
- fs.writeFileSync(path.join(process.env.HIPPOCAMP_GLOBAL_ROOT, "unrelated.md"), "Do not publish this file");
49
- return { ok: true, status: 200, text: async () => JSON.stringify({ output_text: JSON.stringify({
50
- current_state_md: "# Current State\\n\\n- Keep production access read-only.",
51
- open_threads_md: "# Open Threads\\n\\n- Verify deployment."
52
- }) }) };
53
- };
54
- `);
55
- await fs.writeFile(path.join(bin, "gh"), `#!${process.execPath}
56
- const fs = require("node:fs");
57
- const { execFileSync } = require("node:child_process");
58
- const args = process.argv.slice(2);
59
- if (args[0] === "auth") process.exit(0);
60
- if (args[0] === "repo" && args[1] === "clone") {
61
- execFileSync("git", ["clone", process.env.FIXTURE_REMOTE, args[3]], { stdio: "inherit" });
62
- } else if (args[0] === "pr" && args[1] === "list") {
63
- if (fs.existsSync(process.env.FIXTURE_CALLS)) console.log("1");
64
- } else if (args[0] === "pr" && args[1] === "create") {
65
- fs.appendFileSync(process.env.FIXTURE_CALLS, "created\\n");
66
- } else {
67
- throw new Error("Unexpected gh operation: " + args.join(" "));
68
- }
69
- `, { mode: 0o755 });
70
- const git = (args, cwd = seed) => exec("git", args, { cwd, env });
71
- await git(["init", "-b", "main"]);
72
- await git(["add", "."]);
73
- await git(["commit", "-m", "Seed indexed evidence without curated files"]);
74
- const before = (await git(["rev-parse", "HEAD"])).stdout.trim();
75
- await git(["clone", "--bare", seed, remote]);
76
- const runner = path.resolve(__dirname, "../assets/railway/run-dream.sh");
77
- await exec("bash", [runner], { env });
78
- const changed = (await git(["diff", "--name-only", before, "refs/heads/dream/new-project"], remote)).stdout.trim().split("\n");
79
- assert.deepEqual(changed, ["projects/new-project/current_state.md", "projects/new-project/open_threads.md"]);
80
- assert.match((await git(["show", "dream/new-project:projects/new-project/current_state.md"], remote)).stdout, /hippocamp-dream-through:/);
81
- assert.equal(await fs.readFile(calls, "utf8"), "created\n");
82
- // Simulate accepting the reviewed PR, then repeat against the same evidence.
83
- await git(["update-ref", "refs/heads/main", "refs/heads/dream/new-project"], remote);
84
- await exec("bash", [runner], { env });
85
- assert.equal(await fs.readFile(calls, "utf8"), "created\n");
86
- });
@@ -1,190 +0,0 @@
1
- const assert = require("node:assert/strict");
2
- const fs = require("node:fs/promises");
3
- const os = require("node:os");
4
- const path = require("node:path");
5
- const test = require("node:test");
6
- const dream = require("../scripts/hippocamp-dream.cjs");
7
- const memory = require("../scripts/hippocamp-memory.cjs");
8
-
9
- async function fixture(t) {
10
- const previousRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
11
- const previousFetch = global.fetch;
12
- const root = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-dream-test-"));
13
- process.env.HIPPOCAMP_GLOBAL_ROOT = root;
14
- t.after(async () => {
15
- if (previousRoot === undefined) delete process.env.HIPPOCAMP_GLOBAL_ROOT;
16
- else process.env.HIPPOCAMP_GLOBAL_ROOT = previousRoot;
17
- global.fetch = previousFetch;
18
- await fs.rm(root, { recursive: true, force: true });
19
- });
20
- const slug = "dream-fixture";
21
- const projectRoot = `/tmp/${slug}`;
22
- const project = path.join(root, "projects", slug);
23
- await fs.mkdir(project, { recursive: true });
24
- await fs.writeFile(path.join(project, "current_state.md"), "# Current State\n\n- Keep production reads fail-closed.\n");
25
- await fs.writeFile(path.join(project, "open_threads.md"), "# Open Threads\n\n- Merge PR #2928, then verify deployment.\n- Investigate the unresolved native Google blocker.\n");
26
- const options = { thresholdChars: 20000, targetChars: 3000, write: true, apiKey: "test", baseUrl: "https://model.test", model: "test" };
27
- async function event(title, content, offset = -60000) {
28
- const timestamp = new Date(Date.now() + offset).toISOString();
29
- await memory.appendEvent({ projectRoot, scope: "project", sync: false, title, content, keywords: ["dream-test"], timestamp, date: timestamp.slice(0, 10) });
30
- return timestamp;
31
- }
32
- return { root, slug, projectRoot, project, options, event };
33
- }
34
-
35
- function respond(output) {
36
- return { ok: true, status: 200, text: async () => JSON.stringify({ output_text: JSON.stringify(output) }) };
37
- }
38
-
39
- test("small projects receive new evidence and may grow; a successful batch is not reviewed twice", async (t) => {
40
- const f = await fixture(t);
41
- const id = await f.event("PR #2928 merged and a reusable lesson", "PR #2928 merged. Deployment has not been verified.\n\nDecision: project identity follows the canonical repository, because worktree names are temporary.");
42
- const eventPath = path.join(f.project, "events", `${id.slice(0, 10)}.md`);
43
- const originalEvents = await fs.readFile(eventPath, "utf8");
44
- let calls = 0;
45
- global.fetch = async (_url, request) => {
46
- calls++;
47
- const input = JSON.parse(request.body).input;
48
- assert.match(input, /Recent Event Evidence/);
49
- assert.ok(input.includes(id));
50
- assert.match(input, /PR #2928 merged\. Deployment has not been verified/);
51
- assert.match(input, /canonical repository/);
52
- assert.match(input, /unresolved native Google blocker/);
53
- assert.match(input, /A merged PR closes the merge action, not unverified deployment/);
54
- return respond({
55
- current_state_md: `# Current State\n\n- Keep production reads fail-closed.\n- Project identity follows the canonical repository because worktree names are temporary. Source: events/${id.slice(0, 10)}.md, ${id}.`,
56
- open_threads_md: "# Open Threads\n\n- Verify PR #2928 deployment.\n- Investigate the unresolved native Google blocker.",
57
- });
58
- };
59
- const scan = await dream.dreamProject(f.slug, { ...f.options, write: false });
60
- assert.equal(scan.skipped, "dry_run");
61
- assert.equal(calls, 0);
62
- const result = await dream.dreamProject(f.slug, f.options);
63
- assert.ok(result.afterBytes > result.beforeBytes);
64
- assert.ok(result.afterBytes <= f.options.targetChars * 1.02);
65
- assert.equal(result.recentEvidence.items.length, 1);
66
- assert.equal(dream.toPublicReport(result).context.recentEvents, 1);
67
- assert.ok((await fs.readFile(path.join(f.project, "current_state.md"), "utf8")).includes(`hippocamp-dream-through: ${id}`));
68
- assert.equal(await fs.readFile(eventPath, "utf8"), originalEvents);
69
- assert.equal((await dream.dreamProject(f.slug, f.options)).skipped, "below_threshold");
70
- assert.equal(calls, 1);
71
- });
72
-
73
- test("bootstrap ignores old, future, and unindexed events; a recorded cursor survives downtime", async (t) => {
74
- const f = await fixture(t);
75
- const old = await f.event("Old event", "An older durable constraint.", -10 * 86400000);
76
- await f.event("Future event", "Not yet evidence.", 86400000);
77
- const today = new Date().toISOString().slice(0, 10);
78
- await fs.writeFile(path.join(f.project, "events", `${today}.md`), `## ${new Date().toISOString()} — Unindexed\n\nDo not ingest.\n`);
79
- assert.equal((await dream.dreamProject(f.slug, { ...f.options, write: false })).skipped, "below_threshold");
80
- const marker = new Date(Date.now() - 11 * 86400000).toISOString();
81
- await fs.appendFile(path.join(f.project, "current_state.md"), `\n<!-- hippocamp-dream-through: ${marker} -->\n`);
82
- const resumed = await dream.dreamProject(f.slug, { ...f.options, write: false });
83
- assert.equal(resumed.skipped, "dry_run");
84
- assert.deepEqual(resumed.recentEvidence.items.map((e) => e.id), [old]);
85
- });
86
-
87
- test("Dream preserves existing curated-file permissions when replacing the snapshot", async (t) => {
88
- const f = await fixture(t);
89
- await f.event("Durable correction", "Keep production reads fail-closed.");
90
- const currentPath = path.join(f.project, "current_state.md");
91
- const threadsPath = path.join(f.project, "open_threads.md");
92
- await fs.chmod(currentPath, 0o600);
93
- await fs.chmod(threadsPath, 0o640);
94
- global.fetch = async () => respond({ current_state_md: "# Current State\n\n- Keep production reads fail-closed.", open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
95
- await dream.dreamProject(f.slug, f.options);
96
- assert.equal((await fs.stat(currentPath)).mode & 0o777, 0o600);
97
- assert.equal((await fs.stat(threadsPath)).mode & 0o777, 0o640);
98
- });
99
-
100
- test("bounded batches defer remaining events without advancing past unseen evidence", async (t) => {
101
- const f = await fixture(t);
102
- const ids = [];
103
- for (let i = 0; i < 5; i++) ids.push(await f.event(`Lesson ${i}`, `Constraint ${i}: ${"Keep the documented boundary. ".repeat(18)}`, -60000 + i * 1000));
104
- global.fetch = async () => respond({ current_state_md: "# Current State\n\n- Keep the documented boundary.", open_threads_md: "# Open Threads\n\n- Investigate the unresolved native Google blocker." });
105
- const first = await dream.dreamProject(f.slug, f.options);
106
- assert.ok(first.recentEvidence.items.length > 0 && first.recentEvidence.items.length < ids.length);
107
- assert.equal(first.recentEvidence.reviewedThrough, first.recentEvidence.items.at(-1).id);
108
- const second = await dream.dreamProject(f.slug, { ...f.options, write: false });
109
- assert.equal(second.recentEvidence.items[0].id, ids[first.recentEvidence.items.length]);
110
- const report = dream.toPublicReport(first);
111
- assert.ok(report.context.evidenceChars <= report.context.evidenceBudgetChars);
112
- });
113
-
114
- test("an oversized model result fails without changing files or the event cursor", async (t) => {
115
- const f = await fixture(t);
116
- await f.event("Durable correction", "Keep production reads fail-closed.");
117
- const paths = ["current_state.md", "open_threads.md"].map((name) => path.join(f.project, name));
118
- const before = await Promise.all(paths.map((name) => fs.readFile(name, "utf8")));
119
- global.fetch = async () => respond({ current_state_md: `# Current State\n\n- ${"Too large. ".repeat(400)}`, open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
120
- await assert.rejects(dream.dreamProject(f.slug, f.options), /did not reduce the snapshot/);
121
- assert.deepEqual(await Promise.all(paths.map((name) => fs.readFile(name, "utf8"))), before);
122
- });
123
-
124
- test("an oversized first excerpt fails before a model call without skipping evidence", async (t) => {
125
- const f = await fixture(t);
126
- await f.event("Durable correction", "Keep production reads fail-closed.");
127
- const paths = ["current_state.md", "open_threads.md"].map((name) => path.join(f.project, name));
128
- const before = await Promise.all(paths.map((name) => fs.readFile(name, "utf8")));
129
- global.fetch = async () => assert.fail("Model must not be called");
130
- await assert.rejects(dream.dreamProject(f.slug, { ...f.options, targetChars: 100 }), /Recent event evidence does not fit/);
131
- assert.deepEqual(await Promise.all(paths.map((name) => fs.readFile(name, "utf8"))), before);
132
- });
133
-
134
- test("compaction retries retain source evidence while the caller owns the review marker", async (t) => {
135
- const f = await fixture(t);
136
- const id = await f.event("Durable correction", "Keep production reads fail-closed.");
137
- const source = `events/${id.slice(0, 10)}.md, ${id}`;
138
- let calls = 0;
139
- global.fetch = async (_url, request) => {
140
- const input = JSON.parse(request.body).input;
141
- calls++;
142
- if (calls === 1) return respond({ current_state_md: `# Current State\n\n- Keep production reads fail-closed. Source: ${source}.\n\n- ${"Historical detail. ".repeat(200)}`, open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
143
- assert.ok(input.includes(source));
144
- assert.match(input, /Preserve the event path and timestamp attached to each retained/);
145
- assert.doesNotMatch(input, /<!-- hippocamp-dream-through:/);
146
- const retryTarget = Number(input.match(/Target combined size[^:]*: (\d+) chars/)[1]);
147
- assert.ok(retryTarget > 0 && retryTarget < f.options.targetChars);
148
- return respond({ current_state_md: `# Current State\n\n- Keep production reads fail-closed. Source: ${source}.`, open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
149
- };
150
- const result = await dream.dreamProject(f.slug, f.options);
151
- assert.equal(result.compactionPasses, 2);
152
- const current = await fs.readFile(path.join(f.project, "current_state.md"), "utf8");
153
- assert.ok(current.includes(source));
154
- assert.equal((current.match(/<!-- hippocamp-dream-through:/g) || []).length, 1);
155
- assert.equal((await dream.dreamProject(f.slug, f.options)).skipped, "below_threshold");
156
- assert.equal(calls, 2);
157
- });
158
-
159
- test("recent reads ignore an index path that points outside its canonical event day", async (t) => {
160
- const f = await fixture(t);
161
- const id = await f.event("Safe event", "Canonical event evidence.");
162
- const indexPath = path.join(f.project, "events", `${id.slice(0, 10)}.index.json`);
163
- const index = JSON.parse(await fs.readFile(indexPath, "utf8"));
164
- index.path = "../../outside.md";
165
- await fs.writeFile(path.join(f.root, "outside.md"), `## ${id} — Other\n\nOutside scope.\n`);
166
- await fs.writeFile(indexPath, JSON.stringify(index));
167
- const events = await memory.readRecentEvents({ projectRoot: f.projectRoot, after: new Date(Date.now() - 86400000).toISOString() });
168
- assert.equal(events.length, 1);
169
- assert.match(events[0].snippet, /Canonical event evidence/);
170
- assert.doesNotMatch(events[0].snippet, /Outside scope/);
171
- });
172
-
173
- for (const failure of ["current_state.md", "open_threads.md", "commit"]) {
174
- test(`a failed ${failure} save leaves both files and the review marker unchanged`, async (t) => {
175
- const f = await fixture(t);
176
- await f.event("Durable correction", "Keep production reads fail-closed.");
177
- const paths = ["current_state.md", "open_threads.md"].map((name) => path.join(f.project, name));
178
- const before = await Promise.all(paths.map((name) => fs.readFile(name, "utf8")));
179
- global.fetch = async () => respond({ current_state_md: "# Current State\n\n- Keep production reads fail-closed.", open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
180
- const method = failure === "commit" ? "rename" : "writeFile";
181
- const original = fs[method];
182
- t.mock.method(fs, method, async (file, ...args) => {
183
- if (failure === "commit" ? args[0] === paths[0] : path.basename(file) === failure) throw new Error("Fixture write failure");
184
- return original(file, ...args);
185
- });
186
- await assert.rejects(dream.dreamProject(f.slug, f.options), /Fixture write failure/);
187
- assert.deepEqual(await Promise.all(paths.map((name) => fs.readFile(name, "utf8"))), before);
188
- assert.equal((await fs.readdir(f.project)).some((name) => name.startsWith(".dream-")), false);
189
- });
190
- }
@@ -1,129 +0,0 @@
1
- const test = require("node:test");
2
- const assert = require("node:assert/strict");
3
- const { execFileSync } = require("node:child_process");
4
- const fsSync = require("node:fs");
5
- const fs = require("node:fs/promises");
6
- const os = require("node:os");
7
- const path = require("node:path");
8
- const { mcpServerCommand, prepareLagoon } = require("../scripts/install-common.cjs");
9
-
10
- // CI runners have no Git identity; commits in these tests need one.
11
- const gitEnv = {
12
- GIT_AUTHOR_NAME: "Test",
13
- GIT_AUTHOR_EMAIL: "test@example.com",
14
- GIT_COMMITTER_NAME: "Test",
15
- GIT_COMMITTER_EMAIL: "test@example.com",
16
- };
17
-
18
- // Scoped to each test that needs a Git identity, then restored.
19
- function useGitIdentity(t) {
20
- const saved = Object.fromEntries(Object.keys(gitEnv).map((key) => [key, process.env[key]]));
21
- Object.assign(process.env, gitEnv);
22
- t.after(() => {
23
- for (const [key, value] of Object.entries(saved)) {
24
- if (value === undefined) {
25
- delete process.env[key];
26
- } else {
27
- process.env[key] = value;
28
- }
29
- }
30
- });
31
- }
32
-
33
- async function tempDir(t, name) {
34
- const root = await fs.mkdtemp(path.join(os.tmpdir(), `hippocamp-${name}-`));
35
- t.after(() => fs.rm(root, { recursive: true, force: true, maxRetries: 5 }));
36
- return root;
37
- }
38
-
39
- test("mcpServerCommand copies npx installs to a stable folder and keeps source checkouts direct", async (t) => {
40
- const root = await tempDir(t, "npx");
41
- const modules = path.join(root, "_npx", "abc", "node_modules");
42
- const npxRoot = path.join(modules, "hippocamp");
43
- const home = path.join(root, "home");
44
- await fs.mkdir(path.join(npxRoot, "scripts"), { recursive: true });
45
- await fs.mkdir(path.join(modules, "minisearch"), { recursive: true });
46
- await fs.writeFile(path.join(npxRoot, "package.json"), '{ "version": "9.9.9" }');
47
- await fs.writeFile(path.join(npxRoot, "scripts", "hippocamp-mcp.cjs"), "// server\n");
48
- await fs.writeFile(path.join(modules, "minisearch", "index.js"), "// dependency\n");
49
-
50
- const command = mcpServerCommand(npxRoot, home);
51
- const stable = path.join(home, ".hippocamp", "9.9.9", "node_modules");
52
-
53
- assert.deepEqual(command, [process.execPath, path.join(stable, "hippocamp", "scripts", "hippocamp-mcp.cjs")]);
54
- await fs.stat(command[1]);
55
- await fs.stat(path.join(stable, "minisearch", "index.js"));
56
-
57
- // A reinstall of the same version replaces the copy: no stale files, no staging left behind.
58
- await fs.writeFile(path.join(stable, "stale.js"), "// old\n");
59
- mcpServerCommand(npxRoot, home);
60
- await assert.rejects(fs.stat(path.join(stable, "stale.js")));
61
- assert.deepEqual(await fs.readdir(path.join(home, ".hippocamp", "9.9.9")), ["node_modules"]);
62
-
63
- const source = path.join(root, "src", "hippocamp");
64
- assert.deepEqual(mcpServerCommand(source), [process.execPath, path.join(source, "scripts", "hippocamp-mcp.cjs")]);
65
- });
66
-
67
- test("prepareLagoon creates a missing Lagoon repo and says it is local only", async (t) => {
68
- useGitIdentity(t);
69
- const lagoon = path.join(await tempDir(t, "lagoon"), "lagoon");
70
- const result = await prepareLagoon(lagoon);
71
-
72
- assert.equal(result.created, true);
73
- assert.equal(result.push, "no_remote");
74
- assert.match(result.hints.join("\n"), /gh repo create lagoon --private/);
75
- assert.equal(execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: lagoon }).toString().trim(), "true");
76
- });
77
-
78
- test("prepareLagoon leaves a non-empty folder that is not a repo alone", async (t) => {
79
- const folder = await tempDir(t, "notes");
80
- await fs.writeFile(path.join(folder, "notes.md"), "mine\n");
81
- const result = await prepareLagoon(folder);
82
-
83
- assert.equal(result.push, "not_git");
84
- await assert.rejects(fs.stat(path.join(folder, ".git")));
85
- });
86
-
87
- test("prepareLagoon checks push to an existing remote", async (t) => {
88
- useGitIdentity(t);
89
- const root = await tempDir(t, "remote");
90
- const remote = path.join(root, "remote.git");
91
- const lagoon = path.join(root, "lagoon");
92
- execFileSync("git", ["init", "--bare", "-b", "main", remote]);
93
- execFileSync("git", ["init", "-b", "main", lagoon]);
94
- execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: lagoon });
95
- execFileSync("git", ["remote", "add", "origin", remote], { cwd: lagoon });
96
- execFileSync("git", ["push", "-u", "origin", "main"], { cwd: lagoon, stdio: "ignore" });
97
-
98
- const result = await prepareLagoon(lagoon);
99
-
100
- assert.equal(result.created, false);
101
- assert.equal(result.remote, remote);
102
- assert.equal(result.push, "ok");
103
- });
104
-
105
- test("prepareLagoon refuses a folder inside another repo", async (t) => {
106
- useGitIdentity(t);
107
- const parent = await tempDir(t, "parent");
108
- execFileSync("git", ["init", "-b", "main", parent]);
109
- const nested = path.join(parent, "notes");
110
- await fs.mkdir(nested);
111
- await fs.writeFile(path.join(nested, "a.md"), "x\n");
112
-
113
- const result = await prepareLagoon(nested);
114
-
115
- assert.equal(result.push, "not_git");
116
- await assert.rejects(fs.stat(path.join(nested, ".git")));
117
- });
118
-
119
- test("prepareLagoon gives an empty folder inside another repo its own repo", async (t) => {
120
- useGitIdentity(t);
121
- const parent = await tempDir(t, "parent-empty");
122
- execFileSync("git", ["init", "-b", "main", parent]);
123
- const nested = path.join(parent, "lagoon");
124
-
125
- const result = await prepareLagoon(nested);
126
-
127
- assert.equal(result.created, true);
128
- assert.equal(fsSync.realpathSync(execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: nested }).toString().trim()), fsSync.realpathSync(nested));
129
- });
@@ -1,80 +0,0 @@
1
- const assert = require("node:assert/strict");
2
- const { execFileSync } = require("node:child_process");
3
- const fs = require("node:fs/promises");
4
- const os = require("node:os");
5
- const path = require("node:path");
6
- const test = require("node:test");
7
-
8
- const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
9
- const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
10
-
11
- function resultText(result) {
12
- return result.content.map((item) => item.text).join("\n");
13
- }
14
-
15
- test("MCP server rejects calls that would succeed but misbehave", async (t) => {
16
- const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-mcp-params-"));
17
- const lagoonRoot = path.join(tempRoot, "lagoon");
18
- const projectRoot = path.join(tempRoot, "params-project");
19
-
20
- await fs.mkdir(projectRoot, { recursive: true });
21
- execFileSync("git", ["init", "-q", projectRoot]);
22
-
23
- const client = new Client({ name: "hippocamp-test", version: "0.0.0" });
24
- await client.connect(
25
- new StdioClientTransport({
26
- command: process.execPath,
27
- args: [path.join(__dirname, "..", "scripts", "hippocamp-mcp.cjs")],
28
- cwd: projectRoot,
29
- env: { ...process.env, HIPPOCAMP_GLOBAL_ROOT: lagoonRoot, HIPPOCAMP_PROJECT_ROOT: projectRoot },
30
- }),
31
- );
32
- t.after(async () => {
33
- await client.close();
34
- await fs.rm(tempRoot, { recursive: true, force: true });
35
- });
36
-
37
- const eventWrite = await client.callTool({
38
- name: "write_memory_file",
39
- arguments: { scope: "project", path: "events/2026-09-29.md", content: "## Bypass\n\nNo index.", sync: false },
40
- });
41
- assert.equal(eventWrite.isError, true);
42
- assert.match(resultText(eventWrite), /append-only/);
43
-
44
- const noKeywords = await client.callTool({
45
- name: "append_event",
46
- arguments: { title: "Missing keywords", content: "Unsearchable.", sync: false },
47
- });
48
- assert.equal(noKeywords.isError, true);
49
-
50
- const tooManyKeywords = await client.callTool({
51
- name: "append_event",
52
- arguments: {
53
- title: "Too many keywords",
54
- keywords: Array.from({ length: 9 }, (_, index) => `keyword-${index}`),
55
- content: "Too broad.",
56
- sync: false,
57
- },
58
- });
59
- assert.equal(tooManyKeywords.isError, true);
60
-
61
- const noTitle = await client.callTool({
62
- name: "append_event",
63
- arguments: { title: " ", keywords: ["untitled"], content: "No heading.", sync: false },
64
- });
65
- assert.equal(noTitle.isError, true);
66
-
67
- const wrongProject = await client.callTool({
68
- name: "wake_up",
69
- arguments: { projectRoot: path.join(tempRoot, "missing-project") },
70
- });
71
- assert.equal(wrongProject.isError, true);
72
- assert.match(resultText(wrongProject), /Git repository/);
73
-
74
- const valid = await client.callTool({
75
- name: "append_event",
76
- arguments: { title: "Valid event", keywords: ["valid-keyword"], content: "Indexed.", projectRoot, sync: false },
77
- });
78
- assert.notEqual(valid.isError, true);
79
- assert.deepEqual(JSON.parse(resultText(valid)).keywords, ["valid-keyword"]);
80
- });