@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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog — @kienntpixon/dieu-phoi
2
2
 
3
+ ## 0.7.6 (2026-11-07)
4
+
5
+ Đi cùng skill 1.6.6. `CORE_VERSION` 0.7.6. Không đổi sink (`sheet/Code.gs`, `fixtures/` giữ nguyên).
6
+ **Sau khi cài:** `npm i -g @kienntpixon/dieu-phoi` + **`dieu-phoi setup` (bắt buộc: hook cài nay đi kèm `bin/lib/`)** + `dieu-phoi doctor`.
7
+
8
+ - **`wt gc [--repo R | --all-repos] [--yes] [--json] [--no-measure]`:** quét MỌI worktree đăng ký trong git (có dấu hay không) và xếp lớp `safe` / `keep-slot` / `abandoned` / `keep` / `prunable` / `shell` / `temp-branch`, kèm dung lượng từng mục và tổng thu hồi (ước lượng, trần thời gian). Dry-run mặc định; `--yes` chỉ gỡ `safe`, vỏ mồ côi, mục prunable và nhánh tạm đã merge. Nhàn rỗi tính theo mốc mới nhất của commit cuối, file mới nhất trong worktree và agent sống; không biết thì không bao giờ safe. Slot tên tự đặt (`keep-slot`) KHÔNG BAO GIỜ tự gỡ. Lớp `abandoned` (chưa merge, nhàn rỗi ≥ `wt_abandon_days` = 14) chỉ liệt kê.
9
+ - **Sổ repo + `wt repos [--seed <dir>…] [--depth N] [--json]`:** `data/repos.json` ghi repo mà hook (cwd, repo đích của brief), agent sống, `wt add` và sự kiện worktree chạm tới; `--seed` quét thư mục. `wt gc --all-repos` đọc sổ này.
10
+ - **Dọn tự động, chỉ bốn lớp an toàn — safe, vỏ, prunable, nhánh tạm `-d`; không bao giờ bỏ dở/keep/slot (`wt_auto_gc` = `safe`|`off`, mặc định `safe`):** (a) `verdict … accept` của người dùng gc ở repo vừa nghiệm thu (`--clean-merged` còn là bí danh); (b) hook `UserPromptSubmit` spawn nền `hook.cjs --gc-auto` mỗi `wt_gc_interval_h` (6) giờ, có khoá và nhật ký `data/gc-log.jsonl`, không chặn prompt; (c) ổ chứa repo còn dưới `wt_disk_low_gb` (15, 0 = tắt) GB: một dòng cảnh báo kèm số GB thu hồi được ở lớp cần duyệt, và gc safe chạy ngay. Đường đĩa spawn tối đa mỗi 30 phút. MỖI lần gỡ ghi một sự kiện `worktree` riêng (`remove`, `remove-shell`, `prune`, `branch-delete`, kèm `trigger`) + `wt_gc_auto` có `paths` và `branches`; `state` hiện cả hai. Lỗi lặp do thiếu `bin/lib` chỉ ghi `hook-errors.log` một lần mỗi 24 giờ. `wt_keep` thêm mẫu slot giữ lại.
11
+ - **Sửa: hook cài bằng `setup` thiếu `lib/`.** Từ 0.7.6 s2 hook nạp `./lib/repos.cjs` / `./lib/wtgc.cjs`, nhưng `setup` chỉ chép một file, nên hook cài sẽ lỗi nạp (lỗi này có từ commit nền b8c5011). `setup` nay chép toàn bộ `lib/*.cjs` vào `bin/lib/` (`uninstall --purge` xoá). Tính năng phụ (sổ repo, cảnh báo ổ, gc nền) bọc try/catch: thiếu `bin/lib` chỉ ghi `errors.log`, prompt không bao giờ hỏng. Test chạy bản đã cài từ thư mục setup tạm.
12
+ - **Hoãn sang 0.7.7 (s4):** tag `dp-archive/<branch>-<yyyymmdd>` và `wt gc --abandoned --yes` cho lớp bỏ dở. 0.7.6 chưa gắn tag, chưa gỡ lớp này.
13
+
14
+ ## 0.7.5 (2026-11-06)
15
+
16
+ Đi cùng skill 1.6.5. `CORE_VERSION` 0.7.5. Không đổi sink (`sheet/Code.gs`, `fixtures/` giữ nguyên).
17
+ **Sau khi cài:** `npm i -g @kienntpixon/dieu-phoi` + `dieu-phoi setup` + `dieu-phoi doctor`.
18
+
19
+ - **`wt clean --merged [--repo R] [--yes]`:** gỡ worktree có dấu và nhánh local khi nhánh đã vào HEAD checkout chính. Điều kiện: tuổi ≥ 1 giờ (worktree mới chưa commit luôn "đã merge"), verdict gần nhất của task không thuộc reject/fail/timeout/aborted, sạch, không agent sống. Dry-run mặc định; `git branch -d` (không force).
20
+ - **Nhắc sau nghiệm thu:** `verdict --root … accept` / `verdict --task … accept` của người dùng in một dòng số worktree đã merge của repo; `--clean-merged` gỡ luôn (như `wt clean --merged --yes`).
21
+ - **`wt add --base <ref>`:** tạo nhánh từ commit chọn được, sửa known issue 0.7.4 (trước đây luôn từ HEAD checkout chính). Ở mức cảnh báo 75% hoặc khi bị chặn trần, `wt add` gợi ý worktree của bước đã xong có thể tái dùng (sạch, không agent sống, verdict pass/accept); không tự dùng lại.
22
+ - **Knowledge delta, header `repo:`:** nhận cả dạng hiển thị `repo: <tên> (<tag>) — <dir>` (chép từ `knowledge get`) lẫn đường dẫn thô, LF/CRLF. Lỗi nay nói đúng nguyên nhân: tên ngắn không khớp kho nào / thư mục trong header không còn (worktree đã gỡ hoặc header sai) / đường dẫn không tồn tại.
23
+
3
24
  ## 0.7.4 (2026-11-06)
