@danielblomma/cortex-mcp 0.4.5 → 1.0.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.
Files changed (64) hide show
  1. package/README.md +125 -42
  2. package/bin/cortex.mjs +36 -63
  3. package/bin/wsl.mjs +30 -0
  4. package/package.json +15 -3
  5. package/scaffold/.context/ontology.cypher +47 -0
  6. package/scaffold/.githooks/post-commit +14 -0
  7. package/scaffold/.githooks/post-rewrite +23 -0
  8. package/scaffold/mcp/package-lock.json +16 -16
  9. package/scaffold/mcp/package.json +4 -1
  10. package/scaffold/mcp/src/contextEntities.ts +311 -0
  11. package/scaffold/mcp/src/defaults.ts +6 -0
  12. package/scaffold/mcp/src/embed.ts +163 -37
  13. package/scaffold/mcp/src/frontmatter.ts +39 -0
  14. package/scaffold/mcp/src/graph.ts +253 -130
  15. package/scaffold/mcp/src/graphMetrics.ts +12 -0
  16. package/scaffold/mcp/src/impactPresentation.ts +202 -0
  17. package/scaffold/mcp/src/impactRanking.ts +237 -0
  18. package/scaffold/mcp/src/impactResponse.ts +47 -0
  19. package/scaffold/mcp/src/impactResults.ts +173 -0
  20. package/scaffold/mcp/src/impactSeed.ts +33 -0
  21. package/scaffold/mcp/src/impactTraversal.ts +83 -0
  22. package/scaffold/mcp/src/jsonl.ts +34 -0
  23. package/scaffold/mcp/src/loadGraph.ts +345 -86
  24. package/scaffold/mcp/src/paths.ts +33 -2
  25. package/scaffold/mcp/src/presets.ts +137 -0
  26. package/scaffold/mcp/src/relatedResponse.ts +30 -0
  27. package/scaffold/mcp/src/relatedTraversal.ts +101 -0
  28. package/scaffold/mcp/src/rules.ts +27 -0
  29. package/scaffold/mcp/src/search.ts +186 -455
  30. package/scaffold/mcp/src/searchCore.ts +274 -0
  31. package/scaffold/mcp/src/searchResults.ts +133 -0
  32. package/scaffold/mcp/src/server.ts +95 -3
  33. package/scaffold/mcp/src/types.ts +82 -3
  34. package/scaffold/scripts/context.sh +12 -46
  35. package/scaffold/scripts/dashboard.mjs +797 -0
  36. package/scaffold/scripts/dashboard.sh +13 -0
  37. package/scaffold/scripts/ingest.mjs +2227 -59
  38. package/scaffold/scripts/install-git-hooks.sh +3 -1
  39. package/scaffold/scripts/memory-compile.mjs +241 -0
  40. package/scaffold/scripts/memory-compile.sh +20 -0
  41. package/scaffold/scripts/memory-lint.mjs +384 -0
  42. package/scaffold/scripts/memory-lint.sh +20 -0
  43. package/scaffold/scripts/parsers/config.mjs +178 -0
  44. package/scaffold/scripts/parsers/cpp.mjs +316 -0
  45. package/scaffold/scripts/parsers/dotnet/VbNetParser/Program.cs +374 -0
  46. package/scaffold/scripts/parsers/dotnet/VbNetParser/VbNetParser.csproj +13 -0
  47. package/scaffold/scripts/parsers/javascript/ast.mjs +61 -0
  48. package/scaffold/scripts/parsers/javascript/calls.mjs +53 -0
  49. package/scaffold/scripts/parsers/javascript/chunks.mjs +388 -0
  50. package/scaffold/scripts/parsers/javascript/imports.mjs +162 -0
  51. package/scaffold/scripts/parsers/javascript/patterns.mjs +82 -0
  52. package/scaffold/scripts/parsers/javascript/scope-analysis.mjs +3 -0
  53. package/scaffold/scripts/parsers/javascript/scope-builder.mjs +305 -0
  54. package/scaffold/scripts/parsers/javascript/scope-resolver.mjs +82 -0
  55. package/scaffold/scripts/parsers/javascript.mjs +27 -350
  56. package/scaffold/scripts/parsers/resources.mjs +166 -0
  57. package/scaffold/scripts/parsers/rust.mjs +515 -0
  58. package/scaffold/scripts/parsers/sql.mjs +137 -0
  59. package/scaffold/scripts/parsers/vbnet.mjs +143 -0
  60. package/scaffold/scripts/status.sh +0 -7
  61. package/scaffold/scripts/watch.sh +9 -1
  62. package/scaffold/scripts/capture-note.sh +0 -55
  63. package/scaffold/scripts/plan-state-engine.cjs +0 -310
  64. package/scaffold/scripts/plan-state.sh +0 -71
