@kienntpixon/dieu-phoi 0.2.2 → 0.4.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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog — @kienntpixon/dieu-phoi
2
2
 
3
+ ## 0.4.0
4
+
5
+ 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).
6
+
7
+ - **Nâng cấp:** **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` (rơi vào `sync-rejected.jsonl`), coi `aborted` như verdict thường và tính `orch_share` từ đầu phiên. Sau đó `npm i -g @kienntpixon/dieu-phoi@0.4.0` rồi `dieu-phoi setup`.
8
+ - **Chi phí điều phối tính từ dispatch đầu tiên** của mỗi phiên, không từ đầu phiên (`orchestration.orch_usd_total` / `orch_share`, CLI + sink, fixture parity cập nhật). Trước đây một phiên làm việc khác suốt buổi rồi mới giao một agent bị tính $540 "điều phối": `orch_share` báo 46% trong khi thật ~9,5%, làm tín hiệu retro mục 6 bắn sai.
9
+ - **Outcome mới `aborted`** cho agent dừng vì hết usage limit / quota: không phải pass hay fail, không thành mẫu, không tính leo thang. Hook `SubagentStop` tự ghi verdict `aborted` (`reason_code` `auto-usage-limit` khi dòng cuối là lỗi API "You've hit your … limit" / `rate_limit` / 429; `auto-quota-pause` khi dòng đầu báo cáo cuối là đúng dấu `STATUS: partial — quota-pause`). `verdict <agent_id> aborted` để ghi tay. Dữ liệu cũ: `timeout` có `reason_code` bắt đầu bằng `paused-for-quota` / `quota-pause` / `usage-limit` được đọc là `aborted` (5/18 lượt không pass trong log thật là quota pause, 3 trên sonnet/high). `state` hiện `aborted`, rồi `done` khi agent được làm tiếp và xong; `verdict last` vẫn coi agent `aborted` là chờ verdict.
10
+ - **Leo thang đếm cả làm lại bằng `SendMessage`:** mỗi verdict agent là một kết quả ở tier đó — fail, fail trên **cùng** một agent = 2 fail cùng tier → lên tier (trước đây chỉ verdict cuối của mỗi agent được tính, nên luật "2 fail cùng tier" không bao giờ bắn: 10 agent fail-rồi-pass, 2 agent fail-fail-pass).
11
+ - **`verdict --root <root_task_id> accept [--steps a,b]`:** người dùng nghiệm thu mọi bước đã giao exec của kế hoạch đã duyệt mà chưa có verdict người dùng, trong một lệnh (chỉ 16% task có verdict người dùng).
12
+ - **Steer chỉ tính pha exec:** steer trên scout/planner (trả lời câu hỏi cổng 1) và verifier không vào `steer_rate` / tín hiệu "brainstorm hỏi quá ít" nữa, mà vào cột mới `plan_phase_actions` (16/28 hành động lái trong log thật là trả lời cổng 1 cho scout). `steer --task` không đánh dấu verifier. Sink cũng bỏ steer trên agent không phải exec khỏi cột `steer_*`.
13
+ - **Bài học cho điều phối viên tới được điều phối viên:** `start` (`orch_lessons` trong `--json`) và khối UserPromptSubmit in 5 dòng cuối của `profile/lessons.md` có thẻ `[orch]` (hoặc `[orchestrator]` cũ), mỗi dòng ≤ 240 ký tự.
14
+ - **Override khi duyệt kế hoạch:** `plan-table --approve` ghi sự kiện `override` cho bước có `override` khác đề xuất (cả khi duyệt lại đổi tier); không nhân đôi khi đã có override cùng task + tier. Trước đây override trong file steps không bao giờ thành sự kiện (0 override trong log thật).
15
+ - **Chặn task_type tự đặt:** `choose` đổi nhãn quen sang loại cố định (`bug-fix`→`debug`, `config`/`shell`/`deploy`→`mechanical-edit`, `docs`→`content-write`, `design`/`plan`→`plan-design`, `research`→`research-synth`, `art-fix`→`art-gen` khi profile có, …) kèm `task_type`, `mapped_from`, `type_warning`; nhãn lạ khác → `action:"ask_user"`, `unknown_type:true`. `plan-table` cảnh báo, `--approve` từ chối nhãn lạ, bản ghi kế hoạch lưu loại đã đổi. (23 agent / 9 nhãn tự đặt trong log thật đã rơi về opus/medium.)
16
+ - **Ghi log an toàn hơn:** mỗi sự kiện là một lệnh ghi O_APPEND trọn dòng; file không kết thúc bằng `\n` (tiến trình chết giữa dòng) → ghi `\n` trước để mảnh dở không nuốt sự kiện mới. Điều tra dispatch `a955459fed35d6614` có trên Sheet mà mất ở `events.jsonl`: không phải tranh ghi — một agent theo yêu cầu người dùng đã **sửa tay** log để xoá một yêu cầu nhầm (task `63w6xmsh`, có bản sao `.bak-63w6xmsh`) sau khi dòng đó đã được đẩy lên sink. Sửa gốc: lệnh mới **`dieu-phoi discard --task <id> [--reason R]`** ghi sự kiện `discard` (kind mới, đồng bộ lên sink); CLI (stats/choose/state/verdict --root) và sink bỏ mọi sự kiện của task đó và của agent của nó; `orchestration` giữ nguyên (tiền đã tiêu).
17
+ - (sau verifier) Nhận diện `aborted` chỉ theo dấu chính xác: dòng đầu báo cáo `STATUS: partial — quota-pause`, hoặc dòng lỗi usage limit tổng hợp của Claude Code (`<synthetic>` + `rate_limit`/429/"You've hit your … limit"); `timeout` cũ chỉ đổi thành `aborted` khi `reason_code` bắt đầu bằng `quota-pause|usage-limit|auto-*|paused-for-quota`. `doctor` bỏ qua thư mục có tên giống file tạm.
18
+ - **Cảnh báo context bớt ồn:** `choose --json` thêm `orch_ctx_warn` — `true` chỉ khi vượt một ngưỡng mới của phiên (trần, 2×, 4×, …) hoặc vẫn vượt sau một lần `/compact`; dòng `CẢNH BÁO` của bản chữ chỉ in khi đó (trạng thái ở `data/ctx-warn.json`). `orch_ctx_over` giữ nghĩa cũ. Trước đây cảnh báo bắn ở 78% dispatch.
19
+ - `doctor`: dòng `tmp-files` xoá file `*.tmp-*` bỏ dở cũ hơn 1 giờ (6 file trong máy thật), dòng `plans` cảnh báo kế hoạch có root id sai định dạng.
20
+ - `retro --json`: `outcomes.aborted`, `totals.discarded_tasks`.
21
+ - `time_cap_min` mặc định của core (chỉ dùng khi không có profile) theo profile ai-lab đã tune: feature-impl 80, content-write 40, debug 55.
22
+ - **Không đổi:** priors, λ, `explore_rate`, `orch_ctx_soft_cap`, trần opus/medium, không Fable, không thăm dò task phán đoán / khó hoàn tác / gấp.
23
+
24
+ ## 0.3.0
25
+
26
+ - **Khởi động không qua shell:** hook mới `UserPromptSubmit` (`setup` đăng ký, không matcher, timeout 5 s; chạy lại không nhân đôi; `uninstall` gỡ). Chỉ kích hoạt khi prompt **mở đầu** (sau khi cắt khoảng trắng) bằng `/dieu-phoi`, "điều phối" (không phân biệt hoa thường / có dấu hay không, có thể kèm ":") hoặc một cụm đặc trưng "spawn subagents", "orchestrate subagents", "chia việc cho agent", "giao cho subagent"; "điều phối" ở giữa câu ("trung tâm điều phối…", "không cần điều phối") không kích hoạt, yêu cầu kiểu retro / state / stats / lệnh CLI và "điều phối viên…" cũng không → hook tạo sẵn `root_task_id` (cùng bộ sinh với `new-id`), **tạo** run dir `~/.dieu-phoi/runs/<id>/` (theo `DIEU_PHOI_RUNS_DIR`), 10 `step_ids` dự phòng cho các bước, tình trạng sức khoẻ cục bộ và đường dẫn brief scout dựng sẵn, rồi tiêm vào `hookSpecificOutput.additionalContext` (dòng đầu `dieu-phoi: khởi động sẵn (hook UserPromptSubmit, …)`). Prompt khác → im lặng. Không gọi mạng, không ghi sự kiện, luôn exit 0 (lỗi → `hook-errors.log`). Đo: ~40 ms cả tiến trình node, < 5 ms phần xử lý. Lý do: lệnh shell đầu tiên của phiên Claude Code trong repo lớn có thể treo 6–11 phút trước khi có shell snapshot; trước đây `doctor; new-id` nằm đúng trên đường găng của cổng 1.
27
+ - Lệnh mới `dieu-phoi start [--json]`: dự phòng khi hook không chạy — một lệnh (~50 ms) kiểm cục bộ (hook đã cài/đăng ký, `UserPromptSubmit`, quyền run dir, config, profile, sink từ bộ nhớ đệm), sinh `root_task_id` + `step_ids`, tạo run dir, in đường dẫn brief scout. Không gọi mạng; luôn exit 0, vấn đề nằm trong `health`.
28
+ - **`doctor` thôi gọi mạng mỗi lần:** trạng thái sink lấy từ bộ nhớ đệm 24 giờ — `data/sink-check.json` (lần kiểm trực tiếp gần nhất của `doctor`, gắn với URL sink) hoặc `data/last-sync.json` (sync nền; từ 0.3.0 mang khoá `url` = sha256-8 của `sink_url`, khác URL hiện tại hoặc thiếu khoá → không dùng); không có bản nào còn hạn → kiểm trực tiếp một lần rồi lưu (kể cả khi lỗi). `doctor --net` buộc kiểm trực tiếp. Trước đây `doctor` mất 3,5–4,9 s, trong đó 3–4 s là request tới Apps Script.
29
+ - `doctor` có dòng `prompt-hook` (CẢNH BÁO khi thiếu hook `UserPromptSubmit` — chạy lại `setup`); dòng `settings` vẫn chỉ xét 3 hook cũ nên cài đặt 0.2.x không bị báo LỖI.
30
+ - Core export `isDieuPhoiPrompt`, `sinkStatusCached`, `quickHealth`, `startInfo`, `handleUserPromptSubmit`, … để CLI và hook dùng chung.
31
+
3
32
  ## 0.2.2
4
33
 
5
34
  - **Trần model hạ xuống `opus/medium`** (trước là `opus/high`). Thang tier bỏ `opus/high`: `haiku/low … sonnet/high < opus/medium`. Mọi (model, effort) cao hơn (opus `high`/`xhigh`/`max`, Fable) bị kẹp về `opus/medium` khi đọc log và profile; `--override opus/high` / `--approved opus/high` bị từ chối (không nằm trên thang, lỗi nêu trần `opus/medium`).
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.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
+ >
11
13
  > 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`.