4
25
 
5
26
  Đi cùng skill 1.6.4. `CORE_VERSION` 0.7.4. Không đổi sink (`sheet/Code.gs` giữ nguyên).
package/bin/dieu-phoi.cjs CHANGED
@@ -35,10 +35,11 @@ Cách dùng: dieu-phoi <lệnh> [tuỳ chọn]
35
35
  verdict --root <root_task_id> accept [--steps a,b]
36
36
  người dùng nhận mọi bước đã giao exec của kế hoạch đã duyệt chưa có verdict người dùng
37
37
  accept (--task/--root) tự gộp knowledge-delta-<task_id>*.md của run vào kho (--no-knowledge: bỏ)
38
+ accept của người dùng nhắc số worktree đã merge của repo (một dòng); --clean-merged: gỡ luôn (wt clean --merged --yes)
38
39
  discard --task <task_id> [--reason R]
39
40
  loại một task nhầm khỏi thống kê/choose/state (ghi sự kiện discard, không sửa log);
40
41
  gỡ worktree có dấu của task nếu sạch (còn thay đổi thì giữ và báo)
41
- wt add --task <task_id> [--repo R] [--path D] [--branch B] [--force] [--deps auto|link|install|none] [--deps-path D]
42
+ wt add --task <task_id> [--repo R] [--path D] [--branch B] [--base REF] [--force] [--deps auto|link|install|none] [--deps-path D]
42
43
  tạo worktree cho một bước: <cha repo>/<tên repo>-wt-<task_id> trên nhánh dp/<task_id>,
43
44
  có dấu dieu-phoi (điều phối viên dùng lệnh này, không git worktree add tay).
44
45
  Repo đủ max_worktrees_repo (8; đếm cả checkout chính) thì từ chối kèm ứng viên dọn; --force vượt trần.
@@ -48,8 +49,20 @@ Cách dùng: dieu-phoi <lệnh> [tuỳ chọn]
48
49
  wt clean [--repo R] [--stale-days N] [--yes]
49
50
  liệt kê worktree có dấu mà task đã nghiệm thu / bị loại, thư mục đã mất, hoặc sạch + nhánh đã merge
50
51
  + cũ ≥ N ngày (mặc định wt_stale_days 7); mặc định dry-run, --yes mới gỡ (còn thay đổi luôn giữ; nhánh giữ nguyên)
52
+ wt clean --merged [--repo R] [--yes]
53
+ gỡ worktree có dấu mà nhánh đã vào HEAD checkout chính, có verdict (không reject/fail), tuổi ≥ 1 giờ,
54
+ không agent sống, sạch; rồi git branch -d nhánh. Dry-run mặc định; không đụng checkout chính / worktree không dấu
51
55
  wt list [--repo R] [--all] [--json]
52
56
  bảng worktree có dấu: task, tuổi, sạch/bẩn, node_modules (link|dir|none), deps của dấu; --all thêm worktree không dấu (chỉ báo)
57
+ wt repos [--seed <dir>…] [--depth N] [--json]
58
+ sổ repo đã chạm (data/repos.json): --seed quét thư mục tới độ sâu N (mặc định 3), sự kiện worktree và git worktree list của repo đã biết
59
+ wt gc [--repo R | --all-repos] [--yes] [--json] [--no-measure]
60
+ quét MỌI worktree đăng ký trong git (có dấu hay không; --all-repos = mọi repo trong sổ repo) và xếp lớp:
61
+ safe (nhánh đang checkout đã vào một nhánh bền bất kỳ, sạch, không agent sống, không khoá thật; có dấu: tuổi ≥ 1 giờ
62
+ + verdict pass/accept; không dấu hoặc thiếu verdict: nhàn rỗi > 24 giờ), keep-slot (tên tự đặt, Library/, lane/*,
63
+ .dieu-phoi-keep, wt_keep: không bao giờ tự gỡ), abandoned (chưa merge, nhàn rỗi ≥ wt_abandon_days 14: chỉ liệt kê),
64
+ keep (kèm lý do), prunable, vỏ mồ côi, nhánh tạm đã merge. Báo dung lượng từng mục + tổng thu hồi (ước lượng, trần thời gian).
65
+ Dry-run mặc định; --yes chỉ gỡ safe + vỏ + prune + git branch -d nhánh tạm
53
66
  steer <agent_id> --kind minor|plan|redirect [--reason R]
54
67
  steer --task <task_id> --kind minor|plan|redirect [--reason R]
55
68
  ghi một lần người dùng lái giữa chừng (--task: chỉ các agent đang chạy của task)
package/hook.cjs CHANGED
@@ -28,7 +28,7 @@ const path = require('path');
28
28
  const os = require('os');
29
29
  const crypto = require('crypto');
30
30
 
31
- const CORE_VERSION = '0.7.4';
31
+ const CORE_VERSION = '0.7.6';
32
32
  const SCHEMA = 1;
33
33
  const TASK_TYPES = [
34
34
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -205,6 +205,27 @@ function logError(home, err, where) {
205
205
  } catch (_) { /* nothing left to do */ }
206
206
  }
207
207
 
