@kienntpixon/dieu-phoi 0.7.4 → 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
@@ -7,6 +7,9 @@
7
7
  // `git worktree remove` (không --force), nhánh giữ nguyên; còn thay đổi chưa commit thì giữ và báo.
8
8
  // Worktree không dấu không bị đụng.
9
9
  // - `wt clean` (dry-run mặc định) liệt kê worktree có dấu mà task đã nghiệm thu / bị loại, hoặc thư mục đã mất.
10
+ // core 0.7.5 (s4):
11
+ // - `wt clean --merged`: gỡ worktree có dấu mà nhánh đã vào HEAD checkout chính, có verdict (không reject/fail),
12
+ // tuổi ≥ 1 giờ, không agent sống, sạch; rồi `git branch -d`. verdict accept của người dùng chỉ nhắc (--clean-merged: gỡ).
10
13
  // core 0.7.3 (s2):
11
14
  // - Trần worktree/repo (max_worktrees_repo, mặc định 8, 0 = tắt): `wt add` đếm mọi mục của `git worktree list`
12
15
  // (gồm checkout chính, có dấu hay không). Đủ trần → từ chối kèm ứng viên dọn (sạch, có dấu, cũ nhất); --force bỏ qua.
@@ -25,15 +28,27 @@ const core = require('../hook.cjs');
25
28
  const agg = require('./aggregate.cjs');
26
29
  const { ctxOf, repoOf } = require('./args.cjs');
27
30
  const deps = require('./deps.cjs');
31
+ const repos = require('./repos.cjs'); // core 0.7.6 (s2): sổ repo đã chạm (data/repos.json)
28
32
 
29
33
  const MARK = 'dieu-phoi.json';
30
34
  const BRANCH_PREFIX = 'dp/';
31
35
  const DAY_MS = 86400000;
32
36
  const DEFAULT_MAX_WORKTREES = 8;
33
37
  const DEFAULT_STALE_DAYS = 7;
38
+ // s3 (core 0.7.5): verdict cuối không thuộc nhóm này thì bước chưa xong (nhánh không có commit mới vẫn "đã merge").
39
+ const NOT_DONE = new Set(['reject', 'fail', 'timeout', 'aborted']);
40
+ // s4 (0.7.5): `wt clean --merged` chỉ gỡ worktree tuổi ≥ 1 giờ có verdict (pass/accept). Worktree vừa tạo có 0 commit
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ỡ.
42
+ const MERGED_MIN_AGE_MS = 60 * 60 * 1000;
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; }
34
48
 
35
49
  function git(cwd, args) {
36
- 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 });
37
52
  const err = r.stderr || (r.error ? r.error.message : '') || '';
38
53
  return { ok: r.status === 0, out: (r.stdout || '').trim(), err: err.trim() };
39
54
  }
@@ -66,18 +81,65 @@ function markedWorktrees(common) {
66
81
  out.push({
67
82
  admin, name, task_id: mark.task_id, root: mark.root || null, branch: mark.branch || null,
68
83
  created_ts: mark.created_ts || null, path: wtPath, exists: !!wtPath && fs.existsSync(wtPath),
84
+ keep: mark.keep === true,
69
85
  deps: typeof mark.deps === 'string' ? mark.deps : null, deps_path: typeof mark.deps_path === 'string' ? mark.deps_path : '.',
70
86
  });
71
87
  }
72
88
  return out;
73
89
  }
74
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
+
75
134
  /**
76
135
  * true = còn thay đổi (kể cả file chưa theo dõi), false = sạch, null = không đọc được.
77
136
  * s3: dòng `?? …node_modules/` là link (junction dùng chung do --deps link tạo) không tính là thay đổi.
78
137
  */
