@kienntpixon/dieu-phoi 0.7.5 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/repos.cjs ADDED
@@ -0,0 +1,387 @@
1
+ 'use strict';
2
+ // core 0.7.6 (s2, contract r1; fix try2): sổ repo đã chạm — <dataDir>/repos.json = { "<checkout chính, / không \": { last_ts, src } }.
3
+ // - checkoutOfPath: dò checkout chính bằng fs (đi lên tìm `.git`; worktree đọc `gitdir:`), không spawn git.
4
+ // - recordRepo / recordMany: best effort, không ném; khoá file quanh đọc-sửa-ghi; chỉ ghi khi repo chưa có hoặc last_ts cũ hơn 1 giờ.
5
+ // Nguồn src: hook (cwd + repo đích của brief), live (SubagentStart/Stop: cwd và worktree của agent), wt (wt add),
6
+ // worktree (sự kiện kind=worktree có repo), seed (wt repos --seed và tự seed).
7
+ // - recordWorktreeAddCommand: lệnh Bash `git [-C dir] worktree add` → repo qua `git rev-parse --git-common-dir`.
8
+ // - seedRepos: quét thư mục tới độ sâu --depth (mặc định 3), sự kiện worktree có repo, và git worktree list của repo đã biết.
9
+ // - autoSeedIfStale: helper cho wt gc (s3): seed khi dấu seed cũ hơn 24 giờ; gốc quét = roots truyền vào + thư mục cha của repo đã biết.
10
+ // - listRepos: bỏ repo không còn `.git` khi đọc; không ghi lại file.
11
+ // - cmdRepos: dieu-phoi wt repos [--seed <dir>…] [--depth N] [--json].
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const cp = require('child_process');
15
+
16
+ const REGISTRY_FILE = 'repos.json';
17
+ const STAMP_FILE = 'repos.seeded';
18
+ const DEEP_STAMP = 'repos.deepscan'; // lần quét sâu thư mục gần nhất hoàn tất (chu kỳ AUTO_SEED_MS)
19
+ const REFRESH_MS = 60 * 60 * 1000;
20
+ const AUTO_SEED_MS = 24 * 60 * 60 * 1000;
21
+ const DEFAULT_DEPTH = 3;
22
+ const MAX_DEPTH = 6;
23
+ const MAX_UP = 64;
24
+ const MAX_SCAN_ENTRIES = 20000;
25
+ const LOCK_WAIT_MS = 1500;
26
+ const LOCK_STALE_MS = 30000;
27
+ const SKIP_DIRS = new Set(['node_modules', 'Library', 'Temp', 'obj', 'Logs', 'Packages']);
28
+ const WT_ADD_RE = /\bgit\s+(?:-C\s+(?:"([^"]+)"|'([^']+)'|(\S+))\s+)?worktree\s+add\b/;
29
+
30
+ function norm(p) {
31
+ return path.resolve(p).replace(/\\/g, '/');
32
+ }
33
+
34
+ function registryFile(dataDir) {
35
+ return path.join(dataDir, REGISTRY_FILE);
36
+ }
37
+
38
+ function readRegistry(dataDir) {
39
+ try {
40
+ const j = JSON.parse(fs.readFileSync(registryFile(dataDir), 'utf8').replace(/^/, ''));
41
+ return j && typeof j === 'object' && !Array.isArray(j) ? j : {};
42
+ } catch (_) {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ function writeRegistry(dataDir, reg) {
48
+ fs.mkdirSync(dataDir, { recursive: true });
49
+ const f = registryFile(dataDir);
50
+ const tmp = `${f}.${process.pid}.tmp`;
51
+ fs.writeFileSync(tmp, `${JSON.stringify(reg, null, 2)}\n`, 'utf8');
52
+ fs.renameSync(tmp, f);
53
+ }
54
+
55
+ function sleepMs(ms) {
56
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
57
+ }
58
+
59
+ // Khoá file quanh đọc-sửa-ghi sổ. Chờ tối đa 1,5 s rồi chạy không khoá (sổ chỉ là gợi ý); khoá cũ hơn 30 s bị bỏ.
60
+ function withRegLock(dataDir, fn) {
61
+ fs.mkdirSync(dataDir, { recursive: true });
62
+ const lock = `${registryFile(dataDir)}.lock`;
63
+ let got = false;
64
+ const t0 = Date.now();
65
+ for (;;) {
66
+ try {
67
+ fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
68
+ got = true;
69
+ break;
70
+ } catch (_) {
71
+ try { if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.unlinkSync(lock); } catch (_2) { /* đã mất */ }
72
+ if (Date.now() - t0 > LOCK_WAIT_MS) break;
73
+ sleepMs(20);
74
+ }
75
+ }
76
+ try {
77
+ return fn();
78
+ } finally {
79
+ if (got) { try { fs.unlinkSync(lock); } catch (_) { /* đã mất */ } }
80
+ }
81
+ }
82
+
83
+ // Checkout chính (thư mục có `.git` thư mục) của một đường dẫn trong repo hoặc worktree; null nếu không phải repo git. Không spawn.
84
+ function checkoutOfPath(dir) {
85
+ try {
86
+ let d = path.resolve(dir);
87
+ for (let i = 0; i < MAX_UP; i++) {
88
+ const g = path.join(d, '.git');
89
+ let st = null;
90
+ try { st = fs.statSync(g); } catch (_) { st = null; }
91
+ if (st && st.isDirectory()) return norm(d);
92
+ if (st && st.isFile()) {
93
+ // worktree: `.git` là file "gitdir: <common>/worktrees/<name>"
94
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(g, 'utf8'));
95
+ if (!m) return null;
96
+ const common = path.resolve(d, m[1].trim(), '..', '..');
97
+ return path.basename(common) === '.git' ? norm(path.dirname(common)) : null;
98
+ }
99
+ const up = path.dirname(d);
100
+ if (up === d) return null;
101
+ d = up;
102
+ }
103
+ } catch (_) { /* best effort */ }
104
+ return null;
105
+ }
106
+
107
+ // Ghi nhiều đường dẫn trong MỘT lần đọc-ghi. Trả số repo mới được ghi (vượt ngưỡng làm mới).
108
+ function recordMany(dataDir, dirs, src, now) {
109
+ try {
110
+ if (!dataDir) return 0;
111
+ const mains = new Set();
112
+ for (const d of dirs) {
113
+ if (typeof d !== 'string' || !d) continue;
114
+ const m = checkoutOfPath(d);
115
+ if (m) mains.add(m);
116
+ }
117
+ if (!mains.size) return 0;
118
+ const ts = typeof now === 'number' ? now : Date.now();
119
+ return withRegLock(dataDir, () => {
120
+ const reg = readRegistry(dataDir);
121
+ let n = 0;
122
+ for (const main of mains) {
123
+ const cur = reg[main];
124
+ if (cur && typeof cur.last_ts === 'number' && ts - cur.last_ts < REFRESH_MS) continue;
125
+ reg[main] = { last_ts: ts, src: src || 'hook' };
126
+ n++;
127
+ }
128
+ if (n) writeRegistry(dataDir, reg);
129
+ return n;
130
+ });
131
+ } catch (_) {
132
+ return 0;
133
+ }
134
+ }
135
+
136
+ /** Ghi repo của `dir` vào sổ (best effort, không ném). Trả true khi đã ghi. */
137
+ function recordRepo(dataDir, dir, src, now) {
138
+ return recordMany(dataDir, [dir], src, now) > 0;
139
+ }
140
+
141
+ /**
142
+ * Lệnh Bash `git [-C dir] worktree add ...`: repo = dir của -C (hoặc cwd), resolve bằng `git rev-parse --git-common-dir`.
143
+ * Trả true khi ghi được. Chỉ spawn git khi lệnh khớp.
144
+ */
145
+ function recordWorktreeAddCommand(dataDir, command, cwd) {
146
+ try {
147
+ if (typeof command !== 'string' || command.indexOf('worktree') < 0) return false;
148
+ const m = WT_ADD_RE.exec(command);
149
+ if (!m) return false;
150
+ const base = cwd || process.cwd();
151
+ const given = m[1] || m[2] || m[3];
152
+ const dir = given ? path.resolve(base, given) : base;
153
+ const r = cp.spawnSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
154
+ encoding: 'utf8', windowsHide: true, timeout: 5000,
155
+ });
156
+ if (r.status !== 0) return false;
157
+ const common = String(r.stdout || '').trim();
158
+ if (path.basename(common) !== '.git') return false;
159
+ return recordMany(dataDir, [path.dirname(common)], 'hook') > 0;
160
+ } catch (_) {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ /** Danh sách repo còn tồn tại (có `.git`), mới nhất trước. */
166
+ function listRepos(dataDir) {
167
+ const reg = readRegistry(dataDir);
168
+ return Object.keys(reg)
169
+ .filter((k) => fs.existsSync(path.join(k, '.git')))
170
+ .map((k) => ({ path: k, last_ts: Number(reg[k].last_ts) || 0, src: String(reg[k].src || '') }))
171
+ .sort((a, b) => b.last_ts - a.last_ts);
172
+ }
173
+
174
+ function clampDepth(d) {
175
+ const n = Number(d);
176
+ if (!Number.isFinite(n) || n < 1) return DEFAULT_DEPTH;
177
+ return Math.min(MAX_DEPTH, Math.floor(n));
178
+ }
179
+
180
+ // Thư mục con của `base` tới độ sâu `depth` có `.git`. Bỏ dot-dir, node_modules và thư mục sinh ra của Unity.
181
+ function scanRepos(base, depth, out, budget) {
182
+ let entries = [];
183
+ try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch (_) { return; }
184
+ for (const e of entries) {
185
+ if (budget.left-- <= 0) return;
186
+ if (budget.deadline && (budget.left & 63) === 0 && Date.now() > budget.deadline) { budget.left = 0; return; }
187
+ if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
188
+ const c = path.join(base, e.name);
189
+ if (fs.existsSync(path.join(c, '.git'))) out.push(c);
190
+ if (depth > 1) scanRepos(c, depth - 1, out, budget);
191
+ }
192
+ }
193
+
194
+ // Mọi worktree liên kết của một repo, đọc thẳng <repo>/.git/worktrees/*/gitdir (không gọi git: ~1 giây/lệnh trên máy thật,
195
+ // `git worktree list` cho 13 repo mất 118 giây). Mỗi cái trỏ về cùng checkout chính, nên chỉ có tác dụng với repo khác git-common-dir.
196
+ function worktreesOf(repo) {
197
+ const root = path.join(repo, '.git', 'worktrees');
198
+ let names = [];
199
+ try { names = fs.readdirSync(root); } catch (_) { return []; }
200
+ const out = [];
201
+ for (const n of names) {
202
+ try { out.push(path.dirname(fs.readFileSync(path.join(root, n, 'gitdir'), 'utf8').trim())); } catch (_) { /* admin hỏng: bỏ */ }
203
+ }
204
+ return out;
205
+ }
206
+
207
+ function touchStamp(dataDir) {
208
+ try { fs.writeFileSync(path.join(dataDir, STAMP_FILE), new Date().toISOString(), 'utf8'); } catch (_) { /* best effort */ }
209
+ }
210
+
211
+ function lastSeedMs(dataDir) {
212
+ try { return fs.statSync(path.join(dataDir, STAMP_FILE)).mtimeMs; } catch (_) { return 0; }
213
+ }
214
+
215
+ // Repo (đường dẫn tuyệt đối) nhắc tới trong sự kiện: trường repo / cwd / worktree. Đọc cả events.jsonl, gộp trùng.
216
+ function reposFromEvents(eventsFile) {
217
+ const out = new Set();
218
+ let text = '';
219
+ try { text = fs.readFileSync(eventsFile, 'utf8'); } catch (_) { return []; }
220
+ for (const line of text.split(String.fromCharCode(10))) {
221
+ if (line.indexOf('"repo"') < 0 && line.indexOf('"cwd"') < 0 && line.indexOf('"worktree"') < 0) continue;
222
+ try {
223
+ const ev = JSON.parse(line);
224
+ if (!ev) continue;
225
+ for (const k of ['repo', 'cwd', 'worktree']) if (typeof ev[k] === 'string' && ev[k] && path.isAbsolute(ev[k])) out.add(ev[k]);
226
+ } catch (_) { /* dòng hỏng: bỏ */ }
227
+ }
228
+ return [...out];
229
+ }
230
+
231
+ const RUN_FILE_RE = /^(dp-out|brief|handoff|contract|context|steps|plan)[^/\\]*\.(md|json|txt)$/i;
232
+ const RUN_REPO_RE = /(?:^|[\s,;|(])(?:repo|worktree)\s*:\s*`?((?:[A-Za-z]:[\\/]|\/)[^\s`'",;|)]+)/gim;
233
+ // Dòng `repo: …` / `worktree: …` trong brief, dp-out, contract… của các run dir (mới nhất trước, có trần số run và số file).
234
+ function reposFromRuns(runsDir, maxRuns) {
235
+ const out = new Set();
236
+ let names = [];
237
+ try { names = fs.readdirSync(runsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch (_) { return []; }
238
+ const stamped = names.map((n) => { let t = 0; try { t = fs.statSync(path.join(runsDir, n)).mtimeMs; } catch (_) { /* bỏ */ } return { n, t }; })
239
+ .sort((x, y) => y.t - x.t).slice(0, maxRuns || 400);
240
+ let files = 0;
241
+ for (const { n } of stamped) {
242
+ let ents = [];
243
+ try { ents = fs.readdirSync(path.join(runsDir, n)); } catch (_) { continue; }
244
+ for (const f of ents) {
245
+ if (!RUN_FILE_RE.test(f)) continue;
246
+ if (files++ > 6000) return [...out];
247
+ let text = '';
248
+ try {
249
+ const fd = fs.openSync(path.join(runsDir, n, f), 'r');
250
+ const buf = Buffer.alloc(8192);
251
+ const len = fs.readSync(fd, buf, 0, 8192, 0);
252
+ fs.closeSync(fd);
253
+ text = buf.slice(0, len).toString('utf8');
254
+ } catch (_) { continue; }
255
+ RUN_REPO_RE.lastIndex = 0;
256
+ let m;
257
+ while ((m = RUN_REPO_RE.exec(text))) out.add(m[1].replace(/[.:]+$/, ''));
258
+ }
259
+ }
260
+ return [...out];
261
+ }
262
+
263
+ // Mọi thư mục con TRỰC TIẾP của `base` có `.git` (ngân sách riêng: không phụ thuộc quét sâu).
264
+ function directChildRepos(base) {
265
+ const out = [];
266
+ let ents = [];
267
+ try { ents = fs.readdirSync(base, { withFileTypes: true }); } catch (_) { return out; }
268
+ for (const e of ents.slice(0, 5000)) {
269
+ if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
270
+ const c = path.join(base, e.name);
271
+ if (fs.existsSync(path.join(c, '.git'))) out.push(c);
272
+ }
273
+ return out;
274
+ }
275
+
276
+ /**
277
+ * Seed: (1) quét `dirs` tới độ sâu `opts.depth` (mặc định 3); (2) repo trong sự kiện (repo/cwd/worktree) của eventsFile;
278
+ * (3) dòng `repo:`/`worktree:` trong run dir (opts.runsDir); (4) mọi repo nằm TRỰC TIẾP dưới thư mục cha của mỗi repo đã biết
279
+ * (D:/Projects/Anything/*, D:/Projects/*); (5) `git worktree list` của mọi repo đã biết. Trả số repo mới ghi.
280
+ */
281
+ function seedRepos(dataDir, dirs, eventsFile, now, opts) {
282
+ const o = opts || {};
283
+ const depth = clampDepth(o.depth);
284
+ const stats = o.stats || {};
285
+ stats.phases = stats.phases || {};
286
+ stats.skipped = stats.skipped || [];
287
+ const over = () => !!o.deadline && Date.now() > o.deadline;
288
+ // Mỗi pha ghi vào sổ ngay (recordMany) nên bị cắt giữa chừng vẫn giữ phần đã tìm được.
289
+ const phase = (name, fn, force) => {
290
+ if (!force && over()) { stats.skipped.push(name); return 0; }
291
+ const t = Date.now();
292
+ let r = 0;
293
+ try { r = fn() || 0; } finally { stats.phases[name] = Date.now() - t; }
294
+ return r;
295
+ };
296
+ let added = 0;
297
+ // events / runs / siblings rẻ (vài giây) và là nguồn chính: luôn chạy. Quét sâu thư mục đắt hơn: chạy sau cùng, theo hạn chót.
298
+ if (eventsFile) added += phase('events', () => recordMany(dataDir, reposFromEvents(eventsFile), 'seed', now), true);
299
+ if (o.runsDir) added += phase('runs', () => recordMany(dataDir, reposFromRuns(o.runsDir, o.maxRuns), 'seed', now), true);
300
+ added += phase('siblings', () => {
301
+ const parents = new Set();
302
+ for (const r of listRepos(dataDir)) parents.add(path.dirname(r.path));
303
+ const kids = [];
304
+ for (const d of parents) for (const c of directChildRepos(d)) kids.push(c);
305
+ return recordMany(dataDir, kids, 'seed', now);
306
+ }, true);
307
+ if (dirs.length) {
308
+ added += phase('scan', () => {
309
+ const found = [];
310
+ const budget = { left: MAX_SCAN_ENTRIES, deadline: o.deadline || 0 };
311
+ for (const base of dirs) scanRepos(path.resolve(base), depth, found, budget);
312
+ if (budget.left <= 0) stats.skipped.push('scan(bị cắt)');
313
+ return recordMany(dataDir, found, 'seed', now);
314
+ });
315
+ }
316
+ // Pha tuỳ chọn (opts.worktrees): chỉ có ích với repo khác git-common-dir nên mặc định tắt; không chặn đóng dấu seed.
317
+ if (o.worktrees === true) added += phase('worktrees', () => {
318
+ const wts = [];
319
+ let n = 0;
320
+ for (const r of listRepos(dataDir)) {
321
+ if (over()) { stats.skipped.push('worktrees(còn lại)'); break; }
322
+ for (const w of worktreesOf(r.path)) wts.push(w);
323
+ n++;
324
+ }
325
+ stats.worktree_repos = n;
326
+ return recordMany(dataDir, wts, 'seed', now);
327
+ });
328
+ // Đóng dấu khi các pha rẻ đã xong; quét sâu bị cắt thì dấu quét sâu không ghi, lần seed sau thử lại.
329
+ if (!stats.skipped.some((x) => !x.startsWith('scan'))) touchStamp(dataDir);
330
+ stats.added = added;
331
+ return added;
332
+ }
333
+
334
+ /**
335
+ * Helper cho `wt gc` (s3): seed khi dấu seed cũ hơn maxAgeMs (mặc định 24 giờ). Gốc quét = opts.roots + thư mục cha của
336
+ * mỗi repo đã biết. Trả { seeded, added, roots }.
337
+ */
338
+ function autoSeedIfStale(dataDir, opts) {
339
+ const o = opts || {};
340
+ const now = typeof o.now === 'number' ? o.now : Date.now();
341
+ const maxAge = typeof o.maxAgeMs === 'number' ? o.maxAgeMs : AUTO_SEED_MS;
342
+ if (now - lastSeedMs(dataDir) < maxAge) return { seeded: false, added: 0, roots: 0 };
343
+ const roots = new Set((o.roots || []).map((p) => path.resolve(p)));
344
+ for (const r of listRepos(dataDir)) roots.add(path.dirname(r.path));
345
+ let dirs = [...roots].filter((d) => { try { return fs.statSync(d).isDirectory(); } catch (_) { return false; } });
346
+ // gốc nằm trong gốc khác (≤ độ sâu quét) đã được quét rồi; và quét sâu chỉ lặp lại sau AUTO_SEED_MS.
347
+ const depthN = clampDepth(o.depth);
348
+ dirs = dirs.filter((d) => !dirs.some((p) => p !== d && norm(d).startsWith(norm(p) + '/') && norm(d).split('/').length - norm(p).split('/').length < depthN));
349
+ const deepFile = path.join(dataDir, DEEP_STAMP);
350
+ let deepFresh = false;
351
+ try { deepFresh = now - fs.statSync(deepFile).mtimeMs < AUTO_SEED_MS; } catch (_) { deepFresh = false; }
352
+ const scanDirs = deepFresh ? [] : dirs;
353
+ const stats = {};
354
+ const t0 = Date.now();
355
+ const added = seedRepos(dataDir, scanDirs, o.eventsFile, now, { depth: o.depth, runsDir: o.runsDir, deadline: o.deadline, stats });
356
+ if (scanDirs.length && stats.phases.scan !== undefined && !stats.skipped.some((x) => x.startsWith('scan'))) { try { fs.writeFileSync(deepFile, new Date().toISOString(), 'utf8'); } catch (_) { /* best effort */ } }
357
+ return { seeded: true, added, roots: dirs.length, ms: Date.now() - t0, phases: stats.phases, skipped: stats.skipped };
358
+ }
359
+
360
+ function cmdRepos(opts, ctx) {
361
+ const dataDir = ctx.paths.data;
362
+ if (opts.seed) {
363
+ const dirs = [];
364
+ if (typeof opts.seed === 'string') dirs.push(opts.seed);
365
+ for (const d of (opts._ || []).slice(1)) dirs.push(d);
366
+ if (!dirs.length) throw new Error('Cú pháp: dieu-phoi wt repos --seed <thư mục…> [--depth N] [--json]');
367
+ const n = seedRepos(dataDir, dirs.map((d) => path.resolve(d)), ctx.paths.events, Date.now(), { depth: opts.depth, worktrees: true, runsDir: require('../hook.cjs').runsDir ? require('../hook.cjs').runsDir() : null });
368
+ console.log(`wt repos --seed: thêm ${n} repo (độ sâu ${clampDepth(opts.depth)}, ${dirs.length} thư mục gốc, sự kiện và worktree của repo đã biết)`);
369
+ }
370
+ const rows = listRepos(dataDir);
371
+ if (opts.json) {
372
+ console.log(JSON.stringify(rows, null, 2));
373
+ return;
374
+ }
375
+ if (!rows.length) {
376
+ console.log('(sổ repo trống — chạy `dieu-phoi wt repos --seed <thư mục…>` hoặc chờ hook ghi)');
377
+ return;
378
+ }
379
+ for (const r of rows) {
380
+ console.log(`${new Date(r.last_ts).toISOString()} ${r.src.padEnd(8)} ${r.path}`);
381
+ }
382
+ }
383
+
384
+ module.exports = {
385
+ REGISTRY_FILE, DEFAULT_DEPTH, AUTO_SEED_MS, checkoutOfPath, recordRepo, recordMany, recordWorktreeAddCommand,
386
+ listRepos, seedRepos, autoSeedIfStale, cmdRepos, readRegistry,
387
+ };
package/lib/setup.cjs CHANGED
@@ -361,6 +361,21 @@ function skillDirOfInstall(opts, claudeDir, home) {
361
361
  return skillDir || findSkillDir(claudeDir);
362
362
  }
363
363
 
364
+ /** Copy every lib/*.cjs of the package to <binDir>/lib (LF); drop stale .cjs there. Returns the copied names. */
365
+ function installLib(binDir) {
366
+ const srcDir = path.join(PKG_ROOT, 'lib');
367
+ const dstDir = path.join(binDir, 'lib');
368
+ fs.mkdirSync(dstDir, { recursive: true });
369
+ const names = fs.readdirSync(srcDir).filter((f) => f.endsWith('.cjs')).sort();
370
+ for (const f of names) {
371
+ core.writeFileAtomic(path.join(dstDir, f), fs.readFileSync(path.join(srcDir, f), 'utf8').replace(/\r\n/g, '\n'));
372
+ }
373
+ try {
374
+ for (const f of fs.readdirSync(dstDir)) if (f.endsWith('.cjs') && !names.includes(f)) fs.unlinkSync(path.join(dstDir, f));
375
+ } catch (_) { /* stale module left behind: harmless */ }
376
+ return names;
377
+ }
378
+
364
379
  function setup(opts) {
365
380
  const claudeDir = path.resolve(opts.claudeDir);
366
381
  const home = path.join(claudeDir, 'dieu-phoi');
@@ -383,6 +398,11 @@ function setup(opts) {
383
398
  core.writeFileAtomic(path.join(paths.bin, dest), src);
384
399
  }
385
400
 
401
+ // r1 s6 (0.7.6): the hook lazily requires ./lib/repos.cjs (registry) and ./lib/wtgc.cjs (background gc, which pulls
402
+ // aggregate/args/deps/wtfast/worktree and ../hook.cjs). Ship the whole lib/ as bin/lib/ so the installed copy resolves
403
+ // them exactly like the repo layout does (bin/lib/x.cjs -> ../hook.cjs = bin/hook.cjs).
404
+ const libFiles = installLib(paths.bin);
405
+
386
406
  // 2. config
387
407
  const cfg = core.loadConfig(home);
388
408
  if (opts.dept !== undefined) cfg.dept = String(opts.dept);
@@ -428,6 +448,7 @@ function setup(opts) {
428
448
  agents,
429
449
  home,
430
450
  hook: paths.hook,
451
+ lib: libFiles,
431
452
  command: cmd,
432
453
  settings: settingsFile,
433
454
  settings_changed: res.changed,
@@ -458,6 +479,7 @@ function uninstall(opts) {
458
479
  if (opts.purge && fs.existsSync(hookFile)) {
459
480
  fs.unlinkSync(hookFile);
460
481
  hookRemoved = true;
482
+ try { fs.rmSync(path.join(path.dirname(hookFile), 'lib'), { recursive: true, force: true }); } catch (_) { /* best effort */ }
461
483
  }
462
484
  return { removed, agents_removed: agentsRemoved, permissions_removed: permsRemoved, settings: settingsFile, backup: res.backup, hook_removed: hookRemoved, data_kept: core.pathsFor(home).data };
463
485
  }