@kienntpixon/dieu-phoi 0.5.1 → 0.5.3

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.5.3 (2026-10-22)
4
+
5
+ Đề xuất core 1–3 của retro 2026-10-22 (`~/.dieu-phoi/runs/258ajcrr/retro-2026-10-22.md`). `CORE_VERSION` 0.5.3; đi cùng skill 1.4.3. Không cần deploy lại `sheet/Code.gs`.
6
+
7
+ - **Thẻ sai được gắn cờ thay vì mất task_id:** 76 dp-exec ($407, 25% chi phí 10-19..21) mất `task_id` vì brief đặt id vào ô loại (`[dp:<id>|…]`, thiếu `task=`). Hook mới có `resolveTag` (`parseTag` giữ nguyên, gate gen-2 vẫn dùng bản chặt): `dispatch`/`complete` mang `tag_issue` = `type-is-id|bad-type|missing-task|no-tag` (nối `+`); thiếu `task=` mà ô loại là id (đúng định dạng, không phải loại đã biết, có chữ số hoặc được nhắc trong `dp-out-<id>`/`handoff-<id>`/`task=<id>` của brief) → `task_id` = id đó + `task_id_inferred: true`, loại lấy từ phần khác của thẻ nếu có. Id bắt đầu bằng chữ số (trước đây `parseTag` trả null, agent thành `untagged` và không có watchdog) giờ cũng được nhận. Loại hợp lệ = 12 loại cố định + `art-gen` + `extra_task_types` của profile.
8
+ - **Cảnh báo trả về điều phối viên:** PostToolUse(Agent) in `additionalContext` khi thẻ bị gắn cờ (nêu lỗi, id đã suy hoặc "không vào stats", mẫu thẻ đúng, danh sách loại). Agent không phải `dp-*` và không có thẻ: không cờ, không in.
9
+ - **Dispatch ghi tier `choose` đã đề xuất:** `choose --task` và `plan-table --approve` để lại `data/suggest/<task>-<role>.json`; dispatch kế tiếp của task + role đó ghi `suggested_tier` (tier choose tự chọn, trước override), `choose_tier` (tier choose trả về), `suggested_source`, `dispatched_tier`, `tier_mismatch`. Bản ghi > 14 ngày bị bỏ qua.
10
+ - **Trần chuỗi handoff:** dispatch exec ghi `task_handoffs` (số agent đã handoff của task); từ handoff thứ 3 hook cảnh báo dừng tiếp nối và giao planner chia phần còn lại (retro: 15/63 task feature-impl handoff ≥ 2 lần; cq7yn1ws 10 handoff, $70, fail).
11
+ - **Sửa hai lỗi nhỏ của cảnh báo thẻ:** `bad-type` nêu đúng token sai (trường `bad_type`) kể cả khi thẻ có thêm một loại hợp lệ (`[dp:foo|review|…]` báo "foo", không phải "review"); watchdog (`readHeadTag`) nay truyền `extra_task_types` của profile nên loại của profile không bị coi là `bad-type`.
12
+ - Sink không thêm cột (các trường mới là chẩn đoán cục bộ; `suggested_tier` trùng tên cột sẵn có của `override`). Test mới `test/retro-1022.test.cjs`.
13
+
14
+ ## 0.5.2
15
+
16
+ - **`dieu-phoi prune --after-retro <file> [--yes]`:** dọn sau retro, dry-run mặc định. Run dir của task đã có verdict người dùng và mọi sự kiện ≤ mốc cắt (dòng `cutoff:` trong file retro, không có thì mtime) chỉ còn `retro-*.md` + dp-out exec/verify cuối (giữ = try cao nhất VÀ file mới nhất theo mtime cho mỗi task+role; file legacy không phân loại được và kết quả inline cuối cũng giữ); xoá file plan hỏng (tên không phải task id hoặc không đọc được); task còn mở không đụng. Event của task đã dọn chuyển vào `data/archive/events-<ngày>.jsonl.gz` (ghi + xác minh archive trước, rồi ghi lại `events.jsonl` qua file tạm + rename, giữ khoá sync); `readEvents` đọc cả archive (chống trùng nếu prune chết giữa chừng) nên retro/stats/choose/state không đổi số; sync chỉ đọc `events.jsonl`, offset được dịch theo phần đã bỏ, event chưa đẩy lên sink (khi có sink) không bị chuyển. Run dir có file mới hơn mốc cắt (việc đang chạy chưa vào log) không bị dọn. Từ chối gốc ổ đĩa, không đi theo symlink/junction, chỉ đụng runs dir + data dir.
17
+ - **Nâng cấp:** dán lại `sheet/Code.gs` và deploy bản mới cùng lúc phát hành — Code.gs cũ từ chối kind `gate` (`bad_kind` → `sync-rejected.jsonl`) và bỏ các cột `complete` mới.
18
+ - **Điểm chọn tier dùng trung vị thời gian (retro 2026-10-18):** ô `retro`/`stats` tính `score = cost_mean + λ × time_p50` (trước: `time_mean`); Thompson trong `choose` dùng `attempt_p50_min` (urgent vẫn p75). Thời gian chờ người dùng/quota đẩy trung bình lên 3–13× và lật prior sang tier chậm hơn. `time_mean_min` vẫn được báo. Test mới `test/retro-score.test.cjs`.
19
+ - **Prior/trần thời gian (profile ai-lab, builtin khớp):** `debug` → sonnet/high; `time_cap_min` theo p75 thật: mechanical-edit 25, test-write 60, review 40, plan-design 30, art-gen 100.
20
+ - **Telemetry `complete`:** thêm `status` (dấu `STATUS:`/`VERDICT:` chuẩn hoá: done|pass|fail|partial|blocked|handoff|quota|other|none), `handoff`, `peak_ctx` (ngữ cảnh lớn nhất một request, cách đo của watchdog), `compactions` (số `compact_boundary`), `inline_result` (exec/verify/plan: hook phải tự ghi `.inline.md`). `.inline.md` giờ được ghi trước khi ghi event.
21
+ - **Kind mới `gate`:** hook `--gate`/`--gate-release` ghi một event cho mỗi quyết định `allow|deny|release` (`slot`, `children`, `child_task_id`, `reason_code` = `not-exec|type|model|tag|parent` nối `+`, hoặc `max-children`). Thước đo bỏ qua. Sink thêm cột tương ứng; fixture parity + simulate có ca mới. Test mới `test/telemetry.test.cjs`.
22
+ - **Agent `dp-verify`/`dp-scout`:** dòng bắt buộc "chốt VERDICT xong thì hành động kế tiếp là Write dp-out" (retro: verifier trả inline thay vì ghi file).
23
+
3
24
  ## 0.5.1
4
25
 
5
26
  Nâng ngưỡng watchdog ngữ cảnh subagent 150k/180k -> 200k/230k. Đo thí điểm: sonnet exec với `autoCompactWindow: 200000` tự nén ở 167.370 (~0,837 cửa sổ) — nén TRƯỚC khi lời nhắc 200k kịp tới.