79
138
  function dirtyOf(wtPath) {
80
- 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']);
81
143
  if (!r.ok) return null;
82
144
  const lines = r.out.split(/\r?\n/).filter(Boolean).filter((l) => {
83
145
  const m = /^\?\? "?(.*?node_modules)\/?"?$/.exec(l);
@@ -95,35 +157,376 @@ function realKey(p) {
95
157
  return process.platform === 'win32' ? r.toLowerCase() : r;
96
158
  }
97
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
+
98
172
  /**
99
- * 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
100
- * (core.readLive: bỏ file quá TTL; kèm ý định giao chưa nhận SubagentStart, mang worktree của brief). Trả mô tả agent
101
- * ("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.
102
178
  */
103
- function liveAgentInWorktree(ctx, wtPath) {
179
+ function liveActivityOf(ctx, wtPath, taskIds) {
104
180
  const wt = realKey(wtPath);
105
181
  if (!wt) return null;
106
182
  let live;
107
183
  try { live = core.readLive(ctx); } catch (_) { return null; }
184
+ const ids = new Set(taskIds || []);
185
+ let best = null;
108
186
  for (const r of live.agents.concat(live.pending)) {
109
187
  const at = realKey(r.cwd || r.worktree);
110
- 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;
111
191
  const who = r.agent_id ? `agent ${String(r.agent_id).slice(0, 8)}` : 'agent đang chờ nhận';
112
- 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);
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';
303
+ return null;
304
+ }
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 */ }
113
328
  }
114
329
  return null;
115
330
  }
116
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
+
346
+ /**
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
+
117
405
  /**
118
- * Gỡ một worktree có dấu đang còn thư mục. Không --force: còn thay đổi thì giữ.
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ỡ)..
119
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.
120
- * 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.
121
519
  */
122
- function removeMarked(w, mainDir, ctx) {
123
- const busy = liveAgentInWorktree(ctx, w.path);
124
- if (busy) return { removed: false, msg: `giữ ${w.path}: đang là cwd của agent sống (${busy})` };
125
- const dirty = dirtyOf(w.path);
126
- 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
+ }
127
530
  let links;
128
531
  try { links = deps.unlinkDepLinks(w.path, [w.deps_path || '.']); } catch (e) {
129
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` };
@@ -138,7 +541,7 @@ function removeMarked(w, mainDir, ctx) {
138
541
  try { deps.removeShell(w.path); } catch (e) { shell = `; còn vỏ thư mục chưa xoá được (${e.message})`; }
139
542
  }
140
543
  const unl = links.length ? `; đã gỡ junction node_modules trước` : '';
141
- 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})` };
142
545
  }
143
546
 