208
+ /**
209
+ * Lỗi lặp lại theo prompt (thiếu bin/lib ở bản cài lẻ): chỉ ghi MỘT lần mỗi LOG_ONCE_MS cho mỗi (nơi, mã lỗi).
210
+ * Mốc ở <home>/hook-errors-once.json; không đọc/ghi được mốc thì cứ ghi (thà ồn còn hơn câm).
211
+ */
212
+ const LOG_ONCE_MS = 24 * 3600000;
213
+ function logErrorOnce(home, err, where, now) {
214
+ try {
215
+ const p = pathsFor(home || resolveHome());
216
+ const f = path.join(p.home, 'hook-errors-once.json');
217
+ const t = typeof now === 'number' ? now : Date.now();
218
+ const key = where + ':' + String((err && (err.code || err.message)) || err).slice(0, 120);
219
+ let m = {};
220
+ try { m = JSON.parse(fs.readFileSync(f, 'utf8')) || {}; } catch (_) { m = {}; }
221
+ if (t - (Number(m[key]) || 0) < LOG_ONCE_MS) return;
222
+ m[key] = t;
223
+ fs.mkdirSync(p.home, { recursive: true });
224
+ fs.writeFileSync(f, JSON.stringify(m) + '\n');
225
+ } catch (_) { /* không ghi được mốc: vẫn ghi lỗi */ }
226
+ logError(home, err, where);
227
+ }
228
+
208
229
  // ------------------------------------------------------------------ identity
209
230
 
