@kienntpixon/dieu-phoi 0.5.3 → 0.6.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.
@@ -0,0 +1,1002 @@
1
+ 'use strict';
2
+ // `dieu-phoi knowledge ...` — per-project knowledge store (core 0.6.0).
3
+ // s1: project identity (path, rekey). s2: the store itself — index.json + sections/<id>.md + history/, read with
4
+ // get/list, written only by `merge --run <id>` (lock, tmp+rename, history, 40-line cap, sha/rev conflicts), and
5
+ // cross-repo links (related: child|parent) for a parent repo whose sessions work in its child repos.
6
+ // s3: freshness per section (core.computeFreshness in hook.cjs) on get/list/status, and the project_knowledge line of the start block.
7
+ //
8
+ // Store layout (<projects>/<name>-<sha8(key)>/):
9
+ // index.json { schema, key, name, related: [{key,name,path,rel}], sections: [{id,title,tags,paths,sha,rev,updated,lines}] }
10
+ // sections/<id>.md body only (<= 40 lines)
11
+ // history/<id>/r<rev>-<sha8>-<stamp>.md the replaced body, first line a JSON comment with its index entry
12
+ // meta.json { keys: [...] } (aliases written by rekey), knowledge.lock while a writer holds the store
13
+ // cache/freshness.json freshness per HEAD (key: HEAD + index.json mtime/size, <= 12 HEADs), rebuildable; git errors not cached
14
+ //
15
+ // Delta (written by agents into a run dir as knowledge-delta*.md, merged at gate 3):
16
+ // repo: <path of the repo/worktree the delta was checked against> (header, before the first `## `)
17
+ // head: <sha> (optional; else HEAD read from repo:)
18
+ // ## +<id> new section ## <id> replace an existing one
19
+ // title: / tags: a, b / paths: glob, glob / base: <sha>#<rev> (meta lines right under the heading)
20
+ // body...
21
+ // merge is idempotent: it records the sha256 of every delta file it applied (meta.json `applied`) and skips that file
22
+ // next time; a file whose text changed hashes differently and counts as new. A "+id" / stale-base section whose content
23
+ // equals the current body or an older body kept in history/ counts as already merged ("same"), never a conflict.
24
+ // A replacement must carry `base:` = the `base:` line `knowledge get` printed; if the section moved on since (another
25
+ // session merged), the section is reported as a conflict and left untouched.
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const cp = require('child_process');
30
+ const crypto = require('crypto');
31
+ const core = require('../hook.cjs');
32
+ const { repoOf } = require('./args.cjs');
33
+
34
+ const KNOWLEDGE_HELP = `Cách dùng: dieu-phoi knowledge <lệnh> [--repo <đường dẫn>] [--json]
35
+
36
+ path [--json] in thư mục kho knowledge của repo (cwd hoặc --repo; worktree/clone cùng repo ra cùng thư mục)
37
+ --json: khoá, loại khoá (remote|git), tên, HEAD đọc từ file, alias, gợi ý rekey
38
+ rekey [--from <tên thư mục kho>]
39
+ repo vừa có remote nên khoá đổi: ghi khoá mới thành alias của kho cũ (không đổi tên/di chuyển
40
+ thư mục). Không --from thì tự lấy kho của khoá git-common-dir nếu có
41
+ list [--follow] [--dirty] [--json]
42
+ các mục của kho (id, tiêu đề, số dòng, sha#rev, tags, paths) + repo liên quan (related),
43
+ mỗi mục gắn ok / STALE / ? (độ tươi so với HEAD của worktree đang đứng)
44
+ status [--follow] [--dirty] [--no-cache] [--json]
45
+ chỉ độ tươi: mục nào stale (file nào đổi sau sha của mục), mục nào không xác định (sha không
46
+ có trong repo này…). Một tiến trình git (diff-tree --no-renames, pathspec = paths của các mục),
47
+ cache theo HEAD đọc từ file. --dirty: thêm file chưa commit (git status -uno, tối đa 2 s,
48
+ quá thì "chưa kiểm"). --no-cache: bỏ qua cache
49
+ get <id|từ khoá>... [--path <file>[,<file>]] [--follow] [--json]
50
+ in đúng các mục cần: id khớp đúng, hoặc mọi từ khoá đều có trong id/tag/tiêu đề; --path: các
51
+ mục có glob paths khớp file (đường dẫn trong repo); cả hai = giao. Mỗi mục in dòng
52
+ "base: <sha>#<rev>" và độ tươi (STALE + file đã đổi / không xác định) — chép base vào delta
53
+ khi sửa mục đó; --dirty như status. --follow: tra thêm kho cha (upstream).
54
+ "children"/"upstream" chưa có trong kho thì được dựng từ related. Không mục nào khớp → exit 1
55
+ merge --run <id|thư mục run> [--task <id>[,<id>] [--untagged]] [--dry-run] [--json]
56
+ gộp mọi knowledge-delta*.md của run (--task: chỉ knowledge-delta-<task_id>[-r<n>].md của các
57
+ task đó, --untagged: thêm file không mang task_id; verdict accept tự gọi bước này) vào kho của repo ghi ở header "repo:" (không có thì
58
+ --repo/cwd): khoá knowledge.lock, ghi tmp+rename, bản cũ vào history/, sha = HEAD của repo đó.
59
+ "## +id" thêm mục (bắt buộc paths:), "## id" thay mục (bắt buộc base: <sha>#<rev>); base cũ
60
+ → xung đột, không ghi đè; mục > 40 dòng bị từ chối. Có xung đột/từ chối → exit 2.
61
+ Idempotent: file delta đã gộp (hash trong meta.json) và mục trùng một rev cũ trong history/ → "không đổi"
62
+ bootstrap [--children <mẫu tên>[,…]] [--json]
63
+ chỉ đọc: liệt kê nguồn để seed kho (CLAUDE.md/README/AGENTS.md của repo, context-r/contract/
64
+ handoff cũ trong runs có nhắc repo), mục đã có, mục nên có và mẫu delta. --children: repo anh em
65
+ khớp mẫu tên (vd. pff-*,fff-*) để ghi mục children — không tạo kho cho chúng
66
+ link <đường dẫn repo khác> --rel child|parent [--remove]
67
+ ghi liên kết cha–con vào related của cả hai kho (repo kia là con/cha của repo này)
68
+ `;
69
+
70
+ const MAX_LINES = 40;
71
+ const INDEX_SCHEMA = 1;
72
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
73
+ const SYNTH_IDS = new Set(['children', 'upstream']);
74
+ const LOCK_TRIES = 200; // x 50 ms = 10 s
75
+
76
+ function sleepSync(ms) {
77
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch (_) { /* busy-wait fallback not needed */ }
78
+ }
79
+
80
+ /** Run fn under a file lock (wx, stale after 5 min via core.acquireLock); { locked: true } when it stays busy. */
81
+ function withLock(file, fn, tries) {
82
+ let got = false;
83
+ const n = tries || 60;
84
+ for (let i = 0; i < n && !got; i++) {
85
+ got = core.acquireLock(file);
86
+ if (!got) sleepSync(50);
87
+ }
88
+ if (!got) return { locked: true };
89
+ try { return { value: fn() }; } finally { try { fs.unlinkSync(file); } catch (_) { /* gone */ } }
90
+ }
91
+
92
+ function isDir(p) {
93
+ try { return fs.statSync(p).isDirectory(); } catch (_) { return false; }
94
+ }
95
+
96
+ const slash = (p) => String(p).replace(/\\/g, '/');
97
+
98
+ function infoOf(opts, fallback) {
99
+ const start = repoOf(opts);
100
+ const info = core.resolveProject(start, { fallback });
101
+ if (!info) return { start, info: null };
102
+ return { start, info };
103
+ }
104
+
105
+ function notRepo(start) {
106
+ return { code: 1, out: `Không phải repo git: ${start} (dùng --repo <đường dẫn trong repo>).` };
107
+ }
108
+
109
+ function cmdPath(opts) {
110
+ const { start, info } = infoOf(opts, true);
111
+ if (!info) return notRepo(start);
112
+ const head = core.readHead(start);
113
+ const hint = info.rekey_from
114
+ ? `Repo đã có remote nhưng kho cũ "${info.rekey_from}" (khoá git-common-dir) chưa được nối: chạy \`dieu-phoi knowledge rekey\`.`
115
+ : null;
116
+ if (opts.json) {
117
+ return { code: 0, out: JSON.stringify(Object.assign({ path: start }, info, { head, rekey_hint: hint }), null, 2) };
118
+ }
119
+ if (hint) process.stderr.write(`${hint}\n`);
120
+ return { code: 0, out: info.dir };
121
+ }
122
+
123
+ function cmdRekey(opts) {
124
+ const { start, info } = infoOf(opts, true);
125
+ if (!info) return notRepo(start);
126
+ const store = core.projectsDir();
127
+ const from = typeof opts.from === 'string' ? opts.from : info.rekey_from;
128
+ if (opts.from !== undefined && typeof opts.from !== 'string') return { code: 1, out: '--from cần tên thư mục kho.' };
129
+ if (!from) {
130
+ return { code: 0, out: info.exists ? `Kho đã có (${info.dir}) — không cần rekey.` : 'Không có kho cũ nào để nối (thử --from <tên thư mục kho>).' };
131
+ }
132
+ if (!core.isStoreDirName(from) || !fs.statSync(path.join(store, from), { throwIfNoEntry: false })) {
133
+ return { code: 1, out: `Không thấy kho "${from}" trong ${store}.` };
134
+ }
135
+ if (info.exists && info.dir_name !== from) {
136
+ return { code: 1, out: `Khoá mới đã có kho riêng (${info.dir_name}) — hai kho tồn tại, rekey không tự gộp. Gộp tay hoặc xoá một bên.` };
137
+ }
138
+ if (info.exists && info.dir_name === from && info.alias) {
139
+ return { code: 0, out: `Đã nối từ trước: ${info.key} -> ${from}.` };
140
+ }
141
+ const r = withLock(path.join(store, 'aliases.lock'), () => {
142
+ const aliases = core.loadAliases();
143
+ aliases[info.key] = from;
144
+ core.writeFileAtomic(core.aliasesFile(), `${JSON.stringify(aliases, null, 2)}\n`);
145
+ const metaFile = path.join(store, from, 'meta.json');
146
+ const meta = core.readJsonFile(metaFile, {});
147
+ const keys = Array.isArray(meta.keys) ? meta.keys.slice() : [];
148
+ if (!keys.includes(info.key)) keys.push(info.key);
149
+ core.writeFileAtomic(metaFile, `${JSON.stringify(Object.assign({}, meta, { keys }), null, 2)}\n`);
150
+ });
151
+ if (r.locked) return { code: 1, out: 'aliases.json đang bị tiến trình khác khoá — thử lại.' };
152
+ return { code: 0, out: `Đã nối ${info.key} -> ${path.join(store, from).replace(/\\/g, '/')}` };
153
+ }
154
+
155
+ // ------------------------------------------------------------------ store: read
156
+
157
+ /**
158
+ * index.json of a store dir: { exists, idx, error }. A file that exists but does not parse is an error (a writer must
159
+ * never replace it with an empty index and lose every section).
160
+ */
161
+ function readIndex(dir) {
162
+ const file = path.join(dir, 'index.json');
163
+ let txt;
164
+ try { txt = fs.readFileSync(file, 'utf8'); } catch (e) {
165
+ if (e.code === 'ENOENT') return { exists: false, idx: null };
166
+ return { exists: true, idx: null, error: `không đọc được ${slash(file)}: ${e.message}` };
167
+ }
168
+ let idx;
169
+ try { idx = JSON.parse(txt.replace(/^/, '')); } catch (e) {
170
+ return { exists: true, idx: null, error: `index.json hỏng (${slash(file)}): ${e.message}` };
171
+ }
172
+ if (!idx || typeof idx !== 'object' || Array.isArray(idx)) return { exists: true, idx: null, error: `index.json không phải object: ${slash(file)}` };
173
+ if (!Array.isArray(idx.sections)) idx.sections = [];
174
+ if (!Array.isArray(idx.related)) idx.related = [];
175
+ return { exists: true, idx };
176
+ }
177
+
178
+ function newIndex(info) {
179
+ return { schema: INDEX_SCHEMA, key: info.key, name: info.name, related: [], sections: [] };
180
+ }
181
+
182
+ function sectionFile(dir, id) {
183
+ return path.join(dir, 'sections', `${id}.md`);
184
+ }
185
+
186
+ function readBody(dir, id) {
187
+ try { return fs.readFileSync(sectionFile(dir, id), 'utf8').replace(/\r\n/g, '\n').replace(/\n+$/, ''); } catch (_) { return null; }
188
+ }
189
+
190
+ /** Body line count by the same rule everywhere (merge's <= 40 limit, list, get): CRLF, CR or LF break a line; trailing blank lines do not count. */
191
+ const LINE_BREAK = /\r\n|\r|\n/;
192
+ function countLines(text) {
193
+ const s = String(text == null ? '' : text).replace(/(\r\n|\r|\n)+$/, '');
194
+ return s ? s.split(LINE_BREAK).length : 0;
195
+ }
196
+
197
+ /** True body lines of a stored section: counted from sections/<id>.md (what get prints), the index value only as a fallback. */
198
+ function sectionLines(dir, s) {
199
+ const body = dir ? readBody(dir, s.id) : null;
200
+ return body == null ? (s.lines || 0) : countLines(body);
201
+ }
202
+
203
+ /** Store dir of a related entry: its own key dir, an alias of its key, or whatever its path resolves to now. */
204
+ function storeDirOf(entry) {
205
+ if (!entry || typeof entry.key !== 'string') return null;
206
+ const store = core.projectsDir();
207
+ if (typeof entry.name === 'string') {
208
+ const own = core.keyDirName(entry.key, entry.name);
209
+ if (core.isStoreDirName(own) && isDir(path.join(store, own))) return path.join(store, own);
210
+ }
211
+ const a = core.loadAliases()[entry.key];
212
+ if (core.isStoreDirName(a) && isDir(path.join(store, a))) return path.join(store, a);
213
+ if (typeof entry.path === 'string' && isDir(entry.path)) {
214
+ const r = core.resolveProject(entry.path);
215
+ if (r && r.key === entry.key) return r.dir;
216
+ }
217
+ return null;
218
+ }
219
+
220
+ // globMatch and the freshness check live in hook.cjs (s3): the installed hook is one self-contained file.
221
+ const { globMatch } = core;
222
+
223
+ /** `--path` value -> repo-relative path (absolute and ./ ../ paths resolve against `start`); null when outside the repo. */
224
+ function relInRepo(info, start, f) {
225
+ const p = slash(core.nativePath(String(f).trim()));
226
+ if (!(path.isAbsolute(p) || p.startsWith('./') || p.startsWith('../'))) return p.replace(/^\/+/, '');
227
+ if (!info.root) return null;
228
+ const rel = path.relative(path.resolve(info.root), path.resolve(start, p));
229
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return rel === '' ? '' : null;
230
+ return slash(rel);
231
+ }
232
+
233
+ // ------------------------------------------------------------------ get / list
234
+
235
+ function tokensOf(s) {
236
+ return String(s).normalize('NFC').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
237
+ }
238
+
239
+ function termMatches(sec, term) {
240
+ const t = String(term).toLowerCase().trim();
241
+ if (!t) return false;
242
+ if (sec.id === t) return true;
243
+ // Word-prefix match: "ui" finds "Bẫy UI" / "ui-pitfalls" but not "build".
244
+ const words = tokensOf([sec.id, String(sec.title || ''), ...(sec.tags || []).map(String)].join(' '));
245
+ const toks = tokensOf(t);
246
+ return toks.length > 0 && toks.every((tok) => words.some((w) => w.startsWith(tok)));
247
+ }
248
+
249
+ /** Matching index entries (index order): ids/terms OR'ed, then AND'ed with --path files when both are given. */
250
+ function selectSections(idx, terms, rels) {
251
+ return idx.sections.filter((s) => {
252
+ const byTerm = terms.length ? terms.some((t) => termMatches(s, t)) : true;
253
+ const byPath = rels.length ? rels.some((r) => (s.paths || []).some((g) => globMatch(g, r))) : true;
254
+ return byTerm && byPath;
255
+ });
256
+ }
257
+
258
+ function baseOf(sec) {
259
+ return `${sec.sha || 'none'}#${sec.rev || 0}`;
260
+ }
261
+
262
+ function shortSha(s) {
263
+ return s ? String(s).slice(0, 7) : '-';
264
+ }
265
+
266
+ function sectionOut(repo, sec, body, fr) {
267
+ const out = {
268
+ repo: repo.name, repo_rel: repo.rel || 'self', id: sec.id, title: sec.title, tags: sec.tags || [], paths: sec.paths || [],
269
+ sha: sec.sha || null, rev: sec.rev || 0, updated: sec.updated || null, lines: body ? countLines(body) : (sec.lines || 0), base: baseOf(sec), body,
270
+ };
271
+ const f = fr && fr.sections && fr.sections[sec.id];
272
+ if (f) {
273
+ out.fresh = f.state;
274
+ if (f.files) { out.stale = f.files; out.stale_count = f.n_files || f.files.length; }
275
+ if (f.why) out.fresh_why = f.why;
276
+ if (f.dirty) out.dirty = f.dirty;
277
+ }
278
+ return out;
279
+ }
280
+
281
+ /**
282
+ * Freshness of a store for the worktree around `start` (s3): at most one git process (diff-tree --no-renames over the
283
+ * sections' globs), cached by HEAD read from files; --dirty adds uncommitted files (git status -uno, 2 s timeout).
284
+ */
285
+ function freshnessOf(storeDir, idx, start, opts) {
286
+ const g = start ? core.findGit(start) : null;
287
+ if (!g) {
288
+ const sections = {};
289
+ for (const s of idx.sections) sections[s.id] = { state: 'unknown', why: 'không thấy repo (đường dẫn đã mất?)' };
290
+ return { head: null, cached: false, sections, counts: core.freshCounts(sections) };
291
+ }
292
+ let head = core.readHead(g);
293
+ if (head && head.unsupported) { const sha = headShaOf(start); if (sha) head = { sha, branch: null }; }
294
+ const fr = core.computeFreshness({ dir: storeDir, idx, root: g.root, head, cache: !opts['no-cache'] });
295
+ if (opts.dirty) fr.dirty_check = core.addDirty(fr, idx, g.root);
296
+ return fr;
297
+ }
298
+
299
+ function freshLine(s) {
300
+ if (s.fresh === 'stale') return `STALE — ${s.stale_count} file đổi sau sha của mục: ${core.shortFileList(s.stale, 5)} (kiểm lại trước khi dùng)`;
301
+ if (s.fresh === 'unknown') return `độ tươi: không xác định — ${s.fresh_why}`;
302
+ if (s.fresh === 'fresh') return 'độ tươi: tươi';
303
+ return null;
304
+ }
305
+
306
+ function synthSection(id, idx) {
307
+ const want = id === 'children' ? 'child' : 'parent';
308
+ const rel = idx.related.filter((r) => r.rel === want);
309
+ if (!rel.length) return null;
310
+ const body = rel.map((r) => `- ${r.name} — ${r.path || '?'} (khoá ${r.key})`).join('\n')
311
+ + (want === 'parent' ? '\n\nThứ chép từ repo cha (toolset, kit) tra ở kho cha: `dieu-phoi knowledge get <id> --follow`, hoặc `--repo <đường dẫn cha>`.' : '');
312
+ return { id, title: want === 'child' ? 'Repo con' : 'Repo cha (upstream)', tags: ['related'], paths: [], sha: null, rev: 0, updated: null, lines: body.split('\n').length, synthesized: true, body };
313
+ }
314
+
315
+ function renderSection(s) {
316
+ const meta = [`sha ${shortSha(s.sha)}`, `rev ${s.rev}`, s.updated ? String(s.updated).slice(0, 10) : null,
317
+ s.tags.length ? `tags: ${s.tags.join(', ')}` : null, s.paths.length ? `paths: ${s.paths.join(', ')}` : null].filter(Boolean);
318
+ const head = [`## ${s.id} — ${s.title || s.id}`, meta.join(' · ')];
319
+ if (s.synthesized) head.push('(dựng từ related — chưa có mục lưu trong kho)');
320
+ else {
321
+ head.push(`base: ${s.base}`);
322
+ const fl = freshLine(s);
323
+ if (fl) head.push(fl);
324
+ if (s.dirty && s.dirty.length) head.push(`chưa commit: ${core.shortFileList(s.dirty, 5)}`);
325
+ }
326
+ return `${head.join('\n')}\n\n${s.body}`;
327
+ }
328
+
329
+ function repoLine(r) {
330
+ const tag = r.rel && r.rel !== 'self' ? ` (${r.rel === 'parent' ? 'cha' : r.rel})` : '';
331
+ return `repo: ${r.name}${tag} — ${slash(r.dir)}`;
332
+ }
333
+
334
+ function parentsOf(idx) {
335
+ const out = [];
336
+ for (const r of idx.related.filter((x) => x.rel === 'parent')) {
337
+ const dir = storeDirOf(r);
338
+ if (!dir) { out.push({ name: r.name, key: r.key, dir: null, path: r.path, rel: 'parent', idx: null }); continue; }
339
+ const ri = readIndex(dir);
340
+ out.push({ name: r.name, key: r.key, dir, path: r.path, rel: 'parent', idx: ri.idx, error: ri.error });
341
+ }
342
+ return out;
343
+ }
344
+
345
+ function cmdGet(opts) {
346
+ const { start, info } = infoOf(opts, false);
347
+ if (!info) return notRepo(start);
348
+ const terms = opts._.map(String).filter((t) => t.trim());
349
+ let rels = [];
350
+ if (opts.path !== undefined) {
351
+ if (typeof opts.path !== 'string' || !opts.path.trim()) return { code: 1, out: '--path cần đường dẫn file.' };
352
+ for (const f of opts.path.split(',').map((x) => x.trim()).filter(Boolean)) {
353
+ const r = relInRepo(info, start, f);
354
+ if (r == null) return { code: 1, out: `--path nằm ngoài repo: ${f}` };
355
+ rels.push(r);
356
+ }
357
+ }
358
+ if (!terms.length && !rels.length) return { code: 1, out: `Cần <id|từ khoá> hoặc --path.\n\n${KNOWLEDGE_HELP.trimEnd()}` };
359
+ const ri = readIndex(info.dir);
360
+ if (ri.error) return { code: 1, out: ri.error };
361
+ if (!ri.exists) return { code: 1, out: `Kho chưa có mục nào: ${info.dir} (chưa merge delta nào cho repo này).` };
362
+ const self = { name: info.name, key: info.key, dir: info.dir, rel: 'self' };
363
+ const results = [];
364
+ const hit = new Set();
365
+ const picked = selectSections(ri.idx, terms, rels);
366
+ const fr = picked.length ? freshnessOf(info.dir, ri.idx, start, opts) : null;
367
+ for (const sec of picked) {
368
+ results.push(sectionOut(self, sec, readBody(info.dir, sec.id) || '', fr));
369
+ terms.forEach((t) => { if (termMatches(sec, t)) hit.add(t); });
370
+ }
371
+ if (!rels.length) {
372
+ for (const t of terms) {
373
+ const id = t.toLowerCase().trim();
374
+ if (SYNTH_IDS.has(id) && !ri.idx.sections.some((s) => s.id === id)) {
375
+ const s = synthSection(id, ri.idx);
376
+ if (s) { results.push(Object.assign(sectionOut(self, s, s.body), { synthesized: true })); hit.add(t); }
377
+ }
378
+ }
379
+ }
380
+ const followed = [];
381
+ if (opts.follow && terms.length) {
382
+ for (const p of parentsOf(ri.idx)) {
383
+ followed.push({ name: p.name, dir: p.dir, error: p.error || (p.dir ? null : 'không thấy kho') });
384
+ if (!p.idx) continue;
385
+ const pPicked = selectSections(p.idx, terms, []);
386
+ // The parent's freshness is checked against the parent's own checkout (related.path), refreshed on first get.
387
+ const pfr = pPicked.length ? freshnessOf(p.dir, p.idx, p.path && isDir(core.nativePath(p.path)) ? core.nativePath(p.path) : null, opts) : null;
388
+ for (const sec of pPicked) {
389
+ results.push(sectionOut(p, sec, readBody(p.dir, sec.id) || '', pfr));
390
+ terms.forEach((t) => { if (termMatches(sec, t)) hit.add(t); });
391
+ }
392
+ }
393
+ }
394
+ const missing = terms.filter((t) => !hit.has(t));
395
+ const code = results.length ? 0 : 1;
396
+ if (opts.json) {
397
+ const freshness = fr ? { head: fr.head, cached: fr.cached, counts: fr.counts, error: fr.error || null, dirty_check: fr.dirty_check || null } : null;
398
+ return { code, out: JSON.stringify({ repo: { key: info.key, name: info.name, dir: info.dir }, freshness, followed, sections: results, missing }, null, 2) };
399
+ }
400
+ if (!results.length) {
401
+ const ids = ri.idx.sections.map((s) => s.id);
402
+ return { code, out: `Không có mục khớp (${[...terms, ...rels.map((r) => `--path ${r}`)].join(', ')}) trong ${info.dir}.${ids.length ? ` Có: ${ids.join(', ')}.` : ''}` };
403
+ }
404
+ const parts = [];
405
+ let lastRepo = null;
406
+ for (const s of results) {
407
+ const r = s.repo_rel === 'self' ? self : followed.find((f) => f.name === s.repo) || { name: s.repo, dir: '?' };
408
+ const tag = `${s.repo_rel}:${s.repo}`;
409
+ if (tag !== lastRepo) { parts.push(repoLine(Object.assign({}, r, { rel: s.repo_rel }))); lastRepo = tag; }
410
+ parts.push(renderSection(s));
411
+ }
412
+ if (missing.length) parts.push(`(không khớp: ${missing.join(', ')})`);
413
+ for (const f of followed.filter((x) => x.error)) parts.push(`(kho cha ${f.name}: ${f.error})`);
414
+ return { code, out: parts.join('\n\n') };
415
+ }
416
+
417
+ const FRESH_MARK = { fresh: 'ok ', stale: 'STALE', unknown: '? ' };
418
+
419
+ function listLines(idx, fr, dir) {
420
+ return idx.sections.map((s) => {
421
+ const f = fr && fr.sections && fr.sections[s.id];
422
+ const mark = f ? `${FRESH_MARK[f.state] || '? '} ` : '';
423
+ return ` ${mark}${s.id.padEnd(18)} ${String(sectionLines(dir, s)).padStart(3)}d ${shortSha(s.sha)}#${s.rev || 0} ${String(s.updated || '').slice(0, 10)} ${s.title || ''}`
424
+ + `${(s.tags || []).length ? ` [${s.tags.join(', ')}]` : ''}${(s.paths || []).length ? ` ${s.paths.join(', ')}` : ''}`;
425
+ });
426
+ }
427
+
428
+ function freshHeader(fr, n) {
429
+ if (!fr) return '';
430
+ const head = fr.head ? `HEAD ${fr.head.slice(0, 7)}${fr.branch ? ` (${fr.branch})` : ''}` : 'HEAD ?';
431
+ return `${head} · ${core.knowledgeSummary(fr, n)} · ${fr.cached ? 'cache' : `tính mới${fr.ms_git ? ` (git ${fr.ms_git} ms)` : ''}`}${fr.error ? ` · git lỗi: ${fr.error}` : ''}`;
432
+ }
433
+
434
+ /** Details under list/status: stale files, unknown reasons, uncommitted files (--dirty). */
435
+ function freshDetails(idx, fr) {
436
+ const out = [];
437
+ if (!fr) return out;
438
+ for (const s of idx.sections) {
439
+ const f = fr.sections[s.id];
440
+ if (!f) continue;
441
+ if (f.state === 'stale') out.push(` ${s.id}: ${f.n_files || f.files.length} file đổi — ${core.shortFileList(f.files, 5)}`);
442
+ else if (f.state === 'unknown') out.push(` ${s.id}: không xác định — ${f.why}`);
443
+ if (f.dirty) out.push(` ${s.id}: chưa commit — ${core.shortFileList(f.dirty, 5)}`);
444
+ }
445
+ if (fr.dirty_check && !fr.dirty_check.checked) out.push(` (--dirty: ${fr.dirty_check.why})`);
446
+ return out;
447
+ }
448
+
449
+ function parentFresh(p, opts) {
450
+ if (!p.idx) return null;
451
+ const pp = p.path && isDir(core.nativePath(p.path)) ? core.nativePath(p.path) : null;
452
+ return freshnessOf(p.dir, p.idx, pp, opts);
453
+ }
454
+
455
+ /** list (all sections + freshness marks) and status (freshness only, with the stale files) share this. */
456
+ function listOrStatus(opts, status) {
457
+ const { start, info } = infoOf(opts, false);
458
+ if (!info) return notRepo(start);
459
+ const ri = readIndex(info.dir);
460
+ if (ri.error) return { code: 1, out: ri.error };
461
+ const idx = ri.idx || newIndex(info);
462
+ const fr = ri.exists ? freshnessOf(info.dir, idx, start, opts) : null;
463
+ const parents = opts.follow ? parentsOf(idx) : [];
464
+ const pfr = parents.map((p) => parentFresh(p, opts));
465
+ if (opts.json) {
466
+ const fj = (f) => (f ? { head: f.head, branch: f.branch || null, cached: f.cached, counts: f.counts, error: f.error || null, dirty_check: f.dirty_check || null, sections: f.sections } : null);
467
+ return {
468
+ code: 0,
469
+ out: JSON.stringify({
470
+ repo: { key: info.key, name: info.name, dir: info.dir, exists: ri.exists }, related: idx.related, sections: idx.sections.map((s) => Object.assign({}, s, { lines: sectionLines(info.dir, s) })), freshness: fj(fr),
471
+ parents: parents.map((p, i) => ({ name: p.name, key: p.key, dir: p.dir, sections: p.idx ? p.idx.sections.map((s) => Object.assign({}, s, { lines: sectionLines(p.dir, s) })) : [], freshness: fj(pfr[i]), error: p.error || null })),
472
+ }, null, 2),
473
+ };
474
+ }
475
+ const out = [`${repoLine({ name: info.name, dir: info.dir })} — ${ri.exists ? `${idx.sections.length} mục` : 'chưa có kho'}`];
476
+ if (fr) out.push(` ${freshHeader(fr, idx.sections.length)}`);
477
+ if (!status) out.push(...listLines(idx, fr, info.dir));
478
+ out.push(...freshDetails(idx, fr));
479
+ for (const r of idx.related) out.push(` related: ${r.rel === 'parent' ? 'cha' : 'con'} ${r.name} — ${r.path || '?'}`);
480
+ parents.forEach((p, i) => {
481
+ out.push(repoLine({ name: p.name, dir: p.dir || '?', rel: 'parent' }) + (p.idx ? ` — ${p.idx.sections.length} mục` : ` — ${p.error || 'không thấy kho'}`));
482
+ if (!p.idx) return;
483
+ out.push(` ${freshHeader(pfr[i], p.idx.sections.length)}`);
484
+ if (!status) out.push(...listLines(p.idx, pfr[i], p.dir));
485
+ out.push(...freshDetails(p.idx, pfr[i]));
486
+ });
487
+ return { code: 0, out: out.join('\n') };
488
+ }
489
+
490
+ function cmdList(opts) {
491
+ return listOrStatus(opts, false);
492
+ }
493
+
494
+ function cmdStatus(opts) {
495
+ return listOrStatus(opts, true);
496
+ }
497
+
498
+ // ------------------------------------------------------------------ delta parsing
499
+
500
+ const META_RE = /^(title|tags|paths|base)\s*:\s*(.*?)\s*$/i;
501
+
502
+ function unquote(v) {
503
+ return String(v).trim().replace(/^[`"'](.*)[`"']$/, '$1').trim();
504
+ }
505
+
506
+ function splitList(v, lower) {
507
+ return String(v).split(/[,\s]+/).map((x) => unquote(x)).filter(Boolean).map((x) => (lower ? x.toLowerCase() : slash(x)));
508
+ }
509
+
510
+ /** Parse a delta file: { repo, head, sections: [{ add, rawId, id, title?, tags?, paths?, base?, body, lines }] }. */
511
+ function parseDelta(text) {
512
+ const lines = String(text).replace(/^/, '').split(LINE_BREAK);
513
+ const out = { repo: null, head: null, sections: [] };
514
+ let cur = null;
515
+ let fence = false;
516
+ for (const line of lines) {
517
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence;
518
+ const h = !fence && /^##\s+(\+)?\s*(.+?)\s*$/.exec(line); // a heading that is not a valid id is rejected later
519
+ if (h) {
520
+ cur = { add: !!h[1], rawId: h[2], raw: [] };
521
+ out.sections.push(cur);
522
+ continue;
523
+ }
524
+ if (cur) { cur.raw.push(line); continue; }
525
+ const m = /^\s*(repo|head)\s*:\s*(.+?)\s*$/i.exec(line);
526
+ if (m && !out[m[1].toLowerCase()]) out[m[1].toLowerCase()] = unquote(m[2].replace(/[.;,]+$/, '')).replace(/[.;,]+$/, '');
527
+ }
528
+ for (const s of out.sections) {
529
+ s.id = s.rawId.toLowerCase();
530
+ let i = 0;
531
+ while (i < s.raw.length && !s.raw[i].trim()) i++;
532
+ for (; i < s.raw.length; i++) {
533
+ const m = META_RE.exec(s.raw[i]);
534
+ if (!m) break;
535
+ const k = m[1].toLowerCase();
536
+ if (k === 'title') s.title = unquote(m[2]);
537
+ else if (k === 'tags') s.tags = splitList(m[2], true);
538
+ else if (k === 'paths') s.paths = splitList(m[2], false);
539
+ else s.base = unquote(m[2]);
540
+ }
541
+ const body = s.raw.slice(i).map((l) => l.replace(/\s+$/, ''));
542
+ while (body.length && !body[0]) body.shift();
543
+ while (body.length && !body[body.length - 1]) body.pop();
544
+ s.body = body.join('\n');
545
+ s.lines = countLines(s.body);
546
+ delete s.raw;
547
+ }
548
+ return out;
549
+ }
550
+
551
+ // ------------------------------------------------------------------ merge
552
+
553
+ function headShaOf(dir) {
554
+ const h = core.readHead(dir);
555
+ if (h && h.sha) return h.sha;
556
+ if (h && h.unsupported) {
557
+ const r = cp.spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8', windowsHide: true, timeout: 10000 });
558
+ const v = r.status === 0 ? r.stdout.trim() : '';
559
+ if (/^[0-9a-f]{40,64}$/i.test(v)) return v.toLowerCase();
560
+ }
561
+ return null;
562
+ }
563
+
564
+ function runDirOf(v) {
565
+ if (typeof v !== 'string' || !v.trim()) return { error: '--run cần id run hoặc thư mục run.' };
566
+ const s = core.nativePath(v.trim());
567
+ if (/[\\/]/.test(s) || path.isAbsolute(s)) {
568
+ const p = path.resolve(s);
569
+ return isDir(p) ? { dir: p } : { error: `Không thấy thư mục run: ${p}` };
570
+ }
571
+ if (!/^[A-Za-z0-9_-]+$/.test(s)) return { error: `id run không hợp lệ: ${v}` };
572
+ const p = path.join(core.runsDir(), s);
573
+ return isDir(p) ? { dir: p } : { error: `Không thấy run ${s} (${slash(p)}).` };
574
+ }
575
+
576
+ /** Task id a delta file belongs to: knowledge-delta-<task_id>[-r<n>].md (8 chars a-z0-9); null = untagged. */
577
+ function deltaTaskOf(file) {
578
+ const m = /^knowledge-delta-([a-z0-9]{8})(?:-r\d+)?\.md$/i.exec(path.basename(file));
579
+ return m ? m[1].toLowerCase() : null;
580
+ }
581
+
582
+ /**
583
+ * Delta files of a run, oldest round first. filter: { tasks: Set|null, untagged: bool } — with tasks, only files
584
+ * tagged with one of them (plus untagged ones when untagged is true).
585
+ */
586
+ function deltaFiles(runDir, filter) {
587
+ const num = (f) => { const m = /-r(\d+)\.md$/i.exec(f); return m ? Number(m[1]) : 0; };
588
+ return fs.readdirSync(runDir)
589
+ .filter((f) => /^knowledge-delta.*\.md$/i.test(f))
590
+ .filter((f) => {
591
+ if (!filter || !filter.tasks) return true;
592
+ const t = deltaTaskOf(f);
593
+ return t ? filter.tasks.has(t) : !!filter.untagged;
594
+ })
595
+ .sort((a, b) => num(a) - num(b) || a.localeCompare(b))
596
+ .map((f) => path.join(runDir, f));
597
+ }
598
+
599
+ function taskFilterOf(opts) {
600
+ if (typeof opts.task !== 'string' || !opts.task.trim()) return null;
601
+ return { tasks: new Set(opts.task.split(',').map((x) => x.trim().toLowerCase()).filter(Boolean)), untagged: !!opts.untagged };
602
+ }
603
+
604
+ function sameContent(cur, body, next) {
605
+ const eqList = (a, b) => JSON.stringify(a || []) === JSON.stringify(b || []);
606
+ return cur.body === body && (cur.title || '') === (next.title || '') && eqList(cur.tags, next.tags) && eqList(cur.paths, next.paths);
607
+ }
608
+
609
+ const APPLIED_MAX = 300;
610
+
611
+ /** sha256 of the delta text, BOM and CRLF normalised so a CRLF checkout hashes like the LF original. */
612
+ function deltaHash(text) {
613
+ return crypto.createHash('sha256').update(String(text).replace(/^/, '').replace(/\r\n?/g, '\n')).digest('hex');
614
+ }
615
+
616
+ function appliedOf(dir) {
617
+ const meta = core.readJsonFile(path.join(dir, 'meta.json'), {}) || {};
618
+ return Array.isArray(meta.applied) ? meta.applied : [];
619
+ }
620
+
621
+ /** Add one applied-delta entry to meta.json (caller holds the store lock). */
622
+ function recordApplied(dir, info, entry) {
623
+ ensureMeta(dir, info);
624
+ const file = path.join(dir, 'meta.json');
625
+ const meta = core.readJsonFile(file, {}) || {};
626
+ const applied = (Array.isArray(meta.applied) ? meta.applied : []).filter((a) => a && a.sha256 !== entry.sha256);
627
+ applied.push(entry);
628
+ core.writeFileAtomic(file, `${JSON.stringify(Object.assign({}, meta, { applied: applied.slice(-APPLIED_MAX) }), null, 2)}\n`);
629
+ }
630
+
631
+ /** Older revisions of a section kept in history/<id>/: [{ rev, title, tags, paths, body }]. */
632
+ function historyRevs(dir, id) {
633
+ const hd = path.join(dir, 'history', id);
634
+ let names;
635
+ try { names = fs.readdirSync(hd); } catch (_) { return []; }
636
+ const out = [];
637
+ for (const n of names) {
638
+ if (!/\.md$/i.test(n)) continue;
639
+ let txt;
640
+ try { txt = fs.readFileSync(path.join(hd, n), 'utf8').replace(/\r\n/g, '\n'); } catch (_) { continue; }
641
+ const m = /^<!-- knowledge-history (\{.*\}) -->\n/.exec(txt);
642
+ let head = null;
643
+ try { head = m ? JSON.parse(m[1]) : null; } catch (_) { head = null; }
644
+ if (!head) continue;
645
+ out.push({ rev: head.rev || 0, title: head.title, tags: head.tags, paths: head.paths, body: txt.slice(m[0].length).replace(/\n+$/, '') });
646
+ }
647
+ return out;
648
+ }
649
+
650
+ /** The older revision whose content equals the delta section's, or null: a delta merged before is recognised, not a conflict. */
651
+ function mergedBefore(dir, id, body, next) {
652
+ return historyRevs(dir, id).find((h) => sameContent(h, body, next)) || null;
653
+ }
654
+
655
+ function stamp(d) {
656
+ return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
657
+ }
658
+
659
+ /** Validate a delta section against the current index (no writes). Returns the planned outcome. */
660
+ function planSection(s, idx, dir, seen) {
661
+ if (!ID_RE.test(s.id)) return { op: 'reject', why: `id không hợp lệ "${s.rawId}" (a-z 0-9 . _ -, ≤ 64 ký tự)` };
662
+ if (seen.has(s.id)) return { op: 'reject', why: 'trùng id trong cùng delta' };
663
+ seen.add(s.id);
664
+ if (!s.body) return { op: 'reject', why: 'thân mục rỗng' };
665
+ if (s.lines > MAX_LINES) return { op: 'reject', why: `${s.lines} dòng > ${MAX_LINES}` };
666
+ const cur = idx.sections.find((x) => x.id === s.id);
667
+ const curBody = cur ? readBody(dir, s.id) : null;
668
+ if (s.add) {
669
+ const next = { title: s.title || s.id, tags: s.tags || [], paths: s.paths || [] };
670
+ if (!next.paths.length) return { op: 'reject', why: 'mục mới phải có paths: (glob file mà mục nói tới)' };
671
+ if (cur) {
672
+ if (sameContent({ body: curBody, title: cur.title, tags: cur.tags, paths: cur.paths }, s.body, next)) return { op: 'same', cur };
673
+ const old = mergedBefore(dir, s.id, s.body, next);
674
+ if (old) return { op: 'same', cur, why: `khớp rev ${old.rev} cũ, đã gộp trước đó` };
675
+ return { op: 'conflict', cur, why: `mục đã có (rev ${cur.rev || 0}, ${String(cur.updated || '').slice(0, 19)}) — phiên khác vừa thêm; get rồi sửa thành "## ${s.id}" kèm base` };
676
+ }
677
+ return { op: 'add', next };
678
+ }
679
+ if (!cur) return { op: 'reject', why: `chưa có mục ${s.id} để thay (thêm mới dùng "## +${s.id}")` };
680
+ const next = { title: s.title || cur.title || s.id, tags: s.tags || cur.tags || [], paths: s.paths || cur.paths || [] };
681
+ if (!next.paths.length) return { op: 'reject', why: 'mục phải có paths:' };
682
+ if (sameContent({ body: curBody, title: cur.title, tags: cur.tags, paths: cur.paths }, s.body, next)) return { op: 'same', cur };
683
+ const m = /^([0-9a-f]{7,64}|none)(?:#(\d+))?$/i.exec(String(s.base || '').trim());
684
+ const merged = () => {
685
+ const old = mergedBefore(dir, s.id, s.body, next);
686
+ return old ? { op: 'same', cur, why: `khớp rev ${old.rev} cũ, đã gộp trước đó` } : null;
687
+ };
688
+ if (!m) return merged() || { op: 'conflict', cur, why: `thiếu base: (chép dòng "base: ${baseOf(cur)}" từ knowledge get rồi kiểm lại nội dung)` };
689
+ const bsha = m[1].toLowerCase();
690
+ const csha = String(cur.sha || 'none').toLowerCase();
691
+ const shaOk = bsha === 'none' ? csha === 'none' : csha.startsWith(bsha);
692
+ const revOk = m[2] === undefined || Number(m[2]) === (cur.rev || 0);
693
+ if (!shaOk || !revOk) {
694
+ return merged() || { op: 'conflict', cur, why: `base ${shortSha(bsha)}#${m[2] === undefined ? '?' : m[2]} ≠ hiện tại ${baseOf(Object.assign({}, cur, { sha: shortSha(cur.sha) }))} (cập nhật ${String(cur.updated || '').slice(0, 19)}) — đọc lại bằng get, sửa delta rồi merge lại` };
695
+ }
696
+ return { op: 'replace', cur, curBody, next };
697
+ }
698
+
699
+ /** Apply one parsed delta to a store (caller holds the lock unless dry). Returns per-section results. */
700
+ function applyDelta(delta, info, sha, dry, src) {
701
+ const dir = info.dir;
702
+ const ri = readIndex(dir);
703
+ if (ri.error) return { error: ri.error };
704
+ const idx = ri.idx || newIndex(info);
705
+ if (src && appliedOf(dir).some((a) => a && a.sha256 === src.hash)) {
706
+ return { results: delta.sections.map((s) => ({ id: s.id || s.rawId, op: 'same', lines: s.lines, why: 'file delta đã gộp trước đó (cùng nội dung), bỏ qua' })), skipped: true };
707
+ }
708
+ const delay = Number(process.env.DIEU_PHOI_TEST_KNOWLEDGE_LOCK_DELAY_MS || 0);
709
+ if (delay > 0 && !dry) sleepSync(delay); // test hook: widen the read-modify-write window to prove the lock
710
+ const now = new Date();
711
+ const results = [];
712
+ const seen = new Set();
713
+ let changed = false;
714
+ for (const s of delta.sections) {
715
+ const p = planSection(s, idx, dir, seen);
716
+ const r = { id: s.id || s.rawId, op: p.op, lines: s.lines };
717
+ if (p.why) r.why = p.why;
718
+ if (p.op === 'add' || p.op === 'replace') {
719
+ const rev = p.op === 'add' ? 1 : (p.cur.rev || 0) + 1;
720
+ const entry = { id: s.id, title: p.next.title, tags: p.next.tags, paths: p.next.paths, sha, rev, updated: now.toISOString(), lines: s.lines };
721
+ r.rev = rev;
722
+ if (p.op === 'replace') r.from_rev = p.cur.rev || 0;
723
+ if (!dry) {
724
+ if (p.op === 'replace' && p.curBody != null) {
725
+ const hist = path.join(dir, 'history', s.id, `r${p.cur.rev || 0}-${shortSha(p.cur.sha)}-${stamp(now)}.md`);
726
+ const head = JSON.stringify(Object.assign({}, p.cur, { replaced: now.toISOString(), replaced_by_sha: sha }));
727
+ core.writeFileAtomic(hist, `<!-- knowledge-history ${head} -->\n${p.curBody}\n`);
728
+ }
729
+ core.writeFileAtomic(sectionFile(dir, s.id), `${s.body}\n`);
730
+ }
731
+ const at = idx.sections.findIndex((x) => x.id === s.id);
732
+ if (at >= 0) idx.sections[at] = entry; else idx.sections.push(entry);
733
+ changed = true;
734
+ }
735
+ results.push(r);
736
+ }
737
+ if (changed && !dry) {
738
+ idx.schema = INDEX_SCHEMA;
739
+ idx.key = info.key;
740
+ idx.name = idx.name || info.name;
741
+ core.writeFileAtomic(path.join(dir, 'index.json'), `${JSON.stringify(idx, null, 2)}\n`);
742
+ ensureMeta(dir, info);
743
+ }
744
+ if (changed && !dry && src && !results.some((r) => r.op === 'conflict' || r.op === 'reject')) {
745
+ recordApplied(dir, info, { sha256: src.hash, file: src.file, run: src.run, at: now.toISOString() });
746
+ }
747
+ return { results };
748
+ }
749
+
750
+ function ensureMeta(dir, info) {
751
+ const file = path.join(dir, 'meta.json');
752
+ const meta = core.readJsonFile(file, {}) || {};
753
+ const keys = Array.isArray(meta.keys) ? meta.keys.slice() : [];
754
+ if (keys.includes(info.key) && meta.created) return;
755
+ if (!keys.includes(info.key)) keys.push(info.key);
756
+ core.writeFileAtomic(file, `${JSON.stringify(Object.assign({ created: new Date().toISOString(), name: info.name }, meta, { keys }), null, 2)}\n`);
757
+ }
758
+
759
+ const OP_MARK = { add: '+', replace: '~', same: '=', conflict: '!', reject: 'x' };
760
+
761
+ function describe(r) {
762
+ if (r.op === 'add') return `+ ${r.id} (${r.lines} dòng, rev 1)`;
763
+ if (r.op === 'replace') return `~ ${r.id} (rev ${r.from_rev} → ${r.rev}, ${r.lines} dòng)`;
764
+ if (r.op === 'same') return `= ${r.id} (không đổi${r.why ? `: ${r.why}` : ''})`;
765
+ if (r.op === 'conflict') return `! ${r.id} XUNG ĐỘT: ${r.why}`;
766
+ return `${OP_MARK[r.op] || '?'} ${r.id} BỊ TỪ CHỐI: ${r.why}`;
767
+ }
768
+
769
+ function cmdMerge(opts) {
770
+ const rd = runDirOf(opts.run);
771
+ if (rd.error) return { code: 1, out: rd.error };
772
+ const filter = taskFilterOf(opts);
773
+ const files = deltaFiles(rd.dir, filter);
774
+ if (!files.length) return { code: 1, out: `Run ${slash(rd.dir)} không có knowledge-delta*.md${filter ? ` của task ${Array.from(filter.tasks).join(',')}` : ''}.` };
775
+ const dry = !!opts['dry-run'];
776
+ const report = [];
777
+ const totals = { add: 0, replace: 0, same: 0, conflict: 0, reject: 0, error: 0 };
778
+ for (const file of files) {
779
+ const rep = { file: slash(file) };
780
+ report.push(rep);
781
+ let delta;
782
+ let src = null;
783
+ try {
784
+ const text = fs.readFileSync(file, 'utf8');
785
+ delta = parseDelta(text);
786
+ src = { hash: deltaHash(text), file: path.basename(file), run: path.basename(rd.dir) };
787
+ } catch (e) { rep.error = `không đọc được: ${e.message}`; totals.error++; continue; }
788
+ if (!delta.sections.length) { rep.error = 'không có mục "## <id>" nào'; totals.error++; continue; }
789
+ let target = null;
790
+ if (delta.repo) {
791
+ const p = path.resolve(core.nativePath(delta.repo));
792
+ if (isDir(p)) target = p;
793
+ else if (opts.repo !== undefined) { target = repoOf(opts); rep.note = `repo: ${delta.repo} không còn — dùng --repo`; } else {
794
+ rep.error = `repo: ${delta.repo} không tồn tại (worktree đã xoá?) — chạy lại với --repo <đường dẫn repo>`; totals.error++; continue;
795
+ }
796
+ } else target = repoOf(opts);
797
+ const info = core.resolveProject(target);
798
+ if (!info) { rep.error = `không phải repo git: ${slash(target)}`; totals.error++; continue; }
799
+ let sha = null;
800
+ if (delta.head) {
801
+ if (!/^[0-9a-f]{7,64}$/i.test(delta.head)) { rep.error = `head: không phải sha: ${delta.head}`; totals.error++; continue; }
802
+ sha = delta.head.toLowerCase();
803
+ } else sha = headShaOf(target);
804
+ if (!sha) { rep.error = `không xác định được HEAD của ${slash(target)} (repo chưa có commit?) — thêm dòng "head: <sha>" vào delta`; totals.error++; continue; }
805
+ Object.assign(rep, { repo: slash(target), store: info.dir, key: info.key, sha });
806
+ let out;
807
+ if (dry) out = applyDelta(delta, info, sha, true, src);
808
+ else {
809
+ const lk = withLock(path.join(info.dir, 'knowledge.lock'), () => applyDelta(delta, info, sha, false, src), LOCK_TRIES);
810
+ out = lk.locked ? { error: `kho đang bị khoá quá ${LOCK_TRIES * 50 / 1000} s (${slash(path.join(info.dir, 'knowledge.lock'))}) — thử lại` } : lk.value;
811
+ }
812
+ if (out.error) { rep.error = out.error; totals.error++; continue; }
813
+ rep.sections = out.results;
814
+ for (const r of out.results) totals[r.op]++;
815
+ }
816
+ const code = totals.conflict || totals.reject || totals.error ? 2 : 0;
817
+ if (opts.json) return { code, out: JSON.stringify({ run: slash(rd.dir), dry_run: dry, totals, deltas: report }, null, 2) };
818
+ const lines = [];
819
+ if (dry) lines.push('(dry-run — không ghi gì)');
820
+ for (const rep of report) {
821
+ const name = path.basename(rep.file);
822
+ if (rep.error) { lines.push(`${name}: LỖI — ${rep.error}`); continue; }
823
+ lines.push(`${name} → ${rep.store} (sha ${shortSha(rep.sha)})${rep.note ? ` [${rep.note}]` : ''}`);
824
+ for (const r of rep.sections) lines.push(` ${describe(r)}`);
825
+ }
826
+ lines.push(`Tổng: ${totals.add} thêm, ${totals.replace} thay, ${totals.same} không đổi, ${totals.conflict} xung đột, ${totals.reject} từ chối${totals.error ? `, ${totals.error} file lỗi` : ''}.`);
827
+ return { code, out: lines.join('\n') };
828
+ }
829
+
830
+ // ------------------------------------------------------------------ bootstrap
831
+
832
+ const BOOT_SECTIONS = [
833
+ ['build-test', 'lệnh build/test/lint, thời gian chạy, test nào chậm/flaky'],
834
+ ['ports-resources', 'cổng, DB, thư mục output dùng chung, tiến trình nền — thứ hai phiên có thể tranh nhau'],
835
+ ['conventions', 'quy ước code/commit/nhánh/worktree, file không được sửa'],
836
+ ['pitfalls', 'bẫy đã gặp (kèm cách tránh)'],
837
+ ['env-secrets', 'TÊN biến môi trường/file cấu hình cần có (không ghi giá trị)'],
838
+ ['arch-<module>', 'vài dòng định hướng + tên project trong codebase-memory-mcp (không chép cấu trúc code)'],
839
+ ];
840
+ const BOOT_DOCS = /^(claude|agents|readme|contributing|contract)(\.[a-z]+)?\.md$/i;
841
+ const BOOT_RUN_FILE = /^(context-r\d*.*|contract.*|dp-out-.*-plan.*|handoff-.*)\.md$/i;
842
+ const BOOT_MAX_RUNS = 15;
843
+ const BOOT_SKIP_DIR = /^(node_modules|\.git|library|temp|obj|build|dist|assets|packages|vendor|_archive)$/i;
844
+
845
+ function lineCount(file) {
846
+ try { return countLines(fs.readFileSync(file, 'utf8')); } catch (_) { return 0; }
847
+ }
848
+
849
+ /** Repo docs worth reading: root + one level down (CLAUDE.md, AGENTS.md, README*, CONTRACT*), skipping vendored dirs. */
850
+ function bootDocs(root) {
851
+ const out = [];
852
+ const scan = (dir, depth) => {
853
+ let ents = [];
854
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
855
+ for (const e of ents) {
856
+ const p = path.join(dir, e.name);
857
+ if (e.isFile() && BOOT_DOCS.test(e.name)) out.push({ path: slash(path.relative(root, p)), lines: lineCount(p) });
858
+ else if (e.isDirectory() && depth < 1 && !BOOT_SKIP_DIR.test(e.name) && (!e.name.startsWith('.') || e.name === '.claude')) scan(p, depth + 1);
859
+ }
860
+ };
861
+ scan(root, 0);
862
+ return out;
863
+ }
864
+
865
+ const reEsc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
866
+
867
+ /** Old run files (context-r, contract, plan outputs, handoffs) that name this repo, newest first. */
868
+ function bootRuns(info, extraPaths) {
869
+ const needles = [info.root].concat(extraPaths || []).map((p) => slash(p).toLowerCase().replace(/\/+$/, '')).filter(Boolean);
870
+ const nameRe = new RegExp(`[/\\\\]${reEsc(info.name)}(?![a-z0-9_-])`, 'i');
871
+ const hits = [];
872
+ let dirs = [];
873
+ try { dirs = fs.readdirSync(core.runsDir(), { withFileTypes: true }).filter((d) => d.isDirectory()); } catch (_) { return hits; }
874
+ for (const d of dirs) {
875
+ const rd = path.join(core.runsDir(), d.name);
876
+ let files = [];
877
+ try { files = fs.readdirSync(rd).filter((f) => BOOT_RUN_FILE.test(f)); } catch (_) { continue; }
878
+ for (const f of files) {
879
+ const p = path.join(rd, f);
880
+ let st;
881
+ let txt;
882
+ try { st = fs.statSync(p); if (st.size > 400 * 1024) continue; txt = fs.readFileSync(p, 'utf8'); } catch (_) { continue; }
883
+ const low = slash(txt).toLowerCase();
884
+ const n = needles.reduce((a, s) => a + low.split(s).length - 1, 0) + (nameRe.test(txt) ? 1 : 0);
885
+ if (n) hits.push({ path: slash(p), mentions: n, mtime: st.mtime.toISOString() });
886
+ }
887
+ }
888
+ return hits.sort((a, b) => b.mtime.localeCompare(a.mtime)).slice(0, BOOT_MAX_RUNS);
889
+ }
890
+
891
+ /** Sibling repos whose folder name matches one of the patterns (for a parent repo's `children` section). No linking. */
892
+ function bootChildren(info, patterns) {
893
+ if (typeof patterns !== 'string' || !patterns.trim()) return [];
894
+ const parent = path.dirname(path.resolve(info.root));
895
+ const res = patterns.split(',').map((x) => x.trim()).filter(Boolean)
896
+ .map((g) => new RegExp(`^${reEsc(g).replace(/\\\*/g, '.*').replace(/\\\?/g, '.')}$`, 'i'));
897
+ let ents = [];
898
+ try { ents = fs.readdirSync(parent, { withFileTypes: true }); } catch (_) { return []; }
899
+ return ents.filter((e) => e.isDirectory() && res.some((r) => r.test(e.name)) && fs.existsSync(path.join(parent, e.name, '.git'))
900
+ && path.resolve(parent, e.name) !== path.resolve(info.root))
901
+ .map((e) => slash(path.join(parent, e.name)));
902
+ }
903
+
904
+ /**
905
+ * `knowledge bootstrap [--repo R] [--children pat,pat] [--json]`: read-only. Lists what a bootstrap agent should read
906
+ * (repo docs, old run files naming this repo), what the store already has, and the delta it should write. The agent
907
+ * writes knowledge-delta-<task>.md into its run dir; merge --run writes the store — the CLI never summarises.
908
+ */
909
+ function cmdBootstrap(opts) {
910
+ const { start, info } = infoOf(opts, true);
911
+ if (!info) return notRepo(start);
912
+ const ri = readIndex(info.dir);
913
+ const have = ri.idx ? ri.idx.sections.map((s) => s.id) : [];
914
+ const startSlash = slash(path.resolve(start));
915
+ const res = {
916
+ repo: slash(info.root), store: slash(info.dir), key: info.key, head: headShaOf(start), sections: have,
917
+ docs: bootDocs(info.root), runs: bootRuns(info, startSlash !== slash(info.root) ? [startSlash] : []),
918
+ children: bootChildren(info, opts.children),
919
+ suggested: BOOT_SECTIONS.map(([id, what]) => ({ id, what, exists: have.includes(id) })),
920
+ };
921
+ if (opts.json) return { code: 0, out: JSON.stringify(res, null, 2) };
922
+ const L = [`Bootstrap knowledge: ${res.repo}`,
923
+ `kho: ${res.store}${have.length ? ` (đã có ${have.length} mục: ${have.join(', ')})` : ' (chưa có mục nào)'}`,
924
+ '', 'Đọc (chỉ đọc, không sửa repo):'];
925
+ for (const d of res.docs) L.push(` ${d.path} (${d.lines} dòng)`);
926
+ if (!res.docs.length) L.push(' (không thấy CLAUDE.md/README/AGENTS.md)');
927
+ L.push(`Run cũ có nhắc repo này (${res.runs.length}, mới nhất trước; điều gì đã cũ so với code hiện tại thì bỏ):`);
928
+ for (const r of res.runs) L.push(` ${r.path} (${r.mentions} lần, ${r.mtime.slice(0, 10)})`);
929
+ if (res.children.length) {
930
+ L.push('Repo con theo mẫu tên (ghi vào mục "children": đường dẫn + vai trò; KHÔNG gộp knowledge của con vào kho này):');
931
+ for (const c of res.children) L.push(` ${c}`);
932
+ }
933
+ L.push('', 'Mục nên có (≤ 40 dòng/mục; chỉ điều không suy ra được từ code; cấu trúc code để cho codebase-memory-mcp):');
934
+ for (const s of res.suggested) L.push(` ${s.exists ? '=' : '+'} ${s.id}: ${s.what}`);
935
+ L.push('', 'Ghi <run>/knowledge-delta-<task_id>.md:', ` repo: ${res.repo}`, ` head: ${res.head || '<sha>'}`,
936
+ ' ## +build-test', ' title: …', ' tags: …', ' paths: package.json, test/** (bắt buộc: glob các file mục nói tới)', ' <thân mục>',
937
+ 'Agent KHÔNG tự chạy knowledge merge: kho chỉ đổi khi người dùng accept (verdict … accept ở cổng 3 tự gộp delta).',
938
+ 'Kiểm trước, không ghi gì: dieu-phoi knowledge merge --run <run> --task <task_id> --dry-run (sửa mục đã có: "## <id>" + dòng base: chép từ knowledge get).');
939
+ return { code: 0, out: L.join('\n') };
940
+ }
941
+
942
+ // ------------------------------------------------------------------ link
943
+
944
+ function upsertRelated(info, other, rel, remove) {
945
+ const lk = withLock(path.join(info.dir, 'knowledge.lock'), () => {
946
+ const ri = readIndex(info.dir);
947
+ if (ri.error) return { error: ri.error };
948
+ const idx = ri.idx || newIndex(info);
949
+ const before = JSON.stringify(idx.related);
950
+ idx.related = idx.related.filter((r) => r.key !== other.key);
951
+ if (!remove) idx.related.push({ key: other.key, name: other.name, path: other.root, rel });
952
+ if (JSON.stringify(idx.related) === before) return { changed: false };
953
+ idx.schema = INDEX_SCHEMA;
954
+ idx.key = info.key;
955
+ idx.name = idx.name || info.name;
956
+ core.writeFileAtomic(path.join(info.dir, 'index.json'), `${JSON.stringify(idx, null, 2)}\n`);
957
+ ensureMeta(info.dir, info);
958
+ return { changed: true };
959
+ }, LOCK_TRIES);
960
+ return lk.locked ? { error: `kho ${info.dir} đang bị khoá — thử lại` } : lk.value;
961
+ }
962
+
963
+ function cmdLink(opts) {
964
+ const { start, info } = infoOf(opts, false);
965
+ if (!info) return notRepo(start);
966
+ const target = opts._[0];
967
+ if (!target) return { code: 1, out: 'Cần đường dẫn repo kia: knowledge link <đường dẫn> --rel child|parent' };
968
+ if (opts.rel !== 'child' && opts.rel !== 'parent') return { code: 1, out: '--rel phải là child (repo kia là con) hoặc parent (repo kia là cha).' };
969
+ const otherPath = path.resolve(core.nativePath(String(target)));
970
+ const other = isDir(otherPath) ? core.resolveProject(otherPath) : null;
971
+ if (!other) return { code: 1, out: `Không phải repo git: ${otherPath}` };
972
+ if (other.key === info.key) return { code: 1, out: 'Hai đường dẫn là cùng một repo (cùng khoá).' };
973
+ const remove = !!opts.remove;
974
+ const inverse = opts.rel === 'child' ? 'parent' : 'child';
975
+ const a = upsertRelated(info, other, opts.rel, remove);
976
+ if (a.error) return { code: 1, out: a.error };
977
+ const b = upsertRelated(other, info, inverse, remove);
978
+ if (b.error) return { code: 1, out: b.error };
979
+ const verb = remove ? 'Đã gỡ liên kết' : 'Đã liên kết';
980
+ return { code: 0, out: `${verb}: ${other.name} là ${opts.rel === 'child' ? 'con' : 'cha'} của ${info.name}${a.changed || b.changed ? '' : ' (không đổi)'}.` };
981
+ }
982
+
983
+ function cmdKnowledge(opts) {
984
+ const sub = opts._.shift();
985
+ if (!sub || opts.help || opts.h || sub === 'help') {
986
+ return { code: sub || opts.help || opts.h ? 0 : 1, out: KNOWLEDGE_HELP.trimEnd() };
987
+ }
988
+ if (sub === 'path') return cmdPath(opts);
989
+ if (sub === 'rekey') return cmdRekey(opts);
990
+ if (sub === 'get') return cmdGet(opts);
991
+ if (sub === 'list') return cmdList(opts);
992
+ if (sub === 'status') return cmdStatus(opts);
993
+ if (sub === 'merge') return cmdMerge(opts);
994
+ if (sub === 'link') return cmdLink(opts);
995
+ if (sub === 'bootstrap') return cmdBootstrap(opts);
996
+ return { code: 1, out: `Lệnh knowledge lạ: ${sub}\n\n${KNOWLEDGE_HELP.trimEnd()}` };
997
+ }
998
+
999
+ module.exports = {
1000
+ cmdKnowledge, KNOWLEDGE_HELP, MAX_LINES, parseDelta, globMatch, readIndex, storeDirOf, selectSections, headShaOf, freshnessOf,
1001
+ deltaFiles, deltaTaskOf, cmdMerge, cmdBootstrap,
1002
+ };