12
14
 
13
15
  Khuyến nghị cài toàn cục để skill gọi được `dieu-phoi …` trực tiếp:
@@ -34,16 +36,19 @@ Khởi động lại Claude Code sau khi cài. Kiểm tra bằng `dieu-phoi doct
34
36
 
35
37
  | Lệnh | Việc |
36
38
  |---|---|
37
- | `setup [--dept D] [--sink-url U --sink-token T] [--skill-dir P]` | cài hook, đăng ký `PostToolUse(Agent)` + `SubagentStop` + `SessionStart(compact)` trong settings, ghi config; tạo thư mục báo cáo `~/.dieu-phoi/runs` (env `DIEU_PHOI_RUNS_DIR` để đổi; ngoài `~/.claude` vì đó là protected path) và thêm quyền `Read`/`Edit(~/.dieu-phoi/runs/**)` + `additionalDirectories` để subagent nền ghi được báo cáo (không nhân đôi, có sao lưu); gỡ luật cũ của bản dev cho `~/.claude/dieu-phoi/runs` |
38
- | `doctor [--json]` | kiểm node, hook, settings, config, profile, sink; context phiên điều phối hiện tại nếu đo được (`orch-ctx`); thư mục `~/.dieu-phoi/runs` + quyền của nó, không còn luật cũ (`run-permissions`); `--json` có `runs_dir` |
39
- | `verdict <agent_id\|last> <pass\|fail\|timeout> [--verify tests\|lint\|review\|judgment\|none] [--reason short-kebab]` | nghiệm thu tầng agent (`by=agent`). `last` = agent hoàn thành gần nhất; từ chối (liệt kê ứng viên) nếu trong 30 phút qua có > 1 agent xong mà chưa có verdict. Được viết tắt agent_id nếu không trùng |
39
+ | `setup [--dept D] [--sink-url U --sink-token T] [--skill-dir P]` | cài hook, đăng ký `PostToolUse(Agent)` + `SubagentStop` + `SessionStart(compact)` + `UserPromptSubmit` trong settings, ghi config; tạo thư mục báo cáo `~/.dieu-phoi/runs` (env `DIEU_PHOI_RUNS_DIR` để đổi; ngoài `~/.claude` vì đó là protected path) và thêm quyền `Read`/`Edit(~/.dieu-phoi/runs/**)` + `additionalDirectories` để subagent nền ghi được báo cáo (không nhân đôi, có sao lưu); gỡ luật cũ của bản dev cho `~/.claude/dieu-phoi/runs` |
40
+ | `doctor [--net] [--json]` | kiểm node, hook, settings (+ `prompt-hook`), config, profile, sink (từ bộ nhớ đệm 24 giờ; `--net` kiểm trực tiếp); context phiên điều phối hiện tại nếu đo được (`orch-ctx`); thư mục `~/.dieu-phoi/runs` + quyền của nó, không còn luật cũ (`run-permissions`); `--json` có `runs_dir` |
41
+ | `verdict <agent_id\|last> <pass\|fail\|timeout\|aborted> [--verify tests\|lint\|review\|judgment\|none] [--reason short-kebab]` | nghiệm thu tầng agent (`by=agent`). `aborted` (0.4.0) = dừng vì usage limit/quota: không pass/fail, không leo thang; hook tự ghi khi bắt được. `last` = agent hoàn thành gần nhất; từ chối (liệt kê ứng viên) nếu trong 30 phút qua có > 1 agent xong mà chưa có verdict. Được viết tắt agent_id nếu không trùng |
40
42
  | `verdict --task <task_id> <accept\|reject> [--cause quality\|scope] [--round n] [--reason R]` | nghiệm thu của **người dùng** (`by=user`). `reject` bắt buộc `--cause`: `quality` = làm sai (tính là fail), `scope` = yêu cầu đổi/thiếu (không tính cho model). `--round` mặc định tự tăng |
43
+ | `verdict --root <root_task_id> accept [--steps a,b]` | (0.4.0) người dùng nhận một lúc mọi bước đã giao exec của kế hoạch đã duyệt mà chưa có verdict người dùng |
44
+ | `discard --task <task_id> [--reason R]` | (0.4.0) rút một task nhầm khỏi stats/choose/state/sink bằng sự kiện `discard` — không bao giờ sửa tay `events.jsonl` |
41
45
  | `steer <agent_id> \| --task <task_id> --kind minor\|plan\|redirect [--reason R]` | ghi **một** lần người dùng lái giữa chừng (một `steer_id`). `--task` chỉ đánh dấu agent đang chạy của task; không có agent nào đang chạy → bản ghi cấp task. `plan`/`redirect` trước verdict loại lượt đó khỏi tỉ lệ của tier (chi phí vẫn tính); steer không xoá fail đã ghi |
42
46
  | `stats [--type T] [--json]` | các ô `task_type × model/effort` theo "Thước đo" của `CONTRACT.md`: n, n_excluded, accept_rate, agent_pass_rate, chi phí/thời gian mỗi task (TB, p50, p75); bảng steer theo task_type; tóm tắt điều phối (`orchestration`: số phiên, context p50/max, chi phí điều phối vs subagent, `orch_share`) |
43
47
  | `choose --type T --score N [--urgency U] [--reversible y\|n] [--verifiable y\|n] [--role R] [--task ID] [--override M/E \| --approved M/E] [--skill-dir P] [--seed S] [--json]` | đề xuất model + effort kèm lý do, `action`, `role`, ước tính chi phí/thời gian. `--override` cần `--task`. Kèm `orch_ctx_tokens`, `orch_ctx_growth`, `orch_ctx_over` (như `ctx`); vượt trần mềm → thêm một dòng cảnh báo |
44
48
  | `ctx [--session <id>] [--json]` | context phiên điều phối: `ctx_tokens` (request cuối), `ctx_baseline` (request đầu: system prompt + tools), `ctx_growth`, chi phí cộng dồn `cum_usd`, số lần đã nén `compactions`; `over` = `ctx_growth` > `orch_ctx_soft_cap` (profile, mặc định 60000 token tăng, chỉ được hạ). Session mặc định = `$CLAUDE_CODE_SESSION_ID`. JSON: `session_id, ctx_tokens, ctx_baseline, ctx_growth, cum_usd, compactions, soft_cap, over, advice`. Vượt trần → khuyên gõ `/compact` (trạng thái điều phối được nạp lại tự động). Đọc tăng dần qua cache `data/orch/`. Không đo được → exit 0, các số `null`, `over: false` |
45
49
  | `state [--session <id>] [--json]` | trạng thái điều phối của phiên dựng lại chỉ từ log: mỗi task — cổng (1/2/3/xong), vòng, tier, các lượt (agent, role, try, model/effort, running/done/pass/fail/timeout), verdict người dùng, steer cuối, thư mục chạy; nhóm theo kế hoạch đã duyệt (tier đã duyệt, bước "chưa giao", thư mục chạy theo root). Hook `SessionStart(compact)` tự nạp bản này vào ngữ cảnh sau `/compact` hoặc auto-compact. Bản chữ ≤ 8.000 ký tự |
46
50
  | `plan-table --file <steps.json> [--json] [--approve --root <root_task_id>]` | bảng phân công tiếng Việt (Bước \| Model/effort \| Rubric: điểm · kiểm chứng · đảo ngược \| Lý do \| Ước tính) + tổng chi phí + thời gian đường găng. Bước lỗi (override sai) hiện \"từ chối: …\", các bước khác vẫn hiện. Sau khi người dùng duyệt: `--approve --root R` ghi kế hoạch đã duyệt vào `data/plans/R.json` (cục bộ; `approval` = số lần duyệt, duyệt lại = +1, lần cũ vào `history`) và in thêm "Đã ghi kế hoạch đã duyệt (root R, lần duyệt n)". Từ chối (không ghi gì) khi bước thiếu `task_id`, `task_id` sai định dạng, `task_id` lặp, hoặc trùng `--root`; `--root` phải đúng 8 ký tự `a-z0-9` |
51
+ | `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` |
47
52
  | `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 |
48
53
  | `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 |
49
54
  | `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`) |
@@ -52,7 +57,7 @@ Khởi động lại Claude Code sau khi cài. Kiểm tra bằng `dieu-phoi doct
52
57
  ### `choose`
53
58
 
54
59
  1. **Luật an toàn** (cố định trong core): trần `opus/medium` (opus/high, xhigh, max, Fable bị kẹp về opus/medium; `--override opus/high` bị từ chối). Sàn: score ≥ 9 → opus/medium; `--verifiable n` → tối thiểu opus/medium; `--reversible n` → tối thiểu opus/medium (role exec). Score ≥ 9, `--verifiable n`, `--reversible n` hoặc `urgent` → không thăm dò xuống tier thấp. Sàn được ghi rõ trong `reason`.
55
- 2. **Leo thang** với `--task <task_id>`: fail 1 lần → cùng tier (làm lại kèm lỗi); fail 2 lần cùng tier → lên một tier; tier kế vượt opus/medium (role exec) → **không tự lập lại kế hoạch**, trả `action: "ask_user"` kèm `fails` và `tried` để hỏi người dùng. Fail = agent `fail|timeout` hoặc người dùng `reject --cause quality`. Lượt bị steer `plan`/`redirect` không tính.
60
+ 2. **Leo thang** với `--task <task_id>`: fail 1 lần → cùng tier (làm lại kèm lỗi); fail 2 lần cùng tier → lên một tier; tier kế vượt opus/medium (role exec) → **không tự lập lại kế hoạch**, trả `action: "ask_user"` kèm `fails` và `tried` để hỏi người dùng. Fail = agent `fail|timeout` hoặc người dùng `reject --cause quality`; mỗi verdict là một kết quả, nên làm lại bằng `SendMessage` cho cùng agent cũng được đếm (0.4.0); `aborted` không bao giờ tính. Lượt bị steer `plan`/`redirect` không tính.
56
61
  3. **Prior** từ `<skill-dir>/profile/profile.json` (không thấy thì dùng bản sao dựng sẵn của `skill/profile/profile.json` v1.0.0). `profile_source` trong kết quả cho biết đang dùng file nào (`builtin` hoặc đường dẫn).
57
62
  4. **Thompson sampling** khi ô của prior có ≥ 5 task đã xong + có verdict: rút p ~ Beta(1+pass, 1+fail) cho từng tier đủ dữ liệu, điểm = E[cost]/p + λ·E[time_phút]/p (urgent dùng p75 thời gian), chọn điểm thấp nhất. Tier có p75 vượt `time_cap_min` của loại task bị loại.
58
63
  5. **Thăm dò** một tier thấp hơn với xác suất `explore_rate` (mặc định 0.1) khi luật an toàn cho phép.
@@ -84,7 +89,7 @@ Kết quả `--json`: `{"action":"dispatch","model":"sonnet","model_id":"claude-
84
89
 
85
90
  ## Dữ liệu
86
91
 
87
- - Sự kiện: `~/.claude/dieu-phoi/data/events.jsonl` (`dispatch`, `complete`, `verdict`, `steer`, `override`; `schema: 1`), xem `CONTRACT.md`. File chỉ ghi nối (append-only), không bao giờ viết lại. Tiến độ đẩy lên sink nằm ở `data/sync-offset.json` (byte offset sau dòng cuối đã đẩy); trạng thái segment từng agent ở `data/segments/<agent_id>.json`.
92
+ - Sự kiện: `~/.claude/dieu-phoi/data/events.jsonl` (`dispatch`, `complete`, `verdict`, `steer`, `override`, `discard`; `schema: 1`), xem `CONTRACT.md`. File chỉ ghi nối (append-only), không bao giờ viết lại. Tiến độ đẩy lên sink nằm ở `data/sync-offset.json` (byte offset sau dòng cuối đã đẩy); trạng thái segment từng agent ở `data/segments/<agent_id>.json`.
88
93
  - Lỗi của hook: `~/.claude/dieu-phoi/hook-errors.log`.
89
94
  - Subagent nội bộ của Claude Code (không có transcript, không qua Agent tool) không được ghi; số lần bỏ qua ở `data/skipped-internal.json`. Log cũ của 0.1.0 có các `complete` rỗng loại này — `stats`/`retro` tự bỏ qua, không cần sửa file (từ 0.1.1).
90
95
  - Người dùng chỉ lưu dạng sha256 rút gọn 8 ký tự (`git config user.email`, không có thì tên tài khoản máy). Phiên: `session` = sha256-8; từ 0.2.0 `dispatch` có thêm `session_id` thô (mã phiên, không phải nội dung) để nối với `dieu-phoi ctx`.
package/bin/dieu-phoi.cjs CHANGED
@@ -10,11 +10,17 @@ Cách dùng: dieu-phoi <lệnh> [tuỳ chọn]
10
10
 
11
11
  setup [--dept D] [--sink-url U --sink-token T] [--skill-dir P]
12
12
  cài hook vào ~/.claude/dieu-phoi/bin, đăng ký trong settings.json
13
- doctor kiểm hook, node, config, sink
14
- verdict <agent_id|last> <pass|fail|timeout> [--verify tests|lint|review|judgment|none] [--reason short-kebab]
15
- nghiệm thu tầng agent (by=agent)
13
+ doctor [--net] kiểm hook, node, config, sink (sink lấy từ bộ nhớ đệm 24 giờ; --net kiểm trực tiếp)
14
+ start [--json] khởi động cổng 1 trong một lệnh (~150 ms, không gọi mạng): kiểm cục bộ, task_id gốc,
15
+ tạo run dir, đường dẫn brief scout — dùng khi hook UserPromptSubmit không chạy
16
+ verdict <agent_id|last> <pass|fail|timeout|aborted> [--verify tests|lint|review|judgment|none] [--reason short-kebab]
17
+ nghiệm thu tầng agent (by=agent); aborted = dừng vì hết quota/usage limit (không phải fail)
16
18
  verdict --task <task_id> <accept|reject> [--cause quality|scope] [--round n] [--reason R]
17
19
  nghiệm thu của người dùng (by=user); reject bắt buộc --cause
20
+ verdict --root <root_task_id> accept [--steps a,b]
21
+ người dùng nhận mọi bước đã giao exec của kế hoạch đã duyệt chưa có verdict người dùng
22
+ discard --task <task_id> [--reason R]
23
+ loại một task nhầm khỏi thống kê/choose/state (ghi sự kiện discard, không sửa log)
18
24
  steer <agent_id> --kind minor|plan|redirect [--reason R]
19
25
  steer --task <task_id> --kind minor|plan|redirect [--reason R]
20
26
  ghi một lần người dùng lái giữa chừng (--task: chỉ các agent đang chạy của task)
@@ -63,8 +69,10 @@ async function main(argv) {
63
69
  const table = {
64
70
  setup: c.cmdSetup,
65
71
  doctor: c.cmdDoctor,
72
+ start: c.cmdStart,
66
73
  verdict: c.cmdVerdict,
67
74
  steer: c.cmdSteer,
75
+ discard: c.cmdDiscard,
68
76
  'plan-table': c.cmdPlanTable,
69
77
  'new-id': c.cmdNewId,
70
78
  stats: c.cmdStats,
package/hook.cjs CHANGED
@@ -4,10 +4,11 @@
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) and SubagentStop. It must stay
7
+ * and run by Claude Code on PostToolUse(Agent), SubagentStop, SessionStart(compact) and UserPromptSubmit. It must stay
8
8
  * self-contained: Node built-ins only, no requires of other package files.
9
9
  *
10
- * Hook rules: never write to stdout, always exit 0, no synchronous network.
10
+ * Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit, always exit 0,
11
+ * no network.
11
12
  * Errors go to <home>/hook-errors.log.
12
13
  *
13
14
  * node hook.cjs -> read hook payload from stdin, append event
@@ -19,7 +20,7 @@ const path = require('path');
19
20
  const os = require('os');
20
21
  const crypto = require('crypto');
21
22
 
22
- const CORE_VERSION = '0.2.2';
23
+ const CORE_VERSION = '0.4.0';
23
24
  const SCHEMA = 1;
24
25
  const TASK_TYPES = [
25
26
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -124,6 +125,7 @@ function pathsFor(home) {
124
125
  syncOffset: path.join(data, 'sync-offset.json'),
125
126
  syncSpawn: path.join(data, 'sync-spawn.json'),
126
127
  lastSync: path.join(data, 'last-sync.json'),
128
+ sinkCheck: path.join(data, 'sink-check.json'),
127
129
  syncRejected: path.join(data, 'sync-rejected.jsonl'),
128
130
  lock: path.join(data, 'sync.lock'),
129
131
  config: path.join(home, 'config.json'),
@@ -735,10 +737,30 @@ function findSessionTranscript(claudeDir, sessionId) {
735
737
 
736
738
  // ------------------------------------------------------------------ events (append-only)
737
739
 
738
- /** Append one event. events.jsonl is append-only: never rewritten, sync progress lives elsewhere. */
740
+ /**
741
+ * Append one event. events.jsonl is append-only: never rewritten, sync progress lives elsewhere.
742
+ * Core 0.4.0: one O_APPEND write of the whole line (hooks of parallel agents may append at the same time:
743
+ * an O_APPEND write lands as one unit, so lines never interleave). If the file does not
744
+ * end with '\n' (a writer died mid-line), a '\n' is written first so the torn fragment cannot swallow this
745
+ * event — readers skip the fragment as one unparsable line. Withdrawing a task = `dieu-phoi discard`, never an
746
+ * edit of this file.
747
+ */
739
748
  function appendEvent(ctx, ev) {
740
749
  fs.mkdirSync(ctx.paths.data, { recursive: true });
741
- fs.appendFileSync(ctx.paths.events, `${JSON.stringify(ev)}\n`);
750
+ const fd = fs.openSync(ctx.paths.events, 'a+');
751
+ try {
752
+ let lead = '';
753
+ const size = fs.fstatSync(fd).size;
754
+ if (size > 0) {
755
+ const last = Buffer.alloc(1);
756
+ if (fs.readSync(fd, last, 0, 1, size - 1) === 1 && last[0] !== 0x0a) lead = '\n';
757
+ }
758
+ const buf = Buffer.from(`${lead}${JSON.stringify(ev)}\n`, 'utf8');
759
+ let off = 0;
760
+ while (off < buf.length) off += fs.writeSync(fd, buf, off, buf.length - off);
761
+ } finally {
762
+ fs.closeSync(fd);
763
+ }
742
764
  }
743
765
 
744
766
  function readEventsBuffer(ctx) {
@@ -857,7 +879,10 @@ function verdictLayer(ev) {
857
879
  const by = ev.by === 'user' ? 'user' : 'agent';
858
880
  let cause = ev.cause || null;
859
881
  if (by === 'user' && ev.outcome === 'reject' && cause !== 'scope') cause = 'quality';
860
- return { by, outcome: ev.outcome, cause };
882
+ // legacy quota pause recorded as timeout → aborted (same reading as the stats)
883
+ const outcome = by === 'agent' && ev.outcome === 'timeout' && /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/.test(String(ev.reason_code || ''))
884
+ ? 'aborted' : ev.outcome;
885
+ return { by, outcome, cause };
861
886
  }
862
887
 
863
888
  function dispatchInSession(ev, sessionId) {
@@ -891,7 +916,9 @@ function buildSessionState(events, sessionId, opts) {
891
916
  const runs = toPosix(o.runsDir || runsDir());
892
917
  const taskIds = [];
893
918
  const seenTask = new Set();
919
+ const discarded = new Set(events.filter((ev) => ev && ev.kind === 'discard' && ev.task_id).map((ev) => ev.task_id));
894
920
  for (const ev of events) {
921
+ if (ev && discarded.has(ev.task_id)) continue;
895
922
  if (dispatchInSession(ev, sessionId) && typeof ev.task_id === 'string' && ev.task_id && !seenTask.has(ev.task_id)) {
896
923
  seenTask.add(ev.task_id);
897
924
  taskIds.push(ev.task_id);
@@ -906,7 +933,7 @@ function buildSessionState(events, sessionId, opts) {
906
933
  for (const s of p.steps) if (s && s.task_id && !stepOf.has(s.task_id)) stepOf.set(s.task_id, { plan: p, step: s });
907
934
  }
908
935
  for (const id of [...planOfRoot.keys(), ...stepOf.keys()]) {
909
- if (!seenTask.has(id)) { seenTask.add(id); taskIds.push(id); }
936
+ if (!seenTask.has(id) && !discarded.has(id)) { seenTask.add(id); taskIds.push(id); }
910
937
  }
911
938
  const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
912
939
  events.forEach((ev, i) => {
@@ -923,7 +950,7 @@ function buildSessionState(events, sessionId, opts) {
923
950
  const agentVerdicts = new Map();
924
951
  for (const ev of events) {
925
952
  if (!ev || typeof ev !== 'object') continue;
926
- if (ev.kind === 'complete' && agentTask.has(ev.agent_id)) completes.set(ev.agent_id, (completes.get(ev.agent_id) || 0) + 1);
953
+ if (ev.kind === 'complete' && agentTask.has(ev.agent_id)) completes.set(ev.agent_id, Math.max(completes.get(ev.agent_id) || 1, evMs(ev) || 1));
927
954
  if (ev.kind === 'verdict') {
928
955
  const v = verdictLayer(ev);
929
956
  if (v.by === 'user') {
@@ -968,7 +995,9 @@ function buildSessionState(events, sessionId, opts) {
968
995
  const vs = (agentVerdicts.get(d.agent_id) || []).slice().sort(byTime);
969
996
  const last = vs[vs.length - 1];
970
997
  let status;
971
- if (last) status = last.outcome;
998
+ // aborted (quota pause) then resumed and completed again, no verdict yet → done (waiting for a verdict)
999
+ if (last && last.outcome === 'aborted' && (completes.get(d.agent_id) || 0) > evMs(last)) status = 'done';
1000
+ else if (last) status = last.outcome;
972
1001
  else status = completes.get(d.agent_id) ? 'done' : 'running';
973
1002
  return {
974
1003
  agent_id: d.agent_id, role: d.role || 'exec', try: d.try === undefined ? null : d.try,
@@ -1140,6 +1169,182 @@ function handleSessionStart(p, ctx) {
1140
1169
  };
1141
1170
  }
1142
1171
 
1172
+ // ------------------------------------------------------------------ fast start (UserPromptSubmit + `start`)
1173
+
1174
+ const SINK_CACHE_TTL_MS = 24 * 3600 * 1000;
1175
+ const START_PREFIX = 'dieu-phoi: khởi động sẵn (hook UserPromptSubmit, không cần chạy shell trước scout):';
1176
+
1177
+ /**
1178
+ * Prompt that starts a NEW dieu-phoi orchestration. Conservative: the trigger must OPEN the prompt
1179
+ * (after trimming) — `/dieu-phoi`, "điều phối" (any case, with or without diacritics, optionally
1180
+ * followed by ":"), or one of the skill's distinctive phrases ("spawn subagents", "orchestrate
1181
+ * subagents", "chia việc cho agent", "giao cho subagent"). "điều phối" elsewhere in the prompt never
1182
+ * fires. Retro / state / stats / CLI-style requests and "điều phối viên …" never fire (not a gate 1).
1183
+ */
1184
+ const PROMPT_START_RE = /^(?:\/dieu-phoi|dieu phoi|spawn (?:\d+ )?sub-?agents?|orchestrate sub-?agents?|chia viec cho (?:cac )?(?:sub-?)?agents?|giao (?:viec )?cho (?:cac )?sub-?agents?)(?=$|[\s:,.!?])/;
1185
+ const PROMPT_REST_EXCLUDE_RE = /^[\s:,.!?-]*(?:vien\b|retro|state\b|stats?\b|status\b|trang thai|thong ke|ctx\b|doctor|setup\b|start\b|sync\b|uninstall|new-id|choose\b|plan-table|verdict|steer\b)/;
1186
+ const PROMPT_ANY_EXCLUDE_RE = /\b(?:retro|thong ke)\b/;
1187
+
1188
+ function foldPrompt(s) {
1189
+ return String(s).toLowerCase().replace(/đ/g, 'd').normalize('NFD').replace(/[̀-ͯ]/g, '')
1190
+ .replace(/\s+/g, ' ');
1191
+ }
1192
+
1193
+ function isDieuPhoiPrompt(prompt) {
1194
+ if (typeof prompt !== 'string' || !prompt) return false;
1195
+ const t = foldPrompt(prompt.trimStart().slice(0, 300)).trim();
1196
+ const m = PROMPT_START_RE.exec(t);
1197
+ if (!m) return false;
1198
+ if (PROMPT_REST_EXCLUDE_RE.test(t.slice(m[0].length))) return false;
1199
+ return !PROMPT_ANY_EXCLUDE_RE.test(t);
1200
+ }
1201
+
1202
+ function sinkUrlKey(cfg) {
1203
+ return cfg && cfg.sink_url ? sha8(String(cfg.sink_url)) : null;
1204
+ }
1205
+
1206
+ /**
1207
+ * Sink health without a network call: newest of data/sink-check.json (doctor's live check, same sink
1208
+ * URL) and data/last-sync.json (every background sync) within 24 h.
1209
+ * Returns { state: 'none'|'ok'|'fail'|'unknown', ts, source, detail }.
1210
+ */
1211
+ function sinkStatusCached(ctx, nowMs) {
1212
+ const cfg = ctx.config || {};
1213
+ if (!cfg.sink_url || !cfg.sink_token) return { state: 'none', ts: null, source: null, detail: 'chưa cấu hình (chỉ lưu cục bộ)' };
1214
+ const now = nowMs || Date.now();
1215
+ const cands = [];
1216
+ const chk = readJsonFile(ctx.paths.sinkCheck, null);
1217
+ if (chk && chk.url === sinkUrlKey(cfg) && Date.parse(chk.ts)) {
1218
+ cands.push({ state: chk.ok ? 'ok' : 'fail', ts: chk.ts, source: 'doctor', detail: chk.detail || null });
1219
+ }
1220
+ const ls = readJsonFile(ctx.paths.lastSync, null);
1221
+ // last-sync is only trusted for the sink it was written for (sink_url changed → stale)
1222
+ if (ls && ls.url === sinkUrlKey(cfg) && Date.parse(ls.ts)) {
1223
+ cands.push({ state: ls.ok ? 'ok' : 'fail', ts: ls.ts, source: 'sync', detail: ls.ok ? `đẩy ${ls.sent || 0} bản ghi` : (ls.error || 'lỗi') });
1224
+ }
1225
+ const fresh = cands.filter((c) => now - Date.parse(c.ts) <= SINK_CACHE_TTL_MS && Date.parse(c.ts) <= now + 60000)
1226
+ .sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts));
1227
+ if (fresh.length) return fresh[0];
1228
+ return { state: 'unknown', ts: null, source: null, detail: 'chưa kiểm trong 24 giờ qua (`dieu-phoi doctor --net`)' };
1229
+ }
1230
+
1231
+ /** Absolute path of the ready-made scout brief of the installed skill, or null. */
1232
+ function scoutBriefPath(ctx) {
1233
+ const sd = ctx.config && ctx.config.skill_dir;
1234
+ if (!sd) return null;
1235
+ const f = path.join(sd, 'references', 'scout-brief.md');
1236
+ return fs.existsSync(f) ? toPosix(f) : null;
1237
+ }
1238
+
1239
+ const ORCH_LESSON_RE = /\[(?:orch|orchestrator)\]/i;
1240
+ const ORCH_LESSONS_MAX = 5;
1241
+ const ORCH_LESSON_CHARS = 240;
1242
+ const LESSONS_MAX_BYTES = 256 * 1024;
1243
+
1244
+ /**
1245
+ * Lessons addressed to the orchestrator (core 0.4.0): list lines of <skill_dir>/profile/lessons.md that carry the
1246
+ * tag `[orch]` (legacy `[orchestrator]`), the last ORCH_LESSONS_MAX of them, each cut to ORCH_LESSON_CHARS chars.
1247
+ * The orchestrator never reads lessons.md itself; `start` and the UserPromptSubmit block surface these lines.
1248
+ */
1249
+ function orchLessons(ctx) {
1250
+ const sd = ctx.config && ctx.config.skill_dir;
1251
+ if (!sd) return [];
1252
+ let txt;
1253
+ try {
1254
+ const f = path.join(sd, 'profile', 'lessons.md');
1255
+ if (fs.statSync(f).size > LESSONS_MAX_BYTES) return [];
1256
+ txt = fs.readFileSync(f, 'utf8');
1257
+ } catch (_) { return []; }
1258
+ const out = txt.split(/\r?\n/).map((l) => l.trim())
1259
+ .filter((l) => /^[-*] /.test(l) && ORCH_LESSON_RE.test(l))
1260
+ .map((l) => l.replace(/^[-*] +/, ''))
1261
+ .map((l) => (l.length > ORCH_LESSON_CHARS ? `${l.slice(0, ORCH_LESSON_CHARS - 1)}…` : l));
1262
+ return out.slice(-ORCH_LESSONS_MAX);
1263
+ }
1264
+
1265
+ /** Local, network-free health: config, profile, run dir, cached sink status. */
1266
+ function quickHealth(ctx, runs) {
1267
+ const problems = [];
1268
+ if (!fs.existsSync(ctx.paths.config)) problems.push('chưa có config — chạy `dieu-phoi setup --dept <phòng>`');
1269
+ const sd = ctx.config.skill_dir;
1270
+ if (!sd || !fs.existsSync(path.join(sd, 'profile', 'profile.json'))) problems.push('không thấy profile của skill — `choose` dùng priors mặc định');
1271
+ if (runs && !fs.existsSync(runs)) problems.push(`chưa có thư mục ${toPosix(runs)}`);
1272
+ const sink = sinkStatusCached(ctx);
1273
+ if (sink.state === 'fail') problems.push(`sink lỗi lần cuối (${sink.ts}): ${sink.detail}`);
1274
+ return { ok: !problems.length, problems, sink };
1275
+ }
1276
+
1277
+ /** Fresh root task id + its run dir, created. Retries on the (astronomically rare) existing dir. */
1278
+ function startRun() {
1279
+ const runs = runsDir();
1280
+ fs.mkdirSync(runs, { recursive: true });
1281
+ for (let i = 0; i < 5; i++) {
1282
+ const id = newTaskId();
1283
+ const dir = path.join(runs, id);
1284
+ try {
1285
+ fs.mkdirSync(dir);
1286
+ return { id, run_dir: toPosix(dir), runs };
1287
+ } catch (e) {
1288
+ if (e.code !== 'EEXIST') throw e;
1289
+ }
1290
+ }
1291
+ throw new Error('không tạo được run dir mới');
1292
+ }
1293
+
1294
+ function healthLine(h) {
1295
+ const sink = h.sink.state === 'ok' ? `sink ok (${h.sink.source} ${h.sink.ts})`
1296
+ : h.sink.state === 'none' ? 'sink chưa cấu hình' : `sink ${h.sink.state === 'fail' ? 'LỖI' : 'chưa rõ'}`;
1297
+ return h.ok ? `ok — ${sink}` : `CẢNH BÁO — ${h.problems.join('; ')}${h.sink.state === 'fail' ? '' : ` — ${sink}`}`;
1298
+ }
1299
+
1300
+ const START_STEP_IDS = 10;
1301
+
1302
+ /** Spare task_ids for the plan steps, distinct from the root, so the scout needs no `new-id` shell call. */
1303
+ function stepIds(root, n) {
1304
+ const ids = new Set();
1305
+ while (ids.size < n) { const id = newTaskId(); if (id !== root) ids.add(id); }
1306
+ return Array.from(ids);
1307
+ }
1308
+
1309
+ function startInfo(ctx) {
1310
+ const run = startRun();
1311
+ const health = quickHealth(ctx, run.runs);
1312
+ return {
1313
+ id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx),
1314
+ orch_lessons: orchLessons(ctx),
1315
+ };
1316
+ }
1317
+
1318
+ function orchLessonLines(info) {
1319
+ const ls = (info && info.orch_lessons) || [];
1320
+ return ls.length ? ['bài học cho điều phối viên (profile/lessons.md, [orch]):'].concat(ls.map((l) => ` - ${l}`)) : [];
1321
+ }
1322
+
1323
+ function formatStartBlock(info) {
1324
+ return [
1325
+ START_PREFIX,
1326
+ `root_task_id: ${info.id}`,
1327
+ `run_dir: ${info.run_dir}/ (đã tạo)`,
1328
+ `step_ids: ${info.step_ids.join(' ')}`,
1329
+ `health: ${healthLine(info.health)}`,
1330
+ `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
1331
+ ...orchLessonLines(info),
1332
+ 'Dùng khối này cho yêu cầu MỚI ở cổng 1 (không dùng nếu đang giữa một lần điều phối). Không chạy `dieu-phoi doctor` / `new-id` / mkdir: lượt đầu tiên là Agent scout + câu hỏi cổng 1 cho người dùng.',
1333
+ ].join('\n');
1334
+ }
1335
+
1336
+ /** UserPromptSubmit: on a dieu-phoi prompt, pre-create the run and inject id/run dir/health/brief. */
1337
+ function handleUserPromptSubmit(p, ctx) {
1338
+ if (!isDieuPhoiPrompt(p.prompt)) return null;
1339
+ const info = startInfo(ctx);
1340
+ return {
1341
+ stdout: JSON.stringify({
1342
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: formatStartBlock(info) },
1343
+ }),
1344
+ info,
1345
+ };
1346
+ }
1347
+
1143
1348
  // ------------------------------------------------------------------ hook handlers
1144
1349
 
1145
1350
  function extractAgentId(tr) {
@@ -1282,9 +1487,50 @@ function handleSubagentStop(p, ctx) {
1282
1487
  });
1283
1488
  }
1284
1489
  appendEvent(ctx, ev);
1490
+ // Core 0.4.0: a segment that ended on a usage-limit / quota pause gets an automatic agent verdict
1491
+ // `aborted` (never a fail, never counted toward escalation); the real verdict comes after the resume.
1492
+ const why = exists ? abortReasonOf(lines) : null;
1493
+ if (why) {
1494
+ appendEvent(ctx, Object.assign({
1495
+ schema: SCHEMA, kind: 'verdict', ts: nowIso(), agent_id: agentId, task_id: t.task_id, by: 'agent', outcome: 'aborted',
1496
+ verify: 'none', reason_code: `auto-${why}`, cause: null, round: null,
1497
+ }, commonFields(ctx)));
1498
+ }
1285
1499
  return ev;
1286
1500
  }
1287
1501
 
1502
+ // Exact marker an agent puts on the FIRST line of its final report when it stops at a safe point because of a quota
1503
+ // / usage limit (brief template A, SKILL.md). Nothing looser: "STATUS: partial" + "quota" can be a real failure
1504
+ // ("Drive storage quota exceeded", a half-built rate limiter).
1505
+ const QUOTA_PAUSE_MARKER = 'STATUS: partial — quota-pause';
1506
+ // Claude Code's own usage-limit stop text on its synthetic API-error line ("You've hit your session limit · resets 11am").
1507
+ const CC_LIMIT_TEXT_RE = /^You['’]ve hit your (?:[\w-]+ )?limit\b/;
1508
+
1509
+ /**
1510
+ * Why the agent's last segment stopped on a usage limit, or null. Reads only the LAST assistant line:
1511
+ * - Claude Code's synthetic API-error line (`model:"<synthetic>"` + `isApiErrorMessage:true`) whose `error` is
1512
+ * "rate_limit", or HTTP 429, or whose text starts "You've hit your … limit" → "usage-limit";
1513
+ * - the agent's final report whose first line starts with the exact marker QUOTA_PAUSE_MARKER → "quota-pause".
1514
+ * Only a short label is returned; no text is stored.
1515
+ */
1516
+ function abortReasonOf(lines) {
1517
+ let last = null;
1518
+ for (let i = lines.length - 1; i >= 0; i--) {
1519
+ const l = lines[i];
1520
+ if (l && l.type === 'assistant' && l.message) { last = l; break; }
1521
+ }
1522
+ if (!last) return null;
1523
+ const content = Array.isArray(last.message.content) ? last.message.content : [];
1524
+ const text = content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n').slice(0, 2000);
1525
+ if (last.isApiErrorMessage === true) {
1526
+ if (last.message.model !== '<synthetic>') return null;
1527
+ if (last.error === 'rate_limit' || last.apiErrorStatus === 429 || CC_LIMIT_TEXT_RE.test(text.trim())) return 'usage-limit';
1528
+ return null;
1529
+ }
1530
+ if (text.trimStart().split('\n')[0].trim().startsWith(QUOTA_PAUSE_MARKER)) return 'quota-pause';
1531
+ return null;
1532
+ }
1533
+
1288
1534
  function maybeSpawnSync(ctx) {
1289
1535
  if (!ctx.config.sink_url || !ctx.config.sink_token) return false;
1290
1536
  if (process.env.DIEU_PHOI_NO_SPAWN === '1') return false;
@@ -1337,6 +1583,7 @@ function runHook(opts) {
1337
1583
  let ev = null;
1338
1584
  const name = p.hook_event_name;
1339
1585
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
1586
+ if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
1340
1587
  if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
1341
1588
  else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
1342
1589
  if (ev) {
@@ -1477,7 +1724,7 @@ async function runSync(ctx, opts) {
1477
1724
  } finally {
1478
1725
  try {
1479
1726
  writeFileAtomic(ctx.paths.lastSync, `${JSON.stringify({
1480
- ts: nowIso(), ok: result.ok, sent: result.sent, error: result.error,
1727
+ ts: nowIso(), ok: result.ok, sent: result.sent, error: result.error, url: sinkUrlKey(cfg),
1481
1728
  })}\n`);
1482
1729
  } catch (_) { /* best effort */ }
1483
1730
  try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
@@ -1488,7 +1735,7 @@ async function runSync(ctx, opts) {
1488
1735
  // ------------------------------------------------------------------ exports / main
1489
1736
 
1490
1737
  module.exports = {
1491
- runsDir, loadPlans, buildSessionState, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
1738
+ 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,
1492
1739
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
1493
1740
  sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
1494
1741
  logError, computeUserHash, userHash, commonFields, parseTag, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,