@kienntpixon/dieu-phoi 0.7.0 → 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.0';
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',
@@ -270,6 +270,12 @@ 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 === 'session_ok') {
276
+ tag.session_ok = true; // core 0.7.2 (K5): the user agreed to open a new root in an oversized session
277
+ } else if (p === 'batch') {
278
+ tag.batch = true; // core 0.7.1 (C5): the one batched verifier of a fast request
273
279
  }
274
280
  }
275
281
  return tag;
@@ -311,6 +317,27 @@ function taskIdError(id) {
311
317
 
312
318
  const DEFAULT_EXTRA_TYPES = ['art-gen'];
313
319
 
320
+ // 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
321
+ // dispatch tag with such a label is mapped too, tag_issue=aliased instead of bad-type). A label maps only when its target
322
+ // is known (fixed list or the profile's extra_task_types), e.g. art-fix → art-gen needs art-gen in the profile.
323
+ const TYPE_ALIASES = {
324
+ 'bug-fix': 'debug', bugfix: 'debug', fix: 'debug', bug: 'debug',
325
+ config: 'mechanical-edit', shell: 'mechanical-edit', deploy: 'mechanical-edit', ops: 'mechanical-edit', edit: 'mechanical-edit',
326
+ docs: 'content-write', doc: 'content-write', documentation: 'content-write', writing: 'content-write', copy: 'content-write',
327
+ design: 'plan-design', plan: 'plan-design', planning: 'plan-design', architecture: 'plan-design',
328
+ research: 'research-synth', synth: 'research-synth',
329
+ test: 'test-write', tests: 'test-write',
330
+ analysis: 'data-analysis', data: 'data-analysis',
331
+ impl: 'feature-impl', feature: 'feature-impl', implement: 'feature-impl',
332
+ 'code-review': 'review',
333
+ 'art-fix': 'art-gen', art: 'art-gen',
334
+ // retro 2026-10-26 (C2): scout/read labels
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',
339
+ };
340
+
314
341
  /** Fixed task types + the profile's extra_task_types (art-gen when no profile is readable). */
315
342
  function knownTaskTypes(ctx) {
316
343
  const out = TASK_TYPES.concat(DEFAULT_EXTRA_TYPES);
@@ -350,10 +377,19 @@ function resolveTag(text, known) {
350
377
  // 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
378
  const named = new RegExp(`(?:dp-out-|handoff-|task=)${escRe(head)}(?![a-z0-9])`).test(body.slice(first.length));
352
379
  const idLike = TASK_ID_RE.test(head) && !types.includes(head) && (/\d/.test(head) || named);
380
+ let aliased = null;
353
381
  if (idLike) issues.push('type-is-id');
354
382
  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)
383
+ const target = Object.prototype.hasOwnProperty.call(TYPE_ALIASES, head) ? TYPE_ALIASES[head] : null;
384
+ if (target && types.includes(target)) {
385
+ // core 0.7.1 (C2): a known ad-hoc label maps to its fixed type; the data pools instead of being lost
386
+ issues.push('aliased');
387
+ aliased = target;
388
+ tag.type_alias_from = parts[0];
389
+ } else {
390
+ issues.push('bad-type');
391
+ tag.bad_type = parts[0]; // the offending token (task_type may be replaced by a valid altType below)
392
+ }
357
393
  }
358
394
  if (!tag.task_id) {
359
395
  issues.push('missing-task');
@@ -362,7 +398,8 @@ function resolveTag(text, known) {
362
398
  tag.task_id_inferred = true;
363
399
  }
364
400
  }
365
- if (idLike || tag.task_type === 'untagged') tag.task_type = altType || 'untagged';
401
+ if (aliased) tag.task_type = aliased;
402
+ else if (idLike || tag.task_type === 'untagged') tag.task_type = altType || 'untagged';
366
403
  else if (!types.includes(tag.task_type) && altType) tag.task_type = altType;
367
404
  if (issues.length) tag.tag_issue = issues.join('+');
368
405
  return tag;
@@ -375,6 +412,7 @@ function tagIssueMessage(tag, known) {
375
412
  const codes = tag.tag_issue.split('+');
376
413
  if (codes.includes('no-tag')) what.push('agent dp-* được giao mà không có thẻ `[dp:...]` ở dòng đầu prompt');
377
414
  if (codes.includes('type-is-id')) what.push('ô đầu của thẻ là một task id, không phải loại task');
415
+ 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
416
  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
417
  if (codes.includes('missing-task')) what.push('thiếu `task=<id>`');
380
418
  const got = tag.task_id_inferred
@@ -601,7 +639,7 @@ function summarizeTranscript(lines, opts) {
601
639
  // ------------------------------------------------------------------ orchestrator (main session) usage
602
640
 
603
641
  const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
604
- 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)
605
643
  const ORCH_CHUNK = 1 << 20; // read granularity; memory = chunk + longest line
606
644
  const ORCH_SEEN_KEEP = 64; // requestIds kept for last-line-wins dedupe across scans
607
645
  const ORCH_HEAD_BYTES = 4096; // fingerprint of the file start (rotation / replacement check)
@@ -649,8 +687,9 @@ function ctxOfBuckets(b) {
649
687
  * passed to onLine (return true to stop). Returns { consumed: byte just past the last complete line
650
688
  * handled, read: bytes read }.
651
689
  */
652
- function scanUsageLines(fd, start, end, onLine) {
690
+ function scanUsageLines(fd, start, end, onLine, needle) {
653
691
  const buf = Buffer.allocUnsafe(ORCH_CHUNK);
692
+ const one = needle || null; // null: usage or compaction lines; else only lines holding `needle`
654
693
  let carry = null;
655
694
  let pos = start;
656
695
  let consumed = start;
@@ -667,8 +706,9 @@ function scanUsageLines(fd, start, end, onLine) {
667
706
  while ((nl = data.indexOf(0x0a, s)) >= 0) {
668
707
  const line = data.subarray(s, nl);
669
708
  s = nl + 1;
670
- if ((line.indexOf(USAGE_NEEDLE) >= 0 || line.indexOf(COMPACT_NEEDLE) >= 0)
671
- && 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) {
672
712
  return { consumed: base + s, read };
673
713
  }
674
714
  }
@@ -739,13 +779,22 @@ function edgeProbe(fd, size, st) {
739
779
  }
740
780
  st.cum_known = false;
741
781
  st.requests = null;
742
- 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);
743
786
  if (first) {
744
787
  st.baseline = ctxOfBuckets(usageBuckets(first.usage));
745
788
  st.baseline_req = first.key;
746
789
  }
747
790
  if (last) st.last_ctx = ctxOfBuckets(usageBuckets(last.usage));
748
- 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;
749
798
  }
750
799
 
751
800
  /** Drop stale session caches. Only ever touches <data>/orch/*.json (never another directory). */
@@ -781,8 +830,9 @@ function orchCacheFile(paths, sessionId, transcriptPath) {
781
830
  * opts.cacheFile: per-session state ({offset, cost, seen requestIds, baseline, …}); each call then parses
782
831
  * only the bytes appended since the last call. A shrunk or replaced file (offset > size or a different
783
832
  * head fingerprint) is recomputed from 0.
784
- * opts.coldMaxBytes: no usable cache and the file is larger -> head/tail probe only (ctx + baseline,
785
- * 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.
786
836
  * Reads in chunks (memory = chunk + longest line), never the whole file at once. Never throws.
787
837
  */
788
838
  function orchestratorUsage(transcriptPath, pricing, opts) {
@@ -801,7 +851,8 @@ function orchestratorUsage(transcriptPath, pricing, opts) {
801
851
  const cached = o.cacheFile ? readJsonFile(o.cacheFile, null) : null;
802
852
  if (cached && cached.v === ORCH_CACHE_V && Number.isFinite(cached.offset) && cached.offset <= size
803
853
  && cached.head_len <= size && Array.isArray(cached.seen)
804
- && (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)
805
856
  const now = cached.head_len === fp.head_len ? fp : (() => {
806
857
  const b = Buffer.alloc(cached.head_len);
807
858
  if (cached.head_len) fs.readSync(fd, b, 0, cached.head_len, 0);
@@ -1151,7 +1202,8 @@ function buildSessionState(events, sessionId, opts) {
1151
1202
  const id = ev.task_id || agentTask.get(ev.agent_id);
1152
1203
  if (per.has(id)) per.get(id).steers.push(ev);
1153
1204
  }
1154
- 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);
1155
1207
  }
1156
1208
  const byTime = (a, b) => evMs(a) - evMs(b) || a._i - b._i;
1157
1209
  const tasks = [];
@@ -1255,7 +1307,8 @@ function buildSessionState(events, sessionId, opts) {
1255
1307
  mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason,
1256
1308
  };
1257
1309
  });
1258
- return {
1310
+ const orphans = findOrphanAgents(events, taskIds.filter((id) => !discarded.has(id)), { nowMs: o.nowMs });
1311
+ return Object.assign({
1259
1312
  session_id: sessionId,
1260
1313
  generated_at: nowIso(),
1261
1314
  runs_dir: `${runs}/`,
@@ -1267,7 +1320,85 @@ function buildSessionState(events, sessionId, opts) {
1267
1320
  steps: p.steps.length, run_dir: `${runs}/${p.root_task_id}/`,
1268
1321
  })),
1269
1322
  tasks,
1270
- };
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;
1271
1402
  }
1272
1403
 
1273
1404
  function toPosix(p) {
@@ -1329,7 +1460,8 @@ function stateCounts(state) {
1329
1460
  /** One short Vietnamese line shown to the user after /compact re-injected the state. */
1330
1461
  function compactSystemMessage(state) {
1331
1462
  const c = stateCounts(state);
1332
- 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})`;
1333
1465
  }
1334
1466
 
1335
1467
  /** "\nMode: <root>=<mode> …" for the roots of a session (last 6); '' when the state carries none. */
@@ -1360,7 +1492,8 @@ function formatSessionState(state, cap) {
1360
1492
  const render = (omitted) => {
1361
1493
  const body = stateBody(state, kept);
1362
1494
  const tail = omitted ? `\n(bỏ bớt ${omitted} task cũ nhất để vừa ${limit} ký tự; xem đủ: dieu-phoi state --json)` : '';
1363
- 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')}` : ''}`;
1364
1497
  };
1365
1498
  let omitted = 0;
1366
1499
  let text = render(0);
@@ -1545,11 +1678,14 @@ function startInfo(ctx, cwd) {
1545
1678
  };
1546
1679
  const k = startKnowledgeOf(cwd);
1547
1680
  if (k) info.knowledge = k;
1681
+ const d = scanUnmergedDeltasFor(ctx);
1682
+ if (hasUnmergedDeltas(d)) info.unmerged_deltas = d;
1548
1683
  return info;
1549
1684
  }
1550
1685
 
1551
1686
  function knowledgeLines(info) {
1552
- return info && info.knowledge && Array.isArray(info.knowledge.lines) ? info.knowledge.lines : [];
1687
+ const own = info && info.knowledge && Array.isArray(info.knowledge.lines) ? info.knowledge.lines : [];
1688
+ return own.concat(unmergedDeltaLines(info && info.unmerged_deltas));
1553
1689
  }
1554
1690
 
1555
1691
  function orchLessonLines(info) {
@@ -1633,6 +1769,12 @@ function recordSuggestion(ctx, rec) {
1633
1769
  source: prev && prev.suggested_tier ? prev.source : (rec.source || 'choose'),
1634
1770
  ts: nowIso(),
1635
1771
  };
1772
+ // core 0.7.1 (retro 2026-10-26, C3): why choose answered that tier, copied onto the dispatch as choose_*
1773
+ for (const [k, ok] of [['choose_task_type', (v) => typeof v === 'string' && v.length <= 40],
1774
+ ['choose_score', (v) => typeof v === 'number' && isFinite(v)], ['explored', (v) => typeof v === 'boolean'],
1775
+ ['reason_code', (v) => typeof v === 'string' && v.length <= 24], ['thompson_score', (v) => typeof v === 'number' && isFinite(v)]]) {
1776
+ if (ok(rec[k])) out[k] = rec[k];
1777
+ }
1636
1778
  try {
1637
1779
  fs.mkdirSync(ctx.paths.suggest, { recursive: true });
1638
1780
  writeFileAtomic(file, `${JSON.stringify(out)}\n`);
@@ -1666,11 +1808,35 @@ function pruneSuggest(ctx) {
1666
1808
  // ------------------------------------------------------------------ core 0.5.3: handoff chains (retro 2026-10-22)
1667
1809
  //
1668
1810
  // 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.
1811
+ // failed. Core 0.7.1 (retro 2026-10-26, C1): the warning moves to the 2nd handoff (matching the lesson "stop after the
1812
+ // 2nd"), and from the 3rd the global PreToolUse(Agent) gate denies another exec continuation unless the tag has chain_ok.
1670
1813
 
1671
- const HANDOFF_CHAIN_WARN = 3;
1814
+ const HANDOFF_CHAIN_WARN = 2;
1815
+ const HANDOFF_CHAIN_DENY = 3;
1672
1816
 
1673
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
+
1674
1840
  function countTaskHandoffs(ctx, taskId) {
1675
1841
  if (!taskId) return 0;
1676
1842
  let txt;
@@ -1692,7 +1858,7 @@ function countTaskHandoffs(ctx, taskId) {
1692
1858
  }
1693
1859
 
1694
1860
  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\`).`;
1861
+ 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
1862
  }
1697
1863
 
1698
1864
  // ------------------------------------------------------------------ core 0.7.0: mode per request (fast | normal | strict)
@@ -1705,9 +1871,9 @@ function handoffChainMessage(taskId, n) {
1705
1871
  const MODES = ['fast', 'normal', 'strict'];
1706
1872
  const DEFAULT_MODE = 'normal';
1707
1873
  /** Event kinds that stay on this machine: runSync does not send them (an older sink would answer bad_kind). */
1708
- const LOCAL_ONLY_KINDS = new Set(['mode']);
1709
- 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, '
1710
- + '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>`';
1711
1877
 
1712
1878
  function isMode(m) {
1713
1879
  return typeof m === 'string' && MODES.includes(m);
@@ -2421,6 +2587,209 @@ function relatedStatus(r) {
2421
2587
  return { name: r.name, rel: r.rel, n, counts: freshCounts(hit.sections), text: `${knowledgeSummary({ sections: hit.sections }, n)} (cache)` };
2422
2588
  }
2423
2589
 
2590
+ // ---- retro 2026-10-26 (C6): knowledge deltas that nobody merged
2591
+
2592
+ const DELTA_WARN_MS = 24 * 3600 * 1000; // younger than this: the session may still be about to accept/merge
2593
+ const DELTA_WINDOW_MS = 30 * 24 * 3600 * 1000; // older than this: stale history, not a warning any more
2594
+ const DELTA_SCAN_MAX = 300;
2595
+
2596
+ /** sha256 of a delta file as `knowledge merge` records it in meta.json `applied` (BOM and CRLF ignored). */
2597
+ function deltaHash(text) {
2598
+ return crypto.createHash('sha256').update(String(text).replace(/^/, '').replace(/\r\n?/g, '\n')).digest('hex');
2599
+ }
2600
+
2601
+ /** The `repo:` header of a delta text (before its first `## `), unquoted; null when absent. */
2602
+ function deltaRepoOf(text) {
2603
+ for (const line of String(text).replace(/^/, '').split(/\r?\n/)) {
2604
+ if (/^##\s/.test(line)) return null;
2605
+ const m = /^\s*repo\s*:\s*(.+?)\s*$/i.exec(line);
2606
+ if (m) return m[1].replace(/[.;,]+$/, '').replace(/^[`"'](.*)[`"']$/, '$1').trim() || null;
2607
+ }
2608
+ return null;
2609
+ }
2610
+
2611
+ /** A `repo:` value that is a short name (no path separator, no drive), e.g. `pff-tailor`. */
2612
+ function isBareRepoName(v) {
2613
+ return typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v.trim());
2614
+ }
2615
+
2616
+ /** Every knowledge store under projectsDir(): { dir_name, dir, name, key, roots, applied:Set<sha256> } — file reads only. */
2617
+ function storeCatalog() {
2618
+ const root = projectsDir();
2619
+ let names;
2620
+ try { names = fs.readdirSync(root); } catch (_) { return []; }
2621
+ const out = [];
2622
+ for (const n of names) {
2623
+ const dir = path.join(root, n);
2624
+ if (!isStoreDirName(n) || !isDir(dir)) continue;
2625
+ const meta = readJsonFile(path.join(dir, 'meta.json'), null);
2626
+ const idx = readJsonFile(path.join(dir, 'index.json'), null);
2627
+ if (!meta && !idx) continue;
2628
+ const m = meta && typeof meta === 'object' ? meta : {};
2629
+ const i = idx && typeof idx === 'object' ? idx : {};
2630
+ out.push({
2631
+ dir_name: n,
2632
+ dir: toSlash(dir),
2633
+ name: String(i.name || m.name || n.replace(/-[0-9a-f]{8}$/, '')),
2634
+ key: i.key || (Array.isArray(m.keys) ? m.keys[0] : null) || null,
2635
+ roots: Array.isArray(m.roots) ? m.roots.filter((r) => typeof r === 'string' && r) : [],
2636
+ applied: new Set((Array.isArray(m.applied) ? m.applied : []).map((a) => a && a.sha256).filter(Boolean)),
2637
+ });
2638
+ }
2639
+ return out;
2640
+ }
2641
+
2642
+ /**
2643
+ * Known store a bare repo name refers to: its `name`, its directory name, or the basename of a recorded root equals
2644
+ * `name` (case-insensitive). { matches, store } — store only when exactly one store matches.
2645
+ */
2646
+ function storeByName(name, stores) {
2647
+ const want = String(name).trim().toLowerCase();
2648
+ const matches = (stores || storeCatalog()).filter((s) => s.name.toLowerCase() === want || s.dir_name.toLowerCase() === want
2649
+ || s.roots.some((r) => path.basename(r).toLowerCase() === want));
2650
+ return { matches, store: matches.length === 1 ? matches[0] : null };
2651
+ }
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
+
2689
+ /**
2690
+ * Delta files in run dirs that are 24 h .. 30 d old and that no store has applied (hash in meta.json `applied`).
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.
2695
+ */
2696
+ function scanUnmergedDeltas(opts) {
2697
+ const o = opts || {};
2698
+ const now = o.now || Date.now();
2699
+ const warnMs = o.warnMs === undefined ? DELTA_WARN_MS : o.warnMs;
2700
+ const windowMs = o.windowMs === undefined ? DELTA_WINDOW_MS : o.windowMs;
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: [] };
2704
+ try {
2705
+ const runs = runsDir();
2706
+ const applied = new Set();
2707
+ for (const s of storeCatalog()) for (const h of s.applied) applied.add(h);
2708
+ let seen = 0;
2709
+ for (const run of fs.readdirSync(runs)) {
2710
+ const dir = path.join(runs, run);
2711
+ let st;
2712
+ try { st = fs.statSync(dir); } catch (_) { continue; }
2713
+ if (!st.isDirectory() || now - st.mtimeMs > windowMs) continue;
2714
+ let names;
2715
+ try { names = fs.readdirSync(dir); } catch (_) { continue; }
2716
+ const row = { run, n: 0, oldest_ms: 0, repos: {} };
2717
+ const arow = { run, n: 0, oldest_ms: 0, repos: {}, tasks: [], untagged: false };
2718
+ for (const f of names) {
2719
+ if (!/^knowledge-delta.*\.md$/i.test(f)) continue;
2720
+ const file = path.join(dir, f);
2721
+ let fst;
2722
+ try { fst = fs.statSync(file); } catch (_) { continue; }
2723
+ const age = now - fst.mtimeMs;
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;
2730
+ if (seen++ >= DELTA_SCAN_MAX) { res.truncated = true; break; }
2731
+ const text = readTextFile(file);
2732
+ if (text == null || applied.has(deltaHash(text))) continue;
2733
+ const repo = deltaRepoOf(text);
2734
+ const label = repo ? (isBareRepoName(repo) ? repo : path.basename(repo.replace(/[\\/]+$/, ''))) : '?';
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; }
2739
+ }
2740
+ if (row.n) { res.runs.push(row); res.count += row.n; }
2741
+ if (arow.n) { acc.runs.push(arow); acc.count += arow.n; }
2742
+ if (res.truncated) break;
2743
+ }
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;
2747
+ } catch (e) {
2748
+ res.error = e.message;
2749
+ }
2750
+ return res;
2751
+ }
2752
+
2753
+ function ageText(ms) {
2754
+ return ms >= 48 * 3600 * 1000 ? `${Math.floor(ms / 86400000)} ngày` : `${Math.floor(ms / 3600000)} giờ`;
2755
+ }
2756
+
2757
+ /** One warning line (as an array, empty when nothing is pending) for the start block and `state`. */
2758
+ function unmergedDeltaLines(scan) {
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;
2769
+ const oldest = Math.max(...scan.runs.map((r) => r.oldest_ms));
2770
+ const repos = {};
2771
+ for (const r of scan.runs) for (const [k, v] of Object.entries(r.repos)) repos[k] = (repos[k] || 0) + v;
2772
+ const repoText = Object.entries(repos).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ×${v}`).join(', ');
2773
+ const runText = scan.runs.slice(0, 3).map((r) => `${r.run} ×${r.n}`).join(', ') + (scan.runs.length > 3 ? ` (+${scan.runs.length - 3} run)` : '');
2774
+ const more = scan.truncated ? ' (chỉ quét một phần)' : '';
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 });
2791
+ }
2792
+
2424
2793
  /**
2425
2794
  * Knowledge lines for the start block of the repo at `cwd`; null outside a repo or when the repo has no store.
2426
2795
  * Refreshes this repo only (at most one git diff-tree, on a cache miss, bounded by HOOK_DIFF_TIMEOUT_MS; never git
@@ -2491,6 +2860,16 @@ function projectFields(p) {
2491
2860
  return {};
2492
2861
  }
2493
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
+
2494
2873
  function buildDispatch(p, ctx) {
2495
2874
  const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
2496
2875
  const tr = p.tool_response;
@@ -2518,6 +2897,11 @@ function buildDispatch(p, ctx) {
2518
2897
  ...(tag.task_id && !isValidTaskId(tag.task_id) ? { task_id_invalid: true } : {}),
2519
2898
  ...(tag.task_id_inferred ? { task_id_inferred: true } : {}),
2520
2899
  ...(tag.tag_issue ? { tag_issue: tag.tag_issue } : {}),
2900
+ ...(tag.type_alias_from ? { type_alias_from: tag.type_alias_from } : {}),
2901
+ ...(tag.chain_ok ? { chain_ok: true } : {}),
2902
+ ...(tag.session_ok ? { session_ok: true } : {}),
2903
+ ...(tag.batch ? { batch: true } : {}),
2904
+ ...(dispatchRun(ti.prompt) ? { run: dispatchRun(ti.prompt) } : {}),
2521
2905
  task_type: tag.task_type,
2522
2906
  score: tag.score,
2523
2907
  urgency: tag.urgency,
@@ -2543,6 +2927,11 @@ function buildDispatch(p, ctx) {
2543
2927
  commonFields(ctx));
2544
2928
  }
2545
2929
 
2930
+ /** core 0.7.1 (C3): the tier came from `choose` for another task type than the tag carries. */
2931
+ function chooseTypeMismatchMessage(ev) {
2932
+ 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ả).`;
2933
+ }
2934
+
2546
2935
  /** 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
2936
  function modeFields(ctx, tag) {
2548
2937
  if (!tag || !tag.task_id || !isValidTaskId(tag.task_id)) return {};
@@ -2561,6 +2950,15 @@ function tierFields(ctx, tag, model, effort) {
2561
2950
  dispatched_tier: dispatched,
2562
2951
  };
2563
2952
  if (dispatched && !dispatched.includes('?')) out.tier_mismatch = dispatched !== out.choose_tier;
2953
+ // core 0.7.1 (C3): choose's own inputs/reason, and whether choose was asked about another task type
2954
+ if (typeof s.choose_task_type === 'string') {
2955
+ out.choose_task_type = s.choose_task_type;
2956
+ if (tag.task_type && tag.task_type !== s.choose_task_type) out.choose_type_mismatch = true;
2957
+ }
2958
+ if (typeof s.choose_score === 'number') out.choose_score = s.choose_score;
2959
+ if (typeof s.explored === 'boolean') out.choose_explored = s.explored;
2960
+ if (typeof s.reason_code === 'string') out.choose_reason = s.reason_code;
2961
+ if (typeof s.thompson_score === 'number') out.choose_thompson_score = s.thompson_score;
2564
2962
  return out;
2565
2963
  }
2566
2964
 
@@ -2579,12 +2977,21 @@ function handlePostToolUse(p, ctx) {
2579
2977
  const tag = resolveTag(ti.prompt, known) || { tag_issue: ev.tag_issue, task_id: null, task_type: ev.task_type };
2580
2978
  notes.push(tagIssueMessage(tag, known));
2581
2979
  }
2980
+ if (ev.choose_type_mismatch) notes.push(chooseTypeMismatchMessage(ev));
2582
2981
  if (ev.task_id && ev.role === 'exec') {
2583
2982
  const n = countTaskHandoffs(ctx, ev.task_id);
2584
2983
  if (n) ev.task_handoffs = n;
2585
2984
  if (n >= HANDOFF_CHAIN_WARN) notes.push(handoffChainMessage(ev.task_id, n));
2586
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
+ }
2587
2993
  appendEvent(ctx, ev);
2994
+ if (implicit) appendEvent(ctx, implicit);
2588
2995
  if (notes.length) {
2589
2996
  Object.defineProperty(ev, 'stdout', {
2590
2997
  enumerable: false,
@@ -2594,6 +3001,51 @@ function handlePostToolUse(p, ctx) {
2594
3001
  return ev;
2595
3002
  }
2596
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
+
2597
3049
  function hasAssistantUsage(lines) {
2598
3050
  return lines.some((l) => l && l.type === 'assistant' && l.message && l.message.usage
2599
3051
  && l.message.model !== '<synthetic>');
@@ -2739,6 +3191,101 @@ function persistInlineResult(lines, tag, opts) {
2739
3191
  return file;
2740
3192
  }
2741
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
+
2742
3289
  function handleSubagentStop(p, ctx) {
2743
3290
  const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
2744
3291
  if (!agentId) return null;
@@ -2761,6 +3308,14 @@ function handleSubagentStop(p, ctx) {
2761
3308
  sum = summarizeTranscript(lines, { afterUuids: seen, pricing: loadPricing(ctx) });
2762
3309
  tag = tagFromTranscript(lines, knownTaskTypes(ctx));
2763
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
+ }
2764
3319
  const t = tag || UNTAGGED;
2765
3320
  const ev = Object.assign({
2766
3321
  schema: SCHEMA,
@@ -2789,12 +3344,18 @@ function handleSubagentStop(p, ctx) {
2789
3344
  ev.handoff = ev.status === 'handoff';
2790
3345
  ev.peak_ctx = sum ? sum.peak_ctx : null;
2791
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;
2792
3349
  let inline = null;
2793
3350
  if (exists && tag) {
2794
3351
  try { inline = persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
2795
3352
  }
2796
3353
  // exec/verify/plan only: true = it returned without writing its dp-out file (the hook saved the .inline.md)
2797
- 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
+ }
2798
3359
  // core 0.7.0 (s2): seconds spent running tests in this segment (transcript; else the dp-out `TEST_S:` line)
2799
3360
  const tt = exists && sum ? testTimeOf(lines.slice(sum.segment_start)) : null;
2800
3361
  ev.test_s = tt ? tt.test_s : null;
@@ -2822,6 +3383,100 @@ function handleSubagentStop(p, ctx) {
2822
3383
  verify: 'none', reason_code: `auto-${why}`, cause: null, round: null,
2823
3384
  }, commonFields(ctx)));
2824
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);
2825
3480
  return ev;
2826
3481
  }
2827
3482
 
@@ -3287,6 +3942,101 @@ function gateReserve(dir, child) {
3287
3942
  return 0;
3288
3943
  }
3289
3944
 
3945
+ /**
3946
+ * Core 0.7.1 (C1): global PreToolUse(Agent) in the orchestrating session. An exec dispatch of a task that already
3947
+ * handed off HANDOFF_CHAIN_DENY times is denied (the 4th, 5th… segment of one chain cost $15-36 per task in session
3948
+ * 36cee993) unless the tag carries `chain_ok`. Inside a subagent (agent_id) this does nothing: --gate handles gen-2.
3949
+ */
3950
+ function handleChainGate(p, ctx) {
3951
+ if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
3952
+ if (watchAgentId(p)) return null;
3953
+ const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
3954
+ const tag = resolveTag(ti.prompt, knownTaskTypes(ctx));
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) {
3971
+ const n = countTaskHandoffs(ctx, tag.task_id);
3972
+ if (n < HANDOFF_CHAIN_DENY) return null;
3973
+ const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'exec' },
3974
+ { gate_action: 'deny', reason_code: 'handoff-chain', children: n });
3975
+ return {
3976
+ denied: true,
3977
+ event: ev,
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ẻ.'),
4037
+ };
4038
+ }
4039
+
3290
4040
  /** Mark the reservation of `child` as a real spawn. Returns true when its slot was found. */
3291
4041
  function gateConfirm(ctx, key, child) {
3292
4042
  if (!key || !child) return false;
@@ -3443,10 +4193,14 @@ function runHook(opts) {
3443
4193
  if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
3444
4194
  if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
3445
4195
  if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
4196
+ if (name === 'PreToolUse') return handleChainGate(p, ctx); // { stdout } (deny) or null
3446
4197
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
3447
4198
  if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
3448
- if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
3449
- 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);
3450
4204
  if (ev) {
3451
4205
  try { maybeSpawnSync(ctx); } catch (e) { logError(home, e, 'spawn-sync'); }
3452
4206
  }
@@ -3596,22 +4350,22 @@ async function runSync(ctx, opts) {
3596
4350
  // ------------------------------------------------------------------ exports / main
3597
4351
 
3598
4352
  module.exports = {
3599
- 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,
3600
4354
  modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, MODE_ASK_LINE, isMode, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
3601
4355
  modeSuggestion, modeSuggestLine, fastFailRate, rootTaskIds, FAST_FAIL_WINDOW, FAST_FAIL_MIN_N, FAST_FAIL_RATE,
3602
4356
  testCommandInfo, testTimeOf, testSFromText,
3603
- CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
4357
+ CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED, TYPE_ALIASES,
3604
4358
  sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
3605
4359
  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,
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,
3607
4361
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
3608
4362
  firstPromptText, tagFromTranscript, summarizeTranscript,
3609
- 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,
3610
4364
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
3611
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,
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,
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,
3613
4367
  projectsDir, findGit, originUrlFromConfig, normalizeRemoteUrl, resolveProject, readHead, repoFromBrief, projectFields, nativePath, isStoreDirName, aliasesFile, loadAliases, keyDirName,
3614
- extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
4368
+ extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, openBackgroundTasks, scanLeftoverProcs, otherAgentsRunning, maybeSpawnSync, runHook, runSync,
3615
4369
  };
3616
4370
 
3617
4371
  if (require.main === module) {