@@ -10,12 +10,14 @@ if [[ ! -d "$HOOKS_DIR" ]]; then
10
10
  fi
11
11
 
12
12
  chmod +x \
13
+ "$HOOKS_DIR/post-commit" \
13
14
  "$HOOKS_DIR/post-merge" \
14
15
  "$HOOKS_DIR/post-checkout" \
16
+ "$HOOKS_DIR/post-rewrite" \
15
17
  "$HOOKS_DIR/_cortex-update-runner.sh"
16
18
 
17
19
  git -C "$REPO_ROOT" config core.hooksPath .githooks
18
20
 
19
21
  echo "[hooks] installed core.hooksPath=.githooks"
20
- echo "[hooks] post-merge + post-checkout now trigger background cortex update"
22
+ echo "[hooks] post-checkout + post-merge + post-commit + post-rewrite now trigger background cortex update"
21
23
  echo "[hooks] logs: .context/hooks/update.log"
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseFrontmatter, parseStringList } from "../mcp/dist/frontmatter.js";
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ function normalizeForWsl(rawPath) {
11
+ const m = rawPath.match(/^([A-Za-z]):[/\\](.*)/);
12
+ if (!m) return rawPath;
13
+ try { if (!/microsoft|wsl/i.test(fs.readFileSync("/proc/version", "utf8"))) return rawPath; }
14
+ catch { return rawPath; }
15
+ return `/mnt/${m[1].toLowerCase()}/${m[2].replace(/\\/g, "/").replace(/\/+$/, "")}`;
16
+ }
17
+
18
+ const REPO_ROOT = process.env.CORTEX_PROJECT_ROOT
19
+ ? path.resolve(normalizeForWsl(process.env.CORTEX_PROJECT_ROOT))
20
+ : path.resolve(__dirname, "..");
21
+ const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
22
+ const MEMORY_DIR = path.join(CONTEXT_DIR, "memory");
23
+ const RAW_DIR = path.join(MEMORY_DIR, "raw");
24
+ const COMPILED_DIR = path.join(MEMORY_DIR, "compiled");
25
+
26
+ const ALLOWED_TYPES = new Set([
27
+ "decision",
28
+ "gotcha",
29
+ "fix",
30
+ "benchmark",
31
+ "migration-note",
32
+ "note"
33
+ ]);
34
+
35
+ const REQUIRED_FIELDS = ["title", "type", "summary"];
36
+
37
+ // ── ID generation ──────────────────────────────────────────
38
+
39
+ function normalizeId(filename) {
40
+ const base = path.basename(filename, path.extname(filename)).toLowerCase();
41
+ return `memory:${base.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
42
+ }
43
+
44
+ // ── Validation ─────────────────────────────────────────────
45
+
46
+ function validate(rawPath, fields, body) {
47
+ const errors = [];
48
+
49
+ for (const field of REQUIRED_FIELDS) {
50
+ if (!fields.get(field)) {
51
+ errors.push(`missing required field: ${field}`);
52
+ }
53
+ }
54
+
55
+ const memoryType = fields.get("type") || "";
56
+ if (memoryType && !ALLOWED_TYPES.has(memoryType)) {
57
+ errors.push(`unknown type "${memoryType}" (allowed: ${[...ALLOWED_TYPES].join(", ")})`);
58
+ }
59
+
60
+ if (!body) {
61
+ errors.push("empty body — compiled articles need explanatory text");
62
+ }
63
+
64
+ return errors;
65
+ }
66
+
67
+ // ── Supersession detection ─────────────────────────────────
68
+
69
+ function detectSuperseded(compiledArticles) {
70
+ const byTarget = new Map();
71
+ for (const article of compiledArticles) {
72
+ for (const target of article.appliesTo) {
73
+ const list = byTarget.get(target) ?? [];
74
+ list.push(article);
75
+ byTarget.set(target, list);
76
+ }
77
+ }
78
+
79
+ const warnings = [];
80
+ for (const [target, articles] of byTarget) {
81
+ if (articles.length > 1) {
82
+ const ids = articles.map((a) => a.id).join(", ");
83
+ warnings.push(`multiple memories for ${target}: ${ids} — consider adding supersedes field`);
84
+ }
85
+ }
86
+ return warnings;
87
+ }
88
+
89
+ // ── Compiled article serialization ─────────────────────────
90
+
91
+ function serializeCompiled(article) {
92
+ const lines = ["---"];
93
+ lines.push(`id: ${article.id}`);
94
+ lines.push(`title: ${article.title}`);
95
+ lines.push(`type: ${article.type}`);
96
+ lines.push(`summary: ${article.summary}`);
97
+
98
+ if (article.evidence) lines.push(`evidence: ${article.evidence}`);
99
+ if (article.appliesTo.length > 0) lines.push(`applies_to: ${article.appliesTo.join(", ")}`);
100
+ if (article.decisionOrGotcha) lines.push(`decision_or_gotcha: ${article.decisionOrGotcha}`);
101
+ if (article.sources.length > 0) lines.push(`sources: ${article.sources.join(", ")}`);
102
+ if (article.supersedes) lines.push(`supersedes: ${article.supersedes}`);
103
+ lines.push(`freshness: ${article.freshness}`);
104
+ lines.push(`updated_at: ${article.updatedAt}`);
105
+ lines.push(`status: ${article.status}`);
106
+ lines.push(`trust_level: ${article.trustLevel}`);
107
+ if (article.sourceOfTruth) lines.push(`source_of_truth: true`);
108
+
109
+ lines.push("---");
110
+ lines.push("");
111
+ lines.push(article.body);
112
+ lines.push("");
113
+
114
+ return lines.join("\n");
115
+ }
116
+
117
+ // ── Main compile pipeline ──────────────────────────────────
118
+
119
+ function compileRawNote(rawPath) {
120
+ const markdown = fs.readFileSync(rawPath, "utf8");
121
+ const stats = fs.statSync(rawPath);
122
+ const { fields, body } = parseFrontmatter(markdown);
123
+ const filename = path.basename(rawPath);
124
+
125
+ const errors = validate(rawPath, fields, body);
126
+ if (errors.length > 0) {
127
+ return { ok: false, rawPath, errors };
128
+ }
129
+
130
+ const article = {
131
+ id: fields.get("id") || normalizeId(filename),
132
+ title: fields.get("title"),
133
+ type: fields.get("type"),
134
+ summary: fields.get("summary"),
135
+ evidence: fields.get("evidence") || "",
136
+ appliesTo: parseStringList(fields.get("applies_to")),
137
+ decisionOrGotcha: fields.get("decision_or_gotcha") || fields.get("decision") || "",
138
+ sources: parseStringList(fields.get("sources")),
139
+ supersedes: fields.get("supersedes") || "",
140
+ freshness: fields.get("freshness") || "current",
141
+ updatedAt: fields.get("updated_at") || stats.mtime.toISOString(),
142
+ status: fields.get("status") || "active",
143
+ trustLevel: Number(fields.get("trust_level")) || 70,
144
+ sourceOfTruth: fields.get("source_of_truth")?.toLowerCase() === "true",
145
+ body
146
+ };
147
+
148
+ return { ok: true, rawPath, article };
149
+ }
150
+
151
+ function run() {
152
+ const verbose = process.argv.includes("--verbose");
153
+ const dryRun = process.argv.includes("--dry-run");
154
+
155
+ if (!fs.existsSync(RAW_DIR)) {
156
+ console.log(`[memory-compile] no raw directory at ${RAW_DIR} — nothing to compile`);
157
+ console.log(`[memory-compile] create ${RAW_DIR} and add .md notes to get started`);
158
+ process.exit(0);
159
+ }
160
+
161
+ const rawFiles = fs.readdirSync(RAW_DIR, { withFileTypes: true })
162
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
163
+ .map((entry) => entry.name)
164
+ .sort();
165
+
166
+ if (rawFiles.length === 0) {
167
+ console.log("[memory-compile] no raw .md files found — nothing to compile");
168
+ process.exit(0);
169
+ }
170
+
171
+ console.log(`[memory-compile] found ${rawFiles.length} raw note(s)`);
172
+
173
+ fs.mkdirSync(COMPILED_DIR, { recursive: true });
174
+
175
+ const results = rawFiles.map((filename) =>
176
+ compileRawNote(path.join(RAW_DIR, filename))
177
+ );
178
+
179
+ const successes = results.filter((r) => r.ok);
180
+ const failures = results.filter((r) => !r.ok);
181
+
182
+ // Report validation failures
183
+ for (const failure of failures) {
184
+ console.log(`[memory-compile] SKIP ${path.basename(failure.rawPath)}:`);
185
+ for (const error of failure.errors) {
186
+ console.log(` - ${error}`);
187
+ }
188
+ }
189
+
190
+ // Detect supersession warnings
191
+ const articles = successes.map((r) => r.article);
192
+ const supersessionWarnings = detectSuperseded(articles);
193
+ for (const warning of supersessionWarnings) {
194
+ console.log(`[memory-compile] WARN ${warning}`);
195
+ }
196
+
197
+ // Write compiled articles
198
+ let written = 0;
199
+ let skipped = 0;
200
+
201
+ for (const { article, rawPath } of successes) {
202
+ const outputFilename = path.basename(rawPath);
203
+ const outputPath = path.join(COMPILED_DIR, outputFilename);
204
+ const compiled = serializeCompiled(article);
205
+
206
+ // Skip if compiled output is identical to existing
207
+ if (fs.existsSync(outputPath)) {
208
+ const existing = fs.readFileSync(outputPath, "utf8");
209
+ if (existing === compiled) {
210
+ if (verbose) console.log(`[memory-compile] unchanged: ${outputFilename}`);
211
+ skipped++;
212
+ continue;
213
+ }
214
+ }
215
+
216
+ if (dryRun) {
217
+ console.log(`[memory-compile] would write: ${outputFilename} (${article.id})`);
218
+ if (verbose) console.log(compiled);
219
+ } else {
220
+ fs.writeFileSync(outputPath, compiled, "utf8");
221
+ console.log(`[memory-compile] compiled: ${outputFilename} → ${article.id}`);
222
+ }
223
+ written++;
224
+ }
225
+
226
+ console.log("");
227
+ console.log(`[memory-compile] ${dryRun ? "dry-run " : ""}summary:`);
228
+ console.log(` compiled: ${written}`);
229
+ console.log(` skipped (unchanged): ${skipped}`);
230
+ console.log(` failed: ${failures.length}`);
231
+ if (supersessionWarnings.length > 0) {
232
+ console.log(` supersession warnings: ${supersessionWarnings.length}`);
233
+ }
234
+ console.log(` total raw: ${rawFiles.length}`);
235
+
236
+ if (failures.length > 0) {
237
+ process.exit(1);
238
+ }
239
+ }
240
+
241
+ run();
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # Keep in sync with scripts/memory-compile.sh
3
+ set -euo pipefail
4
+
5
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
6
+ CONTEXT_DIR="$REPO_ROOT/.context"
7
+
8
+ printf "[memory-compile] repo: %s\n" "$REPO_ROOT"
9
+
10
+ if [[ ! -d "$CONTEXT_DIR" ]]; then
11
+ echo "[memory-compile] missing .context/ directory — run cortex init first"
12
+ exit 1
13
+ fi
14
+
15
+ if ! command -v node >/dev/null 2>&1; then
16
+ echo "[memory-compile] Node.js is required but not found on PATH"
17
+ exit 1
18
+ fi
19
+
20
+ node "$REPO_ROOT/scripts/memory-compile.mjs" "$@"
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseFrontmatter, parseStringList } from "../mcp/dist/frontmatter.js";
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ function normalizeForWsl(rawPath) {
11
+ const m = rawPath.match(/^([A-Za-z]):[/\\](.*)/);
12
+ if (!m) return rawPath;
13
+ try { if (!/microsoft|wsl/i.test(fs.readFileSync("/proc/version", "utf8"))) return rawPath; }
14
+ catch { return rawPath; }
15
+ return `/mnt/${m[1].toLowerCase()}/${m[2].replace(/\\/g, "/").replace(/\/+$/, "")}`;
16
+ }
17
+
18
+ const REPO_ROOT = process.env.CORTEX_PROJECT_ROOT
19
+ ? path.resolve(normalizeForWsl(process.env.CORTEX_PROJECT_ROOT))
20
+ : path.resolve(__dirname, "..");
21
+ const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
22
+ const MEMORY_DIR = path.join(CONTEXT_DIR, "memory");
23
+ const COMPILED_DIR = path.join(MEMORY_DIR, "compiled");
24
+ const CACHE_DIR = path.join(CONTEXT_DIR, "cache");
25
+
26
+ const ALLOWED_TYPES = new Set([
27
+ "decision",
28
+ "gotcha",
29
+ "fix",
30
+ "benchmark",
31
+ "migration-note",
32
+ "note"
33
+ ]);
34
+
35
+ const REQUIRED_FIELDS = ["title", "type", "summary"];
36
+
37
+ const STALE_DAYS = 90;
38
+
39
+ // ── Index loading ─────────────────────────────────────────
40
+
41
+ function readJsonl(filePath) {
42
+ if (!fs.existsSync(filePath)) return [];
43
+ return fs
44
+ .readFileSync(filePath, "utf8")
45
+ .split(/\r?\n/)
46
+ .map((line) => line.trim())
47
+ .filter(Boolean)
48
+ .map((line) => {
49
+ try { return JSON.parse(line); }
50
+ catch { return null; }
51
+ })
52
+ .filter(Boolean);
53
+ }
54
+
55
+ function loadKnownEntityIds() {
56
+ const ids = new Set();
57
+
58
+ for (const row of readJsonl(path.join(CACHE_DIR, "documents.jsonl"))) {
59
+ if (row.id) ids.add(row.id);
60
+ }
61
+ for (const row of readJsonl(path.join(CACHE_DIR, "entities.adr.jsonl"))) {
62
+ if (row.id) ids.add(row.id);
63
+ }
64
+ for (const row of readJsonl(path.join(CACHE_DIR, "entities.rule.jsonl"))) {
65
+ if (row.id) ids.add(row.id);
66
+ }
67
+ for (const row of readJsonl(path.join(CACHE_DIR, "entities.chunk.jsonl"))) {
68
+ if (row.id) ids.add(row.id);
69
+ }
70
+
71
+ return ids;
72
+ }
73
+
74
+ function loadKnownFilePaths() {
75
+ const paths = new Set();
76
+ for (const row of readJsonl(path.join(CACHE_DIR, "documents.jsonl"))) {
77
+ if (row.path) paths.add(row.path);
78
+ }
79
+ return paths;
80
+ }
81
+
82
+ // ── Compiled article loading ──────────────────────────────
83
+
84
+ function loadCompiledArticles() {
85
+ if (!fs.existsSync(COMPILED_DIR)) return [];
86
+
87
+ return fs
88
+ .readdirSync(COMPILED_DIR, { withFileTypes: true })
89
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
90
+ .map((entry) => entry.name)
91
+ .sort()
92
+ .map((filename) => {
93
+ const absolutePath = path.join(COMPILED_DIR, filename);
94
+ const markdown = fs.readFileSync(absolutePath, "utf8");
95
+ const stats = fs.statSync(absolutePath);
96
+ const { fields, body } = parseFrontmatter(markdown);
97
+
98
+ return {
99
+ filename,
100
+ id: fields.get("id") || "",
101
+ title: fields.get("title") || "",
102
+ type: fields.get("type") || "",
103
+ summary: fields.get("summary") || "",
104
+ evidence: fields.get("evidence") || "",
105
+ appliesTo: parseStringList(fields.get("applies_to")),
106
+ decisionOrGotcha: fields.get("decision_or_gotcha") || fields.get("decision") || "",
107
+ sources: parseStringList(fields.get("sources")),
108
+ supersedes: fields.get("supersedes") || "",
109
+ freshness: fields.get("freshness") || "",
110
+ updatedAt: fields.get("updated_at") || stats.mtime.toISOString(),
111
+ status: fields.get("status") || "active",
112
+ trustLevel: Number(fields.get("trust_level")) || 70,
113
+ sourceOfTruth: fields.get("source_of_truth")?.toLowerCase() === "true",
114
+ body
115
+ };
116
+ });
117
+ }
118
+
119
+ // ── Lint checks ───────────────────────────────────────────
120
+
121
+ function checkMissingProvenance(article) {
122
+ const issues = [];
123
+
124
+ for (const field of REQUIRED_FIELDS) {
125
+ if (!article[field]) {
126
+ issues.push({ severity: "error", file: article.filename, message: `missing required field: ${field}` });
127
+ }
128
+ }
129
+
130
+ if (!article.id) {
131
+ issues.push({ severity: "error", file: article.filename, message: "missing id field" });
132
+ }
133
+
134
+ const memoryType = article.type;
135
+ if (memoryType && !ALLOWED_TYPES.has(memoryType)) {
136
+ issues.push({
137
+ severity: "error",
138
+ file: article.filename,
139
+ message: `unknown type "${memoryType}" (allowed: ${[...ALLOWED_TYPES].join(", ")})`
140
+ });
141
+ }
142
+
143
+ if (!article.body) {
144
+ issues.push({ severity: "error", file: article.filename, message: "empty body" });
145
+ }
146
+
147
+ if (article.appliesTo.length === 0 && article.sources.length === 0) {
148
+ issues.push({
149
+ severity: "warn",
150
+ file: article.filename,
151
+ message: "no applies_to or sources — memory has no link to codebase"
152
+ });
153
+ }
154
+
155
+ return issues;
156
+ }
157
+
158
+ function checkOrphaned(article, knownEntityIds, knownFilePaths) {
159
+ const issues = [];
160
+
161
+ for (const target of article.appliesTo) {
162
+ if (!knownEntityIds.has(target)) {
163
+ issues.push({
164
+ severity: "warn",
165
+ file: article.filename,
166
+ message: `applies_to target not found in index: ${target}`
167
+ });
168
+ }
169
+ }
170
+
171
+ for (const source of article.sources) {
172
+ const asFileId = `file:${source}`;
173
+ if (!knownEntityIds.has(asFileId) && !knownFilePaths.has(source)) {
174
+ issues.push({
175
+ severity: "warn",
176
+ file: article.filename,
177
+ message: `source file not found in index: ${source}`
178
+ });
179
+ }
180
+ }
181
+
182
+ return issues;
183
+ }
184
+
185
+ function checkStale(article) {
186
+ const issues = [];
187
+
188
+ if (article.freshness.toLowerCase() === "stale") {
189
+ issues.push({
190
+ severity: "warn",
191
+ file: article.filename,
192
+ message: "freshness is marked stale"
193
+ });
194
+ return issues;
195
+ }
196
+
197
+ if (!article.updatedAt) return issues;
198
+
199
+ const updatedDate = new Date(article.updatedAt);
200
+ if (isNaN(updatedDate.getTime())) return issues;
201
+
202
+ const ageMs = Date.now() - updatedDate.getTime();
203
+ const ageDays = ageMs / (1000 * 60 * 60 * 24);
204
+
205
+ if (ageDays > STALE_DAYS) {
206
+ issues.push({
207
+ severity: "warn",
208
+ file: article.filename,
209
+ message: `updated_at is ${Math.floor(ageDays)} days old (threshold: ${STALE_DAYS} days)`
210
+ });
211
+ }
212
+
213
+ return issues;
214
+ }
215
+
216
+ function checkDuplicates(articles) {
217
+ const issues = [];
218
+ const byId = new Map();
219
+
220
+ for (const article of articles) {
221
+ if (!article.id) continue;
222
+ const list = byId.get(article.id) ?? [];
223
+ list.push(article);
224
+ byId.set(article.id, list);
225
+ }
226
+
227
+ for (const [id, group] of byId) {
228
+ if (group.length > 1) {
229
+ const files = group.map((a) => a.filename).join(", ");
230
+ issues.push({
231
+ severity: "error",
232
+ file: group[0].filename,
233
+ message: `duplicate memory id "${id}" in files: ${files}`
234
+ });
235
+ }
236
+ }
237
+
238
+ return issues;
239
+ }
240
+
241
+ function checkContradictions(articles) {
242
+ const issues = [];
243
+ const activeArticles = articles.filter((a) => a.status === "active");
244
+
245
+ // Group active articles by applies_to target
246
+ const byTarget = new Map();
247
+ for (const article of activeArticles) {
248
+ for (const target of article.appliesTo) {
249
+ const list = byTarget.get(target) ?? [];
250
+ list.push(article);
251
+ byTarget.set(target, list);
252
+ }
253
+ }
254
+
255
+ // Conflicting types on the same target signal potential contradiction
256
+ const conflictingPairs = new Set(["decision|decision", "gotcha|fix", "fix|fix", "decision|gotcha"]);
257
+
258
+ for (const [target, group] of byTarget) {
259
+ if (group.length < 2) continue;
260
+
261
+ for (let i = 0; i < group.length; i++) {
262
+ for (let j = i + 1; j < group.length; j++) {
263
+ const a = group[i];
264
+ const b = group[j];
265
+
266
+ // Skip if one supersedes the other
267
+ if (a.supersedes === b.id || b.supersedes === a.id) continue;
268
+
269
+ const pair = [a.type, b.type].sort().join("|");
270
+ if (conflictingPairs.has(pair)) {
271
+ issues.push({
272
+ severity: "warn",
273
+ file: a.filename,
274
+ message: `potential contradiction on ${target}: "${a.id}" (${a.type}) vs "${b.id}" (${b.type})`
275
+ });
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ // Check for broken supersedes references
282
+ const allIds = new Set(articles.map((a) => a.id));
283
+ for (const article of articles) {
284
+ if (!article.supersedes) continue;
285
+ if (!allIds.has(article.supersedes)) {
286
+ issues.push({
287
+ severity: "warn",
288
+ file: article.filename,
289
+ message: `supersedes target "${article.supersedes}" not found among compiled articles`
290
+ });
291
+ }
292
+ }
293
+
294
+ return issues;
295
+ }
296
+
297
+ // ── Main ──────────────────────────────────────────────────
298
+
299
+ function run() {
300
+ const verbose = process.argv.includes("--verbose");
301
+ const json = process.argv.includes("--json");
302
+
303
+ if (!fs.existsSync(COMPILED_DIR)) {
304
+ if (json) {
305
+ console.log(JSON.stringify({ issues: [], summary: { errors: 0, warnings: 0, articles: 0 } }));
306
+ } else {
307
+ console.log("[memory-lint] no compiled directory — nothing to lint");
308
+ }
309
+ process.exit(0);
310
+ }
311
+
312
+ const articles = loadCompiledArticles();
313
+
314
+ if (articles.length === 0) {
315
+ if (json) {
316
+ console.log(JSON.stringify({ issues: [], summary: { errors: 0, warnings: 0, articles: 0 } }));
317
+ } else {
318
+ console.log("[memory-lint] no compiled articles found");
319
+ }
320
+ process.exit(0);
321
+ }
322
+
323
+ const knownEntityIds = loadKnownEntityIds();
324
+ const knownFilePaths = loadKnownFilePaths();
325
+
326
+ const allIssues = [];
327
+
328
+ // Per-article checks
329
+ for (const article of articles) {
330
+ allIssues.push(...checkMissingProvenance(article));
331
+ allIssues.push(...checkOrphaned(article, knownEntityIds, knownFilePaths));
332
+ allIssues.push(...checkStale(article));
333
+ }
334
+
335
+ // Cross-article checks
336
+ allIssues.push(...checkDuplicates(articles));
337
+ allIssues.push(...checkContradictions(articles));
338
+
339
+ const errors = allIssues.filter((i) => i.severity === "error");
340
+ const warnings = allIssues.filter((i) => i.severity === "warn");
341
+
342
+ if (json) {
343
+ console.log(JSON.stringify({
344
+ issues: allIssues,
345
+ summary: { errors: errors.length, warnings: warnings.length, articles: articles.length }
346
+ }, null, 2));
347
+ process.exit(errors.length > 0 ? 1 : 0);
348
+ }
349
+
350
+ console.log(`[memory-lint] linting ${articles.length} compiled article(s)`);
351
+ console.log("");
352
+
353
+ if (allIssues.length === 0) {
354
+ console.log("[memory-lint] no issues found");
355
+ process.exit(0);
356
+ }
357
+
358
+ // Group issues by file for readable output
359
+ const byFile = new Map();
360
+ for (const issue of allIssues) {
361
+ const list = byFile.get(issue.file) ?? [];
362
+ list.push(issue);
363
+ byFile.set(issue.file, list);
364
+ }
365
+
366
+ for (const [file, issues] of byFile) {
367
+ console.log(` ${file}:`);
368
+ for (const issue of issues) {
369
+ const prefix = issue.severity === "error" ? "ERROR" : "WARN";
370
+ console.log(` ${prefix}: ${issue.message}`);
371
+ }
372
+ if (verbose) console.log("");
373
+ }
374
+
375
+ console.log("");
376
+ console.log(`[memory-lint] summary:`);
377
+ console.log(` articles: ${articles.length}`);
378
+ console.log(` errors: ${errors.length}`);
379
+ console.log(` warnings: ${warnings.length}`);
380
+
381
+ process.exit(errors.length > 0 ? 1 : 0);
382
+ }
383
+
384
+ run();
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ # Keep in sync with scripts/memory-lint.sh
3
+ set -euo pipefail
4
+
5
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
6
+ CONTEXT_DIR="$REPO_ROOT/.context"
7
+
8
+ printf "[memory-lint] repo: %s\n" "$REPO_ROOT"
9
+
10
+ if [[ ! -d "$CONTEXT_DIR" ]]; then
11
+ echo "[memory-lint] missing .context/ directory — run cortex init first"
12
+ exit 1
13
+ fi
14
+
15
+ if ! command -v node >/dev/null 2>&1; then
16
+ echo "[memory-lint] Node.js is required but not found on PATH"
17
+ exit 1
18
+ fi
19
+
20
+ node "$REPO_ROOT/scripts/memory-lint.mjs" "$@"