210
231
  function computeUserHash() {
@@ -946,6 +967,10 @@ const EVENTS_LOCK_WAIT_MS = 1500;
946
967
 
947
968
  function appendEvent(ctx, ev) {
948
969
  fs.mkdirSync(ctx.paths.data, { recursive: true });
970
+ // core 0.7.6 (s2): sự kiện worktree (add/remove/gc) có repo → sổ repo đã chạm (best effort)
971
+ try {
972
+ if (ev && ev.kind === 'worktree' && typeof ev.repo === 'string' && ev.repo) require('./lib/repos.cjs').recordRepo(ctx.paths.data, ev.repo, 'worktree');
973
+ } catch (_) { /* sổ repo không được làm hỏng sự kiện */ }
949
974
  // Core 0.5.4: appends take events.lock so `prune --yes` (which rewrites the file under the same lock) can never
950
975
  // lose a line that lands between its read and its rename. Lock wait is capped well under the shortest hook timeout
951
976
  // (UserPromptSubmit 5 s) so a stuck lock cannot get the hook killed mid-write; on timeout append anyway (never drop telemetry).
@@ -1708,21 +1733,174 @@ function formatStartBlock(info) {
1708
1733
  ].filter((l) => l !== '').join('\n');
1709
1734
  }
1710
1735
 
1736
+ // ------------------------------------------------------------------ r1 s5 (core 0.7.6): gc nền tự động
1737
+ // Hook chỉ làm việc RẺ: đọc data/gc-auto.json (+ sổ repo, statfs mỗi ổ) rồi spawn tách rời `hook.cjs --gc-auto`; không require wtgc.
1738
+ // Chỉ lớp safe (gc quyết định bằng kiểm tra tươi, xem wtgc.cjs); lớp bỏ dở / slot / keep KHÔNG BAO GIỜ tự gỡ.
1739
+ const GC_AUTO_FILE = 'gc-auto.json'; // { t: lần spawn theo giờ, disk_t: lần spawn vì thiếu đĩa, warn_t: lần cảnh báo }
1740
+ const GC_TOTALS_FILE = 'gc-totals.json'; // do tiến trình gc nền ghi: { ts, abandoned_bytes, keep_slot_bytes, reclaimed_bytes }
1741
+ const GC_LOCK_FILE = 'gc-auto.lock';
1742
+ const GC_LOCK_STALE_MS = 30 * 60 * 1000;
1743
+ const GC_DISK_GAP_MS = 30 * 60 * 1000; // spawn vì thiếu đĩa: cách nhau tối thiểu
1744
+ const GC_WARN_GAP_MS = 30 * 60 * 1000; // cảnh báo thiếu đĩa: cách nhau tối thiểu (mỗi prompt một dòng là ồn)
1745
+ const GC_DEFAULT_INTERVAL_H = 6;
1746
+ const GC_DEFAULT_DISK_LOW_GB = 15;
1747
+ const GC_GB = 1073741824;
1748
+
1749
+ /** wt_auto_gc: env DIEU_PHOI_WT_AUTO_GC -> config.json -> 'safe'. off/false/0/no -> 'off', còn lại 'safe'. */
1750
+ function autoGcModeOf(ctx) {
1751
+ const cfg = ctx && ctx.config ? ctx.config : {};
1752
+ const v = [process.env.DIEU_PHOI_WT_AUTO_GC, cfg.wt_auto_gc].find((x) => x !== undefined && x !== null && x !== '');
1753
+ const s = String(v === undefined ? 'safe' : v).trim().toLowerCase();
1754
+ return ['off', 'false', '0', 'no'].includes(s) ? 'off' : 'safe';
1755
+ }
1756
+
1757
+ /** Số >= 0 (có thể thập phân): env DIEU_PHOI_<KEY> -> config.json -> mặc định; nguồn hỏng rơi xuống nguồn kế. */
1758
+ function gcNumberOf(ctx, key, def) {
1759
+ const cfg = ctx && ctx.config ? ctx.config : {};
1760
+ for (const v of [process.env['DIEU_PHOI_' + key.toUpperCase()], cfg[key]]) {
1761
+ if (v === undefined || v === null || v === '') continue;
1762
+ const n = Number(v);
1763
+ if (Number.isFinite(n) && n >= 0) return n;
1764
+ }
1765
+ return def;
1766
+ }
1767
+
1768
+ /** Ổ chứa repo đã đăng ký có dưới lowGb GB trống không -> [{ drive, free_bytes }] (rẻ: một statfs mỗi ổ). */
1769
+ function lowDisksOf(ctx, lowGb, statfs) {
1770
+ let reg;
1771
+ try { reg = require('./lib/repos.cjs').readRegistry(ctx.paths.data); } catch (e) {
1772
+ // thiếu/hỏng lib (cài lẻ): không có sổ repo thì không cảnh báo ổ; nhưng gc theo giờ vẫn chạy. Ghi lỗi để doctor thấy.
1773
+ try { logErrorOnce(ctx.home, e, 'disk-registry'); } catch (_) { /* hết cách */ }
1774
+ return [];
1775
+ }
1776
+ const sf = statfs || ((p) => fs.statfsSync(p));
1777
+ const seen = new Set();
1778
+ const low = [];
1779
+ for (const repo of Object.keys(reg)) {
1780
+ let key;
1781
+ let probe = repo;
1782
+ if (process.platform === 'win32') { probe = path.parse(repo).root; key = probe.toLowerCase(); } else {
1783
+ try { key = String(fs.statSync(repo).dev); } catch (_) { continue; }
1784
+ }
1785
+ if (!key || seen.has(key)) continue;
1786
+ seen.add(key);
1787
+ try {
1788
+ const s = sf(probe);
1789
+ const free = Number(s.bavail) * Number(s.bsize);
1790
+ if (Number.isFinite(free) && free < lowGb * GC_GB) low.push({ drive: probe, free_bytes: free });
1791
+ } catch (_) { /* ổ không đọc được: bỏ qua */ }
1792
+ }
1793
+ return low;
1794
+ }
1795
+
1796
+ /** Tạo khoá (wx); khoá cũ hơn GC_LOCK_STALE_MS bị chiếm lại. true = đã giữ khoá. */
1797
+ function takeGcLock(file, now) {
1798
+ for (let i = 0; i < 2; i++) {
1799
+ try {
1800
+ const fd = fs.openSync(file, 'wx');
1801
+ fs.writeSync(fd, JSON.stringify({ t: now, pid: process.pid }) + '\n');
1802
+ fs.closeSync(fd);
1803
+ return true;
1804
+ } catch (e) {
1805
+ if (e.code !== 'EEXIST') return false;
1806
+ try { if (Date.now() - fs.statSync(file).mtimeMs <= GC_LOCK_STALE_MS) return false; fs.unlinkSync(file); } catch (_) { return false; }
1807
+ }
1808
+ }
1809
+ return false;
1810
+ }
1811
+
1812
+ /**
1813
+ * Mỗi prompt (rẻ, không chặn): quá wt_gc_interval_h (6) hoặc ổ chứa repo dưới wt_disk_low_gb (15) -> spawn tách rời gc lớp safe.
1814
+ * Mốc ghi TRƯỚC khi spawn (hai prompt liền nhau chỉ spawn một lần); khoá data/gc-auto.lock chặn chạy chồng.
1815
+ * deps (test): now, statfs, spawn(exe, args, env), cmd:[exe, args]. Trả { spawned, reason, message }.
1816
+ */
1817
+ function autoGcTick(ctx, deps) {
1818
+ const d = deps || {};
1819
+ const res = { spawned: false, reason: null, message: null };
1820
+ try {
1821
+ if (autoGcModeOf(ctx) === 'off') { res.reason = 'off'; return res; }
1822
+ const injected = !!(d.spawn || d.cmd);
1823
+ if (!injected && (process.env.DIEU_PHOI_NO_SPAWN === '1' || process.env.NODE_TEST_CONTEXT)) { res.reason = 'no-spawn'; return res; }
1824
+ const now = typeof d.now === 'number' ? d.now : Date.now();
1825
+ const file = path.join(ctx.paths.data, GC_AUTO_FILE);
1826
+ const st = readJsonFile(file, {}) || {};
1827
+ const intervalMs = gcNumberOf(ctx, 'wt_gc_interval_h', GC_DEFAULT_INTERVAL_H) * 3600000;
1828
+ const lowGb = gcNumberOf(ctx, 'wt_disk_low_gb', GC_DEFAULT_DISK_LOW_GB);
1829
+ const low = lowGb > 0 ? lowDisksOf(ctx, lowGb, d.statfs) : [];
1830
+ const timed = intervalMs > 0 && now - (Number(st.t) || 0) >= intervalMs;
1831
+ const disk = low.length > 0 && now - (Number(st.disk_t) || 0) >= GC_DISK_GAP_MS;
1832
+ let running = false;
1833
+ if (timed || disk) {
1834
+ const lock = path.join(ctx.paths.data, GC_LOCK_FILE);
1835
+ fs.mkdirSync(ctx.paths.data, { recursive: true });
1836
+ if (!takeGcLock(lock, now)) { running = true; res.reason = 'locked'; } else {
1837
+ const next = Object.assign({}, st, { t: now });
1838
+ if (disk) next.disk_t = now;
1839
+ writeFileAtomic(file, JSON.stringify(next) + '\n'); // mốc trước khi spawn
1840
+ Object.assign(st, next);
1841
+ try {
1842
+ const cmd = d.cmd || [process.execPath, [__filename, '--gc-auto']];
1843
+ const env = Object.assign({}, process.env, { DIEU_PHOI_HOME: ctx.home, DIEU_PHOI_GC_TRIGGER: disk && !timed ? 'disk' : 'hook' });
1844
+ if (d.spawn) d.spawn(cmd[0], cmd[1], env); else {
1845
+ const child = require('child_process').spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore', windowsHide: true, env });
1846
+ child.on('error', (e) => { logError(ctx.home, e, 'spawn-gc'); try { fs.unlinkSync(lock); } catch (_) { /* đã mất */ } });
1847
+ child.unref();
1848
+ }
1849
+ res.spawned = true;
1850
+ res.reason = disk && !timed ? 'disk' : 'interval';
1851
+ } catch (e) {
1852
+ logError(ctx.home, e, 'spawn-gc');
1853
+ try { fs.unlinkSync(lock); } catch (_) { /* đã mất */ }
1854
+ }
1855
+ }
1856
+ }
1857
+ if (low.length && now - (Number(st.warn_t) || 0) >= GC_WARN_GAP_MS) {
1858
+ const tot = readJsonFile(path.join(ctx.paths.data, GC_TOTALS_FILE), null);
1859
+ const need = tot ? (Number(tot.abandoned_bytes) || 0) + (Number(tot.keep_slot_bytes) || 0) : null;
1860
+ const gb = (n) => (n / GC_GB).toFixed(1);
1861
+ const drives = low.map((x) => x.drive.replace(/[\\/]+$/, '') + ' còn ' + gb(x.free_bytes) + ' GB').join(', ');
1862
+ const figure = need === null ? 'chưa đo lớp cần duyệt' : 'thu hồi được ≥ ' + gb(need) + ' GB ở lớp bỏ dở/slot (cần duyệt)';
1863
+ const doing = res.spawned || running ? 'đang dọn nền lớp safe' : 'lớp safe vừa dọn gần đây';
1864
+ res.message = 'dieu-phoi: ổ ' + drives + ' (< ' + lowGb + ' GB); ' + doing + '; ' + figure + '. Xem lớp bỏ dở/giữ (không tự gỡ): dieu-phoi wt gc --all-repos --measure';
1865
+ writeFileAtomic(file, JSON.stringify(Object.assign({}, st, { warn_t: now })) + '\n');
1866
+ }
1867
+ } catch (e) {
1868
+ try { logErrorOnce(ctx.home, e, 'gc-tick'); } catch (_) { /* hết cách */ }
1869
+ }
1870
+ return res;
1871
+ }
1872
+
1873
+ /** Tiến trình nền (hook.cjs --gc-auto): gc lớp safe mọi repo, ghi log + sự kiện; luôn nhả khoá. */
1874
+ function runGcChild() {
1875
+ const ctx = makeCtx();
1876
+ try {
1877
+ require('./lib/wtgc.cjs').autoGc(ctx, { allRepos: true, trigger: process.env.DIEU_PHOI_GC_TRIGGER || 'hook' });
1878
+ } catch (e) {
1879
+ logError(ctx.home, e, 'gc-auto');
1880
+ } finally {
1881
+ try { fs.unlinkSync(path.join(ctx.paths.data, GC_LOCK_FILE)); } catch (_) { /* đã mất */ }
1882
+ }
1883
+ }
1884
+
1711
1885
  /** UserPromptSubmit: on a dieu-phoi prompt, pre-create the run and inject id/run dir/health/brief. */
1712
- function handleUserPromptSubmit(p, ctx) {
1886
+ function handleUserPromptSubmit(p, ctx, gcDeps) {
1713
1887
  // s6: stuck/orphaned agents of this session → TaskStop + reap reminder, on any prompt (not only dieu-phoi ones)
1714
1888
  const stuck = typeof p.session_id === 'string' && p.session_id && !watchAgentId(p) ? stuckReminder(ctx, { sessionId: p.session_id }) : null;
1889
+ // r1 s5: gc nền lớp safe, không chặn prompt. r1 s6: tính năng phụ — mọi lỗi (thiếu lib, statfs, spawn) chỉ ghi log, prompt vẫn đi tiếp.
1890
+ let gcTick = { spawned: false, reason: 'error', message: null };
1891
+ try { gcTick = autoGcTick(ctx, gcDeps || null); } catch (e) { try { logError(ctx.home, e, 'gc-tick'); } catch (_) { /* hết cách */ } }
1892
+ const sys = gcTick.message ? { systemMessage: gcTick.message } : {};
1715
1893
  if (!isDieuPhoiPrompt(p.prompt)) {
1716
- if (!stuck) return null;
1717
- return { stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: stuck } }), stuck: true };
1894
+ if (!stuck) return gcTick.message ? { stdout: JSON.stringify(sys), gc: gcTick } : null;
1895
+ return { stdout: JSON.stringify(Object.assign({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: stuck } }, sys)), stuck: true };
1718
1896
  }
