@kienntpixon/dieu-phoi 0.7.1 → 0.7.2

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/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.1';
29
+ const CORE_VERSION = '0.7.2';
30
30
  const SCHEMA = 1;
31
31
  const TASK_TYPES = [
32
32
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -272,6 +272,8 @@ function parseTag(text) {
272
272
  tag.parent_task_id = mm[1]; // core 0.5.0: gen-2 helper -> the dp-exec step that spawned it
273
273
  } else if (p === 'chain_ok') {
274
274
  tag.chain_ok = true; // core 0.7.1 (C1): the user agreed to continue a long handoff chain
275
+ } else if (p === 'session_ok') {
276
+ tag.session_ok = true; // core 0.7.2 (K5): the user agreed to open a new root in an oversized session
275
277
  } else if (p === 'batch') {
276
278
  tag.batch = true; // core 0.7.1 (C5): the one batched verifier of a fast request
277
279
  }
@@ -331,6 +333,9 @@ const TYPE_ALIASES = {
331
333
  'art-fix': 'art-gen', art: 'art-gen',
332
334
  // retro 2026-10-26 (C2): scout/read labels
333
335
  scan: 'search', read: 'search', 'code-read': 'search', explore: 'search', scout: 'search', lookup: 'search',
336
+ // core 0.7.2 (K6, retro 2026-11-05): labels that still arrived as bad-type in the 11-05 logs
337
+ 'test-run': 'test-write', 'test-verify': 'test-write', 'device-qa': 'test-write',
338
+ 'git-ops': 'mechanical-edit', 'release-build': 'mechanical-edit', 'read-scan': 'search',
334
339
  };
335
340
 
336
341
  /** Fixed task types + the profile's extra_task_types (art-gen when no profile is readable). */
@@ -634,7 +639,7 @@ function summarizeTranscript(lines, opts) {
634
639
  // ------------------------------------------------------------------ orchestrator (main session) usage
635
640
 
636
641
  const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
637
- const ORCH_CACHE_V = 2; // 2: + compactions
642
+ const ORCH_CACHE_V = 2; // 2: + compactions (0.7.2: a cold-probe cache with compactions null is re-probed, see orchestratorUsage)
638
643
  const ORCH_CHUNK = 1 << 20; // read granularity; memory = chunk + longest line
639
644
  const ORCH_SEEN_KEEP = 64; // requestIds kept for last-line-wins dedupe across scans
640
645
  const ORCH_HEAD_BYTES = 4096; // fingerprint of the file start (rotation / replacement check)
@@ -682,8 +687,9 @@ function ctxOfBuckets(b) {
682
687
  * passed to onLine (return true to stop). Returns { consumed: byte just past the last complete line
683
688
  * handled, read: bytes read }.
684
689
  */
685
- function scanUsageLines(fd, start, end, onLine) {
690
+ function scanUsageLines(fd, start, end, onLine, needle) {
686
691
  const buf = Buffer.allocUnsafe(ORCH_CHUNK);
692
+ const one = needle || null; // null: usage or compaction lines; else only lines holding `needle`
687
693
  let carry = null;
688
694
  let pos = start;
689
695
  let consumed = start;
@@ -700,8 +706,9 @@ function scanUsageLines(fd, start, end, onLine) {
700
706
  while ((nl = data.indexOf(0x0a, s)) >= 0) {
701
707
  const line = data.subarray(s, nl);
702
708
  s = nl + 1;
703
- if ((line.indexOf(USAGE_NEEDLE) >= 0 || line.indexOf(COMPACT_NEEDLE) >= 0)
704
- && onLine(line.toString('utf8')) === true) {
709
+ const hit = one ? line.indexOf(one) >= 0
710
+ : (line.indexOf(USAGE_NEEDLE) >= 0 || line.indexOf(COMPACT_NEEDLE) >= 0);
711
+ if (hit && onLine(line.toString('utf8')) === true) {
705
712
  return { consumed: base + s, read };
706
713
  }
707
714
  }
@@ -772,13 +779,22 @@ function edgeProbe(fd, size, st) {
772
779
  }
773
780
  st.cum_known = false;
774
781
  st.requests = null;
775
- st.compactions = null; // unknown until a full scan
782
+ st.offset = lastNl >= 0 ? lastNl + 1 : 0;
783
+ // core 0.7.2 (K5): count compactions over [0, offset) too (byte scan, JSON only on hits). The incremental
784
+ // scan takes over from offset, so nothing is counted twice; before 0.7.2 this stayed null for the session.
785
+ st.compactions = countCompactions(fd, st.offset);
776
786
  if (first) {
777
787
  st.baseline = ctxOfBuckets(usageBuckets(first.usage));
778
788
  st.baseline_req = first.key;
779
789
  }
780
790
  if (last) st.last_ctx = ctxOfBuckets(usageBuckets(last.usage));
781
- st.offset = lastNl >= 0 ? lastNl + 1 : 0;
791
+ }
792
+
793
+ /** Main-thread compaction markers in [0, end) (sidechain and look-alike text ignored). Never throws. */
794
+ function countCompactions(fd, end) {
795
+ let n = 0;
796
+ if (end > 0) scanUsageLines(fd, 0, end, (t) => { const u = parseMainLine(t); if (u && u.compact) n++; return false; }, COMPACT_NEEDLE);
797
+ return n;
782
798
  }
783
799
 
784
800
  /** Drop stale session caches. Only ever touches <data>/orch/*.json (never another directory). */
@@ -814,8 +830,9 @@ function orchCacheFile(paths, sessionId, transcriptPath) {
814
830
  * opts.cacheFile: per-session state ({offset, cost, seen requestIds, baseline, …}); each call then parses
815
831
  * only the bytes appended since the last call. A shrunk or replaced file (offset > size or a different
816
832
  * head fingerprint) is recomputed from 0.
817
- * opts.coldMaxBytes: no usable cache and the file is larger -> head/tail probe only (ctx + baseline,
818
- * cum_usd null); a later call without the limit (`dieu-phoi ctx`) recomputes the full sum.
833
+ * opts.coldMaxBytes: no usable cache and the file is larger -> head/tail probe (ctx + baseline, cum_usd null)
834
+ * plus a byte scan of the compaction markers before the probe offset; a later call without the limit
835
+ * (`dieu-phoi ctx`) recomputes the full sum.
819
836
  * Reads in chunks (memory = chunk + longest line), never the whole file at once. Never throws.
820
837
  */
821
838
  function orchestratorUsage(transcriptPath, pricing, opts) {
@@ -834,7 +851,8 @@ function orchestratorUsage(transcriptPath, pricing, opts) {
834
851
  const cached = o.cacheFile ? readJsonFile(o.cacheFile, null) : null;
835
852
  if (cached && cached.v === ORCH_CACHE_V && Number.isFinite(cached.offset) && cached.offset <= size
836
853
  && cached.head_len <= size && Array.isArray(cached.seen)
837
- && (cached.cum_known || o.coldMaxBytes)) {
854
+ && (cached.cum_known || (o.coldMaxBytes && typeof cached.compactions === 'number'))) {
855
+ // a probe state written before 0.7.2 holds compactions null: not reused, so the probe counts it (cum_known kept)
838
856
  const now = cached.head_len === fp.head_len ? fp : (() => {
839
857
  const b = Buffer.alloc(cached.head_len);
840
858
  if (cached.head_len) fs.readSync(fd, b, 0, cached.head_len, 0);
@@ -1184,7 +1202,8 @@ function buildSessionState(events, sessionId, opts) {
1184
1202
  const id = ev.task_id || agentTask.get(ev.agent_id);
1185
1203
  if (per.has(id)) per.get(id).steers.push(ev);
1186
1204
  }
1187
- if (ev.kind === 'override' && per.has(ev.task_id)) per.get(ev.task_id).overrides.push(ev);
1205
+ // core 0.7.2 (K4): an implicit override is a recorded deviation, not the user's choice: it never moves the approved tier
1206
+ if (ev.kind === 'override' && ev.source !== 'implicit' && per.has(ev.task_id)) per.get(ev.task_id).overrides.push(ev);
1188
1207
  }
1189
1208
  const byTime = (a, b) => evMs(a) - evMs(b) || a._i - b._i;
1190
1209
  const tasks = [];
@@ -1288,7 +1307,8 @@ function buildSessionState(events, sessionId, opts) {
1288
1307
  mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason,
1289
1308
  };
1290
1309
  });
1291
- return {
1310
+ const orphans = findOrphanAgents(events, taskIds.filter((id) => !discarded.has(id)), { nowMs: o.nowMs });
1311
+ return Object.assign({
1292
1312
  session_id: sessionId,
1293
1313
  generated_at: nowIso(),
1294
1314
  runs_dir: `${runs}/`,
@@ -1300,7 +1320,85 @@ function buildSessionState(events, sessionId, opts) {
1300
1320
  steps: p.steps.length, run_dir: `${runs}/${p.root_task_id}/`,
1301
1321
  })),
1302
1322
  tasks,
1303
- };
1323
+ }, orphans.length ? { orphans } : {});
1324
+ }
1325
+
1326
+ // retro 2026-11-05 K2: orphan agents. An exec/verify/plan agent whose LAST segment ended without a STATUS:/VERDICT: line
1327
+ // (`complete.ended_without_status`; older events: status "none"), or that never reported ending, and that has no agent
1328
+ // verdict, no newer dispatch of the same task+role and no user/auto accept, is listed so the orchestrator re-dispatches
1329
+ // (try+1) or drops it instead of waiting forever. Pure function of the events; never reads run-dir files.
1330
+ const ORPHAN_MIN_MS = 15 * 60 * 1000;
1331
+ // An agent with no `complete` at all may simply still be running in the background (state is rebuilt right after /compact):
1332
+ // only after this long is it called orphaned, and the line says to ignore it when the agent is still alive.
1333
+ const ORPHAN_NO_COMPLETE_MS = 2 * 3600 * 1000;
1334
+ const ORPHAN_MAX_LINES = 6;
1335
+
1336
+ function findOrphanAgents(events, taskIds, opts) {
1337
+ const now = (opts && opts.nowMs) || Date.now();
1338
+ const want = new Set(taskIds || []);
1339
+ const seenAgent = new Set();
1340
+ const latest = new Map(); // "task|role" -> newest dispatch
1341
+ const lastComplete = new Map();
1342
+ const judged = new Set(); // agent ids with any verdict
1343
+ const accepted = new Set();
1344
+ for (const ev of events) {
1345
+ if (!ev || typeof ev !== 'object') continue;
1346
+ if (ev.kind === 'dispatch' && want.has(ev.task_id) && !seenAgent.has(ev.agent_id)) {
1347
+ const role = ev.role || 'exec';
1348
+ if (!RESULT_ROLES.includes(role)) continue;
1349
+ seenAgent.add(ev.agent_id);
1350
+ latest.set(`${ev.task_id}|${role}`, ev);
1351
+ } else if (ev.kind === 'complete') {
1352
+ lastComplete.set(ev.agent_id, ev);
1353
+ } else if (ev.kind === 'verdict') {
1354
+ if (ev.agent_id) judged.add(ev.agent_id);
1355
+ if (ev.task_id && ev.outcome === 'accept') accepted.add(ev.task_id);
1356
+ else if (ev.task_id && (ev.outcome === 'reject' || ev.outcome === 'rework')) accepted.delete(ev.task_id);
1357
+ }
1358
+ }
1359
+ const out = [];
1360
+ for (const d of latest.values()) {
1361
+ if (judged.has(d.agent_id) || accepted.has(d.task_id)) continue;
1362
+ const c = lastComplete.get(d.agent_id);
1363
+ let reason;
1364
+ let since;
1365
+ if (c) {
1366
+ const ended = c.ended_without_status === true || (c.ended_without_status === undefined && c.status === 'none');
1367
+ if (!ended) continue;
1368
+ since = evMs(c);
1369
+ if (!since || now - since < ORPHAN_MIN_MS) continue;
1370
+ reason = c.inline_result === true ? 'inline' : 'no-status';
1371
+ } else {
1372
+ since = evMs(d);
1373
+ if (!since || now - since < ORPHAN_NO_COMPLETE_MS) continue;
1374
+ reason = 'no-complete';
1375
+ }
1376
+ out.push({
1377
+ task_id: d.task_id, agent_id: d.agent_id, role: d.role || 'exec', try: Number(d.try) || 1, task_type: d.task_type || null,
1378
+ score: Number.isFinite(d.score) ? d.score : null, urgency: d.urgency || 'normal', reason, age_min: Math.round((now - since) / 60000),
1379
+ });
1380
+ }
1381
+ return out;
1382
+ }
1383
+
1384
+ const ORPHAN_REASON_TEXT = {
1385
+ 'no-status': 'kết thúc mà báo cáo cuối không có dòng STATUS:/VERDICT:',
1386
+ inline: 'không ghi file dp-out (hook chỉ lưu .inline.md) và không có dòng STATUS:/VERDICT:',
1387
+ 'no-complete': 'không thấy kết thúc (nếu agent còn chạy nền thì bỏ qua dòng này)',
1388
+ };
1389
+
1390
+ /** Lines telling the orchestrator what to do with each orphan agent (at most ORPHAN_MAX_LINES). [] when none. */
1391
+ function orphanLines(orphans) {
1392
+ const list = Array.isArray(orphans) ? orphans : [];
1393
+ if (!list.length) return [];
1394
+ const lines = [`agent mồ côi: ${list.length} lượt giao chưa có kết quả dùng được, chưa verdict, chưa giao lại — đọc dp-out/.inline.md của bước (nếu có) rồi giao lại hoặc bỏ:`];
1395
+ for (const x of list.slice(0, ORPHAN_MAX_LINES)) {
1396
+ lines.push(` - ${x.task_id} [${x.task_type || '?'}] ${x.role} try${x.try} (${x.agent_id}) — ${ORPHAN_REASON_TEXT[x.reason] || x.reason}, ${x.age_min} phút trước; `
1397
+ + `giao lại: [dp:${x.task_type || '<type>'}|s${x.score === null ? '<n>' : x.score}|${x.urgency}|try${x.try + 1}|task=${x.task_id}|role=${x.role}]; `
1398
+ + `bỏ: dieu-phoi verdict ${x.agent_id} fail --reason ${x.reason === 'no-complete' ? 'no-complete' : 'ended-without-status'}`);
1399
+ }
1400
+ if (list.length > ORPHAN_MAX_LINES) lines.push(` (+${list.length - ORPHAN_MAX_LINES} nữa — xem đủ: dieu-phoi state --json, khoá orphans)`);
1401
+ return lines;
1304
1402
  }
1305
1403
 
1306
1404
  function toPosix(p) {
@@ -1362,7 +1460,8 @@ function stateCounts(state) {
1362
1460
  /** One short Vietnamese line shown to the user after /compact re-injected the state. */
1363
1461
  function compactSystemMessage(state) {
1364
1462
  const c = stateCounts(state);
1365
- return `dieu-phoi: đã nạp lại trạng thái điều phối sau khi nén (${c.tasks} task, ${c.running} agent đang chạy, ${c.pending} bước chưa giao)`;
1463
+ const orph = Array.isArray(state.orphans) && state.orphans.length ? `, ${state.orphans.length} agent mồ côi` : '';
1464
+ return `dieu-phoi: đã nạp lại trạng thái điều phối sau khi nén (${c.tasks} task, ${c.running} agent đang chạy, ${c.pending} bước chưa giao${orph})`;
1366
1465
  }
1367
1466
 
1368
1467
  /** "\nMode: <root>=<mode> …" for the roots of a session (last 6); '' when the state carries none. */
@@ -1393,7 +1492,8 @@ function formatSessionState(state, cap) {
1393
1492
  const render = (omitted) => {
1394
1493
  const body = stateBody(state, kept);
1395
1494
  const tail = omitted ? `\n(bỏ bớt ${omitted} task cũ nhất để vừa ${limit} ký tự; xem đủ: dieu-phoi state --json)` : '';
1396
- return `${header}\n${body.join('\n')}${tail}`;
1495
+ const orph = orphanLines(state.orphans);
1496
+ return `${header}\n${body.join('\n')}${tail}${orph.length ? `\n${orph.join('\n')}` : ''}`;
1397
1497
  };
1398
1498
  let omitted = 0;
1399
1499
  let text = render(0);
@@ -1578,8 +1678,8 @@ function startInfo(ctx, cwd) {
1578
1678
  };
1579
1679
  const k = startKnowledgeOf(cwd);
1580
1680
  if (k) info.knowledge = k;
1581
- const d = scanUnmergedDeltas();
1582
- if (d.count) info.unmerged_deltas = d;
1681
+ const d = scanUnmergedDeltasFor(ctx);
1682
+ if (hasUnmergedDeltas(d)) info.unmerged_deltas = d;
1583
1683
  return info;
1584
1684
  }
1585
1685
 
@@ -1715,6 +1815,28 @@ const HANDOFF_CHAIN_WARN = 2;
1715
1815
  const HANDOFF_CHAIN_DENY = 3;
1716
1816
 
1717
1817
  /** Number of agents whose `complete` event has handoff:true for this task (substring scan of events.jsonl). */
1818
+ /**
1819
+ * Core 0.7.2 (K3/K5): whether events.jsonl holds a dispatch for any task in `taskIds` (optionally only of `role`).
1820
+ * Same substring scan as countTaskHandoffs: rare path, one read of the file.
1821
+ */
1822
+ function hasDispatchFor(ctx, taskIds, role) {
1823
+ let txt;
1824
+ try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { return false; }
1825
+ for (const id of taskIds) {
1826
+ if (!id) continue;
1827
+ const needle = `"task_id":${JSON.stringify(id)}`;
1828
+ let i = txt.indexOf(needle);
1829
+ while (i >= 0) {
1830
+ const start = txt.lastIndexOf('\n', i) + 1;
1831
+ const end = txt.indexOf('\n', i);
1832
+ const line = txt.slice(start, end < 0 ? undefined : end);
1833
+ if (line.includes('"kind":"dispatch"') && (!role || line.includes(`"role":${JSON.stringify(role)}`))) return true;
1834
+ i = end < 0 ? -1 : txt.indexOf(needle, end);
1835
+ }
1836
+ }
1837
+ return false;
1838
+ }
1839
+
1718
1840
  function countTaskHandoffs(ctx, taskId) {
1719
1841
  if (!taskId) return 0;
1720
1842
  let txt;
@@ -1749,9 +1871,9 @@ function handoffChainMessage(taskId, n) {
1749
1871
  const MODES = ['fast', 'normal', 'strict'];
1750
1872
  const DEFAULT_MODE = 'normal';
1751
1873
  /** Event kinds that stay on this machine: runSync does not send them (an older sink would answer bad_kind). */
1752
- const LOCAL_ONLY_KINDS = new Set(['mode']);
1753
- const MODE_ASK_LINE = 'mode: HỎI người dùng chọn mode cho yêu cầu này — fast (bỏ verifier riêng ở bước s≤6, test chọn lọc, '
1754
- + 'một verifier gộp) | normal | strict (mặc định normal nếu không trả lời); đặt bằng `dieu-phoi mode --root <root_task_id> <mode>`';
1874
+ const LOCAL_ONLY_KINDS = new Set(['mode', 'knowledge_merge', 'leftover', 'worktree']);
1875
+ const MODE_ASK_LINE = 'mode: chưa chọn — chỉ hỏi ở cổng 1 khi plan-table báo ≥4 bước s≤6 (fast | normal | strict, mặc định normal); '
1876
+ + 'đặt bằng `dieu-phoi mode --root <root_task_id> <mode>`';
1755
1877
 
1756
1878
  function isMode(m) {
1757
1879
  return typeof m === 'string' && MODES.includes(m);
@@ -2528,9 +2650,48 @@ function storeByName(name, stores) {
2528
2650
  return { matches, store: matches.length === 1 ? matches[0] : null };
2529
2651
  }
2530
2652
 
2653
+ /** Task id a delta file belongs to: knowledge-delta-<task_id>[-r<n>|-h<n>].md (8 chars a-z0-9); null = untagged. */
2654
+ function deltaTagOf(file) {
2655
+ const m = /^knowledge-delta-([a-z0-9]{8})(?:-[rh]\d+)?\.md$/i.exec(path.basename(String(file)));
2656
+ return m ? m[1].toLowerCase() : null;
2657
+ }
2658
+
2659
+ /**
2660
+ * retro 11-05 (K1): who has judged what, from events + approved plans. `deltaState(run, tag)` is the user's decision on
2661
+ * a delta file in run dir `run` ('accept' | 'reject' | null): a tagged file follows the LAST user verdict of its task;
2662
+ * an untagged one (or one tagged with the run id) follows the tasks living in that run (own id, plan members,
2663
+ * dispatch `run`): accept when any of them was accepted, reject when all were rejected, else null.
2664
+ */
2665
+ function deltaClassifier(events, plansDir) {
2666
+ const last = new Map();
2667
+ const runOf = new Map();
2668
+ for (const e of events || []) {
2669
+ if (!e || !e.task_id) continue;
2670
+ if (e.kind === 'verdict' && e.by === 'user' && (e.outcome === 'accept' || e.outcome === 'reject')) last.set(e.task_id, e.outcome);
2671
+ if (e.kind === 'dispatch' && typeof e.run === 'string' && e.run) runOf.set(e.task_id, e.run);
2672
+ }
2673
+ const planRoot = new Map();
2674
+ for (const p of loadPlans(plansDir)) for (const x of p.steps) if (x && x.task_id) planRoot.set(x.task_id, p.root_task_id);
2675
+ const byRun = new Map();
2676
+ const add = (run, t) => { if (!byRun.has(run)) byRun.set(run, new Set()); byRun.get(run).add(t); };
2677
+ for (const t of last.keys()) { add(t, t); if (planRoot.has(t)) add(planRoot.get(t), t); if (runOf.has(t)) add(runOf.get(t), t); }
2678
+ return {
2679
+ state: (t) => last.get(t) || null,
2680
+ deltaState(run, tag) {
2681
+ if (tag && tag !== run) return last.get(tag) || null;
2682
+ const ts = Array.from(byRun.get(run) || []).map((t) => last.get(t));
2683
+ if (!ts.length) return null;
2684
+ return ts.includes('accept') ? 'accept' : 'reject';
2685
+ },
2686
+ };
2687
+ }
2688
+
2531
2689
  /**
2532
2690
  * Delta files in run dirs that are 24 h .. 30 d old and that no store has applied (hash in meta.json `applied`).
2533
2691
  * { count, runs: [{ run, n, oldest_ms, repos: { name: n } }], truncated } — never throws; count 0 when all is merged.
2692
+ * With opts.events (+ opts.plansDir) the files are split by what the user decided: count/runs = no user verdict yet
2693
+ * (24 h .. 30 d old); `accepted` = { count, runs: [{ run, n, oldest_ms, repos, tasks, untagged }] } = ALREADY ACCEPTED but
2694
+ * not merged (an error: no age floor, only the 30 d window); a task the user rejected is left out of both.
2534
2695
  */
2535
2696
  function scanUnmergedDeltas(opts) {
2536
2697
  const o = opts || {};
@@ -2538,6 +2699,8 @@ function scanUnmergedDeltas(opts) {
2538
2699
  const warnMs = o.warnMs === undefined ? DELTA_WARN_MS : o.warnMs;
2539
2700
  const windowMs = o.windowMs === undefined ? DELTA_WINDOW_MS : o.windowMs;
2540
2701
  const res = { count: 0, runs: [], truncated: false };
2702
+ const cls = Array.isArray(o.events) ? deltaClassifier(o.events, o.plansDir) : null;
2703
+ const acc = { count: 0, runs: [] };
2541
2704
  try {
2542
2705
  const runs = runsDir();
2543
2706
  const applied = new Set();
@@ -2551,26 +2714,36 @@ function scanUnmergedDeltas(opts) {
2551
2714
  let names;
2552
2715
  try { names = fs.readdirSync(dir); } catch (_) { continue; }
2553
2716
  const row = { run, n: 0, oldest_ms: 0, repos: {} };
2717
+ const arow = { run, n: 0, oldest_ms: 0, repos: {}, tasks: [], untagged: false };
2554
2718
  for (const f of names) {
2555
2719
  if (!/^knowledge-delta.*\.md$/i.test(f)) continue;
2556
2720
  const file = path.join(dir, f);
2557
2721
  let fst;
2558
2722
  try { fst = fs.statSync(file); } catch (_) { continue; }
2559
2723
  const age = now - fst.mtimeMs;
2560
- if (age < warnMs || age > windowMs) continue;
2724
+ if (age > windowMs) continue;
2725
+ const tag = deltaTagOf(f);
2726
+ const verdict = cls ? cls.deltaState(run, tag) : null;
2727
+ if (verdict === 'reject') continue;
2728
+ const into = verdict === 'accept' ? arow : row;
2729
+ if (into === row && age < warnMs) continue;
2561
2730
  if (seen++ >= DELTA_SCAN_MAX) { res.truncated = true; break; }
2562
2731
  const text = readTextFile(file);
2563
2732
  if (text == null || applied.has(deltaHash(text))) continue;
2564
2733
  const repo = deltaRepoOf(text);
2565
2734
  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;
2735
+ into.n++;
2736
+ into.oldest_ms = Math.max(into.oldest_ms, age);
2737
+ into.repos[label] = (into.repos[label] || 0) + 1;
2738
+ if (into === arow) { if (tag && tag !== run) { if (!arow.tasks.includes(tag)) arow.tasks.push(tag); } else arow.untagged = true; }
2569
2739
  }
2570
2740
  if (row.n) { res.runs.push(row); res.count += row.n; }
2741
+ if (arow.n) { acc.runs.push(arow); acc.count += arow.n; }
2571
2742
  if (res.truncated) break;
2572
2743
  }
2573
2744
  res.runs.sort((a, b) => b.n - a.n || b.oldest_ms - a.oldest_ms);
2745
+ acc.runs.sort((a, b) => b.n - a.n || b.oldest_ms - a.oldest_ms);
2746
+ if (acc.count) res.accepted = acc;
2574
2747
  } catch (e) {
2575
2748
  res.error = e.message;
2576
2749
  }
@@ -2583,15 +2756,38 @@ function ageText(ms) {
2583
2756
 
2584
2757
  /** One warning line (as an array, empty when nothing is pending) for the start block and `state`. */
2585
2758
  function unmergedDeltaLines(scan) {
2586
- if (!scan || !scan.count) return [];
2759
+ const out = [];
2760
+ const a = scan && scan.accepted;
2761
+ if (a && a.count) {
2762
+ const r0 = a.runs[0];
2763
+ const cmd = `dieu-phoi knowledge merge --run ${r0.run}${r0.tasks.length ? ` --task ${r0.tasks.slice(0, 8).join(',')}` : ''}${r0.untagged ? ' --untagged' : ''}`;
2764
+ const runText = a.runs.slice(0, 3).map((r) => `${r.run} ×${r.n}`).join(', ') + (a.runs.length > 3 ? ` (+${a.runs.length - 3} run)` : '');
2765
+ out.push(`knowledge_delta_accepted: LỖI — ${a.count} file delta của task ĐÃ nghiệm thu mà chưa vào kho (gộp lúc accept thất bại hoặc bị bỏ sót) — run ${runText}; cũ nhất ${ageText(Math.max(...a.runs.map((r) => r.oldest_ms)))}. `
2766
+ + `Gộp: \`${cmd} --dry-run\` rồi bỏ --dry-run.`);
2767
+ }
2768
+ if (!scan || !scan.count) return out;
2587
2769
  const oldest = Math.max(...scan.runs.map((r) => r.oldest_ms));
2588
2770
  const repos = {};
2589
2771
  for (const r of scan.runs) for (const [k, v] of Object.entries(r.repos)) repos[k] = (repos[k] || 0) + v;
2590
2772
  const repoText = Object.entries(repos).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ×${v}`).join(', ');
2591
2773
  const runText = scan.runs.slice(0, 3).map((r) => `${r.run} ×${r.n}`).join(', ') + (scan.runs.length > 3 ? ` (+${scan.runs.length - 3} run)` : '');
2592
2774
  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.`];
2775
+ const pending = scan.accepted ? ' (chưa có verdict người dùng)' : '';
2776
+ out.push(`knowledge_delta: ${scan.count} file delta chưa gộp quá 24h${pending}${more} — run ${runText}; kho ${repoText}; cũ nhất ${ageText(oldest)}. `
2777
+ + `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.`);
2778
+ return out;
2779
+ }
2780
+
2781
+ /** Whether a scan has anything to show (undecided and/or accepted-but-unmerged deltas). */
2782
+ function hasUnmergedDeltas(scan) {
2783
+ return !!(scan && (scan.count || (scan.accepted && scan.accepted.count)));
2784
+ }
2785
+
2786
+ /** scanUnmergedDeltas with the user's verdicts (events + approved plans of ctx), so accepted-unmerged is told apart. */
2787
+ function scanUnmergedDeltasFor(ctx) {
2788
+ let events = [];
2789
+ try { events = readEvents(ctx); } catch (_) { /* no log: everything stays undecided */ }
2790
+ return scanUnmergedDeltas({ events, plansDir: ctx && ctx.paths && ctx.paths.plans });
2595
2791
  }
2596
2792
 
2597
2793
  /**
@@ -2664,6 +2860,16 @@ function projectFields(p) {
2664
2860
  return {};
2665
2861
  }
2666
2862
 
2863
+ /** Run dir id the brief points at (retro 11-05 K1: accept finds the delta there whatever the plan/root says), else null. */
2864
+ function dispatchRun(prompt) {
2865
+ try {
2866
+ const probe = 'norundir0'; // resultRunDir falls back to runs/<task_id>: the probe id tells "named in the brief" from that fallback
2867
+ const d = resultRunDir(prompt, probe);
2868
+ const id = path.basename(d);
2869
+ return path.dirname(d) === path.resolve(runsDir()) && id !== probe && /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
2870
+ } catch (_) { return null; }
2871
+ }
2872
+
2667
2873
  function buildDispatch(p, ctx) {
2668
2874
  const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
2669
2875
  const tr = p.tool_response;
@@ -2693,7 +2899,9 @@ function buildDispatch(p, ctx) {
2693
2899
  ...(tag.tag_issue ? { tag_issue: tag.tag_issue } : {}),
2694
2900
  ...(tag.type_alias_from ? { type_alias_from: tag.type_alias_from } : {}),
2695
2901
  ...(tag.chain_ok ? { chain_ok: true } : {}),
2902
+ ...(tag.session_ok ? { session_ok: true } : {}),
2696
2903
  ...(tag.batch ? { batch: true } : {}),
2904
+ ...(dispatchRun(ti.prompt) ? { run: dispatchRun(ti.prompt) } : {}),
2697
2905
  task_type: tag.task_type,
2698
2906
  score: tag.score,
2699
2907
  urgency: tag.urgency,
@@ -2775,7 +2983,15 @@ function handlePostToolUse(p, ctx) {
2775
2983
  if (n) ev.task_handoffs = n;
2776
2984
  if (n >= HANDOFF_CHAIN_WARN) notes.push(handoffChainMessage(ev.task_id, n));
2777
2985
  }
2986
+ // core 0.7.2 (K4): an exec with no choose record gets a reminder; a tier that differs from choose's answer without an
2987
+ // override logged for it leaves an implicit override event right after the dispatch.
2988
+ let implicit = null;
2989
+ if (ev.task_id && ev.role === 'exec' && isValidTaskId(ev.task_id)) {
2990
+ if (!ev.suggested_tier) notes.push(noChooseMessage(ev.task_id));
2991
+ else implicit = implicitOverrideEvent(ctx, ev);
2992
+ }
2778
2993
  appendEvent(ctx, ev);
2994
+ if (implicit) appendEvent(ctx, implicit);
2779
2995
  if (notes.length) {
2780
2996
  Object.defineProperty(ev, 'stdout', {
2781
2997
  enumerable: false,
@@ -2785,6 +3001,51 @@ function handlePostToolUse(p, ctx) {
2785
3001
  return ev;
2786
3002
  }
2787
3003
 
3004
+ /** core 0.7.2 (K4): reminder for an exec whose task has no choose record (data/suggest) when it was dispatched. */
3005
+ function noChooseMessage(taskId) {
3006
+ return `dieu-phoi: exec của task ${taskId} được giao mà chưa có kết quả \`choose\` (không có tier trong data/suggest). Lần giao sau chạy \`dieu-phoi choose --type <task_type> --task ${taskId} --json\` trước khi giao; tier giao khác tier choose trả mà không có --override sẽ được ghi là override implicit.`;
3007
+ }
3008
+
3009
+ /**
3010
+ * core 0.7.2 (K4): the implicit override for an exec dispatch whose tier differs from what choose answered
3011
+ * (choose_tier, else suggested_tier) while no override of this task to that tier is logged yet. Null otherwise.
3012
+ */
3013
+ function implicitOverrideEvent(ctx, ev) {
3014
+ const dispatched = ev.dispatched_tier;
3015
+ const said = ev.choose_tier || ev.suggested_tier;
3016
+ if (!dispatched || dispatched.includes('?') || dispatched === said) return null;
3017
+ if (overrideLoggedFor(ctx, ev.task_id, dispatched)) return null;
3018
+ return Object.assign({
3019
+ schema: SCHEMA,
3020
+ kind: 'override',
3021
+ ts: nowIso(),
3022
+ agent_id: `task:${ev.task_id}`,
3023
+ task_id: ev.task_id,
3024
+ task_type: ev.task_type || null,
3025
+ suggested_tier: said,
3026
+ chosen_tier: dispatched,
3027
+ source: 'implicit',
3028
+ }, commonFields(ctx));
3029
+ }
3030
+
3031
+ /** Whether events.jsonl already holds an override (explicit or implicit) of this task to this tier. */
3032
+ function overrideLoggedFor(ctx, taskId, tier) {
3033
+ let txt;
3034
+ try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { return false; }
3035
+ const needle = `"task_id":${JSON.stringify(taskId)}`;
3036
+ let i = txt.indexOf('"kind":"override"');
3037
+ while (i >= 0) {
3038
+ const start = txt.lastIndexOf('\n', i) + 1;
3039
+ const end = txt.indexOf('\n', i);
3040
+ const line = txt.slice(start, end < 0 ? undefined : end);
3041
+ if (line.includes(needle)) {
3042
+ try { if (JSON.parse(line).chosen_tier === tier) return true; } catch (_) { /* a junk line never matches */ }
3043
+ }
3044
+ i = end < 0 ? -1 : txt.indexOf('"kind":"override"', end);
3045
+ }
3046
+ return false;
3047
+ }
3048
+
2788
3049
  function hasAssistantUsage(lines) {
2789
3050
  return lines.some((l) => l && l.type === 'assistant' && l.message && l.message.usage
2790
3051
  && l.message.model !== '<synthetic>');
@@ -2930,6 +3191,101 @@ function persistInlineResult(lines, tag, opts) {
2930
3191
  return file;
2931
3192
  }
2932
3193
 
3194
+ // s15 (core 0.7.2): tools that start background work an agent may leave running, and the tools that stop it.
3195
+ // s16 (core 0.7.2): the formats below were checked against real subagent transcripts (Claude Code, 2026-11):
3196
+ // - Bash/PowerShell result "Command running in background with ID: <id>. Output is being written to: ..." appears for
3197
+ // run_in_background:true and also when a long foreground command is moved to the background by itself (no flag);
3198
+ // - Monitor result "Monitor started (task <id>, timeout ...)" (no "ID:"); a refused Monitor is a tool_result is_error:true;
3199
+ // - TaskStop input {task_id}, the same id;
3200
+ // - task finished = a <task-notification> carrying <status> (+ <task-id>, <tool-use-id>). It is delivered mostly as an
3201
+ // `attachment` line (attachment.type "queued_command", attachment.origin "task-notification", XML in attachment.prompt),
3202
+ // sometimes as a user line with string content. A <task-notification> without <status> is a Monitor progress <event>.
3203
+ const BG_START_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor']);
3204
+ const BG_SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
3205
+ const BG_STOP_TOOLS = new Set(['TaskStop', 'KillShell']);
3206
+ const BG_ID_RE = /\bID:\s*([A-Za-z0-9_-]{2,64})/;
3207
+ const BG_MONITOR_ID_RE = /\(task\s+([A-Za-z0-9_-]{2,64})[,)]/;
3208
+ const BG_AUTO_RE = /^\s*Command running in background with ID:/; // anchored: test/grep output may quote the phrase
3209
+ const NOTIF_BLOCK_RE = /<task-notification>[\s\S]*?<\/task-notification>/g;
3210
+ const TASK_ID_TAG_RE = /<task-id>\s*([A-Za-z0-9_-]{2,64})\s*<\/task-id>/;
3211
+ const TOOL_USE_ID_TAG_RE = /<tool-use-id>\s*([A-Za-z0-9_-]{2,80})\s*<\/tool-use-id>/;
3212
+
3213
+ function textOfContent(c) {
3214
+ if (typeof c === 'string') return c;
3215
+ if (!Array.isArray(c)) return '';
3216
+ return c.map((b) => (b && b.type === 'text' && typeof b.text === 'string' ? b.text : '')).join('\n');
3217
+ }
3218
+
3219
+ /**
3220
+ * s15/s16: background work an agent opened and did not stop, read from its transcript (pure; returns ids, never a command).
3221
+ * Opened = a Bash/PowerShell tool_use with run_in_background:true, or whose result says "Command running in background
3222
+ * with ID:" (a long command moved to the background by itself), or a Monitor tool_use whose result is not an error. The id
3223
+ * comes from the tool_result ("... ID: <id>" / "(task <id>, ..."); without one the tool_use id stands in. Closed = a
3224
+ * TaskStop/KillShell whose input names the id, or a finished-task <task-notification> (one with <status>) naming the id
3225
+ * or the tool_use id, wherever it sits (queued_command attachment, user text). Returns the ids still open, in start order.
3226
+ */
3227
+ function openBackgroundTasks(lines) {
3228
+ const open = new Map(); // tool_use id -> task id, null while no id is known
3229
+ const finished = new Set(); // task ids and tool_use ids named by a stop or a finished notice (may precede the start result)
3230
+ const kinds = new Map(); // tool_use id -> tool name of every candidate start
3231
+ const flagged = new Set(); // tool_use ids that asked for the background or are Monitors
3232
+ const close = (id) => {
3233
+ finished.add(id);
3234
+ for (const [k, v] of open) if (k === id || v === id) open.delete(k);
3235
+ };
3236
+ const closeNotified = (text) => {
3237
+ if (!text || !text.includes('<task-notification>')) return;
3238
+ for (const blk of text.match(NOTIF_BLOCK_RE) || []) {
3239
+ if (!blk.includes('<status>')) continue; // a Monitor <event> notice: the task is still running
3240
+ const t = TASK_ID_TAG_RE.exec(blk);
3241
+ const u = TOOL_USE_ID_TAG_RE.exec(blk);
3242
+ if (t) close(t[1]);
3243
+ if (u) close(u[1]);
3244
+ }
3245
+ };
3246
+ for (const l of Array.isArray(lines) ? lines : []) {
3247
+ if (!l) continue;
3248
+ if (l.type === 'attachment') {
3249
+ const a = l.attachment;
3250
+ if (a && a.type === 'queued_command' && typeof a.prompt === 'string') closeNotified(a.prompt);
3251
+ continue;
3252
+ }
3253
+ const msg = l.message;
3254
+ if (!msg) continue;
3255
+ const isUser = l.type === 'user';
3256
+ if (typeof msg.content === 'string') {
3257
+ if (isUser) closeNotified(msg.content);
3258
+ continue;
3259
+ }
3260
+ if (!Array.isArray(msg.content)) continue;
3261
+ for (const b of msg.content) {
3262
+ if (!b || typeof b !== 'object') continue;
3263
+ if (b.type === 'tool_use' && typeof b.id === 'string') {
3264
+ const input = b.input && typeof b.input === 'object' ? b.input : {};
3265
+ if (BG_START_TOOLS.has(b.name)) {
3266
+ kinds.set(b.id, b.name);
3267
+ if (b.name === 'Monitor' || input.run_in_background === true) flagged.add(b.id);
3268
+ } else if (BG_STOP_TOOLS.has(b.name)) {
3269
+ const id = input.task_id || input.shell_id;
3270
+ if (typeof id === 'string' && id) close(id);
3271
+ }
3272
+ } else if (b.type === 'tool_result' && kinds.has(b.tool_use_id)) {
3273
+ const text = textOfContent(b.content);
3274
+ const kind = kinds.get(b.tool_use_id);
3275
+ const auto = BG_SHELL_TOOLS.has(kind) && BG_AUTO_RE.test(text);
3276
+ if ((!flagged.has(b.tool_use_id) && !auto) || b.is_error === true) continue; // foreground, or refused: nothing started
3277
+ const m = (kind === 'Monitor' ? BG_MONITOR_ID_RE.exec(text) : null) || BG_ID_RE.exec(text);
3278
+ const id = m ? m[1] : null;
3279
+ if (finished.has(b.tool_use_id) || (id && finished.has(id))) continue;
3280
+ open.set(b.tool_use_id, id);
3281
+ } else if (b.type === 'text' && typeof b.text === 'string' && isUser) {
3282
+ closeNotified(b.text);
3283
+ }
3284
+ }
3285
+ }
3286
+ return Array.from(open, ([k, v]) => v || k);
3287
+ }
3288
+
2933
3289
  function handleSubagentStop(p, ctx) {
2934
3290
  const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
2935
3291
  if (!agentId) return null;
@@ -2952,6 +3308,14 @@ function handleSubagentStop(p, ctx) {
2952
3308
  sum = summarizeTranscript(lines, { afterUuids: seen, pricing: loadPricing(ctx) });
2953
3309
  tag = tagFromTranscript(lines, knownTaskTypes(ctx));
2954
3310
  }
3311
+ // s15 (core 0.7.2): a tagged dp-* agent that stops while a background task it opened still runs is sent back once
3312
+ // (decision block: TaskStop it, then report). That second stop has stop_hook_active true: recorded, never blocked again.
3313
+ // The blocked stop writes nothing, so the next stop still counts the whole segment.
3314
+ const leftover = tag && tag.task_id ? openBackgroundTasks(lines) : [];
3315
+ if (leftover.length && p.stop_hook_active !== true && ctx.config.block_leftover_tasks !== false) {
3316
+ const reason = `Còn task nền chưa dừng: ${leftover.slice(0, 5).join(', ')}. TaskStop từng cái rồi trả lại STATUS.`;
3317
+ return { stdout: `${JSON.stringify({ decision: 'block', reason })}\n` };
3318
+ }
2955
3319
  const t = tag || UNTAGGED;
2956
3320
  const ev = Object.assign({
2957
3321
  schema: SCHEMA,
@@ -2980,12 +3344,18 @@ function handleSubagentStop(p, ctx) {
2980
3344
  ev.handoff = ev.status === 'handoff';
2981
3345
  ev.peak_ctx = sum ? sum.peak_ctx : null;
2982
3346
  ev.compactions = sum ? sum.compactions : null;
3347
+ // s15: background tasks still open when the segment ended (after the one allowed block): a count, never the commands
3348
+ if (leftover.length) ev.leftover_tasks = leftover.length;
2983
3349
  let inline = null;
2984
3350
  if (exists && tag) {
2985
3351
  try { inline = persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
2986
3352
  }
2987
3353
  // exec/verify/plan only: true = it returned without writing its dp-out file (the hook saved the .inline.md)
2988
- if (tag && RESULT_ROLES.includes(tag.role)) ev.inline_result = Boolean(inline);
3354
+ if (tag && RESULT_ROLES.includes(tag.role)) {
3355
+ ev.inline_result = Boolean(inline);
3356
+ // retro 2026-11-05 K2: the segment ended with no STATUS:/VERDICT: line (or no final text) and was neither a quota pause nor a handoff
3357
+ ev.ended_without_status = exists && (ev.status === 'none' || ev.status === null);
3358
+ }
2989
3359
  // core 0.7.0 (s2): seconds spent running tests in this segment (transcript; else the dp-out `TEST_S:` line)
2990
3360
  const tt = exists && sum ? testTimeOf(lines.slice(sum.segment_start)) : null;
2991
3361
  ev.test_s = tt ? tt.test_s : null;
@@ -3013,6 +3383,100 @@ function handleSubagentStop(p, ctx) {
3013
3383
  verify: 'none', reason_code: `auto-${why}`, cause: null, round: null,
3014
3384
  }, commonFields(ctx)));
3015
3385
  }
3386
+ // s16 (core 0.7.2): processes the agent left behind (report; kill only with config kill_leftovers). Never throws, bounded to ~5 s.
3387
+ if (tag && tag.task_id && exists) {
3388
+ try { scanLeftoverProcs(ctx, { agentId, taskId: tag.task_id, lines }); } catch (e) { logError(ctx.home, e, 'leftover-procs'); }
3389
+ }
3390
+ return ev;
3391
+ }
3392
+
3393
+ // ------------------------------------------------------------------ leftover processes (s16, core 0.7.2)
3394
+
3395
+ // The process-table helpers live in lib/procs.cjs. The installed hook is a single copied file, so `setup` puts the same
3396
+ // module next to it as bin/procs.cjs; the repo layout finds it under ./lib. Missing module = no scan, never an error.
3397
+ function loadProcs() {
3398
+ for (const f of ['./procs.cjs', './lib/procs.cjs']) {
3399
+ try { return require(f); } catch (_) { /* try the next place */ }
3400
+ }
3401
+ return null;
3402
+ }
3403
+
3404
+ const LEFTOVER_BUDGET_MS = 5000;
3405
+ const LEFTOVER_LIST_TIMEOUT_MS = 4000;
3406
+ const LEFTOVER_OVERLAP_MAX_AGE_MS = 24 * 3600 * 1000;
3407
+
3408
+ /** Start of an agent transcript (first line timestamp, epoch ms) or null. */
3409
+ function transcriptStartMs(lines) {
3410
+ for (const l of Array.isArray(lines) ? lines : []) {
3411
+ const t = l && typeof l.timestamp === 'string' ? Date.parse(l.timestamp) : NaN;
3412
+ if (Number.isFinite(t)) return t;
3413
+ }
3414
+ return null;
3415
+ }
3416
+
3417
+ /**
3418
+ * Is another dp agent alive during [startMs, endMs]? From dispatch/complete events: dispatched at or before endMs and not
3419
+ * completed before startMs (a foreground agent's dispatch is logged when it returns, so it counts too: the safe side).
3420
+ * Dispatches older than 24 h without a complete are ignored (crashed agents).
3421
+ */
3422
+ function otherAgentsRunning(events, agentId, startMs, endMs) {
3423
+ const disp = new Map();
3424
+ const done = new Map();
3425
+ for (const ev of Array.isArray(events) ? events : []) {
3426
+ if (!ev || !ev.agent_id || ev.agent_id === agentId) continue;
3427
+ const t = Date.parse(ev.ts);
3428
+ if (!Number.isFinite(t)) continue;
3429
+ if (ev.kind === 'dispatch') disp.set(ev.agent_id, Math.max(disp.get(ev.agent_id) || 0, t));
3430
+ else if (ev.kind === 'complete') done.set(ev.agent_id, Math.max(done.get(ev.agent_id) || 0, t));
3431
+ }
3432
+ for (const [id, d] of disp) {
3433
+ if (d > endMs) continue;
3434
+ const c = done.get(id);
3435
+ if (c === undefined) { if (endMs - d <= LEFTOVER_OVERLAP_MAX_AGE_MS) return true; continue; }
3436
+ if (c >= startMs) return true;
3437
+ }
3438
+ return false;
3439
+ }
3440
+
3441
+ /**
3442
+ * Looks at the process table after a dp agent stopped. Descendants of this session's Claude Code process created since the
3443
+ * agent started and still alive (not the hook's own chain, not claude/code-graph) -> one local `leftover` event
3444
+ * {task_id, n, names}. They are only reported, unless config kill_leftovers is true AND no other dp agent ran in the
3445
+ * meantime (then the leftover trees are killed). deps = { procs, now, selfPid, events } for tests. Returns the event or null.
3446
+ */
3447
+ function scanLeftoverProcs(ctx, a, deps) {
3448
+ const d = deps || {};
3449
+ if (ctx.config.scan_leftover_procs === false || (!d.procs && process.env.DIEU_PHOI_NO_PROCS === '1')) return null;
3450
+ const procs = d.procs || loadProcs();
3451
+ if (!procs) return null;
3452
+ const startMs = transcriptStartMs(a.lines);
3453
+ const now = typeof d.now === 'number' ? d.now : Date.now();
3454
+ // an agent that started over 24 h ago (a long-resumed or odd transcript) has no meaningful "created since" window
3455
+ if (startMs === null || now - startMs > LEFTOVER_OVERLAP_MAX_AGE_MS) return null;
3456
+ const t0 = Date.now();
3457
+ const list = procs.listProcesses({ timeoutMs: LEFTOVER_LIST_TIMEOUT_MS });
3458
+ if (!Array.isArray(list)) return null;
3459
+ const selfPid = d.selfPid || process.pid;
3460
+ const root = procs.claudeAncestor(list, selfPid);
3461
+ if (!root) return null;
3462
+ const left = procs.leftoverProcesses(list, { rootPid: root.pid, startMs, selfPid });
3463
+ if (!left.all.length) return null;
3464
+ const counts = new Map();
3465
+ for (const p of left.all) { const n = String(p.name || '?').toLowerCase(); counts.set(n, (counts.get(n) || 0) + 1); }
3466
+ const names = Array.from(counts).sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1)).slice(0, 8).map((x) => x[0]);
3467
+ const killOn = ctx.config.kill_leftovers === true;
3468
+ let killed = 0;
3469
+ let skipped = null;
3470
+ if (killOn) {
3471
+ const events = d.events || readEvents(ctx);
3472
+ if (otherAgentsRunning(events, a.agentId, startMs, now)) skipped = 'other-agent-running';
3473
+ else killed = procs.killTree(left.roots.map((p) => p.pid), { deadline: t0 + LEFTOVER_BUDGET_MS }).length;
3474
+ }
3475
+ const ev = Object.assign({
3476
+ schema: SCHEMA, kind: 'leftover', ts: nowIso(), agent_id: a.agentId, task_id: a.taskId,
3477
+ n: left.all.length, names, kill_leftovers: killOn, killed,
3478
+ }, skipped ? { kill_skipped: skipped } : {}, commonFields(ctx));
3479
+ appendEvent(ctx, ev);
3016
3480
  return ev;
3017
3481
  }
3018
3482
 
@@ -3488,7 +3952,22 @@ function handleChainGate(p, ctx) {
3488
3952
  if (watchAgentId(p)) return null;
3489
3953
  const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
3490
3954
  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;
3955
+ if (!tag || !tag.task_id || !isValidTaskId(tag.task_id)) return null;
3956
+ if (tag.role === 'verify') return verifyOrphanGate(p, ctx, tag);
3957
+ if (tag.role !== 'exec') return null;
3958
+ const chain = tag.chain_ok ? null : chainGateDenial(p, ctx, tag);
3959
+ if (chain) return chain;
3960
+ return sessionCapGate(p, ctx, tag);
3961
+ }
3962
+
3963
+ function preToolDeny(reason) {
3964
+ return JSON.stringify({
3965
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
3966
+ });
3967
+ }
3968
+
3969
+ /** Core 0.7.1 (C1): exec continuation of a task that already handed off HANDOFF_CHAIN_DENY times. */
3970
+ function chainGateDenial(p, ctx, tag) {
3492
3971
  const n = countTaskHandoffs(ctx, tag.task_id);
3493
3972
  if (n < HANDOFF_CHAIN_DENY) return null;
3494
3973
  const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'exec' },
@@ -3496,15 +3975,65 @@ function handleChainGate(p, ctx) {
3496
3975
  return {
3497
3976
  denied: true,
3498
3977
  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
- }),
3978
+ stdout: preToolDeny(`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. `
3979
+ + `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\`). `
3980
+ + 'Chỉ khi người dùng đồng ý nối tiếp: thêm `|chain_ok` vào thẻ.'),
3981
+ };
3982
+ }
3983
+
3984
+ /**
3985
+ * Core 0.7.2 (K3, retro 2026-11-05): a `role=verify` dispatch whose task has no exec dispatch in events.jsonl and sits
3986
+ * in no approved plan is a verifier for work that was never dispatched -> denied (`verify-orphan`). `|batch` (the one
3987
+ * verifier of a fast request, covering several steps) is exempt.
3988
+ */
3989
+ function verifyOrphanGate(p, ctx, tag) {
3990
+ if (tag.batch) return null;
3991
+ if (hasDispatchFor(ctx, [tag.task_id], 'exec')) return null;
3992
+ if (planOfTask(ctx, tag.task_id)) return null;
3993
+ const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'verify' },
3994
+ { gate_action: 'deny', reason_code: 'verify-orphan' });
3995
+ return {
3996
+ denied: true,
3997
+ event: ev,
3998
+ stdout: preToolDeny(`dieu-phoi: verifier của task ${tag.task_id} không có lượt exec nào trong phiên này và không nằm trong kế hoạch đã duyệt — `
3999
+ + 'không chạy verifier cho việc chưa giao. Giao exec của task trước; verifier gộp của mode fast dùng thẻ `|batch`.'),
4000
+ };
4001
+ }
4002
+
4003
+ /** Core 0.7.2 (K5): trần phiên điều phối cứng. Vượt một trong hai → exec mở ROOT MỚI bị chặn (session-cap). */
4004
+ const SESSION_CAP_TOKENS = 800000;
4005
+ const SESSION_CAP_COMPACTIONS = 10;
4006
+
4007
+ /**
4008
+ * Core 0.7.2 (K5, retro 2026-11-05): the orchestrating session is past SESSION_CAP_TOKENS of context or
4009
+ * SESSION_CAP_COMPACTIONS compactions. An exec that opens a new root is denied (`session-cap`); an exec of a root that
4010
+ * already has a dispatch (a continuation) passes; `|session_ok` lets the user accept the risk. Main-session only.
4011
+ */
4012
+ function sessionCapGate(p, ctx, tag) {
4013
+ if (tag.session_ok) return null;
4014
+ const plans = loadPlans(ctx.paths.plans);
4015
+ const step = planOfTask(ctx, tag.task_id);
4016
+ const root = step ? step.root : tag.task_id;
4017
+ if (hasDispatchFor(ctx, rootTaskIds(plans, root), null)) return null;
4018
+ const u = orchestratorUsage(p.transcript_path, loadPricing(ctx), {
4019
+ cacheFile: orchCacheFile(ctx.paths, p.session_id, p.transcript_path),
4020
+ coldMaxBytes: HOOK_COLD_MAX_BYTES,
4021
+ });
4022
+ const tokens = typeof u.ctx_tokens === 'number' ? u.ctx_tokens : null;
4023
+ const comp = typeof u.compactions === 'number' ? u.compactions : null;
4024
+ const over = (tokens !== null && tokens > SESSION_CAP_TOKENS) || (comp !== null && comp >= SESSION_CAP_COMPACTIONS);
4025
+ if (!over) return null;
4026
+ const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'exec' },
4027
+ { gate_action: 'deny', reason_code: 'session-cap' });
4028
+ const sid = p.session_id || '<session>';
4029
+ return {
4030
+ denied: true,
4031
+ event: ev,
4032
+ stdout: preToolDeny(`dieu-phoi: phiên điều phối đã chạm trần cứng (context ${tokens === null ? '?' : tokens} token, `
4033
+ + `${comp === null ? '?' : comp} lần nén; trần ${SESSION_CAP_TOKENS} token hoặc ${SESSION_CAP_COMPACTIONS} lần nén) — `
4034
+ + `không mở root mới trong phiên này. Đừng /compact thêm: ghi trạng thái (\`dieu-phoi state --session ${sid}\` + file trạng thái trong run dir), `
4035
+ + 'trình phần đã xong, rồi MỞ PHIÊN MỚI và nạp lại từ state; giao root mới từ phiên đó. '
4036
+ + 'Chỉ khi người dùng đồng ý giao ngay ở đây: thêm `|session_ok` vào thẻ.'),
3508
4037
  };
3509
4038
  }
3510
4039
 
@@ -3667,8 +4196,11 @@ function runHook(opts) {
3667
4196
  if (name === 'PreToolUse') return handleChainGate(p, ctx); // { stdout } (deny) or null
3668
4197
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
3669
4198
  if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
3670
- if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
3671
- else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
4199
+ if (name === 'SubagentStop') {
4200
+ const r = handleSubagentStop(p, ctx);
4201
+ if (r && typeof r.stdout === 'string') return r; // s15: block decision, printed as the hook's JSON answer
4202
+ ev = r;
4203
+ } else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
3672
4204
  if (ev) {
3673
4205
  try { maybeSpawnSync(ctx); } catch (e) { logError(home, e, 'spawn-sync'); }
3674
4206
  }
@@ -3818,7 +4350,7 @@ async function runSync(ctx, opts) {
3818
4350
  // ------------------------------------------------------------------ exports / main
3819
4351
 
3820
4352
  module.exports = {
3821
- runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, startKnowledgeOf, knowledgeLines, 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,
4353
+ runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, startKnowledgeOf, knowledgeLines, orchLessons, orchLessonLines, abortReasonOf, completionStatus, logGate, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, findOrphanAgents, orphanLines, ORPHAN_MIN_MS, ORPHAN_NO_COMPLETE_MS, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
3822
4354
  modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, MODE_ASK_LINE, isMode, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
3823
4355
  modeSuggestion, modeSuggestLine, fastFailRate, rootTaskIds, FAST_FAIL_WINDOW, FAST_FAIL_MIN_N, FAST_FAIL_RATE,
3824
4356
  testCommandInfo, testTimeOf, testSFromText,
@@ -3828,12 +4360,12 @@ module.exports = {
3828
4360
  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,
3829
4361
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
3830
4362
  firstPromptText, tagFromTranscript, summarizeTranscript,
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,
4363
+ RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, countCompactions, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, SESSION_CAP_TOKENS, SESSION_CAP_COMPACTIONS, appendEvent, EVENTS_LOCK_WAIT_MS, eventLinesFrom, readEventLines, readEvents, readEventsBuffer, archiveFiles, readArchiveLines,
3832
4364
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
3833
4365
  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,
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,
4366
+ globMatch, pathspecOf, diffShas, dirtyFiles, computeFreshness, addDirty, indexStamp, freshnessCacheFile, readFreshCache, cachedFor, freshCounts, knowledgeSummary, shortFileList, readStoreIndex, relatedStatus, startKnowledge, deltaHash, deltaRepoOf, isBareRepoName, storeCatalog, storeByName, scanUnmergedDeltas, scanUnmergedDeltasFor, unmergedDeltaLines, hasUnmergedDeltas, deltaTagOf, deltaClassifier, dispatchRun, DELTA_WARN_MS, DELTA_WINDOW_MS, FRESH_CACHE_MAX, DIFF_TIMEOUT_MS, HOOK_DIFF_TIMEOUT_MS, DIRTY_TIMEOUT_MS,
3835
4367
  projectsDir, findGit, originUrlFromConfig, normalizeRemoteUrl, resolveProject, readHead, repoFromBrief, projectFields, nativePath, isStoreDirName, aliasesFile, loadAliases, keyDirName,
3836
- extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
4368
+ extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, openBackgroundTasks, scanLeftoverProcs, otherAgentsRunning, maybeSpawnSync, runHook, runSync,
3837
4369
  };
3838
4370
 
3839
4371
  if (require.main === module) {