@relipa/ai-flow-kit 0.1.6 → 0.1.7

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 (32) hide show
  1. package/README.md +30 -4
  2. package/bin/aiflow.js +38 -34
  3. package/custom/skills/review-plan/SKILL.md +19 -0
  4. package/custom/templates/memory/CODEOWNERS +8 -0
  5. package/custom/templates/memory/ci/memory-finalize.yml +9 -0
  6. package/custom/templates/memory/ci/memory-lint.yml +10 -0
  7. package/custom/templates/memory/gitlab/merge_request_templates/memory.md +18 -0
  8. package/custom/templates/memory/memory-item.md +25 -0
  9. package/custom/templates/memory/skeleton/00.Shared/architecture/_global/.gitkeep +0 -0
  10. package/custom/templates/memory/skeleton/00.Shared/decisions/.gitkeep +0 -0
  11. package/custom/templates/memory/skeleton/00.Shared/domain/_global/.gitkeep +0 -0
  12. package/custom/templates/memory/skeleton/00.Shared/glossary/.gitkeep +0 -0
  13. package/custom/templates/memory/skeleton/01.Lessons/ba/_global/.gitkeep +0 -0
  14. package/custom/templates/memory/skeleton/01.Lessons/dev/_global/.gitkeep +0 -0
  15. package/custom/templates/memory/skeleton/01.Lessons/pm/_global/.gitkeep +0 -0
  16. package/custom/templates/memory/skeleton/01.Lessons/qa/_global/.gitkeep +0 -0
  17. package/custom/templates/memory/skeleton/02.Instincts/approved/_global/.gitkeep +0 -0
  18. package/custom/templates/memory/skeleton/03.Retro/.gitkeep +0 -0
  19. package/custom/templates/memory/skeleton/MEMORY.md +7 -0
  20. package/custom/templates/memory/skeleton/_deprecated/.gitkeep +0 -0
  21. package/custom/templates/shared/create-spec-workflow.md +13 -1
  22. package/custom/templates/shared/create-testcase-workflow.md +12 -0
  23. package/custom/templates/shared/gate-workflow.md +7 -0
  24. package/docs/common/CHANGELOG.md +16 -0
  25. package/package.json +1 -1
  26. package/scripts/create-score-excel.js +1 -1
  27. package/scripts/hooks/session-start.js +105 -2
  28. package/scripts/init.js +24 -0
  29. package/scripts/memory-store.js +391 -0
  30. package/scripts/memory.js +176 -247
  31. package/scripts/update.js +12 -0
  32. package/scripts/use.js +2 -1
