@kienntpixon/dieu-phoi 0.3.0 → 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,26 @@
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
+
3
24
  ## 0.3.0
4
25
 
5
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.
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:
@@ -36,8 +38,10 @@ Khởi động lại Claude Code sau khi cài. Kiểm tra bằng `dieu-phoi doct
36
38
  |---|---|
37
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` |
38
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` |
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 |
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 |
@@ -53,7 +57,7 @@ Khởi động lại Claude Code sau khi cài. Kiểm tra bằng `dieu-phoi doct
53
57
  ### `choose`
54
58
 
55
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`.
56
- 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.
57
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).
58
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.
59
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.
@@ -85,7 +89,7 @@ Kết quả `--json`: `{"action":"dispatch","model":"sonnet","model_id":"claude-
85
89
 
86
90
  ## Dữ liệu
87
91
 
88
- - 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`.
89
93
  - Lỗi của hook: `~/.claude/dieu-phoi/hook-errors.log`.
90
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).
91
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
@@ -13,10 +13,14 @@ Cách dùng: dieu-phoi <lệnh> [tuỳ chọn]
13
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
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
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> [--verify tests|lint|review|judgment|none] [--reason short-kebab]
17
- nghiệm thu tầng agent (by=agent)
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)
18
18
  verdict --task <task_id> <accept|reject> [--cause quality|scope] [--round n] [--reason R]
19
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)
20
24
  steer <agent_id> --kind minor|plan|redirect [--reason R]
21
25
  steer --task <task_id> --kind minor|plan|redirect [--reason R]
22
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)
@@ -68,6 +72,7 @@ async function main(argv) {
68
72
  start: c.cmdStart,
69
73
  verdict: c.cmdVerdict,
70
74
  steer: c.cmdSteer,
75
+ discard: c.cmdDiscard,
71
76
  'plan-table': c.cmdPlanTable,
72
77
  'new-id': c.cmdNewId,
73
78
  stats: c.cmdStats,
package/hook.cjs CHANGED
@@ -20,7 +20,7 @@ const path = require('path');
20
20
  const os = require('os');
21
21
  const crypto = require('crypto');
22
22
 
23
- const CORE_VERSION = '0.3.0';
23
+ const CORE_VERSION = '0.4.0';
24
24
  const SCHEMA = 1;
25
25
  const TASK_TYPES = [
26
26
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -737,10 +737,30 @@ function findSessionTranscript(claudeDir, sessionId) {
737
737
 
738
738
  // ------------------------------------------------------------------ events (append-only)
739
739
 
740
- /** 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
+ */
741
748
  function appendEvent(ctx, ev) {
742
749
  fs.mkdirSync(ctx.paths.data, { recursive: true });
743
- 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
+ }
744
764
  }
745
765
 
746
766
  function readEventsBuffer(ctx) {
@@ -859,7 +879,10 @@ function verdictLayer(ev) {
859
879
  const by = ev.by === 'user' ? 'user' : 'agent';
860
880
  let cause = ev.cause || null;
861
881
  if (by === 'user' && ev.outcome === 'reject' && cause !== 'scope') cause = 'quality';
862
- 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 };
863
886
  }
864
887
 
865
888
  function dispatchInSession(ev, sessionId) {
@@ -893,7 +916,9 @@ function buildSessionState(events, sessionId, opts) {
893
916
  const runs = toPosix(o.runsDir || runsDir());
894
917
  const taskIds = [];
895
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));
896
920
  for (const ev of events) {
921
+ if (ev && discarded.has(ev.task_id)) continue;
897
922
  if (dispatchInSession(ev, sessionId) && typeof ev.task_id === 'string' && ev.task_id && !seenTask.has(ev.task_id)) {
898
923
  seenTask.add(ev.task_id);
899
924
  taskIds.push(ev.task_id);
@@ -908,7 +933,7 @@ function buildSessionState(events, sessionId, opts) {
908
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 });
909
934
  }
910
935
  for (const id of [...planOfRoot.keys(), ...stepOf.keys()]) {
911
- if (!seenTask.has(id)) { seenTask.add(id); taskIds.push(id); }
936
+ if (!seenTask.has(id) && !discarded.has(id)) { seenTask.add(id); taskIds.push(id); }
912
937
  }
913
938
  const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
914
939
  events.forEach((ev, i) => {
@@ -925,7 +950,7 @@ function buildSessionState(events, sessionId, opts) {
925
950
  const agentVerdicts = new Map();
926
951
  for (const ev of events) {
927
952
  if (!ev || typeof ev !== 'object') continue;
928
- 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));
929
954
  if (ev.kind === 'verdict') {
930
955
  const v = verdictLayer(ev);
931
956
  if (v.by === 'user') {
@@ -970,7 +995,9 @@ function buildSessionState(events, sessionId, opts) {
970
995
  const vs = (agentVerdicts.get(d.agent_id) || []).slice().sort(byTime);
971
996
  const last = vs[vs.length - 1];
972
997
  let status;
973
- 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;
974
1001
  else status = completes.get(d.agent_id) ? 'done' : 'running';
975
1002
  return {
976
1003
  agent_id: d.agent_id, role: d.role || 'exec', try: d.try === undefined ? null : d.try,
@@ -1209,6 +1236,32 @@ function scoutBriefPath(ctx) {
1209
1236
  return fs.existsSync(f) ? toPosix(f) : null;
1210
1237
  }
1211
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
+
1212
1265
  /** Local, network-free health: config, profile, run dir, cached sink status. */
1213
1266
  function quickHealth(ctx, runs) {
1214
1267
  const problems = [];
@@ -1256,7 +1309,15 @@ function stepIds(root, n) {
1256
1309
  function startInfo(ctx) {
1257
1310
  const run = startRun();
1258
1311
  const health = quickHealth(ctx, run.runs);
1259
- return { id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx) };
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}`)) : [];
1260
1321
  }
1261
1322
 