1719
1897
  const info = startInfo(ctx, typeof p.cwd === 'string' ? p.cwd : null);
1720
1898
  const text = stuck ? `${formatStartBlock(info)}
1721
1899
  ${stuck}` : formatStartBlock(info);
1722
1900
  return {
1723
- stdout: JSON.stringify({
1901
+ stdout: JSON.stringify(Object.assign({
1724
1902
  hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text },
1725
- }),
1903
+ }, sys)),
1726
1904
  info,
1727
1905
  };
1728
1906
  }
@@ -1875,7 +2053,7 @@ const DEFAULT_MODE = 'normal';
1875
2053
  /** Removed in 0.7.4: old events/plans/configs may still carry it; every reader treats it as normal. */
1876
2054
  const LEGACY_MODE = 'strict';
1877
2055
  /** Event kinds that stay on this machine: runSync does not send them (an older sink would answer bad_kind). */
1878
- const LOCAL_ONLY_KINDS = new Set(['mode', 'knowledge_merge', 'leftover', 'worktree', 'reap', 'reap_orphans']);
2056
+ const LOCAL_ONLY_KINDS = new Set(['mode', 'knowledge_merge', 'leftover', 'worktree', 'reap', 'reap_orphans', 'wt_gc_auto']);
1879
2057
  /** A mode the user may choose now (fast | normal). */
