@kienntpixon/dieu-phoi 0.5.0 → 0.5.2
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 +17 -0
- package/README.md +2 -1
- package/agents/dp-exec.md +2 -1
- package/agents/dp-helper.md +2 -1
- package/agents/dp-scout.md +4 -1
- package/agents/dp-verify.md +4 -1
- package/bin/dieu-phoi.cjs +4 -0
- package/hook.cjs +140 -20
- package/lib/aggregate.cjs +4 -1
- package/lib/args.cjs +1 -1
- package/lib/choose.cjs +9 -7
- package/lib/commands.cjs +2 -2
- package/lib/prune.cjs +321 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog — @kienntpixon/dieu-phoi
|
|
2
2
|
|
|
3
|
+
## 0.5.2
|
|
4
|
+
|
|
5
|
+
- **`dieu-phoi prune --after-retro <file> [--yes]`:** dọn sau retro, dry-run mặc định. Run dir của task đã có verdict người dùng và mọi sự kiện ≤ mốc cắt (dòng `cutoff:` trong file retro, không có thì mtime) chỉ còn `retro-*.md` + dp-out exec/verify cuối (giữ = try cao nhất VÀ file mới nhất theo mtime cho mỗi task+role; file legacy không phân loại được và kết quả inline cuối cũng giữ); xoá file plan hỏng (tên không phải task id hoặc không đọc được); task còn mở không đụng. Event của task đã dọn chuyển vào `data/archive/events-<ngày>.jsonl.gz` (ghi + xác minh archive trước, rồi ghi lại `events.jsonl` qua file tạm + rename, giữ khoá sync); `readEvents` đọc cả archive (chống trùng nếu prune chết giữa chừng) nên retro/stats/choose/state không đổi số; sync chỉ đọc `events.jsonl`, offset được dịch theo phần đã bỏ, event chưa đẩy lên sink (khi có sink) không bị chuyển. Run dir có file mới hơn mốc cắt (việc đang chạy chưa vào log) không bị dọn. Từ chối gốc ổ đĩa, không đi theo symlink/junction, chỉ đụng runs dir + data dir.
|
|
6
|
+
- **Nâng cấp:** dán lại `sheet/Code.gs` và deploy bản mới cùng lúc phát hành — Code.gs cũ từ chối kind `gate` (`bad_kind` → `sync-rejected.jsonl`) và bỏ các cột `complete` mới.
|
|
7
|
+
- **Điểm chọn tier dùng trung vị thời gian (retro 2026-10-18):** ô `retro`/`stats` tính `score = cost_mean + λ × time_p50` (trước: `time_mean`); Thompson trong `choose` dùng `attempt_p50_min` (urgent vẫn p75). Thời gian chờ người dùng/quota đẩy trung bình lên 3–13× và lật prior sang tier chậm hơn. `time_mean_min` vẫn được báo. Test mới `test/retro-score.test.cjs`.
|
|
8
|
+
- **Prior/trần thời gian (profile ai-lab, builtin khớp):** `debug` → sonnet/high; `time_cap_min` theo p75 thật: mechanical-edit 25, test-write 60, review 40, plan-design 30, art-gen 100.
|
|
9
|
+
- **Telemetry `complete`:** thêm `status` (dấu `STATUS:`/`VERDICT:` chuẩn hoá: done|pass|fail|partial|blocked|handoff|quota|other|none), `handoff`, `peak_ctx` (ngữ cảnh lớn nhất một request, cách đo của watchdog), `compactions` (số `compact_boundary`), `inline_result` (exec/verify/plan: hook phải tự ghi `.inline.md`). `.inline.md` giờ được ghi trước khi ghi event.
|
|
10
|
+
- **Kind mới `gate`:** hook `--gate`/`--gate-release` ghi một event cho mỗi quyết định `allow|deny|release` (`slot`, `children`, `child_task_id`, `reason_code` = `not-exec|type|model|tag|parent` nối `+`, hoặc `max-children`). Thước đo bỏ qua. Sink thêm cột tương ứng; fixture parity + simulate có ca mới. Test mới `test/telemetry.test.cjs`.
|
|
11
|
+
- **Agent `dp-verify`/`dp-scout`:** dòng bắt buộc "chốt VERDICT xong thì hành động kế tiếp là Write dp-out" (retro: verifier trả inline thay vì ghi file).
|
|
12
|
+
|
|
13
|
+
## 0.5.1
|
|
14
|
+
|
|
15
|
+
Nâng ngưỡng watchdog ngữ cảnh subagent 150k/180k -> 200k/230k. Đo thí điểm: sonnet exec với `autoCompactWindow: 200000` tự nén ở 167.370 (~0,837 cửa sổ) — nén TRƯỚC khi lời nhắc 200k kịp tới.
|
|
16
|
+
|
|
17
|
+
- **Ngưỡng cấu hình được, không sửa code:** env `DIEU_PHOI_WATCH_WARN` / `DIEU_PHOI_WATCH_HARD`, hoặc `watch_warn` / `watch_hard` trong `<home>/config.json`; đọc lại mỗi lần hook chạy nên subagent đang chạy cũng nhận (env chỉ đổi được với tiến trình mới; config.json đổi tức thì). Giá trị < 20000 / không phải số bị bỏ qua.
|
|
18
|
+
- **`autoCompactWindow: 300000`** trong cả 4 `agents/dp-*.md` (nén ~251k theo tỉ lệ, ~267k theo độ lệch, đều cao hơn 230k >= 20k). Chạy lại `dieu-phoi setup`. Agent đã chạy giữ cửa sổ cũ tới khi kết thúc.
|
|
19
|
+
|
|
3
20
|
## 0.5.0
|
|
4
21
|
|
|
5
22
|
Giới hạn ngữ cảnh subagent (đo trên 429 subagent từ 2026-10-09: p50 đỉnh 144k, p90 373k, max 789k; lượt >128k = 83% chi phí; subagent không bao giờ tự nén vì chạy cửa sổ 1M).
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Không cài toàn cục được thì dùng `npx -y @kienntpixon/dieu-phoi <lệ
|
|
|
29
29
|
1. chép `hook.cjs` + `pricing.json` vào `~/.claude/dieu-phoi/bin/`;
|
|
30
30
|
2. sao lưu `~/.claude/settings.json` thành `settings.json.bak-dieu-phoi-<thời gian>` rồi đăng ký `PostToolUse` (matcher `Agent`) và `SubagentStop` chạy `"<đường dẫn node>" "<…>/dieu-phoi/bin/hook.cjs"` (node tuyệt đối, không phụ thuộc PATH). Các hook và khoá khác giữ nguyên, kể cả mảng/đối tượng rỗng. Chạy lại khi hook đã đúng thì không ghi file, không tạo bản sao lưu. Đổi phiên bản node (nvm…) thì chạy lại `setup`;
|
|
31
31
|
3. ghi `~/.claude/dieu-phoi/config.json` (phòng ban, sink, đường dẫn skill, mã băm người dùng). Thư mục skill tự dò: `<claude>/skills/dieu-phoi`, hoặc thư mục `<claude>/skills/*` có `profile/profile.json` và `SKILL.md` mang `name: dieu-phoi` (fork phòng ban đổi tên thư mục vẫn tìm được).
|
|
32
|
-
4. (0.5.0) ghi `~/.claude/agents/dp-exec.md`, `dp-verify.md`, `dp-scout.md`, `dp-helper.md` (`autoCompactWindow:
|
|
32
|
+
4. (0.5.0) ghi `~/.claude/agents/dp-exec.md`, `dp-verify.md`, `dp-scout.md`, `dp-helper.md` (`autoCompactWindow: 300000`, hook watchdog ngữ cảnh + cổng helper gen-2 trong frontmatter — chỉ chạy khi điều phối viên giao bằng các loại agent này). File trùng tên không phải của dieu-phoi được giữ nguyên.
|
|
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
|
|
|
@@ -54,6 +54,7 @@ Khởi động lại Claude Code sau khi cài. Kiểm tra bằng `dieu-phoi doct
|
|
|
54
54
|
| `start [--json]` | khởi động cổng 1 trong một lệnh, không gọi mạng: kiểm cục bộ, `root_task_id` + `step_ids`, tạo run dir, đường dẫn brief scout. Thường không cần: hook `UserPromptSubmit` làm sẵn việc này khi prompt có "điều phối" hoặc bắt đầu bằng `/dieu-phoi` |
|
|
55
55
|
| `new-id [--count n] [--json]` | in task_id mới hợp lệ (đúng 8 ký tự `a-z0-9`), mỗi dòng một id (mặc định 1, tối đa 1000; `--json`: mảng). Lấy mọi `task_id` / `root_task_id` từ đây — không tự bịa. `choose/verdict/steer --task` và `plan-table --approve` từ chối id sai định dạng (kèm gợi ý id mới); hook vẫn ghi id cũ nhưng gắn `task_id_invalid: true`, `doctor` cảnh báo |
|
|
56
56
|
| `sync [--json]` | đẩy bản ghi chưa đồng bộ lên sink, lô ≤ 200, có thử lại. Bản ghi sink từ chối → `data/sync-rejected.jsonl`, `doctor` báo số lượng |
|
|
57
|
+
| `prune --after-retro <file> [--yes]` | dọn sau retro (dry-run mặc định): run dir đã nghiệm thu, plan hỏng, event cũ → `data/archive/*.jsonl.gz` |
|
|
57
58
|
| `retro [--json]` | tóm tắt cho vòng retro: số liệu từng ô, đếm `reason_code`, danh sách loại task, tóm tắt điều phối (`orchestration`) |
|
|
58
59
|
| `uninstall [--purge]` | gỡ hook và đúng các quyền thư mục `runs` đã thêm khỏi settings (có sao lưu); `--purge` xoá cả `hook.cjs`. Dữ liệu giữ lại |
|
|
59
60
|
|
package/agents/dp-exec.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
name: dp-exec
|
|
3
3
|
description: "dieu-phoi only — executor subagent dispatched by the dieu-phoi orchestrator with a [dp:...] brief. Do not pick this agent on your own."
|
|
4
4
|
# dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
|
|
5
|
-
|
|
5
|
+
# 300000: hook --watch warns at 200k/230k; auto-compact fired at 167,370 of a 200000 window (~0.837, or window-32.6k), so 300000 compacts at ~251k (ratio) / ~267k (offset), >=20k above 230k
|
|
6
|
+
autoCompactWindow: 300000
|
|
6
7
|
hooks:
|
|
7
8
|
PostToolUse:
|
|
8
9
|
- matcher: "*"
|
package/agents/dp-helper.md
CHANGED
|
@@ -3,7 +3,8 @@ name: dp-helper
|
|
|
3
3
|
description: "dieu-phoi only — gen-2 helper spawned by a dp-exec agent (read/scan and summarize, or run tests and summarize logs). Do not pick this agent on your own."
|
|
4
4
|
# dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
|
|
5
5
|
disallowedTools: Agent, Task, Edit, Write, NotebookEdit
|
|
6
|
-
|
|
6
|
+
# 300000: hook --watch warns at 200k/230k; auto-compact fired at 167,370 of a 200000 window (~0.837, or window-32.6k), so 300000 compacts at ~251k (ratio) / ~267k (offset), >=20k above 230k
|
|
7
|
+
autoCompactWindow: 300000
|
|
7
8
|
hooks:
|
|
8
9
|
PostToolUse:
|
|
9
10
|
- matcher: "*"
|
package/agents/dp-scout.md
CHANGED
|
@@ -3,7 +3,8 @@ name: dp-scout
|
|
|
3
3
|
description: "dieu-phoi only — read-only scout/planner subagent dispatched by the dieu-phoi orchestrator with a [dp:...|role=plan] brief. Do not pick this agent on your own."
|
|
4
4
|
# dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
|
|
5
5
|
disallowedTools: Agent, Task, Edit, NotebookEdit
|
|
6
|
-
|
|
6
|
+
# 300000: hook --watch warns at 200k/230k; auto-compact fired at 167,370 of a 200000 window (~0.837, or window-32.6k), so 300000 compacts at ~251k (ratio) / ~267k (offset), >=20k above 230k
|
|
7
|
+
autoCompactWindow: 300000
|
|
7
8
|
hooks:
|
|
8
9
|
PostToolUse:
|
|
9
10
|
- matcher: "*"
|
|
@@ -14,6 +15,8 @@ hooks:
|
|
|
14
15
|
---
|
|
15
16
|
Bạn là scout/planner của dieu-phoi: khảo sát (code, tài liệu, dữ liệu) theo brief ở tin nhắn đầu (thẻ `[dp:...|role=plan]`) và trả về kế hoạch/câu hỏi đúng định dạng brief. Không sửa file của dự án; Write chỉ dùng để ghi báo cáo/kế hoạch vào run dir.
|
|
16
17
|
|
|
18
|
+
**Bắt buộc: có kế hoạch/kết luận xong thì hành động kế tiếp là gọi Write** tạo `dp-out-<task_id>-plan-try<n>.md` (dòng đầu file = dòng `STATUS:`). Chưa Write thành công thì chưa được trả lời; trả lời mà không có file là lỗi bàn giao (hook ghi nhận). Write bị từ chối → Bash heredoc; vẫn bị → trả nội dung inline.
|
|
19
|
+
|
|
17
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.
|
|
18
21
|
|
|
19
22
|
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):
|
package/agents/dp-verify.md
CHANGED
|
@@ -3,7 +3,8 @@ name: dp-verify
|
|
|
3
3
|
description: "dieu-phoi only — independent verifier subagent dispatched by the dieu-phoi orchestrator with a [dp:...|role=verify] brief. Do not pick this agent on your own."
|
|
4
4
|
# dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
|
|
5
5
|
disallowedTools: Agent, Task, Edit, NotebookEdit
|
|
6
|
-
|
|
6
|
+
# 300000: hook --watch warns at 200k/230k; auto-compact fired at 167,370 of a 200000 window (~0.837, or window-32.6k), so 300000 compacts at ~251k (ratio) / ~267k (offset), >=20k above 230k
|
|
7
|
+
autoCompactWindow: 300000
|
|
7
8
|
hooks:
|
|
8
9
|
PostToolUse:
|
|
9
10
|
- matcher: "*"
|
|
@@ -14,6 +15,8 @@ hooks:
|
|
|
14
15
|
---
|
|
15
16
|
Bạn là agent kiểm chứng (verify) của dieu-phoi: kiểm tra độc lập kết quả của bước exec theo brief ở tin nhắn đầu (thẻ `[dp:...|role=verify]`). Chỉ đọc và chạy lệnh kiểm tra (test, lint, build); không sửa code của dự án. Ghi báo cáo vào run dir (Write chỉ dùng cho báo cáo) và trả về verdict ngắn đúng định dạng brief.
|
|
16
17
|
|
|
18
|
+
**Bắt buộc: chốt VERDICT xong thì hành động kế tiếp là gọi Write** tạo `dp-out-<task_id>-verify-try<n>.md` (dòng đầu file = dòng `VERDICT:`). Chưa Write thành công thì chưa được trả lời; trả verdict mà không có file là lỗi bàn giao (hook ghi nhận, 10/24 file `.inline.md` gần đây là của verifier). Write bị từ chối → Bash heredoc; vẫn bị → trả nội dung inline.
|
|
19
|
+
|
|
17
20
|
Bàn giao qua file: ghi TOÀN BỘ kết quả vào `dp-out-<task_id>-verify-try<n>.md` trong run dir (đường dẫn brief chỉ định), rồi chỉ trả: đường dẫn file đó, dòng `STATUS:`/`VERDICT:`, 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.
|
|
18
21
|
|
|
19
22
|
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):
|
package/bin/dieu-phoi.cjs
CHANGED
|
@@ -40,6 +40,9 @@ Cách dùng: dieu-phoi <lệnh> [tuỳ chọn]
|
|
|
40
40
|
in task_id mới hợp lệ (8 ký tự a-z0-9), mỗi dòng một id — không tự bịa id
|
|
41
41
|
sync đẩy bản ghi chưa đồng bộ lên sink
|
|
42
42
|
retro [--json] tóm tắt cho vòng retro (không chứa nội dung)
|
|
43
|
+
prune --after-retro <file retro> [--yes]
|
|
44
|
+
dọn sau retro: run dir đã nghiệm thu (giữ retro-*.md + dp-out exec/verify cuối), plan hỏng,
|
|
45
|
+
events cũ → data/archive/*.jsonl.gz (không xoá event). Mặc định dry-run; --yes mới thực hiện
|
|
43
46
|
uninstall [--purge] gỡ hook khỏi settings.json (--purge xoá cả hook.cjs; dữ liệu giữ lại)
|
|
44
47
|
hook (nội bộ) đọc payload hook từ stdin
|
|
45
48
|
|
|
@@ -81,6 +84,7 @@ async function main(argv) {
|
|
|
81
84
|
state: c.cmdState,
|
|
82
85
|
sync: c.cmdSync,
|
|
83
86
|
retro: c.cmdRetro,
|
|
87
|
+
prune: c.cmdPrune,
|
|
84
88
|
uninstall: c.cmdUninstall,
|
|
85
89
|
};
|
|
86
90
|
const fn = table[cmd];
|
package/hook.cjs
CHANGED
|
@@ -25,7 +25,7 @@ const path = require('path');
|
|
|
25
25
|
const os = require('os');
|
|
26
26
|
const crypto = require('crypto');
|
|
27
27
|
|
|
28
|
-
const CORE_VERSION = '0.5.
|
|
28
|
+
const CORE_VERSION = '0.5.2';
|
|
29
29
|
const SCHEMA = 1;
|
|
30
30
|
const TASK_TYPES = [
|
|
31
31
|
'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
|
|
@@ -413,8 +413,10 @@ function summarizeTranscript(lines, opts) {
|
|
|
413
413
|
let anonReq = 0;
|
|
414
414
|
let firstTs = null;
|
|
415
415
|
let lastTs = null;
|
|
416
|
+
let compactions = 0;
|
|
416
417
|
for (const l of seg) {
|
|
417
418
|
if (!l || typeof l !== 'object') continue;
|
|
419
|
+
if (l.type === 'system' && l.subtype === 'compact_boundary') compactions++;
|
|
418
420
|
if (typeof l.timestamp === 'string') {
|
|
419
421
|
const t = Date.parse(l.timestamp);
|
|
420
422
|
if (!isNaN(t)) {
|
|
@@ -441,10 +443,14 @@ function summarizeTranscript(lines, opts) {
|
|
|
441
443
|
let cost = 0;
|
|
442
444
|
let costKnown = true;
|
|
443
445
|
const modelCount = new Map();
|
|
446
|
+
let peak = null;
|
|
444
447
|
for (const { usage, model } of byReq.values()) {
|
|
445
448
|
if (model) modelCount.set(model, (modelCount.get(model) || 0) + 1);
|
|
446
449
|
if (!usage) continue;
|
|
447
450
|
const b = usageBuckets(usage);
|
|
451
|
+
// same measure as the watchdog (lastContextTokens): input + cache_read + cache_creation of one request
|
|
452
|
+
const ctxTok = b.input + b.cache_read + b.cache_write_5m + b.cache_write_1h;
|
|
453
|
+
if (peak === null || ctxTok > peak) peak = ctxTok;
|
|
448
454
|
for (const k of Object.keys(tokens)) tokens[k] += b[k];
|
|
449
455
|
const c = o.pricing ? requestCost(o.pricing, model, b) : null;
|
|
450
456
|
if (c === null) costKnown = false; else cost += c;
|
|
@@ -462,6 +468,8 @@ function summarizeTranscript(lines, opts) {
|
|
|
462
468
|
tokens,
|
|
463
469
|
cost_usd: costKnown && byReq.size ? Math.round(cost * 1e8) / 1e8 : (byReq.size ? null : 0),
|
|
464
470
|
model: normalizeModel(model),
|
|
471
|
+
peak_ctx: peak,
|
|
472
|
+
compactions,
|
|
465
473
|
last_uuid: lastUuidOf(seg),
|
|
466
474
|
};
|
|
467
475
|
}
|
|
@@ -798,14 +806,46 @@ function readEventLines(ctx) {
|
|
|
798
806
|
return eventLinesFrom(readEventsBuffer(ctx), 0).map((l) => l.text);
|
|
799
807
|
}
|
|
800
808
|
|
|
801
|
-
|
|
809
|
+
/** <data>/archive/events-<date>.jsonl.gz, oldest first (written by `dieu-phoi prune`). */
|
|
810
|
+
function archiveFiles(ctx) {
|
|
811
|
+
const dir = path.join(ctx.paths.data, 'archive');
|
|
812
|
+
let names = [];
|
|
813
|
+
try { names = fs.readdirSync(dir); } catch (_) { return []; }
|
|
814
|
+
return names.filter((n) => /^events-\d{4}-\d{2}-\d{2}\.jsonl\.gz$/.test(n)).sort().map((n) => path.join(dir, n));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Raw non-empty lines of every archive (gzip members may be concatenated). A corrupt archive throws. */
|
|
818
|
+
function readArchiveLines(ctx) {
|
|
802
819
|
const out = [];
|
|
803
|
-
for (const
|
|
820
|
+
for (const f of archiveFiles(ctx)) {
|
|
821
|
+
const txt = require('zlib').gunzipSync(fs.readFileSync(f)).toString('utf8');
|
|
822
|
+
for (const l of txt.split(String.fromCharCode(10))) if (l.trim()) out.push(l.trim());
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function parseEventLines(lines, out) {
|
|
828
|
+
for (const line of lines) {
|
|
804
829
|
try {
|
|
805
830
|
const o = JSON.parse(line);
|
|
806
831
|
if (o && typeof o === 'object') out.push(o);
|
|
807
832
|
} catch (_) { /* skip */ }
|
|
808
833
|
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* All events: archived history first, then events.jsonl. A line present in both (prune died between writing the
|
|
838
|
+
* archive and rewriting events.jsonl) counts once. Sync never uses this: it reads events.jsonl only.
|
|
839
|
+
*/
|
|
840
|
+
function readEvents(ctx) {
|
|
841
|
+
const out = [];
|
|
842
|
+
const cur = readEventLines(ctx);
|
|
843
|
+
const arch = readArchiveLines(ctx);
|
|
844
|
+
if (arch.length) {
|
|
845
|
+
parseEventLines(arch, out);
|
|
846
|
+
const seen = new Set(arch);
|
|
847
|
+
parseEventLines(cur.filter((l) => !seen.has(l)), out);
|
|
848
|
+
} else parseEventLines(cur, out);
|
|
809
849
|
return out;
|
|
810
850
|
}
|
|
811
851
|
|
|
@@ -1609,6 +1649,19 @@ function handleSubagentStop(p, ctx) {
|
|
|
1609
1649
|
cost_usd: sum ? sum.cost_usd : null,
|
|
1610
1650
|
model: sum ? sum.model : null,
|
|
1611
1651
|
}, commonFields(ctx));
|
|
1652
|
+
// Core 0.5.2 (retro 2026-10-18): how the segment ended, measured here instead of re-reading transcripts later.
|
|
1653
|
+
// Labels and numbers only, never text.
|
|
1654
|
+
const why = exists ? abortReasonOf(lines) : null;
|
|
1655
|
+
ev.status = exists ? completionStatus(finalAssistantText(lines, p.last_assistant_message), why) : null;
|
|
1656
|
+
ev.handoff = ev.status === 'handoff';
|
|
1657
|
+
ev.peak_ctx = sum ? sum.peak_ctx : null;
|
|
1658
|
+
ev.compactions = sum ? sum.compactions : null;
|
|
1659
|
+
let inline = null;
|
|
1660
|
+
if (exists && tag) {
|
|
1661
|
+
try { inline = persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
|
|
1662
|
+
}
|
|
1663
|
+
// exec/verify/plan only: true = it returned without writing its dp-out file (the hook saved the .inline.md)
|
|
1664
|
+
if (tag && RESULT_ROLES.includes(tag.role)) ev.inline_result = Boolean(inline);
|
|
1612
1665
|
// Record the segment before appending so a racing duplicate fire sees it.
|
|
1613
1666
|
if (sum && sum.last_uuid) {
|
|
1614
1667
|
saveSegments(ctx, agentId, {
|
|
@@ -1618,12 +1671,8 @@ function handleSubagentStop(p, ctx) {
|
|
|
1618
1671
|
});
|
|
1619
1672
|
}
|
|
1620
1673
|
appendEvent(ctx, ev);
|
|
1621
|
-
if (exists && tag) {
|
|
1622
|
-
try { persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
|
|
1623
|
-
}
|
|
1624
1674
|
// Core 0.4.0: a segment that ended on a usage-limit / quota pause gets an automatic agent verdict
|
|
1625
1675
|
// `aborted` (never a fail, never counted toward escalation); the real verdict comes after the resume.
|
|
1626
|
-
const why = exists ? abortReasonOf(lines) : null;
|
|
1627
1676
|
if (why) {
|
|
1628
1677
|
appendEvent(ctx, Object.assign({
|
|
1629
1678
|
schema: SCHEMA, kind: 'verdict', ts: nowIso(), agent_id: agentId, task_id: t.task_id, by: 'agent', outcome: 'aborted',
|
|
@@ -1669,13 +1718,61 @@ function abortReasonOf(lines) {
|
|
|
1669
1718
|
return null;
|
|
1670
1719
|
}
|
|
1671
1720
|
|
|
1721
|
+
/**
|
|
1722
|
+
* `complete.status` (core 0.5.2): how the agent's final report labels itself, as a fixed label (no text stored).
|
|
1723
|
+
* Quota / usage-limit stop -> "quota"; exact handoff marker -> "handoff"; else the first `STATUS:` or `VERDICT:` line
|
|
1724
|
+
* among the first 12 lines of the final text: done | partial | blocked | pass | fail, anything else -> "other".
|
|
1725
|
+
* No final text -> null; text without such a line -> "none".
|
|
1726
|
+
*/
|
|
1727
|
+
function completionStatus(text, abortWhy) {
|
|
1728
|
+
if (abortWhy === 'usage-limit' || abortWhy === 'quota-pause') return 'quota';
|
|
1729
|
+
if (abortWhy === 'handoff') return 'handoff';
|
|
1730
|
+
if (typeof text !== 'string' || !text.trim()) return null;
|
|
1731
|
+
const head = text.split(/\r?\n/).slice(0, 12);
|
|
1732
|
+
for (const raw of head) {
|
|
1733
|
+
const m = /^[\s>*_`#-]*(STATUS|VERDICT)\s*[*_`]*\s*[:=]\s*[*_`]*\s*([A-Za-z]+)/i.exec(raw);
|
|
1734
|
+
if (!m) continue;
|
|
1735
|
+
const w = m[2].toLowerCase();
|
|
1736
|
+
if (w === 'partial' && /handoff/i.test(raw)) return 'handoff';
|
|
1737
|
+
if (w === 'partial' && /quota/i.test(raw)) return 'quota';
|
|
1738
|
+
if (w === 'done' || w === 'ok' || w === 'complete' || w === 'completed') return 'done';
|
|
1739
|
+
if (w === 'pass' || w === 'passed') return 'pass';
|
|
1740
|
+
if (w === 'fail' || w === 'failed') return 'fail';
|
|
1741
|
+
if (w === 'partial' || w === 'blocked') return w;
|
|
1742
|
+
return 'other';
|
|
1743
|
+
}
|
|
1744
|
+
return 'none';
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1672
1747
|
// ------------------------------------------------------------------ core 0.5.0: context watchdog + gen-2 gate
|
|
1673
1748
|
//
|
|
1674
1749
|
// Both run only from the hooks of the dp-* agent files (~/.claude/agents, installed by `dieu-phoi setup`), so a session
|
|
1675
1750
|
// that never uses dieu-phoi never runs them. They still check that the subagent's first prompt carries a `[dp:` tag
|
|
1676
1751
|
// (state cached per agent in data/watch/<agent_id>.json) and do nothing on the main thread (no agent_id).
|
|
1677
1752
|
|
|
1678
|
-
const HANDOFF_THRESHOLDS = Object.freeze([
|
|
1753
|
+
const HANDOFF_THRESHOLDS = Object.freeze([200000, 230000]);
|
|
1754
|
+
const WATCH_MIN = 20000;
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Warning thresholds [first, second]. Re-read on every hook call so a change reaches running subagents too.
|
|
1758
|
+
* Order: env DIEU_PHOI_WATCH_WARN / DIEU_PHOI_WATCH_HARD, then <home>/config.json watch_warn / watch_hard, then the defaults.
|
|
1759
|
+
* Invalid values (not a number >= 20000) are ignored; second is forced above first.
|
|
1760
|
+
*/
|
|
1761
|
+
function watchThresholds(ctx) {
|
|
1762
|
+
let cfg = {};
|
|
1763
|
+
try { cfg = ctx && ctx.paths ? readJsonFile(ctx.paths.config, {}) || {} : {}; } catch (_) { cfg = {}; }
|
|
1764
|
+
const pick = (envName, key, dflt) => {
|
|
1765
|
+
for (const v of [process.env[envName], cfg[key]]) {
|
|
1766
|
+
const n = Number(v);
|
|
1767
|
+
if (v !== undefined && v !== null && v !== '' && Number.isFinite(n) && n >= WATCH_MIN) return Math.round(n);
|
|
1768
|
+
}
|
|
1769
|
+
return dflt;
|
|
1770
|
+
};
|
|
1771
|
+
const warn = pick('DIEU_PHOI_WATCH_WARN', 'watch_warn', HANDOFF_THRESHOLDS[0]);
|
|
1772
|
+
let hard = pick('DIEU_PHOI_WATCH_HARD', 'watch_hard', HANDOFF_THRESHOLDS[1]);
|
|
1773
|
+
if (hard <= warn) hard = warn + (HANDOFF_THRESHOLDS[1] - HANDOFF_THRESHOLDS[0]);
|
|
1774
|
+
return [warn, hard];
|
|
1775
|
+
}
|
|
1679
1776
|
const WATCH_HEAD_BYTES = 64 * 1024;
|
|
1680
1777
|
const WATCH_HEAD_MAX = 2 << 20;
|
|
1681
1778
|
const WATCH_TAIL_BYTES = 256 * 1024;
|
|
@@ -1852,7 +1949,7 @@ function handleWatch(p, ctx, o) {
|
|
|
1852
1949
|
const ct = parseTag(p.tool_input && p.tool_input.prompt);
|
|
1853
1950
|
if (ct && ct.task_id) gateConfirm(ctx, st.task_id || agentId, ct.task_id);
|
|
1854
1951
|
}
|
|
1855
|
-
const thresholds = Array.isArray(o && o.thresholds) && o.thresholds.length ? o.thresholds :
|
|
1952
|
+
const thresholds = Array.isArray(o && o.thresholds) && o.thresholds.length ? o.thresholds : watchThresholds(ctx);
|
|
1856
1953
|
const tokens = lastContextTokens(tpath);
|
|
1857
1954
|
if (tokens === null) return null;
|
|
1858
1955
|
let level = 0;
|
|
@@ -1951,18 +2048,24 @@ function handleGate(p, ctx) {
|
|
|
1951
2048
|
if (!st || !st.dp) return null;
|
|
1952
2049
|
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
1953
2050
|
const why = [];
|
|
1954
|
-
|
|
1955
|
-
if (
|
|
1956
|
-
if (
|
|
2051
|
+
const codes = [];
|
|
2052
|
+
if (st.role && st.role !== 'exec') { why.push(`chỉ agent role=exec được giao helper (bạn là role=${st.role})`); codes.push('not-exec'); }
|
|
2053
|
+
if (ti.subagent_type !== HELPER_AGENT_TYPE) { why.push(`subagent_type phải là ${HELPER_AGENT_TYPE} (đang là ${ti.subagent_type || 'mặc định'})`); codes.push('type'); }
|
|
2054
|
+
if (!HELPER_MODELS.includes(shortModelName(ti.model))) { why.push(`model phải đặt rõ haiku hoặc sonnet (đang là ${ti.model || 'không đặt = kế thừa'})`); codes.push('model'); }
|
|
1957
2055
|
const tag = parseTag(ti.prompt);
|
|
1958
2056
|
const parentHint = st.task_id || '<task của bạn>';
|
|
1959
2057
|
if (!tag || tag.role !== 'helper' || !tag.task_id || !tag.parent_task_id) {
|
|
1960
2058
|
why.push(`dòng đầu prompt phải là [dp:<type>|s<n>|<urgency>|try1|task=<id mới từ dieu-phoi new-id>|role=helper|parent=${parentHint}]`);
|
|
2059
|
+
codes.push('tag');
|
|
1961
2060
|
} else if (st.task_id && tag.parent_task_id !== st.task_id) {
|
|
1962
2061
|
why.push(`parent= phải là task của bạn (${st.task_id})`);
|
|
2062
|
+
codes.push('parent');
|
|
1963
2063
|
}
|
|
1964
|
-
const
|
|
2064
|
+
const child = tag && tag.task_id ? tag.task_id : null;
|
|
2065
|
+
const log = (action, extra) => logGate(ctx, p, agentId, st, Object.assign({ gate_action: action, child_task_id: child }, extra));
|
|
2066
|
+
const deny = (list, reason) => ({
|
|
1965
2067
|
denied: true,
|
|
2068
|
+
event: log('deny', { reason_code: reason }),
|
|
1966
2069
|
stdout: JSON.stringify({
|
|
1967
2070
|
hookSpecificOutput: {
|
|
1968
2071
|
hookEventName: 'PreToolUse',
|
|
@@ -1971,14 +2074,30 @@ function handleGate(p, ctx) {
|
|
|
1971
2074
|
},
|
|
1972
2075
|
}),
|
|
1973
2076
|
});
|
|
1974
|
-
if (why.length) return deny(why);
|
|
2077
|
+
if (why.length) return deny(why, codes.join('+'));
|
|
1975
2078
|
const root = path.join(ctx.paths.data, 'gate');
|
|
1976
2079
|
fs.mkdirSync(root, { recursive: true });
|
|
1977
2080
|
pruneGate(root);
|
|
1978
2081
|
const dir = gateDir(ctx, st.task_id || agentId);
|
|
1979
2082
|
const slot = gateReserve(dir, tag.task_id);
|
|
1980
|
-
if (!slot) return deny([`đã giao đủ ${GATE_MAX_CHILDREN} helper cho bước này — tự làm phần còn lại`]);
|
|
1981
|
-
|
|
2083
|
+
if (!slot) return deny([`đã giao đủ ${GATE_MAX_CHILDREN} helper cho bước này — tự làm phần còn lại`], 'max-children');
|
|
2084
|
+
const children = gateSlots(dir).length;
|
|
2085
|
+
return { denied: false, slot, children, event: log('allow', { slot, children }) };
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* Core 0.5.2: one `gate` event per gate decision (allow / deny / release) so helper budgets can be measured from the
|
|
2090
|
+
* log instead of slot files. Labels and numbers only. Best effort: logging never changes the decision.
|
|
2091
|
+
*/
|
|
2092
|
+
function logGate(ctx, p, agentId, st, fields) {
|
|
2093
|
+
const ev = Object.assign({
|
|
2094
|
+
schema: SCHEMA, kind: 'gate', ts: nowIso(), agent_id: agentId,
|
|
2095
|
+
session: p.session_id ? sha8(p.session_id) : null,
|
|
2096
|
+
task_id: st.task_id || null, role: st.role || null,
|
|
2097
|
+
gate_action: null, slot: null, children: null, child_task_id: null, reason_code: null,
|
|
2098
|
+
}, fields, commonFields(ctx));
|
|
2099
|
+
try { appendEvent(ctx, ev); } catch (_) { return null; }
|
|
2100
|
+
return ev;
|
|
1982
2101
|
}
|
|
1983
2102
|
|
|
1984
2103
|
/** `--gate-release` (PostToolUseFailure(Agent) inside dp-exec): the spawn failed, give its unconfirmed slot back. */
|
|
@@ -1993,6 +2112,7 @@ function handleGateRelease(p, ctx) {
|
|
|
1993
2112
|
for (const r of gateSlots(gateDir(ctx, st.task_id || agentId))) {
|
|
1994
2113
|
if (r.child !== tag.task_id || r.started) continue;
|
|
1995
2114
|
try { fs.unlinkSync(r.file); } catch (_) { /* already gone */ }
|
|
2115
|
+
logGate(ctx, p, agentId, st, { gate_action: 'release', slot: r.slot, child_task_id: tag.task_id });
|
|
1996
2116
|
return { released: r.slot };
|
|
1997
2117
|
}
|
|
1998
2118
|
return null;
|
|
@@ -2205,15 +2325,15 @@ async function runSync(ctx, opts) {
|
|
|
2205
2325
|
// ------------------------------------------------------------------ exports / main
|
|
2206
2326
|
|
|
2207
2327
|
module.exports = {
|
|
2208
|
-
runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, orchLessons, orchLessonLines, abortReasonOf, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
|
|
2328
|
+
runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, orchLessons, orchLessonLines, abortReasonOf, completionStatus, logGate, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
|
|
2209
2329
|
CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
|
|
2210
|
-
sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
|
|
2330
|
+
sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
|
|
2211
2331
|
logError, computeUserHash, userHash, commonFields, parseTag, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
|
|
2212
2332
|
priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
|
|
2213
2333
|
firstPromptText, tagFromTranscript, summarizeTranscript,
|
|
2214
|
-
RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents,
|
|
2334
|
+
RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents, readEventsBuffer, archiveFiles, readArchiveLines,
|
|
2215
2335
|
loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
|
|
2216
|
-
HANDOFF_MARKER, HANDOFF_THRESHOLDS, GATE_MAX_CHILDREN, HELPER_AGENT_TYPE, watchAgentId, subagentTranscriptPath, readHeadTag, lastContextTokens, watchState, handoffMessage, handleWatch, handleGate, handleGateRelease, gateDir, gateSlots, gateReserve, gateConfirm, GATE_PENDING_MS,
|
|
2336
|
+
HANDOFF_MARKER, HANDOFF_THRESHOLDS, watchThresholds, GATE_MAX_CHILDREN, HELPER_AGENT_TYPE, watchAgentId, subagentTranscriptPath, readHeadTag, lastContextTokens, watchState, handoffMessage, handleWatch, handleGate, handleGateRelease, gateDir, gateSlots, gateReserve, gateConfirm, GATE_PENDING_MS,
|
|
2217
2337
|
extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
|
|
2218
2338
|
};
|
|
2219
2339
|
|
package/lib/aggregate.cjs
CHANGED
|
@@ -328,6 +328,9 @@ function buildCells(tasks, opts) {
|
|
|
328
328
|
const attemptCosts = c.tasks.map((t) => (t.first_cost === undefined ? null : t.first_cost));
|
|
329
329
|
const costMean = mean(costs);
|
|
330
330
|
const timeMean = mean(timesMin);
|
|
331
|
+
// score uses the time median, not the mean: waits for the user (gate 3, quota pauses) inflate the mean
|
|
332
|
+
// 3-13x (retro 2026-10-18: art-gen sonnet mean 483 vs p50 37 min) and would flip priors to slower tiers.
|
|
333
|
+
const timeP50 = percentile(timesMin, 0.5);
|
|
331
334
|
out.push({
|
|
332
335
|
task_type: c.task_type,
|
|
333
336
|
model: c.model,
|
|
@@ -354,7 +357,7 @@ function buildCells(tasks, opts) {
|
|
|
354
357
|
attempt_time_min: mean(attemptTimes),
|
|
355
358
|
attempt_p50_min: percentile(attemptTimes, 0.5),
|
|
356
359
|
attempt_p75_min: percentile(attemptTimes, 0.75),
|
|
357
|
-
score: costMean === null &&
|
|
360
|
+
score: costMean === null && timeP50 === null ? null : round6((costMean || 0) + lambda * (timeP50 || 0)),
|
|
358
361
|
enough: n >= MIN_N,
|
|
359
362
|
note: n >= MIN_N ? '' : 'chưa đủ dữ liệu',
|
|
360
363
|
});
|
package/lib/args.cjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const core = require('../hook.cjs');
|
|
5
5
|
|
|
6
|
-
const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve']);
|
|
6
|
+
const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve', 'yes']);
|
|
7
7
|
|
|
8
8
|
/** Minimal argv parser: positionals, --k v, --k=v, boolean flags. */
|
|
9
9
|
function parseArgs(argv) {
|
package/lib/choose.cjs
CHANGED
|
@@ -21,13 +21,14 @@ const HELPER_TOP = 4; // sonnet/high: ceiling for role=helper (gen-2)
|
|
|
21
21
|
const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
|
|
22
22
|
|
|
23
23
|
// Built-in defaults = skill/profile/profile.json for the fixed task types (time caps as tuned 2026-10-12: feature-impl 80,
|
|
24
|
-
// content-write 40, debug 55 — the old ones sat below the observed p50
|
|
24
|
+
// content-write 40, debug 55 — the old ones sat below the observed p50; 2026-10-18 retro: debug -> sonnet/high, caps of
|
|
25
|
+
// mechanical-edit/test-write/review/plan-design raised to their measured p75); used only when no profile is found.
|
|
25
26
|
const DEFAULT_PRIORS = {
|
|
26
27
|
search: 'haiku/medium',
|
|
27
28
|
extract: 'haiku/medium',
|
|
28
29
|
'mechanical-edit': 'sonnet/medium',
|
|
29
30
|
'feature-impl': 'opus/medium',
|
|
30
|
-
debug: '
|
|
31
|
+
debug: 'sonnet/high',
|
|
31
32
|
refactor: 'opus/medium',
|
|
32
33
|
'test-write': 'sonnet/high',
|
|
33
34
|
review: 'opus/medium',
|
|
@@ -39,15 +40,15 @@ const DEFAULT_PRIORS = {
|
|
|
39
40
|
const DEFAULT_TIME_CAP_MIN = {
|
|
40
41
|
search: 3,
|
|
41
42
|
extract: 5,
|
|
42
|
-
'mechanical-edit':
|
|
43
|
+
'mechanical-edit': 25,
|
|
43
44
|
'feature-impl': 80,
|
|
44
45
|
debug: 55,
|
|
45
46
|
refactor: 30,
|
|
46
|
-
'test-write':
|
|
47
|
-
review:
|
|
47
|
+
'test-write': 60,
|
|
48
|
+
review: 40,
|
|
48
49
|
'research-synth': 20,
|
|
49
50
|
'content-write': 40,
|
|
50
|
-
'plan-design':
|
|
51
|
+
'plan-design': 30,
|
|
51
52
|
'data-analysis': 15,
|
|
52
53
|
};
|
|
53
54
|
const DEFAULT_LAMBDA = { relaxed: 0.02, normal: 0.1, urgent: 1.0 };
|
|
@@ -502,8 +503,9 @@ function choose(opts) {
|
|
|
502
503
|
for (const x of scored) {
|
|
503
504
|
const p = betaSample(rng, 1 + x.cell.passes, 1 + x.cell.fails);
|
|
504
505
|
const cost = x.cell.attempt_cost_usd || 0;
|
|
506
|
+
// p50 (p75 when urgent), never the mean: waiting time inflates the mean (retro 2026-10-18)
|
|
505
507
|
const time = urgency === 'urgent' && x.cell.attempt_p75_min !== null
|
|
506
|
-
? x.cell.attempt_p75_min : (x.cell.
|
|
508
|
+
? x.cell.attempt_p75_min : (x.cell.attempt_p50_min || 0);
|
|
507
509
|
const s = cost / p + lambda * time / p;
|
|
508
510
|
if (!best || s < best.s) best = { i: x.i, s, p };
|
|
509
511
|
}
|
package/lib/commands.cjs
CHANGED
|
@@ -659,7 +659,7 @@ function cmdStats(opts) {
|
|
|
659
659
|
out: `${table(t)}\n\n${table(s)}\n\n${orchLine(orchestration)}\n\n`
|
|
660
660
|
+ 'n = task tính tỉ lệ (/số đã giao, (-k) = bị loại: reject-scope hoặc lượt exec đầu bị steer plan/redirect). '
|
|
661
661
|
+ 'accept% = thành công theo verdict người dùng đầu tiên, chưa có thì verdict agent; agent_pass% = chỉ tầng agent. '
|
|
662
|
-
+ `E[cost], E[time] tính trọn mọi lượt + review của task. score = E[cost] + λ·
|
|
662
|
+
+ `E[cost], E[time] tính trọn mọi lượt + review của task. score = E[cost] + λ·p50[time] (trung vị, không dùng TB: thời gian chờ thổi phồng TB), λ=${lambda} USD/phút.`,
|
|
663
663
|
};
|
|
664
664
|
}
|
|
665
665
|
|
|
@@ -1050,6 +1050,6 @@ function cmdPlanTable(opts) {
|
|
|
1050
1050
|
}
|
|
1051
1051
|
|
|
1052
1052
|
module.exports = {
|
|
1053
|
-
cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
|
|
1053
|
+
cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, cmdStats, cmdRetro, cmdPrune: (o) => require('./prune.cjs').cmdPrune(o), cmdChoose, cmdCtx, cmdState, measureOrch,
|
|
1054
1054
|
cmdPlanTable, cmdNewId, requireTaskId, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
|
|
1055
1055
|
};
|
package/lib/prune.cjs
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// `dieu-phoi prune --after-retro <file> [--yes]` (core 0.5.2): post-retro cleanup of closed run dirs, bad plan
|
|
3
|
+
// files and events.jsonl (moved to a gz archive, never deleted). Dry-run unless --yes.
|
|
4
|
+
// Only touches: <runs dir>/<id>/, <data>/plans, <data>/events.jsonl, <data>/archive, <data>/sync-offset.json.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const zlib = require('zlib');
|
|
9
|
+
const core = require('../hook.cjs');
|
|
10
|
+
const { ctxOf } = require('./args.cjs');
|
|
11
|
+
|
|
12
|
+
const RUN_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
13
|
+
const OUT_RE = /^dp-out-(.+?)-(exec|verify|plan|helper)-try(\d+)(?:[-.].*)?\.md$/;
|
|
14
|
+
// legacy result names; the task segment is optional (`report-try6.md` ranks under the run dir's own task)
|
|
15
|
+
const LEGACY_RE = /^(report|verify)-(?:(.+?)-)?try(\d+)(?:-[A-Za-z0-9-]*)?\.md$/;
|
|
16
|
+
// anything else that looks like a result but cannot be ranked is kept (conservative): report-deploy.md, verify-tokens.md,
|
|
17
|
+
// dp-out-*.md with an odd shape. dp-out-*-debug-* logs are deliberately not results.
|
|
18
|
+
const RESULT_LIKE_RE = /^(?:(?:report|verify)-.*|dp-out-(?!.*-debug-).*)\.md$/;
|
|
19
|
+
|
|
20
|
+
function inside(root, p) {
|
|
21
|
+
const r = path.relative(path.resolve(root), path.resolve(p));
|
|
22
|
+
return !!r && !r.startsWith('..') && !path.isAbsolute(r);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function archiveDir(ctx) { return path.join(ctx.paths.data, 'archive'); }
|
|
26
|
+
|
|
27
|
+
/** Cutoff (ms): `cutoff: <date|iso>` line in the retro file, else its mtime. Date-only = end of that UTC day. */
|
|
28
|
+
function cutoffOf(file) {
|
|
29
|
+
const st = fs.statSync(file);
|
|
30
|
+
let text = '';
|
|
31
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch (_) { /* mtime */ }
|
|
32
|
+
const m = /^\s*[*_>-]*\s*(?:cutoff|cut-off|mốc)\s*[*_]*\s*[:=]\s*[*_`]*\s*([0-9T:.\-Z+]{8,40})/im.exec(text);
|
|
33
|
+
if (m) {
|
|
34
|
+
let t = Date.parse(m[1]);
|
|
35
|
+
if (!isNaN(t)) {
|
|
36
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(m[1])) t += 86400000 - 1;
|
|
37
|
+
return { ms: t, source: 'cutoff-line' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { ms: st.mtimeMs, source: 'mtime' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sizeOf(p) {
|
|
44
|
+
let st;
|
|
45
|
+
try { st = fs.lstatSync(p); } catch (_) { return 0; }
|
|
46
|
+
if (!st.isDirectory()) return st.size;
|
|
47
|
+
let n = 0;
|
|
48
|
+
try { for (const c of fs.readdirSync(p)) n += sizeOf(path.join(p, c)); } catch (_) { /* gone */ }
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function human(n) {
|
|
53
|
+
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
|
|
54
|
+
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
55
|
+
return `${n} B`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Files to keep in a run dir: retro-*.md + per task/role the highest-try result AND the newest-by-mtime result
|
|
60
|
+
* (dp-out, legacy or .inline.md). A re-run can write a lower try number later (e.g. try1 after a blocked try5),
|
|
61
|
+
* so the try number alone can pick a stale attempt. `mtimeOf(name)` is optional; without it only the highest try is kept.
|
|
62
|
+
*/
|
|
63
|
+
function keepSet(names, mtimeOf) {
|
|
64
|
+
const keep = new Set();
|
|
65
|
+
const best = new Map(); // task|role -> max try
|
|
66
|
+
const parsed = [];
|
|
67
|
+
for (const n of names) {
|
|
68
|
+
if (/^retro-.*\.md$/.test(n)) { keep.add(n); continue; }
|
|
69
|
+
const inline = /\.inline\.md$/.test(n); // hook fallback copy: only written when the agent wrote no dp-out
|
|
70
|
+
let m = OUT_RE.exec(n);
|
|
71
|
+
let task; let role; let tr;
|
|
72
|
+
if (m) { task = m[1]; role = m[2]; tr = Number(m[3]); } else {
|
|
73
|
+
m = LEGACY_RE.exec(n);
|
|
74
|
+
if (!m) { if (RESULT_LIKE_RE.test(n)) keep.add(n); continue; }
|
|
75
|
+
task = m[2] || '(dir)'; role = m[1] === 'report' ? 'exec' : 'verify'; tr = Number(m[3]);
|
|
76
|
+
}
|
|
77
|
+
if (role !== 'exec' && role !== 'verify') continue;
|
|
78
|
+
parsed.push({ n, k: `${task}|${role}`, tr, inline });
|
|
79
|
+
best.set(`${task}|${role}`, Math.max(best.get(`${task}|${role}`) || 0, tr));
|
|
80
|
+
}
|
|
81
|
+
// final = highest try over written dp-out, legacy and inline; a written file beats an inline at the same try
|
|
82
|
+
const hasWritten = new Set(parsed.filter((p) => !p.inline).map((p) => `${p.k}|${p.tr}`));
|
|
83
|
+
for (const p of parsed) {
|
|
84
|
+
if (p.tr !== best.get(p.k)) continue;
|
|
85
|
+
if (p.inline && hasWritten.has(`${p.k}|${p.tr}`)) continue;
|
|
86
|
+
keep.add(p.n);
|
|
87
|
+
}
|
|
88
|
+
if (typeof mtimeOf === 'function') {
|
|
89
|
+
const newest = new Map(); // task|role -> newest candidate (ties: higher try)
|
|
90
|
+
for (const p of parsed) {
|
|
91
|
+
if (p.inline && hasWritten.has(`${p.k}|${p.tr}`)) continue;
|
|
92
|
+
let m = 0;
|
|
93
|
+
try { m = Number(mtimeOf(p.n)) || 0; } catch (_) { m = 0; }
|
|
94
|
+
const cur = newest.get(p.k);
|
|
95
|
+
if (!cur || m > cur.m || (m === cur.m && p.tr > cur.tr)) newest.set(p.k, { n: p.n, m, tr: p.tr });
|
|
96
|
+
}
|
|
97
|
+
for (const v of newest.values()) keep.add(v.n);
|
|
98
|
+
}
|
|
99
|
+
return keep;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function taskIdsOfDir(id, names, plansDir) {
|
|
103
|
+
const ids = new Set([id]);
|
|
104
|
+
const plan = core.readJsonFile(path.join(plansDir, `${id}.json`), null);
|
|
105
|
+
if (plan && Array.isArray(plan.steps)) for (const s of plan.steps) if (s && typeof s.task_id === 'string') ids.add(s.task_id);
|
|
106
|
+
for (const n of names) {
|
|
107
|
+
const m = OUT_RE.exec(n) || LEGACY_RE.exec(n);
|
|
108
|
+
if (m) { const t = OUT_RE.test(n) ? m[1] : m[2]; if (t) ids.add(t); }
|
|
109
|
+
const h = /^handoff-(.+)\.md$/.exec(n);
|
|
110
|
+
if (h) ids.add(h[1]);
|
|
111
|
+
}
|
|
112
|
+
return ids;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Eligible run dirs: every event of its tasks ≤ cutoff and every dispatched task has a user verdict (or was discarded). */
|
|
116
|
+
function planRuns(ctx, events, cutoffMs, protect) {
|
|
117
|
+
const runs = core.runsDir();
|
|
118
|
+
const byTask = new Map();
|
|
119
|
+
for (const e of events) {
|
|
120
|
+
if (!e || !e.task_id) continue;
|
|
121
|
+
if (!byTask.has(e.task_id)) byTask.set(e.task_id, []);
|
|
122
|
+
byTask.get(e.task_id).push(e);
|
|
123
|
+
}
|
|
124
|
+
const out = { prune: [], skipped: [] };
|
|
125
|
+
let dirs = [];
|
|
126
|
+
try { dirs = fs.readdirSync(runs, { withFileTypes: true }); } catch (_) { return out; }
|
|
127
|
+
for (const d of dirs) {
|
|
128
|
+
const id = d.name;
|
|
129
|
+
if (!d.isDirectory() || !RUN_ID_RE.test(id)) continue;
|
|
130
|
+
const dir = path.join(runs, id);
|
|
131
|
+
if (!inside(runs, dir)) continue;
|
|
132
|
+
const names = fs.readdirSync(dir);
|
|
133
|
+
const tasks = Array.from(taskIdsOfDir(id, names, ctx.paths.plans));
|
|
134
|
+
let anyVerdict = false; let reason = null; let latest = 0;
|
|
135
|
+
for (const t of tasks) {
|
|
136
|
+
const evs = byTask.get(t) || [];
|
|
137
|
+
const discarded = evs.some((e) => e.kind === 'discard');
|
|
138
|
+
const uv = evs.some((e) => e.kind === 'verdict' && e.by === 'user' && (e.outcome === 'accept' || e.outcome === 'reject'));
|
|
139
|
+
if (uv) anyVerdict = true;
|
|
140
|
+
for (const e of evs) {
|
|
141
|
+
const ts = Date.parse(e.ts);
|
|
142
|
+
if (!isNaN(ts)) latest = Math.max(latest, ts);
|
|
143
|
+
if (isNaN(ts) || ts > cutoffMs) reason = reason || `sự kiện sau mốc cắt (task ${t})`;
|
|
144
|
+
}
|
|
145
|
+
const dispatched = evs.some((e) => e.kind === 'dispatch' && e.role !== 'plan' && e.role !== 'helper');
|
|
146
|
+
if (dispatched && !uv && !discarded) reason = reason || `task ${t} chưa có verdict người dùng`;
|
|
147
|
+
}
|
|
148
|
+
if (!reason && !anyVerdict) reason = 'chưa có verdict người dùng';
|
|
149
|
+
// a file written after the cutoff = work still going on in this dir (e.g. a step not yet in the log)
|
|
150
|
+
if (!reason) {
|
|
151
|
+
for (const n of names) {
|
|
152
|
+
let m = 0;
|
|
153
|
+
try { m = fs.lstatSync(path.join(dir, n)).mtimeMs; } catch (_) { /* gone */ }
|
|
154
|
+
if (m > cutoffMs) { reason = 'có file mới hơn mốc cắt'; break; }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (reason) { out.skipped.push({ id, reason }); continue; }
|
|
158
|
+
const keep = keepSet(names, (n) => { try { return fs.lstatSync(path.join(dir, n)).mtimeMs; } catch (_) { return 0; } });
|
|
159
|
+
const del = [];
|
|
160
|
+
let delBytes = 0; let keepBytes = 0;
|
|
161
|
+
for (const n of names) {
|
|
162
|
+
const p = path.join(dir, n);
|
|
163
|
+
if (keep.has(n) || (protect && path.resolve(p) === protect)) { keepBytes += sizeOf(p); continue; }
|
|
164
|
+
const b = sizeOf(p);
|
|
165
|
+
del.push({ name: n, bytes: b });
|
|
166
|
+
delBytes += b;
|
|
167
|
+
}
|
|
168
|
+
out.prune.push({ id, dir, tasks, keep: Array.from(keep).sort(), del, delete_bytes: delBytes, keep_bytes: keepBytes });
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function badPlans(ctx) {
|
|
174
|
+
let names = [];
|
|
175
|
+
try { names = fs.readdirSync(ctx.paths.plans); } catch (_) { return []; }
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const n of names) {
|
|
178
|
+
if (!n.endsWith('.json')) continue;
|
|
179
|
+
const p = path.join(ctx.paths.plans, n);
|
|
180
|
+
if (!inside(ctx.paths.plans, p)) continue;
|
|
181
|
+
let st; try { st = fs.lstatSync(p); } catch (_) { continue; }
|
|
182
|
+
if (!st.isFile()) continue;
|
|
183
|
+
const parsed = core.readJsonFile(p, null);
|
|
184
|
+
const okShape = parsed && typeof parsed.root_task_id === 'string' && Array.isArray(parsed.steps);
|
|
185
|
+
if (!core.isValidTaskId(n.slice(0, -5)) || !okShape) out.push({ path: p, name: n, bytes: st.size });
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Event lines (raw text + byte range) of events.jsonl, with parsed object (null if unparseable). */
|
|
191
|
+
function eventLines(ctx) {
|
|
192
|
+
const buf = core.readEventsBuffer(ctx);
|
|
193
|
+
return { buf, lines: core.eventLinesFrom(buf, 0).map((l) => {
|
|
194
|
+
let obj = null;
|
|
195
|
+
try { const o = JSON.parse(l.text); if (o && typeof o === 'object') obj = o; } catch (_) { /* keep */ }
|
|
196
|
+
return Object.assign(l, { obj });
|
|
197
|
+
}) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function archiveToday() { return new Date().toISOString().slice(0, 10); }
|
|
201
|
+
|
|
202
|
+
/** Append one gzip member to today's archive via temp file; verify by re-reading; then rename into place. */
|
|
203
|
+
function writeArchive(ctx, texts) {
|
|
204
|
+
fs.mkdirSync(archiveDir(ctx), { recursive: true });
|
|
205
|
+
const file = path.join(archiveDir(ctx), `events-${archiveToday()}.jsonl.gz`);
|
|
206
|
+
let prev = Buffer.alloc(0);
|
|
207
|
+
try { prev = fs.readFileSync(file); } catch (_) { /* new */ }
|
|
208
|
+
const prevCount = prev.length ? zlib.gunzipSync(prev).toString('utf8').split('\n').filter((l) => l.trim()).length : 0;
|
|
209
|
+
const member = zlib.gzipSync(Buffer.from(`${texts.join('\n')}\n`, 'utf8'));
|
|
210
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
211
|
+
fs.writeFileSync(tmp, Buffer.concat([prev, member]));
|
|
212
|
+
const back = zlib.gunzipSync(fs.readFileSync(tmp)).toString('utf8').split('\n').filter((l) => l.trim());
|
|
213
|
+
if (back.length !== prevCount + texts.length || back.slice(prevCount).some((l, i) => l !== texts[i])) {
|
|
214
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* gone */ }
|
|
215
|
+
throw new Error('xác minh archive thất bại — events.jsonl giữ nguyên');
|
|
216
|
+
}
|
|
217
|
+
core.renameWithRetry(tmp, file);
|
|
218
|
+
return file;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function cmdPrune(opts) {
|
|
222
|
+
const ctx = ctxOf(opts);
|
|
223
|
+
const retro = typeof opts['after-retro'] === 'string' ? path.resolve(opts['after-retro']) : null;
|
|
224
|
+
if (!retro) return { code: 2, out: 'prune: thiếu --after-retro <file retro>' };
|
|
225
|
+
let st; try { st = fs.statSync(retro); } catch (_) { st = null; }
|
|
226
|
+
if (!st || !st.isFile()) return { code: 2, out: `prune: không đọc được file retro: ${retro}` };
|
|
227
|
+
const runs = core.runsDir();
|
|
228
|
+
const home = ctx.paths.data;
|
|
229
|
+
for (const [name, p] of [['runs', runs], ['data', home]]) {
|
|
230
|
+
if (!path.isAbsolute(p) || p === path.parse(p).root) return { code: 2, out: `prune: từ chối đường dẫn ${name} không an toàn: ${p}` };
|
|
231
|
+
}
|
|
232
|
+
const yes = opts.yes === true || opts.yes === 'y' || opts.yes === 'yes';
|
|
233
|
+
const cut = cutoffOf(retro);
|
|
234
|
+
const { lines } = fs.existsSync(ctx.paths.events) ? eventLines(ctx) : { lines: [] };
|
|
235
|
+
const allEvents = core.readEvents(ctx);
|
|
236
|
+
const rp = planRuns(ctx, allEvents, cut.ms, retro);
|
|
237
|
+
const prunedTasks = new Set();
|
|
238
|
+
for (const r of rp.prune) for (const t of r.tasks) prunedTasks.add(t);
|
|
239
|
+
const plans = badPlans(ctx);
|
|
240
|
+
|
|
241
|
+
// events to archive: lines of pruned tasks ≤ cutoff; with a sink, only those already pushed (end ≤ offset)
|
|
242
|
+
const cfg = ctx.config || {};
|
|
243
|
+
const hasSink = !!(cfg.sink_url && cfg.sink_token);
|
|
244
|
+
const unsynced = core.unsyncedInfo(ctx);
|
|
245
|
+
const archived = new Set(core.readArchiveLines(ctx));
|
|
246
|
+
const moving = lines.filter((l) => l.obj && l.obj.task_id && prunedTasks.has(l.obj.task_id)
|
|
247
|
+
&& Date.parse(l.obj.ts) <= cut.ms && (!hasSink || l.end <= unsynced.offset));
|
|
248
|
+
const heldBack = lines.filter((l) => l.obj && l.obj.task_id && prunedTasks.has(l.obj.task_id)
|
|
249
|
+
&& Date.parse(l.obj.ts) <= cut.ms).length - moving.length;
|
|
250
|
+
const evBytes = moving.reduce((n, l) => n + (l.end - l.start), 0);
|
|
251
|
+
|
|
252
|
+
const report = {
|
|
253
|
+
mode: yes ? 'apply' : 'dry-run', retro, cutoff: new Date(cut.ms).toISOString(), cutoff_source: cut.source,
|
|
254
|
+
runs: rp.prune.map((r) => ({ id: r.id, delete_files: r.del.length, delete_bytes: r.delete_bytes, keep: r.keep, keep_bytes: r.keep_bytes })),
|
|
255
|
+
runs_skipped: rp.skipped.length,
|
|
256
|
+
skipped_reasons: rp.skipped.reduce((a, s) => { const k = s.reason.replace(/task \S+/, 'task *').replace(/\(task \S+\)/, ''); a[k] = (a[k] || 0) + 1; return a; }, {}),
|
|
257
|
+
bad_plans: plans.map((p) => ({ name: p.name, bytes: p.bytes })),
|
|
258
|
+
events: { archive: moving.length, bytes: evBytes, held_back_unsynced: heldBack, total: lines.length },
|
|
259
|
+
};
|
|
260
|
+
report.totals = {
|
|
261
|
+
run_dirs: rp.prune.length,
|
|
262
|
+
files: rp.prune.reduce((n, r) => n + r.del.length, 0),
|
|
263
|
+
bytes: rp.prune.reduce((n, r) => n + r.delete_bytes, 0) + plans.reduce((n, p) => n + p.bytes, 0),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (yes) {
|
|
267
|
+
// 1. events → archive (archive first + verified, then rewrite events.jsonl via temp + rename)
|
|
268
|
+
if (moving.length) {
|
|
269
|
+
const fresh = moving.filter((l) => !archived.has(l.text));
|
|
270
|
+
const lock = core.acquireLock(ctx.paths.lock);
|
|
271
|
+
if (!lock) return { code: 1, out: 'prune: sync đang giữ khoá — thử lại sau' };
|
|
272
|
+
try {
|
|
273
|
+
if (fresh.length) writeArchive(ctx, fresh.map((l) => l.text));
|
|
274
|
+
const buf = core.readEventsBuffer(ctx);
|
|
275
|
+
const drop = new Map(moving.map((l) => [l.start, l.text]));
|
|
276
|
+
const parts = []; let removedBeforeOffset = 0;
|
|
277
|
+
let pos = 0;
|
|
278
|
+
for (const l of core.eventLinesFrom(buf, 0)) {
|
|
279
|
+
parts.push(buf.subarray(pos, l.start)); // blank lines between
|
|
280
|
+
if (drop.get(l.start) === l.text) {
|
|
281
|
+
if (l.end <= unsynced.offset) removedBeforeOffset += l.end - l.start;
|
|
282
|
+
} else parts.push(buf.subarray(l.start, l.end));
|
|
283
|
+
pos = l.end;
|
|
284
|
+
}
|
|
285
|
+
parts.push(buf.subarray(pos));
|
|
286
|
+
let next = Buffer.concat(parts);
|
|
287
|
+
const cur = core.readEventsBuffer(ctx);
|
|
288
|
+
if (cur.length > buf.length) next = Buffer.concat([next, cur.subarray(buf.length)]); // appended meanwhile
|
|
289
|
+
const tmp = `${ctx.paths.events}.tmp-${process.pid}`;
|
|
290
|
+
fs.writeFileSync(tmp, next);
|
|
291
|
+
core.renameWithRetry(tmp, ctx.paths.events);
|
|
292
|
+
if (unsynced.offset > 0) core.writeFileAtomic(ctx.paths.syncOffset, `${JSON.stringify({ offset: Math.max(0, unsynced.offset - removedBeforeOffset), ts: core.nowIso() })}\n`);
|
|
293
|
+
} finally {
|
|
294
|
+
try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// 2. run dirs
|
|
298
|
+
for (const r of rp.prune) {
|
|
299
|
+
for (const f of r.del) {
|
|
300
|
+
const p = path.join(r.dir, f.name);
|
|
301
|
+
if (!inside(r.dir, p) || !inside(runs, p)) continue;
|
|
302
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
try { if (!fs.readdirSync(r.dir).length) fs.rmdirSync(r.dir); } catch (_) { /* keep */ }
|
|
305
|
+
}
|
|
306
|
+
// 3. bad plans
|
|
307
|
+
for (const p of plans) if (inside(ctx.paths.plans, p.path)) fs.rmSync(p.path, { force: true });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (opts.json) return { code: 0, out: JSON.stringify(report, null, 2) };
|
|
311
|
+
const L = [];
|
|
312
|
+
L.push(`prune ${yes ? '(ĐÃ THỰC HIỆN)' : '(dry-run — thêm --yes để thực hiện)'}: mốc cắt ${report.cutoff} (${cut.source}) từ ${retro}`);
|
|
313
|
+
L.push(`Run dir: ${rp.prune.length} sẽ dọn, ${rp.skipped.length} giữ nguyên (còn mở / sau mốc)`);
|
|
314
|
+
for (const r of rp.prune) L.push(` ${r.id}: xoá ${r.del.length} mục (${human(r.delete_bytes)}), giữ ${r.keep.length} (${human(r.keep_bytes)})`);
|
|
315
|
+
L.push(`Plan hỏng: ${plans.length} (${human(plans.reduce((n, p) => n + p.bytes, 0))})${plans.length ? ` — ${plans.map((p) => p.name).join(', ')}` : ''}`);
|
|
316
|
+
L.push(`events.jsonl: ${moving.length}/${lines.length} dòng → archive/events-${archiveToday()}.jsonl.gz (${human(evBytes)})${heldBack ? `; ${heldBack} dòng chưa đẩy lên sink nên giữ lại` : ''}; không xoá dòng nào`);
|
|
317
|
+
L.push(`Tổng xoá khỏi đĩa: ${report.totals.files} mục run dir + ${plans.length} plan = ${human(report.totals.bytes)}`);
|
|
318
|
+
return { code: 0, out: L.join('\n') };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = { cmdPrune, keepSet, cutoffOf };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kienntpixon/dieu-phoi",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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>",
|