1262
1323
  function formatStartBlock(info) {
@@ -1267,6 +1328,7 @@ function formatStartBlock(info) {
1267
1328
  `step_ids: ${info.step_ids.join(' ')}`,
1268
1329
  `health: ${healthLine(info.health)}`,
1269
1330
  `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
1331
+ ...orchLessonLines(info),
1270
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.',
1271
1333
  ].join('\n');
1272
1334
  }
@@ -1425,9 +1487,50 @@ function handleSubagentStop(p, ctx) {
1425
1487
  });
1426
1488
  }
1427
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
+ }
1428
1499
  return ev;
1429
1500
  }
1430
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
+
1431
1534
  function maybeSpawnSync(ctx) {
1432
1535
  if (!ctx.config.sink_url || !ctx.config.sink_token) return false;
1433
1536
  if (process.env.DIEU_PHOI_NO_SPAWN === '1') return false;
@@ -1632,7 +1735,7 @@ async function runSync(ctx, opts) {
1632
1735
  // ------------------------------------------------------------------ exports / main
1633
1736
 
1634
1737
  module.exports = {
1635
- runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, 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,
1636
1739
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
1637
1740
  sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
1638
1741
  logError, computeUserHash, userHash, commonFields, parseTag, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
package/lib/aggregate.cjs CHANGED
@@ -9,6 +9,12 @@ const core = require('../hook.cjs');
9
9
  const MIN_N = 5;
10
10
  const FAIL_OUTCOMES = new Set(['fail', 'timeout']);
11
11
  const HEAVY_STEERS = new Set(['plan', 'redirect']);
12
+ // core 0.4.0: an agent stopped by a usage-limit/quota pause is `aborted` — neither pass nor fail. Legacy
13
+ // rows recorded such pauses as `timeout` with a quota-ish reason_code; they are read as `aborted` too.
14
+ const ABORTED = 'aborted';
15
+ // Exact markers only (anchored): `quota-pause`, `usage-limit`, the hook's `auto-*` codes and the legacy
16
+ // `paused-for-quota-*` codes the orchestrator wrote before 0.4.0. A reason that merely mentions a rate limit stays a timeout.
17
+ const QUOTA_REASON_RE = /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/;
12
18
 
13
19
  function tsOf(ev) {
14
20
  const t = Date.parse(ev && ev.ts);
@@ -56,7 +62,33 @@ function normalizeVerdict(ev) {
56
62
  const by = ev.by === 'user' ? 'user' : 'agent';
57
63
  let cause = ev.cause || null;
58
64
  if (by === 'user' && ev.outcome === 'reject' && cause !== 'scope') cause = 'quality';
59
- return { by, outcome: ev.outcome, cause, round: Number(ev.round) || null };
65
+ let outcome = ev.outcome;
66
+ if (by === 'agent' && outcome === 'timeout' && typeof ev.reason_code === 'string' && QUOTA_REASON_RE.test(ev.reason_code)) {
67
+ outcome = ABORTED;
68
+ }
69
+ return { by, outcome, cause, round: Number(ev.round) || null };
70
+ }
71
+
72
+ /**
73
+ * Task ids withdrawn with `dieu-phoi discard --task` (kind=discard). Every event of such a task — and every
74
+ * event of an agent dispatched for it — is left out of tasks, cells and steers (CONTRACT "Sự kiện cục bộ").
75
+ */
76
+ function discardedTaskIds(events) {
77
+ const out = new Set();
78
+ for (const ev of events) if (ev && ev.kind === 'discard' && typeof ev.task_id === 'string' && ev.task_id) out.add(ev.task_id);
79
+ return out;
80
+ }
81
+
82
+ /** Events with discarded tasks (and the discard records themselves) replaced by null; order kept. */
83
+ function withoutDiscarded(events) {
84
+ const gone = discardedTaskIds(events);
85
+ const agents = new Set(events.filter((ev) => ev && ev.kind === 'dispatch' && gone.has(ev.task_id)).map((ev) => ev.agent_id));
86
+ return events.map((ev) => {
87
+ if (!ev || typeof ev !== 'object' || ev.kind === 'discard') return null;
88
+ if (gone.has(ev.task_id) || agents.has(ev.agent_id)) return null;
89
+ if (typeof ev.agent_id === 'string' && ev.agent_id.startsWith('task:') && gone.has(ev.agent_id.slice(5))) return null;
90
+ return ev;
91
+ });
60
92
  }
61
93
 
62
94
  function blank(v) {
@@ -111,7 +143,9 @@ function buildAgents(events) {
111
143
  const d = a.dispatch || {};
112
144
  const c0 = a.completes[0] || {};
113
145
  const cl = a.completes[a.completes.length - 1] || {};
114
- const v = a.verdicts.slice().sort((x, y) => tsOf(x) - tsOf(y) || x._idx - y._idx);
146
+ const vAll = a.verdicts.slice().sort((x, y) => tsOf(x) - tsOf(y) || x._idx - y._idx);
147
+ // `aborted` (quota pause) is neither pass nor fail: it never sets the outcome nor counts as a result.
148
+ const v = vAll.filter((x) => x.outcome !== ABORTED);
115
149
  const verdict = v.length ? v[v.length - 1] : null;
116
150
  const firstVerdictTs = v.length ? tsOf(v[0]) : Infinity;
117
151
  const pick = (k) => (d[k] !== undefined && d[k] !== null ? d[k] : (c0[k] !== undefined && c0[k] !== null ? c0[k] : null));
@@ -137,6 +171,9 @@ function buildAgents(events) {
137
171
  tool_uses: a.completes.length ? sumOrNull(a.completes.map((x) => x.tool_uses)) : null,
138
172
  outcome: verdict ? verdict.outcome : null,
139
173
  first_outcome: v.length ? v[0].outcome : null,
174
+ // every non-aborted agent verdict in time order: a retry by SendMessage to the same agent is a new result
175
+ verdict_seq: v.map((x) => ({ outcome: x.outcome, ts: tsOf(x), idx: x._idx })),
176
+ aborted: vAll.length > 0 && vAll[vAll.length - 1].outcome === ABORTED,
140
177
  verify: verdict ? verdict.verify : null,
141
178
  reason_code: verdict ? verdict.reason_code : null,
142
179
  steers: a.steers.map((s) => s.steer_kind).filter(Boolean),
@@ -175,16 +212,25 @@ function buildTasks(agents, userVerdicts) {
175
212
  const first = t.agents.filter((a) => a.role === 'exec').sort(byTry)[0] || null;
176
213
  const user = t.user.slice().sort((x, y) => ((x.round || 0) - (y.round || 0)) || (tsOf(x) - tsOf(y)) || (x._idx - y._idx));
177
214
 
178
- // Effective per-attempt outcome for escalation: agent verdict, overridden by a user verdict
179
- // landing on the latest attempt dispatched before it (reject/quality = fail, accept = pass).
180
- for (const a of attempts) a.eff_outcome = a.outcome === 'pass' ? 'pass' : (FAIL_OUTCOMES.has(a.outcome) ? 'fail' : null);
215
+ // Results for escalation. Each agent verdict of an attempt is one result at its tier (a retry by
216
+ // SendMessage to the same agent is a new result); a user verdict adds a result to the latest attempt
217
+ // dispatched before it (reject/quality = fail, accept = pass). eff_outcome = the attempt's latest result.
218
+ for (const a of attempts) {
219
+ a.results = (a.verdict_seq || [])
220
+ .filter((x) => x.outcome === 'pass' || FAIL_OUTCOMES.has(x.outcome))
221
+ .map((x) => ({ outcome: x.outcome === 'pass' ? 'pass' : 'fail', ts: x.ts, idx: x.idx }));
222
+ a.eff_outcome = a.results.length ? a.results[a.results.length - 1].outcome : null;
223
+ }
181
224
  for (const v of user) {
182
225
  if (v.cause === 'scope') continue;
183
226
  const vt = tsOf(v);
184
227
  let target = null;
185
228
  for (const a of attempts) if (a.ts <= vt && (!target || a.ts >= target.ts)) target = a;
186
229
  if (!target) target = attempts[attempts.length - 1] || null;
187
- if (target) target.eff_outcome = v.outcome === 'accept' ? 'pass' : 'fail';
230
+ if (target) {
231
+ target.eff_outcome = v.outcome === 'accept' ? 'pass' : 'fail';
232
+ target.results.push({ outcome: target.eff_outcome, ts: vt, idx: v._idx, user: true });
233
+ }
188
234
  }
189
235
 
190
236
  const firstUser = user[0] || null;
@@ -307,13 +353,19 @@ function canonicalCells(cells) {
307
353
  return cells.map((c) => Object.fromEntries(CANONICAL_CELL_FIELDS.map((k) => [k, c[k] === undefined ? null : c[k]])));
308
354
  }
309
355
 
310
- /** Steering per task_type, counted in distinct user actions (steer_id), not events. */
356
+ /**
357
+ * Steering per task_type, counted in distinct user actions (steer_id), not events. Core 0.4.0: only
358
+ * exec-phase steers count — a steer on a role=exec agent, or a task-level steer on a task that has an exec
359
+ * attempt. Steers on scouts/planners (gate-1 Q&A) and on verifiers go to `plan_phase_actions` and never
360
+ * touch actions / steer_rate.
361
+ */
311
362
  function buildSteers(tasks, steers, agents) {
312
363
  const typeOfTask = new Map(tasks.map((t) => [t.task_id, t.task_type]));
364
+ const execTask = new Set(tasks.filter((t) => t.first && t.task_id).map((t) => t.task_id));
313
365
  const agentById = new Map(agents.map((a) => [a.agent_id, a]));
314
366
  const by = {};
315
367
  const row = (type) => by[type] || (by[type] = {
316
- task_type: type, tasks: 0, steered_tasks: 0, actions: 0, minor: 0, plan: 0, redirect: 0, _tasks: new Set(), _ids: new Set(),
368
+ task_type: type, tasks: 0, steered_tasks: 0, actions: 0, minor: 0, plan: 0, redirect: 0, _tasks: new Set(), _ids: new Set(), _plan: new Set(),
317
369
  });
318
370
  for (const t of tasks) if (t.first) row(t.task_type).tasks++;
319
371
  for (const s of steers) {
@@ -322,6 +374,8 @@ function buildSteers(tasks, steers, agents) {
322
374
  const type = (taskId && typeOfTask.get(taskId)) || s.task_type || (a && a.task_type) || 'untagged';
323
375
  const r = row(type);
324
376
  const id = steerIdOf(s);
377
+ const execPhase = a ? a.role === 'exec' : !(taskId && typeOfTask.has(taskId) && !execTask.has(taskId));
378
+ if (!execPhase) { r._plan.add(id); continue; }
325
379
  if (r._ids.has(id)) continue;
326
380
  r._ids.add(id);
327
381
  r.actions++;
@@ -334,6 +388,7 @@ function buildSteers(tasks, steers, agents) {
334
388
  task_type: r.task_type, tasks: r.tasks, steered_tasks: steered, actions: r.actions,
335
389
  minor: r.minor, plan: r.plan, redirect: r.redirect,
336
390
  steer_rate: r.tasks ? round6(Math.min(1, steered / r.tasks)) : 0,
391
+ plan_phase_actions: Array.from(r._plan).filter((x) => !r._ids.has(x)).length,
337
392
  };
338
393
  return outRow;
339
394
  }).sort((a, b) => b.steer_rate - a.steer_rate || a.task_type.localeCompare(b.task_type));
@@ -345,7 +400,8 @@ function finiteNum(v) {
345
400
 
346
401
  /**
347
402
  * Orchestration cost (CONTRACT "Thước đo" mục 6). Sessions = distinct non-empty `session_id` over
348
- * dispatch events. Per session: ctx = max orch_ctx_tokens, usd = max orch_cum_usd, growth = max of
403
+ * dispatch events. Per session: ctx = max orch_ctx_tokens, usd = max orch_cum_usd minus orch_cum_usd of the
404
+ * session's first dispatch carrying a number (core 0.4.0; never negative), growth = max of
349
405
  * max(0, orch_ctx_tokens - orch_ctx_baseline) over dispatches carrying both, comp = max orch_compactions
350
406
  * (numbers only). compactions_total = sum of per-session comp, compactions_max = max.
351
407
  * Each agent belongs to the session of its FIRST dispatch (file order); sub_usd_total sums the
@@ -357,11 +413,14 @@ function orchestrationSummary(events) {
357
413
  for (const ev of events) {
358
414
  if (!ev || ev.kind !== 'dispatch' || typeof ev.session_id !== 'string' || !ev.session_id) continue;
359
415
  const sid = ev.session_id;
360
- if (!sessions.has(sid)) sessions.set(sid, { ctx: null, usd: null, growth: null, comp: null });
416
+ if (!sessions.has(sid)) sessions.set(sid, { ctx: null, usd: null, base: null, growth: null, comp: null });
361
417
  const s = sessions.get(sid);
362
418
  const c = finiteNum(ev.orch_ctx_tokens);
363
419
  const u = finiteNum(ev.orch_cum_usd);
364
420
  if (c !== null && (s.ctx === null || c > s.ctx)) s.ctx = c;
421
+ // core 0.4.0: orchestrator cost counts from the session's FIRST dispatch carrying a number (file order),
422
+ // not from session start — whatever the session did before it started dispatching is not orchestration.
423
+ if (u !== null && s.base === null) s.base = u;
365
424
  if (u !== null && (s.usd === null || u > s.usd)) s.usd = u;
366
425
  const k = finiteNum(ev.orch_compactions);
367
426
  if (k !== null && (s.comp === null || k > s.comp)) s.comp = k;
@@ -386,7 +445,7 @@ function orchestrationSummary(events) {
386
445
  if (s.ctx !== null) ctxs.push(s.ctx);
387
446
  if (s.growth !== null) growths.push(s.growth);
388
447
  if (s.comp !== null) comps.push(s.comp);
389
- if (s.usd !== null) orch += s.usd;
448
+ if (s.usd !== null) orch += Math.max(0, s.usd - s.base);
390
449
  }
391
450
  return {
392
451
  sessions: sessions.size,
@@ -403,7 +462,7 @@ function orchestrationSummary(events) {
403
462
  }
404
463
 
405
464
  function analyze(events, opts) {
406
- const { agents, userVerdicts, overrides, steers } = buildAgents(events);
465
+ const { agents, userVerdicts, overrides, steers } = buildAgents(withoutDiscarded(events));
407
466
  const tasks = buildTasks(agents, userVerdicts);
408
467
  const cells = buildCells(tasks, opts);
409
468
  return {
@@ -413,6 +472,6 @@ function analyze(events, opts) {
413
472
  }
414
473
 
415
474
  module.exports = {
416
- MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
475
+ MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, ABORTED, QUOTA_REASON_RE, discardedTaskIds, withoutDiscarded, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
417
476
  buildCells, buildSteers, canonicalCells, orchestrationSummary, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
418
477
  };
package/lib/choose.cjs CHANGED
@@ -19,7 +19,8 @@ const LADDER = [
19
19
  const TOP = LADDER.length - 1;
20
20
  const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
21
21
 
22
- // Built-in defaults = skill/profile/profile.json (v1.0.0); used only when no profile is found.
22
+ // Built-in defaults = skill/profile/profile.json for the fixed task types (time caps as tuned 2026-10-12: feature-impl 80,
23
+ // content-write 40, debug 55 — the old ones sat below the observed p50); used only when no profile is found.
23
24
  const DEFAULT_PRIORS = {
24
25
  search: 'haiku/medium',
25
26
  extract: 'haiku/medium',
@@ -38,13 +39,13 @@ const DEFAULT_TIME_CAP_MIN = {
38
39
  search: 3,
39
40
  extract: 5,
40
41
  'mechanical-edit': 10,
41
- 'feature-impl': 25,
42
- debug: 25,
42
+ 'feature-impl': 80,
43
+ debug: 55,
43
44
  refactor: 30,
44
45
  'test-write': 15,
45
46
  review: 10,
46
47
  'research-synth': 20,
47
- 'content-write': 15,
48
+ 'content-write': 40,
48
49
  'plan-design': 15,
49
50
  'data-analysis': 15,
50
51
  };
@@ -187,25 +188,35 @@ const OPUS_MEDIUM = 5;
187
188
  * - one fail at a tier -> same tier again (retry with the error);
188
189
  * - two fails at the same tier -> next tier up;
189
190
  * - if that next tier would exceed the opus/medium ceiling -> ask the user (no auto re-plan).
190
- * Fail = agent fail|timeout or user reject cause=quality (eff_outcome). Attempts steered
191
- * plan/redirect by the user are ignored.
191
+ * Fail = agent fail|timeout or user reject cause=quality. Core 0.4.0: every result counts, so a retry by
192
+ * SendMessage to the same agent (fail, then fail again) is two fails at that tier; `aborted` (quota pause)
193
+ * is never a result. Attempts steered plan/redirect by the user are ignored.
192
194
  */
193
195
  function escalation(task, role) {
194
196
  const none = { floor: 0, ask: false, note: null, fails: 0, tried: [] };
195
197
  if (!task) return none;
196
198
  const att = task.attempts
197
- .filter((a) => a.eff_outcome && a.role !== 'verify' && !a.steered)
198
- .map((a) => ({ a, i: tierIndex(a.model, a.effort) }))
199
- .filter((x) => x.i !== null)
199
+ .filter((a) => a.role !== 'verify' && !a.steered)
200
+ .map((a) => ({
201
+ a,
202
+ i: tierIndex(a.model, a.effort),
203
+ results: Array.isArray(a.results) ? a.results : (a.eff_outcome ? [{ outcome: a.eff_outcome, ts: a.ts, idx: 0 }] : []),
204
+ }))
205
+ .filter((x) => x.i !== null && x.results.length)
200
206
  .sort((x, y) => (x.a.try - y.a.try) || (x.a.ts - y.a.ts));
201
207
  if (!att.length) return none;
208
+ const res = [];
209
+ for (const x of att) {
210
+ const rs = x.results.slice().sort((p, q) => (p.ts - q.ts) || ((p.idx || 0) - (q.idx || 0)));
211
+ for (const r of rs) res.push({ i: x.i, outcome: r.outcome });
212
+ }
202
213
  const tried = Array.from(new Set(att.map((x) => tierName(x.i))));
203
- const totalFails = att.filter((x) => x.a.eff_outcome === 'fail').length;
204
- const last = att[att.length - 1];
214
+ const totalFails = res.filter((r) => r.outcome === 'fail').length;
215
+ const last = res[res.length - 1];
205
216
  const cur = last.i;
206
217
  const base = { tried, fails: totalFails, ask: false };
207
- if (last.a.eff_outcome !== 'fail') return Object.assign(base, { floor: cur, note: null });
208
- const fails = att.filter((x) => x.i === cur && x.a.eff_outcome === 'fail').length;
218
+ if (last.outcome !== 'fail') return Object.assign(base, { floor: cur, note: null });
219
+ const fails = res.filter((r) => r.i === cur && r.outcome === 'fail').length;
209
220
  if (fails < 2) {
210
221
  return Object.assign(base, { floor: cur, note: `task fail 1 lần ở ${tierName(cur)} → làm lại cùng tier kèm lỗi` });
211
222
  }
@@ -287,6 +298,40 @@ function estimateFor(idx, cell, pricing) {
287
298
  return roughEstimate(idx, pricing);
288
299
  }
289
300
 
301
+ // ------------------------------------------------------------------ task type guard
302
+
303
+ // Ad-hoc labels seen in real logs → the closest fixed type (core 0.4.0). A label maps only when its target
304
+ // is known (fixed list or the profile's extra_task_types), e.g. art-fix → art-gen needs art-gen in the profile.
305
+ const TYPE_ALIASES = {
306
+ 'bug-fix': 'debug', bugfix: 'debug', fix: 'debug', bug: 'debug',
307
+ config: 'mechanical-edit', shell: 'mechanical-edit', deploy: 'mechanical-edit', ops: 'mechanical-edit', edit: 'mechanical-edit',
308
+ docs: 'content-write', doc: 'content-write', documentation: 'content-write', writing: 'content-write', copy: 'content-write',
309
+ design: 'plan-design', plan: 'plan-design', planning: 'plan-design', architecture: 'plan-design',
310
+ research: 'research-synth', synth: 'research-synth',
311
+ test: 'test-write', tests: 'test-write',
312
+ analysis: 'data-analysis', data: 'data-analysis',
313
+ impl: 'feature-impl', feature: 'feature-impl', implement: 'feature-impl',
314
+ 'code-review': 'review',
315
+ 'art-fix': 'art-gen', art: 'art-gen',
316
+ };
317
+
318
+ /**
319
+ * { type, known, mapped_from, warning }: a fixed/extra type passes; an ad-hoc label with a known alias target
320
+ * maps to it (with a warning); anything else stays unknown (choose asks the user to pick a fixed type).
321
+ */
322
+ function resolveTaskType(raw, profile) {
323
+ const t = String(raw || '').toLowerCase();
324
+ const extra = (profile && profile.extra_task_types) || [];
325
+ const isKnown = (x) => core.TASK_TYPES.includes(x) || extra.includes(x);
326
+ if (isKnown(t)) return { type: t, known: true, mapped_from: null, warning: null };
327
+ const target = TYPE_ALIASES[t];
328
+ if (target && isKnown(target)) {
329
+ return { type: target, known: true, mapped_from: t, warning: `loại "${t}" không cố định → dùng "${target}" (ghi --type ${target} trong thẻ)` };
330
+ }
331
+ const list = core.TASK_TYPES.concat(extra).join(', ');
332
+ return { type: t, known: false, mapped_from: null, warning: `loại "${t}" không có trong danh sách cố định (${list}) — chọn loại gần nhất hoặc hỏi người dùng` };
333
+ }
334
+
290
335
  // ------------------------------------------------------------------ choose
291
336
 
292
337
  /**
@@ -296,8 +341,8 @@ function estimateFor(idx, cell, pricing) {
296
341
  * Throws when an override goes below a safety floor or off the ladder.
297
342
  */
298
343
  function choose(opts) {
299
- const type = String(opts.type || '').toLowerCase();
300
- if (!type) throw new Error('Thiếu --type');
344
+ const rawType = String(opts.type || '').toLowerCase();
345
+ if (!rawType) throw new Error('Thiếu --type');
301
346
  const score = Number(opts.score);
302
347
  if (opts.score === undefined || opts.score === null || opts.score === true || !isFinite(score)
303
348
  || score < 0 || score > 12) throw new Error('--score phải trong 0–12');
@@ -327,12 +372,15 @@ function choose(opts) {
327
372
  const notes = [];
328
373
 
329
374
  const analysis = agg.analyze(opts.events || [], { lambda });
330
- const known = core.TASK_TYPES.includes(type) || profile.extra_task_types.includes(type);
375
+ const rt = resolveTaskType(rawType, profile);
376
+ const type = rt.type;
377
+ const known = rt.known;
378
+ if (rt.warning) notes.push(rt.warning);
331
379
  let prior = profile.priors[type];
332
380
  let priorWhy = 'prior';
333
381
  if (prior === undefined || prior === null) {
334
382
  prior = OPUS_MEDIUM;
335
- notes.push(known ? 'không có prior, dùng opus/medium' : `loại "${type}" lạ, dùng opus/medium`);
383
+ if (known) notes.push('không có prior, dùng opus/medium');
336
384
  }
337
385
  const ov = overridePrior(analysis.overrides, type);
338
386
  if (ov && ov.tier !== prior) {
@@ -382,10 +430,17 @@ function choose(opts) {
382
430
  explored,
383
431
  tier: tierName(idx),
384
432
  role,
433
+ task_type: type,
385
434
  budget_s: budgetFor(cell),
386
- }, estimateFor(idx, cell, opts.pricing), { profile_source: src }, extra || {});
435
+ }, estimateFor(idx, cell, opts.pricing), { profile_source: src },
436
+ rt.mapped_from ? { mapped_from: rt.mapped_from } : {}, rt.warning ? { type_warning: rt.warning } : {}, extra || {});
387
437
  };
388
438
 
439
+ // Unknown task type (not fixed, not in the profile, no alias): its stats would never pool — ask first.
440
+ if (!known) {
441
+ return finish(Math.max(prior, safetyFloor), rt.warning, false, { action: 'ask_user', unknown_type: true });
442
+ }
443
+
389
444
  if (overrideIdx !== null && overrideIdx < safetyFloor) {
390
445
  throw new Error(`Từ chối override ${tierName(overrideIdx)}: dưới sàn an toàn ${tierName(safetyFloor)} (${floorWhy.join(', ')}).`);
391
446
  }
@@ -474,4 +529,5 @@ module.exports = {
474
529
  DEFAULT_ORCH_CTX_SOFT_CAP,
475
530
  LADDER, DEFAULT_PRIORS, DEFAULT_LAMBDA, DEFAULT_TIME_CAP_MIN, ROUGH, tierIndex, tierName, parsePrior,
476
531
  parseTier, loadProfile, makeRng, betaSample, escalation, overridePrior, roughEstimate, choose, shortModel,
532
+ TYPE_ALIASES, resolveTaskType,
477
533
  };
package/lib/commands.cjs CHANGED
@@ -10,7 +10,7 @@ const chooser = require('./choose.cjs');
10
10
  const setupLib = require('./setup.cjs');
11
11
  const { claudeDirOf, ctxOf, yesNo } = require('./args.cjs');
12
12
 
13
- const AGENT_OUTCOMES = ['pass', 'fail', 'timeout'];
13
+ const AGENT_OUTCOMES = ['pass', 'fail', 'timeout', 'aborted'];
14
14
  const USER_OUTCOMES = ['accept', 'reject'];
15
15
  const CAUSES = ['quality', 'scope'];
16
16
  const STEER_KINDS = ['minor', 'plan', 'redirect'];
@@ -145,6 +145,15 @@ async function cmdDoctor(opts) {
145
145
  add(!badIds.length, 'task-ids', badIds.length
146
146
  ? `${badIds.length} dispatch trong ${INVALID_ID_WINDOW_DAYS} ngày gần đây có task_id sai định dạng (${Array.from(new Set(badIds.map((e) => e.task_id))).slice(0, 3).map((x) => `"${x}"`).join(', ')}) — task_id phải đúng 8 ký tự a-z0-9, lấy bằng \`dieu-phoi new-id\``
147
147
  : `mọi dispatch trong ${INVALID_ID_WINDOW_DAYS} ngày gần đây có task_id đúng định dạng`, 'CẢNH BÁO');
148
+ const tmp = sweepStaleTmp(ctx);
149
+ add(!tmp.left.length, 'tmp-files', tmp.left.length
150
+ ? `${tmp.left.length} file tạm còn mới (đang ghi?): ${tmp.left.slice(0, 3).join(', ')}`
151
+ : `không còn file *.tmp-* bỏ dở${tmp.removed ? ` (đã xoá ${tmp.removed} file cũ hơn 1 giờ)` : ''}`, 'CẢNH BÁO');
152
+ let badPlans = [];
153
+ try { badPlans = fs.readdirSync(ctx.paths.plans).filter((n) => n.endsWith('.json') && !core.isValidTaskId(n.slice(0, -5))); } catch (_) { /* none */ }
154
+ add(!badPlans.length, 'plans', badPlans.length
155
+ ? `${badPlans.length} kế hoạch có root id sai định dạng (${badPlans.slice(0, 3).join(', ')}) — \`state\` vẫn đọc; loại task bằng \`dieu-phoi discard --task <id>\` hoặc xoá file trong ${ctx.paths.plans}`
156
+ : 'mọi kế hoạch đã duyệt có root id đúng định dạng', 'CẢNH BÁO');
148
157
  const orch = measureOrch(opts, ctx, profileOrNull(opts, ctx));
149
158
  if (orch.ctx_tokens !== null) {
150
159
  add(!orch.over, 'orch-ctx', `session ${orch.session_id}: ${orch.ctx_tokens} token, tăng ${orch.ctx_growth}/${orch.soft_cap}, ≈ ${fmtUsd(orch.cum_usd)} — ${orch.advice}`, 'CẢNH BÁO');
@@ -166,6 +175,33 @@ async function cmdDoctor(opts) {
166
175
  return { code: failed ? 1 : 0, out };
167
176
  }
168
177
 
178
+ const TMP_RE = /\.tmp-\d+-[0-9a-f]+$/;
179
+ const TMP_STALE_MS = 3600 * 1000;
180
+
181
+ /**
182
+ * Leftovers of writeFileAtomic (tmp written, rename never happened: process killed, or rename lost a race on
183
+ * Windows) under data/, data/orch, data/segments, data/plans. Older than 1 h → removed; newer → reported.
184
+ */
185
+ function sweepStaleTmp(ctx) {
186
+ let removed = 0;
187
+ const left = [];
188
+ const now = Date.now();
189
+ for (const dir of [ctx.paths.data, path.join(ctx.paths.data, 'orch'), ctx.paths.segments, ctx.paths.plans]) {
190
+ let names = [];
191
+ try { names = fs.readdirSync(dir); } catch (_) { continue; }
192
+ for (const n of names) {
193
+ if (!TMP_RE.test(n)) continue;
194
+ const f = path.join(dir, n);
195
+ try {
196
+ const st = fs.statSync(f);
197
+ if (!st.isFile()) continue; // a directory (or anything else) with a tmp-like name: not ours, leave it alone
198
+ if (now - st.mtimeMs > TMP_STALE_MS) { fs.unlinkSync(f); removed++; } else left.push(n);
199
+ } catch (_) { left.push(n); }
200
+ }
201
+ }
202
+ return { removed, left };
203
+ }
204
+
169
205
  /** Live sink check (view=stats); result cached in data/sink-check.json for 24 h. */
170
206
  async function checkSinkLive(ctx) {
171
207
  let ok = false;
@@ -211,6 +247,7 @@ function cmdStart(opts) {
211
247
  `step_ids: ${info.step_ids.join(' ')}`,
212
248
  `health: ${core.healthLine(h)}`,
213
249
  `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
250
+ ...core.orchLessonLines(info),
214
251
  ];
215
252
  return { code: 0, out: out.join('\n') };
216
253
  }
@@ -248,7 +285,10 @@ function resolveAgentId(events, ref, now) {
248
285
  const verdicted = new Set();
249
286
  events.forEach((e, i) => {
250
287
  if (!e.agent_id) return;
251
- if (e.kind === 'verdict' && agg.normalizeVerdict(e).by === 'agent') verdicted.add(e.agent_id);
288
+ if (e.kind === 'verdict') {
289
+ const nv = agg.normalizeVerdict(e);
290
+ if (nv.by === 'agent' && nv.outcome !== agg.ABORTED) verdicted.add(e.agent_id); // aborted still needs a verdict
291
+ }
252
292
  if (e.kind !== 'complete') return;
253
293
  const t = Date.parse(e.ts) || 0;
254
294
  const cur = latest.get(e.agent_id);
@@ -285,8 +325,9 @@ function kebab(s) {
285
325
  function verdictAgent(opts, ctx, events) {
286
326
  const [ref, outcome] = opts._;
287
327
  if (!ref || !outcome) {
288
- throw new Error('Cú pháp: dieu-phoi verdict <agent_id|last> <pass|fail|timeout> [--verify V] [--reason R]\n'
289
- + ' dieu-phoi verdict --task <task_id> <accept|reject> [--cause quality|scope] [--round n]');
328
+ throw new Error('Cú pháp: dieu-phoi verdict <agent_id|last> <pass|fail|timeout|aborted> [--verify V] [--reason R]\n'
329
+ + ' dieu-phoi verdict --task <task_id> <accept|reject> [--cause quality|scope] [--round n]\n'
330
+ + ' dieu-phoi verdict --root <root_task_id> accept [--steps a,b]');
290
331
  }
291
332
  if (outcome === 'rework') {
292
333
  throw new Error('"rework" đã bỏ. Người dùng không nhận kết quả → dieu-phoi verdict --task <task_id> reject --cause quality');
@@ -344,9 +385,46 @@ function verdictUser(opts, ctx, events) {
344
385
  }, core.commonFields(ctx));
345
386
  }
346
387
 
388
+ /**
389
+ * `verdict --root <root> accept [--steps a,b]` (core 0.4.0): the user accepts the steps of the latest approved plan
390
+ * in one call. Covers every step whose task has an exec dispatch and no user verdict yet (a step already rejected
391
+ * or accepted is left alone: reject the bad ones per task first, then accept the rest by root).
392
+ */
393
+ function verdictRoot(opts, ctx, events) {
394
+ const root = requireTaskId(String(opts.root));
395
+ if (opts._[0] !== 'accept') {
396
+ throw new Error('verdict --root chỉ nhận accept; bước không đạt → dieu-phoi verdict --task <task_id> reject --cause quality|scope');
397
+ }
398
+ const plan = core.readJsonFile(path.join(ctx.paths.plans, `${root}.json`), null);
399
+ if (!plan || !Array.isArray(plan.steps)) throw new Error(`Không thấy kế hoạch đã duyệt của root ${root} (plan-table --approve --root ${root}).`);
400
+ let steps = plan.steps.filter((x) => x && x.task_id);
401
+ if (typeof opts.steps === 'string') {
402
+ const want = new Set(opts.steps.split(',').map((x) => x.trim()).filter(Boolean));
403
+ const ids = new Set(steps.map((x) => String(x.id)));
404
+ const missing = Array.from(want).filter((x) => !ids.has(x));
405
+ if (missing.length) throw new Error(`--steps: không có bước ${missing.join(', ')} trong kế hoạch ${root}`);
406
+ steps = steps.filter((x) => want.has(String(x.id)));
407
+ }
408
+ const execTasks = new Set(events.filter((e) => e.kind === 'dispatch' && (e.role || 'exec') === 'exec').map((e) => e.task_id));
409
+ const judged = new Set(events.filter((e) => e.kind === 'verdict' && agg.normalizeVerdict(e).by === 'user').map((e) => e.task_id));
410
+ const gone = agg.discardedTaskIds(events);
411
+ const todo = steps.filter((x) => execTasks.has(x.task_id) && !judged.has(x.task_id) && !gone.has(x.task_id));
412
+ if (!todo.length) throw new Error(`Không có bước nào của ${root} cần nghiệm thu (chưa giao exec, hoặc đã có verdict người dùng).`);
413
+ return todo.map((x) => ({
414
+ step: String(x.id),
415
+ ev: verdictUser(Object.assign({}, opts, { task: x.task_id, _: ['accept'], round: undefined, cause: undefined }), ctx, events),
416
+ }));
417
+ }
418
+
347
419
  function cmdVerdict(opts) {
348
420
  const ctx = ctxOf(opts);
349
421
  const events = core.readEvents(ctx);
422
+ if (typeof opts.root === 'string' && typeof opts.task !== 'string') {
423
+ const list = verdictRoot(opts, ctx, events);
424
+ for (const x of list) core.appendEvent(ctx, x.ev);
425
+ if (opts.json) return { code: 0, out: JSON.stringify(list.map((x) => x.ev)) };
426
+ return { code: 0, out: `Đã ghi người dùng accept cho ${list.length} bước của ${opts.root}: ${list.map((x) => `${x.step}=${x.ev.task_id}`).join(', ')}.` };
427
+ }
350
428
  const ev = typeof opts.task === 'string' ? verdictUser(opts, ctx, events) : verdictAgent(opts, ctx, events);
351
429
  core.appendEvent(ctx, ev);
352
430
  if (opts.json) return { code: 0, out: JSON.stringify(ev) };
@@ -356,6 +434,29 @@ function cmdVerdict(opts) {
356
434
  return { code: 0, out: `Đã ghi ${ev.outcome} cho ${ev.agent_id}${ev.task_id ? ` (task ${ev.task_id})` : ''}.` };
357
435
  }
358
436
 
437
+ // ------------------------------------------------------------------ discard
438
+
439
+ /**
440
+ * `dieu-phoi discard --task <task_id> [--reason R]` (core 0.4.0): withdraw a mistaken task. Appends one
441
+ * `discard` event (synced like any other); stats, choose, state and the sink then leave out every event of the
442
+ * task and of its agents. events.jsonl is never edited — a hand edit once lost a dispatch that the sink kept.
443
+ */
444
+ function cmdDiscard(opts) {
445
+ if (typeof opts.task !== 'string') throw new Error('Cú pháp: dieu-phoi discard --task <task_id> [--reason R]');
446
+ const taskId = requireTaskId(opts.task);
447
+ const ctx = ctxOf(opts);
448
+ const events = core.readEvents(ctx);
449
+ if (!events.some((e) => e.task_id === taskId)) throw new Error(`Không thấy task "${taskId}" trong log.`);
450
+ if (agg.discardedTaskIds(events).has(taskId)) return { code: 0, out: opts.json ? JSON.stringify({ already: true, task_id: taskId }) : `Task ${taskId} đã bị loại trước đó.` };
451
+ const ev = Object.assign({
452
+ schema: core.SCHEMA, kind: 'discard', ts: core.nowIso(), agent_id: `task:${taskId}`, task_id: taskId, reason_code: kebab(opts.reason),
453
+ }, core.commonFields(ctx));
454
+ core.appendEvent(ctx, ev);
455
+ const n = events.filter((e) => e.task_id === taskId).length;
456
+ if (opts.json) return { code: 0, out: JSON.stringify(ev) };
457
+ return { code: 0, out: `Đã loại task ${taskId} (${n} bản ghi) khỏi thống kê, choose và state. Log không bị sửa; sink nhận bản ghi discard ở lần sync kế tiếp.` };
458
+ }
459
+
359
460
  // ------------------------------------------------------------------ steer
360
461
 
361
462
  /**
@@ -383,7 +484,8 @@ function cmdSteer(opts) {
383
484
  const taskId = String(opts.task);
384
485
  const mine = agents.filter((a) => a.task_id === taskId);
385
486
  if (!mine.length && !events.some((e) => e.task_id === taskId)) throw new Error(`Không thấy task ${taskId} trong log`);
386
- const inFlight = mine.filter((a) => a.dispatched && !(a.completed && a.outcome));
487
+ // core 0.4.0: verifiers are never marked (a steer is about the work, not its check)
488
+ const inFlight = mine.filter((a) => a.dispatched && a.role !== 'verify' && !(a.completed && a.outcome));
387
489
  const type = (mine.find((a) => a.role === 'exec') || mine[0] || {}).task_type || null;
388
490
  out = inFlight.length ? inFlight.map((a) => mk(a.agent_id, taskId, a.task_type)) : [mk(`task:${taskId}`, taskId, type)];
389
491
  } else {
@@ -427,6 +529,40 @@ function measureOrch(opts, ctx, prof) {
427
529
  };
428
530
  }
429
531
 
532
+ const CTX_WARN_KEEP_MS = 14 * 24 * 3600 * 1000;
533
+
534
+ /** 0 = within the cap; 1 = over the cap, 2 = over 2×, 3 = over 4×, … */
535
+ function ctxWarnLevel(growth, cap) {
536
+ if (!(typeof growth === 'number' && cap > 0 && growth > cap)) return 0;
537
+ return 1 + Math.floor(Math.log2(growth / cap));
538
+ }
539
+
540
+ /**
541
+ * Core 0.4.0: the over-cap advice fires once per threshold crossing (cap, 2×, 4×, …) per session, and again after
542
+ * a /compact that left the session over. State: data/ctx-warn.json {session_id: {level, compactions, t}}.
543
+ * Never throws (no state → warn).
544
+ */
545
+ function ctxWarnOnce(ctx, m) {
546
+ if (!m || !m.over || !m.session_id) return false;
547
+ const file = path.join(ctx.paths.data, 'ctx-warn.json');
548
+ const level = ctxWarnLevel(m.ctx_growth, m.soft_cap);
549
+ try {
550
+ const st = core.readJsonFile(file, {}) || {};
551
+ const cur = st[m.session_id];
552
+ const comp = typeof m.compactions === 'number' ? m.compactions : null;
553
+ const warn = !cur || cur.compactions !== comp || level > (Number(cur.level) || 0);
554
+ if (warn) {
555
+ const now = Date.now();
556
+ st[m.session_id] = { level, compactions: comp, t: now };
557
+ for (const k of Object.keys(st)) if (!(now - Number(st[k] && st[k].t) < CTX_WARN_KEEP_MS)) delete st[k];
558
+ core.writeFileAtomic(file, `${JSON.stringify(st)}\n`);
559
+ }
560
+ return warn;
561
+ } catch (_) {
562
+ return true;
563
+ }
564
+ }
565
+
430
566
  function profileOrNull(opts, ctx) {
431
567
  try { return chooser.loadProfile(skillDirOf(opts, ctx)); } catch (_) { return null; }
432
568
  }
@@ -508,9 +644,9 @@ function cmdStats(opts) {
508
644
  c.note,
509
645
  ]);
510
646
  }
511
- const s = [['task_type', 'task', 'task có steer', 'lần steer', 'minor', 'plan', 'redirect', 'tỉ lệ steer']];
647
+ const s = [['task_type', 'task', 'task có steer', 'lần steer', 'minor', 'plan', 'redirect', 'tỉ lệ steer', 'ngoài exec (không tính)']];
512
648
  for (const r of steerRows) {
513
- s.push([r.task_type, r.tasks, r.steered_tasks, r.actions, r.minor, r.plan, r.redirect, pct(r.steer_rate)]);
649
+ s.push([r.task_type, r.tasks, r.steered_tasks, r.actions, r.minor, r.plan, r.redirect, pct(r.steer_rate), r.plan_phase_actions || 0]);
514
650
  }
515
651
  return {
516
652
  code: 0,
@@ -534,6 +670,7 @@ function cmdRetro(opts) {
534
670
  const userOutcomes = {};
535
671
  for (const a of agents) {
536
672
  if (a.outcome) outcomes[a.outcome] = (outcomes[a.outcome] || 0) + 1;
673
+ if (a.aborted) outcomes.aborted = (outcomes.aborted || 0) + 1;
537
674
  if (a.reason_code) reasons[a.reason_code] = (reasons[a.reason_code] || 0) + 1;
538
675
  }
539
676
  for (const v of userVerdicts) {
@@ -565,6 +702,7 @@ function cmdRetro(opts) {
565
702
  no_verdict_agents: agents.filter((a) => a.completed && !a.outcome && a.role !== 'verify').length,
566
703
  no_user_verdict_tasks: tasks.filter((t) => !t.user_verdicts.length).length,
567
704
  multi_attempt_tasks: escalations,
705
+ discarded_tasks: agg.discardedTaskIds(events).size,
568
706
  overrides: overrides.length,
569
707
  cost_usd: agents.reduce((s, a) => s + (a.cost_usd || 0), 0),
570
708
  },
@@ -614,6 +752,19 @@ function chooseInput(opts, ctx, events) {
614
752
  };
615
753
  }
616
754
 
755
+ function overrideEvent(ctx, taskId, taskType, suggested, chosen) {
756
+ return Object.assign({
757
+ schema: core.SCHEMA,
758
+ kind: 'override',
759
+ ts: core.nowIso(),
760
+ agent_id: taskId ? `task:${taskId}` : `override-${crypto.randomBytes(6).toString('hex')}`,
761
+ task_id: taskId || null,
762
+ task_type: taskType,
763
+ suggested_tier: suggested,
764
+ chosen_tier: chosen,
765
+ }, core.commonFields(ctx));
766
+ }
767
+
617
768
  function cmdChoose(opts) {
618
769
  const ctx = ctxOf(opts);
619
770
  if (opts.override !== undefined && typeof opts.task !== 'string') {
@@ -626,21 +777,14 @@ function cmdChoose(opts) {
626
777
  res.orch_ctx_tokens = orch.ctx_tokens;
627
778
  res.orch_ctx_growth = orch.ctx_growth;
628
779
  res.orch_ctx_over = orch.over;
780
+ res.orch_ctx_warn = ctxWarnOnce(ctx, orch); // over AND a new threshold crossed since the last warning
629
781
  if (res.overridden) {
630
782
  const tid = input.taskId;
631
- core.appendEvent(ctx, Object.assign({
632
- schema: core.SCHEMA,
633
- kind: 'override',
634
- ts: core.nowIso(),
635
- agent_id: tid ? `task:${tid}` : `override-${require('crypto').randomBytes(6).toString('hex')}`,
636
- task_id: tid,
637
- task_type: String(input.type).toLowerCase(),
638
- suggested_tier: res.suggested_tier,
639
- chosen_tier: res.tier,
640
- }, core.commonFields(ctx)));
783
+ core.appendEvent(ctx, overrideEvent(ctx, tid, res.task_type || String(input.type).toLowerCase(), res.suggested_tier, res.tier));
641
784
  }
642
785
  if (opts.json) return { code: 0, out: JSON.stringify(res) };
643
- const warn = orch.over ? `\nCẢNH BÁO: ${orch.advice}` : '';
786
+ const typeWarn = res.type_warning && !String(res.reason).includes(res.type_warning) ? `\nCẢNH BÁO: ${res.type_warning}` : '';
787
+ const warn = `${typeWarn}${res.orch_ctx_warn ? `\nCẢNH BÁO: ${orch.advice}` : ''}`;
644
788
  const est =`~${fmtUsd(res.est_cost_usd)} · ~${fmtNum(res.est_time_min)} phút (${res.est_source === 'data' ? 'dữ liệu' : 'ước lượng thô'})`;
645
789
  if (res.action === 'ask_user') {
646
790
  return { code: 0, out: `HỎI NGƯỜI DÙNG — ${res.reason}${warn}` };
@@ -775,9 +919,35 @@ function planTaskIdProblems(plan, root) {
775
919
  const same = plan.steps.filter((r) => r.task_id === root).map((r) => r.step);
776
920
  if (same.length) problems.push(`task_id của bước ${same.join(', ')} trùng --root "${root}" (root dành cho scout/planner)`);
777
921
  }
922
+ // core 0.4.0: a label outside the fixed list (and without an alias) would never pool its stats
923
+ for (const r of plan.steps) if (r.unknown_type) problems.push(`bước ${r.step}: ${r.type_warning}`);
778
924
  return problems;
779
925
  }
780
926
 
927
+ /** Mapped ad-hoc labels (bug-fix → debug, …): not an error, but the tag must carry the mapped type. */
928
+ function planTypeNotes(plan) {
929
+ return plan.steps.filter((r) => r.mapped_from).map((r) => `bước ${r.step}: ${r.type_warning}`);
930
+ }
931
+
932
+ /**
933
+ * Overrides chosen in the steps file (step `override`) become override events when the plan is approved —
934
+ * also on a re-approval that changes a step's tier — so the override learning sees them (core 0.4.0; before,
935
+ * only `choose --override` logged one). One event per (task_id, chosen tier): re-approving the same tier or a
936
+ * `choose --override` already logged adds nothing.
937
+ */
938
+ function planOverrideEvents(ctx, plan, events) {
939
+ const seen = new Set(events.filter((e) => e.kind === 'override').map((e) => `${e.task_id}|${e.chosen_tier}`));
940
+ const out = [];
941
+ for (const r of plan.steps) {
942
+ if (!r.overridden || !r.task_id || !r.tier || !r.suggested_tier) continue;
943
+ const k = `${r.task_id}|${r.tier}`;
944
+ if (seen.has(k)) continue;
945
+ seen.add(k);
946
+ out.push(overrideEvent(ctx, r.task_id, r.task_type || r.type, r.suggested_tier, r.tier));
947
+ }
948
+ return out;
949
+ }
950
+
781
951
  function recordApprovedPlan(ctx, root, stepsFile, plan) {
782
952
  const file = path.join(ctx.paths.plans, `${root}.json`);
783
953
  const prev = core.readJsonFile(file, null);
@@ -793,7 +963,7 @@ function recordApprovedPlan(ctx, root, stepsFile, plan) {
793
963
  approved_at: core.nowIso(),
794
964
  approval: prevApproval(prev) + 1,
795
965
  steps: plan.steps.map((r) => ({
796
- id: r.step, task_id: r.task_id || null, title: r.title === undefined ? null : r.title, type: r.type || null,
966
+ id: r.step, task_id: r.task_id || null, title: r.title === undefined ? null : r.title, type: r.task_type || r.type || null,
797
967
  tier: r.action === 'refused' ? null : (r.tier || null), depends_on: r.depends_on,
798
968
  })),
799
969
  history,
@@ -840,9 +1010,15 @@ function cmdPlanTable(opts) {
840
1010
  throw new Error(`plan-table --approve: không ghi kế hoạch — ${problems.join('; ')}. Mỗi bước cần task_id riêng (8 ký tự a-z0-9, lấy bằng \`dieu-phoi new-id\`), khác --root.`);
841
1011
  }
842
1012
  if (problems.length) process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${problems.join('; ')} (cần sửa trước khi --approve)\n`);
1013
+ const typeNotes = planTypeNotes(plan);
1014
+ if (typeNotes.length) process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${typeNotes.join('; ')}\n`);
843
1015
  const rec = opts.approve ? recordApprovedPlan(ctx, String(opts.root), path.resolve(opts.file), plan) : null;
1016
+ const ovs = rec ? planOverrideEvents(ctx, plan, base.events) : [];
1017
+ for (const ev of ovs) core.appendEvent(ctx, ev);
844
1018
  if (opts.json) {
845
- const out = rec ? Object.assign({}, plan, { approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file } }) : plan;
1019
+ const out = rec ? Object.assign({}, plan, {
1020
+ approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file, overrides_logged: ovs.length },
1021
+ }) : plan;
846
1022
  return { code: 0, out: JSON.stringify(out, null, 2) };
847
1023
  }
848
1024
  const lines = ['| Bước | Model/effort | Rubric (điểm · kiểm chứng · đảo ngược) | Lý do | Ước tính |', '|---|---|---|---|---|'];
@@ -862,11 +1038,12 @@ function cmdPlanTable(opts) {
862
1038
  process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${plan.refused.length} bước bị từ chối
863
1039
  `);
864
1040
  }
865
- if (rec) lines.push('', `Đã ghi kế hoạch đã duyệt (root ${rec.root_task_id}, lần duyệt ${rec.approval})`);
1041
+ if (typeNotes.length) lines.push('', `**Loại task:** ${typeNotes.join('; ')}`);
1042
+ if (rec) lines.push('', `Đã ghi kế hoạch đã duyệt (root ${rec.root_task_id}, lần duyệt ${rec.approval})${ovs.length ? `; ghi ${ovs.length} override` : ''}`);
866
1043
  return { code: 0, out: lines.join('\n') };
867
1044
  }
868
1045
 
869
1046
  module.exports = {
870
- cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
1047
+ cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
871
1048
  cmdPlanTable, cmdNewId, requireTaskId, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
872
1049
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kienntpixon/dieu-phoi",
3
- "version": "0.3.0",
3
+ "version": "0.4.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>",