@abarcenas/ai-workflow-template 1.25.0 → 1.28.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/.agents/instructions/agent-skill.instructions.md +18 -20
- package/.agents/instructions/agent.instructions.md +111 -570
- package/.agents/instructions/ai-prompt-engineering-safety.instructions.md +3 -3
- package/.agents/instructions/instructions.instructions.md +6 -7
- package/.agents/instructions/memory-bank.instructions.md +72 -18
- package/.agents/instructions/memory-schema.instructions.md +75 -0
- package/.agents/instructions/prompt.instructions.md +25 -30
- package/.agents/skills/conventional-commit/SKILL.md +2 -2
- package/.agents/skills/github-issues/SKILL.md +2 -2
- package/.agents/skills/memory-search/SKILL.md +69 -0
- package/.agents/skills/microsoft-docs/SKILL.md +1 -1
- package/.agents/skills/polygot-test-agent/SKILL.md +0 -1
- package/.agents/skills/prompt-builder/SKILL.md +15 -14
- package/.husky/post-merge +10 -0
- package/.husky/pre-commit +2 -0
- package/.opencode/agents/architect.agent.md +2 -1
- package/.opencode/agents/coder.agent.md +3 -1
- package/.opencode/agents/deployer.agent.md +1 -0
- package/.opencode/agents/designer.agent.md +2 -1
- package/.opencode/agents/e2e-tester.agent.md +1 -0
- package/.opencode/agents/implementer.agent.md +2 -1
- package/.opencode/agents/orchestrator/feature-pipeline.agent.md +4 -3
- package/.opencode/agents/orchestrator/orchestrator.agent.md +4 -3
- package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +8 -4
- package/.opencode/agents/researcher.agent.md +1 -0
- package/.opencode/agents/reviewer.agent.md +1 -0
- package/.opencode/agents/tracker.agent.md +2 -1
- package/.opencode/agents/unit-tester.agent.md +1 -0
- package/README.md +75 -4
- package/memory-bank/.vocabulary.json +52 -0
- package/opencode.mcp.example.json +7 -1
- package/package.json +18 -3
- package/scripts/mcp-memory-server.js +243 -0
- package/scripts/memory-cli.js +110 -0
- package/scripts/memory-index.js +512 -0
- package/scripts/normalize-memory.js +349 -0
- package/scripts/validate-memory-schema.js +190 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* normalize-memory.js — Onboarding script for memory-bank vectorization.
|
|
5
|
+
*
|
|
6
|
+
* Performs one-time normalization of existing documentation:
|
|
7
|
+
* 1. Adds YAML frontmatter to memory-bank/*.md and docs/*.md files missing it
|
|
8
|
+
* 2. Auto-suggests tags and entities from .vocabulary.json
|
|
9
|
+
* 3. Migrates plan/*.md to docs/ (if plan/ directory has content)
|
|
10
|
+
* 4. Validates heading structure (memory-bank files)
|
|
11
|
+
*
|
|
12
|
+
* Idempotent — safe to run multiple times. Files already normalized are skipped.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node scripts/normalize-memory.js # Normalize (creates .normalized marker)
|
|
16
|
+
* node scripts/normalize-memory.js --force # Re-normalize all files
|
|
17
|
+
* node scripts/normalize-memory.js --dry-run # Show what would change
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const packageRoot = resolve(__dirname, '..');
|
|
26
|
+
const consumerRoot = process.env.INIT_CWD ? resolve(process.env.INIT_CWD) : process.cwd();
|
|
27
|
+
|
|
28
|
+
const MEMORY_BANK_DIR = resolve(consumerRoot, 'memory-bank');
|
|
29
|
+
const DOCS_DIR = resolve(consumerRoot, 'docs');
|
|
30
|
+
const PLAN_DIR = resolve(consumerRoot, 'plan');
|
|
31
|
+
const VOCAB_PATH = resolve(MEMORY_BANK_DIR, '.vocabulary.json');
|
|
32
|
+
const MARKER_FILE = resolve(MEMORY_BANK_DIR, '.normalized');
|
|
33
|
+
const SOURCE_VOCAB_PATH = resolve(packageRoot, 'memory-bank', '.vocabulary.json');
|
|
34
|
+
|
|
35
|
+
const args = new Set(process.argv.slice(2));
|
|
36
|
+
const force = args.has('--force');
|
|
37
|
+
const dryRun = args.has('--dry-run');
|
|
38
|
+
|
|
39
|
+
// ── Load vocabulary ──────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function loadVocabulary() {
|
|
42
|
+
const vocabPath = existsSync(VOCAB_PATH) ? VOCAB_PATH : SOURCE_VOCAB_PATH;
|
|
43
|
+
if (!existsSync(vocabPath)) {
|
|
44
|
+
console.warn('[normalize-memory] No .vocabulary.json found. Tags will not be auto-suggested.');
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return JSON.parse(readFileSync(vocabPath, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getAllTags(vocab) {
|
|
51
|
+
if (!vocab || !vocab.tags) return [];
|
|
52
|
+
return Object.values(vocab.tags).flat();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Parse existing frontmatter ───────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function parseFrontmatter(content) {
|
|
58
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
59
|
+
if (!match) return { frontmatter: null, body: content };
|
|
60
|
+
|
|
61
|
+
const raw = match[1];
|
|
62
|
+
const fm = {};
|
|
63
|
+
for (const line of raw.split('\n')) {
|
|
64
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)/);
|
|
65
|
+
if (kv) {
|
|
66
|
+
fm[kv[1]] = kv[2].trim();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { frontmatter: fm, body: content.slice(match[0].length) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Extract H1 title ─────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function extractTitle(content) {
|
|
75
|
+
const match = content.match(/^# (.+)$/m);
|
|
76
|
+
return match ? match[1].trim() : basename(content, extname(content));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Auto-suggest tags ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function suggestTags(body, allTags) {
|
|
82
|
+
const lowerBody = body.toLowerCase();
|
|
83
|
+
return allTags.filter(tag => lowerBody.includes(tag.toLowerCase()));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function suggestEntities(body, vocab) {
|
|
87
|
+
if (!vocab || !vocab.entity_patterns) return [];
|
|
88
|
+
|
|
89
|
+
const entities = [];
|
|
90
|
+
for (const [entity, patterns] of Object.entries(vocab.entity_patterns)) {
|
|
91
|
+
for (const pattern of patterns) {
|
|
92
|
+
try {
|
|
93
|
+
if (new RegExp(pattern, 'i').test(body)) {
|
|
94
|
+
entities.push(entity);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
} catch { /* skip invalid regex */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return entities;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function inferCategory(fileName, vocab) {
|
|
104
|
+
if (!vocab || !vocab.file_category_map) return 'context';
|
|
105
|
+
|
|
106
|
+
const baseId = basename(fileName, extname(fileName)).replace(/^\./, '');
|
|
107
|
+
if (vocab.file_category_map[baseId]) return vocab.file_category_map[baseId];
|
|
108
|
+
|
|
109
|
+
if (fileName.includes('/tasks/')) return 'task';
|
|
110
|
+
if (fileName.startsWith('docs/') || fileName.includes('/docs/')) return 'decision';
|
|
111
|
+
|
|
112
|
+
return 'context';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getGitMtime(filePath) {
|
|
116
|
+
try {
|
|
117
|
+
// Fallback to filesystem mtime if git not available
|
|
118
|
+
const stats = statSync(filePath);
|
|
119
|
+
const d = stats.mtime;
|
|
120
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
121
|
+
} catch {
|
|
122
|
+
return new Date().toISOString().slice(0, 10);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Build frontmatter block ──────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function buildFrontmatterYaml(fm) {
|
|
129
|
+
const lines = ['---'];
|
|
130
|
+
for (const [key, value] of Object.entries(fm)) {
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
lines.push(`${key}: [${value.join(', ')}]`);
|
|
133
|
+
} else {
|
|
134
|
+
lines.push(`${key}: "${value}"`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
lines.push('---');
|
|
138
|
+
return lines.join('\n') + '\n';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Normalize a single file ──────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function normalizeFile(filePath, vocab, allTags, isMemoryBank) {
|
|
144
|
+
const content = readFileSync(filePath, 'utf8');
|
|
145
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
146
|
+
|
|
147
|
+
if (frontmatter && !force) {
|
|
148
|
+
// Already has frontmatter — skip unless forced
|
|
149
|
+
return { file: filePath, action: 'skipped', reason: 'already normalized' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const fileName = basename(filePath);
|
|
153
|
+
const fileId = basename(filePath, extname(filePath));
|
|
154
|
+
const title = extractTitle(body);
|
|
155
|
+
const date = getGitMtime(filePath);
|
|
156
|
+
const tags = suggestTags(body, allTags);
|
|
157
|
+
const entities = suggestEntities(body, vocab);
|
|
158
|
+
const category = isMemoryBank ? inferCategory(fileName, vocab) : undefined;
|
|
159
|
+
|
|
160
|
+
const newFm = {
|
|
161
|
+
id: fileId,
|
|
162
|
+
title,
|
|
163
|
+
updated: date,
|
|
164
|
+
tags: tags.length > 0 ? tags : [],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (isMemoryBank) {
|
|
168
|
+
newFm.entities = entities;
|
|
169
|
+
newFm.category = category;
|
|
170
|
+
} else {
|
|
171
|
+
// docs/ files get doc_type instead
|
|
172
|
+
newFm.doc_type = inferDocType(fileId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const newContent = buildFrontmatterYaml(newFm) + '\n' + body;
|
|
176
|
+
|
|
177
|
+
if (!dryRun) {
|
|
178
|
+
writeFileSync(filePath, newContent, 'utf8');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
file: filePath,
|
|
183
|
+
action: dryRun ? 'would-normalize' : 'normalized',
|
|
184
|
+
tags: newFm.tags,
|
|
185
|
+
entities: newFm.entities || [],
|
|
186
|
+
category: newFm.category || newFm.doc_type || 'n/a',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function inferDocType(fileId) {
|
|
191
|
+
const map = {
|
|
192
|
+
'tracker-log': 'tracker-log',
|
|
193
|
+
'orchestrator-log': 'orchestrator-log',
|
|
194
|
+
'architecture-context': 'architecture',
|
|
195
|
+
'playwright-mcp-configuration': 'configuration',
|
|
196
|
+
};
|
|
197
|
+
if (map[fileId.replace(/^\./, '')]) return map[fileId.replace(/^\./, '')];
|
|
198
|
+
if (fileId.includes('spike')) return 'spike';
|
|
199
|
+
return 'decision';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Collect files ────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
function collectMdFiles(dir, skipDir = null) {
|
|
205
|
+
if (!existsSync(dir)) return [];
|
|
206
|
+
const files = [];
|
|
207
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
const fullPath = join(dir, entry.name);
|
|
210
|
+
if (entry.isDirectory()) {
|
|
211
|
+
if (entry.name === skipDir) continue;
|
|
212
|
+
if (entry.name.startsWith('.') && entry.name !== '.index') continue; // skip hidden dirs except .index
|
|
213
|
+
files.push(...collectMdFiles(fullPath, skipDir));
|
|
214
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
215
|
+
files.push(fullPath);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return files;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Migrate plan/ to docs/ ───────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function migratePlanToDocs(vocab, allTags) {
|
|
224
|
+
if (!existsSync(PLAN_DIR)) return [];
|
|
225
|
+
|
|
226
|
+
const planFiles = collectMdFiles(PLAN_DIR);
|
|
227
|
+
if (planFiles.length === 0) return [];
|
|
228
|
+
|
|
229
|
+
ensureDir(DOCS_DIR);
|
|
230
|
+
const results = [];
|
|
231
|
+
|
|
232
|
+
for (const planFile of planFiles) {
|
|
233
|
+
const fileName = basename(planFile);
|
|
234
|
+
let destPath = join(DOCS_DIR, fileName);
|
|
235
|
+
|
|
236
|
+
// Avoid overwriting existing docs files
|
|
237
|
+
if (existsSync(destPath)) {
|
|
238
|
+
const base = basename(fileName, extname(fileName));
|
|
239
|
+
const ext = extname(fileName);
|
|
240
|
+
destPath = join(DOCS_DIR, `${base}.migrated${ext}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const content = readFileSync(planFile, 'utf8');
|
|
244
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
245
|
+
const title = extractTitle(body);
|
|
246
|
+
const date = getGitMtime(planFile);
|
|
247
|
+
const tags = suggestTags(body, allTags);
|
|
248
|
+
|
|
249
|
+
const fm = {
|
|
250
|
+
id: basename(destPath, extname(destPath)),
|
|
251
|
+
title,
|
|
252
|
+
updated: date,
|
|
253
|
+
tags: tags.length > 0 ? tags : ['migration'],
|
|
254
|
+
doc_type: 'decision',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (frontmatter) {
|
|
258
|
+
// Preserve existing frontmatter but add note
|
|
259
|
+
fm.note = `Migrated from plan/ directory during normalization on ${date}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const newContent = buildFrontmatterYaml(fm) + '\n' + body;
|
|
263
|
+
|
|
264
|
+
if (!dryRun) {
|
|
265
|
+
writeFileSync(destPath, newContent, 'utf8');
|
|
266
|
+
try { renameSync(planFile, planFile + '.migrated'); } catch { /* ok */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
results.push({
|
|
270
|
+
from: planFile,
|
|
271
|
+
to: destPath,
|
|
272
|
+
action: dryRun ? 'would-migrate' : 'migrated',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Clean up empty plan/ directory
|
|
277
|
+
if (!dryRun) {
|
|
278
|
+
try {
|
|
279
|
+
const remaining = readdirSync(PLAN_DIR).filter(f => !f.endsWith('.migrated'));
|
|
280
|
+
if (remaining.length === 0) {
|
|
281
|
+
rmdirSync(PLAN_DIR);
|
|
282
|
+
console.log('[normalize-memory] Removed empty plan/ directory');
|
|
283
|
+
}
|
|
284
|
+
} catch { /* ignore */ }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Main ─────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
function ensureDir(dir) {
|
|
293
|
+
if (!dryRun && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function main() {
|
|
297
|
+
// Check if already normalized
|
|
298
|
+
if (existsSync(MARKER_FILE) && !force && !dryRun) {
|
|
299
|
+
console.log('[normalize-memory] Already normalized. Use --force to re-run.');
|
|
300
|
+
process.exit(0);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const vocab = loadVocabulary();
|
|
304
|
+
const allTags = getAllTags(vocab);
|
|
305
|
+
|
|
306
|
+
console.log(`[normalize-memory] Mode: ${dryRun ? 'dry-run' : force ? 'force' : 'normal'}`);
|
|
307
|
+
|
|
308
|
+
// Migrate plan/ to docs/
|
|
309
|
+
const migrations = migratePlanToDocs(vocab, allTags);
|
|
310
|
+
if (migrations.length > 0) {
|
|
311
|
+
console.log(`[normalize-memory] Migrated ${migrations.length} files from plan/ to docs/`);
|
|
312
|
+
for (const m of migrations) {
|
|
313
|
+
console.log(` ${m.action}: ${m.from} → ${m.to}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Normalize memory-bank files
|
|
318
|
+
const memoryFiles = collectMdFiles(MEMORY_BANK_DIR, '.index');
|
|
319
|
+
console.log(`[normalize-memory] Found ${memoryFiles.length} memory-bank files`);
|
|
320
|
+
for (const file of memoryFiles) {
|
|
321
|
+
const result = normalizeFile(file, vocab, allTags, true);
|
|
322
|
+
if (result.action !== 'skipped') {
|
|
323
|
+
console.log(` ${result.action}: ${basename(file)} (tags: [${result.tags.join(', ')}], category: ${result.category})`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Normalize docs files (including hidden)
|
|
328
|
+
const docFiles = collectMdFiles(DOCS_DIR);
|
|
329
|
+
console.log(`[normalize-memory] Found ${docFiles.length} docs files`);
|
|
330
|
+
for (const file of docFiles) {
|
|
331
|
+
// Memory-bank files may also exist in docs/ for some projects — skip duplicates
|
|
332
|
+
if (file.includes('/.index/')) continue;
|
|
333
|
+
const result = normalizeFile(file, vocab, allTags, false);
|
|
334
|
+
if (result.action !== 'skipped') {
|
|
335
|
+
console.log(` ${result.action}: ${basename(file)} (tags: [${result.tags.join(', ')}])`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Create marker file
|
|
340
|
+
if (!dryRun) {
|
|
341
|
+
ensureDir(MEMORY_BANK_DIR);
|
|
342
|
+
writeFileSync(MARKER_FILE, new Date().toISOString() + '\n', 'utf8');
|
|
343
|
+
console.log('[normalize-memory] Created .normalized marker');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
console.log('[normalize-memory] Done.');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
main();
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* validate-memory-schema.js — Pre-commit hook validator.
|
|
5
|
+
*
|
|
6
|
+
* Checks staged memory-bank/*.md files for valid YAML frontmatter.
|
|
7
|
+
* Rejects commits with missing/invalid fields.
|
|
8
|
+
*
|
|
9
|
+
* Reads allowed values from memory-bank/.vocabulary.json at validation time.
|
|
10
|
+
*
|
|
11
|
+
* Usage (in .husky/pre-commit):
|
|
12
|
+
* node scripts/validate-memory-schema.js
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { basename, extname, resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const MEMORY_BANK_DIR = resolve(process.cwd(), 'memory-bank');
|
|
20
|
+
const VOCAB_PATH = resolve(MEMORY_BANK_DIR, '.vocabulary.json');
|
|
21
|
+
|
|
22
|
+
// ── Load vocabulary ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function loadVocabulary() {
|
|
25
|
+
if (!existsSync(VOCAB_PATH)) {
|
|
26
|
+
console.error('[validate-memory] ⚠ No .vocabulary.json found at memory-bank/.vocabulary.json');
|
|
27
|
+
console.error('[validate-memory] Schema validation skipped — vocabulary is required for validation.');
|
|
28
|
+
process.exit(0); // Don't block commits if vocab is missing (e.g., template not yet installed)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(VOCAB_PATH, 'utf8'));
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(`[validate-memory] ⚠ Failed to parse .vocabulary.json: ${err.message}`);
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Get staged memory-bank files ─────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function getStagedMemoryFiles() {
|
|
42
|
+
try {
|
|
43
|
+
const output = execSync(
|
|
44
|
+
'git diff --cached --name-only --diff-filter=ACM',
|
|
45
|
+
{ encoding: 'utf8', cwd: process.cwd() }
|
|
46
|
+
);
|
|
47
|
+
return output
|
|
48
|
+
.split('\n')
|
|
49
|
+
.filter(f => f.startsWith('memory-bank/') && f.endsWith('.md') && !f.includes('/.index/'));
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Parse frontmatter ────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function parseFrontmatter(content) {
|
|
58
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
59
|
+
if (!match) return null;
|
|
60
|
+
|
|
61
|
+
const raw = match[1];
|
|
62
|
+
const fm = {};
|
|
63
|
+
for (const line of raw.split('\n')) {
|
|
64
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)/);
|
|
65
|
+
if (kv) {
|
|
66
|
+
const key = kv[1];
|
|
67
|
+
let value = kv[2].trim();
|
|
68
|
+
// Parse arrays
|
|
69
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
70
|
+
value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
71
|
+
} else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
72
|
+
value = value.slice(1, -1);
|
|
73
|
+
}
|
|
74
|
+
fm[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return fm;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Validate ─────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function validateFile(filePath, vocab) {
|
|
83
|
+
const errors = [];
|
|
84
|
+
let content;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
content = readFileSync(resolve(process.cwd(), filePath), 'utf8');
|
|
88
|
+
} catch {
|
|
89
|
+
return [`Cannot read file: ${filePath}`];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fm = parseFrontmatter(content);
|
|
93
|
+
if (!fm) {
|
|
94
|
+
errors.push(`Missing YAML frontmatter (file must start with ---...---)`);
|
|
95
|
+
return errors;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fileName = basename(filePath);
|
|
99
|
+
const fileId = basename(filePath, extname(filePath));
|
|
100
|
+
|
|
101
|
+
// Required fields for memory-bank files
|
|
102
|
+
const requiredFields = ['id', 'title', 'updated', 'tags', 'category'];
|
|
103
|
+
for (const field of requiredFields) {
|
|
104
|
+
if (!fm[field]) {
|
|
105
|
+
errors.push(`Missing required field: '${field}'`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// id must match filename
|
|
110
|
+
if (fm.id && fm.id !== fileId) {
|
|
111
|
+
errors.push(`Field 'id' (${fm.id}) does not match filename (${fileId})`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// updated must be ISO date
|
|
115
|
+
if (fm.updated && !/^\d{4}-\d{2}-\d{2}$/.test(fm.updated)) {
|
|
116
|
+
errors.push(`Field 'updated' (${fm.updated}) is not a valid date. Use YYYY-MM-DD format.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// category must be in allowed list
|
|
120
|
+
if (fm.category && vocab?.categories) {
|
|
121
|
+
const allowed = Object.keys(vocab.categories);
|
|
122
|
+
if (!allowed.includes(fm.category)) {
|
|
123
|
+
errors.push(
|
|
124
|
+
`Invalid category '${fm.category}'. Allowed: ${allowed.join(', ')}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// tags must be from vocabulary (warn only — don't block for new user-defined tags)
|
|
130
|
+
if (fm.tags && Array.isArray(fm.tags) && vocab?.tags) {
|
|
131
|
+
const allTags = Object.values(vocab.tags).flat();
|
|
132
|
+
const unknown = fm.tags.filter(t => !allTags.includes(t));
|
|
133
|
+
if (unknown.length > 0) {
|
|
134
|
+
console.warn(
|
|
135
|
+
`[validate-memory] ⚠ ${filePath}: Unknown tags: [${unknown.join(', ')}]. ` +
|
|
136
|
+
`Consider adding them to memory-bank/.vocabulary.json.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return errors;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Main ─────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function main() {
|
|
147
|
+
const stagedFiles = getStagedMemoryFiles();
|
|
148
|
+
|
|
149
|
+
if (stagedFiles.length === 0) {
|
|
150
|
+
process.exit(0); // No memory-bank files staged — nothing to validate
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const vocab = loadVocabulary();
|
|
154
|
+
let hasErrors = false;
|
|
155
|
+
|
|
156
|
+
for (const file of stagedFiles) {
|
|
157
|
+
const errors = validateFile(file, vocab);
|
|
158
|
+
if (errors.length > 0) {
|
|
159
|
+
hasErrors = true;
|
|
160
|
+
console.error(`\n❌ ${file}:`);
|
|
161
|
+
for (const err of errors) {
|
|
162
|
+
console.error(` - ${err}`);
|
|
163
|
+
}
|
|
164
|
+
console.error('');
|
|
165
|
+
console.error(' Fix: Add valid YAML frontmatter at the top of the file:');
|
|
166
|
+
console.error(' ---');
|
|
167
|
+
console.error(' id: <filename-slug>');
|
|
168
|
+
console.error(' title: "<H1 heading>"');
|
|
169
|
+
console.error(' updated: YYYY-MM-DD');
|
|
170
|
+
console.error(' tags: [<from vocabulary>]');
|
|
171
|
+
console.error(' entities: [<components>]');
|
|
172
|
+
console.error(' category: <from list>');
|
|
173
|
+
console.error(' ---');
|
|
174
|
+
console.error('');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (hasErrors) {
|
|
179
|
+
console.error(
|
|
180
|
+
`[validate-memory] ${stagedFiles.length} file(s) checked, errors found. ` +
|
|
181
|
+
`Run 'node scripts/normalize-memory.js' to auto-fix, or fix manually.`
|
|
182
|
+
);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log(`[validate-memory] ✓ ${stagedFiles.length} memory file(s) validated`);
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
main();
|