1880
2058
  function isMode(m) {
1881
2059
  return typeof m === 'string' && MODES.includes(m);
@@ -2897,6 +3075,31 @@ function projectFields(p) {
2897
3075
  return {};
2898
3076
  }
2899
3077
 
3078
+ /**
3079
+ * core 0.7.6 (s2): ghi repo của cwd payload và repo đích của brief vào data/repos.json (best effort, không spawn git).
3080
+ * Module lười nạp trong try: lỗi nạp không được làm hỏng hook.
3081
+ */
3082
+ function recordReposFromPayload(p, ctx) {
3083
+ if (!p || typeof p !== 'object' || !ctx || !ctx.paths || !ctx.paths.data) return;
3084
+ const R = require('./lib/repos.cjs');
3085
+ const dataDir = ctx.paths.data;
3086
+ const name = typeof p.hook_event_name === 'string' ? p.hook_event_name : '';
3087
+ const src = /^Subagent/.test(name) ? 'live' : 'hook';
3088
+ const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
3089
+ const prompt = typeof ti.prompt === 'string' ? ti.prompt : '';
3090
+ const cwd = typeof p.cwd === 'string' && p.cwd ? p.cwd : null;
3091
+ // (a) `git [-C repo] worktree add` trong Bash (Pre/Post): repo resolve bằng git rev-parse --git-common-dir
3092
+ if (p.tool_name === 'Bash' && typeof ti.command === 'string') R.recordWorktreeAddCommand(dataDir, ti.command, cwd);
3093
+ // (b) SubagentStart/Stop: cwd payload và worktree: của brief (Claude Code isolation) → checkout chính của nó
3094
+ const cands = [cwd, prompt ? repoFromBrief(prompt) : null, prompt ? briefWorktree(prompt) : null];
3095
+ const seen = new Set();
3096
+ for (const d of cands) {
3097
+ if (!d || seen.has(d)) continue;
3098
+ seen.add(d);
3099
+ R.recordRepo(dataDir, d, src);
3100
+ }
3101
+ }
3102
+
2900
3103
  /** Run dir id the brief points at (retro 11-05 K1: accept finds the delta there whatever the plan/root says), else null. */
2901
3104
  function dispatchRun(prompt) {
2902
3105
  try {
@@ -4879,6 +5082,7 @@ function runHook(opts) {
4879
5082
  if (!raw || !raw.trim()) return null;
4880
5083
  const p = JSON.parse(raw.replace(/^/, ''));
4881
5084
  const ctx = makeCtx(home);
5085
+ try { recordReposFromPayload(p, ctx); } catch (_) { /* sổ repo (core 0.7.6): best effort, không chặn hook */ }
4882
5086
  let ev = null;
4883
5087
  const name = p.hook_event_name;
4884
5088
  if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
@@ -4893,6 +5097,8 @@ function runHook(opts) {
4893
5097
  // s7: keep the live record (worktree, start) for the guarded leftover kill before the file goes
4894
5098
  let live = null;
4895
5099
  try { live = typeof p.agent_id === 'string' && p.agent_id ? readJsonFile(liveFile(ctx, p.agent_id), null) : null; } catch (_) { live = null; }
5100
+ // (b) core 0.7.6: worktree của agent (live.cwd) → checkout chính vào sổ repo, trước khi live bị xoá
5101
+ try { if (live && typeof live.cwd === 'string' && live.cwd) require('./lib/repos.cjs').recordRepo(ctx.paths.data, live.cwd, 'live'); } catch (_) { /* best effort */ }
4896
5102
  try { removeLive(ctx, p.agent_id); } catch (e) { logError(home, e, 'live-remove'); }
4897
5103
  const r = handleSubagentStop(p, ctx, { live });
4898
5104
  if (r && typeof r.stdout === 'string') return r; // s15: block decision, printed as the hook's JSON answer
@@ -5047,7 +5253,7 @@ async function runSync(ctx, opts) {
5047
5253
  // ------------------------------------------------------------------ exports / main
5048
5254
 
5049
5255
  module.exports = {
5050
- runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, startKnowledgeOf, knowledgeLines, orchLessons, orchLessonLines, abortReasonOf, completionStatus, logGate, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, findOrphanAgents, orphanLines, ORPHAN_MIN_MS, ORPHAN_NO_COMPLETE_MS, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
5256
+ runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, startKnowledgeOf, knowledgeLines, orchLessons, orchLessonLines, abortReasonOf, completionStatus, logGate, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, autoGcTick, autoGcModeOf, gcNumberOf, lowDisksOf, runGcChild, START_PREFIX, buildSessionState, findOrphanAgents, orphanLines, ORPHAN_MIN_MS, ORPHAN_NO_COMPLETE_MS, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
5051
5257
  modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, isMode, normMode, LEGACY_MODE, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
5052
5258
  rootTaskIds,
5053
5259
  testCommandInfo, testTimeOf, testSFromText,
@@ -5070,7 +5276,10 @@ module.exports = {
5070
5276
  if (require.main === module) {
5071
5277
  process.on('uncaughtException', (e) => { logError(null, e, 'uncaught'); process.exit(0); });
5072
5278
  process.on('unhandledRejection', (e) => { logError(null, e, 'unhandled'); process.exit(0); });
5073
- if (process.argv.includes('--sync')) {
5279
+ if (process.argv.includes('--gc-auto')) {
5280
+ runGcChild();
5281
+ process.exit(0);
5282
+ } else if (process.argv.includes('--sync')) {
5074
5283
  const ctx = makeCtx();
5075
5284
  runSync(ctx)
5076
5285
  .then((r) => { if (!r.ok) logError(ctx.home, new Error(r.error), 'sync'); })
package/lib/args.cjs CHANGED
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const core = require('../hook.cjs');
6
6
 
7
- const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve', 'yes', 'force', 'follow', 'remove', 'dirty', 'no-cache', 'untagged', 'no-knowledge', 'net', 'with-codebase-memory', 'cbm', 'allow-custom-source', 'auto', 'include-local', 'orphans']);
7
+ const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve', 'yes', 'force', 'follow', 'remove', 'dirty', 'no-cache', 'untagged', 'no-knowledge', 'net', 'with-codebase-memory', 'cbm', 'allow-custom-source', 'auto', 'include-local', 'orphans', 'merged', 'clean-merged', 'all-repos', 'measure', 'no-measure']);
8
8
 
9
9
  /** Minimal argv parser: positionals, --k v, --k=v, boolean flags. */
10
10
  function parseArgs(argv) {
package/lib/commands.cjs CHANGED
@@ -754,6 +754,8 @@ function cmdVerdict(opts) {
754
754
  const kn = knowledgeOnAccept(opts, ctx, root, [root].concat(list.map((x) => x.ev.task_id)), true, events);
755
755
  // s17: accept của người dùng gỡ worktree có dấu của từng bước (sạch); còn thay đổi thì giữ và báo.
756
756
  const wtLines = list.reduce((acc, x) => acc.concat(wt.cleanupTask(ctx, events, x.ev.task_id, opts.repo)), []);
757
+ // s4 (0.7.5): nhắc số worktree đã merge của repo; --clean-merged thì gỡ luôn (wt clean --merged --yes).
758
+ wtLines.push(...wt.gcAfterAccept(ctx, list.map((x) => x.ev.task_id), opts.repo, opts['clean-merged'] === true));
757
759
  if (!list.length) {
758
760
  // Every step was already judged (e.g. accepted one by one with --task): still merge the root + untagged deltas.
759
761
  // Exit code follows the merge (0 merged or nothing new, 2 conflict or reject, 1 error); no delta at all keeps the old "nothing to do" error.
@@ -775,6 +777,7 @@ function cmdVerdict(opts) {
775
777
  const kn = ev.by === 'user' && ev.outcome === 'accept' ? knowledgeOnAccept(opts, ctx, rootOfTask(ctx, ev.task_id), [ev.task_id], false, events) : null;
776
778
  // s17: người dùng accept → gỡ worktree có dấu của task (sạch); reject giữ nguyên để làm lại.
777
779
  const wtLines = ev.by === 'user' && ev.outcome === 'accept' ? wt.cleanupTask(ctx, events, ev.task_id, opts.repo) : [];
780
+ if (ev.by === 'user' && ev.outcome === 'accept') wtLines.push(...wt.gcAfterAccept(ctx, [ev.task_id], opts.repo, opts['clean-merged'] === true));
778
781
  if (opts.json) {
779
782
  if (kn && kn.out) process.stderr.write(`${kn.out}\n`);
780
783
  if (wtLines.length) process.stderr.write(`${wtLines.join('\n')}\n`);
@@ -810,7 +813,7 @@ function cmdDiscard(opts) {
810
813
  core.appendEvent(ctx, ev);
811
814
  const n = events.filter((e) => e.task_id === taskId).length;
812
815
  // s17: task bị loại → gỡ worktree có dấu của task (sạch); còn thay đổi thì giữ và báo.
813
- const wtLines = wt.cleanupTask(ctx, events, taskId, opts.repo);
816
+ const wtLines = wt.cleanupTask(ctx, events, taskId, opts.repo, { discard: true });
814
817
  if (opts.json) {
815
818
  if (wtLines.length) process.stderr.write(`${wtLines.join('\n')}\n`);
816
819
  return { code: 0, out: JSON.stringify(ev) };
@@ -1011,11 +1014,13 @@ function cmdState(opts) {
1011
1014
  const deltas = core.scanUnmergedDeltasFor(ctx);
1012
1015
  const deltaLines = core.unmergedDeltaLines(deltas);
1013
1016
  const hasDeltas = core.hasUnmergedDeltas(deltas);
1017
+ const gcState = require('./wtgc.cjs').autoGcState(core.readEvents(ctx)); // r1 s5 (d): gc tự động đã gỡ gì
1018
+ const gcLines = require('./wtgc.cjs').autoGcLines(core.readEvents(ctx));
1014
1019
  if (!sid) {
1015
1020
  if (opts.json) {
1016
- return { code: 0, out: JSON.stringify(Object.assign({ session_id: null, generated_at: core.nowIso(), runs_dir: null, plans: [], tasks: [] }, hasDeltas ? { unmerged_deltas: deltas } : {})) };
1021
+ return { code: 0, out: JSON.stringify(Object.assign({ session_id: null, generated_at: core.nowIso(), runs_dir: null, plans: [], tasks: [] }, hasDeltas ? { unmerged_deltas: deltas } : {}, gcState ? { wt_gc_auto: gcState } : {})) };
1017
1022
  }
1018
- return { code: 0, out: ['Không có session (thiếu CLAUDE_CODE_SESSION_ID, dùng --session <id>).'].concat(deltaLines).join('\n') };
1023
+ return { code: 0, out: ['Không có session (thiếu CLAUDE_CODE_SESSION_ID, dùng --session <id>).'].concat(deltaLines, gcLines).join('\n') };
1019
1024
  }
1020
1025
  const state = core.buildSessionState(core.readEvents(ctx), sid, { plansDir: ctx.paths.plans });
1021
1026
  const orphans = orphanCount(ctx); // s16: null (unknown) leaves the line out
@@ -1027,13 +1032,13 @@ function cmdState(opts) {
1027
1032
  session: live.session, machine: live.machine, limits: live.limits, free_session: freeSlots(live.limits.session, live.session), free_machine: freeSlots(live.limits.machine, live.machine), agents: live.agents,
1028
1033
  stuck: stuck.map((a) => ({ agent_id: a.agent_id, task_id: a.task_id, role: a.role, gen: a.gen, state: a.state, reasons: a.reasons, idle_min: a.idle_min, age_min: a.age_min })),
1029
1034
  };
1030
- return { code: 0, out: JSON.stringify(Object.assign({}, state, hasDeltas ? { unmerged_deltas: deltas } : {}, orphans === null ? {} : { orphan_procs: orphans }, { live: liveJ }, batchPending.length ? { batch_pending: batchPending } : {}), null, 2) };
1035
+ return { code: 0, out: JSON.stringify(Object.assign({}, state, hasDeltas ? { unmerged_deltas: deltas } : {}, gcState ? { wt_gc_auto: gcState } : {}, orphans === null ? {} : { orphan_procs: orphans }, { live: liveJ }, batchPending.length ? { batch_pending: batchPending } : {}), null, 2) };
1031
1036
  }
1032
1037
  const batchLines = batchPending.map((p) => `CẢNH BÁO: ${p.root_task_id}: ${p.steps.length} bước self+batch (${p.steps.join(', ')}) chưa có verdict verifier gộp (batch-verify) — giao verifier gộp (brief H) trước cổng 3`);
1033
1038
  const stuckLines = stuck.length
1034
1039
  ? [`stuck: ${stuck.slice(0, 6).map((a) => `${a.task_id || a.agent_id}:${a.role} ${a.state === 'orphan' ? 'mồ côi' : 'kẹt'} ${a.idle_min}'`).join(', ')} → TaskStop rồi \`dieu-phoi reap --yes\``]
1035
1040
  : [];
1036
- return { code: 0, out: [core.formatSessionState(state, core.STATE_TEXT_CAP)].concat(deltaLines, orphans === null ? [] : [`orphan_procs: ${orphans}`], [liveLine(live)], stuckLines, batchLines).join('\n') };
1041
+ return { code: 0, out: [core.formatSessionState(state, core.STATE_TEXT_CAP)].concat(deltaLines, orphans === null ? [] : [`orphan_procs: ${orphans}`], [liveLine(live)], stuckLines, batchLines, gcLines).join('\n') };
1037
1042
  }
1038
1043
 
1039
1044
  // ------------------------------------------------------------------ reap (core 0.7.3 s6)
package/lib/knowledge.cjs CHANGED
@@ -521,7 +521,7 @@ function splitList(v, lower) {
521
521
  /** Parse a delta file: { repo, head, sections: [{ add, rawId, id, title?, tags?, paths?, base?, body, lines }] }. */
522
522
  function parseDelta(text) {
523
523
  const lines = String(text).replace(/^/, '').split(LINE_BREAK);
524
- const out = { repo: null, head: null, sections: [] };
524
+ const out = { repo: null, repoDir: null, head: null, sections: [] };
525
525
  let cur = null;
526
526
  let fence = false;
527
527
  for (const line of lines) {
@@ -534,7 +534,16 @@ function parseDelta(text) {
534
534
  }
535
535
  if (cur) { cur.raw.push(line); continue; }
536
536
  const m = /^\s*(repo|head)\s*:\s*(.+?)\s*$/i.exec(line);
537
- if (m && !out[m[1].toLowerCase()]) out[m[1].toLowerCase()] = unquote(m[2].replace(/[.;,]+$/, '')).replace(/[.;,]+$/, '');
537
+ if (m && !out[m[1].toLowerCase()]) {
538
+ const val = unquote(m[2].replace(/[.;,]+$/, '')).replace(/[.;,]+$/, '');
539
+ const k = m[1].toLowerCase();
540
+ // display form written by repoLine(): `name (tag) — dir` -> name + dir
541
+ const dm = k === 'repo' ? /^(.*?)\s+—\s+(.+)$/.exec(val) : null;
542
+ if (dm) {
543
+ out.repo = dm[1].replace(/\s*\([^)]*\)\s*$/, '').trim() || dm[2].trim();
544
+ out.repoDir = dm[2].trim();
545
+ } else out[k] = val;
546
+ }
538
547
  }
539
548
  for (const s of out.sections) {
540
549
  s.id = s.rawId.toLowerCase();
@@ -821,11 +830,15 @@ function targetOf(delta, opts, rep) {
821
830
  }
822
831
  }
823
832
  if (delta.repo) {
824
- const p = path.resolve(core.nativePath(delta.repo));
833
+ const shown = delta.repoDir ? `${delta.repo} — ${delta.repoDir}` : delta.repo;
834
+ const p = path.resolve(core.nativePath(delta.repoDir || delta.repo));
825
835
  if (isDir(p)) return finish(p);
826
- if (opts.repo !== undefined) { rep.note = `repo: ${delta.repo} không còn — dùng --repo`; return finish(repoOf(opts)); }
827
- const bare = core.isBareRepoName(delta.repo);
828
- return err(`repo: ${delta.repo} ${bare ? 'không khớp kho nào đã biết và không phải thư mục' : 'không tồn tại (worktree đã xoá?)'} — chạy lại với --repo <đường dẫn repo>`);
836
+ if (opts.repo !== undefined) { rep.note = `repo: ${shown} không còn — dùng --repo`; return finish(repoOf(opts)); }
837
+ const bare = !delta.repoDir && core.isBareRepoName(delta.repo);
838
+ const why = bare ? 'tên ngắn không khớp kho nào đã biết (chưa có kho tri thức?) và không phải thư mục'
839
+ : delta.repoDir ? `thư mục ${slash(delta.repoDir)} không tồn tại (worktree đã xoá, hoặc đường dẫn trong header sai?)`
840
+ : 'không tồn tại (worktree đã xoá, hoặc header repo: sai?)';
841
+ return err(`repo: ${shown} ${why} — chạy lại với --repo <đường dẫn repo>`);
829
842
  }
830
843
  return err('không xác định được repo');
831
844
  function finish(target) {