package/README.md CHANGED
@@ -54,6 +54,7 @@ Khởi động lại Claude Code sau khi cài. Kiểm tra bằng `dieu-phoi doct
54
54
  | `start [--json]` | khởi động cổng 1 trong một lệnh, không gọi mạng: kiểm cục bộ, `root_task_id` + `step_ids`, tạo run dir, đường dẫn brief scout. Thường không cần: hook `UserPromptSubmit` làm sẵn việc này khi prompt có "điều phối" hoặc bắt đầu bằng `/dieu-phoi` |
55
55
  | `new-id [--count n] [--json]` | in task_id mới hợp lệ (đúng 8 ký tự `a-z0-9`), mỗi dòng một id (mặc định 1, tối đa 1000; `--json`: mảng). Lấy mọi `task_id` / `root_task_id` từ đây — không tự bịa. `choose/verdict/steer --task` và `plan-table --approve` từ chối id sai định dạng (kèm gợi ý id mới); hook vẫn ghi id cũ nhưng gắn `task_id_invalid: true`, `doctor` cảnh báo |
56
56
  | `sync [--json]` | đẩy bản ghi chưa đồng bộ lên sink, lô ≤ 200, có thử lại. Bản ghi sink từ chối → `data/sync-rejected.jsonl`, `doctor` báo số lượng |
57
+ | `prune --after-retro <file> [--yes]` | dọn sau retro (dry-run mặc định): run dir đã nghiệm thu, plan hỏng, event cũ → `data/archive/*.jsonl.gz` |
57
58
  | `retro [--json]` | tóm tắt cho vòng retro: số liệu từng ô, đếm `reason_code`, danh sách loại task, tóm tắt điều phối (`orchestration`) |
58
59
  | `uninstall [--purge]` | gỡ hook và đúng các quyền thư mục `runs` đã thêm khỏi settings (có sao lưu); `--purge` xoá cả `hook.cjs`. Dữ liệu giữ lại |
59
60
 
@@ -15,6 +15,8 @@ hooks:
15
15
  ---
16
16
  Bạn là scout/planner của dieu-phoi: khảo sát (code, tài liệu, dữ liệu) theo brief ở tin nhắn đầu (thẻ `[dp:...|role=plan]`) và trả về kế hoạch/câu hỏi đúng định dạng brief. Không sửa file của dự án; Write chỉ dùng để ghi báo cáo/kế hoạch vào run dir.
17
17
 
18
+ **Bắt buộc: có kế hoạch/kết luận xong thì hành động kế tiếp là gọi Write** tạo `dp-out-<task_id>-plan-try<n>.md` (dòng đầu file = dòng `STATUS:`). Chưa Write thành công thì chưa được trả lời; trả lời mà không có file là lỗi bàn giao (hook ghi nhận). Write bị từ chối → Bash heredoc; vẫn bị → trả nội dung inline.
19
+
18
20
  Bàn giao qua file: ghi TOÀN BỘ kết quả vào `dp-out-<task_id>-plan-try<n>.md` trong run dir (đường dẫn brief chỉ định), rồi chỉ trả: đường dẫn file đó, dòng `STATUS:`, và ≤ 3 dòng ý chính. Không đặt tên file `report*` / `summary*` / `findings*` / `analysis*.md` — Claude Code chặn cứng Write các tên đó trong subagent. File dp-out/handoff mà brief trỏ tới là báo cáo chính thức: đọc thẳng, không cần xin thêm quyền.
19
21
 
20
22
  Giữ ngữ cảnh nhỏ (mỗi lượt gửi lại toàn bộ ngữ cảnh — số lượt mới là thứ làm phình):
@@ -15,6 +15,8 @@ hooks:
15
15
  ---
16
16
  Bạn là agent kiểm chứng (verify) của dieu-phoi: kiểm tra độc lập kết quả của bước exec theo brief ở tin nhắn đầu (thẻ `[dp:...|role=verify]`). Chỉ đọc và chạy lệnh kiểm tra (test, lint, build); không sửa code của dự án. Ghi báo cáo vào run dir (Write chỉ dùng cho báo cáo) và trả về verdict ngắn đúng định dạng brief.
17
17
 
18
+ **Bắt buộc: chốt VERDICT xong thì hành động kế tiếp là gọi Write** tạo `dp-out-<task_id>-verify-try<n>.md` (dòng đầu file = dòng `VERDICT:`). Chưa Write thành công thì chưa được trả lời; trả verdict mà không có file là lỗi bàn giao (hook ghi nhận, 10/24 file `.inline.md` gần đây là của verifier). Write bị từ chối → Bash heredoc; vẫn bị → trả nội dung inline.
19
+
18
20
  Bàn giao qua file: ghi TOÀN BỘ kết quả vào `dp-out-<task_id>-verify-try<n>.md` trong run dir (đường dẫn brief chỉ định), rồi chỉ trả: đường dẫn file đó, dòng `STATUS:`/`VERDICT:`, và ≤ 3 dòng ý chính. Không đặt tên file `report*` / `summary*` / `findings*` / `analysis*.md` — Claude Code chặn cứng Write các tên đó trong subagent. File dp-out/handoff mà brief trỏ tới là báo cáo chính thức: đọc thẳng, không cần xin thêm quyền.
19
21
 
20
22
  Giữ ngữ cảnh nhỏ (mỗi lượt gửi lại toàn bộ ngữ cảnh — số lượt mới là thứ làm phình):
package/bin/dieu-phoi.cjs CHANGED
@@ -40,6 +40,9 @@ Cách dùng: dieu-phoi <lệnh> [tuỳ chọn]
40
40
  in task_id mới hợp lệ (8 ký tự a-z0-9), mỗi dòng một id — không tự bịa id
41
41
  sync đẩy bản ghi chưa đồng bộ lên sink
42
42
  retro [--json] tóm tắt cho vòng retro (không chứa nội dung)
43
+ prune --after-retro <file retro> [--yes]
44
+ dọn sau retro: run dir đã nghiệm thu (giữ retro-*.md + dp-out exec/verify cuối), plan hỏng,
45
+ events cũ → data/archive/*.jsonl.gz (không xoá event). Mặc định dry-run; --yes mới thực hiện
43
46
  uninstall [--purge] gỡ hook khỏi settings.json (--purge xoá cả hook.cjs; dữ liệu giữ lại)
44
47
  hook (nội bộ) đọc payload hook từ stdin
45
48
 
@@ -81,6 +84,7 @@ async function main(argv) {
81
84
  state: c.cmdState,
82
85
  sync: c.cmdSync,
83
86
  retro: c.cmdRetro,
87
+ prune: c.cmdPrune,
84
88
  uninstall: c.cmdUninstall,
85
89
  };
86
90
  const fn = table[cmd];
package/hook.cjs CHANGED
@@ -9,7 +9,8 @@
9
9
  * handoff watchdog), `--gate` (PreToolUse(Agent), gen-2 helper rules) and `--gate-release`. It must stay
10
10
  * self-contained: Node built-ins only, no requires of other package files.
11
11
  *
12
- * Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit, always exit 0,
12
+ * Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit / a PostToolUse(Agent) warning
13
+ * (core 0.5.3: bad dp tag, handoff chain), always exit 0,
13
14
  * no network.
14
15
  * Errors go to <home>/hook-errors.log.
15
16
  *
@@ -25,7 +26,7 @@ const path = require('path');
25
26
  const os = require('os');
26
27
  const crypto = require('crypto');
27
28
 
28
- const CORE_VERSION = '0.5.1';
29
+ const CORE_VERSION = '0.5.3';
29
30
  const SCHEMA = 1;
30
31
  const TASK_TYPES = [
31
32
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -126,6 +127,7 @@ function pathsFor(home) {
126
127
  events: path.join(data, 'events.jsonl'),
127
128
  segments: path.join(data, 'segments'),
128
129
  plans: path.join(data, 'plans'),
130
+ suggest: path.join(data, 'suggest'),
129
131
  skippedInternal: path.join(data, 'skipped-internal.json'),
130
132
  syncOffset: path.join(data, 'sync-offset.json'),
131
133
  syncSpawn: path.join(data, 'sync-spawn.json'),
@@ -263,6 +265,87 @@ function taskIdError(id) {
263
265
  return `task_id "${id}" không hợp lệ: cần đúng 8 ký tự a-z0-9 (vd. ${newTaskId()}) — lấy id bằng \`dieu-phoi new-id\``;
264
266
  }
265
267
 
268
+ // ------------------------------------------------------------------ tag check (core 0.5.3, retro 2026-10-22)
269
+ //
270
+ // 76 dp-exec ($407, 25% of 10-19..21 cost) lost their task_id because the brief put the id in the type slot
271
+ // (`[dp:<id>|…]`, no `task=`). parseTag stays strict (the gen-2 gate relies on it); resolveTag diagnoses the tag,
272
+ // flags it (`tag_issue`) and recovers the task_id only when the guess is safe.
273
+
274
+ const DEFAULT_EXTRA_TYPES = ['art-gen'];
275
+
276
+ /** Fixed task types + the profile's extra_task_types (art-gen when no profile is readable). */
277
+ function knownTaskTypes(ctx) {
278
+ const out = TASK_TYPES.concat(DEFAULT_EXTRA_TYPES);
279
+ const sd = ctx && ctx.config && ctx.config.skill_dir;
280
+ if (sd) {
281
+ const prof = readJsonFile(path.join(sd, 'profile', 'profile.json'), null);
282
+ const extra = prof && Array.isArray(prof.extra_task_types) ? prof.extra_task_types : [];
283
+ for (const t of extra) if (typeof t === 'string' && !out.includes(t)) out.push(t);
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /**
289
+ * parseTag + diagnosis. null when the first line has no `[dp:` tag at all. Otherwise a tag (never dropped), plus:
290
+ * - `tag_issue`: `type-is-id` (an 8-char [a-z0-9] id in the type slot), `bad-type` (type not a known task type),
291
+ * `missing-task` (no `task=`), joined with `+`;
292
+ * - `task_id_inferred: true` + `task_id` = the type-slot token, only when `task=` is missing, the token matches the
293
+ * task_id format, is not a known type, and either holds a digit or the prompt names it in a run-file path /
294
+ * `task=` (`dp-out-<id>`, `handoff-<id>`) — the same test that tells `type-is-id` from `bad-type` (an 8-letter label
295
+ * such as "planning" stays a label). Then task_type = another tag part that is a known type, else `untagged`.
296
+ */
297
+ function resolveTag(text, known) {
298
+ if (typeof text !== 'string') return null;
299
+ const body = text.replace(/^/, '');
300
+ const first = body.split(/\r?\n/, 1)[0].trim();
301
+ const m = /^\[dp:([^\]]*)\]/.exec(first);
302
+ if (!m) return null;
303
+ const types = Array.isArray(known) && known.length ? known : TASK_TYPES.concat(DEFAULT_EXTRA_TYPES);
304
+ const parts = m[1].split('|').map((s) => s.trim());
305
+ const head = (parts[0] || '').toLowerCase();
306
+ const rest = parts.slice(1);
307
+ let tag = parseTag(body);
308
+ // type slot unusable for parseTag (e.g. an id starting with a digit): parse the other parts on their own
309
+ if (!tag) tag = parseTag(`[dp:untagged|${rest.join('|')}]`);
310
+ const issues = [];
311
+ const altType = rest.map((p) => p.toLowerCase()).find((p) => types.includes(p)) || null;
312
+ // an id, not a label: id format, not a known type, and a digit or a run-file / task= mention ("planning" is a label)
313
+ const named = new RegExp(`(?:dp-out-|handoff-|task=)${escRe(head)}(?![a-z0-9])`).test(body.slice(first.length));
314
+ const idLike = TASK_ID_RE.test(head) && !types.includes(head) && (/\d/.test(head) || named);
315
+ if (idLike) issues.push('type-is-id');
316
+ else if (!types.includes(head)) {
317
+ issues.push('bad-type');
318
+ tag.bad_type = parts[0]; // the offending token (task_type may be replaced by a valid altType below)
319
+ }
320
+ if (!tag.task_id) {
321
+ issues.push('missing-task');
322
+ if (idLike) {
323
+ tag.task_id = head;
324
+ tag.task_id_inferred = true;
325
+ }
326
+ }
327
+ if (idLike || tag.task_type === 'untagged') tag.task_type = altType || 'untagged';
328
+ else if (!types.includes(tag.task_type) && altType) tag.task_type = altType;
329
+ if (issues.length) tag.tag_issue = issues.join('+');
330
+ return tag;
331
+ }
332
+
333
+ /** One Vietnamese line for the orchestrator about a flagged tag (null when the tag is fine). */
334
+ function tagIssueMessage(tag, known) {
335
+ if (!tag || !tag.tag_issue) return null;
336
+ const what = [];
337
+ const codes = tag.tag_issue.split('+');
338
+ if (codes.includes('no-tag')) what.push('agent dp-* được giao mà không có thẻ `[dp:...]` ở dòng đầu prompt');
339
+ if (codes.includes('type-is-id')) what.push('ô đầu của thẻ là một task id, không phải loại task');
340
+ if (codes.includes('bad-type')) what.push(`loại "${tag.bad_type || tag.task_type}" không nằm trong danh sách loại hợp lệ`);
341
+ if (codes.includes('missing-task')) what.push('thiếu `task=<id>`');
342
+ const got = tag.task_id_inferred
343
+ ? ` Hook đã suy task_id=${tag.task_id} từ ô loại (log ghi \`task_id_inferred\`), loại ghi là "${tag.task_type}".`
344
+ : (tag.task_id ? '' : ' Lượt này KHÔNG có task_id: không vào stats/choose/retro.');
345
+ const types = (Array.isArray(known) && known.length ? known : TASK_TYPES.concat(DEFAULT_EXTRA_TYPES)).join(', ');
346
+ return `dieu-phoi: thẻ của agent vừa giao sai (tag_issue=${tag.tag_issue}): ${what.join('; ')}.${got} Lần giao sau dùng đúng \`[dp:<task_type>|s<n>|<urgency>|try<n>|task=<id>|role=<role>]\`, task_type là một trong: ${types}; id lấy bằng \`dieu-phoi new-id\`.`;
347
+ }
348
+
266
349
  // ------------------------------------------------------------------ models / pricing
267
350
 
268
351
  function normalizeModel(m) {
@@ -387,10 +470,13 @@ function firstPromptText(lines) {
387
470
  return null;
388
471
  }
389
472
 
390
- /** Tag from the first user message of the transcript (the original prompt). */
391
- function tagFromTranscript(lines) {
473
+ /**
474
+ * Tag from the first user message of the transcript (the original prompt). Core 0.5.3: resolveTag, so a tag with the
475
+ * id in the type slot still yields its task_id (complete events, watchdog handoff file name, inline results).
476
+ */
477
+ function tagFromTranscript(lines, known) {
392
478
  const text = firstPromptText(lines);
393
- return text === null ? null : parseTag(text);
479
+ return text === null ? null : resolveTag(text, known);
394
480
  }
395
481
 
396
482
  /**
@@ -413,8 +499,10 @@ function summarizeTranscript(lines, opts) {
413
499
  let anonReq = 0;
414
500
  let firstTs = null;
415
501
  let lastTs = null;
502
+ let compactions = 0;
416
503
  for (const l of seg) {
417
504
  if (!l || typeof l !== 'object') continue;
505
+ if (l.type === 'system' && l.subtype === 'compact_boundary') compactions++;
418
506
  if (typeof l.timestamp === 'string') {
419
507
  const t = Date.parse(l.timestamp);
420
508
  if (!isNaN(t)) {
@@ -441,10 +529,14 @@ function summarizeTranscript(lines, opts) {
441
529
  let cost = 0;
442
530
  let costKnown = true;
443
531
  const modelCount = new Map();
532
+ let peak = null;
444
533
  for (const { usage, model } of byReq.values()) {
445
534
  if (model) modelCount.set(model, (modelCount.get(model) || 0) + 1);
446
535
  if (!usage) continue;
447
536
  const b = usageBuckets(usage);
537
+ // same measure as the watchdog (lastContextTokens): input + cache_read + cache_creation of one request
538
+ const ctxTok = b.input + b.cache_read + b.cache_write_5m + b.cache_write_1h;
539
+ if (peak === null || ctxTok > peak) peak = ctxTok;
448
540
  for (const k of Object.keys(tokens)) tokens[k] += b[k];
449
541
  const c = o.pricing ? requestCost(o.pricing, model, b) : null;
450
542
  if (c === null) costKnown = false; else cost += c;
@@ -462,6 +554,8 @@ function summarizeTranscript(lines, opts) {
462
554
  tokens,
463
555
  cost_usd: costKnown && byReq.size ? Math.round(cost * 1e8) / 1e8 : (byReq.size ? null : 0),
464
556
  model: normalizeModel(model),
557
+ peak_ctx: peak,
558
+ compactions,
465
559
  last_uuid: lastUuidOf(seg),
466
560
  };
467
561
  }
@@ -798,14 +892,46 @@ function readEventLines(ctx) {
798
892
  return eventLinesFrom(readEventsBuffer(ctx), 0).map((l) => l.text);
799
893
  }
800
894
 
801
- function readEvents(ctx) {
895
+ /** <data>/archive/events-<date>.jsonl.gz, oldest first (written by `dieu-phoi prune`). */
896
+ function archiveFiles(ctx) {
897
+ const dir = path.join(ctx.paths.data, 'archive');
898
+ let names = [];
899
+ try { names = fs.readdirSync(dir); } catch (_) { return []; }
900
+ return names.filter((n) => /^events-\d{4}-\d{2}-\d{2}\.jsonl\.gz$/.test(n)).sort().map((n) => path.join(dir, n));
901
+ }
902
+
903
+ /** Raw non-empty lines of every archive (gzip members may be concatenated). A corrupt archive throws. */
904
+ function readArchiveLines(ctx) {
802
905
  const out = [];
803
- for (const line of readEventLines(ctx)) {
906
+ for (const f of archiveFiles(ctx)) {
907
+ const txt = require('zlib').gunzipSync(fs.readFileSync(f)).toString('utf8');
908
+ for (const l of txt.split(String.fromCharCode(10))) if (l.trim()) out.push(l.trim());
909
+ }
910
+ return out;
911
+ }
912
+
913
+ function parseEventLines(lines, out) {
914
+ for (const line of lines) {
804
915
  try {
805
916
  const o = JSON.parse(line);
806
917
  if (o && typeof o === 'object') out.push(o);
807
918
  } catch (_) { /* skip */ }
808
919
  }
920
+ }
921
+
922
+ /**
923
+ * All events: archived history first, then events.jsonl. A line present in both (prune died between writing the
924
+ * archive and rewriting events.jsonl) counts once. Sync never uses this: it reads events.jsonl only.
925
+ */
926
+ function readEvents(ctx) {
927
+ const out = [];
928
+ const cur = readEventLines(ctx);
929
+ const arch = readArchiveLines(ctx);
930
+ if (arch.length) {
931
+ parseEventLines(arch, out);
932
+ const seen = new Set(arch);
933
+ parseEventLines(cur.filter((l) => !seen.has(l)), out);
934
+ } else parseEventLines(cur, out);
809
935
  return out;
810
936
  }
811
937
 
@@ -1369,11 +1495,105 @@ function extractAgentId(tr) {
1369
1495
  return m ? m[1] : null;
1370
1496
  }
1371
1497
 
1498
+ // ------------------------------------------------------------------ core 0.5.3: choose's tier per task (retro 2026-10-22)
1499
+ //
1500
+ // `choose --task <id>` and `plan-table --approve` leave data/suggest/<task>-<role>.json; the next dispatch of that
1501
+ // task + role copies it as `suggested_tier` (choose's own pick, before a user override) and `choose_tier` (what
1502
+ // choose answered), next to the tier actually dispatched. Older than SUGGEST_MAX_AGE_MS -> ignored.
1503
+
1504
+ const SUGGEST_MAX_AGE_MS = 14 * 24 * 3600 * 1000;
1505
+
1506
+ function suggestFile(ctx, taskId, role) {
1507
+ return path.join(ctx.paths.suggest, `${safeName(taskId)}-${safeName(role || 'exec')}.json`);
1508
+ }
1509
+
1510
+ /**
1511
+ * rec: { task_id, role?, suggested_tier, choose_tier?, source: 'choose'|'plan', keep_suggested? }. keep_suggested:
1512
+ * a `choose --approved` answer carries no Thompson pick, so an earlier record's suggested_tier (from the plan table)
1513
+ * is kept. Best effort: returns the written record or null.
1514
+ */
1515
+ function recordSuggestion(ctx, rec) {
1516
+ if (!rec || !isValidTaskId(rec.task_id) || typeof rec.suggested_tier !== 'string') return null;
1517
+ const role = rec.role || 'exec';
1518
+ const file = suggestFile(ctx, rec.task_id, role);
1519
+ const prev = rec.keep_suggested ? readSuggestion(ctx, rec.task_id, role) : null;
1520
+ const out = {
1521
+ task_id: rec.task_id,
1522
+ role,
1523
+ suggested_tier: prev && prev.suggested_tier ? prev.suggested_tier : rec.suggested_tier,
1524
+ choose_tier: rec.choose_tier || rec.suggested_tier,
1525
+ source: prev && prev.suggested_tier ? prev.source : (rec.source || 'choose'),
1526
+ ts: nowIso(),
1527
+ };
1528
+ try {
1529
+ fs.mkdirSync(ctx.paths.suggest, { recursive: true });
1530
+ writeFileAtomic(file, `${JSON.stringify(out)}\n`);
1531
+ pruneSuggest(ctx);
1532
+ } catch (e) {
1533
+ logError(ctx.home, e, 'suggest');
1534
+ return null;
1535
+ }
1536
+ return out;
1537
+ }
1538
+
1539
+ function readSuggestion(ctx, taskId, role) {
1540
+ if (!isValidTaskId(taskId)) return null;
1541
+ const r = readJsonFile(suggestFile(ctx, taskId, role), null);
1542
+ if (!r || typeof r !== 'object' || typeof r.suggested_tier !== 'string') return null;
1543
+ const t = Date.parse(r.ts);
1544
+ if (!(Date.now() - t <= SUGGEST_MAX_AGE_MS)) return null;
1545
+ return r;
1546
+ }
1547
+
1548
+ function pruneSuggest(ctx) {
1549
+ let names = [];
1550
+ try { names = fs.readdirSync(ctx.paths.suggest); } catch (_) { return; }
1551
+ const now = Date.now();
1552
+ for (const n of names) {
1553
+ const f = path.join(ctx.paths.suggest, n);
1554
+ try { if (now - fs.statSync(f).mtimeMs > 2 * SUGGEST_MAX_AGE_MS) fs.unlinkSync(f); } catch (_) { /* gone */ }
1555
+ }
1556
+ }
1557
+
1558
+ // ------------------------------------------------------------------ core 0.5.3: handoff chains (retro 2026-10-22)
1559
+ //
1560
+ // 15/63 feature-impl tasks handed off >= 2 times (19% of feature-impl cost); one chain ran 10 handoffs ($70) and
1561
+ // failed. From the 3rd handoff of a task the orchestrator is told to stop continuing and have a planner re-split.
1562
+
1563
+ const HANDOFF_CHAIN_WARN = 3;
1564
+
1565
+ /** Number of agents whose `complete` event has handoff:true for this task (substring scan of events.jsonl). */
1566
+ function countTaskHandoffs(ctx, taskId) {
1567
+ if (!taskId) return 0;
1568
+ let txt;
1569
+ try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { return 0; }
1570
+ const needle = `"task_id":${JSON.stringify(taskId)}`;
1571
+ const agents = new Set();
1572
+ let i = txt.indexOf(needle);
1573
+ while (i >= 0) {
1574
+ const start = txt.lastIndexOf('\n', i) + 1;
1575
+ const end = txt.indexOf('\n', i);
1576
+ const line = txt.slice(start, end < 0 ? undefined : end);
1577
+ if (line.includes('"kind":"complete"') && line.includes('"handoff":true')) {
1578
+ const m = /"agent_id":("(?:[^"\\]|\\.)*")/.exec(line);
1579
+ agents.add(m ? m[1] : `@${start}`);
1580
+ }
1581
+ i = end < 0 ? -1 : txt.indexOf(needle, end);
1582
+ }
1583
+ return agents.size;
1584
+ }
1585
+
1586
+ function handoffChainMessage(taskId, n) {
1587
+ return `dieu-phoi: task ${taskId} đã handoff ${n} lần (trần ${HANDOFF_CHAIN_WARN}). Đừng nối tiếp thêm: nếu agent vừa giao là agent tiếp nối, cân nhắc dừng nó và giao planner (dp-scout) đọc handoff-${taskId}.md rồi chia phần còn lại thành bước mới (task_id mới từ \`dieu-phoi new-id\`).`;
1588
+ }
1589
+
1372
1590
  function buildDispatch(p, ctx) {
1373
1591
  const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
1374
1592
  const tr = p.tool_response;
1375
1593
  const trObj = tr && typeof tr === 'object' && !Array.isArray(tr) ? tr : {};
1376
- const tag = parseTag(ti.prompt) || UNTAGGED;
1594
+ const subagentType = typeof ti.subagent_type === 'string' && ti.subagent_type ? ti.subagent_type : 'general-purpose';
1595
+ let tag = resolveTag(ti.prompt, knownTaskTypes(ctx));
1596
+ if (!tag) tag = /^dp-/.test(subagentType) ? Object.assign({}, UNTAGGED, { tag_issue: 'no-tag' }) : UNTAGGED;
1377
1597
  const explicitEffort = typeof ti.effort === 'string' && ti.effort ? ti.effort : null;
1378
1598
  const parentEffort = p.effort && typeof p.effort === 'object' ? p.effort.level : null;
1379
1599
  // agent_id must never be empty (the sink keys on it): agentId -> tool_use_id -> random.
@@ -1392,6 +1612,8 @@ function buildDispatch(p, ctx) {
1392
1612
  session: p.session_id ? sha8(p.session_id) : null,
1393
1613
  task_id: tag.task_id,
1394
1614
  ...(tag.task_id && !isValidTaskId(tag.task_id) ? { task_id_invalid: true } : {}),
1615
+ ...(tag.task_id_inferred ? { task_id_inferred: true } : {}),
1616
+ ...(tag.tag_issue ? { tag_issue: tag.tag_issue } : {}),
1395
1617
  task_type: tag.task_type,
1396
1618
  score: tag.score,
1397
1619
  urgency: tag.urgency,
@@ -1400,7 +1622,7 @@ function buildDispatch(p, ctx) {
1400
1622
  model: normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null,
1401
1623
  effort: explicitEffort || parentEffort || null,
1402
1624
  effort_inherited: !explicitEffort,
1403
- subagent_type: typeof ti.subagent_type === 'string' && ti.subagent_type ? ti.subagent_type : 'general-purpose',
1625
+ subagent_type: subagentType,
1404
1626
  background: ti.run_in_background === true || trObj.isAsync === true,
1405
1627
  // Orchestrator (main session) at the moment of this dispatch. Raw session_id joins with `dieu-phoi ctx`.
1406
1628
  session_id: typeof p.session_id === 'string' && p.session_id ? p.session_id : null,
@@ -1409,15 +1631,54 @@ function buildDispatch(p, ctx) {
1409
1631
  orch_compactions: orch.compactions,
1410
1632
  orch_cum_usd: orch.cum_usd,
1411
1633
  }, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
1634
+ tierFields(ctx, tag, normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null, explicitEffort || parentEffort || null),
1412
1635
  // core 0.5.0: an Agent call made inside a subagent (gen-2) carries the caller's agent_id
1413
1636
  typeof p.agent_id === 'string' && p.agent_id ? { parent_agent_id: p.agent_id } : {},
1414
1637
  commonFields(ctx));
1415
1638
  }
1416
1639
 
1640
+ /** suggested_tier / choose_tier / suggested_source / dispatched_tier / tier_mismatch, only when choose left a record. */
1641
+ function tierFields(ctx, tag, model, effort) {
1642
+ const s = tag.task_id ? readSuggestion(ctx, tag.task_id, tag.role) : null;
1643
+ if (!s) return {};
1644
+ const dispatched = tierOf(model, effort);
1645
+ const out = {
1646
+ suggested_tier: s.suggested_tier,
1647
+ choose_tier: s.choose_tier || s.suggested_tier,
1648
+ suggested_source: s.source || null,
1649
+ dispatched_tier: dispatched,
1650
+ };
1651
+ if (dispatched && !dispatched.includes('?')) out.tier_mismatch = dispatched !== out.choose_tier;
1652
+ return out;
1653
+ }
1654
+
1655
+ /**
1656
+ * Core 0.5.3: besides the dispatch event, a dieu-phoi dispatch with a flagged tag (tag_issue) or a task already on
1657
+ * its HANDOFF_CHAIN_WARN-th handoff gets a warning back to the caller as PostToolUse additionalContext (the
1658
+ * returned event carries it as a non-enumerable `stdout`, never written to the log).
1659
+ */
1417
1660
  function handlePostToolUse(p, ctx) {
1418
1661
  if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
1419
1662
  const ev = buildDispatch(p, ctx);
1663
+ const notes = [];
1664
+ if (ev.tag_issue) {
1665
+ const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
1666
+ const known = knownTaskTypes(ctx);
1667
+ const tag = resolveTag(ti.prompt, known) || { tag_issue: ev.tag_issue, task_id: null, task_type: ev.task_type };
1668
+ notes.push(tagIssueMessage(tag, known));
1669
+ }
1670
+ if (ev.task_id && ev.role === 'exec') {
1671
+ const n = countTaskHandoffs(ctx, ev.task_id);
1672
+ if (n) ev.task_handoffs = n;
1673
+ if (n >= HANDOFF_CHAIN_WARN) notes.push(handoffChainMessage(ev.task_id, n));
1674
+ }
1420
1675
  appendEvent(ctx, ev);
1676
+ if (notes.length) {
1677
+ Object.defineProperty(ev, 'stdout', {
1678
+ enumerable: false,
1679
+ value: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: notes.join('\n') } }),
1680
+ });
1681
+ }
1421
1682
  return ev;
1422
1683
  }
1423
1684
 
@@ -1586,7 +1847,7 @@ function handleSubagentStop(p, ctx) {
1586
1847
  const last = lastUuidOf(lines);
1587
1848
  if (last && seen.has(last)) return null; // same segment reported twice
1588
1849
  sum = summarizeTranscript(lines, { afterUuids: seen, pricing: loadPricing(ctx) });
1589
- tag = tagFromTranscript(lines);
1850
+ tag = tagFromTranscript(lines, knownTaskTypes(ctx));
1590
1851
  }
1591
1852
  const t = tag || UNTAGGED;
1592
1853
  const ev = Object.assign({
@@ -1608,7 +1869,20 @@ function handleSubagentStop(p, ctx) {
1608
1869
  tokens: sum ? sum.tokens : null,
1609
1870
  cost_usd: sum ? sum.cost_usd : null,
1610
1871
  model: sum ? sum.model : null,
1611
- }, commonFields(ctx));
1872
+ }, t.task_id_inferred ? { task_id_inferred: true } : {}, t.tag_issue ? { tag_issue: t.tag_issue } : {}, commonFields(ctx));
1873
+ // Core 0.5.2 (retro 2026-10-18): how the segment ended, measured here instead of re-reading transcripts later.
1874
+ // Labels and numbers only, never text.
1875
+ const why = exists ? abortReasonOf(lines) : null;
1876
+ ev.status = exists ? completionStatus(finalAssistantText(lines, p.last_assistant_message), why) : null;
1877
+ ev.handoff = ev.status === 'handoff';
1878
+ ev.peak_ctx = sum ? sum.peak_ctx : null;
1879
+ ev.compactions = sum ? sum.compactions : null;
1880
+ let inline = null;
1881
+ if (exists && tag) {
1882
+ try { inline = persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
1883
+ }
1884
+ // exec/verify/plan only: true = it returned without writing its dp-out file (the hook saved the .inline.md)
1885
+ if (tag && RESULT_ROLES.includes(tag.role)) ev.inline_result = Boolean(inline);
1612
1886
  // Record the segment before appending so a racing duplicate fire sees it.
1613
1887
  if (sum && sum.last_uuid) {
1614
1888
  saveSegments(ctx, agentId, {
@@ -1618,12 +1892,8 @@ function handleSubagentStop(p, ctx) {
1618
1892
  });
1619
1893
  }
1620
1894
  appendEvent(ctx, ev);
1621
- if (exists && tag) {
1622
- try { persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
1623
- }
1624
1895
  // Core 0.4.0: a segment that ended on a usage-limit / quota pause gets an automatic agent verdict
1625
1896
  // `aborted` (never a fail, never counted toward escalation); the real verdict comes after the resume.
1626
- const why = exists ? abortReasonOf(lines) : null;
1627
1897
  if (why) {
1628
1898
  appendEvent(ctx, Object.assign({
1629
1899
  schema: SCHEMA, kind: 'verdict', ts: nowIso(), agent_id: agentId, task_id: t.task_id, by: 'agent', outcome: 'aborted',
@@ -1669,6 +1939,32 @@ function abortReasonOf(lines) {
1669
1939
  return null;
1670
1940
  }
1671
1941
 
1942
+ /**
1943
+ * `complete.status` (core 0.5.2): how the agent's final report labels itself, as a fixed label (no text stored).
1944
+ * Quota / usage-limit stop -> "quota"; exact handoff marker -> "handoff"; else the first `STATUS:` or `VERDICT:` line
1945
+ * among the first 12 lines of the final text: done | partial | blocked | pass | fail, anything else -> "other".
1946
+ * No final text -> null; text without such a line -> "none".
1947
+ */
1948
+ function completionStatus(text, abortWhy) {
1949
+ if (abortWhy === 'usage-limit' || abortWhy === 'quota-pause') return 'quota';
1950
+ if (abortWhy === 'handoff') return 'handoff';
1951
+ if (typeof text !== 'string' || !text.trim()) return null;
1952
+ const head = text.split(/\r?\n/).slice(0, 12);
1953
+ for (const raw of head) {
1954
+ const m = /^[\s>*_`#-]*(STATUS|VERDICT)\s*[*_`]*\s*[:=]\s*[*_`]*\s*([A-Za-z]+)/i.exec(raw);
1955
+ if (!m) continue;
1956
+ const w = m[2].toLowerCase();
1957
+ if (w === 'partial' && /handoff/i.test(raw)) return 'handoff';
1958
+ if (w === 'partial' && /quota/i.test(raw)) return 'quota';
1959
+ if (w === 'done' || w === 'ok' || w === 'complete' || w === 'completed') return 'done';
1960
+ if (w === 'pass' || w === 'passed') return 'pass';
1961
+ if (w === 'fail' || w === 'failed') return 'fail';
1962
+ if (w === 'partial' || w === 'blocked') return w;
1963
+ return 'other';
1964
+ }
1965
+ return 'none';
1966
+ }
1967
+
1672
1968
  // ------------------------------------------------------------------ core 0.5.0: context watchdog + gen-2 gate
1673
1969
  //
1674
1970
  // Both run only from the hooks of the dp-* agent files (~/.claude/agents, installed by `dieu-phoi setup`), so a session
@@ -1746,7 +2042,7 @@ function readSlice(fd, start, len) {
1746
2042
  * Tag of the subagent's first prompt, read from the head of its transcript only.
1747
2043
  * Returns { found: false } while no user line is on disk yet (transcript written async), else { found: true, tag|null }.
1748
2044
  */
1749
- function readHeadTag(file) {
2045
+ function readHeadTag(file, known) {
1750
2046
  let fd;
1751
2047
  try { fd = fs.openSync(file, 'r'); } catch (_) { return { found: false, tag: null }; }
1752
2048
  try {
@@ -1756,7 +2052,7 @@ function readHeadTag(file) {
1756
2052
  const text = readSlice(fd, 0, len).toString('utf8');
1757
2053
  const complete = len === size ? text : text.slice(0, text.lastIndexOf('\n') + 1);
1758
2054
  const lines = parseJsonl(complete);
1759
- if (lines.some((l) => l && l.type === 'user' && l.message)) return { found: true, tag: tagFromTranscript(lines), prompt: firstPromptText(lines) };
2055
+ if (lines.some((l) => l && l.type === 'user' && l.message)) return { found: true, tag: tagFromTranscript(lines, known), prompt: firstPromptText(lines) };
1760
2056
  if (len >= size || len >= WATCH_HEAD_MAX) return { found: false, tag: null };
1761
2057
  }
1762
2058
  } finally {
@@ -1827,7 +2123,7 @@ function watchState(p, ctx, agentId) {
1827
2123
  if (!st || typeof st !== 'object') {
1828
2124
  st = null;
1829
2125
  if (tpath) {
1830
- const h = readHeadTag(tpath);
2126
+ const h = readHeadTag(tpath, knownTaskTypes(ctx));
1831
2127
  if (h.found) {
1832
2128
  st = h.tag
1833
2129
  ? { dp: true, task_id: h.tag.task_id, run_id: h.tag.task_id ? null : runIdFromPrompt(h.prompt), role: h.tag.role, parent_task_id: h.tag.parent_task_id || null, fired: 0, t: Date.now() }
@@ -1973,18 +2269,24 @@ function handleGate(p, ctx) {
1973
2269
  if (!st || !st.dp) return null;
1974
2270
  const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
1975
2271
  const why = [];
1976
- if (st.role && st.role !== 'exec') why.push(`chỉ agent role=exec được giao helper (bạn là role=${st.role})`);
1977
- if (ti.subagent_type !== HELPER_AGENT_TYPE) why.push(`subagent_type phải là ${HELPER_AGENT_TYPE} (đang là ${ti.subagent_type || 'mặc định'})`);
1978
- if (!HELPER_MODELS.includes(shortModelName(ti.model))) why.push(`model phải đặt rõ haiku hoặc sonnet (đang là ${ti.model || 'không đặt = kế thừa'})`);
2272
+ const codes = [];
2273
+ if (st.role && st.role !== 'exec') { why.push(`chỉ agent role=exec được giao helper (bạn là role=${st.role})`); codes.push('not-exec'); }
2274
+ if (ti.subagent_type !== HELPER_AGENT_TYPE) { why.push(`subagent_type phải là ${HELPER_AGENT_TYPE} (đang là ${ti.subagent_type || 'mặc định'})`); codes.push('type'); }
2275
+ if (!HELPER_MODELS.includes(shortModelName(ti.model))) { why.push(`model phải đặt rõ haiku hoặc sonnet (đang là ${ti.model || 'không đặt = kế thừa'})`); codes.push('model'); }
1979
2276
  const tag = parseTag(ti.prompt);
1980
2277
  const parentHint = st.task_id || '<task của bạn>';
1981
2278
  if (!tag || tag.role !== 'helper' || !tag.task_id || !tag.parent_task_id) {
1982
2279
  why.push(`dòng đầu prompt phải là [dp:<type>|s<n>|<urgency>|try1|task=<id mới từ dieu-phoi new-id>|role=helper|parent=${parentHint}]`);
2280
+ codes.push('tag');
1983
2281
  } else if (st.task_id && tag.parent_task_id !== st.task_id) {
1984
2282
  why.push(`parent= phải là task của bạn (${st.task_id})`);
2283
+ codes.push('parent');
1985
2284
  }
1986
- const deny = (list) => ({
2285
+ const child = tag && tag.task_id ? tag.task_id : null;
2286
+ const log = (action, extra) => logGate(ctx, p, agentId, st, Object.assign({ gate_action: action, child_task_id: child }, extra));
2287
+ const deny = (list, reason) => ({
1987
2288
  denied: true,
2289
+ event: log('deny', { reason_code: reason }),
1988
2290
  stdout: JSON.stringify({
1989
2291
  hookSpecificOutput: {
1990
2292
  hookEventName: 'PreToolUse',
@@ -1993,14 +2295,30 @@ function handleGate(p, ctx) {
1993
2295
  },
1994
2296
  }),
1995
2297
  });
1996
- if (why.length) return deny(why);
2298
+ if (why.length) return deny(why, codes.join('+'));
1997
2299
  const root = path.join(ctx.paths.data, 'gate');
1998
2300
  fs.mkdirSync(root, { recursive: true });
1999
2301
  pruneGate(root);
2000
2302
  const dir = gateDir(ctx, st.task_id || agentId);
2001
2303
  const slot = gateReserve(dir, tag.task_id);
2002
- if (!slot) return deny([`đã giao đủ ${GATE_MAX_CHILDREN} helper cho bước này — tự làm phần còn lại`]);
2003
- return { denied: false, slot, children: gateSlots(dir).length };
2304
+ if (!slot) return deny([`đã giao đủ ${GATE_MAX_CHILDREN} helper cho bước này — tự làm phần còn lại`], 'max-children');
2305
+ const children = gateSlots(dir).length;
2306
+ return { denied: false, slot, children, event: log('allow', { slot, children }) };
2307
+ }
2308
+
2309
+ /**
2310
+ * Core 0.5.2: one `gate` event per gate decision (allow / deny / release) so helper budgets can be measured from the
2311
+ * log instead of slot files. Labels and numbers only. Best effort: logging never changes the decision.
2312
+ */
2313
+ function logGate(ctx, p, agentId, st, fields) {
2314
+ const ev = Object.assign({
2315
+ schema: SCHEMA, kind: 'gate', ts: nowIso(), agent_id: agentId,
2316
+ session: p.session_id ? sha8(p.session_id) : null,
2317
+ task_id: st.task_id || null, role: st.role || null,
2318
+ gate_action: null, slot: null, children: null, child_task_id: null, reason_code: null,
2319
+ }, fields, commonFields(ctx));
2320
+ try { appendEvent(ctx, ev); } catch (_) { return null; }
2321
+ return ev;
2004
2322
  }
2005
2323
 
2006
2324
  /** `--gate-release` (PostToolUseFailure(Agent) inside dp-exec): the spawn failed, give its unconfirmed slot back. */
@@ -2015,6 +2333,7 @@ function handleGateRelease(p, ctx) {
2015
2333
  for (const r of gateSlots(gateDir(ctx, st.task_id || agentId))) {
2016
2334
  if (r.child !== tag.task_id || r.started) continue;
2017
2335
  try { fs.unlinkSync(r.file); } catch (_) { /* already gone */ }
2336
+ logGate(ctx, p, agentId, st, { gate_action: 'release', slot: r.slot, child_task_id: tag.task_id });
2018
2337
  return { released: r.slot };
2019
2338
  }
2020
2339
  return null;
@@ -2227,13 +2546,14 @@ async function runSync(ctx, opts) {
2227
2546
  // ------------------------------------------------------------------ exports / main
2228
2547
 
2229
2548
  module.exports = {
2230
- 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,
2549
+ runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, orchLessons, orchLessonLines, abortReasonOf, completionStatus, logGate, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
2231
2550
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
2232
- sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
2233
- logError, computeUserHash, userHash, commonFields, parseTag, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
2551
+ sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
2552
+ logError, computeUserHash, userHash, commonFields, parseTag, resolveTag, knownTaskTypes, tagIssueMessage, DEFAULT_EXTRA_TYPES,
2553
+ recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
2234
2554
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
2235
2555
  firstPromptText, tagFromTranscript, summarizeTranscript,
2236
- RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents,
2556
+ RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents, readEventsBuffer, archiveFiles, readArchiveLines,
2237
2557
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
2238
2558
  HANDOFF_MARKER, HANDOFF_THRESHOLDS, watchThresholds, GATE_MAX_CHILDREN, HELPER_AGENT_TYPE, watchAgentId, subagentTranscriptPath, readHeadTag, lastContextTokens, watchState, handoffMessage, handleWatch, handleGate, handleGateRelease, gateDir, gateSlots, gateReserve, gateConfirm, GATE_PENDING_MS,
2239
2559
  extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
package/lib/aggregate.cjs CHANGED
@@ -328,6 +328,9 @@ function buildCells(tasks, opts) {
328
328
  const attemptCosts = c.tasks.map((t) => (t.first_cost === undefined ? null : t.first_cost));
329
329
  const costMean = mean(costs);
330
330
  const timeMean = mean(timesMin);
331
+ // score uses the time median, not the mean: waits for the user (gate 3, quota pauses) inflate the mean
332
+ // 3-13x (retro 2026-10-18: art-gen sonnet mean 483 vs p50 37 min) and would flip priors to slower tiers.
333
+ const timeP50 = percentile(timesMin, 0.5);
331
334
  out.push({
332
335
  task_type: c.task_type,
333
336
  model: c.model,
@@ -354,7 +357,7 @@ function buildCells(tasks, opts) {
354
357
  attempt_time_min: mean(attemptTimes),
355
358
  attempt_p50_min: percentile(attemptTimes, 0.5),
356
359
  attempt_p75_min: percentile(attemptTimes, 0.75),
357
- score: costMean === null && timeMean === null ? null : round6((costMean || 0) + lambda * (timeMean || 0)),
360
+ score: costMean === null && timeP50 === null ? null : round6((costMean || 0) + lambda * (timeP50 || 0)),
358
361
  enough: n >= MIN_N,
359
362
  note: n >= MIN_N ? '' : 'chưa đủ dữ liệu',
360
363
  });
package/lib/args.cjs CHANGED
@@ -3,7 +3,7 @@
3
3
  const path = require('path');
4
4
  const core = require('../hook.cjs');
5
5
 
6
- const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve']);
6
+ const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve', 'yes']);
7
7
 
8
8
  /** Minimal argv parser: positionals, --k v, --k=v, boolean flags. */
9
9
  function parseArgs(argv) {
package/lib/choose.cjs CHANGED
@@ -21,13 +21,14 @@ const HELPER_TOP = 4; // sonnet/high: ceiling for role=helper (gen-2)
21
21
  const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
22
22
 
23
23
  // Built-in defaults = skill/profile/profile.json for the fixed task types (time caps as tuned 2026-10-12: feature-impl 80,
24
- // content-write 40, debug 55 — the old ones sat below the observed p50); used only when no profile is found.
24
+ // content-write 40, debug 55 — the old ones sat below the observed p50; 2026-10-18 retro: debug -> sonnet/high, caps of
25
+ // mechanical-edit/test-write/review/plan-design raised to their measured p75); used only when no profile is found.
25
26
  const DEFAULT_PRIORS = {
26
27
  search: 'haiku/medium',
27
28
  extract: 'haiku/medium',
28
29
  'mechanical-edit': 'sonnet/medium',
29
30
  'feature-impl': 'opus/medium',
30
- debug: 'opus/medium',
31
+ debug: 'sonnet/high',
31
32
  refactor: 'opus/medium',
32
33
  'test-write': 'sonnet/high',
33
34
  review: 'opus/medium',
@@ -39,15 +40,15 @@ const DEFAULT_PRIORS = {
39
40
  const DEFAULT_TIME_CAP_MIN = {
40
41
  search: 3,
41
42
  extract: 5,
42
- 'mechanical-edit': 10,
43
+ 'mechanical-edit': 25,
43
44
  'feature-impl': 80,
44
45
  debug: 55,
45
46
  refactor: 30,
46
- 'test-write': 15,
47
- review: 10,
47
+ 'test-write': 60,
48
+ review: 40,
48
49
  'research-synth': 20,
49
50
  'content-write': 40,
50
- 'plan-design': 15,
51
+ 'plan-design': 30,
51
52
  'data-analysis': 15,
52
53
  };
53
54
  const DEFAULT_LAMBDA = { relaxed: 0.02, normal: 0.1, urgent: 1.0 };
@@ -502,8 +503,9 @@ function choose(opts) {
502
503
  for (const x of scored) {
503
504
  const p = betaSample(rng, 1 + x.cell.passes, 1 + x.cell.fails);
504
505
  const cost = x.cell.attempt_cost_usd || 0;
506
+ // p50 (p75 when urgent), never the mean: waiting time inflates the mean (retro 2026-10-18)
505
507
  const time = urgency === 'urgent' && x.cell.attempt_p75_min !== null
506
- ? x.cell.attempt_p75_min : (x.cell.attempt_time_min || 0);
508
+ ? x.cell.attempt_p75_min : (x.cell.attempt_p50_min || 0);
507
509
  const s = cost / p + lambda * time / p;
508
510
  if (!best || s < best.s) best = { i: x.i, s, p };
509
511
  }
package/lib/commands.cjs CHANGED
@@ -186,13 +186,13 @@ const TMP_STALE_MS = 3600 * 1000;
186
186
 
187
187
  /**
188
188
  * Leftovers of writeFileAtomic (tmp written, rename never happened: process killed, or rename lost a race on
189
- * Windows) under data/, data/orch, data/segments, data/plans. Older than 1 h → removed; newer → reported.
189
+ * Windows) under data/, data/orch, data/segments, data/plans, data/suggest. Older than 1 h → removed; newer → reported.
190
190
  */
191
191
  function sweepStaleTmp(ctx) {
192
192
  let removed = 0;
193
193
  const left = [];
194
194
  const now = Date.now();
195
- for (const dir of [ctx.paths.data, path.join(ctx.paths.data, 'orch'), ctx.paths.segments, ctx.paths.plans]) {
195
+ for (const dir of [ctx.paths.data, path.join(ctx.paths.data, 'orch'), ctx.paths.segments, ctx.paths.plans, ctx.paths.suggest]) {
196
196
  let names = [];
197
197
  try { names = fs.readdirSync(dir); } catch (_) { continue; }
198
198
  for (const n of names) {
@@ -659,7 +659,7 @@ function cmdStats(opts) {
659
659
  out: `${table(t)}\n\n${table(s)}\n\n${orchLine(orchestration)}\n\n`
660
660
  + 'n = task tính tỉ lệ (/số đã giao, (-k) = bị loại: reject-scope hoặc lượt exec đầu bị steer plan/redirect). '
661
661
  + 'accept% = thành công theo verdict người dùng đầu tiên, chưa có thì verdict agent; agent_pass% = chỉ tầng agent. '
662
- + `E[cost], E[time] tính trọn mọi lượt + review của task. score = E[cost] + λ·E[time], λ=${lambda} USD/phút.`,
662
+ + `E[cost], E[time] tính trọn mọi lượt + review của task. score = E[cost] + λ·p50[time] (trung vị, không dùng TB: thời gian chờ thổi phồng TB), λ=${lambda} USD/phút.`,
663
663
  };
664
664
  }
665
665
 
@@ -788,6 +788,13 @@ function cmdChoose(opts) {
788
788
  const tid = input.taskId;
789
789
  core.appendEvent(ctx, overrideEvent(ctx, tid, res.task_type || String(input.type).toLowerCase(), res.suggested_tier, res.tier));
790
790
  }
791
+ // core 0.5.3: the next dispatch of this task + role logs suggested_tier / choose_tier next to the tier it ran on
792
+ if (input.taskId && res.action === 'dispatch' && res.tier) {
793
+ core.recordSuggestion(ctx, {
794
+ task_id: input.taskId, role: res.role || input.role, suggested_tier: res.suggested_tier || res.tier, choose_tier: res.tier,
795
+ source: 'choose', keep_suggested: input.approved !== null && !res.overridden,
796
+ });
797
+ }
791
798
  if (opts.json) return { code: 0, out: JSON.stringify(res) };
792
799
  const typeWarn = res.type_warning && !String(res.reason).includes(res.type_warning) ? `\nCẢNH BÁO: ${res.type_warning}` : '';
793
800
  const warn = `${typeWarn}${res.orch_ctx_warn ? `\nCẢNH BÁO: ${orch.advice}` : ''}`;
@@ -954,6 +961,19 @@ function planOverrideEvents(ctx, plan, events) {
954
961
  return out;
955
962
  }
956
963
 
964
+ /** Core 0.5.3: each approved step with a task_id leaves its choose tier for the step's dispatch (data/suggest/). */
965
+ function recordPlanSuggestions(ctx, plan) {
966
+ let n = 0;
967
+ for (const r of plan.steps) {
968
+ if (!r.task_id || !r.tier || r.action !== 'dispatch') continue;
969
+ const ok = core.recordSuggestion(ctx, {
970
+ task_id: r.task_id, role: r.role || 'exec', suggested_tier: r.suggested_tier || r.tier, choose_tier: r.tier, source: 'plan',
971
+ });
972
+ if (ok) n++;
973
+ }
974
+ return n;
975
+ }
976
+
957
977
  function recordApprovedPlan(ctx, root, stepsFile, plan) {
958
978
  const file = path.join(ctx.paths.plans, `${root}.json`);
959
979
  const prev = core.readJsonFile(file, null);
@@ -1021,6 +1041,7 @@ function cmdPlanTable(opts) {
1021
1041
  const rec = opts.approve ? recordApprovedPlan(ctx, String(opts.root), path.resolve(opts.file), plan) : null;
1022
1042
  const ovs = rec ? planOverrideEvents(ctx, plan, base.events) : [];
1023
1043
  for (const ev of ovs) core.appendEvent(ctx, ev);
1044
+ if (rec) recordPlanSuggestions(ctx, plan);
1024
1045
  if (opts.json) {
1025
1046
  const out = rec ? Object.assign({}, plan, {
1026
1047
  approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file, overrides_logged: ovs.length },
@@ -1050,6 +1071,6 @@ function cmdPlanTable(opts) {
1050
1071
  }
1051
1072
 
1052
1073
  module.exports = {
1053
- cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
1074
+ cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, recordPlanSuggestions, cmdStats, cmdRetro, cmdPrune: (o) => require('./prune.cjs').cmdPrune(o), cmdChoose, cmdCtx, cmdState, measureOrch,
1054
1075
  cmdPlanTable, cmdNewId, requireTaskId, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
1055
1076
  };
package/lib/prune.cjs ADDED
@@ -0,0 +1,321 @@
1
+ 'use strict';
2
+ // `dieu-phoi prune --after-retro <file> [--yes]` (core 0.5.2): post-retro cleanup of closed run dirs, bad plan
3
+ // files and events.jsonl (moved to a gz archive, never deleted). Dry-run unless --yes.
4
+ // Only touches: <runs dir>/<id>/, <data>/plans, <data>/events.jsonl, <data>/archive, <data>/sync-offset.json.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const zlib = require('zlib');
9
+ const core = require('../hook.cjs');
10
+ const { ctxOf } = require('./args.cjs');
11
+
12
+ const RUN_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
13
+ const OUT_RE = /^dp-out-(.+?)-(exec|verify|plan|helper)-try(\d+)(?:[-.].*)?\.md$/;
14
+ // legacy result names; the task segment is optional (`report-try6.md` ranks under the run dir's own task)
15
+ const LEGACY_RE = /^(report|verify)-(?:(.+?)-)?try(\d+)(?:-[A-Za-z0-9-]*)?\.md$/;
16
+ // anything else that looks like a result but cannot be ranked is kept (conservative): report-deploy.md, verify-tokens.md,
17
+ // dp-out-*.md with an odd shape. dp-out-*-debug-* logs are deliberately not results.
18
+ const RESULT_LIKE_RE = /^(?:(?:report|verify)-.*|dp-out-(?!.*-debug-).*)\.md$/;
19
+
20
+ function inside(root, p) {
21
+ const r = path.relative(path.resolve(root), path.resolve(p));
22
+ return !!r && !r.startsWith('..') && !path.isAbsolute(r);
23
+ }
24
+
25
+ function archiveDir(ctx) { return path.join(ctx.paths.data, 'archive'); }
26
+
27
+ /** Cutoff (ms): `cutoff: <date|iso>` line in the retro file, else its mtime. Date-only = end of that UTC day. */
28
+ function cutoffOf(file) {
29
+ const st = fs.statSync(file);
30
+ let text = '';
31
+ try { text = fs.readFileSync(file, 'utf8'); } catch (_) { /* mtime */ }
32
+ const m = /^\s*[*_>-]*\s*(?:cutoff|cut-off|mốc)\s*[*_]*\s*[:=]\s*[*_`]*\s*([0-9T:.\-Z+]{8,40})/im.exec(text);
33
+ if (m) {
34
+ let t = Date.parse(m[1]);
35
+ if (!isNaN(t)) {
36
+ if (/^\d{4}-\d{2}-\d{2}$/.test(m[1])) t += 86400000 - 1;
37
+ return { ms: t, source: 'cutoff-line' };
38
+ }
39
+ }
40
+ return { ms: st.mtimeMs, source: 'mtime' };
41
+ }
42
+
43
+ function sizeOf(p) {
44
+ let st;
45
+ try { st = fs.lstatSync(p); } catch (_) { return 0; }
46
+ if (!st.isDirectory()) return st.size;
47
+ let n = 0;
48
+ try { for (const c of fs.readdirSync(p)) n += sizeOf(path.join(p, c)); } catch (_) { /* gone */ }
49
+ return n;
50
+ }
51
+
52
+ function human(n) {
53
+ if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
54
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
55
+ return `${n} B`;
56
+ }
57
+
58
+ /**
59
+ * Files to keep in a run dir: retro-*.md + per task/role the highest-try result AND the newest-by-mtime result
60
+ * (dp-out, legacy or .inline.md). A re-run can write a lower try number later (e.g. try1 after a blocked try5),
61
+ * so the try number alone can pick a stale attempt. `mtimeOf(name)` is optional; without it only the highest try is kept.
62
+ */
63
+ function keepSet(names, mtimeOf) {
64
+ const keep = new Set();
65
+ const best = new Map(); // task|role -> max try
66
+ const parsed = [];
67
+ for (const n of names) {
68
+ if (/^retro-.*\.md$/.test(n)) { keep.add(n); continue; }
69
+ const inline = /\.inline\.md$/.test(n); // hook fallback copy: only written when the agent wrote no dp-out
70
+ let m = OUT_RE.exec(n);
71
+ let task; let role; let tr;
72
+ if (m) { task = m[1]; role = m[2]; tr = Number(m[3]); } else {
73
+ m = LEGACY_RE.exec(n);
74
+ if (!m) { if (RESULT_LIKE_RE.test(n)) keep.add(n); continue; }
75
+ task = m[2] || '(dir)'; role = m[1] === 'report' ? 'exec' : 'verify'; tr = Number(m[3]);
76
+ }
77
+ if (role !== 'exec' && role !== 'verify') continue;
78
+ parsed.push({ n, k: `${task}|${role}`, tr, inline });
79
+ best.set(`${task}|${role}`, Math.max(best.get(`${task}|${role}`) || 0, tr));
80
+ }
81
+ // final = highest try over written dp-out, legacy and inline; a written file beats an inline at the same try
82
+ const hasWritten = new Set(parsed.filter((p) => !p.inline).map((p) => `${p.k}|${p.tr}`));
83
+ for (const p of parsed) {
84
+ if (p.tr !== best.get(p.k)) continue;
85
+ if (p.inline && hasWritten.has(`${p.k}|${p.tr}`)) continue;
86
+ keep.add(p.n);
87
+ }
88
+ if (typeof mtimeOf === 'function') {
89
+ const newest = new Map(); // task|role -> newest candidate (ties: higher try)
90
+ for (const p of parsed) {
91
+ if (p.inline && hasWritten.has(`${p.k}|${p.tr}`)) continue;
92
+ let m = 0;
93
+ try { m = Number(mtimeOf(p.n)) || 0; } catch (_) { m = 0; }
94
+ const cur = newest.get(p.k);
95
+ if (!cur || m > cur.m || (m === cur.m && p.tr > cur.tr)) newest.set(p.k, { n: p.n, m, tr: p.tr });
96
+ }
97
+ for (const v of newest.values()) keep.add(v.n);
98
+ }
99
+ return keep;
100
+ }
101
+
102
+ function taskIdsOfDir(id, names, plansDir) {
103
+ const ids = new Set([id]);
104
+ const plan = core.readJsonFile(path.join(plansDir, `${id}.json`), null);
105
+ if (plan && Array.isArray(plan.steps)) for (const s of plan.steps) if (s && typeof s.task_id === 'string') ids.add(s.task_id);
106
+ for (const n of names) {
107
+ const m = OUT_RE.exec(n) || LEGACY_RE.exec(n);
108
+ if (m) { const t = OUT_RE.test(n) ? m[1] : m[2]; if (t) ids.add(t); }
109
+ const h = /^handoff-(.+)\.md$/.exec(n);
110
+ if (h) ids.add(h[1]);
111
+ }
112
+ return ids;
113
+ }
114
+
115
+ /** Eligible run dirs: every event of its tasks ≤ cutoff and every dispatched task has a user verdict (or was discarded). */
116
+ function planRuns(ctx, events, cutoffMs, protect) {
117
+ const runs = core.runsDir();
118
+ const byTask = new Map();
119
+ for (const e of events) {
120
+ if (!e || !e.task_id) continue;
121
+ if (!byTask.has(e.task_id)) byTask.set(e.task_id, []);
122
+ byTask.get(e.task_id).push(e);
123
+ }
124
+ const out = { prune: [], skipped: [] };
125
+ let dirs = [];
126
+ try { dirs = fs.readdirSync(runs, { withFileTypes: true }); } catch (_) { return out; }
127
+ for (const d of dirs) {
128
+ const id = d.name;
129
+ if (!d.isDirectory() || !RUN_ID_RE.test(id)) continue;
130
+ const dir = path.join(runs, id);
131
+ if (!inside(runs, dir)) continue;
132
+ const names = fs.readdirSync(dir);
133
+ const tasks = Array.from(taskIdsOfDir(id, names, ctx.paths.plans));
134
+ let anyVerdict = false; let reason = null; let latest = 0;
135
+ for (const t of tasks) {
136
+ const evs = byTask.get(t) || [];
137
+ const discarded = evs.some((e) => e.kind === 'discard');
138
+ const uv = evs.some((e) => e.kind === 'verdict' && e.by === 'user' && (e.outcome === 'accept' || e.outcome === 'reject'));
139
+ if (uv) anyVerdict = true;
140
+ for (const e of evs) {
141
+ const ts = Date.parse(e.ts);
142
+ if (!isNaN(ts)) latest = Math.max(latest, ts);
143
+ if (isNaN(ts) || ts > cutoffMs) reason = reason || `sự kiện sau mốc cắt (task ${t})`;
144
+ }
145
+ const dispatched = evs.some((e) => e.kind === 'dispatch' && e.role !== 'plan' && e.role !== 'helper');
146
+ if (dispatched && !uv && !discarded) reason = reason || `task ${t} chưa có verdict người dùng`;
147
+ }
148
+ if (!reason && !anyVerdict) reason = 'chưa có verdict người dùng';
149
+ // a file written after the cutoff = work still going on in this dir (e.g. a step not yet in the log)
150
+ if (!reason) {
151
+ for (const n of names) {
152
+ let m = 0;
153
+ try { m = fs.lstatSync(path.join(dir, n)).mtimeMs; } catch (_) { /* gone */ }
154
+ if (m > cutoffMs) { reason = 'có file mới hơn mốc cắt'; break; }
155
+ }
156
+ }
157
+ if (reason) { out.skipped.push({ id, reason }); continue; }
158
+ const keep = keepSet(names, (n) => { try { return fs.lstatSync(path.join(dir, n)).mtimeMs; } catch (_) { return 0; } });
159
+ const del = [];
160
+ let delBytes = 0; let keepBytes = 0;
161
+ for (const n of names) {
162
+ const p = path.join(dir, n);
163
+ if (keep.has(n) || (protect && path.resolve(p) === protect)) { keepBytes += sizeOf(p); continue; }
164
+ const b = sizeOf(p);
165
+ del.push({ name: n, bytes: b });
166
+ delBytes += b;
167
+ }
168
+ out.prune.push({ id, dir, tasks, keep: Array.from(keep).sort(), del, delete_bytes: delBytes, keep_bytes: keepBytes });
169
+ }
170
+ return out;
171
+ }
172
+
173
+ function badPlans(ctx) {
174
+ let names = [];
175
+ try { names = fs.readdirSync(ctx.paths.plans); } catch (_) { return []; }
176
+ const out = [];
177
+ for (const n of names) {
178
+ if (!n.endsWith('.json')) continue;
179
+ const p = path.join(ctx.paths.plans, n);
180
+ if (!inside(ctx.paths.plans, p)) continue;
181
+ let st; try { st = fs.lstatSync(p); } catch (_) { continue; }
182
+ if (!st.isFile()) continue;
183
+ const parsed = core.readJsonFile(p, null);
184
+ const okShape = parsed && typeof parsed.root_task_id === 'string' && Array.isArray(parsed.steps);
185
+ if (!core.isValidTaskId(n.slice(0, -5)) || !okShape) out.push({ path: p, name: n, bytes: st.size });
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /** Event lines (raw text + byte range) of events.jsonl, with parsed object (null if unparseable). */
191
+ function eventLines(ctx) {
192
+ const buf = core.readEventsBuffer(ctx);
193
+ return { buf, lines: core.eventLinesFrom(buf, 0).map((l) => {
194
+ let obj = null;
195
+ try { const o = JSON.parse(l.text); if (o && typeof o === 'object') obj = o; } catch (_) { /* keep */ }
196
+ return Object.assign(l, { obj });
197
+ }) };
198
+ }
199
+
200
+ function archiveToday() { return new Date().toISOString().slice(0, 10); }
201
+
202
+ /** Append one gzip member to today's archive via temp file; verify by re-reading; then rename into place. */
203
+ function writeArchive(ctx, texts) {
204
+ fs.mkdirSync(archiveDir(ctx), { recursive: true });
205
+ const file = path.join(archiveDir(ctx), `events-${archiveToday()}.jsonl.gz`);
206
+ let prev = Buffer.alloc(0);
207
+ try { prev = fs.readFileSync(file); } catch (_) { /* new */ }
208
+ const prevCount = prev.length ? zlib.gunzipSync(prev).toString('utf8').split('\n').filter((l) => l.trim()).length : 0;
209
+ const member = zlib.gzipSync(Buffer.from(`${texts.join('\n')}\n`, 'utf8'));
210
+ const tmp = `${file}.tmp-${process.pid}`;
211
+ fs.writeFileSync(tmp, Buffer.concat([prev, member]));
212
+ const back = zlib.gunzipSync(fs.readFileSync(tmp)).toString('utf8').split('\n').filter((l) => l.trim());
213
+ if (back.length !== prevCount + texts.length || back.slice(prevCount).some((l, i) => l !== texts[i])) {
214
+ try { fs.unlinkSync(tmp); } catch (_) { /* gone */ }
215
+ throw new Error('xác minh archive thất bại — events.jsonl giữ nguyên');
216
+ }
217
+ core.renameWithRetry(tmp, file);
218
+ return file;
219
+ }
220
+
221
+ function cmdPrune(opts) {
222
+ const ctx = ctxOf(opts);
223
+ const retro = typeof opts['after-retro'] === 'string' ? path.resolve(opts['after-retro']) : null;
224
+ if (!retro) return { code: 2, out: 'prune: thiếu --after-retro <file retro>' };
225
+ let st; try { st = fs.statSync(retro); } catch (_) { st = null; }
226
+ if (!st || !st.isFile()) return { code: 2, out: `prune: không đọc được file retro: ${retro}` };
227
+ const runs = core.runsDir();
228
+ const home = ctx.paths.data;
229
+ for (const [name, p] of [['runs', runs], ['data', home]]) {
230
+ if (!path.isAbsolute(p) || p === path.parse(p).root) return { code: 2, out: `prune: từ chối đường dẫn ${name} không an toàn: ${p}` };
231
+ }
232
+ const yes = opts.yes === true || opts.yes === 'y' || opts.yes === 'yes';
233
+ const cut = cutoffOf(retro);
234
+ const { lines } = fs.existsSync(ctx.paths.events) ? eventLines(ctx) : { lines: [] };
235
+ const allEvents = core.readEvents(ctx);
236
+ const rp = planRuns(ctx, allEvents, cut.ms, retro);
237
+ const prunedTasks = new Set();
238
+ for (const r of rp.prune) for (const t of r.tasks) prunedTasks.add(t);
239
+ const plans = badPlans(ctx);
240
+
241
+ // events to archive: lines of pruned tasks ≤ cutoff; with a sink, only those already pushed (end ≤ offset)
242
+ const cfg = ctx.config || {};
243
+ const hasSink = !!(cfg.sink_url && cfg.sink_token);
244
+ const unsynced = core.unsyncedInfo(ctx);
245
+ const archived = new Set(core.readArchiveLines(ctx));
246
+ const moving = lines.filter((l) => l.obj && l.obj.task_id && prunedTasks.has(l.obj.task_id)
247
+ && Date.parse(l.obj.ts) <= cut.ms && (!hasSink || l.end <= unsynced.offset));
248
+ const heldBack = lines.filter((l) => l.obj && l.obj.task_id && prunedTasks.has(l.obj.task_id)
249
+ && Date.parse(l.obj.ts) <= cut.ms).length - moving.length;
250
+ const evBytes = moving.reduce((n, l) => n + (l.end - l.start), 0);
251
+
252
+ const report = {
253
+ mode: yes ? 'apply' : 'dry-run', retro, cutoff: new Date(cut.ms).toISOString(), cutoff_source: cut.source,
254
+ runs: rp.prune.map((r) => ({ id: r.id, delete_files: r.del.length, delete_bytes: r.delete_bytes, keep: r.keep, keep_bytes: r.keep_bytes })),
255
+ runs_skipped: rp.skipped.length,
256
+ skipped_reasons: rp.skipped.reduce((a, s) => { const k = s.reason.replace(/task \S+/, 'task *').replace(/\(task \S+\)/, ''); a[k] = (a[k] || 0) + 1; return a; }, {}),
257
+ bad_plans: plans.map((p) => ({ name: p.name, bytes: p.bytes })),
258
+ events: { archive: moving.length, bytes: evBytes, held_back_unsynced: heldBack, total: lines.length },
259
+ };
260
+ report.totals = {
261
+ run_dirs: rp.prune.length,
262
+ files: rp.prune.reduce((n, r) => n + r.del.length, 0),
263
+ bytes: rp.prune.reduce((n, r) => n + r.delete_bytes, 0) + plans.reduce((n, p) => n + p.bytes, 0),
264
+ };
265
+
266
+ if (yes) {
267
+ // 1. events → archive (archive first + verified, then rewrite events.jsonl via temp + rename)
268
+ if (moving.length) {
269
+ const fresh = moving.filter((l) => !archived.has(l.text));
270
+ const lock = core.acquireLock(ctx.paths.lock);
271
+ if (!lock) return { code: 1, out: 'prune: sync đang giữ khoá — thử lại sau' };
272
+ try {
273
+ if (fresh.length) writeArchive(ctx, fresh.map((l) => l.text));
274
+ const buf = core.readEventsBuffer(ctx);
275
+ const drop = new Map(moving.map((l) => [l.start, l.text]));
276
+ const parts = []; let removedBeforeOffset = 0;
277
+ let pos = 0;
278
+ for (const l of core.eventLinesFrom(buf, 0)) {
279
+ parts.push(buf.subarray(pos, l.start)); // blank lines between
280
+ if (drop.get(l.start) === l.text) {
281
+ if (l.end <= unsynced.offset) removedBeforeOffset += l.end - l.start;
282
+ } else parts.push(buf.subarray(l.start, l.end));
283
+ pos = l.end;
284
+ }
285
+ parts.push(buf.subarray(pos));
286
+ let next = Buffer.concat(parts);
287
+ const cur = core.readEventsBuffer(ctx);
288
+ if (cur.length > buf.length) next = Buffer.concat([next, cur.subarray(buf.length)]); // appended meanwhile
289
+ const tmp = `${ctx.paths.events}.tmp-${process.pid}`;
290
+ fs.writeFileSync(tmp, next);
291
+ core.renameWithRetry(tmp, ctx.paths.events);
292
+ if (unsynced.offset > 0) core.writeFileAtomic(ctx.paths.syncOffset, `${JSON.stringify({ offset: Math.max(0, unsynced.offset - removedBeforeOffset), ts: core.nowIso() })}\n`);
293
+ } finally {
294
+ try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
295
+ }
296
+ }
297
+ // 2. run dirs
298
+ for (const r of rp.prune) {
299
+ for (const f of r.del) {
300
+ const p = path.join(r.dir, f.name);
301
+ if (!inside(r.dir, p) || !inside(runs, p)) continue;
302
+ fs.rmSync(p, { recursive: true, force: true });
303
+ }
304
+ try { if (!fs.readdirSync(r.dir).length) fs.rmdirSync(r.dir); } catch (_) { /* keep */ }
305
+ }
306
+ // 3. bad plans
307
+ for (const p of plans) if (inside(ctx.paths.plans, p.path)) fs.rmSync(p.path, { force: true });
308
+ }
309
+
310
+ if (opts.json) return { code: 0, out: JSON.stringify(report, null, 2) };
311
+ const L = [];
312
+ L.push(`prune ${yes ? '(ĐÃ THỰC HIỆN)' : '(dry-run — thêm --yes để thực hiện)'}: mốc cắt ${report.cutoff} (${cut.source}) từ ${retro}`);
313
+ L.push(`Run dir: ${rp.prune.length} sẽ dọn, ${rp.skipped.length} giữ nguyên (còn mở / sau mốc)`);
314
+ for (const r of rp.prune) L.push(` ${r.id}: xoá ${r.del.length} mục (${human(r.delete_bytes)}), giữ ${r.keep.length} (${human(r.keep_bytes)})`);
315
+ L.push(`Plan hỏng: ${plans.length} (${human(plans.reduce((n, p) => n + p.bytes, 0))})${plans.length ? ` — ${plans.map((p) => p.name).join(', ')}` : ''}`);
316
+ L.push(`events.jsonl: ${moving.length}/${lines.length} dòng → archive/events-${archiveToday()}.jsonl.gz (${human(evBytes)})${heldBack ? `; ${heldBack} dòng chưa đẩy lên sink nên giữ lại` : ''}; không xoá dòng nào`);
317
+ L.push(`Tổng xoá khỏi đĩa: ${report.totals.files} mục run dir + ${plans.length} plan = ${human(report.totals.bytes)}`);
318
+ return { code: 0, out: L.join('\n') };
319
+ }
320
+
321
+ module.exports = { cmdPrune, keepSet, cutoffOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kienntpixon/dieu-phoi",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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>",