@kienntpixon/dieu-phoi 0.7.0 → 0.7.1

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,41 @@
1
1
  # Changelog — @kienntpixon/dieu-phoi
2
2
 
3
+ ## 0.7.1 (2026-10-26)
4
+
5
+ Đề xuất core C1–C6 của retro 2026-10-26 (run 4b3mohix, task fgcq8dl9). `CORE_VERSION` 0.7.1; đi cùng skill 1.6.1.
6
+ **Thứ tự phát hành:** deploy Code.gs bản mới (cột `batch`, C5) TRƯỚC, rồi `npm i -g` + `dieu-phoi setup`.
7
+
8
+ - **`setup`: matcher `Agent|Task`** (task y5lcc5vx): hook PostToolUse/PreToolUse đăng ký cho cả tên công cụ `Agent` (Claude Code hiện tại) lẫn `Task`
9
+ (bản cũ). Đăng ký cũ chỉ `Agent` không còn tính là "đã đăng ký" → `setup` viết lại (không nhân đôi); `doctor` nhận cả hai.
10
+
11
+ - **C5 — verifier gộp là ô riêng** (task meq4adm7): agent `role=verify` có `batch:true` (thẻ `|batch`) không còn cộng chi phí/thời gian
12
+ vào task mang `task_id` của nó (tức ô `task_type × tier` của bước fast cuối) — ở cả `aggregate` lẫn `sheet/Code.gs` (cột mới `batch`).
13
+ `stats --json`/`retro --json` thêm `batch_verify: [{model, effort, n, tasks, cost_total_usd, cost_mean_usd, cost_p50_usd,
14
+ time_mean_min, time_p50_min}]` (CLI-only, ngoài parity); `stats` in bảng "verifier gộp", `retro` thêm một dòng. Thống kê theo mode
15
+ (`verify_min`…), `sub_usd_total`, `totals.cost_usd` vẫn tính nó. Dữ liệu cũ (không `batch`) không đổi số. **Sink: dán lại Code.gs**
16
+ (≈ 39 KB → minify khi deploy qua gws); Code.gs cũ làm rơi `batch` nên ô sink còn lệch CLI tới khi deploy.
17
+ - **C6 — delta knowledge chưa gộp + `repo:` tên ngắn** (task 1l9as0f3): `start` (khối hook, bản chữ, `--json` `unmerged_deltas`) và `state` (dòng cuối, `--json`)
18
+ cảnh báo `knowledge_delta: N file delta chưa gộp quá 24h …` khi có `knowledge-delta*.md` tuổi 24 h–30 ngày mà hash không nằm trong sổ `applied` của kho nào
19
+ (quét ~20 ms, chỉ đọc file). `knowledge merge`: header `repo:` nhận tên ngắn/basename (`pff-tailor`) nếu khớp đúng một kho đã biết (`name`, tên thư mục kho hoặc
20
+ basename của `roots`); HEAD từ `head:`, `roots` (mới: `meta.json` ghi checkout chính sau mỗi lần gộp có đổi) hoặc `--repo`; khớp ≥ 2 kho hoặc không khớp → lỗi rõ ràng.
21
+ File delta mà mọi mục đều "không đổi" nay cũng được ghi vào `applied` (để hết cảnh báo; test `knowledge-idem` cập nhật: meta.json chỉ thêm sổ `applied`).
22
+ Delta cũ gộp trước khi có sổ sẽ còn bị cảnh báo đến khi chạy `knowledge merge` một lần (idempotent, thành "không đổi").
23
+ - **C4 — sàn tier theo sở thích (`tier_floor`)**: profile.json có thể ghi `tier_floor {task_type: "model/effort"}`; `choose` lấy
24
+ max(sàn này, sàn an toàn, leo thang), không thăm dò xuống dưới, Thompson không chọn ô thấp hơn dù dữ liệu nghiêng về ô rẻ hơn.
25
+ Chỉ siết (trên trần → kẹp opus/medium; ngoài thang → bỏ). `--override`/`--approved` thấp hơn sàn vẫn giữ, có ghi chú.
26
+ - **C3 — telemetry của `choose`**: kết quả có thêm `score`, `reason_code` (`thompson | floor | escalation | prior | user-prior | explore |
27
+ plan | override | ask | unknown-type`), `thompson_score`; bản ghi `data/suggest/*.json` lưu `choose_task_type`, `choose_score`, `explored`,
28
+ `reason_code`, `thompson_score`, dispatch chép thành `choose_*` (chỉ ở máy). Thẻ ghi loại khác loại đã hỏi `choose` → `choose_type_mismatch:true`
29
+ + PostToolUse nhắc điều phối viên.
30
+ - **C2 — bí danh task_type trong hook**: bảng bí danh dời sang hook.cjs (`TYPE_ALIASES`, `choose` dùng chung) và thêm
31
+ `scan`/`read`/`code-read`/`explore`/`scout`/`lookup` → `search`. Thẻ dispatch mang nhãn có bí danh (vd. `architecture`, `shell`, `config`,
32
+ `deploy`) được ghi `task_type` = loại đích, `tag_issue=aliased` + `type_alias_from` thay cho `bad-type` (số liệu gộp vào ô đúng).
33
+ - **C1 — trần phiên/chuỗi handoff**: cảnh báo PostToolUse dời về handoff **thứ 2** (`HANDOFF_CHAIN_WARN=2`); hook PreToolUse(Agent) mới
34
+ (setup đăng ký thêm, `doctor` mục `chain-gate`) **từ chối** lượt exec tiếp nối của task đã handoff ≥ 3 lần (sự kiện `gate` `deny`,
35
+ `reason_code=handoff-chain`, `agent_id=task:<id>`) trừ khi thẻ có `|chain_ok` (dispatch ghi `chain_ok:true`); trong subagent không áp.
36
+ `ctx`/`choose`: context > 400k hoặc ≥ 5 lần nén → `new_session:true` (`choose`: `orch_new_session`), `over:true`, advice "mở phiên mới
37
+ + `state`" thay cho `/compact`. Thẻ cũng nhận cờ `|batch` (C5). **Cần chạy lại `dieu-phoi setup`.**
38
+
3
39
  ## 0.7.0 (2026-10-23)
4
40
 
5
41
  `CORE_VERSION` 0.7.0; đi cùng skill 1.6.0. Chế độ `fast | normal | strict` theo yêu cầu (nhánh `fast-mode`, kế hoạch hzgqonzr; egm0w7mx, ue15wumm, d1m3rg7s). Sự kiện `mode` chỉ ở máy, không gửi sink. `sheet/Code.gs` ĐỔI ở phần dữ liệu học fast (d1m3rg7s): **deploy Apps Script trước khi phát hành CLI** (sink cũ đọc `by=auto` thành verdict agent).
package/hook.cjs CHANGED
@@ -26,7 +26,7 @@ const path = require('path');
26
26
  const os = require('os');
27
27
  const crypto = require('crypto');
28
28
 
29
- const CORE_VERSION = '0.7.0';
29
+ const CORE_VERSION = '0.7.1';
30
30
  const SCHEMA = 1;
31
31
  const TASK_TYPES = [
32
32
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -270,6 +270,10 @@ function parseTag(text) {
270
270
  tag.role = mm[1];
271
271
  } else if ((mm = /^parent=([A-Za-z0-9_-]{1,32})$/.exec(p))) {
272
272
  tag.parent_task_id = mm[1]; // core 0.5.0: gen-2 helper -> the dp-exec step that spawned it
273
+ } else if (p === 'chain_ok') {
274
+ tag.chain_ok = true; // core 0.7.1 (C1): the user agreed to continue a long handoff chain
275
+ } else if (p === 'batch') {
276
+ tag.batch = true; // core 0.7.1 (C5): the one batched verifier of a fast request
273
277
  }
274
278
  }
275
279
  return tag;
