@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/worktree.cjs CHANGED
@@ -28,6 +28,7 @@ const core = require('../hook.cjs');
28
28
  const agg = require('./aggregate.cjs');
29
29
  const { ctxOf, repoOf } = require('./args.cjs');
30
30
  const deps = require('./deps.cjs');
31
+ const repos = require('./repos.cjs'); // core 0.7.6 (s2): sổ repo đã chạm (data/repos.json)
31
32
 
32
33
  const MARK = 'dieu-phoi.json';
33
34
  const BRANCH_PREFIX = 'dp/';
@@ -40,8 +41,14 @@ const NOT_DONE = new Set(['reject', 'fail', 'timeout', 'aborted']);
40
41
  // nên "đã merge" tầm thường; không có ngưỡng này thì bước đang chờ agent đầu tiên có thể bị gỡ.
41
42
  const MERGED_MIN_AGE_MS = 60 * 60 * 1000;
42
43
 
44
+ // Hạn chót toàn cục cho mọi lệnh git (ms epoch; 0 = không hạn). `wt gc` đặt để một lệnh git treo/chậm không kéo quá ngân sách.
45
+ let gitDeadline = 0;
46
+ let gitCallMs = 60000;
47
+ function setGitDeadline(ts, perCallMs) { gitDeadline = Number(ts) > 0 ? Number(ts) : 0; gitCallMs = Number(perCallMs) > 0 ? Number(perCallMs) : 60000; }
48
+
43
49
  function git(cwd, args) {
44
- const r = cp.spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout: 60000 });
50
+ const timeout = gitDeadline ? Math.max(1500, Math.min(gitCallMs, gitDeadline - Date.now())) : 60000;
51
+ const r = cp.spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout });
45
52
  const err = r.stderr || (r.error ? r.error.message : '') || '';
46
53
  return { ok: r.status === 0, out: (r.stdout || '').trim(), err: err.trim() };
47
54
  }
@@ -74,18 +81,65 @@ function markedWorktrees(common) {
74
81
  out.push({
75
82
  admin, name, task_id: mark.task_id, root: mark.root || null, branch: mark.branch || null,
76
83
  created_ts: mark.created_ts || null, path: wtPath, exists: !!wtPath && fs.existsSync(wtPath),
84
+ keep: mark.keep === true,
77
85
  deps: typeof mark.deps === 'string' ? mark.deps : null, deps_path: typeof mark.deps_path === 'string' ? mark.deps_path : '.',
78
86
  });
79
87
  }
80
88
  return out;
81
89
  }
82
90
 
91
+ // `git status` trên đĩa lạnh + Defender mất 5–10 giây mỗi worktree: gc chạy trước song song (một tiến trình node phụ, spawnSync
92
+ // từ phía gọi nên scanRepo vẫn đồng bộ) rồi dirtyOf lấy kết quả từ bộ nhớ đệm một lần.
93
+ const dirtyCache = new Map();
94
+ const PREFETCH_SCRIPT = `
95
+ const cp = require('child_process');
96
+ const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
97
+ const { paths, concurrency, callMs } = input;
98
+ let i = 0;
99
+ function next() {
100
+ if (i >= paths.length) return;
101
+ const p = paths[i++];
102
+ cp.execFile('git', ['--no-optional-locks', 'status', '--porcelain'], { cwd: p, encoding: 'utf8', windowsHide: true, timeout: callMs, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {
103
+ const lines = String(out || '').split(/\\r?\\n/).filter(Boolean).slice(0, 2000);
104
+ process.stdout.write(JSON.stringify({ p, ok: !err, out: lines.join('\\n') }) + '\\n');
105
+ next();
106
+ });
107
+ }
108
+ for (let c = 0; c < Math.min(concurrency, paths.length); c++) next();
109
+ `;
110
+
111
+ /** Chạy `git status` song song cho nhiều worktree; kết quả vào bộ nhớ đệm cho dirtyOf. Tới hạn chót thì dừng (phần còn lại dirtyOf tự gọi). */
112
+ function prefetchDirty(paths, opts) {
113
+ const o = opts || {};
114
+ const list = (paths || []).filter(Boolean);
115
+ if (list.length < 2) return 0;
116
+ const left = o.deadline ? o.deadline - Date.now() : 120000;
117
+ if (left < 3000) return 0;
118
+ const r = cp.spawnSync(process.execPath, ['-e', PREFETCH_SCRIPT], {
119
+ input: JSON.stringify({ paths: list, concurrency: o.concurrency || 8, callMs: o.callMs || 30000 }),
120
+ encoding: 'utf8', windowsHide: true, timeout: Math.max(1000, left - 500), maxBuffer: 64 * 1024 * 1024,
121
+ });
122
+ let n = 0;
123
+ for (const line of String(r.stdout || '').split('\n')) {
124
+ if (!line) continue;
125
+ try {
126
+ const j = JSON.parse(line);
127
+ if (j && j.ok) { dirtyCache.set(realKey(j.p), { ok: true, out: j.out, err: '' }); n++; }
128
+ } catch (_) { /* dòng bị cắt do hết giờ */ }
129
+ }
130
+ return n;
131
+ }
132
+ function clearDirtyCache() { dirtyCache.clear(); }
133
+
83
134
  /**
84
135
  * true = còn thay đổi (kể cả file chưa theo dõi), false = sạch, null = không đọc được.
85
136
  * s3: dòng `?? …node_modules/` là link (junction dùng chung do --deps link tạo) không tính là thay đổi.
86
137
  */