@@ -0,0 +1,391 @@
1
+ const fsp = require('fs/promises');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+
5
+ // Inlined from scripts/docs-repo.js (not required — that module pulls in fs-extra/chalk,
6
+ // which this file must not depend on, see the fs shim note below). AK-Docs/Shared-Docs are
7
+ // fixed-name folders directly under the workspace root — see that file for the full model.
8
+ function resolveDocsRepoPath(projectDir, repoName) {
9
+ return path.join(projectDir, repoName);
10
+ }
11
+
12
+ // Engine for the "99.Memory/" Project Brain — see docs/internal/Memory-Architecture-v1.0.md.
13
+ // Pure functions only (no chalk/console output, no CLI parsing) — scripts/memory.js and
14
+ // scripts/hooks/session-start.js are the callers.
15
+ //
16
+ // Deliberately NO fs-extra (or any third-party dependency): this file is copied standalone
17
+ // into deployed projects at .claude/lib/memory-store.js (scripts/init.js setupSuperpowersHook)
18
+ // so session-start.js can require() it — the target project has no access to the kit's own
19
+ // node_modules, only Node core modules resolve there (same constraint scripts/telemetry/*
20
+ // already follows). This tiny shim covers just the fs-extra methods the file below uses.
21
+ const fs = {
22
+ async pathExists(p) {
23
+ try { await fsp.access(p); return true; } catch { return false; }
24
+ },
25
+ async ensureDir(p) {
26
+ await fsp.mkdir(p, { recursive: true });
27
+ },
28
+ readFile(p, enc) {
29
+ return fsp.readFile(p, enc);
30
+ },
31
+ writeFile(p, data) {
32
+ return fsp.writeFile(p, data);
33
+ },
34
+ readdir(p, opts) {
35
+ return fsp.readdir(p, opts);
36
+ },
37
+ async readJson(p) {
38
+ return JSON.parse(await fsp.readFile(p, 'utf-8'));
39
+ },
40
+ async writeJson(p, obj) {
41
+ await fsp.writeFile(p, JSON.stringify(obj, null, 2));
42
+ },
43
+ async copy(src, dest) {
44
+ const stat = await fsp.stat(src);
45
+ if (stat.isDirectory()) {
46
+ await fsp.mkdir(dest, { recursive: true });
47
+ const entries = await fsp.readdir(src, { withFileTypes: true });
48
+ for (const entry of entries) {
49
+ await fs.copy(path.join(src, entry.name), path.join(dest, entry.name));
50
+ }
51
+ } else {
52
+ await fsp.mkdir(path.dirname(dest), { recursive: true });
53
+ await fsp.copyFile(src, dest);
54
+ }
55
+ },
56
+ };
57
+
58
+ const PKG_DIR = path.join(__dirname, '..');
59
+ const SKELETON_SRC = path.join(PKG_DIR, 'custom', 'templates', 'memory', 'skeleton');
60
+
61
+ // category → { type, flat } — flat categories don't get a functionId sub-folder (mục 3.2, 5.3):
62
+ // glossary/decisions are almost always global, everything else is scoped per functionId.
63
+ const CATEGORIES = {
64
+ '00.Shared/architecture': { type: 'fact', flat: false },
65
+ '00.Shared/domain': { type: 'fact', flat: false },
66
+ '00.Shared/glossary': { type: 'glossary', flat: true },
67
+ '00.Shared/decisions': { type: 'decision', flat: true },
68
+ '01.Lessons/dev': { type: 'lesson', flat: false },
69
+ '01.Lessons/qa': { type: 'lesson', flat: false },
70
+ '01.Lessons/ba': { type: 'lesson', flat: false },
71
+ '01.Lessons/pm': { type: 'lesson', flat: false },
72
+ '02.Instincts/approved': { type: 'instinct', flat: false },
73
+ };
74
+
75
+ function memoryDir(projectDir) {
76
+ return path.join(resolveDocsRepoPath(projectDir, 'AK-Docs'), '99.Memory');
77
+ }
78
+
79
+ function pendingDir(projectDir) {
80
+ return path.join(memoryDir(projectDir), '_pending');
81
+ }
82
+
83
+ // Commits ONLY the given path(s), locally, on whatever branch is currently checked
84
+ // out (normally `main`). Never pushes — main is a protected branch (doc §5.1.2), so
85
+ // pushing is left to a human even though the doc says this bootstrap step itself
86
+ // needs no separate review (there's no content yet to review, just empty structure).
87
+ // Swallows all errors (not a repo, nothing to commit, no git user configured) —
88
+ // non-fatal: worst case the skeleton is left as an uncommitted local change for a
89
+ // human to sort out, same as if this helper didn't exist.
90
+ function tryCommit(repoPath, relPaths, message) {
91
+ try {
92
+ execSync(`git add -- ${relPaths.map(p => JSON.stringify(p)).join(' ')}`, { cwd: repoPath, stdio: 'ignore' });
93
+ execSync(`git commit -m ${JSON.stringify(message)}`, { cwd: repoPath, stdio: 'ignore' });
94
+ return true;
95
+ } catch (_) {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ async function ensureSkeleton(projectDir) {
101
+ const akDocsPath = resolveDocsRepoPath(projectDir, 'AK-Docs');
102
+ if (!(await fs.pathExists(akDocsPath))) return { created: false, reason: 'AK-Docs not cloned yet' };
103
+ const dest = memoryDir(projectDir);
104
+ if (await fs.pathExists(dest)) return { created: false };
105
+ await fs.copy(SKELETON_SRC, dest);
106
+ const committed = tryCommit(akDocsPath, ['99.Memory'], 'memory: bootstrap 99.Memory/ skeleton');
107
+ return { created: true, committed };
108
+ }
109
+
110
+ async function ensureMemoryGitignored(projectDir) {
111
+ const akDocsPath = resolveDocsRepoPath(projectDir, 'AK-Docs');
112
+ if (!(await fs.pathExists(akDocsPath))) return { changed: false };
113
+
114
+ const gitignorePath = path.join(akDocsPath, '.gitignore');
115
+ const entry = '99.Memory/_pending/';
116
+ let content = (await fs.pathExists(gitignorePath)) ? await fs.readFile(gitignorePath, 'utf-8') : '';
117
+ const lines = content.split('\n').map(l => l.trim());
118
+ if (lines.includes(entry)) return { changed: false };
119
+
120
+ const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
121
+ content += `${separator}${entry}\n`;
122
+ await fs.writeFile(gitignorePath, content);
123
+ const committed = tryCommit(akDocsPath, ['.gitignore'], 'memory: gitignore 99.Memory/_pending/');
124
+ return { changed: true, committed };
125
+ }
126
+
127
+ function slugify(text) {
128
+ return String(text)
129
+ .toLowerCase()
130
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '') // strip accents (Vietnamese input)
131
+ .replace(/[^a-z0-9\s-]/g, '')
132
+ .trim()
133
+ .replace(/\s+/g, '-')
134
+ .replace(/-+/g, '-')
135
+ .substring(0, 60);
136
+ }
137
+
138
+ // functionId component of an id: doc example "F-003_Payment" -> "F003"
139
+ // (drop everything from the first underscore, then strip non-alnum separators)
140
+ function slugifyFunctionId(functionId) {
141
+ return String(functionId).split('_')[0].replace(/[^a-zA-Z0-9]/g, '');
142
+ }
143
+
144
+ function resolveCategory(category) {
145
+ const meta = CATEGORIES[category];
146
+ if (!meta) {
147
+ const known = Object.keys(CATEGORIES).join(', ');
148
+ throw new Error(`Unknown memory category "${category}". Known: ${known}`);
149
+ }
150
+ return meta;
151
+ }
152
+
153
+ /**
154
+ * Where a memory file lives relative to 99.Memory/, once approved
155
+ * (or, with pending=true, its local draft path under _pending/).
156
+ */
157
+ function relativePath(category, scope, slug, { pending = false } = {}) {
158
+ const meta = resolveCategory(category);
159
+ const scopeDir = meta.flat ? null : (!scope || scope === 'global' ? '_global' : scope);
160
+ const parts = pending ? ['_pending', category] : [category];
161
+ if (scopeDir) parts.push(scopeDir);
162
+ parts.push(`${slug}.md`);
163
+ return path.join(...parts);
164
+ }
165
+
166
+ function buildId(category, scope, slug) {
167
+ resolveCategory(category); // validates category, unused otherwise — id only depends on scope
168
+ if (!scope || scope === 'global') return `mem-global-${slug}`;
169
+ return `mem-${slugifyFunctionId(scope)}-${slug}`;
170
+ }
171
+
172
+ // ── Minimal frontmatter parse/serialize ──────────────────────────
173
+ // The schema is small and fixed (doc §5.3) — a hand-rolled parser avoids adding a
174
+ // YAML dependency the kit doesn't otherwise have (checked package.json: none present).
175
+
176
+ function parseScalarOrArray(raw) {
177
+ const value = raw.trim();
178
+ if (value === '') return '';
179
+ if (value.startsWith('[') && value.endsWith(']')) {
180
+ const inner = value.slice(1, -1).trim();
181
+ return inner === '' ? [] : inner.split(',').map(s => s.trim());
182
+ }
183
+ if (value === 'true') return true;
184
+ if (value === 'false') return false;
185
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
186
+ return value;
187
+ }
188
+
189
+ function parseFrontmatter(text) {
190
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
191
+ if (!match) return { data: {}, content: text };
192
+ const [, rawFrontmatter, content] = match;
193
+ const data = {};
194
+ for (const line of rawFrontmatter.split('\n')) {
195
+ const m = /^([a-zA-Z_]+):\s?(.*)$/.exec(line);
196
+ if (!m) continue; // skip comment lines and continuations
197
+ const [, key, raw] = m;
198
+ data[key] = parseScalarOrArray(raw);
199
+ }
200
+ return { data, content: content.replace(/^\n/, '') };
201
+ }
202
+
203
+ function serializeValue(value) {
204
+ if (Array.isArray(value)) return `[${value.join(', ')}]`;
205
+ if (value === null || value === undefined) return '';
206
+ return String(value);
207
+ }
208
+
209
+ const FRONTMATTER_FIELDS = [
210
+ 'id', 'type', 'workflows', 'tags', 'scope', 'confidence', 'source',
211
+ 'status', 'hits', 'created', 'reviewed_by', 'refs', 'verified_commit', 'stale',
212
+ ];
213
+
214
+ function serializeFrontmatter(data, content) {
215
+ const lines = ['---'];
216
+ for (const key of FRONTMATTER_FIELDS) {
217
+ lines.push(`${key}: ${serializeValue(data[key])}`);
218
+ }
219
+ lines.push('---', '', content.trim(), '');
220
+ return lines.join('\n');
221
+ }
222
+
223
+ /**
224
+ * Create a local draft under _pending/. Returns { conflict: true, existingPath } if a
225
+ * memory with the same id already exists (approved or pending) — the dedup signal from
226
+ * doc §4 Luồng 2 (trùng slug trong cùng folder → đề xuất cập nhật bản cũ thay vì tạo mới).
227
+ */
228
+ async function createDraft(projectDir, {
229
+ category, functionId, slug: rawSlug, tags = [], workflows = ['all'],
230
+ scope, content, source = '', confidence = 0.5,
231
+ }) {
232
+ const meta = resolveCategory(category);
233
+ const resolvedScope = meta.flat ? (scope || 'global') : (functionId || scope || 'global');
234
+ const slug = slugify(rawSlug);
235
+ if (!slug) throw new Error('slug is required (and must contain at least one alphanumeric character)');
236
+
237
+ const id = buildId(category, resolvedScope, slug);
238
+ const approvedRel = relativePath(category, resolvedScope, slug);
239
+ const pendingRel = relativePath(category, resolvedScope, slug, { pending: true });
240
+ const base = memoryDir(projectDir);
241
+ const approvedAbs = path.join(base, approvedRel);
242
+ const pendingAbs = path.join(base, pendingRel);
243
+
244
+ if (await fs.pathExists(approvedAbs)) return { conflict: true, existingPath: approvedRel };
245
+ if (await fs.pathExists(pendingAbs)) return { conflict: true, existingPath: pendingRel };
246
+
247
+ const data = {
248
+ id, type: meta.type, workflows, tags: tags.map(t => slugify(t)),
249
+ scope: resolvedScope, confidence, source, status: 'pending', hits: 0,
250
+ created: new Date().toISOString().slice(0, 10),
251
+ reviewed_by: '', refs: [], verified_commit: '', stale: false,
252
+ };
253
+
254
+ await fs.ensureDir(path.dirname(pendingAbs));
255
+ await fs.writeFile(pendingAbs, serializeFrontmatter(data, content || ''));
256
+
257
+ return { conflict: false, id, path: pendingRel, absPath: pendingAbs, approvedRelPath: approvedRel };
258
+ }
259
+
260
+ async function walkMarkdownFiles(dir) {
261
+ const results = [];
262
+ if (!(await fs.pathExists(dir))) return results;
263
+ const entries = await fs.readdir(dir, { withFileTypes: true });
264
+ for (const entry of entries) {
265
+ const full = path.join(dir, entry.name);
266
+ if (entry.isDirectory()) {
267
+ results.push(...await walkMarkdownFiles(full));
268
+ } else if (entry.name.endsWith('.md')) {
269
+ results.push(full);
270
+ }
271
+ }
272
+ return results;
273
+ }
274
+
275
+ async function readMemoryFile(absPath, base) {
276
+ const text = await fs.readFile(absPath, 'utf-8');
277
+ const { data, content } = parseFrontmatter(text);
278
+ return { ...data, content, absPath, relPath: path.relative(base, absPath) };
279
+ }
280
+
281
+ async function listDrafts(projectDir) {
282
+ const base = memoryDir(projectDir);
283
+ const files = await walkMarkdownFiles(pendingDir(projectDir));
284
+ return Promise.all(files.map(f => readMemoryFile(f, base)));
285
+ }
286
+
287
+ async function listApproved(projectDir) {
288
+ const base = memoryDir(projectDir);
289
+ const memories = [];
290
+ for (const category of Object.keys(CATEGORIES)) {
291
+ const dir = path.join(base, category);
292
+ const files = await walkMarkdownFiles(dir);
293
+ for (const f of files) {
294
+ const m = await readMemoryFile(f, base);
295
+ if (m.status === 'approved') memories.push(m);
296
+ }
297
+ }
298
+ return memories;
299
+ }
300
+
301
+ // ── Scoring (doc §5.2) ────────────────────────────────────────────
302
+
303
+ function scoreMemory(memory, { functionId, workflow, tags = [] } = {}) {
304
+ let score = 0;
305
+ if (functionId && memory.scope === functionId) score += 3;
306
+
307
+ const memWorkflows = Array.isArray(memory.workflows) ? memory.workflows : [];
308
+ if (workflow && (memWorkflows.includes(workflow) || memWorkflows.includes('all'))) score += 2;
309
+
310
+ const memTags = Array.isArray(memory.tags) ? memory.tags : [];
311
+ const queryTags = tags.map(t => slugify(t));
312
+ const overlap = memTags.filter(t => queryTags.includes(t)).length;
313
+ score += 2 * overlap;
314
+
315
+ score += Number(memory.confidence) || 0;
316
+
317
+ const ageDays = memory.created ? (Date.now() - new Date(memory.created).getTime()) / 86400000 : 0;
318
+ if (ageDays > 90 && (Number(memory.hits) || 0) === 0) score -= 1;
319
+
320
+ if (memory.status === 'pending') score -= 0.5;
321
+
322
+ if (memory.type === 'lesson' && workflow === 'coding') score += 1;
323
+
324
+ return score;
325
+ }
326
+
327
+ function estimateTokens(text) {
328
+ return Math.ceil(String(text || '').length / 4);
329
+ }
330
+
331
+ /**
332
+ * Layer 2 relevant-set loader — approved memories team-wide + this machine's own
333
+ * pending drafts (doc §3.2: pending is recall-able only on the creator's machine).
334
+ */
335
+ async function loadRelevant(projectDir, { functionId, workflow, tags = [], topN = 10, budgetTokens = 2000 } = {}) {
336
+ const [approved, drafts] = await Promise.all([listApproved(projectDir), listDrafts(projectDir)]);
337
+ const all = [...approved, ...drafts];
338
+
339
+ const scored = all
340
+ .map(m => ({ ...m, score: scoreMemory(m, { functionId, workflow, tags }) }))
341
+ .sort((a, b) => b.score - a.score);
342
+
343
+ const selected = [];
344
+ let usedTokens = 0;
345
+ for (const m of scored) {
346
+ if (selected.length >= topN) break;
347
+ const cost = estimateTokens(m.content);
348
+ if (usedTokens + cost > budgetTokens && selected.length > 0) break;
349
+ selected.push(m);
350
+ usedTokens += cost;
351
+ }
352
+ return selected;
353
+ }
354
+
355
+ async function readIndex(projectDir) {
356
+ const indexPath = path.join(memoryDir(projectDir), 'MEMORY.md');
357
+ if (!(await fs.pathExists(indexPath))) return '';
358
+ return fs.readFile(indexPath, 'utf-8');
359
+ }
360
+
361
+ // ── Local hit-count ledger ────────────────────────────────────────
362
+ // Local-only, NOT the analytics telemetry buffer (scripts/telemetry/record.js is an
363
+ // event stream for a remote dashboard, wrong shape for per-memory counters) — read
364
+ // later by `ak memory consolidate` (Phase 2, not built yet) to update `hits:` via one MR.
365
+
366
+ function hitsLedgerPath(projectDir) {
367
+ return path.join(projectDir, '.aiflow', 'memory-hits.json');
368
+ }
369
+
370
+ // Batches all increments into a single read-modify-write — calling recordHit once per
371
+ // id concurrently (e.g. via Promise.all over a relevant-set) would race: every call reads
372
+ // the same starting ledger, so all but the last writer's increment gets silently lost.
373
+ async function recordHits(projectDir, memIds) {
374
+ if (!memIds || memIds.length === 0) return;
375
+ const ledgerPath = hitsLedgerPath(projectDir);
376
+ const ledger = (await fs.pathExists(ledgerPath)) ? await fs.readJson(ledgerPath).catch(() => ({})) : {};
377
+ for (const memId of memIds) ledger[memId] = (ledger[memId] || 0) + 1;
378
+ await fs.ensureDir(path.dirname(ledgerPath));
379
+ await fs.writeJson(ledgerPath, ledger, { spaces: 2 });
380
+ }
381
+
382
+ module.exports = {
383
+ CATEGORIES,
384
+ memoryDir, pendingDir,
385
+ ensureSkeleton, ensureMemoryGitignored,
386
+ slugify, slugifyFunctionId, buildId, relativePath, resolveCategory,
387
+ parseFrontmatter, serializeFrontmatter,
388
+ createDraft, listDrafts, listApproved,
389
+ scoreMemory, loadRelevant, estimateTokens,
390
+ readIndex, recordHits,
391
+ };