@abarcenas/ai-workflow-template 1.41.0 → 1.44.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 (30) hide show
  1. package/.agents/instructions/knowledge-retrieval.instructions.md +69 -0
  2. package/.agents/instructions/knowledgebase.instructions.md +43 -22
  3. package/.agents/instructions/learned-knowledge.instructions.md +180 -0
  4. package/.agents/instructions/memory-schema.instructions.md +22 -0
  5. package/.husky/pre-commit +1 -0
  6. package/.opencode/agents/architect.agent.md +1 -0
  7. package/.opencode/agents/coder.agent.md +1 -1
  8. package/.opencode/agents/deployer.agent.md +3 -2
  9. package/.opencode/agents/designer.agent.md +4 -3
  10. package/.opencode/agents/e2e-tester.agent.md +5 -4
  11. package/.opencode/agents/implementer.agent.md +1 -0
  12. package/.opencode/agents/orchestrator/feature-pipeline.agent.md +6 -2
  13. package/.opencode/agents/orchestrator/orchestrator.agent.md +5 -1
  14. package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +7 -1
  15. package/.opencode/agents/researcher.agent.md +9 -1
  16. package/.opencode/agents/reviewer.agent.md +2 -0
  17. package/.opencode/agents/tracker.agent.md +8 -6
  18. package/.opencode/agents/unit-tester.agent.md +6 -5
  19. package/README.md +124 -304
  20. package/memory-bank/.vocabulary.json +125 -31
  21. package/package.json +1 -1
  22. package/scripts/mcp-knowledgebase-server.js +85 -19
  23. package/scripts/mcp-knowledgebase-server.test.js +745 -0
  24. package/scripts/setup/constants.js +2 -1
  25. package/scripts/sync.js +1 -0
  26. package/scripts/sync.test.js +4 -3
  27. package/scripts/validate-memory-schema.js +28 -3
  28. package/scripts/validate-memory-schema.test.js +328 -0
  29. package/scripts/vocab-sync.js +216 -0
  30. package/scripts/vocab-sync.test.js +814 -0
@@ -46,11 +46,12 @@ export const TEMPLATE_HOOKS = {
46
46
  '#!/bin/sh',
47
47
  '',
48
48
  'node scripts/bump-version.js',
49
+ 'node scripts/vocab-sync.js',
49
50
  'node scripts/validate-memory-schema.js',
50
51
  '',
51
52
  ].join('\n'),
52
53
  description:
53
- 'Auto-bump version + validate memory-bank schema before commits',
54
+ 'Auto-bump version + sync vocabulary + validate memory-bank schema before commits',
54
55
  },