144
547
  function recordWt(ctx, taskId, fields) {
@@ -163,7 +566,8 @@ function reposOfTask(events, taskId, repoDir) {
163
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.
164
567
  * repoDir: --repo của lệnh (nếu có) — ngoài repo đã ghi trong sự kiện worktree.
165
568
  */
166
- function cleanupTask(ctx, events, taskId, repoDir) {
569
+ function cleanupTask(ctx, events, taskId, repoDir, opts) {
570
+ const discard = !!(opts && opts.discard);
167
571
  const lines = [];
168
572
  for (const repo of reposOfTask(events, taskId, repoDir)) {
169
573
  let info;
@@ -171,9 +575,13 @@ function cleanupTask(ctx, events, taskId, repoDir) {
171
575
  const mine = markedWorktrees(info.common).filter((w) => w.task_id === taskId);
172
576
  for (const w of mine) {
173
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).
174
580
  const r = removeMarked(w, info.main, ctx);
175
581
  if (r.removed) recordWt(ctx, taskId, { action: 'remove', path: w.path, repo: info.main, reason: 'verdict' });
176
- 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}`);
177
585
  }
178
586
  }
179
587
  return lines;
@@ -212,9 +620,12 @@ function registeredOf(main) {
212
620
  const out = [];
213
621
  let cur = null;
214
622
  for (const line of r.out.split(/\r?\n/)) {
215
- 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); }
216
624
  else if (cur && line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '');
217
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;
218
629
  }
219
630
  return out;
220
631
  }
@@ -247,6 +658,35 @@ function candidateLines(common) {
247
658
  return `Ứng viên dọn (sạch, có dấu, cũ nhất trước):\n${c.map((w) => ` - ${w.path} task=${w.task_id} tạo ${w.created_ts || '?'}`).join('\n')}`;
248
659
  }
249
660
 
661
+ /**
662
+ * s3 (core 0.7.5): worktree có dấu của bước đã xong, có thể tái dùng. Điều kiện: còn thư mục, sạch, không có agent
663
+ * sống, task không bị loại, và (verdict cuối của task là pass/accept, hoặc nhánh đã vào HEAD mà task đã có verdict).
664
+ * Nhánh "đã merge" một mình không đủ: worktree vừa tạo, chưa commit, cũng có 0 commit ngoài HEAD.
665
+ * Chỉ để báo, không bao giờ tự dùng lại. Không có ứng viên thì trả chuỗi rỗng.
666
+ */
667
+ function reusableLines(ctx, main, common) {
668
+ const events = core.readEvents(ctx);
669
+ const gone = agg.discardedTaskIds(events);
670
+ const last = new Map();
671
+ for (const e of events) {
672
+ if (e && e.kind === 'verdict' && typeof e.task_id === 'string') last.set(e.task_id, agg.normalizeVerdict(e).outcome);
673
+ }
674
+ const rows = [];
675
+ for (const w of markedWorktrees(common)) {
676
+ if (!w.exists || gone.has(w.task_id)) continue;
677
+ const v = last.get(w.task_id);
678
+ let why = null;
679
+ if (v === 'pass' || v === 'accept') why = `verdict ${v}`;
680
+ else if (v !== undefined && !NOT_DONE.has(v) && branchMerged(main, w.branch)) why = 'nhánh đã vào HEAD';
681
+ if (!why) continue;
682
+ if (liveAgentInWorktree(ctx, w.path, taskIdsOfWorktree(w.path, w.branch, w))) continue;
683
+ if (dirtyOf(w.path) !== false) continue;
684
+ rows.push(` - ${w.path} task=${w.task_id} nhánh=${w.branch || '?'} (${why})`);
685
+ }
686
+ if (!rows.length) return '';
687
+ return `Worktree bước đã xong có thể tái dùng (chỉ báo, không tự dùng lại; gỡ bằng dieu-phoi wt clean sau khi xem dry-run):\n${rows.join('\n')}`;
688
+ }
689
+
250
690
  /** Trạng thái node_modules của một worktree: link (symlink/junction), dir (thư mục thật), none. */
251
691
  function nodeModulesOf(p) {
252
692
  try {
@@ -256,8 +696,22 @@ function nodeModulesOf(p) {
256
696
  } catch (_) { return 'none'; }
257
697
  }
258
698
 
699
+ /**
700
+ * s3 (core 0.7.5): --base <ref> cho `wt add`. Không có --base → nhánh mới từ HEAD của checkout chính (như cũ).
701
+ * Ref phải là commit hợp lệ trong repo chính; không bắt đầu bằng `-` (tránh bị git hiểu là tuỳ chọn).
702
+ */
703
+ function baseArg(opts, main) {
704
+ if (opts.base === undefined) return null;
705
+ if (typeof opts.base !== 'string' || !opts.base || opts.base.startsWith('-')) {
706
+ throw new Error('wt add: --base cần tên ref (nhánh, tag hoặc commit), không bắt đầu bằng "-"');
707
+ }
708
+ const v = git(main, ['rev-parse', '--verify', '--quiet', `${opts.base}^{commit}`]);
709
+ if (!v.ok || !v.out) throw new Error(`wt add: --base "${opts.base}" không phải commit/nhánh hợp lệ trong ${main}`);
710
+ return opts.base;
711
+ }
712
+
259
713
  function wtAdd(opts) {
260
- if (typeof opts.task !== 'string') throw new Error('Cú pháp: dieu-phoi wt add --task <task_id> [--repo r] [--path dir] [--branch b] [--force] [--deps auto|link|install|none] [--deps-path dir]');
714
+ if (typeof opts.task !== 'string') 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]');
261
715
  const taskId = requireTaskId(opts.task);
262
716
  const ctx = ctxOf(opts);
263
717
  const depsMode = deps.depsModeOf(opts, ctx);
@@ -265,29 +719,57 @@ function wtAdd(opts) {
265
719
  const { main, common } = mainCheckoutOf(repoOf(opts));
266
720
  const dup = markedWorktrees(common).filter((w) => w.task_id === taskId && w.exists);
267
721
  if (dup.length) throw new Error(`task ${taskId} đã có worktree có dấu: ${dup.map((w) => w.path).join(', ')}`);
722
+ const base = baseArg(opts, main);
268
723
  const max = maxWorktreesOf(ctx);
269
724
  const total = registeredOf(main).length;
270
725
  const capMsg = max > 0 && total >= max ? `repo đã có ${total}/${max} worktree (trần max_worktrees_repo)` : null;
726
+ const near = max > 0 && total + 1 >= warnAtOf(max);
271
727
  if (capMsg && opts.force !== true) {
728
+ const hint = reusableLines(ctx, main, common);
272
729
  throw new Error(`wt add: ${capMsg}; không tạo thêm cho task ${taskId}.\n${candidateLines(common)}\n`
730
+ + (hint ? `${hint}\n` : '')
273
731
  + 'Dọn: dieu-phoi wt clean --yes (sau khi xem dry-run) hoặc dieu-phoi wt list --all; tạo vượt trần: thêm --force.');
274
732
  }
275
733
  const notes = [];
276
734
  if (capMsg) notes.push(`CẢNH BÁO: vượt trần (--force): ${capMsg}`);
277
- else if (max > 0 && total + 1 >= warnAtOf(max)) notes.push(`CẢNH BÁO: repo sẽ có ${total + 1}/${max} worktree (đạt 75% trần)`);
735
+ else if (near) notes.push(`CẢNH BÁO: repo sẽ có ${total + 1}/${max} worktree (đạt 75% trần)`);
736
+ if (capMsg || near) {
737
+ const hint = reusableLines(ctx, main, common);
738
+ if (hint) notes.push(hint);
739
+ }
278
740
  const wtPath = typeof opts.path === 'string' && opts.path
279
741
  ? path.resolve(opts.path)
280
742
  : path.join(path.dirname(main), `${path.basename(main)}-wt-${taskId}`);
281
743
  if (fs.existsSync(wtPath)) throw new Error(`wt add: ${wtPath} đã tồn tại`);
282
744
  const branch = typeof opts.branch === 'string' && opts.branch ? opts.branch : `${BRANCH_PREFIX}${taskId}`;
283
- const r = git(main, ['worktree', 'add', '-b', branch, wtPath]);
284
- if (!r.ok) throw new Error(`git worktree add lỗi: ${r.err || 'không rõ'}`);
285
- const adminDir = git(wtPath, ['rev-parse', '--path-format=absolute', '--git-dir']).out;
745
+ const r = git(main, ['worktree', 'add', '-b', branch, wtPath, ...(base ? [base] : [])]);
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);
286
763
  if (!adminDir) throw new Error(`worktree ${wtPath} tạo xong nhưng không đọc được git-dir để ghi dấu`);
287
764
  const markPath = path.join(adminDir, MARK);
288
- const mark = { task_id: taskId, root: main, created_ts: core.nowIso(), branch, deps: 'none', deps_path: depsPath };
289
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.
290
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
+ }
291
773
  const d = deps.setupDeps({ main, wtPath, mode: depsMode, depsPath });
292
774
  if (d.deps !== 'none') {
293
775
  mark.deps = d.deps;
@@ -299,7 +781,7 @@ function wtAdd(opts) {
299
781
  const text = [msg, d.note, warning].filter(Boolean).join('\n');
300
782
  return {
301
783
  code: 0,
302
- out: opts.json ? JSON.stringify({ task_id: taskId, path: wtPath, branch, total: total + 1, max, warning, deps: mark.deps, deps_path: depsPath, deps_note: d.note }) : text,
784
+ out: opts.json ? JSON.stringify({ task_id: taskId, path: wtPath, branch, base: base || null, total: total + 1, max, warning, deps: mark.deps, deps_path: depsPath, deps_note: d.note }) : text,
303
785
  };
304
786
  }
305
787
 
@@ -321,13 +803,16 @@ function wtClean(opts) {
321
803
  .map((e) => e.task_id));
322
804
  const gone = agg.discardedTaskIds(events);
323
805
  const rows = [];
806
+ const reg = registeredOf(main);
324
807
  for (const w of markedWorktrees(common)) {
325
808
  let why = null;
326
809
  if (!w.exists) why = 'thư mục đã mất';
327
810
  else if (accepted.has(w.task_id)) why = 'đã nghiệm thu';
328
811
  else if (gone.has(w.task_id)) why = 'đã loại (discard)';
329
- else if (ageMsOf(w) >= staleDays * DAY_MS && branchMerged(main, w.branch) && dirtyOf(w.path) === false) {
330
- 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]})`;
331
816
  }
332
817
  if (why) rows.push(Object.assign({}, w, { why }));
333
818
  }
@@ -345,12 +830,12 @@ function wtClean(opts) {
345
830
  } else lines.push(`sẽ bỏ đăng ký ${w.path || w.name}: ${label}`);
346
831
  continue;
347
832
  }
348
- const busy = liveAgentInWorktree(ctx, w.path);
349
- if (busy) { lines.push(`giữ ${w.path}: đang là cwd của agent sống (${busy}) — ${label}`); continue; }
350
- const dirty = dirtyOf(w.path);
351
- 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; }
352
837
  if (!apply) { lines.push(`sẽ gỡ ${w.path}: ${label}`); continue; }
353
- const r = removeMarked(w, main, ctx);
838
+ const r = removeMarked(w, main, ctx, ropts);
354
839
  if (r.removed) recordWt(ctx, w.task_id, { action: 'remove', path: w.path, repo: main, reason: w.why });
355
840
  lines.push(`${r.msg}: ${label}`);
356
841
  }