@@ -311,6 +315,24 @@ function taskIdError(id) {
311
315
 
312
316
  const DEFAULT_EXTRA_TYPES = ['art-gen'];
313
317
 
318
+ // Ad-hoc labels seen in real logs → the closest fixed type (core 0.4.0 in choose; core 0.7.1: shared with the hook so a
319
+ // dispatch tag with such a label is mapped too, tag_issue=aliased instead of bad-type). A label maps only when its target
320
+ // is known (fixed list or the profile's extra_task_types), e.g. art-fix → art-gen needs art-gen in the profile.
321
+ const TYPE_ALIASES = {
322
+ 'bug-fix': 'debug', bugfix: 'debug', fix: 'debug', bug: 'debug',
323
+ config: 'mechanical-edit', shell: 'mechanical-edit', deploy: 'mechanical-edit', ops: 'mechanical-edit', edit: 'mechanical-edit',
324
+ docs: 'content-write', doc: 'content-write', documentation: 'content-write', writing: 'content-write', copy: 'content-write',
325
+ design: 'plan-design', plan: 'plan-design', planning: 'plan-design', architecture: 'plan-design',
326
+ research: 'research-synth', synth: 'research-synth',
327
+ test: 'test-write', tests: 'test-write',
328
+ analysis: 'data-analysis', data: 'data-analysis',
329
+ impl: 'feature-impl', feature: 'feature-impl', implement: 'feature-impl',
330
+ 'code-review': 'review',
331
+ 'art-fix': 'art-gen', art: 'art-gen',
332
+ // retro 2026-10-26 (C2): scout/read labels
333
+ scan: 'search', read: 'search', 'code-read': 'search', explore: 'search', scout: 'search', lookup: 'search',
334
+ };
335
+
314
336
  /** Fixed task types + the profile's extra_task_types (art-gen when no profile is readable). */
315
337
  function knownTaskTypes(ctx) {
316
338
  const out = TASK_TYPES.concat(DEFAULT_EXTRA_TYPES);
@@ -350,10 +372,19 @@ function resolveTag(text, known) {
350
372
  // an id, not a label: id format, not a known type, and a digit or a run-file / task= mention ("planning" is a label)
351
373
  const named = new RegExp(`(?:dp-out-|handoff-|task=)${escRe(head)}(?![a-z0-9])`).test(body.slice(first.length));
352
374
  const idLike = TASK_ID_RE.test(head) && !types.includes(head) && (/\d/.test(head) || named);
375
+ let aliased = null;
353
376
  if (idLike) issues.push('type-is-id');
354
377
  else if (!types.includes(head)) {
355
- issues.push('bad-type');
356
- tag.bad_type = parts[0]; // the offending token (task_type may be replaced by a valid altType below)
378
+ const target = Object.prototype.hasOwnProperty.call(TYPE_ALIASES, head) ? TYPE_ALIASES[head] : null;
379
+ if (target && types.includes(target)) {
380
+ // core 0.7.1 (C2): a known ad-hoc label maps to its fixed type; the data pools instead of being lost
381
+ issues.push('aliased');
382
+ aliased = target;
383
+ tag.type_alias_from = parts[0];
384
+ } else {
385
+ issues.push('bad-type');
386
+ tag.bad_type = parts[0]; // the offending token (task_type may be replaced by a valid altType below)
387
+ }
357
388
  }
358
389
  if (!tag.task_id) {
359
390
  issues.push('missing-task');
@@ -362,7 +393,8 @@ function resolveTag(text, known) {
362
393
  tag.task_id_inferred = true;
363
394
  }
364
395
  }
365
- if (idLike || tag.task_type === 'untagged') tag.task_type = altType || 'untagged';
396
+ if (aliased) tag.task_type = aliased;
397
+ else if (idLike || tag.task_type === 'untagged') tag.task_type = altType || 'untagged';
366
398
  else if (!types.includes(tag.task_type) && altType) tag.task_type = altType;
367
399
  if (issues.length) tag.tag_issue = issues.join('+');
368
400
  return tag;
@@ -375,6 +407,7 @@ function tagIssueMessage(tag, known) {
375
407
  const codes = tag.tag_issue.split('+');
376
408
  if (codes.includes('no-tag')) what.push('agent dp-* được giao mà không có thẻ `[dp:...]` ở dòng đầu prompt');
377
409
  if (codes.includes('type-is-id')) what.push('ô đầu của thẻ là một task id, không phải loại task');
410
+ if (codes.includes('aliased')) what.push(`loại "${tag.type_alias_from}" không cố định — hook đã ghi thành "${tag.task_type}" (dùng đúng loại này trong thẻ)`);
378
411
  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ệ`);
379
412
  if (codes.includes('missing-task')) what.push('thiếu `task=<id>`');
380
413
  const got = tag.task_id_inferred
@@ -1545,11 +1578,14 @@ function startInfo(ctx, cwd) {
1545
1578
  };
1546
1579
  const k = startKnowledgeOf(cwd);
1547
1580
  if (k) info.knowledge = k;
1581
+ const d = scanUnmergedDeltas();
1582
+ if (d.count) info.unmerged_deltas = d;
1548
1583
  return info;
1549
1584
  }
1550
1585
 
1551
1586
  function knowledgeLines(info) {
1552
- return info && info.knowledge && Array.isArray(info.knowledge.lines) ? info.knowledge.lines : [];
1587
+ const own = info && info.knowledge && Array.isArray(info.knowledge.lines) ? info.knowledge.lines : [];
1588
+ return own.concat(unmergedDeltaLines(info && info.unmerged_deltas));
1553
1589
  }
1554
1590
 
1555
1591
  function orchLessonLines(info) {
@@ -1633,6 +1669,12 @@ function recordSuggestion(ctx, rec) {
1633
1669
  source: prev && prev.suggested_tier ? prev.source : (rec.source || 'choose'),
1634
1670
  ts: nowIso(),
1635
1671
  };
1672
+ // core 0.7.1 (retro 2026-10-26, C3): why choose answered that tier, copied onto the dispatch as choose_*
1673
+ for (const [k, ok] of [['choose_task_type', (v) => typeof v === 'string' && v.length <= 40],
1674
+ ['choose_score', (v) => typeof v === 'number' && isFinite(v)], ['explored', (v) => typeof v === 'boolean'],
1675
+ ['reason_code', (v) => typeof v === 'string' && v.length <= 24], ['thompson_score', (v) => typeof v === 'number' && isFinite(v)]]) {
1676
+ if (ok(rec[k])) out[k] = rec[k];
1677
+ }
1636
1678
  try {
1637
1679
  fs.mkdirSync(ctx.paths.suggest, { recursive: true });
1638
1680
  writeFileAtomic(file, `${JSON.stringify(out)}\n`);
@@ -1666,9 +1708,11 @@ function pruneSuggest(ctx) {
1666
1708
  // ------------------------------------------------------------------ core 0.5.3: handoff chains (retro 2026-10-22)
1667
1709
  //
1668
1710
  // 15/63 feature-impl tasks handed off >= 2 times (19% of feature-impl cost); one chain ran 10 handoffs ($70) and
1669
- // failed. From the 3rd handoff of a task the orchestrator is told to stop continuing and have a planner re-split.
1711
+ // failed. Core 0.7.1 (retro 2026-10-26, C1): the warning moves to the 2nd handoff (matching the lesson "stop after the
1712
+ // 2nd"), and from the 3rd the global PreToolUse(Agent) gate denies another exec continuation unless the tag has chain_ok.
1670
1713
 
1671
- const HANDOFF_CHAIN_WARN = 3;
1714
+ const HANDOFF_CHAIN_WARN = 2;
1715
+ const HANDOFF_CHAIN_DENY = 3;
1672
1716
 
1673
1717
  /** Number of agents whose `complete` event has handoff:true for this task (substring scan of events.jsonl). */
1674
1718
  function countTaskHandoffs(ctx, taskId) {
@@ -1692,7 +1736,7 @@ function countTaskHandoffs(ctx, taskId) {
1692
1736
  }
1693
1737
 
1694
1738
  function handoffChainMessage(taskId, n) {
1695
- 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\`).`;
1739
+ return `dieu-phoi: task ${taskId} đã handoff ${n} lần (cảnh báo từ ${HANDOFF_CHAIN_WARN}; từ ${HANDOFF_CHAIN_DENY} hook chặn lượt exec tiếp nối, trừ thẻ có \`chain_ok\` khi người dùng đồng ý). Đừ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\`).`;
1696
1740
  }
1697
1741
 
1698
1742
  // ------------------------------------------------------------------ core 0.7.0: mode per request (fast | normal | strict)
@@ -2421,6 +2465,135 @@ function relatedStatus(r) {
2421
2465
  return { name: r.name, rel: r.rel, n, counts: freshCounts(hit.sections), text: `${knowledgeSummary({ sections: hit.sections }, n)} (cache)` };
2422
2466
  }
2423
2467
 
2468
+ // ---- retro 2026-10-26 (C6): knowledge deltas that nobody merged
2469
+
2470
+ const DELTA_WARN_MS = 24 * 3600 * 1000; // younger than this: the session may still be about to accept/merge
2471
+ const DELTA_WINDOW_MS = 30 * 24 * 3600 * 1000; // older than this: stale history, not a warning any more
2472
+ const DELTA_SCAN_MAX = 300;
2473
+
2474
+ /** sha256 of a delta file as `knowledge merge` records it in meta.json `applied` (BOM and CRLF ignored). */
2475
+ function deltaHash(text) {
2476
+ return crypto.createHash('sha256').update(String(text).replace(/^/, '').replace(/\r\n?/g, '\n')).digest('hex');
2477
+ }
2478
+
2479
+ /** The `repo:` header of a delta text (before its first `## `), unquoted; null when absent. */
2480
+ function deltaRepoOf(text) {
2481
+ for (const line of String(text).replace(/^/, '').split(/\r?\n/)) {
2482
+ if (/^##\s/.test(line)) return null;
2483
+ const m = /^\s*repo\s*:\s*(.+?)\s*$/i.exec(line);
2484
+ if (m) return m[1].replace(/[.;,]+$/, '').replace(/^[`"'](.*)[`"']$/, '$1').trim() || null;
2485
+ }
2486
+ return null;
2487
+ }
2488
+
2489
+ /** A `repo:` value that is a short name (no path separator, no drive), e.g. `pff-tailor`. */
2490
+ function isBareRepoName(v) {
2491
+ return typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v.trim());
2492
+ }
2493
+
2494
+ /** Every knowledge store under projectsDir(): { dir_name, dir, name, key, roots, applied:Set<sha256> } — file reads only. */
2495
+ function storeCatalog() {
2496
+ const root = projectsDir();
2497
+ let names;
2498
+ try { names = fs.readdirSync(root); } catch (_) { return []; }
2499
+ const out = [];
2500
+ for (const n of names) {
2501
+ const dir = path.join(root, n);
2502
+ if (!isStoreDirName(n) || !isDir(dir)) continue;
2503
+ const meta = readJsonFile(path.join(dir, 'meta.json'), null);
2504
+ const idx = readJsonFile(path.join(dir, 'index.json'), null);
2505
+ if (!meta && !idx) continue;
2506
+ const m = meta && typeof meta === 'object' ? meta : {};
2507
+ const i = idx && typeof idx === 'object' ? idx : {};
2508
+ out.push({
2509
+ dir_name: n,
2510
+ dir: toSlash(dir),
2511
+ name: String(i.name || m.name || n.replace(/-[0-9a-f]{8}$/, '')),
2512
+ key: i.key || (Array.isArray(m.keys) ? m.keys[0] : null) || null,
2513
+ roots: Array.isArray(m.roots) ? m.roots.filter((r) => typeof r === 'string' && r) : [],
2514
+ applied: new Set((Array.isArray(m.applied) ? m.applied : []).map((a) => a && a.sha256).filter(Boolean)),
2515
+ });
2516
+ }
2517
+ return out;
2518
+ }
2519
+
2520
+ /**
2521
+ * Known store a bare repo name refers to: its `name`, its directory name, or the basename of a recorded root equals
2522
+ * `name` (case-insensitive). { matches, store } — store only when exactly one store matches.
2523
+ */
2524
+ function storeByName(name, stores) {
2525
+ const want = String(name).trim().toLowerCase();
2526
+ const matches = (stores || storeCatalog()).filter((s) => s.name.toLowerCase() === want || s.dir_name.toLowerCase() === want
2527
+ || s.roots.some((r) => path.basename(r).toLowerCase() === want));
2528
+ return { matches, store: matches.length === 1 ? matches[0] : null };
2529
+ }
2530
+
2531
+ /**
2532
+ * Delta files in run dirs that are 24 h .. 30 d old and that no store has applied (hash in meta.json `applied`).
2533
+ * { count, runs: [{ run, n, oldest_ms, repos: { name: n } }], truncated } — never throws; count 0 when all is merged.
2534
+ */
2535
+ function scanUnmergedDeltas(opts) {
2536
+ const o = opts || {};
2537
+ const now = o.now || Date.now();
2538
+ const warnMs = o.warnMs === undefined ? DELTA_WARN_MS : o.warnMs;
2539
+ const windowMs = o.windowMs === undefined ? DELTA_WINDOW_MS : o.windowMs;
2540
+ const res = { count: 0, runs: [], truncated: false };
2541
+ try {
2542
+ const runs = runsDir();
2543
+ const applied = new Set();
2544
+ for (const s of storeCatalog()) for (const h of s.applied) applied.add(h);
2545
+ let seen = 0;
2546
+ for (const run of fs.readdirSync(runs)) {
2547
+ const dir = path.join(runs, run);
2548
+ let st;
2549
+ try { st = fs.statSync(dir); } catch (_) { continue; }
2550
+ if (!st.isDirectory() || now - st.mtimeMs > windowMs) continue;
2551
+ let names;
2552
+ try { names = fs.readdirSync(dir); } catch (_) { continue; }
2553
+ const row = { run, n: 0, oldest_ms: 0, repos: {} };
2554
+ for (const f of names) {
2555
+ if (!/^knowledge-delta.*\.md$/i.test(f)) continue;
2556
+ const file = path.join(dir, f);
2557
+ let fst;
2558
+ try { fst = fs.statSync(file); } catch (_) { continue; }
2559
+ const age = now - fst.mtimeMs;
2560
+ if (age < warnMs || age > windowMs) continue;
2561
+ if (seen++ >= DELTA_SCAN_MAX) { res.truncated = true; break; }
2562
+ const text = readTextFile(file);
2563
+ if (text == null || applied.has(deltaHash(text))) continue;
2564
+ const repo = deltaRepoOf(text);
2565
+ const label = repo ? (isBareRepoName(repo) ? repo : path.basename(repo.replace(/[\\/]+$/, ''))) : '?';
2566
+ row.n++;
2567
+ row.oldest_ms = Math.max(row.oldest_ms, age);
2568
+ row.repos[label] = (row.repos[label] || 0) + 1;
2569
+ }
2570
+ if (row.n) { res.runs.push(row); res.count += row.n; }
2571
+ if (res.truncated) break;
2572
+ }
2573
+ res.runs.sort((a, b) => b.n - a.n || b.oldest_ms - a.oldest_ms);
2574
+ } catch (e) {
2575
+ res.error = e.message;
2576
+ }
2577
+ return res;
2578
+ }
2579
+
2580
+ function ageText(ms) {
2581
+ return ms >= 48 * 3600 * 1000 ? `${Math.floor(ms / 86400000)} ngày` : `${Math.floor(ms / 3600000)} giờ`;
2582
+ }
2583
+
2584
+ /** One warning line (as an array, empty when nothing is pending) for the start block and `state`. */
2585
+ function unmergedDeltaLines(scan) {
2586
+ if (!scan || !scan.count) return [];
2587
+ const oldest = Math.max(...scan.runs.map((r) => r.oldest_ms));
2588
+ const repos = {};
2589
+ for (const r of scan.runs) for (const [k, v] of Object.entries(r.repos)) repos[k] = (repos[k] || 0) + v;
2590
+ const repoText = Object.entries(repos).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ×${v}`).join(', ');
2591
+ const runText = scan.runs.slice(0, 3).map((r) => `${r.run} ×${r.n}`).join(', ') + (scan.runs.length > 3 ? ` (+${scan.runs.length - 3} run)` : '');
2592
+ const more = scan.truncated ? ' (chỉ quét một phần)' : '';
2593
+ return [`knowledge_delta: ${scan.count} file delta chưa gộp quá 24h${more} — run ${runText}; kho ${repoText}; cũ nhất ${ageText(oldest)}. `
2594
+ + `Gộp: \`dieu-phoi knowledge merge --run ${scan.runs[0].run} --dry-run\` rồi bỏ --dry-run (hoặc verdict accept); bỏ qua nếu cố ý không gộp.`];
2595
+ }
2596
+
2424
2597
  /**
2425
2598
  * Knowledge lines for the start block of the repo at `cwd`; null outside a repo or when the repo has no store.
2426
2599
  * Refreshes this repo only (at most one git diff-tree, on a cache miss, bounded by HOOK_DIFF_TIMEOUT_MS; never git
@@ -2518,6 +2691,9 @@ function buildDispatch(p, ctx) {
2518
2691
  ...(tag.task_id && !isValidTaskId(tag.task_id) ? { task_id_invalid: true } : {}),
2519
2692
  ...(tag.task_id_inferred ? { task_id_inferred: true } : {}),
2520
2693
  ...(tag.tag_issue ? { tag_issue: tag.tag_issue } : {}),
2694
+ ...(tag.type_alias_from ? { type_alias_from: tag.type_alias_from } : {}),
2695
+ ...(tag.chain_ok ? { chain_ok: true } : {}),
2696
+ ...(tag.batch ? { batch: true } : {}),
2521
2697
  task_type: tag.task_type,
2522
2698
  score: tag.score,
2523
2699
  urgency: tag.urgency,
@@ -2543,6 +2719,11 @@ function buildDispatch(p, ctx) {
2543
2719
  commonFields(ctx));
2544
2720
  }
2545
2721
 
2722
+ /** core 0.7.1 (C3): the tier came from `choose` for another task type than the tag carries. */
2723
+ function chooseTypeMismatchMessage(ev) {
2724
+ return `dieu-phoi: task ${ev.task_id} được \`choose\` tính cho loại "${ev.choose_task_type}" nhưng thẻ ghi "${ev.task_type}" — tier ${ev.choose_tier} có thể không hợp loại này. Lần sau gọi \`choose --type ${ev.task_type}\` (hoặc sửa thẻ cho đúng loại choose trả).`;
2725
+ }
2726
+
2546
2727
  /** core 0.7.0: mode of the task at the moment of this dispatch (kept by later tries), only when the tag carries a task_id. */
2547
2728
  function modeFields(ctx, tag) {
2548
2729
  if (!tag || !tag.task_id || !isValidTaskId(tag.task_id)) return {};
@@ -2561,6 +2742,15 @@ function tierFields(ctx, tag, model, effort) {
2561
2742
  dispatched_tier: dispatched,
2562
2743
  };
2563
2744
  if (dispatched && !dispatched.includes('?')) out.tier_mismatch = dispatched !== out.choose_tier;
2745
+ // core 0.7.1 (C3): choose's own inputs/reason, and whether choose was asked about another task type
2746
+ if (typeof s.choose_task_type === 'string') {
2747
+ out.choose_task_type = s.choose_task_type;
2748
+ if (tag.task_type && tag.task_type !== s.choose_task_type) out.choose_type_mismatch = true;
2749
+ }
2750
+ if (typeof s.choose_score === 'number') out.choose_score = s.choose_score;
2751
+ if (typeof s.explored === 'boolean') out.choose_explored = s.explored;
2752
+ if (typeof s.reason_code === 'string') out.choose_reason = s.reason_code;
2753
+ if (typeof s.thompson_score === 'number') out.choose_thompson_score = s.thompson_score;
2564
2754
  return out;
2565
2755
  }
2566
2756
 
@@ -2579,6 +2769,7 @@ function handlePostToolUse(p, ctx) {
2579
2769
  const tag = resolveTag(ti.prompt, known) || { tag_issue: ev.tag_issue, task_id: null, task_type: ev.task_type };
2580
2770
  notes.push(tagIssueMessage(tag, known));
2581
2771
  }
2772
+ if (ev.choose_type_mismatch) notes.push(chooseTypeMismatchMessage(ev));
2582
2773
  if (ev.task_id && ev.role === 'exec') {
2583
2774
  const n = countTaskHandoffs(ctx, ev.task_id);
2584
2775
  if (n) ev.task_handoffs = n;
@@ -3287,6 +3478,36 @@ function gateReserve(dir, child) {
3287
3478
  return 0;
3288
3479
  }
3289
3480
 
3481
+ /**
3482
+ * Core 0.7.1 (C1): global PreToolUse(Agent) in the orchestrating session. An exec dispatch of a task that already
3483
+ * handed off HANDOFF_CHAIN_DENY times is denied (the 4th, 5th… segment of one chain cost $15-36 per task in session
3484
+ * 36cee993) unless the tag carries `chain_ok`. Inside a subagent (agent_id) this does nothing: --gate handles gen-2.
3485
+ */
3486
+ function handleChainGate(p, ctx) {
3487
+ if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
3488
+ if (watchAgentId(p)) return null;
3489
+ const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
3490
+ const tag = resolveTag(ti.prompt, knownTaskTypes(ctx));
3491
+ if (!tag || tag.role !== 'exec' || !tag.task_id || !isValidTaskId(tag.task_id) || tag.chain_ok) return null;
3492
+ const n = countTaskHandoffs(ctx, tag.task_id);
3493
+ if (n < HANDOFF_CHAIN_DENY) return null;
3494
+ const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'exec' },
3495
+ { gate_action: 'deny', reason_code: 'handoff-chain', children: n });
3496
+ return {
3497
+ denied: true,
3498
+ event: ev,
3499
+ stdout: JSON.stringify({
3500
+ hookSpecificOutput: {
3501
+ hookEventName: 'PreToolUse',
3502
+ permissionDecision: 'deny',
3503
+ permissionDecisionReason: `dieu-phoi: task ${tag.task_id} đã handoff ${n} lần (trần ${HANDOFF_CHAIN_DENY}) — không giao thêm exec tiếp nối. `
3504
+ + `Giao planner (dp-scout) đọc handoff-${tag.task_id}.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\`). `
3505
+ + 'Chỉ khi người dùng đồng ý nối tiếp: thêm `|chain_ok` vào thẻ.',
3506
+ },
3507
+ }),
3508
+ };
3509
+ }
3510
+
3290
3511
  /** Mark the reservation of `child` as a real spawn. Returns true when its slot was found. */
3291
3512
  function gateConfirm(ctx, key, child) {
3292
3513
  if (!key || !child) return false;
@@ -3443,6 +3664,7 @@ function runHook(opts) {
3443
3664
  if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
3444
3665
  if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
3445
3666
  if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
3667
+ if (name === 'PreToolUse') return handleChainGate(p, ctx); // { stdout } (deny) or null
3446
3668
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
3447
3669
  if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
3448
3670
  if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
@@ -3600,16 +3822,16 @@ module.exports = {
3600
3822
  modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, MODE_ASK_LINE, isMode, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
3601
3823
  modeSuggestion, modeSuggestLine, fastFailRate, rootTaskIds, FAST_FAIL_WINDOW, FAST_FAIL_MIN_N, FAST_FAIL_RATE,
3602
3824
  testCommandInfo, testTimeOf, testSFromText,
3603
- CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
3825
+ CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED, TYPE_ALIASES,
3604
3826
  sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
3605
3827
  logError, computeUserHash, userHash, commonFields, parseTag, resolveTag, knownTaskTypes, tagIssueMessage, DEFAULT_EXTRA_TYPES,
3606
- recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
3828
+ recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, HANDOFF_CHAIN_DENY, handleChainGate, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
3607
3829
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
3608
3830
  firstPromptText, tagFromTranscript, summarizeTranscript,
3609
3831
  RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, EVENTS_LOCK_WAIT_MS, eventLinesFrom, readEventLines, readEvents, readEventsBuffer, archiveFiles, readArchiveLines,
3610
3832
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
3611
3833
  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,
3612
- globMatch, pathspecOf, diffShas, dirtyFiles, computeFreshness, addDirty, indexStamp, freshnessCacheFile, readFreshCache, cachedFor, freshCounts, knowledgeSummary, shortFileList, readStoreIndex, relatedStatus, startKnowledge, FRESH_CACHE_MAX, DIFF_TIMEOUT_MS, HOOK_DIFF_TIMEOUT_MS, DIRTY_TIMEOUT_MS,
3834
+ globMatch, pathspecOf, diffShas, dirtyFiles, computeFreshness, addDirty, indexStamp, freshnessCacheFile, readFreshCache, cachedFor, freshCounts, knowledgeSummary, shortFileList, readStoreIndex, relatedStatus, startKnowledge, deltaHash, deltaRepoOf, isBareRepoName, storeCatalog, storeByName, scanUnmergedDeltas, unmergedDeltaLines, DELTA_WARN_MS, FRESH_CACHE_MAX, DIFF_TIMEOUT_MS, HOOK_DIFF_TIMEOUT_MS, DIRTY_TIMEOUT_MS,
3613
3835
  projectsDir, findGit, originUrlFromConfig, normalizeRemoteUrl, resolveProject, readHead, repoFromBrief, projectFields, nativePath, isStoreDirName, aliasesFile, loadAliases, keyDirName,
3614
3836
  extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
3615
3837
  };
package/lib/aggregate.cjs CHANGED
@@ -173,6 +173,8 @@ function buildAgents(events) {
173
173
  task_id: pick('task_id') || (verdict && verdict.task_id) || (a.steers[0] && a.steers[0].task_id) || null,
174
174
  try: Number(pick('try')) || 1,
175
175
  role: pick('role') || 'exec',
176
+ // core 0.7.1 (C5): the one batched verifier of a fast request (tag `|batch`, dispatch `batch:true`)
177
+ batch: pick('batch') === true,
176
178
  score: d.score !== undefined ? d.score : null,
177
179
  urgency: d.urgency || null,
178
180
  model: core.normalizeModel(cl.model) || core.normalizeModel(d.model) || null,
@@ -284,7 +286,9 @@ function buildTasks(agents, userVerdicts, autoVerdicts) {
284
286
  const autoOverturned = autoAccept && user.some((v) => v.outcome === 'reject' && tsOf(v) > Math.min(...autoTs));
285
287
  const lastUser = user[user.length - 1] || null;
286
288
  const accepted = lastUser ? lastUser.outcome === 'accept' : attempts.some((a) => a.outcome === 'pass');
287
- const costed = t.agents.filter((a) => a.completed || a.outcome);
289
+ // core 0.7.1 (C5): the batched verifier checks every fast step of a request; it is billed to its own cell
290
+ // (batchVerifyCells), never to the task_type x tier of the fast step whose task_id it carries.
291
+ const costed = t.agents.filter((a) => (a.completed || a.outcome) && !isBatchVerify(a));
288
292
  tasks.push({
289
293
  key: t.key,
290
294
  task_id: t.task_id,
@@ -315,6 +319,43 @@ function buildTasks(agents, userVerdicts, autoVerdicts) {
315
319
  return tasks;
316
320
  }
317
321
 
322
+ /** core 0.7.1 (C5): a role=verify agent dispatched with `batch:true`. */
323
+ function isBatchVerify(a) {
324
+ return !!a && a.role === 'verify' && a.batch === true;
325
+ }
326
+
327
+ /**
328
+ * core 0.7.1 (C5), CLI only: batched verifiers grouped by their own model x effort. Agents with a complete or a
329
+ * verdict; cost/time per agent = sum over its complete segments (null when unknown, left out of the percentiles).
330
+ */
331
+ function batchVerifyCells(agents) {
332
+ const groups = new Map();
333
+ for (const a of agents || []) {
334
+ if (!isBatchVerify(a) || !(a.completed || a.outcome)) continue;
335
+ const key = `${a.model || '?'}|${a.effort || '?'}`;
336
+ if (!groups.has(key)) groups.set(key, { model: a.model, effort: a.effort, agents: [] });
337
+ groups.get(key).agents.push(a);
338
+ }
339
+ const out = [];
340
+ for (const g of groups.values()) {
341
+ const costs = g.agents.map((a) => a.cost_usd);
342
+ const times = g.agents.map((a) => (a.duration_s === null ? null : a.duration_s / 60));
343
+ out.push({
344
+ model: g.model,
345
+ effort: g.effort,
346
+ n: g.agents.length,
347
+ tasks: [...new Set(g.agents.map((a) => a.task_id).filter(Boolean))].length,
348
+ cost_total_usd: round6(costs.reduce((s, c) => s + (typeof c === 'number' && isFinite(c) ? c : 0), 0)),
349
+ cost_mean_usd: round6(mean(costs)),
350
+ cost_p50_usd: round6(percentile(costs, 0.5)),
351
+ time_mean_min: round6(mean(times)),
352
+ time_p50_min: round6(percentile(times, 0.5)),
353
+ });
354
+ }
355
+ out.sort((a, b) => String(a.model).localeCompare(String(b.model)) || String(a.effort).localeCompare(String(b.effort)));
356
+ return out;
357
+ }
358
+
318
359
  function cellKey(type, model, effort) {
319
360
  return `${type}|${model || '?'}|${effort || '?'}`;
320
361
  }
@@ -528,12 +569,13 @@ function analyze(events, opts) {
528
569
  return {
529
570
  agents, tasks, cells, overrides, userVerdicts, autoVerdicts, steerEvents: steers, steers: buildSteers(tasks, steers, agents),
530
571
  orchestration: orchestrationSummary(events),
572
+ batch_verify: batchVerifyCells(agents),
531
573
  };
532
574
  }
533
575
 
534
576
  module.exports = {
535
577
  MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, ABORTED, QUOTA_REASON_RE, HANDOFF_REASON_RE, discardedTaskIds, withoutDiscarded, CANONICAL_CELL_FIELDS, normalizeVerdict, isSelf, isInternalComplete, buildAgents, buildTasks,
536
- buildCells, buildSteers, canonicalCells, orchestrationSummary, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
578
+ buildCells, buildSteers, canonicalCells, isBatchVerify, batchVerifyCells, orchestrationSummary, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
537
579
  };
538
580
 
539
581
  // ------------------------------------------------------------------ core 0.7.0 (s2): stats split by mode
package/lib/choose.cjs CHANGED
@@ -132,9 +132,19 @@ function loadProfile(skillDir) {
132
132
  const v = Number(r.orch_ctx_soft_cap);
133
133
  if (isFinite(v) && v > 0) softCap = Math.min(DEFAULT_ORCH_CTX_SOFT_CAP, Math.floor(v));
134
134
  }
135
+ // core 0.7.1 (retro 2026-10-26, C4): per-type tier floor from the user's preference (e.g. art-gen -> opus/medium).
136
+ // Only tightens: choose takes max(floor, safety floors, escalation); an entry off the ladder is ignored.
137
+ const tierFloor = {};
138
+ if (r.tier_floor && typeof r.tier_floor === 'object' && !Array.isArray(r.tier_floor)) {
139
+ for (const [k, v] of Object.entries(r.tier_floor)) {
140
+ if (k.startsWith('_')) continue;
141
+ const idx = parsePrior(v);
142
+ if (idx !== null) tierFloor[String(k).toLowerCase()] = idx;
143
+ }
144
+ }
135
145
  return {
136
146
  source, dept: r.dept || null, priors, lambda, explore_rate: explore, time_cap_min: timeCap, extra_task_types: extra,
137
- orch_ctx_soft_cap: softCap,
147
+ orch_ctx_soft_cap: softCap, tier_floor: tierFloor,
138
148
  };
139
149
  }
140
150
 
@@ -302,20 +312,8 @@ function estimateFor(idx, cell, pricing) {
302
312
 
303
313
  // ------------------------------------------------------------------ task type guard
304
314
 
305
- // Ad-hoc labels seen in real logs → the closest fixed type (core 0.4.0). A label maps only when its target
306
- // is known (fixed list or the profile's extra_task_types), e.g. art-fix → art-gen needs art-gen in the profile.
307
- const TYPE_ALIASES = {
308
- 'bug-fix': 'debug', bugfix: 'debug', fix: 'debug', bug: 'debug',
309
- config: 'mechanical-edit', shell: 'mechanical-edit', deploy: 'mechanical-edit', ops: 'mechanical-edit', edit: 'mechanical-edit',
310
- docs: 'content-write', doc: 'content-write', documentation: 'content-write', writing: 'content-write', copy: 'content-write',
311
- design: 'plan-design', plan: 'plan-design', planning: 'plan-design', architecture: 'plan-design',
312
- research: 'research-synth', synth: 'research-synth',
313
- test: 'test-write', tests: 'test-write',
314
- analysis: 'data-analysis', data: 'data-analysis',
315
- impl: 'feature-impl', feature: 'feature-impl', implement: 'feature-impl',
316
- 'code-review': 'review',
317
- 'art-fix': 'art-gen', art: 'art-gen',
318
- };
315
+ // Ad-hoc labels → the closest fixed type: the table lives in hook.cjs (core 0.7.1) so dispatch tags map the same way.
316
+ const TYPE_ALIASES = core.TYPE_ALIASES;
319
317
 
320
318
  /**
321
319
  * { type, known, mapped_from, warning }: a fixed/extra type passes; an ad-hoc label with a known alias target
@@ -451,6 +449,11 @@ function choose(opts) {
451
449
  if (!verifiable) { safetyFloor = Math.max(safetyFloor, OPUS_MEDIUM); floorWhy.push('không kiểm chứng bằng máy → tối thiểu opus/medium'); }
452
450
  if (!reversible && role === 'exec') { safetyFloor = Math.max(safetyFloor, OPUS_MEDIUM); floorWhy.push('khó hoàn tác → tối thiểu opus/medium'); }
453
451
 
452
+ // Preference floor from the profile (tier_floor): never lowers a safety floor (max), never above the ceiling.
453
+ const prefFloor = known && profile.tier_floor && Number.isInteger(profile.tier_floor[type])
454
+ ? Math.min(TOP, profile.tier_floor[type]) : 0;
455
+ const prefWhy = prefFloor > 0 ? `tier_floor của profile: ${type} tối thiểu ${tierName(prefFloor)}` : null;
456
+
454
457
  const noExploreWhy = [];
455
458
  if (score >= 9) noExploreWhy.push(`score ${score}≥9`);
456
459
  if (!verifiable) noExploreWhy.push('không kiểm chứng bằng máy');
@@ -489,6 +492,7 @@ function choose(opts) {
489
492
  tier: tierName(idx),
490
493
  role,
491
494
  task_type: type,
495
+ score,
492
496
  mode,
493
497
  verify_plan: vp ? vp.verify_plan : null,
494
498
  verify_reason: vp ? vp.verify_reason : null,
@@ -501,7 +505,7 @@ function choose(opts) {
501
505
 
502
506
  // Unknown task type (not fixed, not in the profile, no alias): its stats would never pool — ask first.
503
507
  if (!known) {
504
- return finish(Math.max(prior, safetyFloor), rt.warning, false, { action: 'ask_user', unknown_type: true });
508
+ return finish(Math.max(prior, safetyFloor), rt.warning, false, { action: 'ask_user', unknown_type: true, reason_code: 'unknown-type' });
505
509
  }
506
510
 
507
511
  if (overrideIdx !== null && overrideIdx < safetyFloor) {
@@ -509,34 +513,37 @@ function choose(opts) {
509
513
  }
510
514
  // Escalation beats override/approval: the user must decide first.
511
515
  if (esc.ask) {
512
- const userIdx = overrideIdx !== null ? overrideIdx : (approvedIdx !== null ? approvedIdx : 0);
516
+ const userIdx = overrideIdx !== null ? overrideIdx : (approvedIdx !== null ? approvedIdx : prefFloor);
513
517
  const idx = Math.max(userIdx, esc.floor, safetyFloor);
514
- return finish(idx, esc.note, false, { action: 'ask_user', fails: esc.fails, tried: esc.tried });
518
+ return finish(idx, esc.note, false, { action: 'ask_user', fails: esc.fails, tried: esc.tried, reason_code: 'ask' });
515
519
  }
516
520
 
517
521
  // Tier approved by the user in the plan table: no Thompson/exploration; only floors can raise it.
518
522
  if (approvedIdx !== null) {
519
523
  const forced = Math.max(safetyFloor, esc.floor);
520
524
  if (approvedIdx >= forced) {
521
- const n = esc.note ? `; ${esc.note}` : '';
522
- return finish(approvedIdx, `người dùng đã duyệt ${tierName(approvedIdx)} trong bảng phân công${n}`, false, { deviation: false });
525
+ let n = esc.note ? `; ${esc.note}` : '';
526
+ if (approvedIdx < prefFloor) n += `; dưới ${prefWhy} (người dùng duyệt nên giữ)`;
527
+ return finish(approvedIdx, `người dùng đã duyệt ${tierName(approvedIdx)} trong bảng phân công${n}`, false, { deviation: false, reason_code: 'plan' });
523
528
  }
524
529
  const why = [];
525
530
  if (safetyFloor > approvedIdx) why.push(`sàn an toàn ${tierName(safetyFloor)} (${floorWhy.join(', ')})`);
526
531
  if (esc.floor > approvedIdx) why.push(esc.note || `leo thang → ${tierName(esc.floor)}`);
527
- return finish(forced, `lệch khỏi tier đã duyệt ${tierName(approvedIdx)} → ${tierName(forced)}: ${why.join('; ')}`, false, { deviation: true, approved_tier: tierName(approvedIdx) });
532
+ return finish(forced, `lệch khỏi tier đã duyệt ${tierName(approvedIdx)} → ${tierName(forced)}: ${why.join('; ')}`, false, { deviation: true, approved_tier: tierName(approvedIdx), reason_code: 'plan' });
528
533
  }
529
534
  if (esc.note && !esc.ask) notes.push(esc.note);
530
535
 
531
- const floor = Math.max(safetyFloor, esc.ask ? 0 : esc.floor);
536
+ const floor = Math.max(safetyFloor, esc.ask ? 0 : esc.floor, prefFloor);
532
537
  const base = Math.min(TOP, Math.max(prior, floor));
533
538
  if (base > prior) {
534
539
  const why = floorWhy.slice();
535
540
  if (esc.floor > prior && esc.floor >= safetyFloor && !esc.ask) why.push(`leo thang → ${tierName(esc.floor)}`);
541
+ if (prefFloor > prior) why.push(prefWhy);
536
542
  notes.unshift(`sàn ${tierName(base)} cao hơn ${priorWhy} ${tierName(prior)}: ${why.join(', ') || 'luật an toàn'}`);
537
543
  } else if (floorWhy.length) {
538
544
  notes.push(`sàn an toàn: ${floorWhy.join(', ')}`);
539
545
  }
546
+ if (prefFloor > 0 && base <= prior) notes.push(prefWhy);
540
547
 
541
548
  const minIdx = noExplore ? base : floor;
542
549
  const candidates = [];
@@ -552,10 +559,15 @@ function choose(opts) {
552
559
 
553
560
  let chosen = base;
554
561
  let why;
562
+ // core 0.7.1 (C3): why this tier, as a code — thompson | floor | escalation | prior | user-prior | explore | plan | override | ask | unknown-type
563
+ let code;
564
+ let tsScore = null;
555
565
  const baseCell = cellAt(base);
556
566
  const scored = pool.filter((x) => x.cell && x.cell.enough);
557
567
  if (!baseCell || !baseCell.enough || !scored.length) {
558
568
  why = `${priorWhy} ${tierName(base)} (${baseCell ? `n=${baseCell.n}` : 'n=0'}, chưa đủ dữ liệu)`;
569
+ if (base > prior) code = esc.floor === base && esc.floor > Math.max(safetyFloor, prefFloor) ? 'escalation' : 'floor';
570
+ else code = priorWhy === 'prior' ? 'prior' : 'user-prior';
559
571
  } else {
560
572
  let best = null;
561
573
  for (const x of scored) {
@@ -568,6 +580,8 @@ function choose(opts) {
568
580
  if (!best || s < best.s) best = { i: x.i, s, p };
569
581
  }
570
582
  chosen = best.i;
583
+ code = 'thompson';
584
+ tsScore = Math.round(best.s * 1000) / 1000;
571
585
  why = `Thompson: ${tierName(chosen)} điểm ${best.s.toFixed(3)} (p≈${best.p.toFixed(2)}, ${scored.length} ô đủ dữ liệu)`;
572
586
  }
573
587
 
@@ -576,17 +590,19 @@ function choose(opts) {
576
590
  if (!noExplore && chosen - 1 >= floor && u < profile.explore_rate) {
577
591
  chosen -= 1;
578
592
  explored = true;
593
+ code = 'explore';
579
594
  why = `thăm dò xuống ${tierName(chosen)} (xác suất ${profile.explore_rate}); ${why}`;
580
595
  }
581
596
 
582
597
  if (overrideIdx !== null) {
583
598
  const sugg = tierName(chosen);
584
- return finish(overrideIdx, `người dùng chọn ${tierName(overrideIdx)} (đề xuất là ${sugg}: ${why})`, false, {
585
- overridden: overrideIdx !== chosen, suggested_tier: sugg,
599
+ const below = overrideIdx < prefFloor ? `; dưới ${prefWhy} (người dùng chọn nên giữ)` : '';
600
+ return finish(overrideIdx, `người dùng chọn ${tierName(overrideIdx)} (đề xuất là ${sugg}: ${why})${below}`, false, {
601
+ overridden: overrideIdx !== chosen, suggested_tier: sugg, reason_code: 'override', suggested_reason_code: code, thompson_score: tsScore,
586
602
  });
587
603
  }
588
604
  if (noExplore) notes.push(`không thăm dò: ${noExploreWhy.join(', ')}`);
589
- return finish(chosen, [why].concat(notes).join('; '), explored);
605
+ return finish(chosen, [why].concat(notes).join('; '), explored, { reason_code: code, thompson_score: tsScore });
590
606
  }
591
607
 
592
608
  module.exports = {
package/lib/commands.cjs CHANGED
@@ -65,7 +65,7 @@ function cmdSetup(opts) {
65
65
  const lines = [
66
66
  `Đã cài hook: ${res.hook}`,
67
67
  res.settings_changed
68
- ? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) + UserPromptSubmit trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
68
+ ? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) + UserPromptSubmit + PreToolUse(Agent) trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
69
69
  : `settings.json đã có hook, không đổi gì (${res.settings})`,
70
70
  `Agent dp-*: ${res.agents.map((a) => `${a.name}=${a.action}`).join(', ')}${res.agents.some((a) => a.action === 'skipped-foreign') ? ' (file trùng tên không phải của dieu-phoi: giữ nguyên, đổi tên rồi chạy lại setup)' : ''}`,
71
71
  `Thư mục báo cáo subagent: ${res.runs_dir} (quyền: ${res.permissions.allow.join(', ')} + additionalDirectories)`,
@@ -128,6 +128,9 @@ async function cmdDoctor(opts) {
128
128
  add(reg.UserPromptSubmit, 'prompt-hook', reg.UserPromptSubmit
129
129
  ? 'UserPromptSubmit có — prompt "điều phối" được tạo sẵn task_id + run dir, lượt đầu là scout'
130
130
  : 'thiếu hook UserPromptSubmit — mỗi lần điều phối phải chạy `dieu-phoi start` trước scout; chạy lại `dieu-phoi setup`', 'CẢNH BÁO');
131
+ add(reg.PreToolUse, 'chain-gate', reg.PreToolUse
132
+ ? 'PreToolUse(Agent) có — chặn exec tiếp nối từ handoff thứ 3 (trừ thẻ chain_ok)'
133
+ : 'thiếu hook PreToolUse(Agent) (core 0.7.1) — chuỗi handoff dài không bị chặn; chạy lại `dieu-phoi setup`', 'CẢNH BÁO');
131
134
  const permOk = reg.permissions && reg.runs_dir_exists && !reg.legacy_permissions;
132
135
  add(permOk, 'run-permissions', permOk
133
136
  ? `${reg.permission_entries.allow.join(', ')} + additionalDirectories ${reg.permission_entries.dir}`
@@ -726,19 +729,27 @@ function measureOrch(opts, ctx, prof) {
726
729
  u = core.orchestratorUsage(file, core.loadPricing(ctx), { cacheFile: core.orchCacheFile(ctx.paths, sid, file) });
727
730
  }
728
731
  } catch (_) { /* measurement never fails the caller */ }
729
- const over = typeof u.ctx_growth === 'number' && u.ctx_growth > softCap;
732
+ // core 0.7.1 (retro 2026-10-26, C1): past ORCH_NEW_SESSION_TOKENS or ORCH_NEW_SESSION_COMPACTIONS a /compact no longer
733
+ // helps (session 36cee993: 946k, 20 compactions, 0 user verdicts) -> advise a new session instead.
734
+ const newSession = (typeof u.ctx_tokens === 'number' && u.ctx_tokens > ORCH_NEW_SESSION_TOKENS)
735
+ || (typeof u.compactions === 'number' && u.compactions >= ORCH_NEW_SESSION_COMPACTIONS);
736
+ const over = (typeof u.ctx_growth === 'number' && u.ctx_growth > softCap) || newSession;
730
737
  let advice;
731
738
  if (!sid) advice = 'Không đo được: không có session (thiếu CLAUDE_CODE_SESSION_ID, dùng --session <id>).';
732
739
  else if (u.ctx_tokens === null) advice = 'Không đo được: không thấy transcript của session này.';
733
- else if (over) advice = `Context điều phối đã tăng ${u.ctx_growth} token (nền ${u.ctx_baseline}, nay ${u.ctx_tokens}) vượt trần mềm ${softCap}: gõ /compact để nén phiên — trạng thái điều phối sẽ được nạp lại tự động.`;
740
+ else if (newSession) {
741
+ advice = `Phiên điều phối đã quá lớn (context ${u.ctx_tokens} token, nén ${u.compactions === null ? '?' : u.compactions} lần; ngưỡng ${ORCH_NEW_SESSION_TOKENS} token hoặc ${ORCH_NEW_SESSION_COMPACTIONS} lần nén): đừng /compact thêm — ghi trạng thái (\`dieu-phoi state --session ${sid}\` + file trạng thái trong run dir), trình cổng 3 phần đã xong, rồi MỞ PHIÊN MỚI và nạp lại từ \`state\`.`;
742
+ } else if (over) advice = `Context điều phối đã tăng ${u.ctx_growth} token (nền ${u.ctx_baseline}, nay ${u.ctx_tokens}) vượt trần mềm ${softCap}: gõ /compact để nén phiên — trạng thái điều phối sẽ được nạp lại tự động.`;
734
743
  else advice = `Context điều phối tăng ${u.ctx_growth}/${softCap} token so với nền ${u.ctx_baseline}, còn trong trần mềm.`;
735
744
  return {
736
745
  session_id: sid, ctx_tokens: u.ctx_tokens, ctx_baseline: u.ctx_baseline, ctx_growth: u.ctx_growth,
737
- cum_usd: u.cum_usd, compactions: u.compactions, soft_cap: softCap, over, advice,
746
+ cum_usd: u.cum_usd, compactions: u.compactions, soft_cap: softCap, over, new_session: newSession, advice,
738
747
  };
739
748
  }
740
749
 
741
750
  const CTX_WARN_KEEP_MS = 14 * 24 * 3600 * 1000;
751
+ const ORCH_NEW_SESSION_TOKENS = 400000;
752
+ const ORCH_NEW_SESSION_COMPACTIONS = 5;
742
753
 
743
754
  /** 0 = within the cap; 1 = over the cap, 2 = over 2×, 3 = over 4×, … */
744
755
  function ctxWarnLevel(growth, cap) {
@@ -808,13 +819,18 @@ function cmdState(opts) {
808
819
  const ctx = ctxOf(opts);
809
820
  const sid = typeof opts.session === 'string' && opts.session ? opts.session
810
821
  : (process.env.CLAUDE_CODE_SESSION_ID || null);
822
+ // retro C6: knowledge deltas older than 24 h that no store has applied (file reads only, never throws)
823
+ const deltas = core.scanUnmergedDeltas();
824
+ const deltaLines = core.unmergedDeltaLines(deltas);
811
825
  if (!sid) {
812
- if (opts.json) return { code: 0, out: JSON.stringify({ session_id: null, generated_at: core.nowIso(), runs_dir: null, plans: [], tasks: [] }) };
813
- return { code: 0, out: 'Không có session (thiếu CLAUDE_CODE_SESSION_ID, dùng --session <id>).' };
826
+ if (opts.json) {
827
+ return { code: 0, out: JSON.stringify(Object.assign({ session_id: null, generated_at: core.nowIso(), runs_dir: null, plans: [], tasks: [] }, deltas.count ? { unmerged_deltas: deltas } : {})) };
828
+ }
829
+ return { code: 0, out: ['Không có session (thiếu CLAUDE_CODE_SESSION_ID, dùng --session <id>).'].concat(deltaLines).join('\n') };
814
830
  }
815
831
  const state = core.buildSessionState(core.readEvents(ctx), sid, { plansDir: ctx.paths.plans });
816
- if (opts.json) return { code: 0, out: JSON.stringify(state, null, 2) };
817
- return { code: 0, out: core.formatSessionState(state, core.STATE_TEXT_CAP) };
832
+ if (opts.json) return { code: 0, out: JSON.stringify(deltas.count ? Object.assign({}, state, { unmerged_deltas: deltas }) : state, null, 2) };
833
+ return { code: 0, out: [core.formatSessionState(state, core.STATE_TEXT_CAP)].concat(deltaLines).join('\n') };
818
834
  }
819
835
 
820
836
  // ------------------------------------------------------------------ stats / retro
@@ -832,13 +848,13 @@ function cmdStats(opts) {
832
848
  const ctx = ctxOf(opts);
833
849
  const { lambda } = lambdaOf(opts, ctx);
834
850
  const events = core.readEvents(ctx);
835
- const { cells, steers, orchestration, tasks } = agg.analyze(events, { lambda });
851
+ const { cells, steers, orchestration, tasks, batch_verify: batchVerify } = agg.analyze(events, { lambda });
836
852
  const modes = agg.modeSummary(events, tasks);
837
853
  const type = typeof opts.type === 'string' ? opts.type : null;
838
854
  const rows = type ? cells.filter((c) => c.task_type === type) : cells;
839
855
  const steerRows = type ? steers.filter((s) => s.task_type === type) : steers;
840
856
  if (opts.json) {
841
- return { code: 0, out: JSON.stringify({ lambda_usd_per_min: lambda, min_n: agg.MIN_N, cells: rows, steers: steerRows, orchestration, modes }, null, 2) };
857
+ return { code: 0, out: JSON.stringify({ lambda_usd_per_min: lambda, min_n: agg.MIN_N, cells: rows, steers: steerRows, orchestration, modes, batch_verify: batchVerify }, null, 2) };
842
858
  }
843
859
  if (!rows.length) {
844
860
  return { code: 0, out: orchestration.sessions ? `Chưa có dữ liệu theo ô.\n${orchLine(orchestration)}` : 'Chưa có dữ liệu.' };
@@ -865,7 +881,7 @@ function cmdStats(opts) {
865
881
  }
866
882
  return {
867
883
  code: 0,
868
- out: `${table(t)}\n\n${table(s)}\n\n${modeTable(modes)}${orchLine(orchestration)}\n\n`
884
+ out: `${table(t)}\n\n${table(s)}\n\n${modeTable(modes)}${batchVerifyTable(batchVerify)}${orchLine(orchestration)}\n\n`
869
885
  + 'n = task tính tỉ lệ (/số đã giao, (-k) = bị loại: reject-scope hoặc lượt exec đầu bị steer plan/redirect). '
870
886
  + '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. '
871
887
  + `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.`,
@@ -890,6 +906,20 @@ function modeTable(modes) {
890
906
  return `${table(t)}\n\n`;
891
907
  }
892
908
 
909
+ /** core 0.7.1 (C5): the batched verifiers' own cell (model/effort), kept out of the task cells; '' when none. */
910
+ function batchVerifyTable(rows) {
911
+ if (!rows || !rows.length) return '';
912
+ const t = [['verifier gộp', 'lần', 'task', 'Σ cost', 'E[cost]', 'p50 cost', 'p50 time']];
913
+ for (const r of rows) {
914
+ t.push([
915
+ `${chooser.shortModel(r.model) || r.model || '?'}/${r.effort || '?'}`, r.n, r.tasks,
916
+ fmtUsd(r.cost_total_usd), fmtUsd(r.cost_mean_usd), fmtUsd(r.cost_p50_usd),
917
+ r.time_p50_min === null ? '-' : `${fmtNum(r.time_p50_min)}m`,
918
+ ]);
919
+ }
920
+ return `${table(t)}\n(chi phí verifier gộp là ô riêng, không cộng vào ô task_type × tier của bước fast mang task_id của nó)\n\n`;
921
+ }
922
+
893
923
  /** One retro line: per mode with tasks. */
894
924
  function modeRetroLine(modes) {
895
925
  const parts = agg.MODE_KEYS.filter((m) => modes[m].tasks).map((m) => {
@@ -906,7 +936,7 @@ function cmdRetro(opts) {
906
936
  const ctx = ctxOf(opts);
907
937
  const { prof, lambda } = lambdaOf(opts, ctx);
908
938
  const events = core.readEvents(ctx);
909
- const { agents, tasks, cells, steers, userVerdicts, overrides, orchestration } = agg.analyze(events, { lambda });
939
+ const { agents, tasks, cells, steers, userVerdicts, overrides, orchestration, batch_verify: batchVerify } = agg.analyze(events, { lambda });
910
940
  const modes = agg.modeSummary(events, tasks);
911
941
  const reasons = {};
912
942
  const outcomes = {};
@@ -964,6 +994,7 @@ function cmdRetro(opts) {
964
994
  overrides: overrides.map((o) => ({ ts: o.ts, task_type: o.task_type, suggested_tier: o.suggested_tier, chosen_tier: o.chosen_tier })),
965
995
  orchestration,
966
996
  modes,
997
+ batch_verify: batchVerify,
967
998
  cells,
968
999
  };
969
1000
  if (opts.json) return { code: 0, out: JSON.stringify(summary, null, 2) };
@@ -977,6 +1008,7 @@ function cmdRetro(opts) {
977
1008
  `Steer nhiều (brainstorm hỏi quá ít): ${highSteer.map((s) => `${s.task_type} ${pct(s.steer_rate)}`).join(', ') || '-'}`,
978
1009
  orchLine(orchestration),
979
1010
  modeRetroLine(modes),
1011
+ ...(batchVerify.length ? [`Verifier gộp (ô riêng): ${batchVerify.map((r) => `${chooser.shortModel(r.model) || r.model || '?'}/${r.effort || '?'} ×${r.n}, Σ ${fmtUsd(r.cost_total_usd)}, p50 ${r.time_p50_min === null ? '-' : `${fmtNum(r.time_p50_min)}m`}`).join(' · ')}`] : []),
980
1012
  'Chi tiết theo ô: `dieu-phoi stats`; đưa cho LLM: `dieu-phoi retro --json`.',
981
1013
  ];
982
1014
  return { code: 0, out: lines.join('\n') };
@@ -1062,6 +1094,7 @@ function cmdChoose(opts) {
1062
1094
  res.orch_ctx_tokens = orch.ctx_tokens;
1063
1095
  res.orch_ctx_growth = orch.ctx_growth;
1064
1096
  res.orch_ctx_over = orch.over;
1097
+ res.orch_new_session = orch.new_session === true;
1065
1098
  res.orch_ctx_warn = ctxWarnOnce(ctx, orch); // over AND a new threshold crossed since the last warning
1066
1099
  if (res.overridden) {
1067
1100
  const tid = input.taskId;
@@ -1072,6 +1105,8 @@ function cmdChoose(opts) {
1072
1105
  core.recordSuggestion(ctx, {
1073
1106
  task_id: input.taskId, role: res.role || input.role, suggested_tier: res.suggested_tier || res.tier, choose_tier: res.tier,
1074
1107
  source: 'choose', keep_suggested: input.approved !== null && !res.overridden,
1108
+ choose_task_type: res.task_type, choose_score: res.score, explored: res.explored, reason_code: res.reason_code,
1109
+ thompson_score: res.thompson_score === null ? undefined : res.thompson_score,
1075
1110
  });
1076
1111
  }
1077
1112
  if (opts.json) return { code: 0, out: JSON.stringify(res) };
@@ -1257,6 +1292,8 @@ function recordPlanSuggestions(ctx, plan) {
1257
1292
  if (!r.task_id || !r.tier || r.action !== 'dispatch') continue;
1258
1293
  const ok = core.recordSuggestion(ctx, {
1259
1294
  task_id: r.task_id, role: r.role || 'exec', suggested_tier: r.suggested_tier || r.tier, choose_tier: r.tier, source: 'plan',
1295
+ choose_task_type: r.task_type, choose_score: r.score, explored: r.explored, reason_code: r.reason_code,
1296
+ thompson_score: r.thompson_score === null ? undefined : r.thompson_score,
1260
1297
  });
1261
1298
  if (ok) n++;
1262
1299
  }
package/lib/knowledge.cjs CHANGED
@@ -14,6 +14,7 @@
14
14
  //
15
15
  // Delta (written by agents into a run dir as knowledge-delta*.md, merged at gate 3):
16
16
  // repo: <path of the repo/worktree the delta was checked against> (header, before the first `## `)
17
+ // or a short name (`pff-tailor`) that matches exactly one known store (its name / dir name / a recorded root)
17
18
  // head: <sha> (optional; else HEAD read from repo:)
18
19
  // ## +<id> new section ## <id> replace an existing one
19
20
  // title: / tags: a, b / paths: glob, glob / base: <sha>#<rev> (meta lines right under the heading)
@@ -27,7 +28,6 @@
27
28
  const fs = require('fs');
28
29
  const path = require('path');
29
30
  const cp = require('child_process');
30
- const crypto = require('crypto');
31
31
  const core = require('../hook.cjs');
32
32
  const { repoOf } = require('./args.cjs');
33
33
 
@@ -55,7 +55,8 @@ const KNOWLEDGE_HELP = `Cách dùng: dieu-phoi knowledge <lệnh> [--repo <đư
55
55
  merge --run <id|thư mục run> [--task <id>[,<id>] [--untagged]] [--dry-run] [--json]
56
56
  gộp mọi knowledge-delta*.md của run (--task: chỉ knowledge-delta-<task_id>[-r<n>].md của các
57
57
  task đó, --untagged: thêm file không mang task_id; verdict accept tự gọi bước này) vào kho của repo ghi ở header "repo:" (không có thì
58
- --repo/cwd): khoá knowledge.lock, ghi tmp+rename, bản cũ vào history/, sha = HEAD của repo đó.
58
+ --repo/cwd; "repo:" là tên ngắn như pff-tailor thì khớp kho đã biết nếu duy nhất — HEAD lấy từ repo đã ghi
59
+ nhớ (meta.json roots), --repo hoặc dòng head:): khoá knowledge.lock, ghi tmp+rename, bản cũ vào history/, sha = HEAD của repo đó.
59
60
  "## +id" thêm mục (bắt buộc paths:), "## id" thay mục (bắt buộc base: <sha>#<rev>); base cũ
60
61
  → xung đột, không ghi đè; mục > 40 dòng bị từ chối. Có xung đột/từ chối → exit 2.
61
62
  Idempotent: file delta đã gộp (hash trong meta.json) và mục trùng một rev cũ trong history/ → "không đổi"
@@ -609,8 +610,25 @@ function sameContent(cur, body, next) {
609
610
  const APPLIED_MAX = 300;
610
611
 
611
612
  /** sha256 of the delta text, BOM and CRLF normalised so a CRLF checkout hashes like the LF original. */
612
- function deltaHash(text) {
613
- return crypto.createHash('sha256').update(String(text).replace(/^/, '').replace(/\r\n?/g, '\n')).digest('hex');
613
+ const deltaHash = core.deltaHash; // shared with the unmerged-delta scan in hook.cjs
614
+
615
+ /** Main checkout of the repo `info` describes (a linked worktree is deleted later; the checkout stays). */
616
+ function mainRootOf(info) {
617
+ const common = info && info.common_dir ? String(info.common_dir) : '';
618
+ if (common && path.basename(common) === '.git') return slash(path.dirname(common));
619
+ return info && info.root ? slash(info.root) : null;
620
+ }
621
+
622
+ /** Remember where a store's repo lives (meta.json `roots`, newest first, <= 5) so a bare `repo: <name>` can find HEAD. */
623
+ function noteRoot(dir, info) {
624
+ const root = mainRootOf(info);
625
+ if (!root || !isDir(dir)) return;
626
+ const file = path.join(dir, 'meta.json');
627
+ const meta = core.readJsonFile(file, {}) || {};
628
+ const roots = Array.isArray(meta.roots) ? meta.roots : [];
629
+ if (roots[0] === root) return;
630
+ const next = [root].concat(roots.filter((r) => r !== root)).slice(0, 5);
631
+ core.writeFileAtomic(file, `${JSON.stringify(Object.assign({}, meta, { roots: next }), null, 2)}\n`);
614
632
  }
615
633
 
616
634
  function appliedOf(dir) {
@@ -741,9 +759,11 @@ function applyDelta(delta, info, sha, dry, src) {
741
759
  core.writeFileAtomic(path.join(dir, 'index.json'), `${JSON.stringify(idx, null, 2)}\n`);
742
760
  ensureMeta(dir, info);
743
761
  }
744
- if (changed && !dry && src && !results.some((r) => r.op === 'conflict' || r.op === 'reject')) {
762
+ if (!dry && src && isDir(dir) && !results.some((r) => r.op === 'conflict' || r.op === 'reject')) {
763
+ // also when every section was already merged ("same"): the file counts as handled, not as an unmerged delta
745
764
  recordApplied(dir, info, { sha256: src.hash, file: src.file, run: src.run, at: now.toISOString() });
746
765
  }
766
+ if (!dry && changed && info.common_dir) noteRoot(dir, info);
747
767
  return { results };
748
768
  }
749
769
 
@@ -766,6 +786,45 @@ function describe(r) {
766
786
  return `${OP_MARK[r.op] || '?'} ${r.id} BỊ TỪ CHỐI: ${r.why}`;
767
787
  }
768
788
 
789
+ /**
790
+ * Where a delta goes: { target, info } — target = repo dir to read HEAD from (null when only the store is known),
791
+ * info = project identity (resolveProject, or the bare store entry for a short `repo:` name). Order: a short name that
792
+ * matches exactly one known store, then the path, then --repo as the fallback for a repo that no longer exists.
793
+ */
794
+ function targetOf(delta, opts, rep) {
795
+ const err = (error) => ({ error });
796
+ if (!delta.repo) return finish(repoOf(opts));
797
+ if (core.isBareRepoName(delta.repo)) {
798
+ const hit = core.storeByName(delta.repo);
799
+ if (hit.matches.length > 1) {
800
+ return err(`repo: ${delta.repo} khớp ${hit.matches.length} kho (${hit.matches.map((m) => m.dir_name).join(', ')}) — ghi đường dẫn đầy đủ hoặc chạy với --repo`);
801
+ }
802
+ if (hit.store) {
803
+ const st = hit.store;
804
+ rep.note = `repo: ${delta.repo} → kho ${st.dir_name}`;
805
+ const cands = st.roots.map((r) => core.nativePath(r)).concat(opts.repo !== undefined ? [repoOf(opts)] : []);
806
+ for (const c of cands) {
807
+ if (!isDir(c)) continue;
808
+ const pi = core.resolveProject(c);
809
+ if (pi && pi.dir === st.dir) return { target: path.resolve(c), info: pi };
810
+ }
811
+ return { target: null, info: { dir: st.dir, dir_name: st.dir_name, key: st.key, name: st.name } };
812
+ }
813
+ }
814
+ if (delta.repo) {
815
+ const p = path.resolve(core.nativePath(delta.repo));
816
+ if (isDir(p)) return finish(p);
817
+ if (opts.repo !== undefined) { rep.note = `repo: ${delta.repo} không còn — dùng --repo`; return finish(repoOf(opts)); }
818
+ const bare = core.isBareRepoName(delta.repo);
819
+ return err(`repo: ${delta.repo} ${bare ? 'không khớp kho nào đã biết và không phải thư mục' : 'không tồn tại (worktree đã xoá?)'} — chạy lại với --repo <đường dẫn repo>`);
820
+ }
821
+ return err('không xác định được repo');
822
+ function finish(target) {
823
+ const info = core.resolveProject(target);
824
+ return info ? { target, info } : err(`không phải repo git: ${slash(target)}`);
825
+ }
826
+ }
827
+
769
828
  function cmdMerge(opts) {
770
829
  const rd = runDirOf(opts.run);
771
830
  if (rd.error) return { code: 1, out: rd.error };
@@ -786,23 +845,20 @@ function cmdMerge(opts) {
786
845
  src = { hash: deltaHash(text), file: path.basename(file), run: path.basename(rd.dir) };
787
846
  } catch (e) { rep.error = `không đọc được: ${e.message}`; totals.error++; continue; }
788
847
  if (!delta.sections.length) { rep.error = 'không có mục "## <id>" nào'; totals.error++; continue; }
789
- let target = null;
790
- if (delta.repo) {
791
- const p = path.resolve(core.nativePath(delta.repo));
792
- if (isDir(p)) target = p;
793
- else if (opts.repo !== undefined) { target = repoOf(opts); rep.note = `repo: ${delta.repo} không còn — dùng --repo`; } else {
794
- rep.error = `repo: ${delta.repo} không tồn tại (worktree đã xoá?) — chạy lại với --repo <đường dẫn repo>`; totals.error++; continue;
795
- }
796
- } else target = repoOf(opts);
797
- const info = core.resolveProject(target);
798
- if (!info) { rep.error = `không phải repo git: ${slash(target)}`; totals.error++; continue; }
848
+ const t = targetOf(delta, opts, rep);
849
+ if (t.error) { rep.error = t.error; totals.error++; continue; }
850
+ const { target, info } = t;
799
851
  let sha = null;
800
852
  if (delta.head) {
801
853
  if (!/^[0-9a-f]{7,64}$/i.test(delta.head)) { rep.error = `head: không phải sha: ${delta.head}`; totals.error++; continue; }
802
854
  sha = delta.head.toLowerCase();
803
- } else sha = headShaOf(target);
804
- if (!sha) { rep.error = `không xác định được HEAD của ${slash(target)} (repo chưa có commit?) — thêm dòng "head: <sha>" vào delta`; totals.error++; continue; }
805
- Object.assign(rep, { repo: slash(target), store: info.dir, key: info.key, sha });
855
+ } else if (target) sha = headShaOf(target);
856
+ if (!sha) {
857
+ rep.error = target ? `không xác định được HEAD của ${slash(target)} (repo chưa có commit?) — thêm dòng "head: <sha>" vào delta`
858
+ : `repo: ${delta.repo} khớp kho ${info.dir_name} nhưng chưa biết repo nằm đâu để đọc HEAD — thêm dòng "head: <sha>" vào delta hoặc chạy với --repo <đường dẫn repo>`;
859
+ totals.error++; continue;
860
+ }
861
+ Object.assign(rep, { repo: target ? slash(target) : null, store: info.dir, key: info.key, sha });
806
862
  let out;
807
863
  if (dry) out = applyDelta(delta, info, sha, true, src);
808
864
  else {
package/lib/setup.cjs CHANGED
@@ -57,7 +57,11 @@ function stripOurHooks(settings) {
57
57
  return removed;
58
58
  }
59
59
 
60
- /** True when settings already hold exactly our four entries with this command. */
60
+ // core 0.7.1: the subagent tool is `Agent` in current Claude Code and `Task` in older builds — match both
61
+ // (an old `Agent`-only registration is not "already registered", so `setup` rewrites it).
62
+ const AGENT_MATCHER = 'Agent|Task';
63
+
64
+ /** True when settings already hold exactly our five entries with this command. */
61
65
  function alreadyRegistered(settings, cmd) {
62
66
  const hooks = settings && settings.hooks;
63
67
  if (!hooks || typeof hooks !== 'object') return false;
@@ -69,14 +73,15 @@ function alreadyRegistered(settings, cmd) {
69
73
  if (!isOurs(h)) continue;
70
74
  ours++;
71
75
  if (h.command !== cmd) continue;
72
- if (event === 'PostToolUse' && g.matcher === 'Agent') good++;
76
+ if (event === 'PostToolUse' && g.matcher === AGENT_MATCHER) good++;
73
77
  else if (event === 'SubagentStop') good++;
74
78
  else if (event === 'SessionStart' && g.matcher === 'compact') good++;
75
79
  else if (event === 'UserPromptSubmit' && !g.matcher) good++;
80
+ else if (event === 'PreToolUse' && g.matcher === AGENT_MATCHER) good++;
76
81
  }
77
82
  }
78
83
  }
79
- return ours === 4 && good === 4;
84
+ return ours === 5 && good === 5;
80
85
  }
81
86
 
82
87
  function addOurHooks(settings, hookPath) {
@@ -84,7 +89,7 @@ function addOurHooks(settings, hookPath) {
84
89
  if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
85
90
  const h = settings.hooks;
86
91
  if (!Array.isArray(h.PostToolUse)) h.PostToolUse = [];
87
- h.PostToolUse.push({ matcher: 'Agent', hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
92
+ h.PostToolUse.push({ matcher: AGENT_MATCHER, hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
88
93
  if (!Array.isArray(h.SubagentStop)) h.SubagentStop = [];
89
94
  h.SubagentStop.push({ hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
90
95
  // after /compact or auto-compact: re-inject the orchestration state rebuilt from the log
@@ -93,6 +98,9 @@ function addOurHooks(settings, hookPath) {
93
98
  // on a dieu-phoi prompt: pre-create the run dir + id so the first tool call can be the scout (no shell)
94
99
  if (!Array.isArray(h.UserPromptSubmit)) h.UserPromptSubmit = [];
95
100
  h.UserPromptSubmit.push({ hooks: [{ type: 'command', command: cmd, timeout: 5 }] });
101
+ // core 0.7.1 (C1): deny another exec continuation of a task that already handed off 3 times (unless chain_ok)
102
+ if (!Array.isArray(h.PreToolUse)) h.PreToolUse = [];
103
+ h.PreToolUse.push({ matcher: AGENT_MATCHER, hooks: [{ type: 'command', command: cmd, timeout: 5 }] });
96
104
  }
97
105
 
98
106
  // ------------------------------------------------------------------ run-dir permissions
@@ -413,7 +421,7 @@ function registration(claudeDir) {
413
421
  const perms = permissionEntries(runsDir());
414
422
  const legacy = permissionEntries(legacyRunsDirOf(claudeDir));
415
423
  const out = {
416
- file, PostToolUse: false, SubagentStop: false, SessionStart: false, UserPromptSubmit: false, commands: [], permissions: false, permission_entries: perms,
424
+ file, PostToolUse: false, SubagentStop: false, SessionStart: false, UserPromptSubmit: false, PreToolUse: false, commands: [], permissions: false, permission_entries: perms,
417
425
  runs_dir: perms.dir, runs_dir_exists: fs.existsSync(perms.dir), legacy_permissions: false,
418
426
  };
419
427
  let obj;
@@ -423,11 +431,11 @@ function registration(claudeDir) {
423
431
  out.legacy_permissions = legacy.allow.some((r) => Array.isArray(lp.allow) && lp.allow.includes(r))
424
432
  || (Array.isArray(lp.additionalDirectories) && lp.additionalDirectories.includes(legacy.dir));
425
433
  const hooks = obj.hooks || {};
426
- for (const ev of ['PostToolUse', 'SubagentStop', 'SessionStart', 'UserPromptSubmit']) {
434
+ for (const ev of ['PostToolUse', 'SubagentStop', 'SessionStart', 'UserPromptSubmit', 'PreToolUse']) {
427
435
  for (const g of Array.isArray(hooks[ev]) ? hooks[ev] : []) {
428
436
  for (const h of (g && Array.isArray(g.hooks) ? g.hooks : [])) {
429
437
  if (!isOurs(h)) continue;
430
- if (ev === 'PostToolUse' && g.matcher && !/\bAgent\b/.test(g.matcher)) continue;
438
+ if ((ev === 'PostToolUse' || ev === 'PreToolUse') && g.matcher && !/\b(Agent|Task)\b/.test(g.matcher)) continue;
431
439
  if (ev === 'SessionStart' && g.matcher !== 'compact') continue;
432
440
  out[ev] = true;
433
441
  out.commands.push(h.command);
@@ -438,6 +446,6 @@ function registration(claudeDir) {
438
446
  }
439
447
 
440
448
  module.exports = {
441
- MARKER, AGENT_NAMES, AGENT_MARKER, renderAgent, agentStatus, nodePathOf, installAgents, removeAgents, agentsDirOf, setup, uninstall, registration, stripOurHooks, addOurHooks, alreadyRegistered, hookCommand,
449
+ MARKER, AGENT_NAMES, AGENT_MARKER, renderAgent, agentStatus, nodePathOf, installAgents, removeAgents, agentsDirOf, setup, uninstall, registration, stripOurHooks, addOurHooks, alreadyRegistered, hookCommand, AGENT_MATCHER,
442
450
  skillVersionOf, findSkillDir, tsStamp, runsDir, legacyRunsDirOf, permissionEntries, addOurPermissions, stripOurPermissions, hasOurPermissions,
443
451
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kienntpixon/dieu-phoi",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
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>",