@cairnkeep/cli 1.1.3 → 2.1.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,44 @@
3
3
  All notable user-facing changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [2.1.0] - 2026-07-16
7
+
8
+ ### Added
9
+
10
+ - Add `memory_apply_reviewed` for idempotent application of externally reviewed
11
+ memory revisions with durable provenance and collision-safe history.
12
+ - Add `memory_invalidate_reviewed` for revision-matched retraction and durable
13
+ tombstones that prevent delayed writes from resurrecting invalid memory.
14
+ - Add end-to-end checks for replay, supersession, manual changes, retraction,
15
+ tombstones, and reserved provenance records.
16
+
17
+ ### Changed
18
+
19
+ - Make history snapshot keys unique when multiple revisions occur in the same
20
+ millisecond.
21
+
22
+ The existing memory tools, database locations, and remote HTTP routing contract
23
+ are unchanged. Existing clients remain compatible and reviewed-memory records
24
+ are created only when an integration explicitly invokes the new tools.
25
+
26
+ ## [2.0.0] - 2026-07-15
27
+
28
+ ### Breaking
29
+
30
+ - Require Node.js 22 or newer. Node.js 18 and 20 are end-of-life upstream;
31
+ Cairnkeep 1.x remains available for machines that cannot upgrade yet.
32
+
33
+ ### Changed
34
+
35
+ - Upgrade the runtime schema dependency to Zod 4 in both package manifests.
36
+ - Build with TypeScript 7 against Node.js 22 type definitions.
37
+ - Exercise Node.js 22, 24, and 26 on Linux, retain real macOS installation
38
+ coverage, and verify the packed package at the Node.js 22 runtime floor.
39
+
40
+ The memory database format, default storage location, remote HTTP protocol,
41
+ and project scaffold format are unchanged. Upgrading does not migrate or delete
42
+ stored memories.
43
+
6
44
  ## [1.1.3] - 2026-07-14
7
45
 
8
46
  ### Fixed
@@ -51,6 +89,10 @@ All notable user-facing changes are documented here. This project follows
51
89
  - Preserve executable permissions and Bash 3.2 portability on macOS.
52
90
  - Add backup-first uninstall and SQLite-safe memory export/import guidance.
53
91
 
92
+ [2.1.0]: https://github.com/cairnkeep/cairnkeep/compare/v2.0.0...v2.1.0
93
+ [2.0.0]: https://github.com/cairnkeep/cairnkeep/compare/v1.1.3...v2.0.0
94
+ [1.1.3]: https://github.com/cairnkeep/cairnkeep/compare/v1.1.2...v1.1.3
95
+ [1.1.2]: https://github.com/cairnkeep/cairnkeep/compare/v1.1.1...v1.1.2
54
96
  [1.1.1]: https://github.com/cairnkeep/cairnkeep/compare/v1.1.0...v1.1.1
55
97
  [1.1.0]: https://github.com/cairnkeep/cairnkeep/compare/v1.0.5...v1.1.0
56
98
  [1.0.5]: https://github.com/cairnkeep/cairnkeep/compare/v1.0.4...v1.0.5
package/CONTRIBUTING.md CHANGED
@@ -15,9 +15,9 @@ Contributions are welcome through GitHub issues and pull requests.
15
15
 
16
16
  ## Development setup
17
17
 
18
- The memory server requires Node.js 18 or newer. Node.js 22 or 24 is recommended
19
- for development; current CI uses Node.js 24 and retains a clean Node.js 18
20
- compatibility boot check.
18
+ The memory server requires Node.js 22 or newer. CI builds and tests on Node.js
19
+ 22, 24, and 26, including a clean packed-install boot check at the Node.js 22
20
+ runtime floor.
21
21
 