@@ -404,14 +889,163 @@ function wtList(opts) {
404
889
  }
405
890
 
406
891
  /** `dieu-phoi wt add|clean|list` (core 0.7.2 s17; 0.7.3 s2: trần, --stale-days, list). */
892
+ // ---- s4 (0.7.5): wt clean --merged và nhắc sau verdict accept ----
893
+
894
+ /**
895
+ * Worktree có dấu mà nhánh đã vào HEAD của checkout chính (không gồm checkout chính). go = gỡ được: có verdict không
896
+ * phải reject/fail/timeout/aborted, tuổi ≥ MERGED_MIN_AGE_MS, không agent sống, sạch. kept = [{ w, why }] còn lại kèm
897
+ * lý do. Chỉ đọc; cũ nhất trước.
898
+ */
899
+ function mergedPlan(ctx, main, common, events) {
900
+ const last = new Map();
901
+ for (const e of events) {
902
+ if (e && e.kind === 'verdict' && typeof e.task_id === 'string') last.set(e.task_id, agg.normalizeVerdict(e).outcome);
903
+ }
904
+ const tsOf = (w) => (w.created_ts && Number.isFinite(Date.parse(w.created_ts)) ? Date.parse(w.created_ts) : Infinity);
905
+ const go = [];
906
+ const kept = [];
907
+ const reg = registeredOf(main);
908
+ for (const w of markedWorktrees(common).sort((a, b) => tsOf(a) - tsOf(b))) {
909
+ if (!w.exists || !w.branch || pathKey(w.path) === pathKey(main)) 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;
913
+ const v = last.get(w.task_id);
914
+ let why = null;
915
+ if (ageMsOf(w) < MERGED_MIN_AGE_MS) why = 'mới tạo dưới 1 giờ';
916
+ else if (v === undefined) why = 'chưa có verdict';
917
+ else if (NOT_DONE.has(v)) why = `verdict ${v}`;
918
+ else if (!sf.ok) why = sf.why;
919
+ if (why) kept.push({ w, why });
920
+ else go.push(Object.assign({}, w, { branch_now: sf.branch_now }));
921
+ }
922
+ return { go, kept };
923
+ }
924
+
925
+ /**
926
+ * Thực hiện (apply) hoặc liệt kê (dry-run) mergedPlan. Gỡ worktree bằng removeMarked (không --force), rồi
927
+ * `git branch -d` nhánh (từ chối nếu chưa merge vào HEAD: an toàn). Trả { n, removed, lines }. Không ném.
928
+ */
929
+ function mergedRun(ctx, main, common, apply) {
930
+ const { go, kept } = mergedPlan(ctx, main, common, core.readEvents(ctx));
931
+ const lines = kept.map(({ w, why }) => `giữ ${w.path}: ${why} — task=${w.task_id} nhánh=${w.branch}`);
932
+ let removed = 0;
933
+ for (const w of go) {
934
+ const label = `task=${w.task_id} nhánh=${w.branch}`;
935
+ if (!apply) {
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)`);
937
+ continue;
938
+ }
939
+ const r = removeMarked(w, main, ctx, { anyBranch: true });
940
+ if (!r.removed) { lines.push(`${r.msg}: ${label}`); continue; }
941
+ removed++;
942
+ recordWt(ctx, w.task_id, { action: 'remove', path: w.path, repo: main, reason: 'merged' });
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}`);
951
+ }
952
+ return { n: go.length, removed, lines };
953
+ }
954
+
955
+ /** `dieu-phoi wt clean --merged [--repo R] [--yes]`: dry-run mặc định; không đụng --stale-days hay worktree không dấu. */
956
+ function wtCleanMerged(opts) {
957
+ const ctx = ctxOf(opts);
958
+ const { main, common } = mainCheckoutOf(repoOf(opts));
959
+ const apply = opts.yes === true;
960
+ const r = mergedRun(ctx, main, common, apply);
961
+ if (!r.lines.length) return { code: 0, out: 'wt clean --merged: không có worktree có dấu nào đã merge vào HEAD.' };
962
+ const tail = apply ? '' : '\nDry-run: chưa xoá gì. Chạy lại với --yes để thực hiện.';
963
+ const head = `wt clean --merged (${apply ? 'đã thực hiện' : 'dry-run'}): ${r.n} worktree đã merge${apply ? `, đã gỡ ${r.removed}` : ''}`;
964
+ return { code: 0, out: `${head}\n${r.lines.join('\n')}${tail}` };
965
+ }
966
+
967
+ /**
968
+ * Sau verdict accept của NGƯỜI DÙNG (--task hoặc --root): một dòng nhắc số worktree đã merge của từng repo liên quan
969
+ * (repo lấy từ sự kiện worktree của các task, cộng --repo nếu có). clean (--clean-merged): gỡ luôn như
970
+ * `wt clean --merged --yes`. Không ném: lỗi thành một dòng báo.
971
+ */
972
+ function mergedAfterAccept(ctx, taskIds, repoDir, clean) {
973
+ const lines = [];
974
+ try {
975
+ const events = core.readEvents(ctx);
976
+ const repos = new Set();
977
+ for (const id of taskIds) for (const r of reposOfTask(events, id, repoDir)) repos.add(r);
978
+ for (const repo of repos) {
979
+ let info;
980
+ try { info = mainCheckoutOf(repo); } catch (e) { lines.push(`wt --merged: bỏ qua ${repo} (${e.message})`); continue; }
981
+ if (clean) {
982
+ const r = mergedRun(ctx, info.main, info.common, true);
983
+ if (!r.lines.length) lines.push(`wt --clean-merged: không có worktree đã merge nào của ${info.main}`);
984
+ for (const x of r.lines) lines.push(`wt --clean-merged: ${x}`);
985
+ continue;
986
+ }
987
+ const n = mergedPlan(ctx, info.main, info.common, events).go.length;
988
+ if (n) lines.push(`wt: ${n} worktree đã merge của ${info.main}; dọn: dieu-phoi wt clean --merged --repo ${info.main} --yes (hoặc thêm --clean-merged vào lệnh verdict)`);
989
+ }
990
+ } catch (e) {
991
+ lines.push(`wt --merged: bỏ qua (${e.message})`);
992
+ }
993
+ return lines;
994
+ }
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
+
407
1026
  function cmdWt(opts) {
408
1027
  const sub = opts._[0];
409
- if (sub === 'add') return wtAdd(opts);
410
- if (sub === 'clean') return wtClean(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
+ }
1033
+ if (sub === 'clean') return opts.merged === true ? wtCleanMerged(opts) : wtClean(opts);
411
1034
  if (sub === 'list') return wtList(opts);
412
- throw new Error('Cú pháp: dieu-phoi wt add --task <task_id> [--repo r] [--path dir] [--branch b] [--force] [--deps auto|link|install|none] [--deps-path dir]\n'
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
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'
413
1038
  + ' dieu-phoi wt clean [--repo r] [--stale-days N] [--yes]\n'
414
- + ' dieu-phoi wt list [--repo r] [--all] [--json]');
1039
+ + ' dieu-phoi wt clean --merged [--repo r] [--yes]\n'
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]');
415
1043
  }
416
1044
 
417
- module.exports = { cmdWt, cleanupTask, 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
+ };