87
138
  function dirtyOf(wtPath) {
88
- const r = git(wtPath, ['status', '--porcelain']);
139
+ const k = realKey(wtPath);
140
+ const pre = dirtyCache.get(k);
141
+ if (pre) dirtyCache.delete(k); // dùng một lần: kết quả quét song song chỉ hợp lệ cho lượt quét đang chạy
142
+ const r = pre || git(wtPath, ['--no-optional-locks', 'status', '--porcelain']);
89
143
  if (!r.ok) return null;
90
144
  const lines = r.out.split(/\r?\n/).filter(Boolean).filter((l) => {
91
145
  const m = /^\?\? "?(.*?node_modules)\/?"?$/.exec(l);
@@ -103,35 +157,376 @@ function realKey(p) {
103
157
  return process.platform === 'win32' ? r.toLowerCase() : r;
104
158
  }
105
159
 
160
+ /** Các task id có thể gắn với một worktree: dấu, hậu tố nhánh `<x>/<id8>` / `dp/<id8>`, hậu tố tên thư mục `<x>-<id8>`. */
161
+ function taskIdsOfWorktree(wtPath, branch, mark) {
162
+ const ids = new Set();
163
+ const add = (v) => { if (typeof v === 'string' && core.isValidTaskId(v)) ids.add(v); };
164
+ if (mark) add(mark.task_id);
165
+ if (branch) add(String(branch).slice(String(branch).lastIndexOf('/') + 1));
166
+ const base = realKey(wtPath);
167
+ const leaf = base.slice(base.lastIndexOf('/') + 1);
168
+ add(leaf.slice(leaf.lastIndexOf('-') + 1));
169
+ return ids;
170
+ }
171
+
106
172
  /**
107
- * s8: kiểm "worktree đang là cwd (hoặc thư mục con) của một agent sống" trước khi gỡ. Đọc sổ agent sống data/live
108
- * (core.readLive: bỏ file quá TTL; kèm ý định giao chưa nhận SubagentStart, mang worktree của brief). Trả mô tả agent
109
- * ("agent ab12 task xyz") để removeMarked GIỮ worktree, hoặc null. Không ném: sổ lỗi thì coi như không có agent.
173
+ * s8: kiểm "worktree đang có agent sống" trước khi gỡ. Đọc sổ agent sống data/live (core.readLive: bỏ file quá TTL; kèm ý định
174
+ * giao chưa nhận SubagentStart, mang worktree của brief). Khớp theo ĐƯỜNG DẪN (cwd/worktree là worktree hoặc thư mục con) HOẶC
175
+ * theo TASK_ID (agent có cwd là run dir vẫn bắt được nếu task của nó = task gắn với worktree: dấu, hậu tố nhánh, hậu tố tên thư mục).
176
+ * `taskIds` (Set/mảng) truyền thêm nếu có. Trả { desc, lastMs } (lastMs = hoạt động cuối của agent khớp, ms epoch hoặc null) hoặc
177
+ * null. Không ném: sổ lỗi thì coi như không có agent.
110
178
  */
111
- function liveAgentInWorktree(ctx, wtPath) {
179
+ function liveActivityOf(ctx, wtPath, taskIds) {
112
180
  const wt = realKey(wtPath);
113
181
  if (!wt) return null;
114
182
  let live;
115
183
  try { live = core.readLive(ctx); } catch (_) { return null; }
184
+ const ids = new Set(taskIds || []);
185
+ let best = null;
116
186
  for (const r of live.agents.concat(live.pending)) {
117
187
  const at = realKey(r.cwd || r.worktree);
118
- if (!at || !(at === wt || at.startsWith(`${wt}/`))) continue;
188
+ const byPath = at && (at === wt || at.startsWith(`${wt}/`));
189
+ const byTask = typeof r.task_id === 'string' && ids.has(r.task_id);
190
+ if (!byPath && !byTask) continue;
119
191
  const who = r.agent_id ? `agent ${String(r.agent_id).slice(0, 8)}` : 'agent đang chờ nhận';
120
- return `${who}${r.task_id ? ` task ${r.task_id}` : ''}`;
192
+ const t = Date.parse(r.last_activity || r.start || r.t || '');
193
+ const lastMs = Number.isFinite(t) ? t : (Number.isFinite(Number(r.last_activity)) ? Number(r.last_activity) : null);
194
+ if (!best || (lastMs || 0) > (best.lastMs || 0)) {
195
+ best = { desc: `${who}${r.task_id ? ` task ${r.task_id}` : ''}${byPath ? '' : ' (khớp theo task_id)'}`, lastMs };
196
+ }
197
+ }
198
+ return best;
199
+ }
200
+
201
+ /** Tương thích: mô tả agent sống (chuỗi) hoặc null. */
202
+ function liveAgentInWorktree(ctx, wtPath, taskIds) {
203
+ const r = liveActivityOf(ctx, wtPath, taskIds);
204
+ return r ? r.desc : null;
205
+ }
206
+
207
+ // ---- r1 s1: bộ vị từ an toàn duy nhất cho MỌI worktree đăng ký trong git ----
208
+
209
+ const IDLE_UNMARKED_MS = 24 * 60 * 60 * 1000; // phụ lục Q1: worktree không dấu phải nhàn rỗi > 24 giờ
210
+ const LOCK_STALE_MS = 30 * 60 * 1000; // khoá `initializing` của `git worktree add` bị giết: mồ côi sau 30 phút
211
+ const INIT_LOCK = 'initializing';
212
+
213
+ /** Tên nhánh (bỏ `refs/heads/` hoặc `refs/remotes/<remote>/`) hoặc null nếu không phải nhánh. */
214
+ function shortBranchOf(ref) {
215
+ if (ref.startsWith('refs/heads/')) return ref.slice('refs/heads/'.length);
216
+ const m = /^refs\/remotes\/[^/]+\/(.+)$/.exec(ref);
217
+ return m ? m[1] : null;
218
+ }
219
+
220
+ /**
221
+ * Nhánh tạm (không tính là nơi "đã merge vào"): CHỈ tên do công cụ sinh: dp/*, r####-*, worktree-agent-*, claude/*.
222
+ * feat/*, rust/<id8>, x/<id8>... KHÔNG tạm (nhánh tích hợp như feat/rust-poc phải được tính là đích merge); nhánh làm việc đang
223
+ * checkout trong worktree liên kết được loại riêng bởi durablePolicy, không nhờ tên.
224
+ */
225
+ function isTempBranch(name) {
226
+ return name.startsWith(BRANCH_PREFIX) || name.startsWith('worktree-agent-') || name.startsWith('claude/') || /^r\d{4}-/.test(name);
227
+ }
228
+
229
+ /**
230
+ * Chính sách "ref bền" dùng chung (wtfast.makeFacts và mergedAnywhere): ref nào được tính là ĐÍCH mà một nhánh có thể "đã vào".
231
+ * reg = sổ worktree (mục đầu = checkout chính); hasUpstream(name) = nhánh cục bộ có upstream; exclude = nhánh đang xét.
232
+ * policy(name, local) → true khi ref bền:
233
+ * - không phải HEAD, không phải chính nhánh đang xét (cả bản remote cùng tên), không phải tên tạm (dp/*, r####-*, worktree-agent-*, claude/*)
234
+ * — trừ nhánh checkout của checkout CHÍNH (luôn bền: nhánh tích hợp như feat/overhaul);
235
+ * - nhánh CỤC BỘ đang checkout trong một worktree LIÊN KẾT là nhánh làm việc (trivially chứa chính nó, và chứa cả những gì nó
236
+ * đã nuốt từ nhánh khác): KHÔNG bền, TRỪ KHI nó có upstream (đã đẩy lên remote, vd feat/rust-poc là nhánh tích hợp đặt ở
237
+ * .claude/worktrees/rust-poc-int; nhánh làm việc rust/<id8> không upstream thì không bao giờ bền);
238
+ * - mọi ref khác (main/master/develop/release*, nhánh cục bộ không checkout ở worktree liên kết, ref remote) bền.
239
+ */
240
+ function durablePolicy(reg, hasUpstream, exclude) {
241
+ const list = reg || [];
242
+ const mainBranch = list.length && list[0].branch ? list[0].branch : null;
243
+ const linked = new Set(list.slice(1).map((r) => r.branch).filter(Boolean));
244
+ return (name, local) => {
245
+ if (!name || name === 'HEAD' || name === exclude) return false;
246
+ if (name === mainBranch && local !== false) return true;
247
+ if (isTempBranch(name)) return false;
248
+ if (local !== false && linked.has(name)) return !!(hasUpstream && hasUpstream(name));
249
+ return true;
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Nhánh `branch` đã nằm trong MỘT ref bền chưa (durablePolicy: KHÔNG tính nhánh làm việc đang checkout trong worktree liên kết
255
+ * trừ khi có upstream, không tính chính nó và nhánh tạm). Dùng
256
+ * `for-each-ref --contains <sha> refs/heads refs/remotes`. `reg` truyền sẵn thì khỏi gọi `git worktree list`.
257
+ * Trả { merged, into: [tên nhánh] }; lỗi git / không có nhánh → merged=false (giữ lại).
258
+ */
259
+ function mergedAnywhere(main, branch, reg) {
260
+ const none = { merged: false, into: [] };
261
+ if (!branch) return none;
262
+ const sha = git(main, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`]);
263
+ if (!sha.ok || !sha.out) return none;
264
+ const r = git(main, ['for-each-ref', '--contains', sha.out, '--format=%(refname)%09%(upstream)', 'refs/heads', 'refs/remotes']);
265
+ if (!r.ok) return none;
266
+ let list = reg;
267
+ if (!list) { try { list = registeredOf(main); } catch (_) { list = null; } }
268
+ if (!list) return none; // không biết ai đang checkout gì thì không dám tính nhánh nào là bền
269
+ const rows = r.out.split(String.fromCharCode(10)).map((l) => l.replace(String.fromCharCode(13), '')).filter(Boolean).map((l) => l.split(String.fromCharCode(9)));
270
+ const upstream = new Set();
271
+ for (const [ref, up] of rows) if (ref.startsWith('refs/heads/') && up) upstream.add(ref.slice('refs/heads/'.length));
272
+ const ok = durablePolicy(list, (n) => upstream.has(n), branch);
273
+ const into = [];
274
+ for (const [ref] of rows) {
275
+ const name = shortBranchOf(ref);
276
+ if (name && ok(name, ref.startsWith('refs/heads/')) && !into.includes(name)) into.push(name);
121
277
  }
278
+ return { merged: into.length > 0, into };
279
+ }
280
+
281
+ /**
282
+ * Tự gỡ được (khi wtSafety ok) chỉ với: tên do `wt add` sinh (`<tên repo>-wt-<task id 8 ký tự>`) hoặc BẤT KỲ thư mục nào dưới
283
+ * `<repo>/.claude/worktrees/` (Claude Code quản lý: agent-*, rust-<id8>...; phán quyết điều phối viên cho Q3). Tên tự đặt ở nơi khác
284
+ * (`<repo>-wt/<lane>`, `<repo>-wt-<tên>`, đường dẫn --path...) là slot giữ lại.
285
+ */
286
+ function isAutoName(main, wtPath) {
287
+ const key = realKey(wtPath);
288
+ const base = key.slice(key.lastIndexOf('/') + 1);
289
+ const mb = realKey(main);
290
+ const mainBase = mb.slice(mb.lastIndexOf('/') + 1);
291
+ const m = /^(.*)-wt-([a-z0-9]{8})$/.exec(base);
292
+ if (m && m[1] === mainBase) return true;
293
+ if (key.startsWith(`${mb}/.claude/worktrees/`) && key.length > `${mb}/.claude/worktrees/`.length) return true;
294
+ // tương thích s1: mẫu cô lập cũ `.../.claude/worktrees/agent-*` ở bất kỳ đâu.
295
+ return /\/\.claude\/worktrees\/agent-[^/]+$/.test(key);
296
+ }
297
+
298
+ /** Lý do KHÔNG tự gỡ (chuỗi) hoặc null: là checkout chính, dấu `keep: true` (tạo bằng --path), hoặc tên tự đặt. */
299
+ function keepSlotReason(main, wtPath, mark) {
300
+ if (realKey(wtPath) === realKey(main)) return 'là checkout chính';
301
+ if (mark && mark.keep === true) return 'slot giữ lại (dấu keep: true, tạo bằng --path tự đặt): chỉ gỡ tay';
302
+ if (!isAutoName(main, wtPath)) return 'slot giữ lại (tên thư mục tự đặt, không phải tên do wt add sinh, cũng không nằm dưới <repo>/.claude/worktrees/): chỉ gỡ tay';
122
303
  return null;
123
304
  }
124
305
 
306
+ /** Mục `git worktree list` của đúng đường dẫn (so sánh realKey); `reg` truyền sẵn thì khỏi gọi git lại. */
307
+ function registeredEntry(main, wtPath, reg) {
308
+ const key = realKey(wtPath);
309
+ return (reg || registeredOf(main)).find((r) => realKey(r.path) === key) || null;
310
+ }
311
+
312
+ /** Thư mục admin (git-dir) của một worktree: rev-parse trong worktree; hỏng thì quét <common>/worktrees/NAME/gitdir. */
313
+ function adminDirOf(wtPath, common) {
314
+ const r = git(wtPath, ['rev-parse', '--path-format=absolute', '--git-dir']);
315
+ // Thư mục không phải worktree đã đăng ký thì rev-parse trả .git của repo cha: chỉ nhận git-dir nằm trong <common>/worktrees.
316
+ const inWorktrees = (d) => !common || realKey(path.dirname(d)) === realKey(path.join(common, 'worktrees'));
317
+ if (r.ok && r.out && inWorktrees(r.out)) return r.out;
318
+ if (!common) return null;
319
+ const root = path.join(common, 'worktrees');
320
+ const key = realKey(wtPath);
321
+ let names = [];
322
+ try { names = fs.readdirSync(root); } catch (_) { return null; }
323
+ for (const n of names) {
324
+ try {
325
+ const g = path.dirname(fs.readFileSync(path.join(root, n, 'gitdir'), 'utf8').trim());
326
+ if (realKey(g) === key) return path.join(root, n);
327
+ } catch (_) { /* admin hỏng: bỏ qua */ }
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /** Tuổi (ms) của khoá worktree: mtime file `locked` trong git-dir admin, không có thì mtime thư mục admin. null = không đọc được. */
333
+ function lockAgeMs(admin) {
334
+ if (!admin) return null;
335
+ for (const p of [path.join(admin, 'locked'), admin]) {
336
+ try { return Date.now() - fs.statSync(p).mtimeMs; } catch (_) { /* thử mốc kế */ }
337
+ }
338
+ return null;
339
+ }
340
+
341
+ // Thư mục không tính vào "mtime file mới nhất" (nặng/sinh tự động, và đụng vào là do công cụ, không phải người làm).
342
+ const IDLE_SKIP_DIRS = new Set(['.git', 'node_modules', 'target', 'build', 'dist', 'out', 'obj', 'bin', 'Library', 'Temp', 'Logs', '.next', '.nuxt', '.gradle', '.venv', 'venv', '__pycache__', '.cache', '.turbo', 'coverage']);
343
+ const IDLE_WALK_CAP = 3000; // số mục tối đa duyệt mỗi worktree
344
+ const IDLE_WALK_MS = 1500; // thời gian tối đa duyệt mỗi worktree (đĩa lạnh + Defender: ~4 ms/mục)
345
+
125
346
  /**
126
- * Gỡ một worktree có dấu đang còn thư mục. Không --force: còn thay đổi thì giữ.
347
+ * Duyệt RỘNG (file nông trước) lấy mtime file MỚI NHẤT trong worktree, có trần số mục (opts.cap) và thời gian (opts.ms, và hạn chót
348
+ * git toàn cục nếu có); bỏ IDLE_SKIP_DIRS và con trỏ `.git`, không đi qua symlink/junction.
349
+ * Trả { newest, complete }: complete=false khi hết trần trước khi duyệt xong. Khi đó `newest` chỉ là CẬN DƯỚI của mtime thật:
350
+ * file mới có thể nằm ở phần chưa duyệt (chạm lại file không đổi nội dung, file bị gitignore: git status không thấy) nên người gọi
351
+ * PHẢI coi nhàn rỗi là không biết (null), không được dùng như một con số.
352
+ */
353
+ function walkNewest(root, opts) {
354
+ const o = opts || {};
355
+ let budget = o.cap || IDLE_WALK_CAP;
356
+ const until = Math.min(Date.now() + (o.ms || IDLE_WALK_MS), gitDeadline || Infinity);
357
+ let newest = null;
358
+ let complete = true;
359
+ const queue = [root];
360
+ for (let qi = 0; qi < queue.length; qi++) {
361
+ if (budget <= 0 || Date.now() > until) { complete = false; break; }
362
+ let ents;
363
+ try { ents = fs.readdirSync(queue[qi], { withFileTypes: true }); } catch (_) { continue; }
364
+ for (const e of ents) {
365
+ if (budget-- <= 0 || Date.now() > until) { return { newest, complete: false }; }
366
+ if (e.isSymbolicLink() || e.name === '.git') continue; // `.git` trong worktree là FILE (con trỏ), mtime = lúc tạo
367
+ const full = path.join(queue[qi], e.name);
368
+ if (e.isDirectory()) { if (!IDLE_SKIP_DIRS.has(e.name)) queue.push(full); continue; }
369
+ try { const m = fs.statSync(full).mtimeMs; if (newest === null || m > newest) newest = m; } catch (_) { /* biến mất giữa chừng */ }
370
+ }
371
+ }
372
+ return { newest, complete };
373
+ }
374
+
375
+ /** mtime file mới nhất (số) hoặc null; KHÔNG báo bị cắt: chỉ dùng cho kiểm tra/test. Quyết định an toàn dùng walkNewest/idleMsOf. */
376
+ function newestFileMtimeMs(root, opts) { return walkNewest(root, opts).newest; }
377
+
378
+ /**
379
+ * Thời gian nhàn rỗi (ms): mốc MỚI NHẤT trong {commit cuối của nhánh, mtime file mới nhất trong worktree (duyệt có trần),
380
+ * hoạt động cuối của agent sống gắn với worktree}. KHÔNG dùng mtime git-dir admin / index (git status, gc quét... tự làm chúng
381
+ * nhảy về "bây giờ"). null = không đọc được gì HOẶC duyệt file bị cắt trần (opts.cap/opts.ms): nhàn rỗi không biết, người gọi phải giữ.
382
+ * opts.cachedNewest (số|null): kết quả duyệt đầy đủ đã lưu; opts.out nhận { walk: {newest, complete, cached} }. `liveMs` = hoạt động cuối của agent sống (ms epoch) hoặc null.
383
+ * opts.walk === false: bỏ duyệt file (chỉ commit + agent sống): cận trên rẻ của nhàn rỗi thật.
384
+ */
385
+ function idleMsOf(wtPath, commitMs, liveMs, opts) {
386
+ const o = opts || {};
387
+ const marks = [];
388
+ if (commitMs === undefined) {
389
+ const c = git(wtPath, ['log', '-1', '--format=%ct']);
390
+ if (c.ok && /^\d+$/.test(c.out)) marks.push(Number(c.out) * 1000);
391
+ } else if (commitMs) marks.push(commitMs); // gc quét hàng loạt: thời điểm commit đầu nhánh lấy sẵn từ một lệnh for-each-ref
392
+ if (o.walk !== false) {
393
+ // bộ nhớ đệm của gc (HEAD + mtime thư mục worktree không đổi): kết quả duyệt đầy đủ lần trước; không có thì duyệt thật.
394
+ const w = o.cachedIncomplete === true ? { newest: null, complete: false, cached: true }
395
+ : o.cachedNewest !== undefined ? { newest: o.cachedNewest, complete: true, cached: true } : walkNewest(wtPath, o);
396
+ if (o.out) o.out.walk = w;
397
+ // Duyệt bị cắt (hết trần): không biết file nào mới hơn → nhàn rỗi KHÔNG BIẾT, không phải một con số (an toàn: coi là còn hoạt động).
398
+ if (!w.complete) return null;
399
+ if (w.newest !== null) marks.push(w.newest);
400
+ }
401
+ if (Number.isFinite(liveMs) && liveMs) marks.push(liveMs);
402
+ return marks.length ? Math.max(0, Date.now() - Math.max(...marks)) : null;
403
+ }
404
+
405
+ /**
406
+ * wtSafety(ctx, main, w, opts): vị từ an toàn DUY NHẤT để gỡ một worktree (verdict accept, wt clean, --merged, gc).
407
+ * w = mục registeredOf ({path, branch, detached, locked}) + tuỳ chọn { mark: bản ghi markedWorktrees, admin }.
408
+ * ok khi: thư mục còn; không detached; nhánh đang checkout THẬT (porcelain, không phải mark.branch) đã vào một ref bền
409
+ * (mergedAnywhere); sạch; không agent sống; nhàn rỗi đủ lâu; không khoá — hoặc khoá `initializing` mồ côi (lock.stale).
410
+ * opts: requireMerged (mặc định true), minIdleMs (mặc định 0 nếu có dấu, 24 giờ nếu không dấu: phụ lục Q1).
411
+ * Chỉ đọc, không bao giờ sửa gì. Trả { ok, why, branch_now, merged, merged_into, dirty, live, lock, idle_ms };
412
+ * lock = null | { reason, stale, age_ms } (stale=true → cần `git worktree unlock` trước khi gỡ).
413
+ */
414
+ function wtSafety(ctx, main, w, opts) {
415
+ const o = opts || {};
416
+ const requireMerged = o.requireMerged !== false;
417
+ const mark = w.mark || null;
418
+ const minIdle = Number.isFinite(o.minIdleMs) ? o.minIdleMs : (mark ? 0 : IDLE_UNMARKED_MS);
419
+ const res = {
420
+ ok: false, why: null, branch_now: w.branch || null, merged: false, merged_into: [], dirty: null, live: null, lock: null, idle_ms: null,
421
+ };
422
+ const fail = (why) => { if (!res.why) res.why = why; };
423
+ if (!w.path || !fs.existsSync(w.path)) { res.why = 'thư mục đã mất'; return res; }
424
+ // Q3: slot giữ lại (đường dẫn tự đặt) và checkout chính KHÔNG BAO GIỜ tự gỡ, với mọi đường gọi tự động.
425
+ const slot = keepSlotReason(main, w.path, mark);
426
+ if (slot) { res.why = slot; return res; }
427
+ if (w.detached || !w.branch) { res.why = 'đang ở detached HEAD (không có nhánh), có thể giữ commit riêng'; return res; }
428
+ const admin = w.admin || adminDirOf(w.path, null);
429
+ const commitMsOf = o.commitMsOf || null; // opts của gc: batch, không gọi git cho mỗi worktree
430
+ if (w.locked !== null && w.locked !== undefined) {
431
+ const reason = String(w.locked);
432
+ if (reason === INIT_LOCK) {
433
+ const age = lockAgeMs(admin);
434
+ const stale = age !== null && age >= LOCK_STALE_MS;
435
+ res.lock = { reason, stale, age_ms: age };
436
+ if (!stale) fail(`đang khoá "${INIT_LOCK}" (mới dưới 30 phút, có thể git worktree add còn chạy)`);
437
+ } else {
438
+ res.lock = { reason, stale: false, age_ms: lockAgeMs(admin) };
439
+ fail(`đang bị khoá${reason ? ` (${reason})` : ''}`);
440
+ }
441
+ }
442
+ // Đo nhàn rỗi TRƯỚC mọi lệnh git khác trong worktree (status/log có thể ghi lại index, làm mtime nhảy về "bây giờ").
443
+ const liveHit = liveActivityOf(ctx, w.path, taskIdsOfWorktree(w.path, w.branch, mark));
444
+ const liveMs = liveHit ? liveHit.lastMs : null;
445
+ res.live = liveHit ? liveHit.desc : null;
446
+ if (res.live) fail(`đang là cwd của agent sống (${res.live})`);
447
+ // Nhàn rỗi: cận trên rẻ (commit + agent sống) trước; chỉ duyệt file (đắt trên đĩa lạnh) khi opts.walkIdleIf(base, merged) đồng ý.
448
+ // Lô (for-each-ref) có thể hụt dưới tải (quá trần mỗi lệnh git): thiếu thì hỏi riêng worktree này, không coi là "không có commit".
449
+ const batched = commitMsOf ? commitMsOf(w.branch) : null;
450
+ const commitMs = Number.isFinite(batched) && batched > 0 ? batched : (() => {
451
+ const c = git(w.path, ['log', '-1', '--format=%ct']);
452
+ return c.ok && /^\d+$/.test(c.out) ? Number(c.out) * 1000 : null;
453
+ })();
454
+ const baseIdle = idleMsOf(w.path, commitMs || 0, liveMs, { walk: false });
455
+ // opts.dirtyNeeded(res, merged): gc bỏ `git status` (đắt trên repo Unity) khi kết quả không còn đổi được phân lớp.
456
+ const early = o.dirtyNeeded || o.mergedOf ? (o.mergedOf || ((b) => mergedAnywhere(main, b, o.reg)))(w.branch) : null;
457
+ const walkWanted = !o.walkIdleIf || o.walkIdleIf(baseIdle, early);
458
+ const cache = o.cache || {};
459
+ const walkOut = {};
460
+ const fullIdle = () => idleMsOf(w.path, commitMs || 0, liveMs, { cap: o.walkCap, ms: o.walkMs, cachedNewest: cache.newest, cachedIncomplete: cache.incomplete, out: walkOut });
461
+ // Đệm chỉ được rút ngắn kết quả ÂM: "bẩn" đã đệm -> giữ ngay; "sạch" không bao giờ lấy từ đệm (luôn git status thật).
462
+ const dirtyNow = () => { if (cache.dirty === true) { res.dirty_cached = true; return true; } return dirtyOf(w.path); };
463
+ if (!o.dirtyNeeded) {
464
+ res.idle_ms = walkWanted ? fullIdle() : baseIdle;
465
+ res.dirty = dirtyNow();
466
+ if (res.dirty !== false) fail(res.dirty ? 'còn thay đổi chưa commit' : 'không đọc được trạng thái git');
467
+ } else {
468
+ // gc: cận trên rẻ (commit + agent sống) quyết định có cần git status không (superset của nhu cầu thật). Duyệt file (đắt) CHỈ khi
469
+ // cây sạch: cây bẩn/không đọc được thì đằng nào cũng giữ, nhàn rỗi chính xác không đổi phân lớp.
470
+ res.idle_ms = baseIdle;
471
+ if (!o.dirtyNeeded(res, early)) {
472
+ res.dirty = null;
473
+ res.dirty_skipped = true;
474
+ } else {
475
+ res.dirty = dirtyNow();
476
+ if (res.dirty !== false) fail(res.dirty ? 'còn thay đổi chưa commit' : 'không đọc được trạng thái git');
477
+ else if (walkWanted) res.idle_ms = fullIdle();
478
+ }
479
+ }
480
+ if (walkOut.walk) {
481
+ res.walk = walkOut.walk;
482
+ // Tín hiệu nhàn rỗi nào không biết (duyệt bị cắt trần, hoặc không có mốc nào) thì KHÔNG an toàn dù minIdle = 0 (worktree có dấu).
483
+ if (res.idle_ms === null) { res.idle_unknown = true; fail(`idle-unknown: không xác định được nhàn rỗi (${walkOut.walk.complete ? 'không có mốc thời gian nào' : 'duyệt file bị cắt theo trần số mục/thời gian'}), giữ`); }
484
+ }
485
+ const m = early || mergedAnywhere(main, w.branch, o.reg);
486
+ res.merged = m.merged;
487
+ res.merged_into = m.into;
488
+ if (requireMerged && !m.merged) fail(`nhánh ${w.branch} chưa vào nhánh bền nào (gc sẽ gỡ khi nhánh vào main)`);
489
+ if (minIdle > 0 && (res.idle_ms === null || res.idle_ms < minIdle)) {
490
+ fail(`chưa nhàn rỗi đủ ${Math.round((minIdle / 3600000) * 10) / 10} giờ${res.idle_ms === null ? '' : ` (mới ${Math.round(res.idle_ms / 60000)} phút)`}`);
491
+ }
492
+ if (res.dirty_skipped) fail('chưa kiểm sạch (bỏ qua git status vì đã chắc chắn không gỡ được)');
493
+ res.ok = !res.why;
494
+ return res;
495
+ }
496
+
497
+ /** wtSafety cho một worktree có dấu: tìm mục đăng ký tương ứng (reg truyền sẵn thì khỏi gọi git). Không đăng ký → ok=false. */
498
+ function safetyOfMarked(ctx, main, w, opts, reg) {
499
+ const e = w.path ? registeredEntry(main, w.path, reg) : null;
500
+ if (!e) {
501
+ return { ok: false, why: 'không còn đăng ký trong git worktree list', branch_now: null, merged: false, merged_into: [], dirty: null, live: null, lock: null, idle_ms: null };
502
+ }
503
+ return wtSafety(ctx, main, Object.assign({}, e, { mark: w, admin: w.admin }), opts);
504
+ }
505
+
506
+ /** Lý do GIỮ (chuỗi) hoặc null nếu gỡ được. Khác nhánh của dấu thì giữ, trừ khi opts.anyBranch (xét theo nhánh đang checkout). */
507
+ function keepReason(w, s, opts) {
508
+ if (!(opts && opts.anyBranch) && w.branch && s.branch_now && s.branch_now !== w.branch) {
509
+ return `đang checkout ${s.branch_now} (không phải nhánh của task ${w.branch})`;
510
+ }
511
+ return s.ok ? null : s.why;
512
+ }
513
+
514
+ /**
515
+ * Gỡ một worktree có dấu đang còn thư mục. Không --force. Mọi điều kiện an toàn qua wtSafety (+ nhánh đang checkout phải
516
+ * là nhánh của dấu, trừ opts.anyBranch; slot giữ lại / tên tự đặt không bao giờ được gỡ)..
127
517
  * s3: gỡ junction node_modules (không đi xuyên) TRƯỚC `git worktree remove`; git lỗi thì khôi phục junction.
128
- * Gỡ xong mà còn vỏ thư mục (git để lại) thì xoá vỏ bằng removeShell (link chỉ unlink, không vào đích).
518
+ * Khoá `initializing` mồ côi → `git worktree unlock` trước khi gỡ. Gỡ xong mà còn vỏ thư mục thì removeShell.
129
519
  */
130
- function removeMarked(w, mainDir, ctx) {
131
- const busy = liveAgentInWorktree(ctx, w.path);
132
- if (busy) return { removed: false, msg: `giữ ${w.path}: đang là cwd của agent sống (${busy})` };
133
- const dirty = dirtyOf(w.path);
134
- if (dirty !== false) return { removed: false, msg: `giữ ${w.path}: ${dirty ? 'còn thay đổi chưa commit' : 'không đọc được trạng thái git'}` };
520
+ function removeMarked(w, mainDir, ctx, opts) {
521
+ const s = safetyOfMarked(ctx, mainDir, w, opts);
522
+ const why = keepReason(w, s, opts);
523
+ if (why) return { removed: false, msg: `giữ ${w.path}: ${why}` };
524
+ let unlocked = '';
525
+ if (s.lock && s.lock.stale) {
526
+ const u = git(mainDir, ['worktree', 'unlock', w.path]);
527
+ if (!u.ok) return { removed: false, msg: `giữ ${w.path}: không mở được khoá "${INIT_LOCK}" mồ côi (${u.err || 'không rõ'})` };
528
+ unlocked = `; đã mở khoá "${INIT_LOCK}" mồ côi`;
529
+ }
135
530
  let links;
136
531
  try { links = deps.unlinkDepLinks(w.path, [w.deps_path || '.']); } catch (e) {
137
532
  return { removed: false, msg: `giữ ${w.path}: không gỡ được junction node_modules (${e.message}); KHÔNG gỡ worktree để tránh xoá xuyên junction` };
@@ -146,7 +541,7 @@ function removeMarked(w, mainDir, ctx) {
146
541
  try { deps.removeShell(w.path); } catch (e) { shell = `; còn vỏ thư mục chưa xoá được (${e.message})`; }
147
542
  }
148
543
  const unl = links.length ? `; đã gỡ junction node_modules trước` : '';
149
- return { removed: true, msg: `đã gỡ worktree ${w.path} (nhánh ${w.branch || '?'} giữ nguyên${unl}${shell})` };
544
+ return { removed: true, msg: `đã gỡ worktree ${w.path} (nhánh ${w.branch || '?'} giữ nguyên${unl}${unlocked}${shell})` };
150
545
  }
151
546
 
152
547
  function recordWt(ctx, taskId, fields) {
@@ -171,7 +566,8 @@ function reposOfTask(events, taskId, repoDir) {
171
566
  * verdict --task accept (người dùng) / discard: gỡ worktree có dấu của đúng task. Trả các dòng báo, không ném.
172
567
  * repoDir: --repo của lệnh (nếu có) — ngoài repo đã ghi trong sự kiện worktree.
173
568
  */
174
- function cleanupTask(ctx, events, taskId, repoDir) {
569
+ function cleanupTask(ctx, events, taskId, repoDir, opts) {
570
+ const discard = !!(opts && opts.discard);
175
571
  const lines = [];
176
572
  for (const repo of reposOfTask(events, taskId, repoDir)) {
177
573
  let info;
@@ -179,9 +575,13 @@ function cleanupTask(ctx, events, taskId, repoDir) {
179
575
  const mine = markedWorktrees(info.common).filter((w) => w.task_id === taskId);
180
576
  for (const w of mine) {
181
577
  if (!w.exists) { lines.push(`wt: ${w.path || w.name} đã mất thư mục — dọn bằng dieu-phoi wt clean --yes`); continue; }
578
+ // r1 s1: accept VÀ discard chỉ gỡ khi wtSafety ok (nhánh đang checkout là nhánh của task, đã merge, sạch, không agent
579
+ // sống, không phải slot giữ lại). Việc bỏ dở chưa merge (Q2) thuộc `wt gc --abandoned` (tag dp-archive, người dùng duyệt).
182
580
  const r = removeMarked(w, info.main, ctx);
183
581
  if (r.removed) recordWt(ctx, taskId, { action: 'remove', path: w.path, repo: info.main, reason: 'verdict' });
184
- lines.push(`wt: ${r.msg}`);
582
+ const hint = discard && !r.removed && r.msg.includes('chưa vào nhánh bền nào')
583
+ ? '; việc bỏ dở chưa merge được giữ nguyên: dùng dieu-phoi wt gc (lớp abandoned: lưu tag dp-archive/... rồi gỡ khi bạn duyệt)' : '';
584
+ lines.push(`wt: ${r.msg}${hint}`);
185
585
  }
186
586
  }
187
587
  return lines;
@@ -220,9 +620,12 @@ function registeredOf(main) {
220
620
  const out = [];
221
621
  let cur = null;
222
622
  for (const line of r.out.split(/\r?\n/)) {
223
- if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null, detached: false }; out.push(cur); }
623
+ if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null, detached: false, locked: null, prunable: false }; out.push(cur); }
224
624
  else if (cur && line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
225
625
  else if (cur && line === 'detached') cur.detached = true;
626
+ // r1 s1: `locked` (không lý do → '') hoặc `locked <lý do>`; null = không khoá.
627
+ else if (cur && (line === 'locked' || line.startsWith('locked '))) cur.locked = line.slice(7);
628
+ else if (cur && (line === 'prunable' || line.startsWith('prunable '))) cur.prunable = true;
226
629
  }
227
630
  return out;
228
631
  }
@@ -276,7 +679,7 @@ function reusableLines(ctx, main, common) {
276
679
  if (v === 'pass' || v === 'accept') why = `verdict ${v}`;
277
680
  else if (v !== undefined && !NOT_DONE.has(v) && branchMerged(main, w.branch)) why = 'nhánh đã vào HEAD';
278
681
  if (!why) continue;
279
- if (liveAgentInWorktree(ctx, w.path)) continue;
682
+ if (liveAgentInWorktree(ctx, w.path, taskIdsOfWorktree(w.path, w.branch, w))) continue;
280
683
  if (dirtyOf(w.path) !== false) continue;
281
684
  rows.push(` - ${w.path} task=${w.task_id} nhánh=${w.branch || '?'} (${why})`);
282
685
  }
@@ -340,13 +743,33 @@ function wtAdd(opts) {
340
743
  if (fs.existsSync(wtPath)) throw new Error(`wt add: ${wtPath} đã tồn tại`);
341
744
  const branch = typeof opts.branch === 'string' && opts.branch ? opts.branch : `${BRANCH_PREFIX}${taskId}`;
342
745
  const r = git(main, ['worktree', 'add', '-b', branch, wtPath, ...(base ? [base] : [])]);
343
- if (!r.ok) throw new Error(`git worktree add lỗi: ${r.err || 'không rõ'}`);
344
- const adminDir = git(wtPath, ['rev-parse', '--path-format=absolute', '--git-dir']).out;
746
+ const mark = { task_id: taskId, root: main, created_ts: core.nowIso(), branch, deps: 'none', deps_path: depsPath };
747
+ // Q3: --path tự đặt (khác tên mặc định do wt add sinh) → slot giữ lại, không bao giờ tự gỡ.
748
+ if (pathKey(wtPath) !== pathKey(path.join(path.dirname(main), `${path.basename(main)}-wt-${taskId}`))) mark.keep = true;
749
+ if (!r.ok) {
750
+ // r1 s1: git có thể đã đăng ký worktree (và khoá "initializing") trước khi lỗi/bị giết → vẫn ghi dấu + mở khoá,
751
+ // để discard / wt clean nhận ra, không để lại worktree mồ côi.
752
+ const adm = fs.existsSync(wtPath) && registeredEntry(main, wtPath) ? adminDirOf(wtPath, common) : null;
753
+ if (adm) {
754
+ fs.writeFileSync(path.join(adm, MARK), `${JSON.stringify(mark, null, 2)}\n`);
755
+ const e = registeredEntry(main, wtPath);
756
+ if (e && e.locked === INIT_LOCK) git(main, ['worktree', 'unlock', wtPath]);
757
+ recordWt(ctx, taskId, { action: 'add', path: wtPath, repo: main, branch, deps: 'none' });
758
+ throw new Error(`git worktree add lỗi: ${r.err || 'không rõ'}. Worktree ${wtPath} đã được đăng ký dở nên đã ghi dấu task ${taskId}; dọn bằng dieu-phoi discard --task ${taskId} hoặc wt clean.`);
759
+ }
760
+ throw new Error(`git worktree add lỗi: ${r.err || 'không rõ'}`);
761
+ }
762
+ const adminDir = adminDirOf(wtPath, common);
345
763
  if (!adminDir) throw new Error(`worktree ${wtPath} tạo xong nhưng không đọc được git-dir để ghi dấu`);
346
764
  const markPath = path.join(adminDir, MARK);
347
- const mark = { task_id: taskId, root: main, created_ts: core.nowIso(), branch, deps: 'none', deps_path: depsPath };
348
765
  // Dấu ghi trước khi chuẩn bị deps: nếu chết giữa chừng, worktree vẫn có dấu để wt clean nhận ra.
349
766
  fs.writeFileSync(markPath, `${JSON.stringify(mark, null, 2)}\n`);
767
+ // r1 s1: git trên Windows có thể để lại khoá "initializing" nếu tiến trình bị giết giữa checkout → mở khoá và báo.
768
+ const lk = registeredEntry(main, wtPath);
769
+ if (lk && lk.locked === INIT_LOCK) {
770
+ const u = git(main, ['worktree', 'unlock', wtPath]);
771
+ notes.push(u.ok ? `Đã mở khoá "${INIT_LOCK}" git để lại trên ${wtPath}.` : `CẢNH BÁO: ${wtPath} còn khoá "${INIT_LOCK}" và không mở được (${u.err || 'không rõ'}); chạy git worktree unlock "${wtPath}".`);
772
+ }
350
773
  const d = deps.setupDeps({ main, wtPath, mode: depsMode, depsPath });
351
774
  if (d.deps !== 'none') {
352
775
  mark.deps = d.deps;
@@ -380,13 +803,16 @@ function wtClean(opts) {
380
803
  .map((e) => e.task_id));
381
804
  const gone = agg.discardedTaskIds(events);
382
805
  const rows = [];
806
+ const reg = registeredOf(main);
383
807
  for (const w of markedWorktrees(common)) {
384
808
  let why = null;
385
809
  if (!w.exists) why = 'thư mục đã mất';
386
810
  else if (accepted.has(w.task_id)) why = 'đã nghiệm thu';
387
811
  else if (gone.has(w.task_id)) why = 'đã loại (discard)';
388
- else if (ageMsOf(w) >= staleDays * DAY_MS && branchMerged(main, w.branch) && dirtyOf(w.path) === false) {
389
- why = `cũ ≥ ${staleDays} ngày, nhánh đã merge vào HEAD`;
812
+ else if (ageMsOf(w) >= staleDays * DAY_MS) {
813
+ // r1 s1: "đã merge" xét theo nhánh đang checkout THẬT, vào một ref bền bất kỳ (không chỉ HEAD checkout chính).
814
+ const sf = safetyOfMarked(ctx, main, w, { requireMerged: true }, reg);
815
+ if (sf.merged && sf.dirty === false) why = `cũ ≥ ${staleDays} ngày, nhánh đã merge (${sf.branch_now} vào ${sf.merged_into[0]})`;
390
816
  }
391
817
  if (why) rows.push(Object.assign({}, w, { why }));
392
818
  }
@@ -404,12 +830,12 @@ function wtClean(opts) {
404
830
  } else lines.push(`sẽ bỏ đăng ký ${w.path || w.name}: ${label}`);
405
831
  continue;
406
832
  }
407
- const busy = liveAgentInWorktree(ctx, w.path);
408
- if (busy) { lines.push(`giữ ${w.path}: đang là cwd của agent sống (${busy}) — ${label}`); continue; }
409
- const dirty = dirtyOf(w.path);
410
- if (dirty !== false) { lines.push(`giữ ${w.path}: ${dirty ? 'còn thay đổi chưa commit' : 'không đọc được trạng thái git'} — ${label}`); continue; }
833
+ // r1 s1: cùng vị từ wtSafety cho dry-run lẫn --yes (kể cả task bị loại: chưa merge thì để lớp abandoned của gc xử lý).
834
+ const ropts = { anyBranch: w.why.startsWith('cũ ≥') };
835
+ const hold = keepReason(w, safetyOfMarked(ctx, main, w, ropts, reg), ropts);
836
+ if (hold) { lines.push(`giữ ${w.path}: ${hold} — ${label}`); continue; }
411
837
  if (!apply) { lines.push(`sẽ gỡ ${w.path}: ${label}`); continue; }
412
- const r = removeMarked(w, main, ctx);
838
+ const r = removeMarked(w, main, ctx, ropts);
413
839
  if (r.removed) recordWt(ctx, w.task_id, { action: 'remove', path: w.path, repo: main, reason: w.why });
414
840
  lines.push(`${r.msg}: ${label}`);
415
841
  }
@@ -478,21 +904,20 @@ function mergedPlan(ctx, main, common, events) {
478
904
  const tsOf = (w) => (w.created_ts && Number.isFinite(Date.parse(w.created_ts)) ? Date.parse(w.created_ts) : Infinity);
479
905
  const go = [];
480
906
  const kept = [];
907
+ const reg = registeredOf(main);
481
908
  for (const w of markedWorktrees(common).sort((a, b) => tsOf(a) - tsOf(b))) {
482
909
  if (!w.exists || !w.branch || pathKey(w.path) === pathKey(main)) continue;
483
- if (!branchMerged(main, w.branch)) continue;
910
+ // r1 s1: xét theo nhánh đang checkout THẬT (có thể đã đổi khỏi nhánh của dấu) và ref bền bất kỳ, qua wtSafety.
911
+ const sf = safetyOfMarked(ctx, main, w, { requireMerged: true }, reg);
912
+ if (!sf.merged) continue;
484
913
  const v = last.get(w.task_id);
485
914
  let why = null;
486
915
  if (ageMsOf(w) < MERGED_MIN_AGE_MS) why = 'mới tạo dưới 1 giờ';
487
916
  else if (v === undefined) why = 'chưa có verdict';
488
917
  else if (NOT_DONE.has(v)) why = `verdict ${v}`;
489
- else {
490
- const busy = liveAgentInWorktree(ctx, w.path);
491
- if (busy) why = `đang là cwd của agent sống (${busy})`;
492
- else if (dirtyOf(w.path) !== false) why = 'còn thay đổi chưa commit hoặc không đọc được trạng thái git';
493
- }
918
+ else if (!sf.ok) why = sf.why;
494
919
  if (why) kept.push({ w, why });
495
- else go.push(w);
920
+ else go.push(Object.assign({}, w, { branch_now: sf.branch_now }));
496
921
  }
497
922
  return { go, kept };
498
923
  }
@@ -511,12 +936,18 @@ function mergedRun(ctx, main, common, apply) {
511
936
  lines.push(`sẽ gỡ ${w.path} và xoá nhánh (git branch -d): ${label} (đã merge, có verdict, sạch, không agent sống)`);
512
937
  continue;
513
938
  }
514
- const r = removeMarked(w, main, ctx);
939
+ const r = removeMarked(w, main, ctx, { anyBranch: true });
515
940
  if (!r.removed) { lines.push(`${r.msg}: ${label}`); continue; }
516
941
  removed++;
517
942
  recordWt(ctx, w.task_id, { action: 'remove', path: w.path, repo: main, reason: 'merged' });
518
- const b = git(main, ['branch', '-d', w.branch]);
519
- lines.push(`đã gỡ worktree ${w.path}; ${b.ok ? `đã xoá nhánh ${w.branch} (git branch -d)` : `giữ nhánh ${w.branch}: git branch -d lỗi (${b.err || 'không rõ'})`}: ${label}`);
943
+ // Nhánh của dấu chỉ bị `git branch -d` nếu nó cũng đã vào một ref bền (nhánh đã đổi sang cái khác thì có thể chưa).
944
+ let brMsg;
945
+ if (!mergedAnywhere(main, w.branch).merged) brMsg = `giữ nhánh ${w.branch}: chưa vào nhánh bền nào`;
946
+ else {
947
+ const b = git(main, ['branch', '-d', w.branch]);
948
+ brMsg = b.ok ? `đã xoá nhánh ${w.branch} (git branch -d)` : `giữ nhánh ${w.branch}: git branch -d lỗi (${b.err || 'không rõ'})`;
949
+ }
950
+ lines.push(`đã gỡ worktree ${w.path}; ${brMsg}: ${label}`);
520
951
  }
521
952
  return { n: go.length, removed, lines };
522
953
  }
@@ -562,15 +993,59 @@ function mergedAfterAccept(ctx, taskIds, repoDir, clean) {
562
993
  return lines;
563
994
  }
564
995
 
996
+ /**
997
+ * r1 s5 (a): sau verdict accept của người dùng — gc lớp safe giới hạn ở repo của các task vừa nghiệm thu, in những gì đã gỡ.
998
+ * wt_auto_gc=off → quay về hành vi cũ (nhắc / --clean-merged). --clean-merged giữ làm bí danh (gc safe bao trùm nó).
999
+ */
1000
+ function gcAfterAccept(ctx, taskIds, repoDir, legacyClean) {
1001
+ if (core.autoGcModeOf(ctx) === 'off') return mergedAfterAccept(ctx, taskIds, repoDir, legacyClean === true);
1002
+ const lines = [];
1003
+ try {
1004
+ const events = core.readEvents(ctx);
1005
+ const dirs = new Set();
1006
+ for (const id of taskIds) {
1007
+ for (const r of reposOfTask(events, id, repoDir)) {
1008
+ try { dirs.add(mainCheckoutOf(r).main); } catch (e) { lines.push('wt gc: bỏ qua ' + r + ' (' + e.message + ')'); }
1009
+ }
1010
+ }
1011
+ if (!dirs.size) return lines;
1012
+ const G = require('./wtgc.cjs');
1013
+ const rep = G.autoGc(ctx, { repoDirs: Array.from(dirs), trigger: 'verdict', budgetMs: 20000 });
1014
+ for (const r of rep.repos) {
1015
+ for (const l of r.applied || []) if (/^đã (gỡ|xoá|git worktree prune)/.test(l)) lines.push('wt gc (tự động, lớp safe) ' + r.repo + ': ' + l);
1016
+ if (r.error) lines.push('wt gc: ' + r.repo + ' lỗi: ' + r.error);
1017
+ }
1018
+ const c = rep.counts || {};
1019
+ lines.push('wt gc (tự động, lớp safe): đã gỡ ' + rep.removed + ' mục, ~' + G.fmtBytes(rep.reclaimed_bytes) + '; còn giữ ' + (c.abandoned || 0) + ' bỏ dở / ' + (c['keep-slot'] || 0) + ' slot / ' + (c.keep || 0) + ' khác (không tự gỡ; xem: dieu-phoi wt gc --repo <repo> --measure)');
1020
+ } catch (e) {
1021
+ lines.push('wt gc (tự động): bỏ qua (' + e.message + ')');
1022
+ }
1023
+ return lines;
1024
+ }
1025
+
565
1026
  function cmdWt(opts) {
566
1027
  const sub = opts._[0];
567
- if (sub === 'add') return wtAdd(opts);
1028
+ if (sub === 'add') {
1029
+ const out = wtAdd(opts);
1030
+ try { repos.recordRepo(ctxOf(opts).paths.data, opts.repo || process.cwd(), 'wt'); } catch (_) { /* sổ repo: best effort */ }
1031
+ return out;
1032
+ }
568
1033
  if (sub === 'clean') return opts.merged === true ? wtCleanMerged(opts) : wtClean(opts);
569
1034
  if (sub === 'list') return wtList(opts);
1035
+ if (sub === 'repos') return repos.cmdRepos(opts, ctxOf(opts));
1036
+ if (sub === 'gc') return require('./wtgc.cjs').cmdGc(opts); // r1 s3: tự dọn mọi repo đã chạm
570
1037
  throw new Error('Cú pháp: dieu-phoi wt add --task <task_id> [--repo r] [--path dir] [--branch b] [--base ref] [--force] [--deps auto|link|install|none] [--deps-path dir]\n'
571
1038
  + ' dieu-phoi wt clean [--repo r] [--stale-days N] [--yes]\n'
572
1039
  + ' dieu-phoi wt clean --merged [--repo r] [--yes]\n'
573
- + ' dieu-phoi wt list [--repo r] [--all] [--json]');
1040
+ + ' dieu-phoi wt list [--repo r] [--all] [--json]\n'
1041
+ + ' dieu-phoi wt repos [--seed <thư mục…>] [--json]\n'
1042
+ + ' dieu-phoi wt gc [--repo r | --all-repos] [--yes] [--json] [--no-measure]');
574
1043
  }
575
1044
 
576
- module.exports = { cmdWt, cleanupTask, mergedAfterAccept, mainCheckoutOf, markedWorktrees, registeredOf, liveAgentInWorktree, MARK };
1045
+ module.exports = {
1046
+ walkNewest,
1047
+ cmdWt, cleanupTask, mergedAfterAccept, gcAfterAccept, mainCheckoutOf, markedWorktrees, registeredOf, liveAgentInWorktree, MARK,
1048
+ prefetchDirty, clearDirtyCache, wtSafety, safetyOfMarked, mergedAnywhere, durablePolicy, registeredEntry, adminDirOf, liveActivityOf, taskIdsOfWorktree, idleMsOf, newestFileMtimeMs, setGitDeadline,
1049
+ // r1 s3: dùng bởi lib/wtgc.cjs
1050
+ gitRun: git, realKey, dirtyOf, isTempBranch, keepSlotReason, isAutoName, numSetting, ageMsOf, recordWt, NOT_DONE, MERGED_MIN_AGE_MS,
1051
+ };