@kienntpixon/dieu-phoi 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog — @kienntpixon/dieu-phoi
2
2
 
3
+ ## 0.6.1 (2026-10-22)
4
+
5
+ `CORE_VERSION` 0.6.1; đi cùng skill 1.5.1. codebase-memory-mcp là **khuyến nghị, không bắt buộc** (nhánh `cbm-support`, task 7s6mouxg). `sheet/` không đổi, không cần deploy lại `Code.gs`.
6
+
7
+ - **`doctor` kiểm codebase-memory-mcp** (module mới `lib/cbm.cjs`; mọi mục tối đa CẢNH BÁO, không bao giờ LỖI):
8
+ `codebase-memory` — binary (đăng ký MCP → PATH → `~/.local/bin`, `%LOCALAPPDATA%/Programs/codebase-memory-mcp`), `--version`,
9
+ đăng ký MCP trong Claude Code (user/local trong `.claude.json` — `$CLAUDE_CONFIG_DIR/.claude.json` hoặc `~/.claude.json` —
10
+ và project `.mcp.json`); thiếu → lệnh cài `dieu-phoi setup --with-codebase-memory`, chưa đăng ký → lệnh `claude mcp add --scope user`.
11
+ `cbm-version` (chỉ `--net`, GitHub API, timeout 6 s) so với bản mới nhất. `cbm-daemon` đọc `daemon status` (~3 s, timeout 5 s,
12
+ chạy song song với các kiểm khác): daemon khác version đang chạy → cảnh báo kèm pid các phiên giữ nó và cách xử lý.
13
+ `cbm-index` — repo cwd/`--repo` đã index chưa, đọc thư mục cache (`$CBM_CACHE_DIR` hoặc `~/.cache/codebase-memory-mcp/<project>.db`,
14
+ so tên lỏng), không spawn `cli list_projects` (~13 s, và lỗi khi daemon lệch version); worktree chưa index thì báo repo chính.
15
+ - **`setup --with-codebase-memory [--force] [--cbm-dir D]`** (tuỳ chọn, không mặc định): tải `checksums.txt` + asset của
16
+ `DeusData/codebase-memory-mcp` `releases/latest` (windows-amd64/arm64 `.zip`, darwin `.tar.gz`, linux `-portable.tar.gz`),
17
+ kiểm sha256 (lệch → không cài gì), giải nén bằng `tar` (Windows: `System32\tar.exe`), chạy thử `--version`, cài binary và đăng ký
18
+ MCP user scope bằng `claude mcp add --scope user codebase-memory-mcp -- <binary>` (không có `claude` → in lệnh để tự chạy).
19
+ Đã có binary/đăng ký → giữ nguyên trừ khi `--force`; bản cũ đổi tên `<binary>.bak-dieu-phoi-<thời gian>` (đổi tên được cả khi exe
20
+ đang chạy trên Windows), copy lỗi thì khôi phục. Cài lỗi → exit 1 nhưng phần setup dieu-phoi vẫn xong.
21
+ - Agent `dp-exec/dp-scout/dp-verify/dp-helper`: MCP lỗi kết nối/không phản hồi/daemon khác version → Grep/Glob ngay, ghi một dòng
22
+ `codebase-memory: unavailable (<lý do>)`, không thử lại (chạy lại `dieu-phoi setup` để cập nhật file agent).
23
+ - **Sửa theo review (task 0kgiz68m)**:
24
+ - `doctor` thường trở lại ~0,5 s: `daemon status` (~2,7 s) chỉ chạy với `--cbm` hoặc `--net`; không có cờ thì dùng kết quả nhớ
25
+ 10 phút (`data/cbm-daemon.json`, khoá theo đường dẫn + version binary, không lưu timeout) hoặc ghi "chưa kiểm — `doctor --cbm`".
26
+ - `DIEU_PHOI_CBM_BASE_URL` khác nguồn chính thức → từ chối kèm cảnh báo (sha256 lấy cùng nguồn nên không còn bảo đảm), trừ khi có
27
+ cờ mới `--allow-custom-source` (khi đó vẫn ghi CẢNH BÁO vào ghi chú).
28
+ - `--force`: nhớ đăng ký cũ (command/args/env) trước `claude mcp remove`; `mcp add` lỗi → đăng ký lại bản cũ (`mcp: restored`);
29
+ khôi phục cũng lỗi → ghi tên, command, tên biến env (không bao giờ in giá trị) để tự thêm lại.
30
+ - `doctor --net`: timeout kiểm sink 8 s → 20 s (Apps Script đo thực 8,3–14,6 s; có từ trước, không do nhánh này). Hết giờ = "chậm/không
31
+ xác định" (CẢNH BÁO, `sink-check.json` có `timeout: true`, không tính là lỗi cứng ở `doctor`/`start`/hook; `doctor` thường không đợi lại 20 s
32
+ trong 1 giờ). Lỗi kết nối/HTTP thật vẫn là lỗi cứng như cũ.
33
+ - Test mới `test/cbm.test.cjs` (21 ca, không gọi mạng: download/extract/`claude` mock, binary giả `.cjs`, server HTTP cục bộ cho
34
+ `--net`). `_fixtures.cjs` đặt `DIEU_PHOI_CBM_BIN=none` + `CBM_CACHE_DIR` tạm để doctor trong test không chạm binary/cache thật.
35
+
3
36
  ## 0.6.0 (2026-10-22)
4
37
 
5
38
  `CORE_VERSION` 0.6.0; đi cùng skill 1.5.0. Gộp nhánh `s5-parallel-safety` và `knowledge` (kế hoạch 464k2t30). `sheet/` không đổi, không cần deploy lại `Code.gs`.
