@kienntpixon/dieu-phoi 0.4.0 → 0.5.0
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 +12 -0
- package/README.md +3 -0
- package/agents/dp-exec.md +38 -0
- package/agents/dp-helper.md +23 -0
- package/agents/dp-scout.md +25 -0
- package/agents/dp-verify.md +25 -0
- package/bin/dieu-phoi.cjs +1 -1
- package/hook.cjs +490 -16
- package/lib/aggregate.cjs +27 -7
- package/lib/choose.cjs +5 -1
- package/lib/commands.cjs +7 -1
- package/lib/setup.cjs +71 -2
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog — @kienntpixon/dieu-phoi
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
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).
|
|
6
|
+
|
|
7
|
+
- **Nâng cấp:** dán lại `sheet/Code.gs` (cột `parent_task_id`, `parent_agent_id`; luật tiếp nối handoff) rồi `dieu-phoi setup` để cài agent `dp-*`.
|
|
8
|
+
- **File bàn giao `dp-out-<task>-<role>-try<n>.md`:** Claude Code chặn cứng Write trong subagent khi tên khớp `/^(REPORT|SUMMARY|FINDINGS|ANALYSIS).*.md$/i` (trước cả permissions, không cấu hình được) nên exec/verify/scout ghi toàn bộ kết quả vào `dp-out-*` và chỉ trả đường dẫn + `STATUS` + ≤ 3 dòng. Hook `SubagentStop`: agent exec/verify/plan trả kết quả mà không có file (Write hỏng, trả inline) → lưu nguyên văn phần trả về thành `<run>/dp-out-<task>-<role>-try<n>.inline.md` (bỏ qua khi đã có `dp-out-*` hoặc `report-*`/`verify-*` cũ của lượt đó, khi agent handoff, khi là helper; run dir lấy từ đường dẫn trong brief, không bao giờ ra ngoài `runsDir()`). Agent `dp-exec`/`dp-verify`/`dp-scout` ghi rõ hợp đồng này.
|
|
9
|
+
- **Loại agent `dp-exec`, `dp-verify`, `dp-scout`, `dp-helper`** (`cli/agents/`, `setup` cài vào `~/.claude/agents/`): `autoCompactWindow: 200000`, không đặt model, chỉ `dp-exec` có Agent; `dp-helper` không Agent/Edit/Write (độ sâu dừng ở 2). Setup idempotent, không bao giờ ghi đè file trùng tên không có dấu `# dieu-phoi-managed:`; `uninstall` gỡ đúng file của mình; `doctor` báo `agents: …=ok|outdated|missing|foreign`.
|
|
10
|
+
- **Watchdog ngữ cảnh** (`hook.cjs --watch`, PostToolUse trong frontmatter agent dp-*): subagent có thẻ `[dp:` vượt 150k rồi 180k token ngữ cảnh → additionalContext một lần mỗi ngưỡng: ghi `handoff-<task_id>.md` trong run dir và dừng với `STATUS: partial — handoff`. Đọc đuôi transcript subagent, ~2 ms tính.
|
|
11
|
+
- **Handoff = `aborted`, không phải fail:** `SubagentStop` ghi verdict `aborted` `reason_code=auto-handoff`; thước đo (CLI + sink, fixture parity thêm ô `refactor`) nối tầng agent của lượt exec đầu sang agent tiếp nối cùng `try`.
|
|
12
|
+
- **Gen-2 có kiểm soát** (`hook.cjs --gate`, PreToolUse(Agent) của `dp-exec`): tối đa 4 `dp-helper`/bước, model haiku|sonnet đặt rõ, thẻ `role=helper|parent=<task>`; sai luật → deny kèm lý do. Thẻ nhận `role=helper` và `parent=`; dispatch ghi `parent_task_id` + `parent_agent_id`. `choose --role helper` kẹp trần sonnet/high; helper không bao giờ là lượt/mẫu.
|
|
13
|
+
- **Sửa sau review:** suất helper của `--gate` là file `data/gate/<task>/slot-1..4.json` tạo độc quyền (`wx`) — gọi Agent song song không vượt 4, không còn ghi đè trạng thái `--watch` (hai file riêng; file watch tạo bằng `wx`). Suất chỉ tính khi helper chạy thật: xác nhận qua `--watch` (helper tự xác nhận, hoặc PostToolUse(Agent) của exec), trả lại qua `--gate-release` (PostToolUseFailure(Agent) mới trong `dp-exec`) hoặc sau 10 phút chưa xác nhận. Watchdog cho `dp-verify`/`dp-scout`/`dp-helper`: trả verdict/tóm tắt ngay, không ghi handoff (chỉ exec handoff). `attempt_*` (ước lượng `choose`) cộng mọi đoạn exec của chuỗi handoff; fixture parity thêm chuỗi 3 đoạn (ô `refactor` n=3). `doctor` so agent với node đã đăng ký trong settings, không báo "outdated" giả khi chạy bằng node khác.
|
|
14
|
+
|
|
3
15
|
## 0.4.0
|
|
4
16
|
|
|
5
17
|
Theo bản rà soát telemetry tune0001 (2026-10-12: 886 sự kiện sạch, ~157 task, 8 phiên điều phối).
|
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ Hook + CLI cho skill **dieu-phoi**: ghi lại mỗi lần Claude Code giao việ
|
|
|
8
8
|
|
|
9
9
|
## Cài đặt
|
|
10
10
|
|
|
11
|
+
> Nâng cấp lên 0.5.0: dán lại `sheet/Code.gs` (cột `parent_task_id`, `parent_agent_id`, luật tiếp nối handoff) rồi chạy lại `dieu-phoi setup` để cài agent `dp-*`.
|
|
12
|
+
|
|
11
13
|
> Nâng cấp lên 0.4.0: **dán lại `sheet/Code.gs` và deploy bản mới TRƯỚC khi nâng CLI** — Code.gs cũ từ chối kind mới `discard` và tính `orch_share` theo cách cũ; sau đó `npm i -g @kienntpixon/dieu-phoi@0.4.0` rồi `dieu-phoi setup`.
|
|
12
14
|
>
|
|
13
15
|
> Nâng cấp lên 0.2.0: **dán lại `sheet/Code.gs` và deploy bản mới TRƯỚC khi nâng CLI** — Code.gs cũ bỏ qua (làm rơi) các trường mới `session_id, orch_ctx_tokens, orch_cum_usd, orch_ctx_baseline, orch_compactions` của bản ghi được đẩy lên trong lúc chưa cập nhật; sau đó `npm i -g @kienntpixon/dieu-phoi@0.2.0` rồi `dieu-phoi setup`.
|
|
@@ -27,6 +29,7 @@ Không cài toàn cục được thì dùng `npx -y @kienntpixon/dieu-phoi <lệ
|
|
|
27
29
|
1. chép `hook.cjs` + `pricing.json` vào `~/.claude/dieu-phoi/bin/`;
|
|
28
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`;
|
|
29
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: 200000`, 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.
|
|
30
33
|
|
|
31
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ý).
|
|
32
35
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dp-exec
|
|
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
|
+
# dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
|
|
5
|
+
autoCompactWindow: 200000
|
|
6
|
+
hooks:
|
|
7
|
+
PostToolUse:
|
|
8
|
+
- matcher: "*"
|
|
9
|
+
hooks:
|
|
10
|
+
- type: command
|
|
11
|
+
command: '__DP_HOOK__ --watch'
|
|
12
|
+
timeout: 5
|
|
13
|
+
PreToolUse:
|
|
14
|
+
- matcher: "Agent|Task"
|
|
15
|
+
hooks:
|
|
16
|
+
- type: command
|
|
17
|
+
command: '__DP_HOOK__ --gate'
|
|
18
|
+
timeout: 5
|
|
19
|
+
PostToolUseFailure:
|
|
20
|
+
- matcher: "Agent|Task"
|
|
21
|
+
hooks:
|
|
22
|
+
- type: command
|
|
23
|
+
command: '__DP_HOOK__ --gate-release'
|
|
24
|
+
timeout: 5
|
|
25
|
+
---
|
|
26
|
+
Bạn là agent thực thi (exec) của dieu-phoi. Brief ở tin nhắn đầu (dòng đầu là thẻ `[dp:...]`) là hợp đồng: làm đúng phạm vi, tự kiểm chứng, ghi báo cáo vào run dir và trả về bản tóm tắt ngắn đúng định dạng brief yêu cầu.
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
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):
|
|
33
|
+
- 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.
|
|
34
|
+
- Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
|
|
35
|
+
- Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
|
|
36
|
+
- Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
|
|
37
|
+
- Không bao giờ đọc transcript phiên (`~/.claude/projects/**/*.jsonl`).
|
|
38
|
+
- Khi hook dieu-phoi báo ngữ cảnh chạm ngưỡng: dừng việc mới, ghi file handoff hook chỉ định (`handoff-<task_id>.md` trong run dir: Đã xong / Đang dở / Bước tiếp theo / File đã động / Bẫy & ghi chú) rồi kết thúc với dòng đầu báo cáo đúng `STATUS: partial — handoff`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dp-helper
|
|
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
|
+
# dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
|
|
5
|
+
disallowedTools: Agent, Task, Edit, Write, NotebookEdit
|
|
6
|
+
autoCompactWindow: 200000
|
|
7
|
+
hooks:
|
|
8
|
+
PostToolUse:
|
|
9
|
+
- matcher: "*"
|
|
10
|
+
hooks:
|
|
11
|
+
- type: command
|
|
12
|
+
command: '__DP_HOOK__ --watch'
|
|
13
|
+
timeout: 5
|
|
14
|
+
---
|
|
15
|
+
Bạn là helper thế hệ 2 của dieu-phoi, do một agent exec giao (thẻ `[dp:...|role=helper|parent=...]`). Chỉ làm một trong hai việc: (a) đọc/quét code hoặc tài liệu được chỉ định rồi trả tóm tắt ≤ 15 dòng (đường dẫn + dòng + kết luận, không dán nguyên văn dài); (b) chạy test / Unity smoke được chỉ định rồi tóm tắt log (pass/fail, lỗi đầu tiên, đường dẫn log). Không sửa file, không giao việc tiếp. Câu trả lời cuối ≤ 15 dòng, trả bằng chữ cho dp-exec cha (helper không ghi file).
|
|
16
|
+
|
|
17
|
+
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):
|
|
18
|
+
- 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.
|
|
19
|
+
- Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
|
|
20
|
+
- Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
|
|
21
|
+
- Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
|
|
22
|
+
- Không bao giờ đọc transcript phiên (`~/.claude/projects/**/*.jsonl`).
|
|
23
|
+
- Khi hook dieu-phoi báo ngữ cảnh chạm ngưỡng: dừng đọc/chạy thêm, trả bản tóm tắt NGAY với những gì đã có và ghi rõ phần chưa kiểm được. Không ghi file handoff — chỉ agent exec mới handoff.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dp-scout
|
|
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
|
+
# dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
|
|
5
|
+
disallowedTools: Agent, Task, Edit, NotebookEdit
|
|
6
|
+
autoCompactWindow: 200000
|
|
7
|
+
hooks:
|
|
8
|
+
PostToolUse:
|
|
9
|
+
- matcher: "*"
|
|
10
|
+
hooks:
|
|
11
|
+
- type: command
|
|
12
|
+
command: '__DP_HOOK__ --watch'
|
|
13
|
+
timeout: 5
|
|
14
|
+
---
|
|
15
|
+
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
|
+
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
|
+
|
|
19
|
+
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):
|
|
20
|
+
- 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.
|
|
21
|
+
- Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
|
|
22
|
+
- Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
|
|
23
|
+
- Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
|
|
24
|
+
- Không bao giờ đọc transcript phiên (`~/.claude/projects/**/*.jsonl`).
|
|
25
|
+
- Khi hook dieu-phoi báo ngữ cảnh chạm ngưỡng: dừng đọc/chạy thêm, trả bản tóm tắt NGAY với những gì đã có và ghi rõ phần chưa kiểm được. Không ghi file handoff — chỉ agent exec mới handoff.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dp-verify
|
|
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
|
+
# dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
|
|
5
|
+
disallowedTools: Agent, Task, Edit, NotebookEdit
|
|
6
|
+
autoCompactWindow: 200000
|
|
7
|
+
hooks:
|
|
8
|
+
PostToolUse:
|
|
9
|
+
- matcher: "*"
|
|
10
|
+
hooks:
|
|
11
|
+
- type: command
|
|
12
|
+
command: '__DP_HOOK__ --watch'
|
|
13
|
+
timeout: 5
|
|
14
|
+
---
|
|
15
|
+
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
|
+
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
|
+
|
|
19
|
+
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):
|
|
20
|
+
- 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.
|
|
21
|
+
- Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
|
|
22
|
+
- Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
|
|
23
|
+
- Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
|
|
24
|
+
- Không bao giờ đọc transcript phiên (`~/.claude/projects/**/*.jsonl`).
|
|
25
|
+
- Khi hook dieu-phoi báo ngữ cảnh chạm ngưỡng: dừng đọc/chạy thêm, trả verdict (dòng STATUS + các phát hiện) NGAY với những gì đã có và ghi rõ phần chưa kiểm được. Không ghi file handoff — chỉ agent exec mới handoff.
|
package/bin/dieu-phoi.cjs
CHANGED
|
@@ -27,7 +27,7 @@ Cách dùng: dieu-phoi <lệnh> [tuỳ chọn]
|
|
|
27
27
|
stats [--type T] [--json]
|
|
28
28
|
bảng task_type × model/effort: n, accept%, agent_pass%, chi phí/thời gian (TB, p50, p75), steer
|
|
29
29
|
choose --type T --score N [--urgency relaxed|normal|urgent] [--reversible y|n]
|
|
30
|
-
[--verifiable y|n] [--role exec|verify|plan] [--task ID]
|
|
30
|
+
[--verifiable y|n] [--role exec|verify|plan|helper] [--task ID]
|
|
31
31
|
[--override model/effort (cần --task) | --approved model/effort] [--skill-dir P] [--seed S] [--json]
|
|
32
32
|
đề xuất model + effort, lý do, action (dispatch|ask_user), ước tính chi phí/thời gian
|
|
33
33
|
ctx [--session <id>] [--json]
|
package/hook.cjs
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* dieu-phoi hook + core helpers.
|
|
5
5
|
*
|
|
6
6
|
* This file is copied verbatim to ~/.claude/dieu-phoi/bin/hook.cjs by `dieu-phoi setup`
|
|
7
|
-
* and run by Claude Code on PostToolUse(Agent), SubagentStop, SessionStart(compact) and UserPromptSubmit
|
|
7
|
+
* and run by Claude Code on PostToolUse(Agent), SubagentStop, SessionStart(compact) and UserPromptSubmit; the dp-* agent
|
|
8
|
+
* files (`dieu-phoi setup` -> ~/.claude/agents) also run it inside those subagents as `--watch` (PostToolUse, context
|
|
9
|
+
* handoff watchdog), `--gate` (PreToolUse(Agent), gen-2 helper rules) and `--gate-release`. It must stay
|
|
8
10
|
* self-contained: Node built-ins only, no requires of other package files.
|
|
9
11
|
*
|
|
10
12
|
* Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit, always exit 0,
|
|
@@ -13,6 +15,9 @@
|
|
|
13
15
|
*
|
|
14
16
|
* node hook.cjs -> read hook payload from stdin, append event
|
|
15
17
|
* node hook.cjs --sync -> push unsynced events to the sink (spawned detached)
|
|
18
|
+
* node hook.cjs --watch -> dp subagent PostToolUse: context watchdog (additionalContext JSON or nothing)
|
|
19
|
+
* node hook.cjs --gate -> dp-exec PreToolUse(Agent): allow / deny a gen-2 helper spawn
|
|
20
|
+
* node hook.cjs --gate-release -> dp-exec PostToolUseFailure(Agent): give a failed spawn's helper slot back
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
const fs = require('fs');
|
|
@@ -20,14 +25,14 @@ const path = require('path');
|
|
|
20
25
|
const os = require('os');
|
|
21
26
|
const crypto = require('crypto');
|
|
22
27
|
|
|
23
|
-
const CORE_VERSION = '0.
|
|
28
|
+
const CORE_VERSION = '0.5.0';
|
|
24
29
|
const SCHEMA = 1;
|
|
25
30
|
const TASK_TYPES = [
|
|
26
31
|
'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
|
|
27
32
|
'test-write', 'review', 'research-synth', 'content-write', 'plan-design', 'data-analysis',
|
|
28
33
|
];
|
|
29
34
|
const URGENCIES = ['relaxed', 'normal', 'urgent'];
|
|
30
|
-
const ROLES = ['exec', 'verify', 'plan'];
|
|
35
|
+
const ROLES = ['exec', 'verify', 'plan', 'helper'];
|
|
31
36
|
const MODEL_ALIASES = {
|
|
32
37
|
opus: 'claude-opus-5-5',
|
|
33
38
|
sonnet: 'claude-sonnet-5-5',
|
|
@@ -200,7 +205,7 @@ const UNTAGGED = Object.freeze({
|
|
|
200
205
|
task_type: 'untagged', score: null, urgency: null, try: null, task_id: null, role: 'exec',
|
|
201
206
|
});
|
|
202
207
|
|
|
203
|
-
/** Parse `[dp:<type>|s<n>|<urgency>|try<n>|task=<id>|role=<r>]` from the first line. */
|
|
208
|
+
/** Parse `[dp:<type>|s<n>|<urgency>|try<n>|task=<id>|role=<r>|parent=<id>]` from the first line. */
|
|
204
209
|
function parseTag(text) {
|
|
205
210
|
if (typeof text !== 'string') return null;
|
|
206
211
|
const first = text.replace(/^/, '').split(/\r?\n/, 1)[0].trim();
|
|
@@ -223,6 +228,8 @@ function parseTag(text) {
|
|
|
223
228
|
tag.task_id = mm[1];
|
|
224
229
|
} else if ((mm = /^role=([a-z]+)$/.exec(p)) && ROLES.includes(mm[1])) {
|
|
225
230
|
tag.role = mm[1];
|
|
231
|
+
} else if ((mm = /^parent=([A-Za-z0-9_-]{1,32})$/.exec(p))) {
|
|
232
|
+
tag.parent_task_id = mm[1]; // core 0.5.0: gen-2 helper -> the dp-exec step that spawned it
|
|
226
233
|
}
|
|
227
234
|
}
|
|
228
235
|
return tag;
|
|
@@ -366,23 +373,26 @@ function lastUuidOf(lines) {
|
|
|
366
373
|
return null;
|
|
367
374
|
}
|
|
368
375
|
|
|
369
|
-
/**
|
|
370
|
-
function
|
|
376
|
+
/** Text of the first user message of the transcript (the original prompt / brief), or null. */
|
|
377
|
+
function firstPromptText(lines) {
|
|
371
378
|
for (const l of lines) {
|
|
372
379
|
if (!l || l.type !== 'user' || !l.message) continue;
|
|
373
380
|
const c = l.message.content;
|
|
374
|
-
|
|
375
|
-
if (
|
|
376
|
-
else if (Array.isArray(c)) {
|
|
381
|
+
if (typeof c === 'string') return c;
|
|
382
|
+
if (Array.isArray(c)) {
|
|
377
383
|
const b = c.find((x) => x && x.type === 'text' && typeof x.text === 'string');
|
|
378
|
-
if (b)
|
|
384
|
+
if (b) return b.text;
|
|
379
385
|
}
|
|
380
|
-
if (text === null) continue;
|
|
381
|
-
return parseTag(text);
|
|
382
386
|
}
|
|
383
387
|
return null;
|
|
384
388
|
}
|
|
385
389
|
|
|
390
|
+
/** Tag from the first user message of the transcript (the original prompt). */
|
|
391
|
+
function tagFromTranscript(lines) {
|
|
392
|
+
const text = firstPromptText(lines);
|
|
393
|
+
return text === null ? null : parseTag(text);
|
|
394
|
+
}
|
|
395
|
+
|
|
386
396
|
/**
|
|
387
397
|
* Summarize a transcript segment. Usage counted once per requestId (last line wins).
|
|
388
398
|
* opts.afterUuids: uuids already counted by earlier segments; the segment starts after
|
|
@@ -1398,7 +1408,10 @@ function buildDispatch(p, ctx) {
|
|
|
1398
1408
|
orch_ctx_baseline: orch.ctx_baseline,
|
|
1399
1409
|
orch_compactions: orch.compactions,
|
|
1400
1410
|
orch_cum_usd: orch.cum_usd,
|
|
1401
|
-
},
|
|
1411
|
+
}, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
|
|
1412
|
+
// core 0.5.0: an Agent call made inside a subagent (gen-2) carries the caller's agent_id
|
|
1413
|
+
typeof p.agent_id === 'string' && p.agent_id ? { parent_agent_id: p.agent_id } : {},
|
|
1414
|
+
commonFields(ctx));
|
|
1402
1415
|
}
|
|
1403
1416
|
|
|
1404
1417
|
function handlePostToolUse(p, ctx) {
|
|
@@ -1435,6 +1448,124 @@ function countSkippedInternal(ctx) {
|
|
|
1435
1448
|
} catch (_) { /* best effort */ }
|
|
1436
1449
|
}
|
|
1437
1450
|
|
|
1451
|
+
// ------------------------------------------------------------------ core 0.5.0: hand-over files
|
|
1452
|
+
//
|
|
1453
|
+
// Claude Code denies Write inside any subagent when the basename matches BLOCKED_RESULT_NAME_RE ("Subagents should
|
|
1454
|
+
// return findings as text, not write report files" — hardcoded, before permissions). So exec / verify / plan agents
|
|
1455
|
+
// write their full result to dp-out-<task>-<role>-try<n>.md and return only path + STATUS + a few lines. Run dirs
|
|
1456
|
+
// from before 0.5.0 hold report-<task>-try<n>.md / verify-<task>-try<n>.md: still recognised as results.
|
|
1457
|
+
|
|
1458
|
+
const RESULT_ROLES = ['exec', 'verify', 'plan'];
|
|
1459
|
+
const BLOCKED_RESULT_NAME_RE = /^(REPORT|SUMMARY|FINDINGS|ANALYSIS).*\.md$/i;
|
|
1460
|
+
const INLINE_MAX_BYTES = 256 * 1024;
|
|
1461
|
+
|
|
1462
|
+
function escRe(s) {
|
|
1463
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** Canonical hand-over file name of one attempt: dp-out-<task>-<role>-try<n>.md. */
|
|
1467
|
+
function resultFileName(taskId, role, tryN) {
|
|
1468
|
+
return `dp-out-${safeName(taskId)}-${role}-try${Number(tryN) || 1}.md`;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Result files already in runDir for (task, role, try), sorted: dp-out-* (with -2 / -spot / .inline suffixes) and the
|
|
1473
|
+
* legacy report-<task>-try<n>*.md (exec) / verify-<task>-try<n>*.md (verify). Never throws.
|
|
1474
|
+
*/
|
|
1475
|
+
function findResultFiles(runDir, taskId, role, tryN) {
|
|
1476
|
+
let names;
|
|
1477
|
+
try { names = fs.readdirSync(runDir); } catch (_) { return []; }
|
|
1478
|
+
const t = escRe(safeName(taskId));
|
|
1479
|
+
const n = Number(tryN) || 1;
|
|
1480
|
+
const pats = [new RegExp(`^dp-out-${t}-${escRe(role)}-try${n}(?:[-.][A-Za-z0-9.-]*)?\\.md$`)];
|
|
1481
|
+
if (role === 'exec') pats.push(new RegExp(`^report-${t}-try${n}(?:-[A-Za-z0-9-]*)?\\.md$`));
|
|
1482
|
+
if (role === 'verify') pats.push(new RegExp(`^verify-${t}-try${n}(?:-[A-Za-z0-9-]*)?\\.md$`));
|
|
1483
|
+
return names.filter((x) => pats.some((re) => re.test(x))).sort().map((x) => path.join(runDir, x));
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function insideRunsDir(d) {
|
|
1487
|
+
const r = path.relative(runsDir(), d);
|
|
1488
|
+
return !!r && !r.startsWith('..') && !path.isAbsolute(r);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function expandHome(s) {
|
|
1492
|
+
return s === '~' || s.startsWith('~/') ? path.join(os.homedir(), s.slice(1)) : s;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Run dir an agent's brief points at (never outside runsDir()): the folder of a dp-out-/handoff- path named in the
|
|
1497
|
+
* brief, else a `…/runs/<id>` path (the one named after the task first), else runsDir()/<task_id>.
|
|
1498
|
+
*/
|
|
1499
|
+
function resultRunDir(prompt, taskId) {
|
|
1500
|
+
const text = String(prompt || '').replace(/\\/g, '/');
|
|
1501
|
+
const PATH = "(?:[A-Za-z]:)?~?[^\\s`'\"<>()|*?]*";
|
|
1502
|
+
for (const m of text.matchAll(new RegExp(`(${PATH}/)(?:dp-out-|handoff-)[A-Za-z0-9_.-]*\\.md`, 'g'))) {
|
|
1503
|
+
const d = path.resolve(expandHome(m[1]));
|
|
1504
|
+
if (insideRunsDir(d)) return d;
|
|
1505
|
+
}
|
|
1506
|
+
const dirs = [];
|
|
1507
|
+
for (const m of text.matchAll(new RegExp(`(${PATH}/runs/[A-Za-z0-9_-]{1,64})(?![A-Za-z0-9_-])`, 'g'))) {
|
|
1508
|
+
const d = path.resolve(expandHome(m[1]));
|
|
1509
|
+
if (insideRunsDir(d) && path.dirname(d) === path.resolve(runsDir())) dirs.push(d);
|
|
1510
|
+
}
|
|
1511
|
+
const own = dirs.find((d) => path.basename(d) === safeName(taskId));
|
|
1512
|
+
if (own || dirs.length) return own || dirs[0];
|
|
1513
|
+
return path.join(runsDir(), safeName(taskId));
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/** Final text the agent returned: the payload's last_assistant_message, else the last assistant text in the transcript. */
|
|
1517
|
+
function finalAssistantText(lines, given) {
|
|
1518
|
+
if (typeof given === 'string' && given.trim()) return given;
|
|
1519
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1520
|
+
const l = lines[i];
|
|
1521
|
+
if (!l || l.type !== 'assistant' || !l.message) continue;
|
|
1522
|
+
if (l.isApiErrorMessage === true) return null;
|
|
1523
|
+
const c = l.message.content;
|
|
1524
|
+
const text = typeof c === 'string' ? c
|
|
1525
|
+
: (Array.isArray(c) ? c.filter((x) => x && x.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n') : '');
|
|
1526
|
+
if (text.trim()) return text;
|
|
1527
|
+
}
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** Whether the returned text names an existing dp-out-/handoff-/report-/verify-*.md under runsDir() (written under another name). */
|
|
1532
|
+
function namesExistingRunFile(text) {
|
|
1533
|
+
const t = String(text).replace(/\\/g, '/');
|
|
1534
|
+
for (const m of t.matchAll(/((?:[A-Za-z]:)?~?[^\s`'"<>()|*?]*\/(?:dp-out|handoff|report|verify)-[A-Za-z0-9_.-]+\.md)/g)) {
|
|
1535
|
+
const f = path.resolve(expandHome(m[1]));
|
|
1536
|
+
if (insideRunsDir(f) && fs.existsSync(f)) return true;
|
|
1537
|
+
}
|
|
1538
|
+
return false;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* SubagentStop fallback: an exec / verify / plan agent that came back without its hand-over file (Write denied, or it
|
|
1543
|
+
* answered inline) gets its returned text saved as <run>/dp-out-<task>-<role>-try<n>.inline.md so the next step can
|
|
1544
|
+
* still read a file. Skipped when a result file for that attempt exists (dp-out or legacy), when the text names an
|
|
1545
|
+
* existing run-dir .md file, and for handoffs (the handoff file is the hand-over). Returns the path written or null.
|
|
1546
|
+
*/
|
|
1547
|
+
function persistInlineResult(lines, tag, opts) {
|
|
1548
|
+
const o = opts || {};
|
|
1549
|
+
if (!tag || !RESULT_ROLES.includes(tag.role) || !tag.task_id) return null;
|
|
1550
|
+
const text = finalAssistantText(lines, o.finalText);
|
|
1551
|
+
if (!text) return null;
|
|
1552
|
+
if (text.trimStart().startsWith(HANDOFF_MARKER)) return null;
|
|
1553
|
+
const dir = resultRunDir(firstPromptText(lines), tag.task_id);
|
|
1554
|
+
if (findResultFiles(dir, tag.task_id, tag.role, tag.try).length) return null;
|
|
1555
|
+
if (namesExistingRunFile(text)) return null;
|
|
1556
|
+
const file = path.join(dir, resultFileName(tag.task_id, tag.role, tag.try).replace(/\.md$/, '.inline.md'));
|
|
1557
|
+
let body = text.replace(/\r\n/g, '\n');
|
|
1558
|
+
if (Buffer.byteLength(body) > INLINE_MAX_BYTES) body = `${Buffer.from(body).subarray(0, INLINE_MAX_BYTES).toString('utf8')}\n\n[… cắt bớt]`;
|
|
1559
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1560
|
+
try {
|
|
1561
|
+
fs.writeFileSync(file, `<!-- dieu-phoi: agent ${tag.role} không ghi file kết quả; hook SubagentStop lưu nguyên văn phần trả về -->\n\n${body}\n`, { flag: 'wx' });
|
|
1562
|
+
} catch (e) {
|
|
1563
|
+
if (e && e.code === 'EEXIST') return null;
|
|
1564
|
+
throw e;
|
|
1565
|
+
}
|
|
1566
|
+
return file;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1438
1569
|
function handleSubagentStop(p, ctx) {
|
|
1439
1570
|
const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
|
|
1440
1571
|
if (!agentId) return null;
|
|
@@ -1487,6 +1618,9 @@ function handleSubagentStop(p, ctx) {
|
|
|
1487
1618
|
});
|
|
1488
1619
|
}
|
|
1489
1620
|
appendEvent(ctx, ev);
|
|
1621
|
+
if (exists && tag) {
|
|
1622
|
+
try { persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
|
|
1623
|
+
}
|
|
1490
1624
|
// Core 0.4.0: a segment that ended on a usage-limit / quota pause gets an automatic agent verdict
|
|
1491
1625
|
// `aborted` (never a fail, never counted toward escalation); the real verdict comes after the resume.
|
|
1492
1626
|
const why = exists ? abortReasonOf(lines) : null;
|
|
@@ -1503,6 +1637,8 @@ function handleSubagentStop(p, ctx) {
|
|
|
1503
1637
|
// / usage limit (brief template A, SKILL.md). Nothing looser: "STATUS: partial" + "quota" can be a real failure
|
|
1504
1638
|
// ("Drive storage quota exceeded", a half-built rate limiter).
|
|
1505
1639
|
const QUOTA_PAUSE_MARKER = 'STATUS: partial — quota-pause';
|
|
1640
|
+
// Core 0.5.0: exact marker of an agent that stopped on the context watchdog and wrote handoff.md (continuation, not a fail).
|
|
1641
|
+
const HANDOFF_MARKER = 'STATUS: partial — handoff';
|
|
1506
1642
|
// Claude Code's own usage-limit stop text on its synthetic API-error line ("You've hit your session limit · resets 11am").
|
|
1507
1643
|
const CC_LIMIT_TEXT_RE = /^You['’]ve hit your (?:[\w-]+ )?limit\b/;
|
|
1508
1644
|
|
|
@@ -1527,7 +1663,338 @@ function abortReasonOf(lines) {
|
|
|
1527
1663
|
if (last.error === 'rate_limit' || last.apiErrorStatus === 429 || CC_LIMIT_TEXT_RE.test(text.trim())) return 'usage-limit';
|
|
1528
1664
|
return null;
|
|
1529
1665
|
}
|
|
1530
|
-
|
|
1666
|
+
const first = text.trimStart().split('\n')[0].trim();
|
|
1667
|
+
if (first.startsWith(QUOTA_PAUSE_MARKER)) return 'quota-pause';
|
|
1668
|
+
if (first.startsWith(HANDOFF_MARKER)) return 'handoff';
|
|
1669
|
+
return null;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// ------------------------------------------------------------------ core 0.5.0: context watchdog + gen-2 gate
|
|
1673
|
+
//
|
|
1674
|
+
// Both run only from the hooks of the dp-* agent files (~/.claude/agents, installed by `dieu-phoi setup`), so a session
|
|
1675
|
+
// that never uses dieu-phoi never runs them. They still check that the subagent's first prompt carries a `[dp:` tag
|
|
1676
|
+
// (state cached per agent in data/watch/<agent_id>.json) and do nothing on the main thread (no agent_id).
|
|
1677
|
+
|
|
1678
|
+
const HANDOFF_THRESHOLDS = Object.freeze([150000, 180000]);
|
|
1679
|
+
const WATCH_HEAD_BYTES = 64 * 1024;
|
|
1680
|
+
const WATCH_HEAD_MAX = 2 << 20;
|
|
1681
|
+
const WATCH_TAIL_BYTES = 256 * 1024;
|
|
1682
|
+
const WATCH_TAIL_MAX = 8 << 20;
|
|
1683
|
+
const WATCH_TTL_MS = 14 * 24 * 3600 * 1000;
|
|
1684
|
+
const GATE_MAX_CHILDREN = 4;
|
|
1685
|
+
const HELPER_AGENT_TYPE = 'dp-helper';
|
|
1686
|
+
const HELPER_MODELS = ['haiku', 'sonnet'];
|
|
1687
|
+
|
|
1688
|
+
/** The subagent the hook fired in: payload agent_id, else the `agent-<id>.jsonl` transcript name; null on the main thread. */
|
|
1689
|
+
function watchAgentId(p) {
|
|
1690
|
+
if (typeof p.agent_id === 'string' && p.agent_id) return p.agent_id;
|
|
1691
|
+
for (const k of ['agent_transcript_path', 'transcript_path']) {
|
|
1692
|
+
const m = typeof p[k] === 'string' ? /agent-([A-Za-z0-9_-]{6,64})\.jsonl$/.exec(p[k]) : null;
|
|
1693
|
+
if (m) return m[1];
|
|
1694
|
+
}
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* The subagent's own transcript. Claude Code layout: <project>/<session_id>.jsonl (main) and
|
|
1700
|
+
* <project>/<session_id>/subagents/agent-<agent_id>.jsonl. Uses a payload path when it already names the agent's file.
|
|
1701
|
+
*/
|
|
1702
|
+
function subagentTranscriptPath(p, agentId) {
|
|
1703
|
+
const name = `agent-${agentId}.jsonl`;
|
|
1704
|
+
for (const k of ['agent_transcript_path', 'transcript_path']) {
|
|
1705
|
+
if (typeof p[k] === 'string' && p[k] && path.basename(p[k]) === name) return p[k];
|
|
1706
|
+
}
|
|
1707
|
+
const tp = p.transcript_path;
|
|
1708
|
+
if (typeof tp !== 'string' || !tp) return null;
|
|
1709
|
+
const guesses = [
|
|
1710
|
+
path.join(path.dirname(tp), path.basename(tp, '.jsonl'), 'subagents', name),
|
|
1711
|
+
path.join(path.dirname(tp), name), // transcript_path already inside a subagents dir (nested spawn)
|
|
1712
|
+
];
|
|
1713
|
+
for (const g of guesses) if (fs.existsSync(g)) return g;
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function readSlice(fd, start, len) {
|
|
1718
|
+
const buf = Buffer.allocUnsafe(len);
|
|
1719
|
+
const n = fs.readSync(fd, buf, 0, len, start);
|
|
1720
|
+
return buf.subarray(0, Math.max(0, n));
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Tag of the subagent's first prompt, read from the head of its transcript only.
|
|
1725
|
+
* Returns { found: false } while no user line is on disk yet (transcript written async), else { found: true, tag|null }.
|
|
1726
|
+
*/
|
|
1727
|
+
function readHeadTag(file) {
|
|
1728
|
+
let fd;
|
|
1729
|
+
try { fd = fs.openSync(file, 'r'); } catch (_) { return { found: false, tag: null }; }
|
|
1730
|
+
try {
|
|
1731
|
+
const size = fs.fstatSync(fd).size;
|
|
1732
|
+
for (let want = WATCH_HEAD_BYTES; ; want *= 2) {
|
|
1733
|
+
const len = Math.min(want, size, WATCH_HEAD_MAX);
|
|
1734
|
+
const text = readSlice(fd, 0, len).toString('utf8');
|
|
1735
|
+
const complete = len === size ? text : text.slice(0, text.lastIndexOf('\n') + 1);
|
|
1736
|
+
const lines = parseJsonl(complete);
|
|
1737
|
+
if (lines.some((l) => l && l.type === 'user' && l.message)) return { found: true, tag: tagFromTranscript(lines), prompt: firstPromptText(lines) };
|
|
1738
|
+
if (len >= size || len >= WATCH_HEAD_MAX) return { found: false, tag: null };
|
|
1739
|
+
}
|
|
1740
|
+
} finally {
|
|
1741
|
+
try { fs.closeSync(fd); } catch (_) { /* ignore */ }
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/** Context of the subagent's latest API request = input + cache_read + cache_creation (tail of the transcript only). */
|
|
1746
|
+
function lastContextTokens(file) {
|
|
1747
|
+
let fd;
|
|
1748
|
+
try { fd = fs.openSync(file, 'r'); } catch (_) { return null; }
|
|
1749
|
+
try {
|
|
1750
|
+
const size = fs.fstatSync(fd).size;
|
|
1751
|
+
for (let win = WATCH_TAIL_BYTES; ; win *= 2) {
|
|
1752
|
+
const start = Math.max(0, size - win);
|
|
1753
|
+
const buf = readSlice(fd, start, size - start);
|
|
1754
|
+
let end = buf.length;
|
|
1755
|
+
while (end > 0) {
|
|
1756
|
+
const nl = buf.lastIndexOf(0x0a, end - 1);
|
|
1757
|
+
const line = buf.subarray(nl + 1, end);
|
|
1758
|
+
end = nl < 0 ? 0 : nl;
|
|
1759
|
+
if (nl < 0 && start > 0) break; // first partial line of the window
|
|
1760
|
+
if (line.indexOf(USAGE_NEEDLE) < 0) continue;
|
|
1761
|
+
let l;
|
|
1762
|
+
try { l = JSON.parse(line.toString('utf8')); } catch (_) { continue; }
|
|
1763
|
+
const msg = l && l.type === 'assistant' ? l.message : null;
|
|
1764
|
+
if (!msg || !msg.usage || msg.model === '<synthetic>') continue;
|
|
1765
|
+
const u = msg.usage;
|
|
1766
|
+
return (Number(u.input_tokens) || 0) + (Number(u.cache_read_input_tokens) || 0) + (Number(u.cache_creation_input_tokens) || 0);
|
|
1767
|
+
}
|
|
1768
|
+
if (start === 0 || win >= WATCH_TAIL_MAX) return null;
|
|
1769
|
+
}
|
|
1770
|
+
} finally {
|
|
1771
|
+
try { fs.closeSync(fd); } catch (_) { /* ignore */ }
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function watchFile(ctx, agentId) {
|
|
1776
|
+
return path.join(ctx.paths.data, 'watch', `${safeName(agentId)}.json`);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function pruneWatch(dir) {
|
|
1780
|
+
let names = [];
|
|
1781
|
+
try { names = fs.readdirSync(dir); } catch (_) { return; }
|
|
1782
|
+
const cutoff = Date.now() - WATCH_TTL_MS;
|
|
1783
|
+
for (const n of names) {
|
|
1784
|
+
const f = path.join(dir, n);
|
|
1785
|
+
try { if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f); } catch (_) { /* ignore */ }
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Per-agent watch state { dp, task_id, role, parent_task_id, fired, t }, created on the agent's first hook from the
|
|
1791
|
+
* head of its transcript with an exclusive create (two hooks racing to create it never clobber a later update). Only
|
|
1792
|
+
* --watch updates it; the gate keeps its own slot files (gateDir). Returns { st, file, tpath }; st is null while the
|
|
1793
|
+
* first prompt is not on disk yet.
|
|
1794
|
+
*/
|
|
1795
|
+
/** Name of the run dir a brief points at (dp-out-/handoff- folder or …/runs/<id>), else null. */
|
|
1796
|
+
function runIdFromPrompt(prompt) {
|
|
1797
|
+
const d = resultRunDir(prompt, '');
|
|
1798
|
+
return path.dirname(d) === path.resolve(runsDir()) ? path.basename(d) : null;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function watchState(p, ctx, agentId) {
|
|
1802
|
+
const file = watchFile(ctx, agentId);
|
|
1803
|
+
const tpath = subagentTranscriptPath(p, agentId);
|
|
1804
|
+
let st = readJsonFile(file, null);
|
|
1805
|
+
if (!st || typeof st !== 'object') {
|
|
1806
|
+
st = null;
|
|
1807
|
+
if (tpath) {
|
|
1808
|
+
const h = readHeadTag(tpath);
|
|
1809
|
+
if (h.found) {
|
|
1810
|
+
st = h.tag
|
|
1811
|
+
? { dp: true, task_id: h.tag.task_id, run_id: h.tag.task_id ? null : runIdFromPrompt(h.prompt), role: h.tag.role, parent_task_id: h.tag.parent_task_id || null, fired: 0, t: Date.now() }
|
|
1812
|
+
: { dp: false, t: Date.now() };
|
|
1813
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1814
|
+
pruneWatch(path.dirname(file));
|
|
1815
|
+
try {
|
|
1816
|
+
fs.writeFileSync(file, `${JSON.stringify(st)}\n`, { flag: 'wx' });
|
|
1817
|
+
} catch (_) {
|
|
1818
|
+
st = readJsonFile(file, st); // another hook created it first: its copy wins
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
return { st, file, tpath };
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function handoffMessage(st, tokens, level, thresholds) {
|
|
1827
|
+
if (st.role && st.role !== 'exec') {
|
|
1828
|
+
// verify / scout / helper: no continuation agent exists, so no handoff file — wrap up with what is there.
|
|
1829
|
+
const what = st.role === 'verify' ? 'verdict (dòng STATUS + các phát hiện)' : 'bản tóm tắt';
|
|
1830
|
+
const last = level >= thresholds.length ? ' NHẮC LẦN CUỐI.' : '';
|
|
1831
|
+
return `dieu-phoi: ngữ cảnh của bạn đã ~${Math.round(tokens / 1000)}k token.${last} Dừng đọc/chạy thêm: trả ${what} NGAY với những gì đã có, ghi rõ phần nào chưa kiểm được. Không ghi file handoff — chỉ agent exec mới handoff.`;
|
|
1832
|
+
}
|
|
1833
|
+
const tid = safeName(st.task_id || st.run_id || 'task');
|
|
1834
|
+
const file = `handoff-${tid}.md`;
|
|
1835
|
+
const where = `\`${file}\` trong run dir của brief (đã có thì thêm -2, -3; brief không có run dir → ${toPosix(path.join(runsDir(), tid))}/)`;
|
|
1836
|
+
const k = Math.round(tokens / 1000);
|
|
1837
|
+
if (level >= thresholds.length) {
|
|
1838
|
+
return `dieu-phoi: NHẮC LẦN CUỐI — ngữ cảnh của bạn đã ~${k}k token. Không làm thêm việc gì: ghi ${where} ngay (nếu chưa ghi) rồi kết thúc, dòng đầu báo cáo đúng \`${HANDOFF_MARKER}\` kèm đường dẫn file handoff.`;
|
|
1839
|
+
}
|
|
1840
|
+
return `dieu-phoi: ngữ cảnh của bạn đã ~${k}k token (ngưỡng ${Math.round(thresholds[level - 1] / 1000)}k). Dừng nhận việc mới: làm nốt thao tác đang dở tới điểm an toàn (không để file sửa dở, test đang chạy thì chờ kết quả), rồi ghi ${where} gồm 5 mục: Đã xong / Đang dở / Bước tiếp theo / File đã động / Bẫy & ghi chú (lệnh test, đường dẫn, quyết định đã chốt). Sau đó kết thúc: dòng đầu báo cáo đúng \`${HANDOFF_MARKER}\` kèm đường dẫn file handoff. Điều phối viên sẽ giao một agent tiếp nối cùng tier đọc file đó — đây không phải thất bại.`;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/** `--watch` (PostToolUse inside a dp-* subagent): additionalContext once per threshold crossing, else nothing. */
|
|
1844
|
+
function handleWatch(p, ctx, o) {
|
|
1845
|
+
const agentId = watchAgentId(p);
|
|
1846
|
+
if (!agentId) return null;
|
|
1847
|
+
const { st, file, tpath } = watchState(p, ctx, agentId);
|
|
1848
|
+
if (!st || !st.dp || !tpath) return null;
|
|
1849
|
+
// Gate slots: a helper's first hook proves its spawn started; so does the exec's own successful Agent call.
|
|
1850
|
+
if (st.role === 'helper' && st.parent_task_id && st.task_id) gateConfirm(ctx, st.parent_task_id, st.task_id);
|
|
1851
|
+
if ((p.tool_name === 'Agent' || p.tool_name === 'Task') && st.role === 'exec') {
|
|
1852
|
+
const ct = parseTag(p.tool_input && p.tool_input.prompt);
|
|
1853
|
+
if (ct && ct.task_id) gateConfirm(ctx, st.task_id || agentId, ct.task_id);
|
|
1854
|
+
}
|
|
1855
|
+
const thresholds = Array.isArray(o && o.thresholds) && o.thresholds.length ? o.thresholds : HANDOFF_THRESHOLDS;
|
|
1856
|
+
const tokens = lastContextTokens(tpath);
|
|
1857
|
+
if (tokens === null) return null;
|
|
1858
|
+
let level = 0;
|
|
1859
|
+
while (level < thresholds.length && tokens >= thresholds[level]) level++;
|
|
1860
|
+
if (level <= (Number(st.fired) || 0)) return null;
|
|
1861
|
+
st.fired = level;
|
|
1862
|
+
st.ctx_tokens = tokens;
|
|
1863
|
+
st.t = Date.now();
|
|
1864
|
+
writeFileAtomic(file, `${JSON.stringify(st)}\n`);
|
|
1865
|
+
return {
|
|
1866
|
+
level,
|
|
1867
|
+
ctx_tokens: tokens,
|
|
1868
|
+
stdout: JSON.stringify({
|
|
1869
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: handoffMessage(st, tokens, level, thresholds) },
|
|
1870
|
+
}),
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/** A reservation that neither the parent's PostToolUse(Agent) nor the helper's own first hook confirmed in this long never started. */
|
|
1875
|
+
const GATE_PENDING_MS = 10 * 60 * 1000;
|
|
1876
|
+
|
|
1877
|
+
/** Per-step gate dir: one `slot-<n>.json` file per reserved helper (n = 1..GATE_MAX_CHILDREN), keyed by the exec's task. */
|
|
1878
|
+
function gateDir(ctx, key) {
|
|
1879
|
+
return path.join(ctx.paths.data, 'gate', safeName(key));
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function pruneGate(root) {
|
|
1883
|
+
let names = [];
|
|
1884
|
+
try { names = fs.readdirSync(root); } catch (_) { return; }
|
|
1885
|
+
const cutoff = Date.now() - WATCH_TTL_MS;
|
|
1886
|
+
for (const n of names) {
|
|
1887
|
+
const d = path.join(root, n);
|
|
1888
|
+
try { if (fs.statSync(d).mtimeMs < cutoff) fs.rmSync(d, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/** Slots in use: a confirmed one always counts; an unconfirmed one counts until GATE_PENDING_MS, then it is released. */
|
|
1893
|
+
function gateSlots(dir) {
|
|
1894
|
+
const out = [];
|
|
1895
|
+
for (let i = 1; i <= GATE_MAX_CHILDREN; i++) {
|
|
1896
|
+
const f = path.join(dir, `slot-${i}.json`);
|
|
1897
|
+
const r = readJsonFile(f, null);
|
|
1898
|
+
if (!r) continue;
|
|
1899
|
+
if (!r.started && Date.now() - (Number(r.t) || 0) > GATE_PENDING_MS) {
|
|
1900
|
+
try { fs.unlinkSync(f); } catch (_) { /* someone else released it */ }
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
out.push(Object.assign({ file: f, slot: i }, r));
|
|
1904
|
+
}
|
|
1905
|
+
return out;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/** Reserve a free slot with an exclusive create (atomic on Windows and POSIX): parallel calls never share a slot. */
|
|
1909
|
+
function gateReserve(dir, child) {
|
|
1910
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1911
|
+
gateSlots(dir); // drop stale reservations first
|
|
1912
|
+
for (let i = 1; i <= GATE_MAX_CHILDREN; i++) {
|
|
1913
|
+
const f = path.join(dir, `slot-${i}.json`);
|
|
1914
|
+
try {
|
|
1915
|
+
fs.writeFileSync(f, `${JSON.stringify({ child, t: Date.now(), started: false })}\n`, { flag: 'wx' });
|
|
1916
|
+
return i;
|
|
1917
|
+
} catch (_) { /* taken */ }
|
|
1918
|
+
}
|
|
1919
|
+
return 0;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/** Mark the reservation of `child` as a real spawn. Returns true when its slot was found. */
|
|
1923
|
+
function gateConfirm(ctx, key, child) {
|
|
1924
|
+
if (!key || !child) return false;
|
|
1925
|
+
const dir = gateDir(ctx, key);
|
|
1926
|
+
if (!fs.existsSync(dir)) return false;
|
|
1927
|
+
for (const r of gateSlots(dir)) {
|
|
1928
|
+
if (r.child !== child) continue;
|
|
1929
|
+
if (r.started) return true;
|
|
1930
|
+
try {
|
|
1931
|
+
writeFileAtomic(r.file, `${JSON.stringify({ child, t: r.t, started: true, started_t: Date.now() })}\n`);
|
|
1932
|
+
return true;
|
|
1933
|
+
} catch (_) { return false; }
|
|
1934
|
+
}
|
|
1935
|
+
return false;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* `--gate` (PreToolUse(Agent) inside dp-exec): a dp-tagged exec may run at most GATE_MAX_CHILDREN gen-2 children per
|
|
1940
|
+
* step, each `dp-helper`, model haiku|sonnet set explicitly, prompt tagged `role=helper|parent=<own task>|task=<new id>`.
|
|
1941
|
+
* Anything else is denied with the reason; an allowed spawn prints nothing (normal permission flow). The budget is a set
|
|
1942
|
+
* of slot files (gateReserve, exclusive create), separate from the watch state. A slot is confirmed by the parent's
|
|
1943
|
+
* PostToolUse(Agent) or the helper's own first --watch, released by --gate-release (PostToolUseFailure) or, if never
|
|
1944
|
+
* confirmed, after GATE_PENDING_MS — so only spawns that really start use the budget.
|
|
1945
|
+
*/
|
|
1946
|
+
function handleGate(p, ctx) {
|
|
1947
|
+
if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
|
|
1948
|
+
const agentId = watchAgentId(p);
|
|
1949
|
+
if (!agentId) return null;
|
|
1950
|
+
const { st } = watchState(p, ctx, agentId);
|
|
1951
|
+
if (!st || !st.dp) return null;
|
|
1952
|
+
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
1953
|
+
const why = [];
|
|
1954
|
+
if (st.role && st.role !== 'exec') why.push(`chỉ agent role=exec được giao helper (bạn là role=${st.role})`);
|
|
1955
|
+
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'})`);
|
|
1956
|
+
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'})`);
|
|
1957
|
+
const tag = parseTag(ti.prompt);
|
|
1958
|
+
const parentHint = st.task_id || '<task của bạn>';
|
|
1959
|
+
if (!tag || tag.role !== 'helper' || !tag.task_id || !tag.parent_task_id) {
|
|
1960
|
+
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}]`);
|
|
1961
|
+
} else if (st.task_id && tag.parent_task_id !== st.task_id) {
|
|
1962
|
+
why.push(`parent= phải là task của bạn (${st.task_id})`);
|
|
1963
|
+
}
|
|
1964
|
+
const deny = (list) => ({
|
|
1965
|
+
denied: true,
|
|
1966
|
+
stdout: JSON.stringify({
|
|
1967
|
+
hookSpecificOutput: {
|
|
1968
|
+
hookEventName: 'PreToolUse',
|
|
1969
|
+
permissionDecision: 'deny',
|
|
1970
|
+
permissionDecisionReason: `dieu-phoi gen-2: ${list.join('; ')}.`,
|
|
1971
|
+
},
|
|
1972
|
+
}),
|
|
1973
|
+
});
|
|
1974
|
+
if (why.length) return deny(why);
|
|
1975
|
+
const root = path.join(ctx.paths.data, 'gate');
|
|
1976
|
+
fs.mkdirSync(root, { recursive: true });
|
|
1977
|
+
pruneGate(root);
|
|
1978
|
+
const dir = gateDir(ctx, st.task_id || agentId);
|
|
1979
|
+
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
|
+
return { denied: false, slot, children: gateSlots(dir).length };
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/** `--gate-release` (PostToolUseFailure(Agent) inside dp-exec): the spawn failed, give its unconfirmed slot back. */
|
|
1985
|
+
function handleGateRelease(p, ctx) {
|
|
1986
|
+
if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
|
|
1987
|
+
const agentId = watchAgentId(p);
|
|
1988
|
+
if (!agentId) return null;
|
|
1989
|
+
const { st } = watchState(p, ctx, agentId);
|
|
1990
|
+
if (!st || !st.dp) return null;
|
|
1991
|
+
const tag = parseTag(p.tool_input && p.tool_input.prompt);
|
|
1992
|
+
if (!tag || !tag.task_id) return null;
|
|
1993
|
+
for (const r of gateSlots(gateDir(ctx, st.task_id || agentId))) {
|
|
1994
|
+
if (r.child !== tag.task_id || r.started) continue;
|
|
1995
|
+
try { fs.unlinkSync(r.file); } catch (_) { /* already gone */ }
|
|
1996
|
+
return { released: r.slot };
|
|
1997
|
+
}
|
|
1531
1998
|
return null;
|
|
1532
1999
|
}
|
|
1533
2000
|
|
|
@@ -1582,6 +2049,9 @@ function runHook(opts) {
|
|
|
1582
2049
|
const ctx = makeCtx(home);
|
|
1583
2050
|
let ev = null;
|
|
1584
2051
|
const name = p.hook_event_name;
|
|
2052
|
+
if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
|
|
2053
|
+
if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
|
|
2054
|
+
if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
|
|
1585
2055
|
if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
|
|
1586
2056
|
if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
|
|
1587
2057
|
if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
|
|
@@ -1740,8 +2210,10 @@ module.exports = {
|
|
|
1740
2210
|
sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
|
|
1741
2211
|
logError, computeUserHash, userHash, commonFields, parseTag, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
|
|
1742
2212
|
priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
|
|
1743
|
-
tagFromTranscript, summarizeTranscript,
|
|
2213
|
+
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,
|
|
1744
2215
|
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,
|
|
1745
2217
|
extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
|
|
1746
2218
|
};
|
|
1747
2219
|
|
|
@@ -1755,7 +2227,9 @@ if (require.main === module) {
|
|
|
1755
2227
|
.catch((e) => logError(ctx.home, e, 'sync'))
|
|
1756
2228
|
.finally(() => process.exit(0));
|
|
1757
2229
|
} else {
|
|
1758
|
-
const
|
|
2230
|
+
const mode = process.argv.includes('--watch') ? 'watch'
|
|
2231
|
+
: (process.argv.includes('--gate-release') ? 'gate-release' : (process.argv.includes('--gate') ? 'gate' : null));
|
|
2232
|
+
const r = runHook({ mode });
|
|
1759
2233
|
if (r && typeof r.stdout === 'string') process.stdout.write(r.stdout);
|
|
1760
2234
|
process.exitCode = 0;
|
|
1761
2235
|
}
|
package/lib/aggregate.cjs
CHANGED
|
@@ -15,6 +15,9 @@ const ABORTED = 'aborted';
|
|
|
15
15
|
// Exact markers only (anchored): `quota-pause`, `usage-limit`, the hook's `auto-*` codes and the legacy
|
|
16
16
|
// `paused-for-quota-*` codes the orchestrator wrote before 0.4.0. A reason that merely mentions a rate limit stays a timeout.
|
|
17
17
|
const QUOTA_REASON_RE = /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/;
|
|
18
|
+
// core 0.5.0: `aborted` with this reason = the agent stopped on the context watchdog and wrote handoff.md. Its
|
|
19
|
+
// continuation (next exec agent of the task with the same try) carries the attempt's agent-layer result.
|
|
20
|
+
const HANDOFF_REASON_RE = /^(?:auto-)?handoff(?:-|$)/;
|
|
18
21
|
|
|
19
22
|
function tsOf(ev) {
|
|
20
23
|
const t = Date.parse(ev && ev.ts);
|
|
@@ -174,6 +177,7 @@ function buildAgents(events) {
|
|
|
174
177
|
// every non-aborted agent verdict in time order: a retry by SendMessage to the same agent is a new result
|
|
175
178
|
verdict_seq: v.map((x) => ({ outcome: x.outcome, ts: tsOf(x), idx: x._idx })),
|
|
176
179
|
aborted: vAll.length > 0 && vAll[vAll.length - 1].outcome === ABORTED,
|
|
180
|
+
handoff: vAll.some((x) => x.outcome === ABORTED && HANDOFF_REASON_RE.test(String(x.reason_code || ''))),
|
|
177
181
|
verify: verdict ? verdict.verify : null,
|
|
178
182
|
reason_code: verdict ? verdict.reason_code : null,
|
|
179
183
|
steers: a.steers.map((s) => s.steer_kind).filter(Boolean),
|
|
@@ -206,10 +210,20 @@ function buildTasks(agents, userVerdicts) {
|
|
|
206
210
|
const tasks = [];
|
|
207
211
|
for (const t of map.values()) {
|
|
208
212
|
const byTry = (x, y) => (x.try - y.try) || (x.ts - y.ts) || (x._idx - y._idx);
|
|
209
|
-
|
|
213
|
+
// gen-2 helpers (role=helper, own task_id) are never attempts
|
|
214
|
+
const attempts = t.agents.filter((a) => a.role !== 'verify' && a.role !== 'helper').sort(byTry);
|
|
210
215
|
const verifies = t.agents.filter((a) => a.role === 'verify');
|
|
211
216
|
// First exec attempt: lowest try among role=exec (ties: earliest). No exec attempt -> no sample.
|
|
212
|
-
const
|
|
217
|
+
const execs = t.agents.filter((a) => a.role === 'exec').sort(byTry);
|
|
218
|
+
const first = execs[0] || null;
|
|
219
|
+
// core 0.5.0: a first exec that stopped on a context handoff (no result of its own) passes the agent layer to its
|
|
220
|
+
// continuation(s) with the same try; the tier stays the first exec's.
|
|
221
|
+
let firstRes = first;
|
|
222
|
+
const chain = first ? [first] : [];
|
|
223
|
+
for (let i = 1; firstRes && !firstRes.first_outcome && firstRes.handoff && i < execs.length && execs[i].try === first.try; i++) {
|
|
224
|
+
firstRes = execs[i];
|
|
225
|
+
chain.push(firstRes);
|
|
226
|
+
}
|
|
213
227
|
const user = t.user.slice().sort((x, y) => ((x.round || 0) - (y.round || 0)) || (tsOf(x) - tsOf(y)) || (x._idx - y._idx));
|
|
214
228
|
|
|
215
229
|
// Results for escalation. Each agent verdict of an attempt is one result at its tier (a retry by
|
|
@@ -240,8 +254,8 @@ function buildTasks(agents, userVerdicts) {
|
|
|
240
254
|
else userOutcome = firstUser.cause === 'scope' ? 'reject_scope' : 'reject_quality';
|
|
241
255
|
}
|
|
242
256
|
// Agent layer of the first exec attempt: its first agent verdict.
|
|
243
|
-
const agentFirst =
|
|
244
|
-
?
|
|
257
|
+
const agentFirst = firstRes && firstRes.first_outcome && (firstRes.first_outcome === 'pass' || FAIL_OUTCOMES.has(firstRes.first_outcome))
|
|
258
|
+
? firstRes.first_outcome === 'pass' : null;
|
|
245
259
|
let success = null;
|
|
246
260
|
let source = null;
|
|
247
261
|
if (userOutcome === 'accept') { success = true; source = 'user'; } else if (userOutcome === 'reject_quality') { success = false; source = 'user'; } else if (!userOutcome && agentFirst !== null) {
|
|
@@ -255,6 +269,11 @@ function buildTasks(agents, userVerdicts) {
|
|
|
255
269
|
task_id: t.task_id,
|
|
256
270
|
task_type: first ? first.task_type : (t.agents[0] && t.agents[0].task_type) || 'untagged',
|
|
257
271
|
first,
|
|
272
|
+
// the first attempt as a whole: its exec segment plus every handoff continuation (time/cost summed over all
|
|
273
|
+
// segments; the result comes from the last one, firstRes)
|
|
274
|
+
first_chain: chain,
|
|
275
|
+
first_cost: chain.length ? sumOrNull(chain.map((a) => a.cost_usd)) : null,
|
|
276
|
+
first_time_s: chain.length ? sumOrNull(chain.map((a) => a.duration_s)) : null,
|
|
258
277
|
attempts,
|
|
259
278
|
verifies,
|
|
260
279
|
user_verdicts: user,
|
|
@@ -304,8 +323,9 @@ function buildCells(tasks, opts) {
|
|
|
304
323
|
const passes = c.tasks.filter((t) => t.success).length;
|
|
305
324
|
const costs = c.tasks.map((t) => t.total_cost);
|
|
306
325
|
const timesMin = c.tasks.map((t) => (t.total_time_s === null ? null : t.total_time_s / 60));
|
|
307
|
-
|
|
308
|
-
const
|
|
326
|
+
// a handoff chain is one attempt: all its segments count (t.first alone would bill only the first segment)
|
|
327
|
+
const attemptTimes = c.tasks.map((t) => (t.first_time_s === null || t.first_time_s === undefined ? null : t.first_time_s / 60));
|
|
328
|
+
const attemptCosts = c.tasks.map((t) => (t.first_cost === undefined ? null : t.first_cost));
|
|
309
329
|
const costMean = mean(costs);
|
|
310
330
|
const timeMean = mean(timesMin);
|
|
311
331
|
out.push({
|
|
@@ -472,6 +492,6 @@ function analyze(events, opts) {
|
|
|
472
492
|
}
|
|
473
493
|
|
|
474
494
|
module.exports = {
|
|
475
|
-
MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, ABORTED, QUOTA_REASON_RE, discardedTaskIds, withoutDiscarded, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
|
|
495
|
+
MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, ABORTED, QUOTA_REASON_RE, HANDOFF_REASON_RE, discardedTaskIds, withoutDiscarded, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
|
|
476
496
|
buildCells, buildSteers, canonicalCells, orchestrationSummary, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
|
|
477
497
|
};
|
package/lib/choose.cjs
CHANGED
|
@@ -17,6 +17,7 @@ const LADDER = [
|
|
|
17
17
|
{ model: 'opus', effort: 'medium' },
|
|
18
18
|
];
|
|
19
19
|
const TOP = LADDER.length - 1;
|
|
20
|
+
const HELPER_TOP = 4; // sonnet/high: ceiling for role=helper (gen-2)
|
|
20
21
|
const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
|
|
21
22
|
|
|
22
23
|
// Built-in defaults = skill/profile/profile.json for the fixed task types (time caps as tuned 2026-10-12: feature-impl 80,
|
|
@@ -418,7 +419,10 @@ function choose(opts) {
|
|
|
418
419
|
&& shortModel(c.model) === LADDER[i].model && c.effort === LADDER[i].effort) || null;
|
|
419
420
|
|
|
420
421
|
const src = profile.source || 'builtin';
|
|
421
|
-
const finish = (
|
|
422
|
+
const finish = (idx0, reason0, explored, extra) => {
|
|
423
|
+
// core 0.5.0: a gen-2 helper (spawned by a dp-exec) is haiku|sonnet only — clamp to sonnet/high
|
|
424
|
+
const idx = role === 'helper' ? Math.min(idx0, HELPER_TOP) : idx0;
|
|
425
|
+
const reason = idx === idx0 ? reason0 : `${reason0}; helper: trần ${tierName(HELPER_TOP)}`;
|
|
422
426
|
const t = LADDER[idx];
|
|
423
427
|
const cell = cellAt(idx);
|
|
424
428
|
return Object.assign({
|
package/lib/commands.cjs
CHANGED
|
@@ -65,6 +65,7 @@ function cmdSetup(opts) {
|
|
|
65
65
|
res.settings_changed
|
|
66
66
|
? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) + UserPromptSubmit trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
|
|
67
67
|
: `settings.json đã có hook, không đổi gì (${res.settings})`,
|
|
68
|
+
`Agent dp-*: ${res.agents.map((a) => `${a.name}=${a.action}`).join(', ')}${res.agents.some((a) => a.action === 'skipped-foreign') ? ' (file trùng tên không phải của dieu-phoi: giữ nguyên, đổi tên rồi chạy lại setup)' : ''}`,
|
|
68
69
|
`Thư mục báo cáo subagent: ${res.runs_dir} (quyền: ${res.permissions.allow.join(', ')} + additionalDirectories)`,
|
|
69
70
|
`Phòng ban: ${res.config.dept || '(chưa đặt, dùng --dept)'}`,
|
|
70
71
|
`Sink: ${res.config.sink_url ? res.config.sink_url : '(chưa có — log chỉ lưu cục bộ)'}`,
|
|
@@ -80,7 +81,7 @@ function cmdUninstall(opts) {
|
|
|
80
81
|
return {
|
|
81
82
|
code: 0,
|
|
82
83
|
out: res.removed
|
|
83
|
-
? `Đã gỡ ${res.removed} hook khỏi ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}. Dữ liệu giữ ở ${res.data_kept}.`
|
|
84
|
+
? `Đã gỡ ${res.removed} hook khỏi ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}${res.agents_removed.length ? `, agent ${res.agents_removed.join(', ')}` : ''}. Dữ liệu giữ ở ${res.data_kept}.`
|
|
84
85
|
: `Không thấy hook dieu-phoi trong ${res.settings}.`,
|
|
85
86
|
};
|
|
86
87
|
}
|
|
@@ -105,6 +106,11 @@ async function cmdDoctor(opts) {
|
|
|
105
106
|
add(permOk, 'run-permissions', permOk
|
|
106
107
|
? `${reg.permission_entries.allow.join(', ')} + additionalDirectories ${reg.permission_entries.dir}`
|
|
107
108
|
: `${!reg.permissions ? `thiếu quyền cho thư mục báo cáo ${reg.permission_entries.dir} (subagent nền sẽ không ghi được)` : (!reg.runs_dir_exists ? `chưa có thư mục ${reg.runs_dir}` : 'còn quyền cũ cho ~/.claude/dieu-phoi/runs (đường dẫn được bảo vệ, không dùng được)')} — chạy dieu-phoi setup`, 'CẢNH BÁO');
|
|
109
|
+
// compare with the node binary setup wrote into settings.json (and the agent files), not this process's node: a doctor run
|
|
110
|
+
// under another node (nvm, upgrade) must not report a false "outdated"
|
|
111
|
+
const agentSt = setupLib.agentStatus(claudeDir, ctx.paths.hook, setupLib.nodePathOf(reg.commands[0]));
|
|
112
|
+
const agentsOk = agentSt.every((a) => a.state === 'ok');
|
|
113
|
+
add(agentsOk, 'agents', `${agentSt.map((a) => `${a.name}=${a.state}`).join(', ')}${agentsOk ? '' : ' — chạy `dieu-phoi setup` (foreign = file trùng tên không phải của dieu-phoi, không bị ghi đè)'}`, 'CẢNH BÁO');
|
|
108
114
|
const hasCfg = fs.existsSync(ctx.paths.config);
|
|
109
115
|
add(hasCfg, 'config', hasCfg ? `${ctx.paths.config} dept=${ctx.config.dept || '-'}` : 'chưa có — chạy `dieu-phoi setup`');
|
|
110
116
|
const sd = ctx.config.skill_dir;
|
package/lib/setup.cjs
CHANGED
|
@@ -245,6 +245,71 @@ function skillVersionOf(dir) {
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// ------------------------------------------------------------------ dp-* agent files (core 0.5.0)
|
|
249
|
+
|
|
250
|
+
const AGENT_NAMES = ['dp-exec', 'dp-verify', 'dp-scout', 'dp-helper'];
|
|
251
|
+
const AGENT_MARKER = '# dieu-phoi-managed:';
|
|
252
|
+
const AGENT_HOOK_PLACEHOLDER = '__DP_HOOK__';
|
|
253
|
+
|
|
254
|
+
function agentsDirOf(claudeDir) {
|
|
255
|
+
return path.join(path.resolve(claudeDir), 'agents');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Agent file content for this install: template with the absolute hook command (YAML single-quoted). */
|
|
259
|
+
function renderAgent(name, hookPath, nodePath) {
|
|
260
|
+
const tpl = fs.readFileSync(path.join(PKG_ROOT, 'agents', `${name}.md`), 'utf8').replace(/\r\n/g, '\n');
|
|
261
|
+
const cmd = hookCommand(hookPath, nodePath).replace(/'/g, "''");
|
|
262
|
+
return tpl.split(AGENT_HOOK_PLACEHOLDER).join(cmd);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Node binary of a registered hook command (`"<node>" "<hook>" ...`), or undefined. */
|
|
266
|
+
function nodePathOf(cmd) {
|
|
267
|
+
const m = typeof cmd === 'string' ? /^\s*"([^"]+)"\s+"[^"]+"/.exec(cmd) : null;
|
|
268
|
+
return m ? m[1] : undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* State of each dp-* agent file: ok (ours, identical), outdated (ours, differs), missing, foreign (a file with
|
|
273
|
+
* that name we did not write — never touched).
|
|
274
|
+
*/
|
|
275
|
+
function agentStatus(claudeDir, hookPath, nodePath) {
|
|
276
|
+
const dir = agentsDirOf(claudeDir);
|
|
277
|
+
return AGENT_NAMES.map((name) => {
|
|
278
|
+
const file = path.join(dir, `${name}.md`);
|
|
279
|
+
let cur = null;
|
|
280
|
+
try { cur = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); } catch (_) { /* missing */ }
|
|
281
|
+
let state = 'missing';
|
|
282
|
+
if (cur !== null) {
|
|
283
|
+
if (!cur.includes(AGENT_MARKER)) state = 'foreign';
|
|
284
|
+
else state = cur === renderAgent(name, hookPath, nodePath) ? 'ok' : 'outdated';
|
|
285
|
+
}
|
|
286
|
+
return { name, file, state };
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Write missing/outdated dp-* agent files (LF). Foreign files are reported, never overwritten. */
|
|
291
|
+
function installAgents(claudeDir, hookPath, nodePath) {
|
|
292
|
+
const dir = agentsDirOf(claudeDir);
|
|
293
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
294
|
+
return agentStatus(claudeDir, hookPath, nodePath).map((a) => {
|
|
295
|
+
if (a.state === 'ok' || a.state === 'foreign') return Object.assign({ action: a.state === 'ok' ? 'unchanged' : 'skipped-foreign' }, a);
|
|
296
|
+
core.writeFileAtomic(a.file, renderAgent(a.name, hookPath, nodePath));
|
|
297
|
+
return Object.assign({}, a, { action: a.state === 'missing' ? 'installed' : 'updated', state: 'ok' });
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Remove only our dp-* agent files (marker present). */
|
|
302
|
+
function removeAgents(claudeDir) {
|
|
303
|
+
const removed = [];
|
|
304
|
+
for (const name of AGENT_NAMES) {
|
|
305
|
+
const file = path.join(agentsDirOf(claudeDir), `${name}.md`);
|
|
306
|
+
try {
|
|
307
|
+
if (fs.readFileSync(file, 'utf8').includes(AGENT_MARKER)) { fs.unlinkSync(file); removed.push(name); }
|
|
308
|
+
} catch (_) { /* missing */ }
|
|
309
|
+
}
|
|
310
|
+
return removed;
|
|
311
|
+
}
|
|
312
|
+
|
|
248
313
|
/**
|
|
249
314
|
* Install: copy hook + pricing, register hooks, write config.
|
|
250
315
|
* opts: { claudeDir, dept, sinkUrl, sinkToken, skillDir }
|
|
@@ -302,8 +367,11 @@ function setup(opts) {
|
|
|
302
367
|
if (legacyRemoved) dirty = true;
|
|
303
368
|
if (addOurPermissions(obj, perms)) dirty = true;
|
|
304
369
|
if (dirty) res = writeSettings(settingsFile, before, obj);
|
|
370
|
+
// 5. dp-* agent types (~/.claude/agents): only used when the orchestrator dispatches with subagent_type dp-*
|
|
371
|
+
const agents = installAgents(claudeDir, paths.hook, opts.nodePath);
|
|
305
372
|
|
|
306
373
|
return {
|
|
374
|
+
agents,
|
|
307
375
|
home,
|
|
308
376
|
hook: paths.hook,
|
|
309
377
|
command: cmd,
|
|
@@ -329,13 +397,14 @@ function uninstall(opts) {
|
|
|
329
397
|
+ stripOurPermissions(obj, permissionEntries(legacyRunsDirOf(claudeDir)));
|
|
330
398
|
let res = { changed: false, backup: null };
|
|
331
399
|
if (removed || permsRemoved) res = writeSettings(settingsFile, before, obj);
|
|
400
|
+
const agentsRemoved = removeAgents(claudeDir);
|
|
332
401
|
const hookFile = core.pathsFor(home).hook;
|
|
333
402
|
let hookRemoved = false;
|
|
334
403
|
if (opts.purge && fs.existsSync(hookFile)) {
|
|
335
404
|
fs.unlinkSync(hookFile);
|
|
336
405
|
hookRemoved = true;
|
|
337
406
|
}
|
|
338
|
-
return { removed, permissions_removed: permsRemoved, settings: settingsFile, backup: res.backup, hook_removed: hookRemoved, data_kept: core.pathsFor(home).data };
|
|
407
|
+
return { removed, agents_removed: agentsRemoved, permissions_removed: permsRemoved, settings: settingsFile, backup: res.backup, hook_removed: hookRemoved, data_kept: core.pathsFor(home).data };
|
|
339
408
|
}
|
|
340
409
|
|
|
341
410
|
/** Inspect settings for our registration. */
|
|
@@ -369,6 +438,6 @@ function registration(claudeDir) {
|
|
|
369
438
|
}
|
|
370
439
|
|
|
371
440
|
module.exports = {
|
|
372
|
-
MARKER, setup, uninstall, registration, stripOurHooks, addOurHooks, alreadyRegistered, hookCommand,
|
|
441
|
+
MARKER, AGENT_NAMES, AGENT_MARKER, renderAgent, agentStatus, nodePathOf, installAgents, removeAgents, agentsDirOf, setup, uninstall, registration, stripOurHooks, addOurHooks, alreadyRegistered, hookCommand,
|
|
373
442
|
skillVersionOf, findSkillDir, tsStamp, runsDir, legacyRunsDirOf, permissionEntries, addOurPermissions, stripOurPermissions, hasOurPermissions,
|
|
374
443
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kienntpixon/dieu-phoi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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>",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"main": "hook.cjs",
|
|
21
21
|
"files": [
|
|
22
22
|
"bin/",
|
|
23
|
+
"agents/",
|
|
23
24
|
"lib/",
|
|
24
25
|
"hook.cjs",
|
|
25
26
|
"pricing.json",
|