@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.
@@ -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 2>/dev/null || echo "")
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
- if [ -n "$context_lines" ] && [ "$context_lines" != "null" ]; then
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
- Related context found (sessions/units):
81
- ${context_lines}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hir4ta/mneme",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Long-term memory plugin for Claude Code - automated session saving, recording technical decisions, and web dashboard",
5
5
  "keywords": [
6
6
  "claude",
@@ -39,7 +39,7 @@ export async function saveInteractions(
39
39
  }
40
40
 
41
41
  const projectPath = getProjectPath();
42
- const sessionId = mnemeSessionId || claudeSessionId.slice(0, 8);
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.slice(0, 8);
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; shortId: 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 searchDir = (dir: string): string | null => {
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 = searchDir(fullPath);
73
+ const result = searchDirFor(fullPath, fileName);
71
74
  if (result) return result;
72
- } else if (entry.name === `${shortId}.json`) {
75
+ } else if (entry.name === fileName) {
73
76
  return fullPath;
74
77
  }
75
78
  }
76
79
  return null;
77
80
  };
78
- sessionFile = searchDir(sessionsDir);
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, `${shortId}.json`);
105
+ sessionFile = path.join(yearMonth, `${claudeSessionId}.json`);
96
106
  const initial = {
97
- id: shortId,
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
- shortId,
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) => {
@@ -57,11 +57,12 @@ export function registerExtendedTools(server: McpServer) {
57
57
  },
58
58
  async ({ sessionId, includeChain }) => {
59
59
  const sessions = readSessionsById();
60
- const shortId = sessionId.slice(0, 8);
61
- const root = sessions.get(shortId);
62
- if (!root) return fail(`Session not found: ${shortId}`);
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 chain: string[] = [shortId];
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: shortId,
108
+ rootSessionId: rootId,
108
109
  dbAvailable,
109
110
  chainLength: timeline.length,
110
111
  timeline,
@@ -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
+ }
@@ -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();
@@ -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
- - `npm run validate:sources`
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 (`validate:sources`)
97
+ - validation result (`mneme_validate_sources`)
98
98
  - regenerated units count
99
99
  - pending units count