@kienntpixon/dieu-phoi 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.6.1';
29
+ const CORE_VERSION = '0.7.1';
30
30
  const SCHEMA = 1;
31
31
  const TASK_TYPES = [
32
32
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -270,6 +270,10 @@ function parseTag(text) {
270
270
  tag.role = mm[1];
271
271
  } else if ((mm = /^parent=([A-Za-z0-9_-]{1,32})$/.exec(p))) {
272
272
  tag.parent_task_id = mm[1]; // core 0.5.0: gen-2 helper -> the dp-exec step that spawned it
273
+ } else if (p === 'chain_ok') {
274
+ tag.chain_ok = true; // core 0.7.1 (C1): the user agreed to continue a long handoff chain
275
+ } else if (p === 'batch') {
276
+ tag.batch = true; // core 0.7.1 (C5): the one batched verifier of a fast request
273
277
  }
274
278
  }
275
279
  return tag;
@@ -311,6 +315,24 @@ function taskIdError(id) {
311
315
 
312
316
  const DEFAULT_EXTRA_TYPES = ['art-gen'];
313
317
 
318
+ // Ad-hoc labels seen in real logs → the closest fixed type (core 0.4.0 in choose; core 0.7.1: shared with the hook so a
319
+ // dispatch tag with such a label is mapped too, tag_issue=aliased instead of bad-type). A label maps only when its target
320
+ // is known (fixed list or the profile's extra_task_types), e.g. art-fix → art-gen needs art-gen in the profile.
321
+ const TYPE_ALIASES = {
322
+ 'bug-fix': 'debug', bugfix: 'debug', fix: 'debug', bug: 'debug',
323
+ config: 'mechanical-edit', shell: 'mechanical-edit', deploy: 'mechanical-edit', ops: 'mechanical-edit', edit: 'mechanical-edit',
324
+ docs: 'content-write', doc: 'content-write', documentation: 'content-write', writing: 'content-write', copy: 'content-write',
325
+ design: 'plan-design', plan: 'plan-design', planning: 'plan-design', architecture: 'plan-design',
326
+ research: 'research-synth', synth: 'research-synth',
327
+ test: 'test-write', tests: 'test-write',
328
+ analysis: 'data-analysis', data: 'data-analysis',
329
+ impl: 'feature-impl', feature: 'feature-impl', implement: 'feature-impl',
330
+ 'code-review': 'review',
331
+ 'art-fix': 'art-gen', art: 'art-gen',
332
+ // retro 2026-10-26 (C2): scout/read labels
333
+ scan: 'search', read: 'search', 'code-read': 'search', explore: 'search', scout: 'search', lookup: 'search',
334
+ };
335
+
314
336
  /** Fixed task types + the profile's extra_task_types (art-gen when no profile is readable). */
315
337
  function knownTaskTypes(ctx) {
316
338
  const out = TASK_TYPES.concat(DEFAULT_EXTRA_TYPES);
@@ -350,10 +372,19 @@ function resolveTag(text, known) {
350
372
  // an id, not a label: id format, not a known type, and a digit or a run-file / task= mention ("planning" is a label)
351
373
  const named = new RegExp(`(?:dp-out-|handoff-|task=)${escRe(head)}(?![a-z0-9])`).test(body.slice(first.length));
352
374
  const idLike = TASK_ID_RE.test(head) && !types.includes(head) && (/\d/.test(head) || named);
375
+ let aliased = null;
353
376
  if (idLike) issues.push('type-is-id');
354
377
  else if (!types.includes(head)) {
355
- issues.push('bad-type');
356
- tag.bad_type = parts[0]; // the offending token (task_type may be replaced by a valid altType below)
378
+ const target = Object.prototype.hasOwnProperty.call(TYPE_ALIASES, head) ? TYPE_ALIASES[head] : null;
379
+ if (target && types.includes(target)) {
380
+ // core 0.7.1 (C2): a known ad-hoc label maps to its fixed type; the data pools instead of being lost
381
+ issues.push('aliased');
382
+ aliased = target;
383
+ tag.type_alias_from = parts[0];
384
+ } else {
385
+ issues.push('bad-type');
386
+ tag.bad_type = parts[0]; // the offending token (task_type may be replaced by a valid altType below)
387
+ }
357
388
  }
358
389
  if (!tag.task_id) {
359
390
  issues.push('missing-task');
@@ -362,7 +393,8 @@ function resolveTag(text, known) {
362
393
  tag.task_id_inferred = true;
363
394
  }
364
395
  }
365
- if (idLike || tag.task_type === 'untagged') tag.task_type = altType || 'untagged';
396
+ if (aliased) tag.task_type = aliased;
397
+ else if (idLike || tag.task_type === 'untagged') tag.task_type = altType || 'untagged';
366
398
  else if (!types.includes(tag.task_type) && altType) tag.task_type = altType;
367
399
  if (issues.length) tag.tag_issue = issues.join('+');
368
400
  return tag;
@@ -375,6 +407,7 @@ function tagIssueMessage(tag, known) {
375
407
  const codes = tag.tag_issue.split('+');
376
408
  if (codes.includes('no-tag')) what.push('agent dp-* được giao mà không có thẻ `[dp:...]` ở dòng đầu prompt');
377
409
  if (codes.includes('type-is-id')) what.push('ô đầu của thẻ là một task id, không phải loại task');
410
+ if (codes.includes('aliased')) what.push(`loại "${tag.type_alias_from}" không cố định — hook đã ghi thành "${tag.task_type}" (dùng đúng loại này trong thẻ)`);
378
411
  if (codes.includes('bad-type')) what.push(`loại "${tag.bad_type || tag.task_type}" không nằm trong danh sách loại hợp lệ`);
379
412
  if (codes.includes('missing-task')) what.push('thiếu `task=<id>`');
380
413
  const got = tag.task_id_inferred
@@ -1118,6 +1151,9 @@ function buildSessionState(events, sessionId, opts) {
1118
1151
  for (const id of [...planOfRoot.keys(), ...stepOf.keys()]) {
1119
1152
  if (!seenTask.has(id) && !discarded.has(id)) { seenTask.add(id); taskIds.push(id); }
1120
1153
  }
1154
+ const modeEvents = new Map();
1155
+ for (const ev of events) if (ev && ev.kind === 'mode' && isMode(ev.mode) && typeof ev.root_task_id === 'string') modeEvents.set(ev.root_task_id, ev);
1156
+ const modeOfRoot = (root) => (modeEvents.has(root) ? modeEvents.get(root).mode : DEFAULT_MODE);
1121
1157
  const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
1122
1158
  events.forEach((ev, i) => {
1123
1159
  if (!ev || typeof ev !== 'object') return;
@@ -1168,6 +1204,7 @@ function buildSessionState(events, sessionId, opts) {
1168
1204
  const pApproved = pov && evMs(pov) > (Date.parse(ps.plan.approved_at) || 0) ? pov.chosen_tier || null : null;
1169
1205
  tasks.push(Object.assign({
1170
1206
  task_id: id, task_type: ps.step.type || null, gate: 2, pending: true, verify_only: false, round: 0,
1207
+ mode: isMode(ps.step.mode) ? ps.step.mode : modeOfRoot(ps.plan.root_task_id),
1171
1208
  }, planInfo, {
1172
1209
  approved_tier: pApproved || ps.step.tier || null, last_tier: null, attempts: [], user_verdict: null, last_steer: null,
1173
1210
  run_dir: planDir, last_ts: null,
@@ -1225,6 +1262,7 @@ function buildSessionState(events, sessionId, opts) {
1225
1262
  pending: false,
1226
1263
  verify_only: attempts.length > 0 && attempts.every((a) => a.role === 'verify'),
1227
1264
  round,
1265
+ mode: dispatchModeFromEvents(t.dispatches, id) || (ps && isMode(ps.step.mode) ? ps.step.mode : modeOfRoot(plan ? plan.root_task_id : id)),
1228
1266
  }, planInfo, {
1229
1267
  approved_tier: approved,
1230
1268
  last_tier: lastExecAny ? tierOf(lastExecAny.model, lastExecAny.effort) : null,
@@ -1235,10 +1273,26 @@ function buildSessionState(events, sessionId, opts) {
1235
1273
  last_ts: lastMs ? new Date(lastMs).toISOString() : null,
1236
1274
  }));
1237
1275
  }
1276
+ // modes of the roots this session works on: plan roots, tasks' own roots, and roots whose mode event was written here
1277
+ const sidHash = sha8(sessionId);
1278
+ const modeRoots = new Set(plans.map((p) => p.root_task_id));
1279
+ for (const id of taskIds) if (modeEvents.has(id)) modeRoots.add(id);
1280
+ for (const [root, ev] of modeEvents) if (ev.session_id === sessionId || ev.session === sidHash) modeRoots.add(root);
1281
+ const modes = Array.from(modeRoots).map((root) => {
1282
+ const ev = modeEvents.get(root);
1283
+ const mode = ev ? ev.mode : DEFAULT_MODE;
1284
+ // core 0.7.0 (s2): suggestion only — the mode itself never changes here
1285
+ const sug = modeSuggestion(events, root, { mode, taskIds: rootTaskIds(allPlans, root) });
1286
+ return {
1287
+ root_task_id: root, mode, since: ev ? ev.ts : null, source: ev ? ev.source || null : 'default', reason_code: ev ? ev.reason_code || null : null,
1288
+ mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason,
1289
+ };
1290
+ });
1238
1291
  return {
1239
1292
  session_id: sessionId,
1240
1293
  generated_at: nowIso(),
1241
1294
  runs_dir: `${runs}/`,
1295
+ modes,
1242
1296
  plans: plans.map((p) => ({
1243
1297
  root_task_id: p.root_task_id,
1244
1298
  approval: Number.isInteger(p.approval) ? p.approval : (Number.isInteger(p.round) ? p.round : null),
@@ -1268,7 +1322,8 @@ function taskLines(t) {
1268
1322
  + `${t.round ? ` · vòng ${t.round}` : ''}${t.approved_tier ? ` · duyệt ${t.approved_tier}` : ''}`
1269
1323
  + `${t.last_tier && t.last_tier !== t.approved_tier ? ` · tier cuối ${t.last_tier}` : ''}`
1270
1324
  + `${t.user_verdict ? ` · người dùng: ${t.user_verdict.outcome}${t.user_verdict.cause ? `/${t.user_verdict.cause}` : ''}` : ''}`
1271
- + `${t.last_steer ? ` · steer: ${t.last_steer}` : ''}`;
1325
+ + `${t.last_steer ? ` · steer: ${t.last_steer}` : ''}`
1326
+ + `${t.mode && t.mode !== DEFAULT_MODE ? ` · mode ${t.mode}` : ''}`;
1272
1327
  const att = t.attempts.map((a) => `${a.agent_id} ${a.role}${a.try ? ` try${a.try}` : ''} ${tierOf(a.model, a.effort) || '?'} ${a.status}`);
1273
1328
  const out = [head];
1274
1329
  if (att.length) out.push(`${indent} ${att.join(' | ')}`);
@@ -1310,11 +1365,26 @@ function compactSystemMessage(state) {
1310
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)`;
1311
1366
  }
1312
1367
 
1368
+ /** "\nMode: <root>=<mode> …" for the roots of a session (last 6); '' when the state carries none. */
1369
+ function modesLine(state) {
1370
+ const ms = Array.isArray(state.modes) ? state.modes : [];
1371
+ if (!ms.length) return '';
1372
+ const sug = ms.slice(-6).filter((m) => m.mode_suggest).map((m) => `\n${modeSuggestLine(m.root_task_id, m.mode, m.mode_suggest_reason)}`).join('');
1373
+ return `\nMode (theo yêu cầu, đổi bằng \`dieu-phoi mode --root <id> <mode>\`): ${ms.slice(-6).map((m) => `${m.root_task_id}=${m.mode}`).join(', ')}.${sug}`;
1374
+ }
1375
+
1376
+ /** One line telling the orchestrator to ASK the user about returning to normal (never switch by itself). */
1377
+ function modeSuggestLine(root, mode, reason) {
1378
+ return `GỢI Ý mode: ${root ? `${root} đang ${mode}` : `đang ${mode}`} — hỏi người dùng có muốn quay về normal không (${reason}); `
1379
+ + `chỉ đổi khi người dùng đồng ý: \`dieu-phoi mode --root ${root || '<root>'} normal\`. KHÔNG tự đổi mode.`;
1380
+ }
1381
+
1313
1382
  function formatSessionState(state, cap) {
1314
1383
  const limit = cap || STATE_TEXT_CAP;
1315
1384
  const { open, pending, running } = stateCounts(state);
1316
1385
  const header = `Phiên ${state.session_id}: ${state.tasks.length} task (${open.length} chưa xong, ${pending} bước chưa giao, ${running} agent đang chạy). `
1317
- + `Thư mục chạy: ${state.runs_dir} (chỉ trỏ tới, không tự đọc).`;
1386
+ + `Thư mục chạy: ${state.runs_dir} (chỉ trỏ tới, không tự đọc).`
1387
+ + modesLine(state);
1318
1388
  if (!state.tasks.length) return `${header}\nChưa có task nào được giao trong phiên này.`;
1319
1389
  const byAge = (a, b) => String(a.last_ts || '').localeCompare(String(b.last_ts || ''));
1320
1390
  const dropOrder = state.tasks.filter((t) => t.gate === 'done').sort(byAge)
@@ -1503,16 +1573,19 @@ function startInfo(ctx, cwd) {
1503
1573
  const run = startRun();
1504
1574
  const health = quickHealth(ctx, run.runs);
1505
1575
  const info = {
1506
- id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx),
1576
+ id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx), mode: null,
1507
1577
  orch_lessons: orchLessons(ctx),
1508
1578
  };
1509
1579
  const k = startKnowledgeOf(cwd);
1510
1580
  if (k) info.knowledge = k;
1581
+ const d = scanUnmergedDeltas();
1582
+ if (d.count) info.unmerged_deltas = d;
1511
1583
  return info;
1512
1584
  }
1513
1585
 
1514
1586
  function knowledgeLines(info) {
1515
- return info && info.knowledge && Array.isArray(info.knowledge.lines) ? info.knowledge.lines : [];
1587
+ const own = info && info.knowledge && Array.isArray(info.knowledge.lines) ? info.knowledge.lines : [];
1588
+ return own.concat(unmergedDeltaLines(info && info.unmerged_deltas));
1516
1589
  }
1517
1590
 
1518
1591
  function orchLessonLines(info) {
@@ -1520,6 +1593,11 @@ function orchLessonLines(info) {
1520
1593
  return ls.length ? ['bài học cho điều phối viên (profile/lessons.md, [orch]):'].concat(ls.map((l) => ` - ${l}`)) : [];
1521
1594
  }
1522
1595
 
1596
+ /** Gate-1 mode line: the chosen mode, or the reminder to ASK the user (default normal). */
1597
+ function modeLine(info) {
1598
+ return info && isMode(info.mode) ? `mode: ${info.mode} (đã ghi cho root ${info.id}; đổi: dieu-phoi mode --root ${info.id} <mode>)` : MODE_ASK_LINE;
1599
+ }
1600
+
1523
1601
  function formatStartBlock(info) {
1524
1602
  return [
1525
1603
  START_PREFIX,
@@ -1528,6 +1606,7 @@ function formatStartBlock(info) {
1528
1606
  `step_ids: ${info.step_ids.join(' ')}`,
1529
1607
  `health: ${healthLine(info.health)}`,
1530
1608
  `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
1609
+ modeLine(info),
1531
1610
  ...orchLessonLines(info),
1532
1611
  ...knowledgeLines(info),
1533
1612
  'Dùng khối này cho yêu cầu MỚI ở cổng 1 (không dùng nếu đang giữa một lần điều phối). Không chạy `dieu-phoi doctor` / `new-id` / mkdir: lượt đầu tiên là Agent scout + câu hỏi cổng 1 cho người dùng.',
@@ -1590,6 +1669,12 @@ function recordSuggestion(ctx, rec) {
1590
1669
  source: prev && prev.suggested_tier ? prev.source : (rec.source || 'choose'),
1591
1670
  ts: nowIso(),
1592
1671
  };
1672
+ // core 0.7.1 (retro 2026-10-26, C3): why choose answered that tier, copied onto the dispatch as choose_*
1673
+ for (const [k, ok] of [['choose_task_type', (v) => typeof v === 'string' && v.length <= 40],
1674
+ ['choose_score', (v) => typeof v === 'number' && isFinite(v)], ['explored', (v) => typeof v === 'boolean'],
1675
+ ['reason_code', (v) => typeof v === 'string' && v.length <= 24], ['thompson_score', (v) => typeof v === 'number' && isFinite(v)]]) {
1676
+ if (ok(rec[k])) out[k] = rec[k];
1677
+ }
1593
1678
  try {
1594
1679
  fs.mkdirSync(ctx.paths.suggest, { recursive: true });
1595
1680
  writeFileAtomic(file, `${JSON.stringify(out)}\n`);
@@ -1623,9 +1708,11 @@ function pruneSuggest(ctx) {
1623
1708
  // ------------------------------------------------------------------ core 0.5.3: handoff chains (retro 2026-10-22)
1624
1709
  //
1625
1710
  // 15/63 feature-impl tasks handed off >= 2 times (19% of feature-impl cost); one chain ran 10 handoffs ($70) and
1626
- // failed. From the 3rd handoff of a task the orchestrator is told to stop continuing and have a planner re-split.
1711
+ // failed. Core 0.7.1 (retro 2026-10-26, C1): the warning moves to the 2nd handoff (matching the lesson "stop after the
1712
+ // 2nd"), and from the 3rd the global PreToolUse(Agent) gate denies another exec continuation unless the tag has chain_ok.
1627
1713
 
1628
- const HANDOFF_CHAIN_WARN = 3;
1714
+ const HANDOFF_CHAIN_WARN = 2;
1715
+ const HANDOFF_CHAIN_DENY = 3;
1629
1716
 
1630
1717
  /** Number of agents whose `complete` event has handoff:true for this task (substring scan of events.jsonl). */
1631
1718
  function countTaskHandoffs(ctx, taskId) {
@@ -1649,7 +1736,204 @@ function countTaskHandoffs(ctx, taskId) {
1649
1736
  }
1650
1737
 
1651
1738
  function handoffChainMessage(taskId, n) {
1652
- return `dieu-phoi: task ${taskId} đã handoff ${n} lần (trần ${HANDOFF_CHAIN_WARN}). Đừng nối tiếp thêm: nếu agent vừa giao là agent tiếp nối, cân nhắc dừng nó và giao planner (dp-scout) đọc handoff-${taskId}.md rồi chia phần còn lại thành bước mới (task_id mới từ \`dieu-phoi new-id\`).`;
1739
+ return `dieu-phoi: task ${taskId} đã handoff ${n} lần (cảnh báo từ ${HANDOFF_CHAIN_WARN}; từ ${HANDOFF_CHAIN_DENY} hook chặn lượt exec tiếp nối, trừ thẻ có \`chain_ok\` khi người dùng đồng ý). Đừng nối tiếp thêm: nếu agent vừa giao là agent tiếp nối, cân nhắc dừng nó và giao planner (dp-scout) đọc handoff-${taskId}.md rồi chia phần còn lại thành bước mới (task_id mới từ \`dieu-phoi new-id\`).`;
1740
+ }
1741
+
1742
+ // ------------------------------------------------------------------ core 0.7.0: mode per request (fast | normal | strict)
1743
+ //
1744
+ // A request (root_task_id) has a mode; the user picks it at gate 1 (`start --mode`, `plan-table` file, or
1745
+ // `dieu-phoi mode --root R <mode>` at any time). Every change is one local-only event kind=mode. A step not yet
1746
+ // dispatched follows the root's current mode (or its own `mode` override in the approved plan); a step already
1747
+ // dispatched keeps the mode its first exec dispatch recorded. Nothing here ever changes a mode by itself.
1748
+
1749
+ const MODES = ['fast', 'normal', 'strict'];
1750
+ const DEFAULT_MODE = 'normal';
1751
+ /** 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>`';
1755
+
1756
+ function isMode(m) {
1757
+ return typeof m === 'string' && MODES.includes(m);
1758
+ }
1759
+
1760
+ /** Last mode event of `root` in an events array (null when none). */
1761
+ function rootModeFromEvents(events, root) {
1762
+ let last = null;
1763
+ for (const ev of events) {
1764
+ if (ev && ev.kind === 'mode' && ev.root_task_id === root && isMode(ev.mode)) last = ev;
1765
+ }
1766
+ return last;
1767
+ }
1768
+
1769
+ /** Same, from the text of events.jsonl (hook hot path: substring scan, no full parse). */
1770
+ function rootModeFromText(txt, root) {
1771
+ const needle = '"kind":"mode"';
1772
+ const rootNeedle = `"root_task_id":${JSON.stringify(root)}`;
1773
+ let last = null;
1774
+ let i = txt.indexOf(needle);
1775
+ while (i >= 0) {
1776
+ const start = txt.lastIndexOf('\n', i) + 1;
1777
+ const end = txt.indexOf('\n', i);
1778
+ const line = txt.slice(start, end < 0 ? undefined : end);
1779
+ if (line.includes(rootNeedle)) {
1780
+ try {
1781
+ const ev = JSON.parse(line);
1782
+ if (ev && ev.kind === 'mode' && isMode(ev.mode)) last = ev;
1783
+ } catch (_) { /* corrupt line */ }
1784
+ }
1785
+ i = end < 0 ? -1 : txt.indexOf(needle, end);
1786
+ }
1787
+ return last;
1788
+ }
1789
+
1790
+ /** Mode the first exec dispatch of `taskId` recorded (a dispatched step keeps its mode), from an events array. */
1791
+ function dispatchModeFromEvents(events, taskId) {
1792
+ for (const ev of events) {
1793
+ if (ev && ev.kind === 'dispatch' && ev.task_id === taskId && (ev.role || 'exec') === 'exec' && isMode(ev.mode)) return ev.mode;
1794
+ }
1795
+ return null;
1796
+ }
1797
+
1798
+ function dispatchModeFromText(txt, taskId) {
1799
+ const needle = `"task_id":${JSON.stringify(taskId)}`;
1800
+ let i = txt.indexOf(needle);
1801
+ while (i >= 0) {
1802
+ const start = txt.lastIndexOf('\n', i) + 1;
1803
+ const end = txt.indexOf('\n', i);
1804
+ const line = txt.slice(start, end < 0 ? undefined : end);
1805
+ if (line.includes('"kind":"dispatch"') && !line.includes('"role":"verify"') && !line.includes('"role":"plan"') && !line.includes('"role":"helper"')) {
1806
+ const m = /"mode":"(fast|normal|strict)"/.exec(line);
1807
+ if (m) return m[1];
1808
+ }
1809
+ i = end < 0 ? -1 : txt.indexOf(needle, end);
1810
+ }
1811
+ return null;
1812
+ }
1813
+
1814
+ /** Approved plan holding `taskId` as a step (or as its root): { root, step|null } or null. */
1815
+ function planOfTask(ctx, taskId) {
1816
+ for (const p of loadPlans(ctx.paths.plans)) {
1817
+ const step = p.steps.find((s) => s && s.task_id === taskId);
1818
+ if (step) return { root: p.root_task_id, step };
1819
+ }
1820
+ for (const p of loadPlans(ctx.paths.plans)) if (p.root_task_id === taskId) return { root: taskId, step: null };
1821
+ return null;
1822
+ }
1823
+
1824
+ /**
1825
+ * Mode a task runs in: its first exec dispatch (locked) > its step `mode` in the approved plan > the root's current
1826
+ * mode event > normal. `events` (array) is for the CLI; without it the hook scans events.jsonl as text.
1827
+ * Returns { mode, source: 'dispatched'|'step'|'root'|'default', root }.
1828
+ */
1829
+ function taskModeInfo(ctx, taskId, events) {
1830
+ let txt = null;
1831
+ if (!events) { try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { txt = ''; } }
1832
+ const pl = taskId ? planOfTask(ctx, taskId) : null;
1833
+ const root = pl ? pl.root : (taskId || null);
1834
+ if (taskId) {
1835
+ const locked = events ? dispatchModeFromEvents(events, taskId) : dispatchModeFromText(txt, taskId);
1836
+ if (locked) return { mode: locked, source: 'dispatched', root };
1837
+ }
1838
+ if (pl && pl.step && isMode(pl.step.mode)) return { mode: pl.step.mode, source: 'step', root };
1839
+ if (root) {
1840
+ const ev = events ? rootModeFromEvents(events, root) : rootModeFromText(txt, root);
1841
+ if (ev) return { mode: ev.mode, source: 'root', root };
1842
+ }
1843
+ return { mode: DEFAULT_MODE, source: 'default', root };
1844
+ }
1845
+
1846
+ // core 0.7.0 (s2): signals for SUGGESTING a return to normal. The CLI never switches the mode itself; only the
1847
+ // user's `dieu-phoi mode` does. A suggestion is computed only while the root is in fast.
1848
+ const FAST_FAIL_WINDOW = 10; // last N fast exec steps (all roots, file order of their first exec dispatch)
1849
+ const FAST_FAIL_MIN_N = 5; // fewer fast steps than this -> no rate signal yet
1850
+ const FAST_FAIL_RATE = 0.15; // >= this share failed -> suggest normal
1851
+ const BATCH_VERIFY_RE = /^batch-verify(?:-|$)/;
1852
+ const QUOTA_VERDICT_RE = /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/;
1853
+
1854
+ /** A user reject that counts as a quality signal (legacy `rework` too); scope rejects (request changed) do not. */
1855
+ function isQualityReject(ev) {
1856
+ if (ev.outcome === 'rework' && !ev.by) return true;
1857
+ return ev.by === 'user' && ev.outcome === 'reject' && ev.cause !== 'scope';
1858
+ }
1859
+
1860
+ /** Agent-layer fail (fail / timeout, never a quota pause or aborted). */
1861
+ function isAgentFail(ev) {
1862
+ if (ev.by === 'user' || ev.by === 'auto') return false;
1863
+ if (ev.outcome === 'fail') return true;
1864
+ return ev.outcome === 'timeout' && !QUOTA_VERDICT_RE.test(String(ev.reason_code || ''));
1865
+ }
1866
+
1867
+ /**
1868
+ * Fail rate over the last FAST_FAIL_WINDOW fast exec steps (any root): a step is a task whose first exec dispatch
1869
+ * recorded mode=fast; it failed when it has an agent-layer fail or a quality reject. Discarded tasks are left out.
1870
+ * Returns { n, failed, rate } (rate null when n = 0).
1871
+ */
1872
+ function fastFailRate(events) {
1873
+ const gone = new Set();
1874
+ for (const ev of events) if (ev && ev.kind === 'discard' && typeof ev.task_id === 'string') gone.add(ev.task_id);
1875
+ const order = [];
1876
+ const seen = new Set();
1877
+ for (const ev of events) {
1878
+ if (!ev || ev.kind !== 'dispatch' || typeof ev.task_id !== 'string' || !ev.task_id) continue;
1879
+ if ((ev.role || 'exec') !== 'exec' || seen.has(ev.task_id)) continue;
1880
+ seen.add(ev.task_id);
1881
+ if (ev.mode === 'fast' && !gone.has(ev.task_id)) order.push(ev.task_id);
1882
+ }
1883
+ const win = new Set(order.slice(-FAST_FAIL_WINDOW));
1884
+ const failed = new Set();
1885
+ for (const ev of events) {
1886
+ if (ev && ev.kind === 'verdict' && win.has(ev.task_id) && (isAgentFail(ev) || isQualityReject(ev))) failed.add(ev.task_id);
1887
+ }
1888
+ return { n: win.size, failed: failed.size, rate: win.size ? failed.size / win.size : null };
1889
+ }
1890
+
1891
+ /**
1892
+ * Should the orchestrator SUGGEST (never apply) a return to normal for `root`? Only while `mode` (the root's current
1893
+ * mode) is fast. Signals, each with a short Vietnamese reason:
1894
+ * - batch-verify: an agent verdict fail with reason batch-verify on a task of the root, after the root's last mode event;
1895
+ * - reject: a quality reject (user) on a task of the root, after that mode event;
1896
+ * - fail-rate: >= 15% failed among the last 10 fast steps (all roots; needs >= 5 steps).
1897
+ * `taskIds` = the root and its plan's step task ids. Returns { mode_suggest: 'normal'|null, mode_suggest_reason,
1898
+ * mode_suggest_signals[] }.
1899
+ */
1900
+ function modeSuggestion(events, root, o) {
1901
+ const opts = o || {};
1902
+ const none = { mode_suggest: null, mode_suggest_reason: null, mode_suggest_signals: [] };
1903
+ if (opts.mode !== 'fast') return none;
1904
+ const ids = new Set(opts.taskIds || []);
1905
+ if (root) ids.add(root);
1906
+ const last = root ? rootModeFromEvents(events, root) : null;
1907
+ const since = last ? (Date.parse(last.ts) || 0) : 0;
1908
+ const signals = [];
1909
+ const reasons = [];
1910
+ let batch = null;
1911
+ let reject = null;
1912
+ for (const ev of events) {
1913
+ if (!ev || ev.kind !== 'verdict' || !ids.has(ev.task_id)) continue;
1914
+ const t = Date.parse(ev.ts) || 0;
1915
+ if (t < since) continue;
1916
+ if (!batch && ev.outcome === 'fail' && BATCH_VERIFY_RE.test(String(ev.reason_code || ''))) batch = ev.task_id;
1917
+ if (!reject && isQualityReject(ev)) reject = ev.task_id;
1918
+ }
1919
+ if (batch) { signals.push('batch-verify'); reasons.push(`verifier gộp báo fail (task ${batch})`); }
1920
+ if (reject) { signals.push('reject'); reasons.push(`người dùng reject (task ${reject})`); }
1921
+ const fr = fastFailRate(events);
1922
+ if (fr.n >= FAST_FAIL_MIN_N && fr.rate >= FAST_FAIL_RATE) {
1923
+ signals.push('fail-rate');
1924
+ reasons.push(`${fr.failed}/${fr.n} bước fast gần nhất fail (${Math.round(fr.rate * 100)}% ≥ ${Math.round(FAST_FAIL_RATE * 100)}%)`);
1925
+ }
1926
+ if (!signals.length) return none;
1927
+ return { mode_suggest: 'normal', mode_suggest_reason: reasons.join('; '), mode_suggest_signals: signals };
1928
+ }
1929
+
1930
+ /** Task ids of a root: the root itself plus the step task ids of its approved plan (if any). */
1931
+ function rootTaskIds(plans, root) {
1932
+ const out = new Set(root ? [root] : []);
1933
+ for (const p of plans || []) {
1934
+ if (p && p.root_task_id === root) for (const s of p.steps || []) if (s && s.task_id) out.add(s.task_id);
1935
+ }
1936
+ return out;
1653
1937
  }
1654
1938
 
1655
1939
  // ------------------------------------------------------------------ core 0.6.0: project identity
@@ -2181,6 +2465,135 @@ function relatedStatus(r) {
2181
2465
  return { name: r.name, rel: r.rel, n, counts: freshCounts(hit.sections), text: `${knowledgeSummary({ sections: hit.sections }, n)} (cache)` };
2182
2466
  }
2183
2467
 
2468
+ // ---- retro 2026-10-26 (C6): knowledge deltas that nobody merged
2469
+
2470
+ const DELTA_WARN_MS = 24 * 3600 * 1000; // younger than this: the session may still be about to accept/merge
2471
+ const DELTA_WINDOW_MS = 30 * 24 * 3600 * 1000; // older than this: stale history, not a warning any more
2472
+ const DELTA_SCAN_MAX = 300;
2473
+
2474
+ /** sha256 of a delta file as `knowledge merge` records it in meta.json `applied` (BOM and CRLF ignored). */
2475
+ function deltaHash(text) {
2476
+ return crypto.createHash('sha256').update(String(text).replace(/^/, '').replace(/\r\n?/g, '\n')).digest('hex');
2477
+ }
2478
+
2479
+ /** The `repo:` header of a delta text (before its first `## `), unquoted; null when absent. */
2480
+ function deltaRepoOf(text) {
2481
+ for (const line of String(text).replace(/^/, '').split(/\r?\n/)) {
2482
+ if (/^##\s/.test(line)) return null;
2483
+ const m = /^\s*repo\s*:\s*(.+?)\s*$/i.exec(line);
2484
+ if (m) return m[1].replace(/[.;,]+$/, '').replace(/^[`"'](.*)[`"']$/, '$1').trim() || null;
2485
+ }
2486
+ return null;
2487
+ }
2488
+
2489
+ /** A `repo:` value that is a short name (no path separator, no drive), e.g. `pff-tailor`. */
2490
+ function isBareRepoName(v) {
2491
+ return typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v.trim());
2492
+ }
2493
+
2494
+ /** Every knowledge store under projectsDir(): { dir_name, dir, name, key, roots, applied:Set<sha256> } — file reads only. */
2495
+ function storeCatalog() {
2496
+ const root = projectsDir();
2497
+ let names;
2498
+ try { names = fs.readdirSync(root); } catch (_) { return []; }
2499
+ const out = [];
2500
+ for (const n of names) {
2501
+ const dir = path.join(root, n);
2502
+ if (!isStoreDirName(n) || !isDir(dir)) continue;
2503
+ const meta = readJsonFile(path.join(dir, 'meta.json'), null);
2504
+ const idx = readJsonFile(path.join(dir, 'index.json'), null);
2505
+ if (!meta && !idx) continue;
2506
+ const m = meta && typeof meta === 'object' ? meta : {};
2507
+ const i = idx && typeof idx === 'object' ? idx : {};
2508
+ out.push({
2509
+ dir_name: n,
2510
+ dir: toSlash(dir),
2511
+ name: String(i.name || m.name || n.replace(/-[0-9a-f]{8}$/, '')),
2512
+ key: i.key || (Array.isArray(m.keys) ? m.keys[0] : null) || null,
2513
+ roots: Array.isArray(m.roots) ? m.roots.filter((r) => typeof r === 'string' && r) : [],
2514
+ applied: new Set((Array.isArray(m.applied) ? m.applied : []).map((a) => a && a.sha256).filter(Boolean)),
2515
+ });
2516
+ }
2517
+ return out;
2518
+ }
2519
+
2520
+ /**
2521
+ * Known store a bare repo name refers to: its `name`, its directory name, or the basename of a recorded root equals
2522
+ * `name` (case-insensitive). { matches, store } — store only when exactly one store matches.
2523
+ */
2524
+ function storeByName(name, stores) {
2525
+ const want = String(name).trim().toLowerCase();
2526
+ const matches = (stores || storeCatalog()).filter((s) => s.name.toLowerCase() === want || s.dir_name.toLowerCase() === want
2527
+ || s.roots.some((r) => path.basename(r).toLowerCase() === want));
2528
+ return { matches, store: matches.length === 1 ? matches[0] : null };
2529
+ }
2530
+
2531
+ /**
2532
+ * Delta files in run dirs that are 24 h .. 30 d old and that no store has applied (hash in meta.json `applied`).
2533
+ * { count, runs: [{ run, n, oldest_ms, repos: { name: n } }], truncated } — never throws; count 0 when all is merged.
2534
+ */
2535
+ function scanUnmergedDeltas(opts) {
2536
+ const o = opts || {};
2537
+ const now = o.now || Date.now();
2538
+ const warnMs = o.warnMs === undefined ? DELTA_WARN_MS : o.warnMs;
2539
+ const windowMs = o.windowMs === undefined ? DELTA_WINDOW_MS : o.windowMs;
2540
+ const res = { count: 0, runs: [], truncated: false };
2541
+ try {
2542
+ const runs = runsDir();
2543
+ const applied = new Set();
2544
+ for (const s of storeCatalog()) for (const h of s.applied) applied.add(h);
2545
+ let seen = 0;
2546
+ for (const run of fs.readdirSync(runs)) {
2547
+ const dir = path.join(runs, run);
2548
+ let st;
2549
+ try { st = fs.statSync(dir); } catch (_) { continue; }
2550
+ if (!st.isDirectory() || now - st.mtimeMs > windowMs) continue;
2551
+ let names;
2552
+ try { names = fs.readdirSync(dir); } catch (_) { continue; }
2553
+ const row = { run, n: 0, oldest_ms: 0, repos: {} };
2554
+ for (const f of names) {
2555
+ if (!/^knowledge-delta.*\.md$/i.test(f)) continue;
2556
+ const file = path.join(dir, f);
2557
+ let fst;
2558
+ try { fst = fs.statSync(file); } catch (_) { continue; }
2559
+ const age = now - fst.mtimeMs;
2560
+ if (age < warnMs || age > windowMs) continue;
2561
+ if (seen++ >= DELTA_SCAN_MAX) { res.truncated = true; break; }
2562
+ const text = readTextFile(file);
2563
+ if (text == null || applied.has(deltaHash(text))) continue;
2564
+ const repo = deltaRepoOf(text);
2565
+ const label = repo ? (isBareRepoName(repo) ? repo : path.basename(repo.replace(/[\\/]+$/, ''))) : '?';
2566
+ row.n++;
2567
+ row.oldest_ms = Math.max(row.oldest_ms, age);
2568
+ row.repos[label] = (row.repos[label] || 0) + 1;
2569
+ }
2570
+ if (row.n) { res.runs.push(row); res.count += row.n; }
2571
+ if (res.truncated) break;
2572
+ }
2573
+ res.runs.sort((a, b) => b.n - a.n || b.oldest_ms - a.oldest_ms);
2574
+ } catch (e) {
2575
+ res.error = e.message;
2576
+ }
2577
+ return res;
2578
+ }
2579
+
2580
+ function ageText(ms) {
2581
+ return ms >= 48 * 3600 * 1000 ? `${Math.floor(ms / 86400000)} ngày` : `${Math.floor(ms / 3600000)} giờ`;
2582
+ }
2583
+
2584
+ /** One warning line (as an array, empty when nothing is pending) for the start block and `state`. */
2585
+ function unmergedDeltaLines(scan) {
2586
+ if (!scan || !scan.count) return [];
2587
+ const oldest = Math.max(...scan.runs.map((r) => r.oldest_ms));
2588
+ const repos = {};
2589
+ for (const r of scan.runs) for (const [k, v] of Object.entries(r.repos)) repos[k] = (repos[k] || 0) + v;
2590
+ const repoText = Object.entries(repos).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ×${v}`).join(', ');
2591
+ const runText = scan.runs.slice(0, 3).map((r) => `${r.run} ×${r.n}`).join(', ') + (scan.runs.length > 3 ? ` (+${scan.runs.length - 3} run)` : '');
2592
+ const more = scan.truncated ? ' (chỉ quét một phần)' : '';
2593
+ return [`knowledge_delta: ${scan.count} file delta chưa gộp quá 24h${more} — run ${runText}; kho ${repoText}; cũ nhất ${ageText(oldest)}. `
2594
+ + `Gộp: \`dieu-phoi knowledge merge --run ${scan.runs[0].run} --dry-run\` rồi bỏ --dry-run (hoặc verdict accept); bỏ qua nếu cố ý không gộp.`];
2595
+ }
2596
+
2184
2597
  /**
2185
2598
  * Knowledge lines for the start block of the repo at `cwd`; null outside a repo or when the repo has no store.
2186
2599
  * Refreshes this repo only (at most one git diff-tree, on a cache miss, bounded by HOOK_DIFF_TIMEOUT_MS; never git
@@ -2278,6 +2691,9 @@ function buildDispatch(p, ctx) {
2278
2691
  ...(tag.task_id && !isValidTaskId(tag.task_id) ? { task_id_invalid: true } : {}),
2279
2692
  ...(tag.task_id_inferred ? { task_id_inferred: true } : {}),
2280
2693
  ...(tag.tag_issue ? { tag_issue: tag.tag_issue } : {}),
2694
+ ...(tag.type_alias_from ? { type_alias_from: tag.type_alias_from } : {}),
2695
+ ...(tag.chain_ok ? { chain_ok: true } : {}),
2696
+ ...(tag.batch ? { batch: true } : {}),
2281
2697
  task_type: tag.task_type,
2282
2698
  score: tag.score,
2283
2699
  urgency: tag.urgency,
@@ -2296,12 +2712,24 @@ function buildDispatch(p, ctx) {
2296
2712
  orch_cum_usd: orch.cum_usd,
2297
2713
  }, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
2298
2714
  projectFields(p),
2715
+ modeFields(ctx, tag),
2299
2716
  tierFields(ctx, tag, normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null, explicitEffort || parentEffort || null),
2300
2717
  // core 0.5.0: an Agent call made inside a subagent (gen-2) carries the caller's agent_id
2301
2718
  typeof p.agent_id === 'string' && p.agent_id ? { parent_agent_id: p.agent_id } : {},
2302
2719
  commonFields(ctx));
2303
2720
  }
2304
2721
 
2722
+ /** core 0.7.1 (C3): the tier came from `choose` for another task type than the tag carries. */
2723
+ function chooseTypeMismatchMessage(ev) {
2724
+ return `dieu-phoi: task ${ev.task_id} được \`choose\` tính cho loại "${ev.choose_task_type}" nhưng thẻ ghi "${ev.task_type}" — tier ${ev.choose_tier} có thể không hợp loại này. Lần sau gọi \`choose --type ${ev.task_type}\` (hoặc sửa thẻ cho đúng loại choose trả).`;
2725
+ }
2726
+
2727
+ /** core 0.7.0: mode of the task at the moment of this dispatch (kept by later tries), only when the tag carries a task_id. */
2728
+ function modeFields(ctx, tag) {
2729
+ if (!tag || !tag.task_id || !isValidTaskId(tag.task_id)) return {};
2730
+ try { return { mode: taskModeInfo(ctx, tag.task_id).mode }; } catch (_) { return {}; }
2731
+ }
2732
+
2305
2733
  /** suggested_tier / choose_tier / suggested_source / dispatched_tier / tier_mismatch, only when choose left a record. */
2306
2734
  function tierFields(ctx, tag, model, effort) {
2307
2735
  const s = tag.task_id ? readSuggestion(ctx, tag.task_id, tag.role) : null;
@@ -2314,6 +2742,15 @@ function tierFields(ctx, tag, model, effort) {
2314
2742
  dispatched_tier: dispatched,
2315
2743
  };
2316
2744
  if (dispatched && !dispatched.includes('?')) out.tier_mismatch = dispatched !== out.choose_tier;
2745
+ // core 0.7.1 (C3): choose's own inputs/reason, and whether choose was asked about another task type
2746
+ if (typeof s.choose_task_type === 'string') {
2747
+ out.choose_task_type = s.choose_task_type;
2748
+ if (tag.task_type && tag.task_type !== s.choose_task_type) out.choose_type_mismatch = true;
2749
+ }
2750
+ if (typeof s.choose_score === 'number') out.choose_score = s.choose_score;
2751
+ if (typeof s.explored === 'boolean') out.choose_explored = s.explored;
2752
+ if (typeof s.reason_code === 'string') out.choose_reason = s.reason_code;
2753
+ if (typeof s.thompson_score === 'number') out.choose_thompson_score = s.thompson_score;
2317
2754
  return out;
2318
2755
  }
2319
2756
 
@@ -2332,6 +2769,7 @@ function handlePostToolUse(p, ctx) {
2332
2769
  const tag = resolveTag(ti.prompt, known) || { tag_issue: ev.tag_issue, task_id: null, task_type: ev.task_type };
2333
2770
  notes.push(tagIssueMessage(tag, known));
2334
2771
  }
2772
+ if (ev.choose_type_mismatch) notes.push(chooseTypeMismatchMessage(ev));
2335
2773
  if (ev.task_id && ev.role === 'exec') {
2336
2774
  const n = countTaskHandoffs(ctx, ev.task_id);
2337
2775
  if (n) ev.task_handoffs = n;
@@ -2548,6 +2986,16 @@ function handleSubagentStop(p, ctx) {
2548
2986
  }
2549
2987
  // exec/verify/plan only: true = it returned without writing its dp-out file (the hook saved the .inline.md)
2550
2988
  if (tag && RESULT_ROLES.includes(tag.role)) ev.inline_result = Boolean(inline);
2989
+ // core 0.7.0 (s2): seconds spent running tests in this segment (transcript; else the dp-out `TEST_S:` line)
2990
+ const tt = exists && sum ? testTimeOf(lines.slice(sum.segment_start)) : null;
2991
+ ev.test_s = tt ? tt.test_s : null;
2992
+ ev.test_runs = tt ? tt.test_runs : null;
2993
+ ev.full_suite_runs = tt ? tt.full_suite_runs : null;
2994
+ ev.test_s_source = ev.test_s !== null ? 'transcript' : null;
2995
+ if (ev.test_s === null && exists && tag) {
2996
+ const v = testSFromResultFile(lines, tag);
2997
+ if (v !== null) { ev.test_s = v; ev.test_s_source = 'dp-out'; }
2998
+ }
2551
2999
  // Record the segment before appending so a racing duplicate fire sees it.
2552
3000
  if (sum && sum.last_uuid) {
2553
3001
  saveSegments(ctx, agentId, {
@@ -2630,6 +3078,134 @@ function completionStatus(text, abortWhy) {
2630
3078
  return 'none';
2631
3079
  }
2632
3080
 
3081
+ // ------------------------------------------------------------------ core 0.7.0 (s2): time spent running tests
3082
+ //
3083
+ // Measured at SubagentStop from the subagent transcript: every Bash/PowerShell tool_use whose command runs tests is
3084
+ // paired with its tool_result line; test_s = sum of (result timestamp - tool_use timestamp). This is the same span a
3085
+ // PreToolUse/PostToolUse(Bash) pair would give, without registering hooks on every Bash call of every session (the
3086
+ // dp-* agents only hook PostToolUse, which carries no start time). Backgrounded commands return at once, so they are
3087
+ // counted (test_runs) but not timed. Nothing timed -> fall back to the `TEST_S:` line of the agent's dp-out file.
3088
+
3089
+ const TEST_TOOL_NAMES = new Set(['Bash', 'PowerShell']);
3090
+ const PKG_RUNNERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
3091
+ const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])tests?[\\/][^\s]*\.[cm]?[jt]sx?$/;
3092
+
3093
+ /** Positional args (not flags) of a token list. */
3094
+ function positionals(toks) {
3095
+ return toks.filter((t) => t && !t.startsWith('-'));
3096
+ }
3097
+
3098
+ /** One command segment (no && ; |) -> null (not a test run) or { full } (full = whole suite, no file/filter args). */
3099
+ function testSegmentInfo(seg) {
3100
+ let s = ` ${seg} `.replace(/\s\d?>>?&?\s*[^\s]+/g, ' ').trim();
3101
+ for (let k = 0; k < 6; k++) {
3102
+ const n = s.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=[^\s]*\s+|time\s+|env\s+|rtk\s+(?:bypass\s+)?|timeout\s+[0-9]+[a-z]*\s+|npx\s+(?:--yes\s+|-y\s+)?|&\s*)/, '');
3103
+ if (n === s) break;
3104
+ s = n;
3105
+ }
3106
+ const toks = s.split(/\s+/).filter(Boolean).map((t) => t.replace(/^["']|["']$/g, ''));
3107
+ if (!toks.length) return null;
3108
+ const head = toks[0].replace(/\.(?:cmd|exe)$/i, '').split(/[\\/]/).pop();
3109
+ if (PKG_RUNNERS.has(head)) {
3110
+ let i = 1;
3111
+ if (toks[i] === 'run' || toks[i] === 'run-script') i++;
3112
+ const script = toks[i] || '';
3113
+ if (script !== 'test' && script !== 't' && !/^test:/.test(script)) return null;
3114
+ // `npm test` alone = the whole suite; a `test:<x>` script or extra args (`npm test -- file`) = a subset
3115
+ return { full: (script === 'test' || script === 't') && positionals(toks.slice(i + 1)).length === 0 };
3116
+ }
3117
+ if (head === 'node') {
3118
+ if (toks.includes('--test')) return { full: !positionals(toks.slice(1)).some((t) => /\.[cm]?[jt]sx?$/.test(t)) };
3119
+ const file = positionals(toks.slice(1))[0] || '';
3120
+ return TEST_FILE_RE.test(file) ? { full: false } : null;
3121
+ }
3122
+ if (head === 'pytest' || head === 'py.test') return { full: positionals(toks.slice(1)).length === 0 };
3123
+ if (/^python[0-9.]*$|^py$/.test(head) && toks[1] === '-m' && toks[2] === 'pytest') return { full: positionals(toks.slice(3)).length === 0 };
3124
+ if (head === 'jest') return { full: positionals(toks.slice(1)).length === 0 };
3125
+ if (head === 'vitest') {
3126
+ const rest = toks.slice(1);
3127
+ if (rest[0] === 'run') rest.shift();
3128
+ return { full: positionals(rest).length === 0 };
3129
+ }
3130
+ if (head === 'go' && toks[1] === 'test') return { full: toks.slice(2).includes('./...') };
3131
+ if ((head === 'cargo' || head === 'dotnet') && toks[1] === 'test') return { full: positionals(toks.slice(2)).length === 0 };
3132
+ return null;
3133
+ }
3134
+
3135
+ /** A shell command -> null (runs no tests) or { full } (true when any test segment runs a whole suite). */
3136
+ function testCommandInfo(cmd) {
3137
+ if (typeof cmd !== 'string' || !cmd.trim()) return null;
3138
+ let hit = null;
3139
+ for (const seg of cmd.split(/&&|\|\||;|\||\r?\n/)) {
3140
+ const t = seg.trim();
3141
+ if (!t) continue;
3142
+ const info = testSegmentInfo(t);
3143
+ if (info) hit = { full: Boolean((hit && hit.full) || info.full) };
3144
+ }
3145
+ return hit;
3146
+ }
3147
+
3148
+ /**
3149
+ * Test runs in transcript lines (one segment). Returns { test_s, test_runs, full_suite_runs }: test_s = seconds summed
3150
+ * over the timed (foreground, with a result) test commands, null when none was timed; counts include backgrounded runs.
3151
+ */
3152
+ function testTimeOf(lines) {
3153
+ const open = new Map();
3154
+ let runs = 0;
3155
+ let full = 0;
3156
+ let timed = 0;
3157
+ let ms = 0;
3158
+ for (const l of lines || []) {
3159
+ if (!l || typeof l !== 'object' || !l.message || !Array.isArray(l.message.content)) continue;
3160
+ const t = typeof l.timestamp === 'string' ? Date.parse(l.timestamp) : NaN;
3161
+ if (l.type === 'assistant') {
3162
+ for (const b of l.message.content) {
3163
+ if (!b || b.type !== 'tool_use' || !TEST_TOOL_NAMES.has(b.name) || !b.input) continue;
3164
+ const info = testCommandInfo(b.input.command);
3165
+ if (!info) continue;
3166
+ runs++;
3167
+ if (info.full) full++;
3168
+ if (b.id && b.input.run_in_background !== true && !isNaN(t)) open.set(b.id, t);
3169
+ }
3170
+ } else if (l.type === 'user') {
3171
+ for (const b of l.message.content) {
3172
+ if (!b || b.type !== 'tool_result' || !open.has(b.tool_use_id)) continue;
3173
+ const t0 = open.get(b.tool_use_id);
3174
+ open.delete(b.tool_use_id);
3175
+ if (!isNaN(t) && t >= t0) { ms += t - t0; timed++; }
3176
+ }
3177
+ }
3178
+ }
3179
+ return { test_s: timed ? Math.round(ms / 100) / 10 : null, test_runs: runs, full_suite_runs: full };
3180
+ }
3181
+
3182
+ /** Seconds from a `TEST_S: <n>[s|m|phút]` line of a result file's text (first 200 lines), or null. */
3183
+ function testSFromText(text) {
3184
+ const head = String(text || '').split(/\r?\n/).slice(0, 200);
3185
+ for (const raw of head) {
3186
+ const m = /^[\s>*_`#-]*TEST_S\s*[*_`]*\s*[:=]\s*[*_`~≈]*\s*([0-9]+(?:[.,][0-9]+)?)\s*(giây|phút|ph|min|m|s)?\b/i.exec(raw);
3187
+ if (!m) continue;
3188
+ const n = Number(m[1].replace(',', '.'));
3189
+ if (!isFinite(n) || n < 0) return null;
3190
+ const unit = (m[2] || 's').toLowerCase();
3191
+ return Math.round((unit === 's' || unit === 'giây' ? n : n * 60) * 10) / 10;
3192
+ }
3193
+ return null;
3194
+ }
3195
+
3196
+ /** test_s from the agent's dp-out file(s) for (task, role, try) in the run dir its brief names, or null. Never throws. */
3197
+ function testSFromResultFile(lines, tag) {
3198
+ try {
3199
+ if (!tag || !RESULT_ROLES.includes(tag.role) || !tag.task_id) return null;
3200
+ const dir = resultRunDir(firstPromptText(lines), tag.task_id);
3201
+ for (const f of findResultFiles(dir, tag.task_id, tag.role, tag.try)) {
3202
+ const v = testSFromText(readTextFile(f));
3203
+ if (v !== null) return v;
3204
+ }
3205
+ } catch (_) { /* best effort */ }
3206
+ return null;
3207
+ }
3208
+
2633
3209
  // ------------------------------------------------------------------ core 0.5.0: context watchdog + gen-2 gate
2634
3210
  //
2635
3211
  // Both run only from the hooks of the dp-* agent files (~/.claude/agents, installed by `dieu-phoi setup`), so a session
@@ -2902,6 +3478,36 @@ function gateReserve(dir, child) {
2902
3478
  return 0;
2903
3479
  }
2904
3480
 
3481
+ /**
3482
+ * Core 0.7.1 (C1): global PreToolUse(Agent) in the orchestrating session. An exec dispatch of a task that already
3483
+ * handed off HANDOFF_CHAIN_DENY times is denied (the 4th, 5th… segment of one chain cost $15-36 per task in session
3484
+ * 36cee993) unless the tag carries `chain_ok`. Inside a subagent (agent_id) this does nothing: --gate handles gen-2.
3485
+ */
3486
+ function handleChainGate(p, ctx) {
3487
+ if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
3488
+ if (watchAgentId(p)) return null;
3489
+ const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
3490
+ const tag = resolveTag(ti.prompt, knownTaskTypes(ctx));
3491
+ if (!tag || tag.role !== 'exec' || !tag.task_id || !isValidTaskId(tag.task_id) || tag.chain_ok) return null;
3492
+ const n = countTaskHandoffs(ctx, tag.task_id);
3493
+ if (n < HANDOFF_CHAIN_DENY) return null;
3494
+ const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'exec' },
3495
+ { gate_action: 'deny', reason_code: 'handoff-chain', children: n });
3496
+ return {
3497
+ denied: true,
3498
+ event: ev,
3499
+ stdout: JSON.stringify({
3500
+ hookSpecificOutput: {
3501
+ hookEventName: 'PreToolUse',
3502
+ permissionDecision: 'deny',
3503
+ permissionDecisionReason: `dieu-phoi: task ${tag.task_id} đã handoff ${n} lần (trần ${HANDOFF_CHAIN_DENY}) — không giao thêm exec tiếp nối. `
3504
+ + `Giao planner (dp-scout) đọc handoff-${tag.task_id}.md rồi chia phần còn lại thành bước mới (task_id mới từ \`dieu-phoi new-id\`). `
3505
+ + 'Chỉ khi người dùng đồng ý nối tiếp: thêm `|chain_ok` vào thẻ.',
3506
+ },
3507
+ }),
3508
+ };
3509
+ }
3510
+
2905
3511
  /** Mark the reservation of `child` as a real spawn. Returns true when its slot was found. */
2906
3512
  function gateConfirm(ctx, key, child) {
2907
3513
  if (!key || !child) return false;
@@ -3058,6 +3664,7 @@ function runHook(opts) {
3058
3664
  if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
3059
3665
  if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
3060
3666
  if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
3667
+ if (name === 'PreToolUse') return handleChainGate(p, ctx); // { stdout } (deny) or null
3061
3668
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
3062
3669
  if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
3063
3670
  if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
@@ -3175,7 +3782,7 @@ async function runSync(ctx, opts) {
3175
3782
  for (const l of batch) {
3176
3783
  try {
3177
3784
  const r = JSON.parse(l.text);
3178
- if (r && typeof r === 'object' && !Array.isArray(r)) {
3785
+ if (r && typeof r === 'object' && !Array.isArray(r) && !LOCAL_ONLY_KINDS.has(r.kind)) {
3179
3786
  delete r.synced;
3180
3787
  records.push(r);
3181
3788
  }
@@ -3212,16 +3819,19 @@ async function runSync(ctx, opts) {
3212
3819
 
3213
3820
  module.exports = {
3214
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,
3215
- CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
3822
+ modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, MODE_ASK_LINE, isMode, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
3823
+ modeSuggestion, modeSuggestLine, fastFailRate, rootTaskIds, FAST_FAIL_WINDOW, FAST_FAIL_MIN_N, FAST_FAIL_RATE,
3824
+ testCommandInfo, testTimeOf, testSFromText,
3825
+ CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED, TYPE_ALIASES,
3216
3826
  sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
3217
3827
  logError, computeUserHash, userHash, commonFields, parseTag, resolveTag, knownTaskTypes, tagIssueMessage, DEFAULT_EXTRA_TYPES,
3218
- recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
3828
+ recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, HANDOFF_CHAIN_DENY, handleChainGate, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
3219
3829
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
3220
3830
  firstPromptText, tagFromTranscript, summarizeTranscript,
3221
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,
3222
3832
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
3223
3833
  HANDOFF_MARKER, HANDOFF_THRESHOLDS, watchThresholds, GATE_MAX_CHILDREN, HELPER_AGENT_TYPE, watchAgentId, subagentTranscriptPath, readHeadTag, lastContextTokens, watchState, handoffMessage, handleWatch, handleGate, handleGateRelease, gateDir, gateSlots, gateReserve, gateConfirm, GATE_PENDING_MS,
3224
- globMatch, pathspecOf, diffShas, dirtyFiles, computeFreshness, addDirty, indexStamp, freshnessCacheFile, readFreshCache, cachedFor, freshCounts, knowledgeSummary, shortFileList, readStoreIndex, relatedStatus, startKnowledge, FRESH_CACHE_MAX, DIFF_TIMEOUT_MS, HOOK_DIFF_TIMEOUT_MS, DIRTY_TIMEOUT_MS,
3834
+ globMatch, pathspecOf, diffShas, dirtyFiles, computeFreshness, addDirty, indexStamp, freshnessCacheFile, readFreshCache, cachedFor, freshCounts, knowledgeSummary, shortFileList, readStoreIndex, relatedStatus, startKnowledge, deltaHash, deltaRepoOf, isBareRepoName, storeCatalog, storeByName, scanUnmergedDeltas, unmergedDeltaLines, DELTA_WARN_MS, FRESH_CACHE_MAX, DIFF_TIMEOUT_MS, HOOK_DIFF_TIMEOUT_MS, DIRTY_TIMEOUT_MS,
3225
3835
  projectsDir, findGit, originUrlFromConfig, normalizeRemoteUrl, resolveProject, readHead, repoFromBrief, projectFields, nativePath, isStoreDirName, aliasesFile, loadAliases, keyDirName,
3226
3836
  extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
3227
3837
  };