@hir4ta/mneme 0.24.0 → 0.24.1
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/.claude-plugin/plugin.json +1 -1
- package/README.ja.md +3 -4
- package/README.md +3 -4
- package/dist/lib/save/index.js +94 -13
- package/dist/lib/search/prompt.js +225 -8
- package/dist/lib/session/finalize.js +104 -18
- package/dist/lib/session/init.js +12 -9
- package/dist/public/assets/index-BL-68Hbg.css +1 -0
- package/dist/public/assets/index-rwYM2mwM.js +382 -0
- package/dist/public/assets/{react-force-graph-2d-BRZ1ditM.js → react-force-graph-2d-n2R24ZBV.js} +1 -1
- package/dist/public/index.html +2 -2
- package/dist/server.js +6 -3
- package/dist/servers/db-server.js +41 -41
- package/dist/servers/search-server.js +11 -11
- package/hooks/user-prompt-submit.sh +36 -5
- package/package.json +1 -1
- package/servers/db/save.ts +2 -2
- package/servers/db/session-summary.ts +37 -9
- package/servers/db/tools.ts +6 -5
- package/servers/db/utils.ts +6 -0
- package/servers/db/validate-sources.ts +361 -0
- package/servers/db-server.ts +2 -0
- package/skills/harvest/SKILL.md +3 -3
- package/skills/init-mneme/SKILL.md +11 -85
- package/skills/save/SKILL.md +19 -7
- package/skills/search/SKILL.md +19 -0
- package/skills/using-mneme/SKILL.md +11 -0
- package/dist/public/assets/index-B1iHg48P.css +0 -1
- package/dist/public/assets/index-BzMjX28q.js +0 -377
|
@@ -53,8 +53,18 @@ if [ -z "$search_script" ]; then
|
|
|
53
53
|
exit 0
|
|
54
54
|
fi
|
|
55
55
|
|
|
56
|
+
# Detect changed files for file-based session recommendation
|
|
57
|
+
changed_files=""
|
|
58
|
+
if command -v git >/dev/null 2>&1 && git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
59
|
+
changed_files=$(cd "$cwd" && {
|
|
60
|
+
git diff --name-only HEAD 2>/dev/null
|
|
61
|
+
git diff --name-only --cached 2>/dev/null
|
|
62
|
+
} | sort -u | head -20 | paste -sd "," - 2>/dev/null || echo "")
|
|
63
|
+
fi
|
|
64
|
+
|
|
56
65
|
search_output=$(invoke_node "$search_script" \
|
|
57
|
-
--query "$prompt" --project "$cwd" --limit 5
|
|
66
|
+
--query "$prompt" --project "$cwd" --limit 5 \
|
|
67
|
+
${changed_files:+--files "$changed_files"} 2>/dev/null || echo "")
|
|
58
68
|
|
|
59
69
|
if [ -z "$search_output" ]; then
|
|
60
70
|
exit 0
|
|
@@ -75,11 +85,32 @@ context_lines=$(echo "$search_output" | jq -r '
|
|
|
75
85
|
| join("\n")
|
|
76
86
|
')
|
|
77
87
|
|
|
78
|
-
|
|
88
|
+
# Format file-based session recommendations
|
|
89
|
+
file_rec_lines=$(echo "$search_output" | jq -r '
|
|
90
|
+
.fileRecommendations // []
|
|
91
|
+
| .[:3]
|
|
92
|
+
| map("[session:\(.sessionId)] \(.title) | files: \(.matchedFiles | join(", "))")
|
|
93
|
+
| join("\n")
|
|
94
|
+
')
|
|
95
|
+
|
|
96
|
+
if [ -n "$context_lines" ] && [ "$context_lines" != "null" ] || \
|
|
97
|
+
[ -n "$file_rec_lines" ] && [ "$file_rec_lines" != "null" ]; then
|
|
98
|
+
context_parts=""
|
|
99
|
+
if [ -n "$context_lines" ] && [ "$context_lines" != "null" ]; then
|
|
100
|
+
context_parts="Related context found (sessions/units):
|
|
101
|
+
${context_lines}"
|
|
102
|
+
fi
|
|
103
|
+
if [ -n "$file_rec_lines" ] && [ "$file_rec_lines" != "null" ]; then
|
|
104
|
+
if [ -n "$context_parts" ]; then
|
|
105
|
+
context_parts="${context_parts}
|
|
106
|
+
"
|
|
107
|
+
fi
|
|
108
|
+
context_parts="${context_parts}Related sessions (editing same files):
|
|
109
|
+
${file_rec_lines}"
|
|
110
|
+
fi
|
|
79
111
|
context_message="<mneme-context>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
Use /mneme:search for details.
|
|
112
|
+
${context_parts}
|
|
113
|
+
To explore deeper: use /mneme:search with specific technical terms, error messages, or file paths.
|
|
83
114
|
</mneme-context>"
|
|
84
115
|
fi
|
|
85
116
|
|
package/package.json
CHANGED
package/servers/db/save.ts
CHANGED
|
@@ -39,7 +39,7 @@ export async function saveInteractions(
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const projectPath = getProjectPath();
|
|
42
|
-
const sessionId = mnemeSessionId || claudeSessionId
|
|
42
|
+
const sessionId = mnemeSessionId || claudeSessionId;
|
|
43
43
|
|
|
44
44
|
let owner = "unknown";
|
|
45
45
|
try {
|
|
@@ -251,7 +251,7 @@ export function markSessionCommitted(claudeSessionId: string): boolean {
|
|
|
251
251
|
stmt.run(claudeSessionId);
|
|
252
252
|
} else {
|
|
253
253
|
const projectPath = getProjectPath();
|
|
254
|
-
const sessionId = claudeSessionId
|
|
254
|
+
const sessionId = claudeSessionId;
|
|
255
255
|
const insertStmt = database.prepare(`
|
|
256
256
|
INSERT INTO session_save_state (claude_session_id, mneme_session_id, project_path, is_committed)
|
|
257
257
|
VALUES (?, ?, ?, 1)
|
|
@@ -39,11 +39,13 @@ interface SessionSummaryParams {
|
|
|
39
39
|
title?: string;
|
|
40
40
|
description?: string;
|
|
41
41
|
}>;
|
|
42
|
+
filesModified?: Array<{ path: string; action: string }>;
|
|
43
|
+
technologies?: string[];
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
async function updateSessionSummary(
|
|
45
47
|
params: SessionSummaryParams,
|
|
46
|
-
): Promise<{ success: boolean; sessionFile: string;
|
|
48
|
+
): Promise<{ success: boolean; sessionFile: string; sessionId: string }> {
|
|
47
49
|
const {
|
|
48
50
|
claudeSessionId,
|
|
49
51
|
title,
|
|
@@ -55,27 +57,35 @@ async function updateSessionSummary(
|
|
|
55
57
|
errors,
|
|
56
58
|
handoff,
|
|
57
59
|
references,
|
|
60
|
+
filesModified,
|
|
61
|
+
technologies,
|
|
58
62
|
} = params;
|
|
59
63
|
|
|
60
64
|
const projectPath = getProjectPath();
|
|
61
65
|
const sessionsDir = path.join(projectPath, ".mneme", "sessions");
|
|
62
|
-
const shortId = claudeSessionId.slice(0, 8);
|
|
63
66
|
|
|
64
67
|
let sessionFile: string | null = null;
|
|
65
|
-
const
|
|
68
|
+
const searchDirFor = (dir: string, fileName: string): string | null => {
|
|
66
69
|
if (!fs.existsSync(dir)) return null;
|
|
67
70
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
68
71
|
const fullPath = path.join(dir, entry.name);
|
|
69
72
|
if (entry.isDirectory()) {
|
|
70
|
-
const result =
|
|
73
|
+
const result = searchDirFor(fullPath, fileName);
|
|
71
74
|
if (result) return result;
|
|
72
|
-
} else if (entry.name ===
|
|
75
|
+
} else if (entry.name === fileName) {
|
|
73
76
|
return fullPath;
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
return null;
|
|
77
80
|
};
|
|
78
|
-
|
|
81
|
+
// Try full UUID first, then fallback to 8-char for old sessions
|
|
82
|
+
sessionFile = searchDirFor(sessionsDir, `${claudeSessionId}.json`);
|
|
83
|
+
if (!sessionFile && claudeSessionId.length > 8) {
|
|
84
|
+
sessionFile = searchDirFor(
|
|
85
|
+
sessionsDir,
|
|
86
|
+
`${claudeSessionId.slice(0, 8)}.json`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
79
89
|
|
|
80
90
|
if (sessionFile) {
|
|
81
91
|
const existingData = readJsonFile<{ sessionId?: string }>(sessionFile);
|
|
@@ -92,9 +102,9 @@ async function updateSessionSummary(
|
|
|
92
102
|
String(now.getMonth() + 1).padStart(2, "0"),
|
|
93
103
|
);
|
|
94
104
|
if (!fs.existsSync(yearMonth)) fs.mkdirSync(yearMonth, { recursive: true });
|
|
95
|
-
sessionFile = path.join(yearMonth, `${
|
|
105
|
+
sessionFile = path.join(yearMonth, `${claudeSessionId}.json`);
|
|
96
106
|
const initial = {
|
|
97
|
-
id:
|
|
107
|
+
id: claudeSessionId,
|
|
98
108
|
sessionId: claudeSessionId,
|
|
99
109
|
createdAt: now.toISOString(),
|
|
100
110
|
title: "",
|
|
@@ -126,6 +136,9 @@ async function updateSessionSummary(
|
|
|
126
136
|
if (errors && errors.length > 0) data.errors = errors;
|
|
127
137
|
if (handoff) data.handoff = handoff;
|
|
128
138
|
if (references && references.length > 0) data.references = references;
|
|
139
|
+
if (filesModified && filesModified.length > 0)
|
|
140
|
+
data.filesModified = filesModified;
|
|
141
|
+
if (technologies && technologies.length > 0) data.technologies = technologies;
|
|
129
142
|
|
|
130
143
|
const transcriptPath = getTranscriptPath(claudeSessionId);
|
|
131
144
|
if (transcriptPath) {
|
|
@@ -173,7 +186,7 @@ async function updateSessionSummary(
|
|
|
173
186
|
return {
|
|
174
187
|
success: true,
|
|
175
188
|
sessionFile: sessionFile.replace(projectPath, "."),
|
|
176
|
-
|
|
189
|
+
sessionId: claudeSessionId,
|
|
177
190
|
};
|
|
178
191
|
}
|
|
179
192
|
|
|
@@ -283,6 +296,21 @@ export function registerSessionSummaryTool(server: McpServer) {
|
|
|
283
296
|
)
|
|
284
297
|
.optional()
|
|
285
298
|
.describe("Documents and resources referenced during session"),
|
|
299
|
+
filesModified: z
|
|
300
|
+
.array(
|
|
301
|
+
z.object({
|
|
302
|
+
path: z.string().describe("File path relative to project root"),
|
|
303
|
+
action: z
|
|
304
|
+
.string()
|
|
305
|
+
.describe("Action: create, edit, delete, rename"),
|
|
306
|
+
}),
|
|
307
|
+
)
|
|
308
|
+
.optional()
|
|
309
|
+
.describe("Files modified during this session"),
|
|
310
|
+
technologies: z
|
|
311
|
+
.array(z.string())
|
|
312
|
+
.optional()
|
|
313
|
+
.describe("Technologies and frameworks used"),
|
|
286
314
|
},
|
|
287
315
|
},
|
|
288
316
|
async (params) => {
|
package/servers/db/tools.ts
CHANGED
|
@@ -57,11 +57,12 @@ export function registerExtendedTools(server: McpServer) {
|
|
|
57
57
|
},
|
|
58
58
|
async ({ sessionId, includeChain }) => {
|
|
59
59
|
const sessions = readSessionsById();
|
|
60
|
-
|
|
61
|
-
const root = sessions.get(
|
|
62
|
-
if (!root) return fail(`Session not found: ${
|
|
60
|
+
// Dual-key lookup: try as-is first (supports both full UUID and 8-char)
|
|
61
|
+
const root = sessions.get(sessionId);
|
|
62
|
+
if (!root) return fail(`Session not found: ${sessionId}`);
|
|
63
63
|
|
|
64
|
-
const
|
|
64
|
+
const rootId = typeof root.id === "string" ? root.id : sessionId;
|
|
65
|
+
const chain: string[] = [rootId];
|
|
65
66
|
if (includeChain !== false) {
|
|
66
67
|
let current = root;
|
|
67
68
|
let guard = 0;
|
|
@@ -104,7 +105,7 @@ export function registerExtendedTools(server: McpServer) {
|
|
|
104
105
|
return ok(
|
|
105
106
|
JSON.stringify(
|
|
106
107
|
{
|
|
107
|
-
rootSessionId:
|
|
108
|
+
rootSessionId: rootId,
|
|
108
109
|
dbAvailable,
|
|
109
110
|
chainLength: timeline.length,
|
|
110
111
|
timeline,
|
package/servers/db/utils.ts
CHANGED
|
@@ -57,6 +57,12 @@ export function readSessionsById(): Map<string, Record<string, unknown>> {
|
|
|
57
57
|
const id = typeof parsed?.id === "string" ? parsed.id : "";
|
|
58
58
|
if (!id) continue;
|
|
59
59
|
map.set(id, parsed);
|
|
60
|
+
// Also index by full sessionId for dual-key lookup (old sessions have 8-char id)
|
|
61
|
+
const sessionId =
|
|
62
|
+
typeof parsed?.sessionId === "string" ? parsed.sessionId : "";
|
|
63
|
+
if (sessionId && sessionId !== id) {
|
|
64
|
+
map.set(sessionId, parsed);
|
|
65
|
+
}
|
|
60
66
|
}
|
|
61
67
|
return map;
|
|
62
68
|
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source artifact validation for mneme MCP Database Server.
|
|
3
|
+
* Validates decisions, patterns, and rules in .mneme/ directory.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { ok } from "./types.js";
|
|
10
|
+
import { getMnemeDir, listJsonFiles } from "./utils.js";
|
|
11
|
+
|
|
12
|
+
interface ValidationIssue {
|
|
13
|
+
file: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ValidationResult {
|
|
18
|
+
valid: boolean;
|
|
19
|
+
issueCount: number;
|
|
20
|
+
issues: ValidationIssue[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RULE_PRIORITIES = new Set(["p0", "p1", "p2"]);
|
|
24
|
+
const PATTERN_TYPES = new Set(["good", "bad", "error-solution"]);
|
|
25
|
+
|
|
26
|
+
function readJson(filePath: string): Record<string, unknown> {
|
|
27
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasText(value: unknown): value is string {
|
|
31
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasNonEmptyTags(value: unknown): boolean {
|
|
35
|
+
return (
|
|
36
|
+
Array.isArray(value) &&
|
|
37
|
+
value.length > 0 &&
|
|
38
|
+
value.every((item) => hasText(item))
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateDecisions(mnemeDir: string, issues: ValidationIssue[]): void {
|
|
43
|
+
const files = listJsonFiles(path.join(mnemeDir, "decisions"));
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
let parsed: Record<string, unknown>;
|
|
46
|
+
try {
|
|
47
|
+
parsed = readJson(file);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
issues.push({ file, message: `Invalid JSON (${String(error)})` });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!hasText(parsed.id)) issues.push({ file, message: "Missing id" });
|
|
54
|
+
if (!hasText(parsed.title)) issues.push({ file, message: "Missing title" });
|
|
55
|
+
if (!hasText(parsed.decision))
|
|
56
|
+
issues.push({ file, message: "Missing decision" });
|
|
57
|
+
if (!hasText(parsed.reasoning))
|
|
58
|
+
issues.push({ file, message: "Missing reasoning" });
|
|
59
|
+
if (!hasNonEmptyTags(parsed.tags))
|
|
60
|
+
issues.push({ file, message: "Missing tags (at least one required)" });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validatePatterns(mnemeDir: string, issues: ValidationIssue[]): void {
|
|
65
|
+
const files = listJsonFiles(path.join(mnemeDir, "patterns"));
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
let parsed: Record<string, unknown>;
|
|
68
|
+
try {
|
|
69
|
+
parsed = readJson(file);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
issues.push({ file, message: `Invalid JSON (${String(error)})` });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const items = Array.isArray(parsed.items)
|
|
76
|
+
? parsed.items
|
|
77
|
+
: Array.isArray(parsed.patterns)
|
|
78
|
+
? parsed.patterns
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
if (!items) {
|
|
82
|
+
issues.push({ file, message: "Missing items/patterns array" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [index, item] of items.entries()) {
|
|
87
|
+
const pointer = `${file}#${index}`;
|
|
88
|
+
if (!hasText(item.id))
|
|
89
|
+
issues.push({ file: pointer, message: "Missing id" });
|
|
90
|
+
if (!hasText(item.type) || !PATTERN_TYPES.has(item.type)) {
|
|
91
|
+
issues.push({
|
|
92
|
+
file: pointer,
|
|
93
|
+
message: "Invalid type (good|bad|error-solution required)",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (item.type === "error-solution") {
|
|
97
|
+
if (!hasText(item.errorPattern))
|
|
98
|
+
issues.push({
|
|
99
|
+
file: pointer,
|
|
100
|
+
message: "error-solution pattern missing errorPattern",
|
|
101
|
+
});
|
|
102
|
+
if (!hasText(item.solution))
|
|
103
|
+
issues.push({
|
|
104
|
+
file: pointer,
|
|
105
|
+
message: "error-solution pattern missing solution",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (!hasText(item.title) && !hasText(item.description))
|
|
109
|
+
issues.push({ file: pointer, message: "Missing title/description" });
|
|
110
|
+
if (!hasNonEmptyTags(item.tags))
|
|
111
|
+
issues.push({
|
|
112
|
+
file: pointer,
|
|
113
|
+
message: "Missing tags (at least one required)",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateRuleFile(
|
|
120
|
+
file: string,
|
|
121
|
+
expectedType: string,
|
|
122
|
+
issues: ValidationIssue[],
|
|
123
|
+
): void {
|
|
124
|
+
if (!fs.existsSync(file)) return;
|
|
125
|
+
|
|
126
|
+
let parsed: Record<string, unknown>;
|
|
127
|
+
try {
|
|
128
|
+
parsed = readJson(file);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
issues.push({ file, message: `Invalid JSON (${String(error)})` });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const items = Array.isArray(parsed.items)
|
|
135
|
+
? parsed.items
|
|
136
|
+
: Array.isArray(parsed.rules)
|
|
137
|
+
? parsed.rules
|
|
138
|
+
: null;
|
|
139
|
+
|
|
140
|
+
if (!items) {
|
|
141
|
+
issues.push({ file, message: "Missing items/rules array" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (hasText(parsed.ruleType) && parsed.ruleType !== expectedType) {
|
|
146
|
+
issues.push({
|
|
147
|
+
file,
|
|
148
|
+
message: `ruleType mismatch (${parsed.ruleType} != ${expectedType})`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const [index, item] of items.entries()) {
|
|
153
|
+
const pointer = `${file}#${index}`;
|
|
154
|
+
if (!hasText(item.id))
|
|
155
|
+
issues.push({ file: pointer, message: "Missing id" });
|
|
156
|
+
if (!hasText(item.key))
|
|
157
|
+
issues.push({ file: pointer, message: "Missing key" });
|
|
158
|
+
const text = item.text ?? item.rule ?? item.title;
|
|
159
|
+
if (!hasText(text))
|
|
160
|
+
issues.push({ file: pointer, message: "Missing text/rule/title" });
|
|
161
|
+
if (!hasText(item.category))
|
|
162
|
+
issues.push({ file: pointer, message: "Missing category" });
|
|
163
|
+
if (!hasNonEmptyTags(item.tags))
|
|
164
|
+
issues.push({
|
|
165
|
+
file: pointer,
|
|
166
|
+
message: "Missing tags (at least one required)",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const status = hasText(item.status) ? item.status : "active";
|
|
170
|
+
if (status === "active") {
|
|
171
|
+
if (!hasText(item.priority) || !RULE_PRIORITIES.has(item.priority))
|
|
172
|
+
issues.push({
|
|
173
|
+
file: pointer,
|
|
174
|
+
message: "Missing/invalid priority for active rule (p0|p1|p2)",
|
|
175
|
+
});
|
|
176
|
+
if (!hasText(item.rationale))
|
|
177
|
+
issues.push({
|
|
178
|
+
file: pointer,
|
|
179
|
+
message: "Missing rationale for active rule",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateRules(mnemeDir: string, issues: ValidationIssue[]): void {
|
|
186
|
+
validateRuleFile(
|
|
187
|
+
path.join(mnemeDir, "rules", "dev-rules.json"),
|
|
188
|
+
"dev-rules",
|
|
189
|
+
issues,
|
|
190
|
+
);
|
|
191
|
+
validateRuleFile(
|
|
192
|
+
path.join(mnemeDir, "rules", "review-guidelines.json"),
|
|
193
|
+
"review-guidelines",
|
|
194
|
+
issues,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validateIdUniqueness(
|
|
199
|
+
mnemeDir: string,
|
|
200
|
+
issues: ValidationIssue[],
|
|
201
|
+
): void {
|
|
202
|
+
const idMap = new Map<string, string[]>();
|
|
203
|
+
|
|
204
|
+
const track = (id: string, location: string) => {
|
|
205
|
+
const existing = idMap.get(id) || [];
|
|
206
|
+
existing.push(location);
|
|
207
|
+
idMap.set(id, existing);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
for (const file of listJsonFiles(path.join(mnemeDir, "decisions"))) {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = readJson(file);
|
|
213
|
+
if (hasText(parsed.id)) track(parsed.id, file);
|
|
214
|
+
} catch {
|
|
215
|
+
/* skip */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const file of listJsonFiles(path.join(mnemeDir, "patterns"))) {
|
|
220
|
+
try {
|
|
221
|
+
const parsed = readJson(file);
|
|
222
|
+
const items =
|
|
223
|
+
(parsed.items as unknown[]) || (parsed.patterns as unknown[]) || [];
|
|
224
|
+
for (const item of items as Array<Record<string, unknown>>) {
|
|
225
|
+
if (hasText(item.id)) track(item.id, `${file}#${item.id}`);
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
/* skip */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const ruleFile of ["dev-rules", "review-guidelines"]) {
|
|
233
|
+
const file = path.join(mnemeDir, "rules", `${ruleFile}.json`);
|
|
234
|
+
if (!fs.existsSync(file)) continue;
|
|
235
|
+
try {
|
|
236
|
+
const parsed = readJson(file);
|
|
237
|
+
const items =
|
|
238
|
+
(parsed.items as unknown[]) || (parsed.rules as unknown[]) || [];
|
|
239
|
+
for (const item of items as Array<Record<string, unknown>>) {
|
|
240
|
+
if (hasText(item.id)) track(item.id, `${file}#${item.id}`);
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
/* skip */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const [id, files] of idMap) {
|
|
248
|
+
if (files.length > 1) {
|
|
249
|
+
issues.push({
|
|
250
|
+
file: files.join(", "),
|
|
251
|
+
message: `Duplicate ID "${id}" found in ${files.length} locations`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function validateTagExistence(
|
|
258
|
+
mnemeDir: string,
|
|
259
|
+
issues: ValidationIssue[],
|
|
260
|
+
): void {
|
|
261
|
+
const tagsPath = path.join(mnemeDir, "tags.json");
|
|
262
|
+
if (!fs.existsSync(tagsPath)) return;
|
|
263
|
+
|
|
264
|
+
let masterTags: Record<string, unknown>;
|
|
265
|
+
try {
|
|
266
|
+
masterTags = readJson(tagsPath);
|
|
267
|
+
} catch {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const tags = masterTags.tags as Array<Record<string, unknown>> | undefined;
|
|
272
|
+
const validTagIds = new Set(
|
|
273
|
+
(tags || []).map((t) => t.id).filter((id) => typeof id === "string"),
|
|
274
|
+
);
|
|
275
|
+
if (validTagIds.size === 0) return;
|
|
276
|
+
|
|
277
|
+
const checkTags = (fileTags: unknown[], location: string) => {
|
|
278
|
+
for (const tag of fileTags) {
|
|
279
|
+
if (typeof tag === "string" && !validTagIds.has(tag)) {
|
|
280
|
+
issues.push({
|
|
281
|
+
file: location,
|
|
282
|
+
message: `Unknown tag "${tag}" (not in tags.json)`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
for (const file of listJsonFiles(path.join(mnemeDir, "decisions"))) {
|
|
289
|
+
try {
|
|
290
|
+
const parsed = readJson(file);
|
|
291
|
+
if (Array.isArray(parsed.tags)) checkTags(parsed.tags, file);
|
|
292
|
+
} catch {
|
|
293
|
+
/* skip */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const file of listJsonFiles(path.join(mnemeDir, "patterns"))) {
|
|
298
|
+
try {
|
|
299
|
+
const parsed = readJson(file);
|
|
300
|
+
const items =
|
|
301
|
+
(parsed.items as unknown[]) || (parsed.patterns as unknown[]) || [];
|
|
302
|
+
for (const item of items as Array<Record<string, unknown>>) {
|
|
303
|
+
if (Array.isArray(item.tags))
|
|
304
|
+
checkTags(item.tags, `${file}#${item.id || "?"}`);
|
|
305
|
+
}
|
|
306
|
+
} catch {
|
|
307
|
+
/* skip */
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const ruleFile of ["dev-rules", "review-guidelines"]) {
|
|
312
|
+
const file = path.join(mnemeDir, "rules", `${ruleFile}.json`);
|
|
313
|
+
if (!fs.existsSync(file)) continue;
|
|
314
|
+
try {
|
|
315
|
+
const parsed = readJson(file);
|
|
316
|
+
const items =
|
|
317
|
+
(parsed.items as unknown[]) || (parsed.rules as unknown[]) || [];
|
|
318
|
+
for (const item of items as Array<Record<string, unknown>>) {
|
|
319
|
+
if (Array.isArray(item.tags))
|
|
320
|
+
checkTags(item.tags, `${file}#${item.id || "?"}`);
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
/* skip */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function validateSources(mnemeDir: string): ValidationResult {
|
|
329
|
+
if (!fs.existsSync(mnemeDir)) {
|
|
330
|
+
return { valid: true, issueCount: 0, issues: [] };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const issues: ValidationIssue[] = [];
|
|
334
|
+
validateDecisions(mnemeDir, issues);
|
|
335
|
+
validatePatterns(mnemeDir, issues);
|
|
336
|
+
validateRules(mnemeDir, issues);
|
|
337
|
+
validateIdUniqueness(mnemeDir, issues);
|
|
338
|
+
validateTagExistence(mnemeDir, issues);
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
valid: issues.length === 0,
|
|
342
|
+
issueCount: issues.length,
|
|
343
|
+
issues,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function registerValidateSourcesTool(server: McpServer): void {
|
|
348
|
+
server.registerTool(
|
|
349
|
+
"mneme_validate_sources",
|
|
350
|
+
{
|
|
351
|
+
description:
|
|
352
|
+
"Validate source artifacts (decisions, patterns, rules) for required fields and consistency.",
|
|
353
|
+
inputSchema: {},
|
|
354
|
+
},
|
|
355
|
+
async () => {
|
|
356
|
+
const mnemeDir = getMnemeDir();
|
|
357
|
+
const result = validateSources(mnemeDir);
|
|
358
|
+
return ok(JSON.stringify(result, null, 2));
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
}
|
package/servers/db-server.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
QUERY_MAX_LENGTH,
|
|
34
34
|
} from "./db/types.js";
|
|
35
35
|
import { getDb } from "./db/utils.js";
|
|
36
|
+
import { registerValidateSourcesTool } from "./db/validate-sources.js";
|
|
36
37
|
|
|
37
38
|
const server = new McpServer({
|
|
38
39
|
name: "mneme-db",
|
|
@@ -185,6 +186,7 @@ server.registerTool(
|
|
|
185
186
|
// Register extended tools
|
|
186
187
|
registerSessionSummaryTool(server);
|
|
187
188
|
registerExtendedTools(server);
|
|
189
|
+
registerValidateSourcesTool(server);
|
|
188
190
|
|
|
189
191
|
async function main() {
|
|
190
192
|
const transport = new StdioServerTransport();
|
package/skills/harvest/SKILL.md
CHANGED
|
@@ -83,8 +83,8 @@ Use explicit section markers and XML-style tags for hard constraints:
|
|
|
83
83
|
4. Deduplicate/conflict-check against existing sources.
|
|
84
84
|
5. Save source artifacts with `prSource` metadata.
|
|
85
85
|
6. Run source validation gate:
|
|
86
|
-
-
|
|
87
|
-
- If failed: fix artifacts and rerun.
|
|
86
|
+
- Call MCP tool `mneme_validate_sources`
|
|
87
|
+
- If failed (`valid: false`): fix artifacts and rerun.
|
|
88
88
|
7. Regenerate units from sources.
|
|
89
89
|
8. Show pending units for approval.
|
|
90
90
|
|
|
@@ -94,6 +94,6 @@ Use explicit section markers and XML-style tags for hard constraints:
|
|
|
94
94
|
- extracted source item counts by type
|
|
95
95
|
- duplicate/conflict summary
|
|
96
96
|
- priority distribution (`p0/p1/p2` for added/updated rules)
|
|
97
|
-
- validation result (`
|
|
97
|
+
- validation result (`mneme_validate_sources`)
|
|
98
98
|
- regenerated units count
|
|
99
99
|
- pending units count
|