package/README.md CHANGED
@@ -33,14 +33,23 @@ Không cài toàn cục được thì dùng `npx -y @kienntpixon/dieu-phoi <lệ
33
33
 
34
34
  Khởi động lại Claude Code sau khi cài. Kiểm tra bằng `dieu-phoi doctor` (chạy thử chính lệnh hook đã đăng ký).
35
35
 
36
+ **codebase-memory-mcp (khuyến nghị, không bắt buộc).** `setup --with-codebase-memory` (không mặc định) tải bản phát hành
37
+ chính thức từ GitHub `DeusData/codebase-memory-mcp` (`releases/latest`), kiểm sha256 với `checksums.txt`, chạy thử
38
+ `--version`, cài binary (Windows `%LOCALAPPDATA%\Programs\codebase-memory-mcp\`, macOS/Linux `~/.local/bin/`, hoặc
39
+ `--cbm-dir`) và đăng ký MCP ở user scope bằng `claude mcp add --scope user codebase-memory-mcp -- <binary>` (không có
40
+ lệnh `claude` thì in lệnh để tự chạy). Đã có binary/đăng ký → giữ nguyên; `--force` cài lại, bản cũ đổi tên
41
+ `<binary>.bak-dieu-phoi-<thời gian>`. `doctor` thêm các mục `codebase-memory`, `cbm-version` (chỉ với `--net`),
42
+ `cbm-daemon`, `cbm-index` — tất cả tối đa là CẢNH BÁO; `daemon status` mất ~3 s (timeout 5 s, chạy song song với kiểm
43
+ khác).
44
+
36
45
  `CLAUDE_CONFIG_DIR` được tôn trọng (thay cho `~/.claude`). Thử trên thư mục tạm: thêm `--claude-dir <thư mục>` vào mọi lệnh.
37
46
 
38
47
  ## Lệnh
39
48
 
40
49
  | Lệnh | Việc |
41
50
  |---|---|
42
- | `setup [--dept D] [--sink-url U --sink-token T] [--skill-dir P]` | cài hook, đăng ký `PostToolUse(Agent)` + `SubagentStop` + `SessionStart(compact)` + `UserPromptSubmit` trong settings, ghi config; tạo thư mục báo cáo `~/.dieu-phoi/runs` (env `DIEU_PHOI_RUNS_DIR` để đổi; ngoài `~/.claude` vì đó là protected path) và thêm quyền `Read`/`Edit(~/.dieu-phoi/runs/**)` + `additionalDirectories` để subagent nền ghi được báo cáo (không nhân đôi, có sao lưu); gỡ luật cũ của bản dev cho `~/.claude/dieu-phoi/runs` |
43
- | `doctor [--net] [--json]` | kiểm node, hook, settings (+ `prompt-hook`), config, profile, sink (từ bộ nhớ đệm 24 giờ; `--net` kiểm trực tiếp); context phiên điều phối hiện tại nếu đo được (`orch-ctx`); thư mục `~/.dieu-phoi/runs` + quyền của nó, không còn luật cũ (`run-permissions`); `--json` có `runs_dir` |
51
+ | `setup [--dept D] [--sink-url U --sink-token T] [--skill-dir P] [--with-codebase-memory [--force] [--cbm-dir D]]` | (`--with-codebase-memory`: cài + đăng ký codebase-memory-mcp, xem mục Cài đặt) cài hook, đăng ký `PostToolUse(Agent)` + `SubagentStop` + `SessionStart(compact)` + `UserPromptSubmit` trong settings, ghi config; tạo thư mục báo cáo `~/.dieu-phoi/runs` (env `DIEU_PHOI_RUNS_DIR` để đổi; ngoài `~/.claude` vì đó là protected path) và thêm quyền `Read`/`Edit(~/.dieu-phoi/runs/**)` + `additionalDirectories` để subagent nền ghi được báo cáo (không nhân đôi, có sao lưu); gỡ luật cũ của bản dev cho `~/.claude/dieu-phoi/runs` |
52
+ | `doctor [--net] [--repo P] [--json]` | kiểm codebase-memory-mcp (khuyến nghị: cài, MCP, daemon khác version, index của repo; `--net` so bản mới nhất), node, hook, settings (+ `prompt-hook`), config, profile, sink (từ bộ nhớ đệm 24 giờ; `--net` kiểm trực tiếp); context phiên điều phối hiện tại nếu đo được (`orch-ctx`); thư mục `~/.dieu-phoi/runs` + quyền của nó, không còn luật cũ (`run-permissions`); `--json` có `runs_dir` |
44
53
  | `verdict <agent_id\|last> <pass\|fail\|timeout\|aborted> [--verify tests\|lint\|review\|judgment\|none] [--reason short-kebab]` | nghiệm thu tầng agent (`by=agent`). `aborted` (0.4.0) = dừng vì usage limit/quota: không pass/fail, không leo thang; hook tự ghi khi bắt được. `last` = agent hoàn thành gần nhất; từ chối (liệt kê ứng viên) nếu trong 30 phút qua có > 1 agent xong mà chưa có verdict. Được viết tắt agent_id nếu không trùng |
45
54
  | `verdict --task <task_id> <accept\|reject> [--cause quality\|scope] [--round n] [--reason R]` | nghiệm thu của **người dùng** (`by=user`). `reject` bắt buộc `--cause`: `quality` = làm sai (tính là fail), `scope` = yêu cầu đổi/thiếu (không tính cho model). `--round` mặc định tự tăng |
46
55
  | `verdict --root <root_task_id> accept [--steps a,b]` | (0.4.0) người dùng nhận một lúc mọi bước đã giao exec của kế hoạch đã duyệt mà chưa có verdict người dùng. (0.6.0) `accept` tự gộp knowledge: `--task T` gộp `knowledge-delta-T*.md` của run gốc; `--root R` gộp delta của R + các bước vừa accept + file không mang task_id. Xung đột/từ chối không đổi verdict (exit 0): output in `XUNG ĐỘT` + lệnh `knowledge merge` để chạy lại. `--root` mà mọi bước đã có verdict người dùng: vẫn gộp delta của R + file không thẻ, exit theo kết quả gộp (không có delta nào → lỗi "không có bước nào cần nghiệm thu"). `reject` không gộp; `--no-knowledge` bỏ qua |
package/agents/dp-exec.md CHANGED
@@ -28,7 +28,7 @@ Bạn là agent thực thi (exec) của dieu-phoi. Brief ở tin nhắn đầu (
28
28
 
29
29
  Bàn giao qua file: ghi TOÀN BỘ kết quả vào `dp-out-<task_id>-exec-try<n>.md` trong run dir (đường dẫn brief chỉ định), rồi chỉ trả: đường dẫn file đó, dòng `STATUS:`, và ≤ 3 dòng ý chính. Không đặt tên file `report*` / `summary*` / `findings*` / `analysis*.md` — Claude Code chặn cứng Write các tên đó trong subagent. File dp-out/handoff mà brief trỏ tới là báo cáo chính thức: đọc thẳng, không cần xin thêm quyền.
30
30
 
31
- Repo đích và knowledge: dòng `repo:` của brief là repo ĐÍCH (có thể là repo con, khác cwd). Trước khi đọc code: `dieu-phoi knowledge list --repo <repo>` rồi `knowledge get <id|từ khoá> --repo <repo>` (ít nhất `build-test ports-resources` nếu có); mục STALE chỉ là gợi ý. Cấu trúc code: dùng codebase-memory-mcp khi repo đã có index (`list_projects`), không thì Grep/Glob — không tự index. Brief có `worktree:` → chỉ sửa và chạy lệnh trong worktree đó, dùng cổng/tài nguyên riêng brief ghi, chỉ kill tiến trình do chính mình mở. Học được điều không suy ra từ code (lệnh test thật, cổng, bẫy) → ghi `<run>/knowledge-delta-<task_id>.md` (header `repo:`; `## +id` + `paths:` cho mục mới; sửa mục cũ `## id` + dòng `base:` chép từ `get`; ≤ 40 dòng/mục; không ghi giá trị secret); không tự `knowledge merge`.
31
+ Repo đích và knowledge: dòng `repo:` của brief là repo ĐÍCH (có thể là repo con, khác cwd). Trước khi đọc code: `dieu-phoi knowledge list --repo <repo>` rồi `knowledge get <id|từ khoá> --repo <repo>` (ít nhất `build-test ports-resources` nếu có); mục STALE chỉ là gợi ý. Cấu trúc code: dùng codebase-memory-mcp khi repo đã có index (`list_projects`), không thì Grep/Glob — không tự index. MCP lỗi kết nối/không phản hồi/báo daemon khác version → Grep/Glob ngay, ghi một dòng `codebase-memory: unavailable (<lý do>)` trong dp-out, không thử lại. Brief có `worktree:` → chỉ sửa và chạy lệnh trong worktree đó, dùng cổng/tài nguyên riêng brief ghi, chỉ kill tiến trình do chính mình mở. Học được điều không suy ra từ code (lệnh test thật, cổng, bẫy) → ghi `<run>/knowledge-delta-<task_id>.md` (header `repo:`; `## +id` + `paths:` cho mục mới; sửa mục cũ `## id` + dòng `base:` chép từ `get`; ≤ 40 dòng/mục; không ghi giá trị secret); không tự `knowledge merge`.
32
32
 
33
33
  Helper thế hệ 2 (tuỳ chọn, chỉ khi giúp giữ ngữ cảnh nhỏ): tối đa 4 agent `subagent_type: dp-helper`, `model` đặt rõ `haiku` hoặc `sonnet`, cho đúng hai loại việc — (a) đọc/quét code hoặc tài liệu rồi trả tóm tắt ≤ 15 dòng, (b) chạy test / Unity smoke rồi tóm tắt log. Dòng đầu prompt helper: `[dp:<type>|s<n>|<urgency>|try1|task=<id mới từ dieu-phoi new-id>|role=helper|parent=<task của bạn>]`. Hook từ chối mọi lần giao sai luật. Không giao helper sửa code.
34
34
 
@@ -17,6 +17,7 @@ Bạn là helper thế hệ 2 của dieu-phoi, do một agent exec giao (thẻ `
17
17
 
18
18
  Giữ ngữ cảnh nhỏ (mỗi lượt gửi lại toàn bộ ngữ cảnh — số lượt mới là thứ làm phình):
19
19
  - Grep/Glob trước, rồi Read đúng đoạn cần bằng offset/limit; không cat/sed -n cả file qua Bash.
20
+ - codebase-memory-mcp lỗi kết nối/không phản hồi → Grep/Glob ngay, ghi một dòng `codebase-memory: unavailable (<lý do>)` trong tóm tắt, không thử lại.
20
21
  - Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
21
22
  - Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
22
23
  - Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
@@ -19,7 +19,7 @@ Bạn là scout/planner của dieu-phoi: khảo sát (code, tài liệu, dữ li
19
19
 
20
20
  Bàn giao qua file: ghi TOÀN BỘ kết quả vào `dp-out-<task_id>-plan-try<n>.md` trong run dir (đường dẫn brief chỉ định), rồi chỉ trả: đường dẫn file đó, dòng `STATUS:`, và ≤ 3 dòng ý chính. Không đặt tên file `report*` / `summary*` / `findings*` / `analysis*.md` — Claude Code chặn cứng Write các tên đó trong subagent. File dp-out/handoff mà brief trỏ tới là báo cáo chính thức: đọc thẳng, không cần xin thêm quyền.
21
21
 
22
- Repo đích và knowledge: `repo:` trong prompt là repo ĐÍCH (phiên ở repo cha làm việc trên repo con → repo con). Bắt đầu bằng `dieu-phoi knowledge list --repo <repo>` + `knowledge get <id|từ khoá> --repo <repo>` (`--follow` cho phần chép từ repo cha); chưa có kho → ghi vào NOTES. Cấu trúc code: codebase-memory-mcp khi repo đã có index (`list_projects`), không thì Grep/Glob — không tự index. Bước sửa code chạy song song cùng repo → ghi cần worktree + cổng riêng. Điều không suy ra từ code → `<run>/knowledge-delta-<task_id>.md` (header `repo:`, `## +id` + `paths:`), không tự merge.
22
+ Repo đích và knowledge: `repo:` trong prompt là repo ĐÍCH (phiên ở repo cha làm việc trên repo con → repo con). Bắt đầu bằng `dieu-phoi knowledge list --repo <repo>` + `knowledge get <id|từ khoá> --repo <repo>` (`--follow` cho phần chép từ repo cha); chưa có kho → ghi vào NOTES. Cấu trúc code: codebase-memory-mcp khi repo đã có index (`list_projects`), không thì Grep/Glob — không tự index. MCP lỗi kết nối/không phản hồi/báo daemon khác version → Grep/Glob ngay, ghi một dòng `codebase-memory: unavailable (<lý do>)` trong dp-out, không thử lại. Bước sửa code chạy song song cùng repo → ghi cần worktree + cổng riêng. Điều không suy ra từ code → `<run>/knowledge-delta-<task_id>.md` (header `repo:`, `## +id` + `paths:`), không tự merge.
23
23
 
24
24
  Giữ ngữ cảnh nhỏ (mỗi lượt gửi lại toàn bộ ngữ cảnh — số lượt mới là thứ làm phình):
25
25
  - Grep/Glob trước, rồi Read đúng đoạn cần bằng offset/limit; không cat/sed -n cả file qua Bash.
@@ -23,6 +23,7 @@ Repo đích và knowledge: chạy lệnh trong `worktree:` của bước nếu b
23
23
 
24
24
  Giữ ngữ cảnh nhỏ (mỗi lượt gửi lại toàn bộ ngữ cảnh — số lượt mới là thứ làm phình):
25
25
  - Grep/Glob trước, rồi Read đúng đoạn cần bằng offset/limit; không cat/sed -n cả file qua Bash.
26
+ - codebase-memory-mcp lỗi kết nối/không phản hồi → Grep/Glob ngay, ghi một dòng `codebase-memory: unavailable (<lý do>)` trong dp-out, không thử lại.
26
27
  - Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
27
28
  - Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
28
29
  - Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
package/bin/dieu-phoi.cjs CHANGED
@@ -10,7 +10,14 @@ Cách dùng: dieu-phoi <lệnh> [tuỳ chọn]
10
10
 
11
11
  setup [--dept D] [--sink-url U --sink-token T] [--skill-dir P]
12
12
  cài hook vào ~/.claude/dieu-phoi/bin, đăng ký trong settings.json
13
- doctor [--net] kiểm hook, node, config, sink (sink lấy từ bộ nhớ đệm 24 giờ; --net kiểm trực tiếp)
13
+ setup --with-codebase-memory [--force] [--cbm-dir D] [--allow-custom-source]
14
+ thêm: tải bản phát hành chính thức codebase-memory-mcp (kiểm sha256), cài binary, đăng ký
15
+ MCP user scope (\`claude mcp add\`); đã có thì giữ nguyên trừ khi --force (bản cũ đổi tên .bak-dieu-phoi-*;
16
+ đăng ký cũ được khôi phục nếu đăng ký mới lỗi). DIEU_PHOI_CBM_BASE_URL (nguồn tải khác) cần --allow-custom-source
17
+ doctor [--net] [--cbm] [--repo P]
18
+ kiểm hook, node, config, sink (sink lấy từ bộ nhớ đệm 24 giờ; --net kiểm trực tiếp) và
19
+ codebase-memory-mcp (khuyến nghị: cài/MCP/index của repo; --cbm hoặc --net mới chạy \`daemon status\` ~3 s,
20
+ kết quả nhớ 10 phút; --net còn so bản mới nhất)
14
21
  start [--json] khởi động cổng 1 trong một lệnh (~150 ms, không gọi mạng): kiểm cục bộ, task_id gốc,
15
22
  tạo run dir, đường dẫn brief scout — dùng khi hook UserPromptSubmit không chạy
16
23
  verdict <agent_id|last> <pass|fail|timeout|aborted> [--verify tests|lint|review|judgment|none] [--reason short-kebab]
package/hook.cjs CHANGED
@@ -26,7 +26,7 @@ const path = require('path');
26
26
  const os = require('os');
27
27
  const crypto = require('crypto');
28
28
 
29
- const CORE_VERSION = '0.6.0';
29
+ const CORE_VERSION = '0.6.1';
30
30
  const SCHEMA = 1;
31
31
  const TASK_TYPES = [
32
32
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -1397,7 +1397,8 @@ function sinkStatusCached(ctx, nowMs) {
1397
1397
  const now = nowMs || Date.now();
1398
1398
  const cands = [];
1399
1399
  const chk = readJsonFile(ctx.paths.sinkCheck, null);
1400
- if (chk && chk.url === sinkUrlKey(cfg) && Date.parse(chk.ts)) {
1400
+ // a timed-out doctor check (`timeout: true`) is "slow/unknown", not a failure: ignored here, so a fresher sync result or `unknown` wins
1401
+ if (chk && !chk.timeout && chk.url === sinkUrlKey(cfg) && Date.parse(chk.ts)) {
1401
1402
  cands.push({ state: chk.ok ? 'ok' : 'fail', ts: chk.ts, source: 'doctor', detail: chk.detail || null });
1402
1403
  }
1403
1404
  const ls = readJsonFile(ctx.paths.lastSync, null);
@@ -1651,7 +1652,7 @@ function handoffChainMessage(taskId, n) {
1651
1652
  return `dieu-phoi: task ${taskId} đã handoff ${n} lần (trần ${HANDOFF_CHAIN_WARN}). Đừng nối tiếp thêm: nếu agent vừa giao là agent tiếp nối, cân nhắc dừng nó và giao planner (dp-scout) đọc handoff-${taskId}.md rồi chia phần còn lại thành bước mới (task_id mới từ \`dieu-phoi new-id\`).`;
1652
1653
  }
1653
1654
 
1654
- // ------------------------------------------------------------------ core 0.6.0 (unreleased): project identity
1655
+ // ------------------------------------------------------------------ core 0.6.0: project identity
1655
1656
  //
1656
1657
  // A project (= one git repo, however many worktrees/clones) gets a stable key so every session and worktree can
1657
1658
  // share one knowledge store (~/.dieu-phoi/projects/<name>-<sha8(key)>/). Everything here is plain file reads —
@@ -1912,7 +1913,7 @@ function readHead(start) {
1912
1913
  return sha ? { branch, sha, detached: false } : { branch, sha: null, detached: false, unborn: true };
1913
1914
  }
1914
1915
 
1915
- // ------------------------------------------------------------------ core 0.6.0 (unreleased), s3: knowledge freshness
1916
+ // ------------------------------------------------------------------ core 0.6.0, s3: knowledge freshness
1916
1917
  //
1917
1918
  // A knowledge section is stale when a file matching its `paths` globs differs between the commit it was merged at
1918
1919
  // (`sha`) and the HEAD of the worktree asking. Used by the start block here and by `knowledge get/list/status`
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']);
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']);
8
8
 
9
9
  /** Minimal argv parser: positionals, --k v, --k=v, boolean flags. */
10
10
  function parseArgs(argv) {
package/lib/cbm.cjs ADDED
@@ -0,0 +1,490 @@
1
+ 'use strict';
2
+
3
+ // codebase-memory-mcp (https://github.com/DeusData/codebase-memory-mcp): a recommended, optional MCP server that
4
+ // gives agents a code graph. `doctor` reports on it (installed? registered? version? a daemon of another version?
5
+ // repo indexed?) and `setup --with-codebase-memory` installs the official release binary + registers it in Claude
6
+ // Code at user scope. Nothing here is required for dieu-phoi to work: every problem is a warning, never an error.
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const cp = require('child_process');
12
+ const crypto = require('crypto');
13
+
14
+ const REPO = 'DeusData/codebase-memory-mcp';
15
+ const SERVER_NAME = 'codebase-memory-mcp';
16
+ const RELEASE_BASE = `https://github.com/${REPO}/releases/latest/download`;
17
+ const LATEST_API = `https://api.github.com/repos/${REPO}/releases/latest`;
18
+ const INSTALL_HINT = 'dieu-phoi setup --with-codebase-memory';
19
+ const CHECKSUMS_MAX = 1024 * 1024;
20
+ const DAEMON_CACHE_MS = 10 * 60 * 1000;
21
+
22
+ const binName = (platform) => ((platform || process.platform) === 'win32' ? `${SERVER_NAME}.exe` : SERVER_NAME);
23
+ const toSlash = (p) => String(p).replace(/\\/g, '/');
24
+
25
+ /** Claude Code's own config file: `$CLAUDE_CONFIG_DIR/.claude.json`, or `~/.claude.json` for the default ~/.claude. */
26
+ function claudeJsonPath(claudeDir) {
27
+ const def = path.join(os.homedir(), '.claude');
28
+ if (path.resolve(claudeDir) === path.resolve(def)) return path.join(os.homedir(), '.claude.json');
29
+ return path.join(claudeDir, '.claude.json');
30
+ }
31
+
32
+ function isCbmEntry(name, cfg) {
33
+ if (/codebase-memory/i.test(name)) return true;
34
+ return !!(cfg && typeof cfg.command === 'string' && /codebase-memory-mcp(\.exe)?$/i.test(cfg.command.trim()));
35
+ }
36
+
37
+ function readJson(file) {
38
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; }
39
+ }
40
+
41
+ function samePath(a, b) {
42
+ const n = (p) => toSlash(path.resolve(p)).replace(/\/+$/, '');
43
+ return process.platform === 'win32' ? n(a).toLowerCase() === n(b).toLowerCase() : n(a) === n(b);
44
+ }
45
+
46
+ /** MCP registrations of codebase-memory-mcp visible to Claude Code for `repo`: user, local (per project) and project (.mcp.json). */
47
+ function registrations(claudeDir, repo) {
48
+ const out = [];
49
+ const file = claudeJsonPath(claudeDir);
50
+ const j = readJson(file);
51
+ const push = (scope, servers, src) => {
52
+ if (!servers || typeof servers !== 'object') return;
53
+ for (const [name, cfg] of Object.entries(servers)) {
54
+ if (!isCbmEntry(name, cfg)) continue;
55
+ const c = cfg && typeof cfg === 'object' ? cfg : {};
56
+ // args/env are kept (never printed) so `install --force` can restore the entry if re-registering fails
57
+ out.push({
58
+ scope, name, command: typeof c.command === 'string' ? c.command : null, file: src,
59
+ args: Array.isArray(c.args) ? c.args.map(String) : [],
60
+ env: c.env && typeof c.env === 'object' && !Array.isArray(c.env) ? c.env : {},
61
+ });
62
+ }
63
+ };
64
+ if (j) {
65
+ push('user', j.mcpServers, file);
66
+ if (repo && j.projects && typeof j.projects === 'object') {
67
+ for (const [p, pc] of Object.entries(j.projects)) {
68
+ if (pc && samePath(p, repo)) push('local', pc.mcpServers, file);
69
+ }
70
+ }
71
+ }
72
+ if (repo) {
73
+ const root = repoRoot(repo) || repo;
74
+ const mj = readJson(path.join(root, '.mcp.json'));
75
+ if (mj) push('project', mj.mcpServers, path.join(root, '.mcp.json'));
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function isFile(p) {
81
+ try { return fs.statSync(p).isFile(); } catch (_) { return false; }
82
+ }
83
+
84
+ function defaultInstallDir(platform, env) {
85
+ const e = env || process.env;
86
+ if ((platform || process.platform) === 'win32') {
87
+ const base = e.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
88
+ return path.join(base, 'Programs', SERVER_NAME); // where the project's own install.ps1 puts it
89
+ }
90
+ return path.join(os.homedir(), '.local', 'bin');
91
+ }
92
+
93
+ /**
94
+ * Locate the binary: DIEU_PHOI_CBM_BIN (a path; `none` turns off PATH/default-dir search, for tests), the command of a
95
+ * Claude Code registration, PATH, then the default install dirs (~/.local/bin, %LOCALAPPDATA%/Programs/...).
96
+ */
97
+ function findBinary(regs, env) {
98
+ const e = env || process.env;
99
+ const forced = e.DIEU_PHOI_CBM_BIN;
100
+ if (forced && forced !== 'none') return isFile(forced) ? { path: forced, source: 'DIEU_PHOI_CBM_BIN' } : null;
101
+ for (const r of regs || []) {
102
+ if (r.command && isFile(r.command)) return { path: r.command, source: `MCP ${r.scope}` };
103
+ }
104
+ if (forced === 'none') return null;
105
+ const name = binName();
106
+ for (const dir of String(e.PATH || e.Path || '').split(path.delimiter)) {
107
+ if (dir && isFile(path.join(dir, name))) return { path: path.join(dir, name), source: 'PATH' };
108
+ }
109
+ for (const dir of [path.join(os.homedir(), '.local', 'bin'), defaultInstallDir(process.platform, e)]) {
110
+ if (isFile(path.join(dir, name))) return { path: path.join(dir, name), source: 'thư mục mặc định' };
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /** argv for running `bin` (a .js/.cjs path runs under this node — lets tests use a fake binary on every OS). */
116
+ function binArgv(bin, args) {
117
+ return /\.c?js$/i.test(bin) ? [process.execPath, [bin, ...args]] : [bin, args];
118
+ }
119
+
120
+ function probeVersion(bin, timeoutMs) {
121
+ const [cmd, args] = binArgv(bin, ['--version']);
122
+ const r = cp.spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutMs || 3000, windowsHide: true });
123
+ if (r.error || r.status !== 0) return null;
124
+ const m = /(\d+\.\d+\.\d+)/.exec(`${r.stdout || ''}`);
125
+ return m ? m[1] : null;
126
+ }
127
+
128
+ /** `codebase-memory-mcp daemon status` → { state: active|none|unresponsive|unknown, version, pid, clients }. */
129
+ function parseDaemonStatus(text) {
130
+ const t = String(text || '');
131
+ const res = { state: 'unknown', version: null, pid: null, clients: [] };
132
+ if (/daemon:\s*active/i.test(t)) res.state = 'active';
133
+ else if (/not responding/i.test(t)) res.state = 'unresponsive';
134
+ else if (/not running|no daemon|inactive|stopped/i.test(t)) res.state = 'none';
135
+ const b = /build:\s*v?(\d+\.\d+\.\d+)/i.exec(t) || /active version\s*v?(\d+\.\d+\.\d+)/i.exec(t);
136
+ if (b) res.version = b[1];
137
+ const p = /^\s*pid:\s*(\d+)/im.exec(t) || /held by pid\s*(\d+)/i.exec(t);
138
+ if (p) res.pid = Number(p[1]);
139
+ const re = /^\s*-\s*pid\s+(\d+)/gim;
140
+ let m;
141
+ while ((m = re.exec(t))) res.clients.push(Number(m[1]));
142
+ return res;
143
+ }
144
+
145
+ /** Async so doctor can start it first and overlap it with its other (blocking) checks. */
146
+ function daemonStatus(bin, timeoutMs) {
147
+ return new Promise((resolve) => {
148
+ const [cmd, args] = binArgv(bin, ['daemon', 'status']);
149
+ cp.execFile(cmd, args, { encoding: 'utf8', timeout: timeoutMs || 5000, windowsHide: true }, (err, stdout, stderr) => {
150
+ if (err && err.killed) return resolve({ state: 'timeout', version: null, pid: null, clients: [] });
151
+ resolve(parseDaemonStatus(`${stdout || ''}\n${stderr || ''}`));
152
+ });
153
+ });
154
+ }
155
+
156
+ function cacheDir(env) {
157
+ const e = env || process.env;
158
+ return e.CBM_CACHE_DIR ? path.resolve(e.CBM_CACHE_DIR) : path.join(os.homedir(), '.cache', SERVER_NAME);
159
+ }
160
+
161
+ /** Top of the git work tree containing `dir` (a .git dir or file), or null. */
162
+ function repoRoot(dir) {
163
+ let d = path.resolve(dir);
164
+ for (;;) {
165
+ if (fs.existsSync(path.join(d, '.git'))) return d;
166
+ const up = path.dirname(d);
167
+ if (up === d) return null;
168
+ d = up;
169
+ }
170
+ }
171
+
172
+ /** For a linked worktree (`.git` is a file), the main checkout's root; else null. */
173
+ function mainCheckoutOf(root) {
174
+ try {
175
+ const g = path.join(root, '.git');
176
+ if (!fs.statSync(g).isFile()) return null;
177
+ const m = /gitdir:\s*(.+)/.exec(fs.readFileSync(g, 'utf8'));
178
+ if (!m) return null;
179
+ const gitdir = path.resolve(root, m[1].trim());
180
+ let common = gitdir;
181
+ try { common = path.resolve(gitdir, fs.readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim()); } catch (_) { /* none */ }
182
+ return path.basename(common) === '.git' ? path.dirname(common) : null;
183
+ } catch (_) {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ const canon = (s) => toSlash(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
189
+
190
+ /**
191
+ * Is `repo` indexed? codebase-memory-mcp keeps one `<project>.db` per repo in its cache dir, the project name being the
192
+ * path with separators as `-` (D:/Projects/x → D-Projects-x). Compared loosely (case, punctuation). Best effort: no
193
+ * process is spawned (a `cli list_projects` takes ~13 s and fails while a daemon of another version runs).
194
+ */
195
+ function indexStatus(repo, env) {
196
+ const root = repoRoot(repo);
197
+ if (!root) return { root: null, indexed: false, skipped: 'ngoài git' };
198
+ let names = [];
199
+ const dir = cacheDir(env);
200
+ try { names = fs.readdirSync(dir).filter((n) => /\.db$/.test(n) && !n.startsWith('_')).map((n) => n.slice(0, -3)); } catch (_) { /* none */ }
201
+ const want = (p) => names.find((n) => canon(n) === canon(p));
202
+ const hit = want(root);
203
+ if (hit) return { root, indexed: true, project: hit, dir };
204
+ const main = mainCheckoutOf(root);
205
+ const mainHit = main ? want(main) : null;
206
+ return { root, indexed: false, main, main_project: mainHit || null, dir };
207
+ }
208
+
209
+ function cmpVersion(a, b) {
210
+ const pa = String(a).replace(/^v/, '').split('.').map(Number);
211
+ const pb = String(b).replace(/^v/, '').split('.').map(Number);
212
+ for (let i = 0; i < 3; i++) {
213
+ const d = (pa[i] || 0) - (pb[i] || 0);
214
+ if (d) return d < 0 ? -1 : 1;
215
+ }
216
+ return 0;
217
+ }
218
+
219
+ async function latestVersion(env, timeoutMs) {
220
+ const url = (env || process.env).DIEU_PHOI_CBM_LATEST_URL || LATEST_API;
221
+ const res = await fetch(url, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'dieu-phoi' }, signal: AbortSignal.timeout(timeoutMs || 6000) });
222
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
223
+ const j = await res.json();
224
+ const m = /(\d+\.\d+\.\d+)/.exec(String(j && j.tag_name || ''));
225
+ if (!m) throw new Error('không đọc được tag_name');
226
+ return m[1];
227
+ }
228
+
229
+ function indexCommand(bin, root) {
230
+ return `${bin ? `"${toSlash(bin)}"` : SERVER_NAME} cli index_repository '{"repo_path":"${toSlash(root)}"}'`;
231
+ }
232
+
233
+ function registerCommand(bin) {
234
+ return `claude mcp add --scope user ${SERVER_NAME} -- "${toSlash(bin)}"`;
235
+ }
236
+
237
+ function readDaemonCache(file, bin, version, nowMs) {
238
+ if (!file) return null;
239
+ const c = readJson(file);
240
+ if (!c || !c.daemon || c.bin !== toSlash(bin) || c.version !== (version || null)) return null;
241
+ const t = Date.parse(c.ts);
242
+ if (!t || nowMs - t > DAEMON_CACHE_MS || t > nowMs + 60000) return null;
243
+ if (!c.daemon.state || c.daemon.state === 'timeout') return null;
244
+ return { ts: c.ts, daemon: c.daemon };
245
+ }
246
+
247
+ function writeDaemonCache(file, bin, version, daemon, nowMs) {
248
+ if (!file || !daemon || daemon.state === 'timeout') return;
249
+ try {
250
+ fs.mkdirSync(path.dirname(file), { recursive: true });
251
+ fs.writeFileSync(file, `${JSON.stringify({ ts: new Date(nowMs).toISOString(), bin: toSlash(bin), version: version || null, daemon })}\n`);
252
+ } catch (_) { /* cache is best effort */ }
253
+ }
254
+
255
+ /**
256
+ * Doctor checks (all warnings at worst). Spawns at most `--version` (~40 ms) and, only with `deep` (doctor `--net` /
257
+ * `--cbm`), `daemon status` (~2.7 s, async, so it overlaps the other checks); otherwise the daemon line comes from a
258
+ * 10-minute cache (`cacheFile`) or says it was not checked. `net` adds one GitHub API call (6 s timeout).
259
+ * Returns a Promise of the checks: call it early, await it at the end.
260
+ */
261
+ function doctorChecks({ claudeDir, repo, net, deep, cacheFile, env, now }) {
262
+ const e = env || process.env;
263
+ const nowMs = typeof now === 'number' ? now : Date.now();
264
+ const checks = [];
265
+ const add = (ok, name, detail) => checks.push({ ok, level: ok ? 'OK' : 'CẢNH BÁO', name, detail });
266
+ const regs = registrations(claudeDir, repo);
267
+ const found = findBinary(regs, e);
268
+ const regTxt = regs.length ? regs.map((r) => `${r.scope}:${r.name}`).join(', ') : null;
269
+ if (!found) {
270
+ add(false, 'codebase-memory', regs.length
271
+ ? `MCP đã đăng ký (${regTxt}) nhưng không thấy binary ${regs.map((r) => r.command).filter(Boolean).join(', ') || ''} — cài lại: \`${INSTALL_HINT} --force\``
272
+ : `chưa cài (khuyến nghị, không bắt buộc: agent dùng Grep/Glob khi thiếu) — cài: \`${INSTALL_HINT}\``);
273
+ return Promise.resolve(checks);
274
+ }
275
+ const version = probeVersion(found.path);
276
+ const wantDaemon = !!(deep || net);
277
+ const cached = wantDaemon ? null : readDaemonCache(cacheFile, found.path, version, nowMs);
278
+ const daemonP = wantDaemon ? daemonStatus(found.path, 5000) : Promise.resolve(null);
279
+ const latestP = net ? latestVersion(e).then((v) => ({ v }), (err) => ({ err: err.message })) : Promise.resolve(null);
280
+ return (async () => {
281
+ add(!!version && regs.length > 0, 'codebase-memory', `${version ? `v${version}` : 'không chạy được `--version`'} tại ${toSlash(found.path)} (${found.source})${regs.length
282
+ ? `; MCP: ${regTxt}` : ` — chưa đăng ký MCP trong Claude Code: \`${registerCommand(found.path)}\``}`);
283
+ const latest = await latestP;
284
+ if (latest && version) {
285
+ if (latest.err) add(true, 'cbm-version', `không kiểm được bản mới nhất (${latest.err})`);
286
+ else if (cmpVersion(version, latest.v) < 0) add(false, 'cbm-version', `v${version} cũ hơn bản mới nhất v${latest.v} — nâng cấp: \`${INSTALL_HINT} --force\` (đóng các phiên đang dùng MCP trước)`);
287
+ else add(true, 'cbm-version', `v${version} là bản mới nhất`);
288
+ }
289
+ let d = await daemonP;
290
+ let suffix = '';
291
+ if (d) writeDaemonCache(cacheFile, found.path, version, d, nowMs);
292
+ else if (cached) { d = cached.daemon; suffix = ` (bộ nhớ đệm lúc ${cached.ts}; \`dieu-phoi doctor --cbm\` để kiểm lại)`; }
293
+ if (!d) {
294
+ add(true, 'cbm-daemon', 'chưa kiểm (`daemon status` mất ~3 s) — chạy `dieu-phoi doctor --cbm` khi MCP báo lỗi hoặc nghi daemon khác version');
295
+ } else if (d.state === 'active' && d.version && version && d.version !== version) {
296
+ add(false, 'cbm-daemon', `daemon v${d.version} (pid ${d.pid || '?'}) đang chạy khác bản cài v${version} — bản cài sẽ bị từ chối (agent rơi về Grep/Glob) tới khi daemon cũ thoát. Daemon tự thoát khi phiên cuối đóng: đóng/khởi động lại các phiên Claude Code đang dùng MCP${d.clients.length ? ` (pid ${d.clients.join(', ')})` : ''}, hoặc \`codebase-memory-mcp daemon stop\` khi không còn phiên.${suffix}`);
297
+ } else if (d.state === 'unresponsive') {
298
+ add(false, 'cbm-daemon', `daemon không phản hồi${d.pid ? ` (endpoint do pid ${d.pid} giữ)` : ''} — có thể là bản khác; khởi động lại các phiên Claude Code đang dùng MCP${suffix}`);
299
+ } else if (d.state === 'active') {
300
+ add(true, 'cbm-daemon', `daemon v${d.version || '?'} đang chạy${d.clients.length ? `, ${d.clients.length} phiên` : ''}${suffix}`);
301
+ } else if (d.state === 'timeout') {
302
+ add(true, 'cbm-daemon', 'không đọc được `daemon status` trong 5 s (bỏ qua)');
303
+ } else {
304
+ add(true, 'cbm-daemon', `không có daemon đang chạy${suffix}`);
305
+ }
306
+ const ix = indexStatus(repo, e);
307
+ if (ix.root) {
308
+ if (ix.indexed) add(true, 'cbm-index', `${toSlash(ix.root)} đã index (project ${ix.project})`);
309
+ else {
310
+ add(false, 'cbm-index', `${toSlash(ix.root)} chưa index${ix.main_project ? ` (repo chính ${toSlash(ix.main)} đã có: ${ix.main_project})` : ''} — agent dùng Grep/Glob; index: \`${indexCommand(found.path, ix.root)}\``);
311
+ }
312
+ }
313
+ return checks;
314
+ })();
315
+ }
316
+
317
+ // ------------------------------------------------------------------ install
318
+
319
+ /** Official release asset for this OS/arch (names as published in checksums.txt). */
320
+ function assetFor(platform, arch) {
321
+ const a = arch === 'arm64' ? 'arm64' : (arch === 'x64' ? 'amd64' : null);
322
+ if (!a) return null;
323
+ if (platform === 'win32') return `${SERVER_NAME}-windows-${a}.zip`;
324
+ if (platform === 'darwin') return `${SERVER_NAME}-darwin-${a}.tar.gz`;
325
+ if (platform === 'linux') return `${SERVER_NAME}-linux-${a}-portable.tar.gz`; // portable = no glibc version dependency
326
+ return null;
327
+ }
328
+
329
+ function sha256File(file) {
330
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
331
+ }
332
+
333
+ function digestFor(checksums, asset) {
334
+ for (const line of String(checksums).split(/\r?\n/)) {
335
+ const m = /^([0-9a-fA-F]{64})\s+\*?(\S+)\s*$/.exec(line.trim());
336
+ if (m && m[2] === asset) return m[1].toLowerCase();
337
+ }
338
+ return null;
339
+ }
340
+
341
+ const defaultDeps = {
342
+ async fetchText(url) {
343
+ const res = await fetch(url, { redirect: 'follow', headers: { 'User-Agent': 'dieu-phoi' }, signal: AbortSignal.timeout(30000) });
344
+ if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`);
345
+ const len = Number(res.headers.get('content-length') || 0);
346
+ if (len > CHECKSUMS_MAX) throw new Error('checksums.txt vượt 1 MiB');
347
+ const t = await res.text();
348
+ if (t.length > CHECKSUMS_MAX) throw new Error('checksums.txt vượt 1 MiB');
349
+ return t;
350
+ },
351
+ async download(url, dest) {
352
+ const res = await fetch(url, { redirect: 'follow', headers: { 'User-Agent': 'dieu-phoi' }, signal: AbortSignal.timeout(600000) });
353
+ if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`);
354
+ fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
355
+ },
356
+ extract(archive, destDir) {
357
+ // Windows' own bsdtar reads .zip; a git-bash GNU tar earlier on PATH would not.
358
+ const tar = process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : 'tar';
359
+ const r = cp.spawnSync(tar, ['-xf', archive, '-C', destDir], { encoding: 'utf8', timeout: 300000, windowsHide: true });
360
+ if (r.error || r.status !== 0) throw new Error(`giải nén lỗi: ${r.error ? r.error.message : (r.stderr || '').trim().slice(0, 200)}`);
361
+ },
362
+ probeVersion,
363
+ runClaude(args, env) {
364
+ const win = process.platform === 'win32';
365
+ const q = (a) => (win && /[\s"&|<>^]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a);
366
+ const r = cp.spawnSync(win ? ['claude', ...args.map(q)].join(' ') : 'claude', win ? [] : args, {
367
+ shell: win, encoding: 'utf8', timeout: 60000, windowsHide: true, env,
368
+ });
369
+ return { status: r.error ? null : r.status, out: `${r.stdout || ''}${r.stderr || ''}`.trim(), error: r.error ? r.error.message : null };
370
+ },
371
+ };
372
+
373
+ function tsStamp(d) {
374
+ return (d || new Date()).toISOString().replace(/[-:]/g, '').replace(/\..*/, '').replace('T', '-');
375
+ }
376
+
377
+ /**
378
+ * `setup --with-codebase-memory`: install the official release binary (checksum-verified) and register it in Claude Code
379
+ * at user scope. An existing binary / registration is kept unless `force`; a replaced binary is renamed to
380
+ * `<bin>.bak-dieu-phoi-<ts>` first (rename works on Windows even while the old exe runs). `deps` is injectable for tests.
381
+ */
382
+ async function install(opts, deps) {
383
+ const d = Object.assign({}, defaultDeps, deps || {});
384
+ const env = opts.env || process.env;
385
+ const platform = opts.platform || process.platform;
386
+ const arch = opts.arch || process.arch;
387
+ const claudeDir = opts.claudeDir;
388
+ const force = !!opts.force;
389
+ const res = { ok: true, binary: null, version: null, action: null, backup: null, mcp: null, notes: [] };
390
+ const regs = registrations(claudeDir, null).filter((r) => r.scope === 'user');
391
+ const existing = findBinary(regs, env);
392
+
393
+ let target;
394
+ if (existing && !force) {
395
+ res.binary = existing.path;
396
+ res.version = d.probeVersion(existing.path);
397
+ res.action = 'kept';
398
+ target = existing.path;
399
+ } else {
400
+ const asset = assetFor(platform, arch);
401
+ if (!asset) throw new Error(`không có bản phát hành cho ${platform}/${arch} — xem https://github.com/${REPO}/releases`);
402
+ const base = (env.DIEU_PHOI_CBM_BASE_URL || RELEASE_BASE).replace(/\/+$/, '');
403
+ if (base !== RELEASE_BASE) {
404
+ const warn = `DIEU_PHOI_CBM_BASE_URL đang đặt nguồn tải KHÁC bản phát hành chính thức (${base} thay vì ${RELEASE_BASE}); checksums.txt lấy cùng nguồn đó nên sha256 không còn bảo đảm đây là bản chính thức`;
405
+ if (!opts.allowCustomSource) throw new Error(`${warn}. Không tải gì. Nếu đúng ý (mirror nội bộ, thử nghiệm): thêm --allow-custom-source; không thì bỏ biến này.`);
406
+ res.notes.push(`CẢNH BÁO: ${warn} (đã xác nhận bằng --allow-custom-source)`);
407
+ }
408
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dieu-phoi-cbm-'));
409
+ try {
410
+ const sums = await d.fetchText(`${base}/checksums.txt`);
411
+ const want = digestFor(sums, asset);
412
+ if (!want) throw new Error(`checksums.txt không có ${asset}`);
413
+ const archive = path.join(tmp, asset);
414
+ await d.download(`${base}/${asset}`, archive);
415
+ const got = sha256File(archive);
416
+ if (got !== want) throw new Error(`sha256 không khớp cho ${asset}: tải về ${got}, checksums.txt ${want} — không cài`);
417
+ const out = path.join(tmp, 'x');
418
+ fs.mkdirSync(out);
419
+ d.extract(archive, out);
420
+ const name = binName(platform);
421
+ const fresh = path.join(out, name);
422
+ const st = fs.existsSync(fresh) ? fs.lstatSync(fresh) : null;
423
+ if (!st || !st.isFile()) throw new Error(`không thấy ${name} (tệp thường) trong ${asset}`);
424
+ const v = d.probeVersion(fresh);
425
+ if (!v) throw new Error(`binary vừa tải không chạy được \`--version\` — không cài`);
426
+ const dir = typeof opts.dir === 'string' ? path.resolve(opts.dir) : (existing ? path.dirname(existing.path) : defaultInstallDir(platform, env));
427
+ target = path.join(dir, name);
428
+ fs.mkdirSync(dir, { recursive: true });
429
+ if (fs.existsSync(target)) {
430
+ res.backup = `${target}.bak-dieu-phoi-${tsStamp(d.now ? d.now() : new Date())}`;
431
+ fs.renameSync(target, res.backup);
432
+ }
433
+ try {
434
+ fs.copyFileSync(fresh, target);
435
+ if (platform !== 'win32') fs.chmodSync(target, 0o755);
436
+ } catch (e) {
437
+ if (res.backup) { try { fs.renameSync(res.backup, target); res.backup = null; } catch (_) { /* keep backup */ } }
438
+ throw e;
439
+ }
440
+ res.binary = target;
441
+ res.version = v;
442
+ res.action = existing ? 'replaced' : 'installed';
443
+ if (existing && !samePath(existing.path, target)) res.notes.push(`bản cũ vẫn ở ${toSlash(existing.path)} (không đụng)`);
444
+ } finally {
445
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) { /* best effort */ }
446
+ }
447
+ }
448
+
449
+ // registration (user scope, the standard `claude mcp add`)
450
+ const cmdPath = toSlash(target);
451
+ const same = regs.find((r) => r.command && samePath(r.command, target));
452
+ if (regs.length && !force) {
453
+ res.mcp = same ? 'kept' : 'kept-other';
454
+ if (!same) res.notes.push(`MCP user đã đăng ký trỏ tới ${regs.map((r) => r.command).join(', ')} — giữ nguyên (dùng --force để trỏ về ${cmdPath})`);
455
+ } else {
456
+ const cenv = Object.assign({}, env);
457
+ if (path.resolve(claudeDir) !== path.resolve(path.join(os.homedir(), '.claude'))) cenv.CLAUDE_CONFIG_DIR = claudeDir;
458
+ // old entries are remembered (command/args/env) before `remove`, so a failed `add` can put them back
459
+ const removed = [];
460
+ for (const r of regs) {
461
+ const rm = d.runClaude(['mcp', 'remove', '--scope', 'user', r.name], cenv);
462
+ if (rm.status === 0) removed.push(r);
463
+ }
464
+ const r = d.runClaude(['mcp', 'add', '--scope', 'user', SERVER_NAME, '--', cmdPath], cenv);
465
+ if (r.status === 0) res.mcp = regs.length ? 'replaced' : 'added';
466
+ else {
467
+ res.mcp = 'manual';
468
+ res.notes.push(`không chạy được \`claude mcp add\` (${r.error || r.out || `exit ${r.status}`}) — tự chạy: ${registerCommand(cmdPath)}`);
469
+ const lost = [];
470
+ for (const o of removed) {
471
+ const argv = ['mcp', 'add'];
472
+ for (const [k, v] of Object.entries(o.env || {})) argv.push('--env', `${k}=${v}`);
473
+ argv.push('--scope', 'user', o.name, '--', o.command || '', ...(o.args || []));
474
+ if (o.command && d.runClaude(argv, cenv).status === 0) res.mcp = 'restored';
475
+ else lost.push(o);
476
+ }
477
+ if (removed.length && !lost.length) res.notes.push(`đã khôi phục đăng ký cũ (${removed.map((o) => o.name).join(', ')}), không mất gì`);
478
+ for (const o of lost) {
479
+ res.notes.push(`KHÔNG khôi phục được đăng ký cũ "${o.name}" (command: ${o.command ? toSlash(o.command) : '?'}${o.args && o.args.length ? `, ${o.args.length} args` : ''}${Object.keys(o.env || {}).length ? `, env: ${Object.keys(o.env).join(', ')}` : ''}); thêm lại bằng \`claude mcp add --scope user ${o.name} -- <command> <args>\` (giá trị env lấy từ bản sao .claude.json của bạn)`);
480
+ }
481
+ }
482
+ }
483
+ return res;
484
+ }
485
+
486
+ module.exports = {
487
+ REPO, SERVER_NAME, INSTALL_HINT, claudeJsonPath, registrations, findBinary, probeVersion, parseDaemonStatus, daemonStatus,
488
+ cacheDir, repoRoot, indexStatus, cmpVersion, latestVersion, doctorChecks, assetFor, digestFor, install, defaultInstallDir,
489
+ registerCommand, indexCommand,
490
+ };
package/lib/commands.cjs CHANGED
@@ -8,6 +8,7 @@ const core = require('../hook.cjs');
8
8
  const agg = require('./aggregate.cjs');
9
9
  const chooser = require('./choose.cjs');
10
10
  const setupLib = require('./setup.cjs');
11
+ const cbm = require('./cbm.cjs');
11
12
  const { claudeDirOf, ctxOf, yesNo, repoOf } = require('./args.cjs');
12
13
 
13
14
  const AGENT_OUTCOMES = ['pass', 'fail', 'timeout', 'aborted'];
@@ -48,6 +49,7 @@ function table(rows) {
48
49
  // ------------------------------------------------------------------ setup / uninstall
49
50
 
50
51
  function cmdSetup(opts) {
52
+ if (opts['with-codebase-memory']) return cmdSetupWithCbm(opts);
51
53
  const res = setupLib.setup({
52
54
  claudeDir: claudeDirOf(opts),
53
55
  dept: typeof opts.dept === 'string' ? opts.dept : undefined,
@@ -75,6 +77,30 @@ function cmdSetup(opts) {
75
77
  return { code: 0, out: lines.join('\n') };
76
78
  }
77
79
 
80
+ /** `setup --with-codebase-memory [--force] [--cbm-dir D]`: normal setup, then install + register codebase-memory-mcp. */
81
+ async function cmdSetupWithCbm(opts) {
82
+ const base = cmdSetup(Object.assign({}, opts, { 'with-codebase-memory': false, json: false }));
83
+ let res;
84
+ let err = null;
85
+ try {
86
+ res = await cbm.install({ claudeDir: claudeDirOf(opts), force: !!opts.force, dir: typeof opts['cbm-dir'] === 'string' ? opts['cbm-dir'] : undefined, allowCustomSource: !!opts['allow-custom-source'] });
87
+ } catch (e) {
88
+ err = e.message;
89
+ }
90
+ if (opts.json) return { code: err ? 1 : 0, out: JSON.stringify({ setup: 'ok', codebase_memory: err ? { ok: false, error: err } : res }, null, 2) };
91
+ const lines = [base.out];
92
+ if (err) {
93
+ lines.push(`codebase-memory-mcp: LỖI — ${err} (setup dieu-phoi vẫn xong; không có gì bị ghi đè)`);
94
+ } else {
95
+ const act = { kept: 'đã có, giữ nguyên (--force để cài lại)', installed: 'đã cài', replaced: 'đã thay' }[res.action];
96
+ lines.push(`codebase-memory-mcp: ${act} v${res.version || '?'} tại ${res.binary.replace(/\\/g, '/')}${res.backup ? ` (bản cũ: ${path.basename(res.backup)})` : ''}`);
97
+ lines.push(`MCP (user scope): ${{ kept: 'đã đăng ký, giữ nguyên', 'kept-other': 'đã đăng ký (binary khác), giữ nguyên', added: 'đã đăng ký bằng `claude mcp add`', replaced: 'đã đăng ký lại', restored: 'đăng ký mới lỗi — đã khôi phục đăng ký cũ', manual: 'chưa đăng ký (xem ghi chú)' }[res.mcp]}`);
98
+ for (const n of res.notes) lines.push(` - ${n}`);
99
+ if (res.action !== 'kept' || res.mcp === 'added' || res.mcp === 'replaced') lines.push('Khởi động lại các phiên Claude Code để nạp MCP; kiểm bằng `dieu-phoi doctor`.');
100
+ }
101
+ return { code: err ? 1 : 0, out: lines.join('\n') };
102
+ }
103
+
78
104
  function cmdUninstall(opts) {
79
105
  const res = setupLib.uninstall({ claudeDir: claudeDirOf(opts), purge: !!opts.purge });
80
106
  if (opts.json) return { code: 0, out: JSON.stringify(res, null, 2) };
@@ -116,6 +142,14 @@ async function cmdDoctor(opts) {
116
142
  const sd = ctx.config.skill_dir;
117
143
  add(!!sd && fs.existsSync(path.join(sd, 'profile', 'profile.json')), 'profile',
118
144
  sd ? path.join(sd, 'profile', 'profile.json') : 'không có skill_dir — choose dùng priors mặc định', 'CẢNH BÁO');
145
+ // codebase-memory-mcp (optional): started here so its `daemon status` (~2.7 s, only with --cbm/--net; else a 10-minute
146
+ // cache) overlaps the hook-run check below
147
+ let cbmP = Promise.resolve([]);
148
+ try {
149
+ cbmP = cbm.doctorChecks({ claudeDir, repo: repoOf(opts), net: !!opts.net, deep: !!opts.cbm, cacheFile: path.join(ctx.paths.data, 'cbm-daemon.json') }).catch((e) => [{ ok: true, level: 'OK', name: 'codebase-memory', detail: `bỏ qua kiểm (${e.message})` }]);
150
+ } catch (e) {
151
+ cbmP = Promise.resolve([{ ok: true, level: 'OK', name: 'codebase-memory', detail: `bỏ qua kiểm (${e.message})` }]);
152
+ }
119
153
  if (reg.commands.length) {
120
154
  const cmd = reg.commands[0];
121
155
  const t0 = Date.now();
@@ -168,13 +202,17 @@ async function cmdDoctor(opts) {
168
202
  const cached = opts.net ? null : core.sinkStatusCached(ctx);
169
203
  if (cached && (cached.state === 'ok' || cached.state === 'fail')) {
170
204
  add(cached.state === 'ok', 'sink', `${ctx.config.sink_url} → ${cached.state === 'ok' ? 'ok' : `lỗi: ${cached.detail}`} (bộ nhớ đệm: ${cached.source} lúc ${cached.ts}; \`doctor --net\` để kiểm trực tiếp)`);
205
+ } else if (!opts.net && recentSinkTimeout(ctx)) {
206
+ const t = recentSinkTimeout(ctx);
207
+ add(false, 'sink', `${ctx.config.sink_url} → chậm/không xác định: lần kiểm lúc ${t.ts} không phản hồi kịp (chưa coi là lỗi); \`doctor --net\` để thử lại`, 'CẢNH BÁO');
171
208
  } else {
172
209
  const live = await checkSinkLive(ctx);
173
- add(live.ok, 'sink', `${ctx.config.sink_url} → ${live.detail}`);
210
+ add(live.ok, 'sink', `${ctx.config.sink_url} → ${live.detail}`, live.slow ? 'CẢNH BÁO' : undefined);
174
211
  }
175
212
  } else {
176
213
  add(true, 'sink', 'chưa cấu hình (chỉ lưu cục bộ)');
177
214
  }
215
+ for (const c of await cbmP) checks.push(c);
178
216
  const failed = checks.some((c) => c.level === 'LỖI');
179
217
  if (opts.json) return { code: failed ? 1 : 0, out: JSON.stringify({ ok: !failed, runs_dir: reg.runs_dir, checks }, null, 2) };
180
218
  const out = checks.map((c) => `[${c.level}] ${c.name}: ${c.detail}`).join('\n');
@@ -208,26 +246,45 @@ function sweepStaleTmp(ctx) {
208
246
  return { removed, left };
209
247
  }
210
248
 
211
- /** Live sink check (view=stats); result cached in data/sink-check.json for 24 h. */
249
+ /** The doctor's own timed-out sink check for the current sink, if less than 1 h old (else null): plain `doctor` does not retry a 20 s wait each time. */
250
+ function recentSinkTimeout(ctx) {
251
+ const chk = core.readJsonFile(ctx.paths.sinkCheck, null);
252
+ if (!chk || !chk.timeout || chk.url !== core.sinkUrlKey(ctx.config)) return null;
253
+ const t = Date.parse(chk.ts);
254
+ return t && Date.now() - t < 3600 * 1000 && t <= Date.now() + 60000 ? { ts: chk.ts } : null;
255
+ }
256
+
257
+ /**
258
+ * Live sink check (view=stats); result cached in data/sink-check.json for 24 h. Apps Script often needs 8-15 s, so the
259
+ * timeout is 20 s (DIEU_PHOI_SINK_CHECK_TIMEOUT_MS overrides, for tests). A timeout is not a hard failure: it is
260
+ * reported as slow/unknown (CẢNH BÁO) and cached with `timeout: true`, which the cache readers ignore.
261
+ */
212
262
  async function checkSinkLive(ctx) {
213
263
  let ok = false;
264
+ let slow = false;
214
265
  let detail;
266
+ const ms = Number(process.env.DIEU_PHOI_SINK_CHECK_TIMEOUT_MS) > 0 ? Number(process.env.DIEU_PHOI_SINK_CHECK_TIMEOUT_MS) : 20000;
215
267
  try {
216
268
  const u = new URL(ctx.config.sink_url);
217
269
  u.searchParams.set('token', ctx.config.sink_token);
218
270
  u.searchParams.set('view', 'stats');
219
- const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(8000) });
271
+ const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(ms) });
220
272
  const txt = await res.text();
221
273
  ok = res.ok;
222
274
  try { const b = JSON.parse(txt); if (b && b.ok === false) ok = false; } catch (_) { ok = false; }
223
275
  detail = `HTTP ${res.status}`;
224
276
  } catch (e) {
225
- detail = e.message;
277
+ if (e && (e.name === 'TimeoutError' || e.name === 'AbortError')) {
278
+ slow = true;
279
+ detail = `chậm/không xác định: không phản hồi trong ${Math.round(ms / 1000)} s (Apps Script thường mất 8-15 s; chạy lại \`doctor --net\`, hoặc xem kết quả sync nền)`;
280
+ } else {
281
+ detail = e.message;
282
+ }
226
283
  }
227
284
  try {
228
- core.writeFileAtomic(ctx.paths.sinkCheck, `${JSON.stringify({ ts: core.nowIso(), ok, detail, url: core.sinkUrlKey(ctx.config) })}\n`);
285
+ core.writeFileAtomic(ctx.paths.sinkCheck, `${JSON.stringify({ ts: core.nowIso(), ok, detail, url: core.sinkUrlKey(ctx.config), ...(slow ? { timeout: true } : {}) })}\n`);
229
286
  } catch (_) { /* cache is best effort */ }
230
- return { ok, detail };
287
+ return { ok, detail, slow };
231
288
  }
232
289
 
233
290
  // ------------------------------------------------------------------ start
package/lib/knowledge.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // `dieu-phoi knowledge ...` — per-project knowledge store (core 0.6.0, unreleased).
2
+ // `dieu-phoi knowledge ...` — per-project knowledge store (core 0.6.0).
3
3
  // s1: project identity (path, rekey). s2: the store itself — index.json + sections/<id>.md + history/, read with
4
4
  // get/list, written only by `merge --run <id>` (lock, tmp+rename, history, 40-line cap, sha/rev conflicts), and
5
5
  // cross-repo links (related: child|parent) for a parent repo whose sessions work in its child repos.
package/lib/profile.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- // `dieu-phoi profile base|apply` (core 0.6.0, unreleased): apply an approved retro to <skill>/profile/lessons.md and
2
+ // `dieu-phoi profile base|apply` (core 0.6.0): apply an approved retro to <skill>/profile/lessons.md and
3
3
  // profile.json without overwriting what other sessions wrote meanwhile.
4
4
  //
5
5
  // profile base --save <dir> snapshot both files into <dir>/profile-base/ (+ base.json with their sha256) before the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kienntpixon/dieu-phoi",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Hook + CLI ghi log subagent của Claude Code và chọn model/effort theo chi phí + thời gian tới lúc nghiệm thu.",
5
5
  "license": "MIT",
6
6
  "author": "kiennt <kiennt@pixon.games>",