22
22
  ```bash
23
23
  git clone https://github.com/cairnkeep/cairnkeep.git
package/README.md CHANGED
@@ -26,9 +26,9 @@ cairnkeep-org sibling project.
26
26
 
27
27
  ## Compatibility
28
28
 
29
- Node.js 22 or 24 is recommended. Node.js 18 and 20 remain compatible for the
30
- 1.x line but are end-of-life upstream; the minimum will move in the next major
31
- release.
29
+ Node.js 22, 24, and 26 are supported. Cairnkeep 2.x requires Node.js 22 or
30
+ newer; Cairnkeep 1.x remains available for older runtimes, although Node.js 18
31
+ and 20 are end-of-life upstream.
32
32
 
33
33
  | Client or platform | Support level |
34
34
  |---|---|
@@ -38,7 +38,7 @@ release.
38
38
  | Other MCP clients | Memory and optional domain-knowledge MCP tools |
39
39
  | Native Windows | Not supported by the Bash-based installer; use WSL (not yet CI-verified) |
40
40
 
41
- Linux, macOS, Bash 3.2 portability, and a clean Node 18 compatibility install
41
+ Linux, macOS, Bash 3.2 portability, and clean Node 22/24/26 runtime checks
42
42
  are exercised in CI.
43
43
 
44
44
  ## Components
package/docs/operating.md CHANGED
@@ -16,8 +16,8 @@ installs. This guide covers all three in order.
16
16
 
17
17
  ## Prerequisites
18
18
 
19
- - Node.js (for the memory server) and a supported harness: Claude Code or
20
- OpenCode.
19
+ - Node.js 22 or newer (for the memory server) and a supported harness: Claude
20
+ Code or OpenCode.
21
21
  - Optional: the `sqlite3` CLI for `cairn memory export`. Runtime memory and
22
22
  `cairn memory import` do not require it.
23
23
  - Optional: an OpenAI-compatible LLM endpoint for memory extraction and
package/docs/storage.md CHANGED
@@ -112,6 +112,31 @@ sqlite3 /path/to/project/.agentfs/project.db \
112
112
  Treat every database and export archive as sensitive. They may contain source
113
113
  paths, decisions, incident details, and other project context.
114
114
 
115
+ ## Reviewed-memory integration
116
+
117
+ External review systems should not compose `memory_write`, `memory_read`, and
118
+ `memory_delete` into their own promotion protocol. That sequence is not atomic
119
+ and can delete a newer manual revision during lifecycle invalidation.
120
+
121
+ Cairnkeep exposes two additive MCP tools for reviewed integrations:
122
+
123
+ - `memory_apply_reviewed` takes `scope`, `review_id`, `key`, and `value`. It is
124
+ idempotent for the same review and content, preserves a displaced live value
125
+ in memory history, and rejects reuse of the review ID with different content.
126
+ - `memory_invalidate_reviewed` takes `scope`, `review_id`, `key`, and an optional
127
+ `reason`. It removes the live value only when it still matches that reviewed
128
+ revision. If apply has not arrived yet, it records a tombstone so a delayed
129
+ apply cannot resurrect invalid memory.
130
+
131
+ Provenance and tombstones use a hidden reserved namespace in the same scoped
132
+ database. Generic memory writes and deletes cannot modify those records. Review
133
+ IDs must be stable, non-secret idempotency identifiers; evidence payloads and
134
+ credentials do not belong in them.
135
+
136
+ These tools are for an explicit, trusted integration. Cairnkeep itself does not
137
+ approve candidates, discover an evidence service, or promote memory
138
+ automatically. Existing clients and the original memory tools remain unchanged.
139
+
115
140
  ## Automating trusted personal clients
116
141
 
117
142
  Keep fleet-specific values in a private dotfiles repository or secret manager,
@@ -13,6 +13,12 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
13
13
  import { z } from "zod";
14
14
  import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashText, } from "./embeddings.js";
15
15
  import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
16
+ const MINIMUM_NODE_MAJOR = 22;
17
+ const nodeMajor = Number.parseInt(process.versions.node, 10);
18
+ if (!Number.isInteger(nodeMajor) || nodeMajor < MINIMUM_NODE_MAJOR) {
19
+ process.stderr.write(`cairn-memory requires Node.js ${MINIMUM_NODE_MAJOR} or newer; found ${process.versions.node}\n`);
20
+ process.exit(1);
21
+ }
16
22
  class ClientContextError extends Error {
17
23
  }
18
24
  const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -23,6 +29,8 @@ const anythingllmSyncScript = process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT
23
29
  ? resolve(expandHome(process.env.CAIRN_ANYTHINGLLM_SYNC_SCRIPT))
24
30
  : join(infraRoot, "examples", "anythingllm", "sync_to_anythingllm.py");
25
31
  const HISTORY_NAMESPACE = "__history__";
32
+ const REVIEWED_NAMESPACE = "__reviewed__";
33
+ const REVIEW_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
26
34
  const PROJECT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
27
35
  const WORKSPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
28
36
  function expandHome(value) {
@@ -174,17 +182,52 @@ async function openScope(scope, create, options = {}) {
174
182
  function isHistoryKey(key) {
175
183
  return key === HISTORY_NAMESPACE || key.startsWith(`${HISTORY_NAMESPACE}/`);
176
184
  }
185
+ function isReviewedKey(key) {
186
+ return key === REVIEWED_NAMESPACE || key.startsWith(`${REVIEWED_NAMESPACE}/`);
187
+ }
188
+ function assertWritableMemoryKey(key) {
189
+ if (isHistoryKey(key) || isReviewedKey(key)) {
190
+ throw new Error(`Keys under ${HISTORY_NAMESPACE}/ and ${REVIEWED_NAMESPACE}/ are reserved.`);
191
+ }
192
+ }
177
193
  function historyPrefix(baseKey) {
178
194
  return `${HISTORY_NAMESPACE}/${baseKey}/`;
179
195
  }
180
196
  function historySnapshotKey(baseKey, timestamp) {
181
- return `${historyPrefix(baseKey)}${timestamp}`;
197
+ return `${historyPrefix(baseKey)}${timestamp}-${randomUUID()}`;
198
+ }
199
+ const reviewedRecordSchema = z.object({
200
+ schema_version: z.literal(1),
201
+ review_id: z.string(),
202
+ key: z.string(),
203
+ value_hash: z.string(),
204
+ state: z.enum(["active", "superseded", "invalidated"]),
205
+ applied_at: z.string(),
206
+ superseded_at: z.string().optional(),
207
+ superseded_by: z.string().optional(),
208
+ invalidated_at: z.string().optional(),
209
+ invalidation_reason: z.string().optional(),
210
+ });
211
+ function reviewedRecordKey(reviewId) {
212
+ return `${REVIEWED_NAMESPACE}/${reviewId}`;
213
+ }
214
+ function parseReviewedRecord(value) {
215
+ const parsed = reviewedRecordSchema.safeParse(value);
216
+ if (!parsed.success) {
217
+ throw new Error("Stored reviewed-memory provenance is invalid; refusing to continue.");
218
+ }
219
+ return parsed.data;
220
+ }
221
+ async function inImmediateScopeTransaction(agent, operation) {
222
+ const transaction = agent.getDatabase().transaction(operation);
223
+ const immediate = transaction.immediate;
224
+ return immediate();
182
225
  }
183
226
  function visibleEntries(entries, includeHistory) {
184
227
  if (includeHistory) {
185
228
  return entries;
186
229
  }
187
- return entries.filter((entry) => !isHistoryKey(entry.key));
230
+ return entries.filter((entry) => !isHistoryKey(entry.key) && !isReviewedKey(entry.key));
188
231
  }
189
232
  async function listEntries(scope, prefix = "", options = {}) {
190
233
  const agent = await openScope(scope, false, options);
@@ -753,9 +796,7 @@ function createMemoryServer(context = {}) {
753
796
  promote_to: z.string().optional(),
754
797
  }),
755
798
  }, async ({ scope, key, value, promote_to }) => {
756
- if (isHistoryKey(key)) {
757
- throw new Error(`Keys under ${HISTORY_NAMESPACE}/ are reserved for memory history.`);
758
- }
799
+ assertWritableMemoryKey(key);
759
800
  const targets = promote_to && promote_to !== scope ? [scope, promote_to] : [scope];
760
801
  // Collision-safe: in the unified store, writes from different repos/machines
761
802
  // can share a key. If a different value already exists, preserve the old one
@@ -818,6 +859,9 @@ function createMemoryServer(context = {}) {
818
859
  key: z.string().min(1),
819
860
  }),
820
861
  }, async ({ scope, key }) => {
862
+ if (isReviewedKey(key)) {
863
+ throw new Error(`Keys under ${REVIEWED_NAMESPACE}/ are reserved for reviewed-memory provenance.`);
864
+ }
821
865
  const agent = await openScope(scope, false, scopeOptions);
822
866
  if (agent) {
823
867
  try {
@@ -881,9 +925,7 @@ function createMemoryServer(context = {}) {
881
925
  reason: z.string().optional(),
882
926
  }),
883
927
  }, async ({ scope, key, value, reason }) => {
884
- if (isHistoryKey(key)) {
885
- throw new Error(`Keys under ${HISTORY_NAMESPACE}/ are reserved for memory history.`);
886
- }
928
+ assertWritableMemoryKey(key);
887
929
  const agent = await openScope(scope, true, scopeOptions);
888
930
  if (!agent) {
889
931
  throw new Error(`Unable to open scope ${scope}.`);
@@ -923,6 +965,207 @@ function createMemoryServer(context = {}) {
923
965
  await agent.close();
924
966
  }
925
967
  });
968
+ server.registerTool("memory_apply_reviewed", {
969
+ description: "Idempotently apply a human-reviewed memory revision with durable provenance.",
970
+ inputSchema: z.object({
971
+ scope: z.string(),
972
+ review_id: z.string().regex(REVIEW_ID_PATTERN),
973
+ key: z.string().min(1),
974
+ value: z.string(),
975
+ }),
976
+ }, async ({ scope, review_id, key, value }) => {
977
+ assertWritableMemoryKey(key);
978
+ const agent = await openScope(scope, true, scopeOptions);
979
+ if (!agent) {
980
+ throw new Error(`Unable to open scope ${scope}.`);
981
+ }
982
+ try {
983
+ const payload = await inImmediateScopeTransaction(agent, async () => {
984
+ const recordKey = reviewedRecordKey(review_id);
985
+ const valueHash = hashText(value);
986
+ const stored = await agent.kv.get(recordKey);
987
+ if (stored !== undefined) {
988
+ const existing = parseReviewedRecord(stored);
989
+ if (existing.key !== key || existing.value_hash !== valueHash) {
990
+ throw new Error(`Review id ${review_id} was already used with different content.`);
991
+ }
992
+ if (existing.state !== "active") {
993
+ throw new Error(`Review id ${review_id} is ${existing.state} and cannot be reapplied.`);
994
+ }
995
+ return {
996
+ ok: true,
997
+ scope,
998
+ review_id,
999
+ key,
1000
+ applied: false,
1001
+ idempotent: true,
1002
+ snapshot_key: null,
1003
+ };
1004
+ }
1005
+ const appliedAt = new Date().toISOString();
1006
+ const provenance = await agent.kv.list(`${REVIEWED_NAMESPACE}/`);
1007
+ for (const entry of provenance) {
1008
+ const previousRecord = parseReviewedRecord(entry.value);
1009
+ if (previousRecord.state === "active" && previousRecord.key === key) {
1010
+ await agent.kv.set(entry.key, {
1011
+ ...previousRecord,
1012
+ state: "superseded",
1013
+ superseded_at: appliedAt,
1014
+ superseded_by: review_id,
1015
+ });
1016
+ }
1017
+ }
1018
+ const previous = await agent.kv.get(key);
1019
+ const previousValue = previous === undefined ? undefined : normalizeValue(previous);
1020
+ let snapshotKey = null;
1021
+ if (previousValue !== undefined && previousValue !== value) {
1022
+ snapshotKey = historySnapshotKey(key, appliedAt);
1023
+ await agent.kv.set(snapshotKey, {
1024
+ value: previousValue,
1025
+ superseded_at: appliedAt,
1026
+ superseded_reason: `reviewed memory ${review_id}`,
1027
+ });
1028
+ }
1029
+ await agent.kv.set(key, value);
1030
+ await agent.kv.set(recordKey, {
1031
+ schema_version: 1,
1032
+ review_id,
1033
+ key,
1034
+ value_hash: valueHash,
1035
+ state: "active",
1036
+ applied_at: appliedAt,
1037
+ });
1038
+ return {
1039
+ ok: true,
1040
+ scope,
1041
+ review_id,
1042
+ key,
1043
+ applied: true,
1044
+ idempotent: false,
1045
+ snapshot_key: snapshotKey,
1046
+ };
1047
+ });
1048
+ return {
1049
+ content: [{ type: "text", text: asToolText(payload) }],
1050
+ structuredContent: payload,
1051
+ };
1052
+ }
1053
+ finally {
1054
+ await agent.close();
1055
+ }
1056
+ });
1057
+ server.registerTool("memory_invalidate_reviewed", {
1058
+ description: "Invalidate a reviewed revision, removing it only when the live value still matches.",
1059
+ inputSchema: z.object({
1060
+ scope: z.string(),
1061
+ review_id: z.string().regex(REVIEW_ID_PATTERN),
1062
+ key: z.string().min(1),
1063
+ reason: z.string().max(512).optional(),
1064
+ }),
1065
+ }, async ({ scope, review_id, key, reason }) => {
1066
+ assertWritableMemoryKey(key);
1067
+ const agent = await openScope(scope, true, scopeOptions);
1068
+ if (!agent) {
1069
+ throw new Error(`Unable to open scope ${scope}.`);
1070
+ }
1071
+ try {
1072
+ const payload = await inImmediateScopeTransaction(agent, async () => {
1073
+ const recordKey = reviewedRecordKey(review_id);
1074
+ const stored = await agent.kv.get(recordKey);
1075
+ if (stored === undefined) {
1076
+ const invalidatedAt = new Date().toISOString();
1077
+ await agent.kv.set(recordKey, {
1078
+ schema_version: 1,
1079
+ review_id,
1080
+ key,
1081
+ value_hash: "",
1082
+ state: "invalidated",
1083
+ applied_at: invalidatedAt,
1084
+ invalidated_at: invalidatedAt,
1085
+ ...(reason ? { invalidation_reason: reason } : {}),
1086
+ });
1087
+ return {
1088
+ ok: true,
1089
+ scope,
1090
+ review_id,
1091
+ key,
1092
+ invalidated: true,
1093
+ idempotent: false,
1094
+ missing: true,
1095
+ removed: false,
1096
+ current_changed: false,
1097
+ snapshot_key: null,
1098
+ };
1099
+ }
1100
+ const record = parseReviewedRecord(stored);
1101
+ if (record.key !== key) {
1102
+ throw new Error(`Review id ${review_id} belongs to a different memory key.`);
1103
+ }
1104
+ if (record.state === "invalidated") {
1105
+ return {
1106
+ ok: true,
1107
+ scope,
1108
+ review_id,
1109
+ key: record.key,
1110
+ invalidated: false,
1111
+ idempotent: true,
1112
+ missing: record.value_hash === "",
1113
+ removed: false,
1114
+ current_changed: false,
1115
+ snapshot_key: null,
1116
+ };
1117
+ }
1118
+ const invalidatedAt = new Date().toISOString();
1119
+ let currentChanged = false;
1120
+ let removed = false;
1121
+ let snapshotKey = null;
1122
+ if (record.state === "active") {
1123
+ const current = await agent.kv.get(record.key);
1124
+ if (current !== undefined) {
1125
+ const currentValue = normalizeValue(current);
1126
+ if (hashText(currentValue) === record.value_hash) {
1127
+ snapshotKey = historySnapshotKey(record.key, invalidatedAt);
1128
+ await agent.kv.set(snapshotKey, {
1129
+ value: currentValue,
1130
+ superseded_at: invalidatedAt,
1131
+ superseded_reason: reason ?? `reviewed memory ${review_id} invalidated`,
1132
+ });
1133
+ await agent.kv.delete(record.key);
1134
+ removed = true;
1135
+ }
1136
+ else {
1137
+ currentChanged = true;
1138
+ }
1139
+ }
1140
+ }
1141
+ await agent.kv.set(recordKey, {
1142
+ ...record,
1143
+ state: "invalidated",
1144
+ invalidated_at: invalidatedAt,
1145
+ ...(reason ? { invalidation_reason: reason } : {}),
1146
+ });
1147
+ return {
1148
+ ok: true,
1149
+ scope,
1150
+ review_id,
1151
+ key: record.key,
1152
+ invalidated: true,
1153
+ idempotent: false,
1154
+ missing: false,
1155
+ removed,
1156
+ current_changed: currentChanged,
1157
+ snapshot_key: snapshotKey,
1158
+ };
1159
+ });
1160
+ return {
1161
+ content: [{ type: "text", text: asToolText(payload) }],
1162
+ structuredContent: payload,
1163
+ };
1164
+ }
1165
+ finally {
1166
+ await agent.close();
1167
+ }
1168
+ });
926
1169
  server.registerTool("memory_history", {
927
1170
  description: "Read prior versions of a memory entry from the hidden history namespace.",
928
1171
  inputSchema: z.object({
@@ -5,6 +5,9 @@
5
5
  "description": "Durable, harness-agnostic memory MCP server for coding agents — the core of Cairnkeep.",
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
8
11
  "scripts": {
9
12
  "build": "tsc -p tsconfig.json",
10
13
  "start": "node dist/index.js",
@@ -19,8 +22,9 @@
19
22
  "check:explore-cache": "node scripts/smoke-explore-cache.mjs",
20
23
  "check:explore-crossref": "node scripts/smoke-explore-crossref.mjs",
21
24
  "check:route-guard": "node scripts/smoke-route-guard.mjs",
25
+ "check:reviewed-memory": "node scripts/smoke-reviewed-memory.mjs",
22
26
  "test": "npm run test:smoke",
23
- "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard",
27
+ "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory",
24
28
  "test:search": "npm run build && npm run check:search",
25
29
  "test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
26
30
  "test:all": "npm run test:memory"
@@ -28,10 +32,10 @@
28
32
  "dependencies": {
29
33
  "@modelcontextprotocol/sdk": "^1.29.0",
30
34
  "agentfs-sdk": "^0.6.4",
31
- "zod": "^3.25.76"
35
+ "zod": "^4.4.3"
32
36
  },
33
37
  "devDependencies": {
34
- "@types/node": "^22.15.21",
35
- "typescript": "^5.8.3"
38
+ "@types/node": "^22.20.1",
39
+ "typescript": "^7.0.2"
36
40
  }
37
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "1.1.3",
3
+ "version": "2.1.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents (Claude Code, OpenCode).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,12 +28,12 @@
28
28
  "AGENTS.md"
29
29
  ],
30
30
  "engines": {
31
- "node": ">=18"
31
+ "node": ">=22"
32
32
  },
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/sdk": "^1.29.0",
35
35
  "agentfs-sdk": "^0.6.4",
36
- "zod": "^3.25.76"
36
+ "zod": "^4.4.3"
37
37
  },
38
38
  "scripts": {
39
39
  "build:server": "npm --prefix mcp-memory-server ci --include=dev --global=false --dry-run=false && npm --prefix mcp-memory-server run build --global=false --dry-run=false",
package/scripts/doctor.sh CHANGED
@@ -35,7 +35,12 @@ echo "cairn doctor"
35
35
  server="$CAIRN_ROOT/mcp-memory-server/dist/index.js"
36
36
  probe="$CAIRN_ROOT/scripts/probe-memory-server.mjs"
37
37
  if ! command -v node >/dev/null 2>&1; then
38
- fail "Node.js not found (Node.js 18 or newer is required)"
38
+ fail "Node.js not found (Node.js 22 or newer is required)"
39
+ elif ! node_major=$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null) ||
40
+ [[ ! "$node_major" =~ ^[0-9]+$ ]]; then
41
+ fail "could not determine the Node.js version (Node.js 22 or newer is required)"
42
+ elif [[ "$node_major" -lt 22 ]]; then
43
+ fail "Node.js $(node --version 2>/dev/null || printf unknown) is unsupported (Node.js 22 or newer is required)"
39
44
  elif [[ ! -f "$server" ]]; then
40
45
  fail "memory server not built — run: (cd \"$CAIRN_ROOT/mcp-memory-server\" && npm install && npm run build)"
41
46
  elif node "$probe" "$server" >/dev/null 2>&1; then
@@ -17,7 +17,24 @@ proj="$tmp/clean"; mkdir -p "$proj"
17
17
  ( cd "$proj" && "$doctor" ) >"$tmp/out1" 2>&1 || fail "doctor exited non-zero with nothing configured:\n$(cat "$tmp/out1")"
18
18
  grep -q "\[SKIP\]" "$tmp/out1" || fail "expected SKIP lines when nothing is configured"
19
19
 
20
- # 2. A configured but unreachable endpoint → FAIL + non-zero exit.
20
+ # 2. An unsupported runtime is diagnosed before the server probe.
21
+ mkdir -p "$tmp/old-node"
22
+ cat > "$tmp/old-node/node" <<'EOF'
23
+ #!/usr/bin/env bash
24
+ case "${1:-}" in
25
+ -p) echo 20 ;;
26
+ --version) echo v20.19.0 ;;
27
+ *) exit 1 ;;
28
+ esac
29
+ EOF
30
+ chmod 755 "$tmp/old-node/node"
31
+ if ( cd "$proj" && PATH="$tmp/old-node:$PATH" "$doctor" ) >"$tmp/out-old-node" 2>&1; then
32
+ fail "doctor should exit non-zero for an unsupported Node.js runtime:\n$(cat "$tmp/out-old-node")"
33
+ fi
34
+ grep -q "Node.js v20.19.0 is unsupported" "$tmp/out-old-node" ||
35
+ fail "expected a clear unsupported-Node.js diagnostic"
36
+
37
+ # 3. A configured but unreachable endpoint → FAIL + non-zero exit.
21
38
  if command -v curl >/dev/null 2>&1; then
22
39
  proj2="$tmp/broken"; mkdir -p "$proj2/.ai"
23
40
  echo 'CAIRN_LLM_API_URL=http://127.0.0.1:1' > "$proj2/.ai/.env"
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
5
+
6
+ node --input-type=module - "$ROOT" <<'EOF'
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ const root = process.argv[2];
11
+ const rootPackage = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
12
+ const serverPackage = JSON.parse(
13
+ readFileSync(join(root, "mcp-memory-server", "package.json"), "utf8"),
14
+ );
15
+
16
+ const failures = [];
17
+ if (rootPackage.engines?.node !== ">=22") failures.push("root Node.js engine must be >=22");
18
+ if (serverPackage.engines?.node !== rootPackage.engines?.node) {
19
+ failures.push("root and memory-server Node.js engines differ");
20
+ }
21
+ if (rootPackage.dependencies?.zod !== serverPackage.dependencies?.zod) {
22
+ failures.push("root and memory-server Zod declarations differ");
23
+ }
24
+ if (!rootPackage.dependencies?.zod?.startsWith("^4.")) failures.push("Zod must use major 4");
25
+ if (!serverPackage.devDependencies?.typescript?.startsWith("^7.")) {
26
+ failures.push("TypeScript must use major 7");
27
+ }
28
+ if (!serverPackage.devDependencies?.["@types/node"]?.startsWith("^22.")) {
29
+ failures.push("Node.js types must match the Node.js 22 runtime floor");
30
+ }
31
+
32
+ if (failures.length > 0) {
33
+ for (const failure of failures) console.error(`FAIL: ${failure}`);
34
+ process.exit(1);
35
+ }
36
+ EOF
37
+
38
+ grep -q 'node: \["22", "24", "26"\]' "$ROOT/.github/workflows/ci.yml"
39
+ if grep -Eq 'node-version: "(18|20)"|node:(18|20)-slim' "$ROOT/.github/workflows/ci.yml"; then
40
+ echo "FAIL: CI still declares an end-of-life Node.js runtime" >&2
41
+ exit 1
42
+ fi
43
+
44
+ echo "PASS: runtime and toolchain declarations are aligned"