55
56
  'post-merge': {
56
57
  source: '.husky/post-merge',
package/scripts/sync.js CHANGED
@@ -180,6 +180,7 @@ const scriptsToSync = [
180
180
  'memory-index.js',
181
181
  'bump-version.js',
182
182
  'validate-memory-schema.js',
183
+ 'vocab-sync.js',
183
184
  'mcp-memory-server.js',
184
185
  'mcp/playwright-mcp-launcher.js',
185
186
  // Knowledgebase scripts
@@ -53,6 +53,7 @@ describe('__scripts__ sync behavior', () => {
53
53
  'memory-index.js',
54
54
  'bump-version.js',
55
55
  'validate-memory-schema.js',
56
+ 'vocab-sync.js',
56
57
  'mcp-memory-server.js',
57
58
  'mcp/playwright-mcp-launcher.js',
58
59
  ]
@@ -75,7 +76,7 @@ describe('__scripts__ sync behavior', () => {
75
76
  const result = runSync(tempDir)
76
77
  expect(result.status).toBe(0)
77
78
 
78
- // Verify all 6 script files were created in the consumer's scripts/ dir
79
+ // Verify all 7 script files were created in the consumer's scripts/ dir
79
80
  for (const script of scriptsToSync) {
80
81
  const targetFile = join(tempDir, 'scripts', script)
81
82
  expect(
@@ -137,7 +138,7 @@ describe('__scripts__ sync behavior', () => {
137
138
  const result = runSync(tempDir, ['--force'])
138
139
  expect(result.status).toBe(0)
139
140
 
140
- // Verify all 6 files were overwritten with source content
141
+ // Verify all 7 files were overwritten with source content
141
142
  for (const script of scriptsToSync) {
142
143
  const targetFile = join(tempDir, 'scripts', script)
143
144
  const sourceFile = join(sourceScriptsDir, script)
@@ -178,7 +179,7 @@ describe('__scripts__ sync behavior', () => {
178
179
  readFileSync(join(tempDir, '.agents-sync-manifest.json'), 'utf8'),
179
180
  )
180
181
 
181
- // Verify all 6 __scripts__ entries are tracked with valid SHA-256 hashes
182
+ // Verify all 7 __scripts__ entries are tracked with valid SHA-256 hashes
182
183
  for (const script of scriptsToSync) {
183
184
  const key = `__scripts__/${script}`
184
185
  expect(updatedManifest.files[key]).toBeDefined()
@@ -15,6 +15,7 @@
15
15
  import { execSync } from 'node:child_process';
16
16
  import { existsSync, readFileSync } from 'node:fs';
17
17
  import { basename, extname, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
18
19
 
19
20
  const MEMORY_BANK_DIR = resolve(process.cwd(), 'memory-bank');
20
21
  const VOCAB_PATH = resolve(MEMORY_BANK_DIR, '.vocabulary.json');
@@ -128,8 +129,21 @@ function validateFile(filePath, vocab) {
128
129
 
129
130
  // tags must be from vocabulary (warn only — don't block for new user-defined tags)
130
131
  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));
132
+ // Known tags = all tags.* group values PLUS entity_patterns keys (e.g. npm, vitest, playwright)
133
+ const allTags = [
134
+ ...Object.values(vocab.tags).flat(),
135
+ ...Object.keys(vocab.entity_patterns || {}),
136
+ ];
137
+ // Normalize BOTH sides before the unknown-tag filter for parity with
138
+ // vocab-sync.js (which lowercases/kebab-cases tags before comparing and
139
+ // appending). Without this, a staged `My Tag` still warns "Unknown tags:
140
+ // [My Tag]" on every commit even after vocab-sync appends `my-tag`.
141
+ const normalizeTag = (tag) => String(tag).toLowerCase().trim().replace(/[\s_]+/g, '-');
142
+ const knownTags = new Set(allTags.map(normalizeTag).filter(Boolean));
143
+ const unknown = fm.tags.filter((t) => {
144
+ const normalized = normalizeTag(t);
145
+ return normalized && !knownTags.has(normalized);
146
+ });
133
147
  if (unknown.length > 0) {
134
148
  console.warn(
135
149
  `[validate-memory] ⚠ ${filePath}: Unknown tags: [${unknown.join(', ')}]. ` +
@@ -187,4 +201,15 @@ function main() {
187
201
  process.exit(0);
188
202
  }
189
203
 
190
- main();
204
+ // Only run when executed directly (node scripts/validate-memory-schema.js).
205
+ // When the module is imported — e.g. by unit tests exercising validateFile —
206
+ // no side effects are triggered (main() calls process.exit(), which would
207
+ // terminate the test runner).
208
+ const isDirectRun =
209
+ process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
210
+
211
+ if (isDirectRun) {
212
+ main();
213
+ }
214
+
215
+ export { validateFile, main };
@@ -0,0 +1,328 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // validate-memory-schema.test.js — Unit tests for the pre-commit validator
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // Covers plan/feature-vocab-sync-1.md T10 (TEST-01..05) — the T3
6
+ // entity_patterns awareness fix: `entity_patterns` keys (npm, vitest, …) count
7
+ // as known tags, so they no longer produce false-positive "Unknown tags"
8
+ // warnings; genuinely unknown tags still warn; and the validator keeps the
9
+ // warn-don't-block contract (exit code stays 0 on warnings).
10
+ //
11
+ // The validator is exercised through its exported functions (testability seam
12
+ // added in T10: `export { validateFile, main }` + `isDirectRun` guard, matching
13
+ // scripts/vocab-sync.js / scripts/mcp-knowledgebase-server.js convention).
14
+ // child_process.execSync (staged-file detection) and node:fs.readFileSync
15
+ // (file + vocab content) are mocked hermetically so no git/FS side effects occur.
16
+ // ═════════════════════════════════════════════════════════════════════════════
17
+
18
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
19
+
20
+ // Module-level mocks — the validator imports execSync from node:child_process
21
+ // and readFileSync/existsSync from node:fs. vi.mock is hoisted above imports,
22
+ // so the validator under test receives these mocked implementations.
23
+ vi.mock('node:child_process', async () => {
24
+ const actual = await vi.importActual('node:child_process')
25
+ return { ...actual, execSync: vi.fn() }
26
+ })
27
+
28
+ vi.mock('node:fs', async () => {
29
+ const actual = await vi.importActual('node:fs')
30
+ return {
31
+ ...actual,
32
+ readFileSync: vi.fn(),
33
+ existsSync: vi.fn(() => true),
34
+ }
35
+ })
36
+
37
+ import { readFileSync } from 'node:fs'
38
+ import { execSync } from 'node:child_process'
39
+
40
+ import { validateFile, main } from './validate-memory-schema.js'
41
+
42
+ // ── Fixtures ─────────────────────────────────────────────────────────
43
+
44
+ const FILE_PATH = 'memory-bank/sample.md'
45
+
46
+ function makeFileContent({
47
+ id = 'sample',
48
+ title = 'Sample',
49
+ updated = '2026-08-03',
50
+ tags = [],
51
+ category = 'memory_ops',
52
+ } = {}) {
53
+ return [
54
+ '---',
55
+ `id: ${id}`,
56
+ `title: "${title}"`,
57
+ `updated: ${updated}`,
58
+ `tags: [${tags.join(', ')}]`,
59
+ 'entities: [sample]',
60
+ `category: ${category}`,
61
+ '---',
62
+ '',
63
+ '# Sample',
64
+ '',
65
+ 'Body.',
66
+ '',
67
+ ].join('\n')
68
+ }
69
+
70
+ // Mirrors the real memory-bank/.vocabulary.json shape: tags.* groups hold
71
+ // known concept tags; entity_patterns keys hold component/tool names.
72
+ const VOCAB = {
73
+ categories: { memory_ops: 'Memory operations', context: 'Context files' },
74
+ tags: {
75
+ agent_roles: ['architect', 'implementer'],
76
+ workflow: ['planning', 'review'],
77
+ memory_ops: ['indexing'],
78
+ topic: ['mcp', 'graphify'],
79
+ },
80
+ entity_patterns: {
81
+ npm: { patterns: ['\\bnpm\\b'] },
82
+ vitest: { patterns: ['\\bvitest\\b'] },
83
+ playwright: { patterns: ['\\bplaywright\\b'] },
84
+ },
85
+ }
86
+
87
+ // ── Test harness ─────────────────────────────────────────────────────
88
+
89
+ let warnSpy
90
+
91
+ beforeEach(() => {
92
+ vi.clearAllMocks()
93
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
94
+ })
95
+
96
+ afterEach(() => {
97
+ vi.restoreAllMocks()
98
+ })
99
+
100
+ // ═════════════════════════════════════════════════════════════════════════════
101
+ // T10 scenarios — entity_patterns awareness (plan TEST-01..05)
102
+ // ═════════════════════════════════════════════════════════════════════════════
103
+
104
+ describe('validateFile — entity_patterns awareness (plan T10)', () => {
105
+ it('TEST-01: does NOT warn when a tag is an entity_patterns key (npm)', () => {
106
+ readFileSync.mockReturnValue(makeFileContent({ tags: ['npm'] }))
107
+
108
+ const errors = validateFile(FILE_PATH, VOCAB)
109
+
110
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
111
+ expect(errors).toEqual([])
112
+ })
113
+
114
+ it('TEST-02: does NOT warn when a tag is in a tags.* group (mcp in tags.topic)', () => {
115
+ readFileSync.mockReturnValue(makeFileContent({ tags: ['mcp'] }))
116
+
117
+ const errors = validateFile(FILE_PATH, VOCAB)
118
+
119
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
120
+ expect(errors).toEqual([])
121
+ })
122
+
123
+ it('TEST-03: warns when a tag is genuinely unknown (some-random-tag)', () => {
124
+ readFileSync.mockReturnValue(makeFileContent({ tags: ['some-random-tag'] }))
125
+
126
+ const errors = validateFile(FILE_PATH, VOCAB)
127
+
128
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
129
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('some-random-tag'))
130
+ // warn-don't-block: a warning is NOT a validation error
131
+ expect(errors).toEqual([])
132
+ })
133
+
134
+ it('TEST-04: warns ONLY for truly unknown tags when mixed with entity_patterns keys', () => {
135
+ readFileSync.mockReturnValue(
136
+ makeFileContent({ tags: ['npm', 'vitest', 'some-random-tag'] })
137
+ )
138
+
139
+ const errors = validateFile(FILE_PATH, VOCAB)
140
+
141
+ const warnText = warnSpy.mock.calls.map((call) => String(call[0])).join(' ')
142
+ expect(warnText).toContain('Unknown tags')
143
+ expect(warnText).toContain('some-random-tag')
144
+ expect(warnText).not.toContain('npm')
145
+ expect(warnText).not.toContain('vitest')
146
+ expect(errors).toEqual([])
147
+ })
148
+
149
+ it('TEST-05: handles a vocab with no entity_patterns key gracefully (no crash)', () => {
150
+ const vocabNoEntityPatterns = {
151
+ categories: { memory_ops: 'Memory operations' },
152
+ tags: { topic: ['mcp'] },
153
+ // deliberately NO entity_patterns key
154
+ }
155
+ readFileSync.mockReturnValue(makeFileContent({ tags: ['npm'] }))
156
+
157
+ // `Object.keys(vocab.entity_patterns || {})` must not throw on undefined
158
+ expect(() => validateFile(FILE_PATH, vocabNoEntityPatterns)).not.toThrow()
159
+ // without entity_patterns, 'npm' is genuinely unknown → degrades to a warning
160
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
161
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('npm'))
162
+ })
163
+
164
+ it('TEST-05b: does not crash with a completely empty vocab object ({})', () => {
165
+ readFileSync.mockReturnValue(makeFileContent({ tags: ['whatever'] }))
166
+
167
+ expect(() => validateFile(FILE_PATH, {})).not.toThrow()
168
+ // no tags.* groups and no entity_patterns → tag check skipped, no warning
169
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
170
+ })
171
+ })
172
+
173
+ // ═════════════════════════════════════════════════════════════════════════════
174
+ // Reviewer M1 — normalization parity with vocab-sync
175
+ // ═════════════════════════════════════════════════════════════════════════════
176
+ // vocab-sync.js normalizes tags (lowercase, kebab-case) before comparing and
177
+ // appending, so after it appends `my-tag` a staged file whose frontmatter still
178
+ // says `My Tag` must NOT warn. The validator therefore normalizes both sides
179
+ // before the unknown-tag filter (reviewer finding M1). The three scenarios below
180
+ // lock in: (a) normalized-known no-warn, (b) genuinely unknown still warns,
181
+ // (c) mixed-case genuinely unknown still warns.
182
+
183
+ describe('validateFile — tag normalization parity with vocab-sync (reviewer M1)', () => {
184
+ it('M1-a: does NOT warn when a mixed-case staged tag normalizes to a known tag (My Tag → my-tag)', () => {
185
+ const vocabWithNormalized = {
186
+ ...VOCAB,
187
+ tags: { ...VOCAB.tags, topic: [...VOCAB.tags.topic, 'my-tag'] },
188
+ }
189
+ readFileSync.mockReturnValue(makeFileContent({ tags: ['My Tag'] }))
190
+
191
+ const errors = validateFile(FILE_PATH, vocabWithNormalized)
192
+
193
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
194
+ expect(errors).toEqual([])
195
+ })
196
+
197
+ it('M1-b: still warns when a tag is genuinely unknown after normalization (some-random-tag)', () => {
198
+ readFileSync.mockReturnValue(makeFileContent({ tags: ['some-random-tag'] }))
199
+
200
+ const errors = validateFile(FILE_PATH, VOCAB)
201
+
202
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
203
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('some-random-tag'))
204
+ // warn-don't-block: a warning is NOT a validation error
205
+ expect(errors).toEqual([])
206
+ })
207
+
208
+ it('M1-c: still warns for a mixed-case tag that is NOT in the vocabulary', () => {
209
+ readFileSync.mockReturnValue(makeFileContent({ tags: ['Random Tag'] }))
210
+
211
+ const errors = validateFile(FILE_PATH, VOCAB)
212
+
213
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
214
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Random Tag'))
215
+ // warn-don't-block: a warning is NOT a validation error
216
+ expect(errors).toEqual([])
217
+ })
218
+ })
219
+
220
+ // ═════════════════════════════════════════════════════════════════════════════
221
+ // main() — exit-code contract (warn-don't-block, REQ-05 / CON-02)
222
+ // ═════════════════════════════════════════════════════════════════════════════
223
+
224
+ describe('main() — exit code behavior', () => {
225
+ it('exits 0 when unknown-tag warnings occur (warnings do NOT block the commit)', () => {
226
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
227
+ execSync.mockReturnValue(`${FILE_PATH}\n`)
228
+ readFileSync.mockImplementation((filePath) => {
229
+ if (String(filePath).includes('.vocabulary.json')) return JSON.stringify(VOCAB)
230
+ return makeFileContent({ tags: ['some-random-tag'] })
231
+ })
232
+
233
+ main()
234
+
235
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown tags'))
236
+ expect(exitSpy).toHaveBeenCalledWith(0)
237
+ })
238
+
239
+ it('exits 0 when no memory-bank files are staged', () => {
240
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
241
+ execSync.mockReturnValue('')
242
+
243
+ main()
244
+
245
+ expect(exitSpy).toHaveBeenCalledWith(0)
246
+ })
247
+
248
+ it('exits 1 when validation errors are found', () => {
249
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
250
+ execSync.mockReturnValue(`${FILE_PATH}\n`)
251
+ readFileSync.mockImplementation((filePath) => {
252
+ if (String(filePath).includes('.vocabulary.json')) return JSON.stringify(VOCAB)
253
+ // id does not match the filename → real validation error
254
+ return makeFileContent({ id: 'mismatched-id', tags: ['mcp'] })
255
+ })
256
+
257
+ main()
258
+
259
+ expect(exitSpy).toHaveBeenCalledWith(1)
260
+ })
261
+ })
262
+
263
+ // ═════════════════════════════════════════════════════════════════════════════
264
+ // validateFile — error branches (robustness / coverage support)
265
+ // ═════════════════════════════════════════════════════════════════════════════
266
+
267
+ describe('validateFile — error branches', () => {
268
+ it('returns a "Cannot read file" error when the file is unreadable', () => {
269
+ readFileSync.mockImplementation(() => {
270
+ throw new Error('ENOENT')
271
+ })
272
+
273
+ const errors = validateFile(FILE_PATH, VOCAB)
274
+
275
+ expect(errors).toEqual([`Cannot read file: ${FILE_PATH}`])
276
+ })
277
+
278
+ it('reports missing YAML frontmatter', () => {
279
+ readFileSync.mockReturnValue('# No frontmatter here\n')
280
+
281
+ const errors = validateFile(FILE_PATH, VOCAB)
282
+
283
+ expect(errors).toEqual(['Missing YAML frontmatter (file must start with ---...---)'])
284
+ })
285
+
286
+ it('reports missing required fields', () => {
287
+ readFileSync.mockReturnValue('---\nid: sample\n---\n')
288
+
289
+ const errors = validateFile(FILE_PATH, VOCAB)
290
+
291
+ expect(errors).toEqual([
292
+ "Missing required field: 'title'",
293
+ "Missing required field: 'updated'",
294
+ "Missing required field: 'tags'",
295
+ "Missing required field: 'category'",
296
+ ])
297
+ })
298
+
299
+ it('reports an id/filename mismatch', () => {
300
+ readFileSync.mockReturnValue(makeFileContent({ id: 'mismatched-id' }))
301
+
302
+ const errors = validateFile(FILE_PATH, VOCAB)
303
+
304
+ expect(errors).toContain(
305
+ `Field 'id' (mismatched-id) does not match filename (sample)`
306
+ )
307
+ })
308
+
309
+ it('reports an invalid updated date', () => {
310
+ readFileSync.mockReturnValue(makeFileContent({ updated: 'yesterday' }))
311
+
312
+ const errors = validateFile(FILE_PATH, VOCAB)
313
+
314
+ expect(errors).toContain(
315
+ `Field 'updated' (yesterday) is not a valid date. Use YYYY-MM-DD format.`
316
+ )
317
+ })
318
+
319
+ it('reports an invalid category', () => {
320
+ readFileSync.mockReturnValue(makeFileContent({ category: 'not-a-category' }))
321
+
322
+ const errors = validateFile(FILE_PATH, VOCAB)
323
+
324
+ expect(errors).toContain(
325
+ `Invalid category 'not-a-category'. Allowed: memory_ops, context`
326
+ )
327
+ })
328
+ })
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * vocab-sync.js — Pre-commit hook vocabulary sync.
5
+ *
6
+ * Scans STAGED memory-bank/*.md files for YAML frontmatter tags and appends
7
+ * any genuinely-new tags to memory-bank/.vocabulary.json under the `topic`
8
+ * group (the default catch-all). A tag is considered "known" when it exists
9
+ * in any `tags.*` group or as an `entity_patterns` key.
10
+ *
11
+ * Warning-only hook — never blocks the commit. Exits 0 unless an unexpected
12
+ * error occurs (then exits 1 with an error message).
13
+ *
14
+ * Usage (in .husky/pre-commit, BEFORE validate-memory-schema.js):
15
+ * node scripts/vocab-sync.js
16
+ */
17
+
18
+ import { execSync } from 'node:child_process';
19
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const MEMORY_BANK_DIR = resolve(process.cwd(), 'memory-bank');
24
+ const VOCAB_PATH = resolve(MEMORY_BANK_DIR, '.vocabulary.json');
25
+
26
+ // ── Load vocabulary ──────────────────────────────────────────────────
27
+
28
+ function loadVocabulary() {
29
+ if (!existsSync(VOCAB_PATH)) {
30
+ console.warn('[vocab-sync] ⚠ No .vocabulary.json found at memory-bank/.vocabulary.json');
31
+ console.warn('[vocab-sync] Vocabulary sync skipped.');
32
+ return null;
33
+ }
34
+
35
+ try {
36
+ return JSON.parse(readFileSync(VOCAB_PATH, 'utf8'));
37
+ } catch (err) {
38
+ console.warn(`[vocab-sync] ⚠ Failed to parse .vocabulary.json: ${err.message}`);
39
+ console.warn('[vocab-sync] Vocabulary sync skipped.');
40
+ return null;
41
+ }
42
+ }
43
+
44
+ // ── Get staged memory-bank files ─────────────────────────────────────
45
+
46
+ function getStagedMemoryFiles() {
47
+ try {
48
+ const output = execSync(
49
+ 'git diff --cached --name-only --diff-filter=ACM',
50
+ { encoding: 'utf8', cwd: process.cwd() }
51
+ );
52
+ return output
53
+ .split('\n')
54
+ .filter(f => f.startsWith('memory-bank/') && f.endsWith('.md') && !f.includes('/.index/'));
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ // ── Parse frontmatter ────────────────────────────────────────────────
61
+
62
+ function parseFrontmatter(content) {
63
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
64
+ if (!match) return null;
65
+
66
+ const raw = match[1];
67
+ const fm = {};
68
+ for (const line of raw.split('\n')) {
69
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)/);
70
+ if (kv) {
71
+ const key = kv[1];
72
+ let value = kv[2].trim();
73
+ // Parse arrays
74
+ if (value.startsWith('[') && value.endsWith(']')) {
75
+ value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
76
+ } else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
77
+ value = value.slice(1, -1);
78
+ }
79
+ fm[key] = value;
80
+ }
81
+ }
82
+ return fm;
83
+ }
84
+
85
+ // ── Tag normalization ────────────────────────────────────────────────
86
+
87
+ function normalizeTag(tag) {
88
+ return String(tag)
89
+ .toLowerCase()
90
+ .trim()
91
+ .replace(/[\s_]+/g, '-');
92
+ }
93
+
94
+ // ── Known tag collection ─────────────────────────────────────────────
95
+
96
+ function buildKnownTags(vocab) {
97
+ const known = new Set();
98
+
99
+ // All values across all tags.* groups
100
+ for (const group of Object.values(vocab?.tags || {})) {
101
+ // Guard against malformed vocab shapes (consumer-controlled input): if
102
+ // `vocab.tags` is an array or a group is a plain string, iterating the
103
+ // group directly would walk CHARACTERS instead of tags. Skip non-arrays.
104
+ if (!Array.isArray(group)) continue;
105
+ for (const tag of group) {
106
+ const normalized = normalizeTag(tag);
107
+ if (normalized) known.add(normalized);
108
+ }
109
+ }
110
+
111
+ // All entity_patterns keys (component/tool names)
112
+ for (const key of Object.keys(vocab?.entity_patterns || {})) {
113
+ const normalized = normalizeTag(key);
114
+ if (normalized) known.add(normalized);
115
+ }
116
+
117
+ return known;
118
+ }
119
+
120
+ // ── Collect new tags ─────────────────────────────────────────────────
121
+
122
+ function collectNewTags(stagedFiles, vocab) {
123
+ const known = buildKnownTags(vocab);
124
+ const newTags = new Set();
125
+
126
+ for (const file of stagedFiles) {
127
+ let content;
128
+ try {
129
+ content = readFileSync(resolve(process.cwd(), file), 'utf8');
130
+ } catch {
131
+ continue; // unreadable file — skip
132
+ }
133
+
134
+ const fm = parseFrontmatter(content);
135
+ if (!fm || !Array.isArray(fm.tags)) continue;
136
+
137
+ for (const rawTag of fm.tags) {
138
+ const tag = normalizeTag(rawTag);
139
+ if (!tag) continue; // empty / whitespace-only
140
+ if (known.has(tag)) continue; // already in vocabulary
141
+ newTags.add(tag);
142
+ }
143
+ }
144
+
145
+ return [...newTags].sort();
146
+ }
147
+
148
+ // ── Sync ─────────────────────────────────────────────────────────────
149
+
150
+ function syncVocabulary(stagedFiles, vocab) {
151
+ const newTags = collectNewTags(stagedFiles, vocab);
152
+ if (newTags.length === 0) return false; // nothing to do — no-op
153
+
154
+ // Default placement: the `topic` catch-all group
155
+ if (!vocab.tags) vocab.tags = {};
156
+ if (!Array.isArray(vocab.tags.topic)) vocab.tags.topic = [];
157
+
158
+ // Append, normalize existing entries, dedupe, sort alphabetically
159
+ const merged = new Set([
160
+ ...vocab.tags.topic.map(normalizeTag).filter(Boolean),
161
+ ...newTags,
162
+ ]);
163
+ vocab.tags.topic = [...merged].sort();
164
+
165
+ // Write back with 2-space indent + trailing newline (matches existing format)
166
+ writeFileSync(VOCAB_PATH, `${JSON.stringify(vocab, null, 2)}\n`, 'utf8');
167
+
168
+ // Stage the updated vocabulary so it commits with the same change
169
+ try {
170
+ execSync('git add memory-bank/.vocabulary.json', { cwd: process.cwd() });
171
+ } catch (err) {
172
+ console.warn(`[vocab-sync] ⚠ Could not stage memory-bank/.vocabulary.json: ${err.message}`);
173
+ }
174
+
175
+ console.warn(
176
+ `[vocab-sync] Added ${newTags.length} tag(s) to memory-bank/.vocabulary.json: [${newTags.join(', ')}]`
177
+ );
178
+ return true;
179
+ }
180
+
181
+ // ── Main ─────────────────────────────────────────────────────────────
182
+
183
+ function main() {
184
+ const stagedFiles = getStagedMemoryFiles();
185
+ if (stagedFiles.length === 0) return; // no memory-bank files staged — silent no-op
186
+
187
+ const vocab = loadVocabulary();
188
+ if (!vocab) return; // graceful skip when vocabulary is unavailable
189
+
190
+ syncVocabulary(stagedFiles, vocab);
191
+ }
192
+
193
+ // Only run when executed directly (node scripts/vocab-sync.js). When the
194
+ // module is imported — e.g. by unit tests — no side effects are triggered.
195
+ const isDirectRun =
196
+ process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
197
+
198
+ if (isDirectRun) {
199
+ try {
200
+ main();
201
+ } catch (err) {
202
+ console.error(`[vocab-sync] ❌ Unhandled error: ${err.message}`);
203
+ process.exit(1);
204
+ }
205
+ }
206
+
207
+ export {
208
+ loadVocabulary,
209
+ getStagedMemoryFiles,
210
+ parseFrontmatter,
211
+ normalizeTag,
212
+ buildKnownTags,
213
+ collectNewTags,
214
+ syncVocabulary,
215
+ main,
216
+ };