@kienntpixon/dieu-phoi 0.6.0 → 0.7.0

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.0';
29
+ const CORE_VERSION = '0.7.0';
30
30
  const SCHEMA = 1;
31
31
  const TASK_TYPES = [
32
32
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -1118,6 +1118,9 @@ function buildSessionState(events, sessionId, opts) {
1118
1118
  for (const id of [...planOfRoot.keys(), ...stepOf.keys()]) {
1119
1119
  if (!seenTask.has(id) && !discarded.has(id)) { seenTask.add(id); taskIds.push(id); }
1120
1120
  }
1121
+ const modeEvents = new Map();
1122
+ 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);
1123
+ const modeOfRoot = (root) => (modeEvents.has(root) ? modeEvents.get(root).mode : DEFAULT_MODE);
1121
1124
  const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
1122
1125
  events.forEach((ev, i) => {
1123
1126
  if (!ev || typeof ev !== 'object') return;
@@ -1168,6 +1171,7 @@ function buildSessionState(events, sessionId, opts) {
1168
1171
  const pApproved = pov && evMs(pov) > (Date.parse(ps.plan.approved_at) || 0) ? pov.chosen_tier || null : null;
1169
1172
  tasks.push(Object.assign({
1170
1173
  task_id: id, task_type: ps.step.type || null, gate: 2, pending: true, verify_only: false, round: 0,
1174
+ mode: isMode(ps.step.mode) ? ps.step.mode : modeOfRoot(ps.plan.root_task_id),
1171
1175
  }, planInfo, {
1172
1176
  approved_tier: pApproved || ps.step.tier || null, last_tier: null, attempts: [], user_verdict: null, last_steer: null,
1173
1177
  run_dir: planDir, last_ts: null,
@@ -1225,6 +1229,7 @@ function buildSessionState(events, sessionId, opts) {
1225
1229
  pending: false,
1226
1230
  verify_only: attempts.length > 0 && attempts.every((a) => a.role === 'verify'),
1227
1231
  round,
1232
+ mode: dispatchModeFromEvents(t.dispatches, id) || (ps && isMode(ps.step.mode) ? ps.step.mode : modeOfRoot(plan ? plan.root_task_id : id)),
1228
1233
  }, planInfo, {
1229
1234
  approved_tier: approved,
1230
1235
  last_tier: lastExecAny ? tierOf(lastExecAny.model, lastExecAny.effort) : null,
@@ -1235,10 +1240,26 @@ function buildSessionState(events, sessionId, opts) {
1235
1240
  last_ts: lastMs ? new Date(lastMs).toISOString() : null,
1236
1241
  }));
1237
1242
  }
1243
+ // modes of the roots this session works on: plan roots, tasks' own roots, and roots whose mode event was written here
1244
+ const sidHash = sha8(sessionId);
1245
+ const modeRoots = new Set(plans.map((p) => p.root_task_id));
1246
+ for (const id of taskIds) if (modeEvents.has(id)) modeRoots.add(id);
1247
+ for (const [root, ev] of modeEvents) if (ev.session_id === sessionId || ev.session === sidHash) modeRoots.add(root);
1248
+ const modes = Array.from(modeRoots).map((root) => {
1249
+ const ev = modeEvents.get(root);
1250
+ const mode = ev ? ev.mode : DEFAULT_MODE;
1251
+ // core 0.7.0 (s2): suggestion only — the mode itself never changes here
1252
+ const sug = modeSuggestion(events, root, { mode, taskIds: rootTaskIds(allPlans, root) });
1253
+ return {
1254
+ root_task_id: root, mode, since: ev ? ev.ts : null, source: ev ? ev.source || null : 'default', reason_code: ev ? ev.reason_code || null : null,
1255
+ mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason,
1256
+ };
1257
+ });
1238
1258
  return {
1239
1259
  session_id: sessionId,
1240
1260
  generated_at: nowIso(),
1241
1261
  runs_dir: `${runs}/`,
1262
+ modes,
1242
1263
  plans: plans.map((p) => ({
1243
1264
  root_task_id: p.root_task_id,
1244
1265
  approval: Number.isInteger(p.approval) ? p.approval : (Number.isInteger(p.round) ? p.round : null),
@@ -1268,7 +1289,8 @@ function taskLines(t) {
1268
1289
  + `${t.round ? ` · vòng ${t.round}` : ''}${t.approved_tier ? ` · duyệt ${t.approved_tier}` : ''}`
1269
1290
  + `${t.last_tier && t.last_tier !== t.approved_tier ? ` · tier cuối ${t.last_tier}` : ''}`
1270
1291
  + `${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}` : ''}`;
1292
+ + `${t.last_steer ? ` · steer: ${t.last_steer}` : ''}`
1293
+ + `${t.mode && t.mode !== DEFAULT_MODE ? ` · mode ${t.mode}` : ''}`;
1272
1294
  const att = t.attempts.map((a) => `${a.agent_id} ${a.role}${a.try ? ` try${a.try}` : ''} ${tierOf(a.model, a.effort) || '?'} ${a.status}`);
1273
1295
  const out = [head];
1274
1296
  if (att.length) out.push(`${indent} ${att.join(' | ')}`);
@@ -1310,11 +1332,26 @@ function compactSystemMessage(state) {
1310
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)`;
1311
1333
  }
1312
1334
 
1335
+ /** "\nMode: <root>=<mode> …" for the roots of a session (last 6); '' when the state carries none. */
1336
+ function modesLine(state) {
1337
+ const ms = Array.isArray(state.modes) ? state.modes : [];
1338
+ if (!ms.length) return '';
1339
+ 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('');
1340
+ 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}`;
1341
+ }
1342
+
1343
+ /** One line telling the orchestrator to ASK the user about returning to normal (never switch by itself). */
1344
+ function modeSuggestLine(root, mode, reason) {
1345
+ 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}); `
1346
+ + `chỉ đổi khi người dùng đồng ý: \`dieu-phoi mode --root ${root || '<root>'} normal\`. KHÔNG tự đổi mode.`;
1347
+ }
1348
+
1313
1349
  function formatSessionState(state, cap) {
1314
1350
  const limit = cap || STATE_TEXT_CAP;
1315
1351
  const { open, pending, running } = stateCounts(state);
1316
1352
  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).`;
1353
+ + `Thư mục chạy: ${state.runs_dir} (chỉ trỏ tới, không tự đọc).`
1354
+ + modesLine(state);
1318
1355
  if (!state.tasks.length) return `${header}\nChưa có task nào được giao trong phiên này.`;
1319
1356
  const byAge = (a, b) => String(a.last_ts || '').localeCompare(String(b.last_ts || ''));
1320
1357
  const dropOrder = state.tasks.filter((t) => t.gate === 'done').sort(byAge)
@@ -1397,7 +1434,8 @@ function sinkStatusCached(ctx, nowMs) {
1397
1434
  const now = nowMs || Date.now();
1398
1435
  const cands = [];
1399
1436
  const chk = readJsonFile(ctx.paths.sinkCheck, null);
1400
- if (chk && chk.url === sinkUrlKey(cfg) && Date.parse(chk.ts)) {
1437
+ // a timed-out doctor check (`timeout: true`) is "slow/unknown", not a failure: ignored here, so a fresher sync result or `unknown` wins
1438
+ if (chk && !chk.timeout && chk.url === sinkUrlKey(cfg) && Date.parse(chk.ts)) {
1401
1439
  cands.push({ state: chk.ok ? 'ok' : 'fail', ts: chk.ts, source: 'doctor', detail: chk.detail || null });
1402
1440
  }
1403
1441
  const ls = readJsonFile(ctx.paths.lastSync, null);
@@ -1502,7 +1540,7 @@ function startInfo(ctx, cwd) {
1502
1540
  const run = startRun();
1503
1541
  const health = quickHealth(ctx, run.runs);
1504
1542
  const info = {
1505
- id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx),
1543
+ id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx), mode: null,
1506
1544
  orch_lessons: orchLessons(ctx),
1507
1545
  };
1508
1546
  const k = startKnowledgeOf(cwd);
@@ -1519,6 +1557,11 @@ function orchLessonLines(info) {
1519
1557
  return ls.length ? ['bài học cho điều phối viên (profile/lessons.md, [orch]):'].concat(ls.map((l) => ` - ${l}`)) : [];
1520
1558
  }
1521
1559
 
1560
+ /** Gate-1 mode line: the chosen mode, or the reminder to ASK the user (default normal). */
1561
+ function modeLine(info) {
1562
+ return info && isMode(info.mode) ? `mode: ${info.mode} (đã ghi cho root ${info.id}; đổi: dieu-phoi mode --root ${info.id} <mode>)` : MODE_ASK_LINE;
1563
+ }
1564
+
1522
1565
  function formatStartBlock(info) {
1523
1566
  return [
1524
1567
  START_PREFIX,
@@ -1527,6 +1570,7 @@ function formatStartBlock(info) {
1527
1570
  `step_ids: ${info.step_ids.join(' ')}`,
1528
1571
  `health: ${healthLine(info.health)}`,
1529
1572
  `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
1573
+ modeLine(info),
1530
1574
  ...orchLessonLines(info),
1531
1575
  ...knowledgeLines(info),
1532
1576
  '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.',
@@ -1651,7 +1695,204 @@ function handoffChainMessage(taskId, n) {
1651
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\`).`;
1652
1696
  }
1653
1697
 
1654
- // ------------------------------------------------------------------ core 0.6.0 (unreleased): project identity
1698
+ // ------------------------------------------------------------------ core 0.7.0: mode per request (fast | normal | strict)
1699
+ //
1700
+ // A request (root_task_id) has a mode; the user picks it at gate 1 (`start --mode`, `plan-table` file, or
1701
+ // `dieu-phoi mode --root R <mode>` at any time). Every change is one local-only event kind=mode. A step not yet
1702
+ // dispatched follows the root's current mode (or its own `mode` override in the approved plan); a step already
1703
+ // dispatched keeps the mode its first exec dispatch recorded. Nothing here ever changes a mode by itself.
1704
+
1705
+ const MODES = ['fast', 'normal', 'strict'];
1706
+ const DEFAULT_MODE = 'normal';
1707
+ /** 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>`';
1711
+
1712
+ function isMode(m) {
1713
+ return typeof m === 'string' && MODES.includes(m);
1714
+ }
1715
+
1716
+ /** Last mode event of `root` in an events array (null when none). */
1717
+ function rootModeFromEvents(events, root) {
1718
+ let last = null;
1719
+ for (const ev of events) {
1720
+ if (ev && ev.kind === 'mode' && ev.root_task_id === root && isMode(ev.mode)) last = ev;
1721
+ }
1722
+ return last;
1723
+ }
1724
+
1725
+ /** Same, from the text of events.jsonl (hook hot path: substring scan, no full parse). */
1726
+ function rootModeFromText(txt, root) {
1727
+ const needle = '"kind":"mode"';
1728
+ const rootNeedle = `"root_task_id":${JSON.stringify(root)}`;
1729
+ let last = null;
1730
+ let i = txt.indexOf(needle);
1731
+ while (i >= 0) {
1732
+ const start = txt.lastIndexOf('\n', i) + 1;
1733
+ const end = txt.indexOf('\n', i);
1734
+ const line = txt.slice(start, end < 0 ? undefined : end);
1735
+ if (line.includes(rootNeedle)) {
1736
+ try {
1737
+ const ev = JSON.parse(line);
1738
+ if (ev && ev.kind === 'mode' && isMode(ev.mode)) last = ev;
1739
+ } catch (_) { /* corrupt line */ }
1740
+ }
1741
+ i = end < 0 ? -1 : txt.indexOf(needle, end);
1742
+ }
1743
+ return last;
1744
+ }
1745
+
1746
+ /** Mode the first exec dispatch of `taskId` recorded (a dispatched step keeps its mode), from an events array. */
1747
+ function dispatchModeFromEvents(events, taskId) {
1748
+ for (const ev of events) {
1749
+ if (ev && ev.kind === 'dispatch' && ev.task_id === taskId && (ev.role || 'exec') === 'exec' && isMode(ev.mode)) return ev.mode;
1750
+ }
1751
+ return null;
1752
+ }
1753
+
1754
+ function dispatchModeFromText(txt, taskId) {
1755
+ const needle = `"task_id":${JSON.stringify(taskId)}`;
1756
+ let i = txt.indexOf(needle);
1757
+ while (i >= 0) {
1758
+ const start = txt.lastIndexOf('\n', i) + 1;
1759
+ const end = txt.indexOf('\n', i);
1760
+ const line = txt.slice(start, end < 0 ? undefined : end);
1761
+ if (line.includes('"kind":"dispatch"') && !line.includes('"role":"verify"') && !line.includes('"role":"plan"') && !line.includes('"role":"helper"')) {
1762
+ const m = /"mode":"(fast|normal|strict)"/.exec(line);
1763
+ if (m) return m[1];
1764
+ }
1765
+ i = end < 0 ? -1 : txt.indexOf(needle, end);
1766
+ }
1767
+ return null;
1768
+ }
1769
+
1770
+ /** Approved plan holding `taskId` as a step (or as its root): { root, step|null } or null. */
1771
+ function planOfTask(ctx, taskId) {
1772
+ for (const p of loadPlans(ctx.paths.plans)) {
1773
+ const step = p.steps.find((s) => s && s.task_id === taskId);
1774
+ if (step) return { root: p.root_task_id, step };
1775
+ }
1776
+ for (const p of loadPlans(ctx.paths.plans)) if (p.root_task_id === taskId) return { root: taskId, step: null };
1777
+ return null;
1778
+ }
1779
+
1780
+ /**
1781
+ * Mode a task runs in: its first exec dispatch (locked) > its step `mode` in the approved plan > the root's current
1782
+ * mode event > normal. `events` (array) is for the CLI; without it the hook scans events.jsonl as text.
1783
+ * Returns { mode, source: 'dispatched'|'step'|'root'|'default', root }.
1784
+ */
1785
+ function taskModeInfo(ctx, taskId, events) {
1786
+ let txt = null;
1787
+ if (!events) { try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { txt = ''; } }
1788
+ const pl = taskId ? planOfTask(ctx, taskId) : null;
1789
+ const root = pl ? pl.root : (taskId || null);
1790
+ if (taskId) {
1791
+ const locked = events ? dispatchModeFromEvents(events, taskId) : dispatchModeFromText(txt, taskId);
1792
+ if (locked) return { mode: locked, source: 'dispatched', root };
1793
+ }
1794
+ if (pl && pl.step && isMode(pl.step.mode)) return { mode: pl.step.mode, source: 'step', root };
1795
+ if (root) {
1796
+ const ev = events ? rootModeFromEvents(events, root) : rootModeFromText(txt, root);
1797
+ if (ev) return { mode: ev.mode, source: 'root', root };
1798
+ }
1799
+ return { mode: DEFAULT_MODE, source: 'default', root };
1800
+ }
1801
+
1802
+ // core 0.7.0 (s2): signals for SUGGESTING a return to normal. The CLI never switches the mode itself; only the
1803
+ // user's `dieu-phoi mode` does. A suggestion is computed only while the root is in fast.
1804
+ const FAST_FAIL_WINDOW = 10; // last N fast exec steps (all roots, file order of their first exec dispatch)
1805
+ const FAST_FAIL_MIN_N = 5; // fewer fast steps than this -> no rate signal yet
1806
+ const FAST_FAIL_RATE = 0.15; // >= this share failed -> suggest normal
1807
+ const BATCH_VERIFY_RE = /^batch-verify(?:-|$)/;
1808
+ const QUOTA_VERDICT_RE = /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/;
1809
+
1810
+ /** A user reject that counts as a quality signal (legacy `rework` too); scope rejects (request changed) do not. */
1811
+ function isQualityReject(ev) {
1812
+ if (ev.outcome === 'rework' && !ev.by) return true;
1813
+ return ev.by === 'user' && ev.outcome === 'reject' && ev.cause !== 'scope';
1814
+ }
1815
+
1816
+ /** Agent-layer fail (fail / timeout, never a quota pause or aborted). */
1817
+ function isAgentFail(ev) {
1818
+ if (ev.by === 'user' || ev.by === 'auto') return false;
1819
+ if (ev.outcome === 'fail') return true;
1820
+ return ev.outcome === 'timeout' && !QUOTA_VERDICT_RE.test(String(ev.reason_code || ''));
1821
+ }
1822
+
1823
+ /**
1824
+ * Fail rate over the last FAST_FAIL_WINDOW fast exec steps (any root): a step is a task whose first exec dispatch
1825
+ * recorded mode=fast; it failed when it has an agent-layer fail or a quality reject. Discarded tasks are left out.
1826
+ * Returns { n, failed, rate } (rate null when n = 0).
1827
+ */
1828
+ function fastFailRate(events) {
1829
+ const gone = new Set();
1830
+ for (const ev of events) if (ev && ev.kind === 'discard' && typeof ev.task_id === 'string') gone.add(ev.task_id);
1831
+ const order = [];
1832
+ const seen = new Set();
1833
+ for (const ev of events) {
1834
+ if (!ev || ev.kind !== 'dispatch' || typeof ev.task_id !== 'string' || !ev.task_id) continue;
1835
+ if ((ev.role || 'exec') !== 'exec' || seen.has(ev.task_id)) continue;
1836
+ seen.add(ev.task_id);
1837
+ if (ev.mode === 'fast' && !gone.has(ev.task_id)) order.push(ev.task_id);
1838
+ }
1839
+ const win = new Set(order.slice(-FAST_FAIL_WINDOW));
1840
+ const failed = new Set();
1841
+ for (const ev of events) {
1842
+ if (ev && ev.kind === 'verdict' && win.has(ev.task_id) && (isAgentFail(ev) || isQualityReject(ev))) failed.add(ev.task_id);
1843
+ }
1844
+ return { n: win.size, failed: failed.size, rate: win.size ? failed.size / win.size : null };
1845
+ }
1846
+
1847
+ /**
1848
+ * Should the orchestrator SUGGEST (never apply) a return to normal for `root`? Only while `mode` (the root's current
1849
+ * mode) is fast. Signals, each with a short Vietnamese reason:
1850
+ * - batch-verify: an agent verdict fail with reason batch-verify on a task of the root, after the root's last mode event;
1851
+ * - reject: a quality reject (user) on a task of the root, after that mode event;
1852
+ * - fail-rate: >= 15% failed among the last 10 fast steps (all roots; needs >= 5 steps).
1853
+ * `taskIds` = the root and its plan's step task ids. Returns { mode_suggest: 'normal'|null, mode_suggest_reason,
1854
+ * mode_suggest_signals[] }.
1855
+ */
1856
+ function modeSuggestion(events, root, o) {
1857
+ const opts = o || {};
1858
+ const none = { mode_suggest: null, mode_suggest_reason: null, mode_suggest_signals: [] };
1859
+ if (opts.mode !== 'fast') return none;
1860
+ const ids = new Set(opts.taskIds || []);
1861
+ if (root) ids.add(root);
1862
+ const last = root ? rootModeFromEvents(events, root) : null;
1863
+ const since = last ? (Date.parse(last.ts) || 0) : 0;
1864
+ const signals = [];
1865
+ const reasons = [];
1866
+ let batch = null;
1867
+ let reject = null;
1868
+ for (const ev of events) {
1869
+ if (!ev || ev.kind !== 'verdict' || !ids.has(ev.task_id)) continue;
1870
+ const t = Date.parse(ev.ts) || 0;
1871
+ if (t < since) continue;
1872
+ if (!batch && ev.outcome === 'fail' && BATCH_VERIFY_RE.test(String(ev.reason_code || ''))) batch = ev.task_id;
1873
+ if (!reject && isQualityReject(ev)) reject = ev.task_id;
1874
+ }
1875
+ if (batch) { signals.push('batch-verify'); reasons.push(`verifier gộp báo fail (task ${batch})`); }
1876
+ if (reject) { signals.push('reject'); reasons.push(`người dùng reject (task ${reject})`); }
1877
+ const fr = fastFailRate(events);
1878
+ if (fr.n >= FAST_FAIL_MIN_N && fr.rate >= FAST_FAIL_RATE) {
1879
+ signals.push('fail-rate');
1880
+ 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)}%)`);
1881
+ }
1882
+ if (!signals.length) return none;
1883
+ return { mode_suggest: 'normal', mode_suggest_reason: reasons.join('; '), mode_suggest_signals: signals };
1884
+ }
1885
+
1886
+ /** Task ids of a root: the root itself plus the step task ids of its approved plan (if any). */
1887
+ function rootTaskIds(plans, root) {
1888
+ const out = new Set(root ? [root] : []);
1889
+ for (const p of plans || []) {
1890
+ if (p && p.root_task_id === root) for (const s of p.steps || []) if (s && s.task_id) out.add(s.task_id);
1891
+ }
1892
+ return out;
1893
+ }
1894
+
1895
+ // ------------------------------------------------------------------ core 0.6.0: project identity
1655
1896
  //
1656
1897
  // A project (= one git repo, however many worktrees/clones) gets a stable key so every session and worktree can
1657
1898
  // share one knowledge store (~/.dieu-phoi/projects/<name>-<sha8(key)>/). Everything here is plain file reads —
@@ -1912,7 +2153,7 @@ function readHead(start) {
1912
2153
  return sha ? { branch, sha, detached: false } : { branch, sha: null, detached: false, unborn: true };
1913
2154
  }
1914
2155
 
1915
- // ------------------------------------------------------------------ core 0.6.0 (unreleased), s3: knowledge freshness
2156
+ // ------------------------------------------------------------------ core 0.6.0, s3: knowledge freshness
1916
2157
  //
1917
2158
  // A knowledge section is stale when a file matching its `paths` globs differs between the commit it was merged at
1918
2159
  // (`sha`) and the HEAD of the worktree asking. Used by the start block here and by `knowledge get/list/status`
@@ -2295,12 +2536,19 @@ function buildDispatch(p, ctx) {
2295
2536
  orch_cum_usd: orch.cum_usd,
2296
2537
  }, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
2297
2538
  projectFields(p),
2539
+ modeFields(ctx, tag),
2298
2540
  tierFields(ctx, tag, normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null, explicitEffort || parentEffort || null),
2299
2541
  // core 0.5.0: an Agent call made inside a subagent (gen-2) carries the caller's agent_id
2300
2542
  typeof p.agent_id === 'string' && p.agent_id ? { parent_agent_id: p.agent_id } : {},
2301
2543
  commonFields(ctx));
2302
2544
  }
2303
2545
 
2546
+ /** 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
+ function modeFields(ctx, tag) {
2548
+ if (!tag || !tag.task_id || !isValidTaskId(tag.task_id)) return {};
2549
+ try { return { mode: taskModeInfo(ctx, tag.task_id).mode }; } catch (_) { return {}; }
2550
+ }
2551
+
2304
2552
  /** suggested_tier / choose_tier / suggested_source / dispatched_tier / tier_mismatch, only when choose left a record. */
2305
2553
  function tierFields(ctx, tag, model, effort) {
2306
2554
  const s = tag.task_id ? readSuggestion(ctx, tag.task_id, tag.role) : null;
@@ -2547,6 +2795,16 @@ function handleSubagentStop(p, ctx) {
2547
2795
  }
2548
2796
  // exec/verify/plan only: true = it returned without writing its dp-out file (the hook saved the .inline.md)
2549
2797
  if (tag && RESULT_ROLES.includes(tag.role)) ev.inline_result = Boolean(inline);
2798
+ // core 0.7.0 (s2): seconds spent running tests in this segment (transcript; else the dp-out `TEST_S:` line)
2799
+ const tt = exists && sum ? testTimeOf(lines.slice(sum.segment_start)) : null;
2800
+ ev.test_s = tt ? tt.test_s : null;
2801
+ ev.test_runs = tt ? tt.test_runs : null;
2802
+ ev.full_suite_runs = tt ? tt.full_suite_runs : null;
2803
+ ev.test_s_source = ev.test_s !== null ? 'transcript' : null;
2804
+ if (ev.test_s === null && exists && tag) {
2805
+ const v = testSFromResultFile(lines, tag);
2806
+ if (v !== null) { ev.test_s = v; ev.test_s_source = 'dp-out'; }
2807
+ }
2550
2808
  // Record the segment before appending so a racing duplicate fire sees it.
2551
2809
  if (sum && sum.last_uuid) {
2552
2810
  saveSegments(ctx, agentId, {
@@ -2629,6 +2887,134 @@ function completionStatus(text, abortWhy) {
2629
2887
  return 'none';
2630
2888
  }
2631
2889
 
2890
+ // ------------------------------------------------------------------ core 0.7.0 (s2): time spent running tests
2891
+ //
2892
+ // Measured at SubagentStop from the subagent transcript: every Bash/PowerShell tool_use whose command runs tests is
2893
+ // paired with its tool_result line; test_s = sum of (result timestamp - tool_use timestamp). This is the same span a
2894
+ // PreToolUse/PostToolUse(Bash) pair would give, without registering hooks on every Bash call of every session (the
2895
+ // dp-* agents only hook PostToolUse, which carries no start time). Backgrounded commands return at once, so they are
2896
+ // counted (test_runs) but not timed. Nothing timed -> fall back to the `TEST_S:` line of the agent's dp-out file.
2897
+
2898
+ const TEST_TOOL_NAMES = new Set(['Bash', 'PowerShell']);
2899
+ const PKG_RUNNERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
2900
+ const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])tests?[\\/][^\s]*\.[cm]?[jt]sx?$/;
2901
+
2902
+ /** Positional args (not flags) of a token list. */
2903
+ function positionals(toks) {
2904
+ return toks.filter((t) => t && !t.startsWith('-'));
2905
+ }
2906
+
2907
+ /** One command segment (no && ; |) -> null (not a test run) or { full } (full = whole suite, no file/filter args). */
2908
+ function testSegmentInfo(seg) {
2909
+ let s = ` ${seg} `.replace(/\s\d?>>?&?\s*[^\s]+/g, ' ').trim();
2910
+ for (let k = 0; k < 6; k++) {
2911
+ 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*)/, '');
2912
+ if (n === s) break;
2913
+ s = n;
2914
+ }
2915
+ const toks = s.split(/\s+/).filter(Boolean).map((t) => t.replace(/^["']|["']$/g, ''));
2916
+ if (!toks.length) return null;
2917
+ const head = toks[0].replace(/\.(?:cmd|exe)$/i, '').split(/[\\/]/).pop();
2918
+ if (PKG_RUNNERS.has(head)) {
2919
+ let i = 1;
2920
+ if (toks[i] === 'run' || toks[i] === 'run-script') i++;
2921
+ const script = toks[i] || '';
2922
+ if (script !== 'test' && script !== 't' && !/^test:/.test(script)) return null;
2923
+ // `npm test` alone = the whole suite; a `test:<x>` script or extra args (`npm test -- file`) = a subset
2924
+ return { full: (script === 'test' || script === 't') && positionals(toks.slice(i + 1)).length === 0 };
2925
+ }
2926
+ if (head === 'node') {
2927
+ if (toks.includes('--test')) return { full: !positionals(toks.slice(1)).some((t) => /\.[cm]?[jt]sx?$/.test(t)) };
2928
+ const file = positionals(toks.slice(1))[0] || '';
2929
+ return TEST_FILE_RE.test(file) ? { full: false } : null;
2930
+ }
2931
+ if (head === 'pytest' || head === 'py.test') return { full: positionals(toks.slice(1)).length === 0 };
2932
+ if (/^python[0-9.]*$|^py$/.test(head) && toks[1] === '-m' && toks[2] === 'pytest') return { full: positionals(toks.slice(3)).length === 0 };
2933
+ if (head === 'jest') return { full: positionals(toks.slice(1)).length === 0 };
2934
+ if (head === 'vitest') {
2935
+ const rest = toks.slice(1);
2936
+ if (rest[0] === 'run') rest.shift();
2937
+ return { full: positionals(rest).length === 0 };
2938
+ }
2939
+ if (head === 'go' && toks[1] === 'test') return { full: toks.slice(2).includes('./...') };
2940
+ if ((head === 'cargo' || head === 'dotnet') && toks[1] === 'test') return { full: positionals(toks.slice(2)).length === 0 };
2941
+ return null;
2942
+ }
2943
+
2944
+ /** A shell command -> null (runs no tests) or { full } (true when any test segment runs a whole suite). */
2945
+ function testCommandInfo(cmd) {
2946
+ if (typeof cmd !== 'string' || !cmd.trim()) return null;
2947
+ let hit = null;
2948
+ for (const seg of cmd.split(/&&|\|\||;|\||\r?\n/)) {
2949
+ const t = seg.trim();
2950
+ if (!t) continue;
2951
+ const info = testSegmentInfo(t);
2952
+ if (info) hit = { full: Boolean((hit && hit.full) || info.full) };
2953
+ }
2954
+ return hit;
2955
+ }
2956
+
2957
+ /**
2958
+ * Test runs in transcript lines (one segment). Returns { test_s, test_runs, full_suite_runs }: test_s = seconds summed
2959
+ * over the timed (foreground, with a result) test commands, null when none was timed; counts include backgrounded runs.
2960
+ */
2961
+ function testTimeOf(lines) {
2962
+ const open = new Map();
2963
+ let runs = 0;
2964
+ let full = 0;
2965
+ let timed = 0;
2966
+ let ms = 0;
2967
+ for (const l of lines || []) {
2968
+ if (!l || typeof l !== 'object' || !l.message || !Array.isArray(l.message.content)) continue;
2969
+ const t = typeof l.timestamp === 'string' ? Date.parse(l.timestamp) : NaN;
2970
+ if (l.type === 'assistant') {
2971
+ for (const b of l.message.content) {
2972
+ if (!b || b.type !== 'tool_use' || !TEST_TOOL_NAMES.has(b.name) || !b.input) continue;
2973
+ const info = testCommandInfo(b.input.command);
2974
+ if (!info) continue;
2975
+ runs++;
2976
+ if (info.full) full++;
2977
+ if (b.id && b.input.run_in_background !== true && !isNaN(t)) open.set(b.id, t);
2978
+ }
2979
+ } else if (l.type === 'user') {
2980
+ for (const b of l.message.content) {
2981
+ if (!b || b.type !== 'tool_result' || !open.has(b.tool_use_id)) continue;
2982
+ const t0 = open.get(b.tool_use_id);
2983
+ open.delete(b.tool_use_id);
2984
+ if (!isNaN(t) && t >= t0) { ms += t - t0; timed++; }
2985
+ }
2986
+ }
2987
+ }
2988
+ return { test_s: timed ? Math.round(ms / 100) / 10 : null, test_runs: runs, full_suite_runs: full };
2989
+ }
2990
+
2991
+ /** Seconds from a `TEST_S: <n>[s|m|phút]` line of a result file's text (first 200 lines), or null. */
2992
+ function testSFromText(text) {
2993
+ const head = String(text || '').split(/\r?\n/).slice(0, 200);
2994
+ for (const raw of head) {
2995
+ 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);
2996
+ if (!m) continue;
2997
+ const n = Number(m[1].replace(',', '.'));
2998
+ if (!isFinite(n) || n < 0) return null;
2999
+ const unit = (m[2] || 's').toLowerCase();
3000
+ return Math.round((unit === 's' || unit === 'giây' ? n : n * 60) * 10) / 10;
3001
+ }
3002
+ return null;
3003
+ }
3004
+
3005
+ /** 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. */
3006
+ function testSFromResultFile(lines, tag) {
3007
+ try {
3008
+ if (!tag || !RESULT_ROLES.includes(tag.role) || !tag.task_id) return null;
3009
+ const dir = resultRunDir(firstPromptText(lines), tag.task_id);
3010
+ for (const f of findResultFiles(dir, tag.task_id, tag.role, tag.try)) {
3011
+ const v = testSFromText(readTextFile(f));
3012
+ if (v !== null) return v;
3013
+ }
3014
+ } catch (_) { /* best effort */ }
3015
+ return null;
3016
+ }
3017
+
2632
3018
  // ------------------------------------------------------------------ core 0.5.0: context watchdog + gen-2 gate
2633
3019
  //
2634
3020
  // Both run only from the hooks of the dp-* agent files (~/.claude/agents, installed by `dieu-phoi setup`), so a session
@@ -3174,7 +3560,7 @@ async function runSync(ctx, opts) {
3174
3560
  for (const l of batch) {
3175
3561
  try {
3176
3562
  const r = JSON.parse(l.text);
3177
- if (r && typeof r === 'object' && !Array.isArray(r)) {
3563
+ if (r && typeof r === 'object' && !Array.isArray(r) && !LOCAL_ONLY_KINDS.has(r.kind)) {
3178
3564
  delete r.synced;
3179
3565
  records.push(r);
3180
3566
  }
@@ -3211,6 +3597,9 @@ async function runSync(ctx, opts) {
3211
3597
 
3212
3598
  module.exports = {
3213
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,
3600
+ modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, MODE_ASK_LINE, isMode, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
3601
+ modeSuggestion, modeSuggestLine, fastFailRate, rootTaskIds, FAST_FAIL_WINDOW, FAST_FAIL_MIN_N, FAST_FAIL_RATE,
3602
+ testCommandInfo, testTimeOf, testSFromText,
3214
3603
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
3215
3604
  sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
3216
3605
  logError, computeUserHash, userHash, commonFields, parseTag, resolveTag, knownTaskTypes, tagIssueMessage, DEFAULT_EXTRA_TYPES,