@kienntpixon/dieu-phoi 0.7.3 → 0.7.4

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
@@ -6,7 +6,8 @@
6
6
  * This file is copied verbatim to ~/.claude/dieu-phoi/bin/hook.cjs by `dieu-phoi setup`
7
7
  * and run by Claude Code on PostToolUse(Agent), SubagentStop, SessionStart(compact) and UserPromptSubmit; the dp-* agent
8
8
  * files (`dieu-phoi setup` -> ~/.claude/agents) also run it inside those subagents as `--watch` (PostToolUse, context
9
- * handoff watchdog), `--gate` (PreToolUse(Agent), gen-2 helper rules) and `--gate-release`. It must stay
9
+ * handoff watchdog), `--gate` (PreToolUse(Agent), gen-2 helper rules), `--gate-bash` (PreToolUse(Bash), no
10
+ * `knowledge review|merge`) and `--gate-release`. It must stay
10
11
  * self-contained: Node built-ins only, no requires of other package files.
11
12
  *
12
13
  * Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit / a PostToolUse(Agent) warning
@@ -19,6 +20,7 @@
19
20
  * node hook.cjs --watch -> dp subagent PostToolUse: context watchdog (additionalContext JSON or nothing)
20
21
  * node hook.cjs --gate -> dp-exec PreToolUse(Agent): allow / deny a gen-2 helper spawn
21
22
  * node hook.cjs --gate-release -> dp-exec PostToolUseFailure(Agent): give a failed spawn's helper slot back
23
+ * node hook.cjs --gate-bash -> dp-* PreToolUse(Bash|PowerShell): deny `knowledge review|merge` in a subagent
22
24
  */
23
25
 
24
26
  const fs = require('fs');
@@ -26,7 +28,7 @@ const path = require('path');
26
28
  const os = require('os');
27
29
  const crypto = require('crypto');
28
30
 
29
- const CORE_VERSION = '0.7.3';
31
+ const CORE_VERSION = '0.7.4';
30
32
  const SCHEMA = 1;
31
33
  const TASK_TYPES = [
32
34
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -1172,8 +1174,8 @@ function buildSessionState(events, sessionId, opts) {
1172
1174
  if (!seenTask.has(id) && !discarded.has(id)) { seenTask.add(id); taskIds.push(id); }
1173
1175
  }
1174
1176
  const modeEvents = new Map();
1175
- 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);
1176
- const modeOfRoot = (root) => (modeEvents.has(root) ? modeEvents.get(root).mode : DEFAULT_MODE);
1177
+ for (const ev of events) if (ev && ev.kind === 'mode' && normMode(ev.mode) && typeof ev.root_task_id === 'string') modeEvents.set(ev.root_task_id, ev);
1178
+ const modeOfRoot = (root) => (modeEvents.has(root) ? normMode(modeEvents.get(root).mode) : DEFAULT_MODE);
1177
1179
  const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
1178
1180
  events.forEach((ev, i) => {
1179
1181
  if (!ev || typeof ev !== 'object') return;
@@ -1225,7 +1227,7 @@ function buildSessionState(events, sessionId, opts) {
1225
1227
  const pApproved = pov && evMs(pov) > (Date.parse(ps.plan.approved_at) || 0) ? pov.chosen_tier || null : null;
1226
1228
  tasks.push(Object.assign({
1227
1229
  task_id: id, task_type: ps.step.type || null, gate: 2, pending: true, verify_only: false, round: 0,
1228
- mode: isMode(ps.step.mode) ? ps.step.mode : modeOfRoot(ps.plan.root_task_id),
1230
+ mode: normMode(ps.step.mode) || modeOfRoot(ps.plan.root_task_id),
1229
1231
  }, planInfo, {
1230
1232
  approved_tier: pApproved || ps.step.tier || null, last_tier: null, attempts: [], user_verdict: null, last_steer: null,
1231
1233
  run_dir: planDir, last_ts: null,
@@ -1283,7 +1285,7 @@ function buildSessionState(events, sessionId, opts) {
1283
1285
  pending: false,
1284
1286
  verify_only: attempts.length > 0 && attempts.every((a) => a.role === 'verify'),
1285
1287
  round,
1286
- mode: dispatchModeFromEvents(t.dispatches, id) || (ps && isMode(ps.step.mode) ? ps.step.mode : modeOfRoot(plan ? plan.root_task_id : id)),
1288
+ mode: dispatchModeFromEvents(t.dispatches, id) || (ps && normMode(ps.step.mode)) || modeOfRoot(plan ? plan.root_task_id : id),
1287
1289
  }, planInfo, {
1288
1290
  approved_tier: approved,
1289
1291
  last_tier: lastExecAny ? tierOf(lastExecAny.model, lastExecAny.effort) : null,
@@ -1301,12 +1303,9 @@ function buildSessionState(events, sessionId, opts) {
1301
1303
  for (const [root, ev] of modeEvents) if (ev.session_id === sessionId || ev.session === sidHash) modeRoots.add(root);
1302
1304
  const modes = Array.from(modeRoots).map((root) => {
1303
1305
  const ev = modeEvents.get(root);
1304
- const mode = ev ? ev.mode : DEFAULT_MODE;
1305
- // core 0.7.0 (s2): suggestion only — the mode itself never changes here
1306
- const sug = modeSuggestion(events, root, { mode, taskIds: rootTaskIds(allPlans, root) });
1306
+ const mode = ev ? normMode(ev.mode) : DEFAULT_MODE;
1307
1307
  return {
1308
1308
  root_task_id: root, mode, since: ev ? ev.ts : null, source: ev ? ev.source || null : 'default', reason_code: ev ? ev.reason_code || null : null,
1309
- mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason,
1310
1309
  };
1311
1310
  });
1312
1311
  const orphans = findOrphanAgents(events, taskIds.filter((id) => !discarded.has(id)), { nowMs: o.nowMs });
@@ -1470,14 +1469,7 @@ function compactSystemMessage(state) {
1470
1469
  function modesLine(state) {
1471
1470
  const ms = Array.isArray(state.modes) ? state.modes : [];
1472
1471
  if (!ms.length) return '';
1473
- 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('');
1474
- 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}`;
1475
- }
1476
-
1477
- /** One line telling the orchestrator to ASK the user about returning to normal (never switch by itself). */
1478
- function modeSuggestLine(root, mode, reason) {
1479
- 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}); `
1480
- + `chỉ đổi khi người dùng đồng ý: \`dieu-phoi mode --root ${root || '<root>'} normal\`. KHÔNG tự đổi mode.`;
1472
+ 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(', ')}.`;
1481
1473
  }
1482
1474
 
1483
1475
  function formatSessionState(state, cap) {
@@ -1695,9 +1687,10 @@ function orchLessonLines(info) {
1695
1687
  return ls.length ? ['bài học cho điều phối viên (profile/lessons.md, [orch]):'].concat(ls.map((l) => ` - ${l}`)) : [];
1696
1688
  }
1697
1689
 
1698
- /** Gate-1 mode line: the chosen mode, or the reminder to ASK the user (default normal). */
1690
+ /** Gate-1 mode line: only when a mode was chosen (`start --mode fast`); '' otherwise (no mode question, default normal). */
1699
1691
  function modeLine(info) {
1700
- return info && isMode(info.mode) ? `mode: ${info.mode} (đã ghi cho root ${info.id}; đổi: dieu-phoi mode --root ${info.id} <mode>)` : MODE_ASK_LINE;
1692
+ const m = info && normMode(info.mode);
1693
+ return m ? `mode: ${m} (đã ghi cho root ${info.id}; đổi: dieu-phoi mode --root ${info.id} <mode>)` : '';
1701
1694
  }
1702
1695
 
1703
1696
  function formatStartBlock(info) {
@@ -1712,7 +1705,7 @@ function formatStartBlock(info) {
1712
1705
  ...orchLessonLines(info),
1713
1706
  ...knowledgeLines(info),
1714
1707
  '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.',
1715
- ].join('\n');
1708
+ ].filter((l) => l !== '').join('\n');
1716
1709
  }
1717
1710
 
1718
1711
  /** UserPromptSubmit: on a dieu-phoi prompt, pre-create the run and inject id/run dir/health/brief. */
@@ -1870,29 +1863,36 @@ function handoffChainMessage(taskId, n) {
1870
1863
  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\`).`;
1871
1864
  }
1872
1865
 
1873
- // ------------------------------------------------------------------ core 0.7.0: mode per request (fast | normal | strict)
1866
+ // ------------------------------------------------------------------ core 0.7.0: mode per request (fast | normal; 0.7.4: strict removed)
1874
1867
  //
1875
1868
  // A request (root_task_id) has a mode; the user picks it at gate 1 (`start --mode`, `plan-table` file, or
1876
1869
  // `dieu-phoi mode --root R <mode>` at any time). Every change is one local-only event kind=mode. A step not yet
1877
1870
  // dispatched follows the root's current mode (or its own `mode` override in the approved plan); a step already
1878
1871
  // dispatched keeps the mode its first exec dispatch recorded. Nothing here ever changes a mode by itself.
1879
1872
 
1880
- const MODES = ['fast', 'normal', 'strict'];
1873
+ const MODES = ['fast', 'normal'];
1881
1874
  const DEFAULT_MODE = 'normal';
1875
+ /** Removed in 0.7.4: old events/plans/configs may still carry it; every reader treats it as normal. */
1876
+ const LEGACY_MODE = 'strict';
1882
1877
  /** Event kinds that stay on this machine: runSync does not send them (an older sink would answer bad_kind). */
1883
1878
  const LOCAL_ONLY_KINDS = new Set(['mode', 'knowledge_merge', 'leftover', 'worktree', 'reap', 'reap_orphans']);
1884
- 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); '
1885
- + 'đặt bằng `dieu-phoi mode --root <root_task_id> <mode>`';
1886
-
1879
+ /** A mode the user may choose now (fast | normal). */
1887
1880
  function isMode(m) {
1888
1881
  return typeof m === 'string' && MODES.includes(m);
1889
1882
  }
1890
1883
 
1884
+ /** Mode as stored/read: fast | normal; legacy 'strict' reads as normal; anything else null. */
1885
+ function normMode(m) {
1886
+ if (typeof m !== 'string') return null;
1887
+ if (m === LEGACY_MODE) return DEFAULT_MODE;
1888
+ return MODES.includes(m) ? m : null;
1889
+ }
1890
+
1891
1891
  /** Last mode event of `root` in an events array (null when none). */
1892
1892
  function rootModeFromEvents(events, root) {
1893
1893
  let last = null;
1894
1894
  for (const ev of events) {
1895
- if (ev && ev.kind === 'mode' && ev.root_task_id === root && isMode(ev.mode)) last = ev;
1895
+ if (ev && ev.kind === 'mode' && ev.root_task_id === root && normMode(ev.mode)) last = ev.mode === normMode(ev.mode) ? ev : Object.assign({}, ev, { mode: normMode(ev.mode) });
1896
1896
  }
1897
1897
  return last;
1898
1898
  }
@@ -1910,7 +1910,7 @@ function rootModeFromText(txt, root) {
1910
1910
  if (line.includes(rootNeedle)) {
1911
1911
  try {
1912
1912
  const ev = JSON.parse(line);
1913
- if (ev && ev.kind === 'mode' && isMode(ev.mode)) last = ev;
1913
+ if (ev && ev.kind === 'mode' && normMode(ev.mode)) last = ev.mode === normMode(ev.mode) ? ev : Object.assign({}, ev, { mode: normMode(ev.mode) });
1914
1914
  } catch (_) { /* corrupt line */ }
1915
1915
  }
1916
1916
  i = end < 0 ? -1 : txt.indexOf(needle, end);
@@ -1921,7 +1921,7 @@ function rootModeFromText(txt, root) {
1921
1921
  /** Mode the first exec dispatch of `taskId` recorded (a dispatched step keeps its mode), from an events array. */
1922
1922
  function dispatchModeFromEvents(events, taskId) {
1923
1923
  for (const ev of events) {
1924
- if (ev && ev.kind === 'dispatch' && ev.task_id === taskId && (ev.role || 'exec') === 'exec' && isMode(ev.mode)) return ev.mode;
1924
+ if (ev && ev.kind === 'dispatch' && ev.task_id === taskId && (ev.role || 'exec') === 'exec' && normMode(ev.mode)) return normMode(ev.mode);
1925
1925
  }
1926
1926
  return null;
1927
1927
  }
@@ -1935,7 +1935,7 @@ function dispatchModeFromText(txt, taskId) {
1935
1935
  const line = txt.slice(start, end < 0 ? undefined : end);
1936
1936
  if (line.includes('"kind":"dispatch"') && !line.includes('"role":"verify"') && !line.includes('"role":"plan"') && !line.includes('"role":"helper"')) {
1937
1937
  const m = /"mode":"(fast|normal|strict)"/.exec(line);
1938
- if (m) return m[1];
1938
+ if (m) return normMode(m[1]);
1939
1939
  }
1940
1940
  i = end < 0 ? -1 : txt.indexOf(needle, end);
1941
1941
  }
@@ -1966,7 +1966,7 @@ function taskModeInfo(ctx, taskId, events) {
1966
1966
  const locked = events ? dispatchModeFromEvents(events, taskId) : dispatchModeFromText(txt, taskId);
1967
1967
  if (locked) return { mode: locked, source: 'dispatched', root };
1968
1968
  }
1969
- if (pl && pl.step && isMode(pl.step.mode)) return { mode: pl.step.mode, source: 'step', root };
1969
+ if (pl && pl.step && normMode(pl.step.mode)) return { mode: normMode(pl.step.mode), source: 'step', root };
1970
1970
  if (root) {
1971
1971
  const ev = events ? rootModeFromEvents(events, root) : rootModeFromText(txt, root);
1972
1972
  if (ev) return { mode: ev.mode, source: 'root', root };
@@ -1974,90 +1974,6 @@ function taskModeInfo(ctx, taskId, events) {
1974
1974
  return { mode: DEFAULT_MODE, source: 'default', root };
1975
1975
  }
1976
1976
 
1977
- // core 0.7.0 (s2): signals for SUGGESTING a return to normal. The CLI never switches the mode itself; only the
1978
- // user's `dieu-phoi mode` does. A suggestion is computed only while the root is in fast.
1979
- const FAST_FAIL_WINDOW = 10; // last N fast exec steps (all roots, file order of their first exec dispatch)
1980
- const FAST_FAIL_MIN_N = 5; // fewer fast steps than this -> no rate signal yet
1981
- const FAST_FAIL_RATE = 0.15; // >= this share failed -> suggest normal
1982
- const BATCH_VERIFY_RE = /^batch-verify(?:-|$)/;
1983
- const QUOTA_VERDICT_RE = /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/;
1984
-
1985
- /** A user reject that counts as a quality signal (legacy `rework` too); scope rejects (request changed) do not. */
1986
- function isQualityReject(ev) {
1987
- if (ev.outcome === 'rework' && !ev.by) return true;
1988
- return ev.by === 'user' && ev.outcome === 'reject' && ev.cause !== 'scope';
1989
- }
1990
-
1991
- /** Agent-layer fail (fail / timeout, never a quota pause or aborted). */
1992
- function isAgentFail(ev) {
1993
- if (ev.by === 'user' || ev.by === 'auto') return false;
1994
- if (ev.outcome === 'fail') return true;
1995
- return ev.outcome === 'timeout' && !QUOTA_VERDICT_RE.test(String(ev.reason_code || ''));
1996
- }
1997
-
1998
- /**
1999
- * Fail rate over the last FAST_FAIL_WINDOW fast exec steps (any root): a step is a task whose first exec dispatch
2000
- * recorded mode=fast; it failed when it has an agent-layer fail or a quality reject. Discarded tasks are left out.
2001
- * Returns { n, failed, rate } (rate null when n = 0).
2002
- */
2003
- function fastFailRate(events) {
2004
- const gone = new Set();
2005
- for (const ev of events) if (ev && ev.kind === 'discard' && typeof ev.task_id === 'string') gone.add(ev.task_id);
2006
- const order = [];
2007
- const seen = new Set();
2008
- for (const ev of events) {
2009
- if (!ev || ev.kind !== 'dispatch' || typeof ev.task_id !== 'string' || !ev.task_id) continue;
2010
- if ((ev.role || 'exec') !== 'exec' || seen.has(ev.task_id)) continue;
2011
- seen.add(ev.task_id);
2012
- if (ev.mode === 'fast' && !gone.has(ev.task_id)) order.push(ev.task_id);
2013
- }
2014
- const win = new Set(order.slice(-FAST_FAIL_WINDOW));
2015
- const failed = new Set();
2016
- for (const ev of events) {
2017
- if (ev && ev.kind === 'verdict' && win.has(ev.task_id) && (isAgentFail(ev) || isQualityReject(ev))) failed.add(ev.task_id);
2018
- }
2019
- return { n: win.size, failed: failed.size, rate: win.size ? failed.size / win.size : null };
2020
- }
2021
-
2022
- /**
2023
- * Should the orchestrator SUGGEST (never apply) a return to normal for `root`? Only while `mode` (the root's current
2024
- * mode) is fast. Signals, each with a short Vietnamese reason:
2025
- * - batch-verify: an agent verdict fail with reason batch-verify on a task of the root, after the root's last mode event;
2026
- * - reject: a quality reject (user) on a task of the root, after that mode event;
2027
- * - fail-rate: >= 15% failed among the last 10 fast steps (all roots; needs >= 5 steps).
2028
- * `taskIds` = the root and its plan's step task ids. Returns { mode_suggest: 'normal'|null, mode_suggest_reason,
2029
- * mode_suggest_signals[] }.
2030
- */
2031
- function modeSuggestion(events, root, o) {
2032
- const opts = o || {};
2033
- const none = { mode_suggest: null, mode_suggest_reason: null, mode_suggest_signals: [] };
2034
- if (opts.mode !== 'fast') return none;
2035
- const ids = new Set(opts.taskIds || []);
2036
- if (root) ids.add(root);
2037
- const last = root ? rootModeFromEvents(events, root) : null;
2038
- const since = last ? (Date.parse(last.ts) || 0) : 0;
2039
- const signals = [];
2040
- const reasons = [];
2041
- let batch = null;
2042
- let reject = null;
2043
- for (const ev of events) {
2044
- if (!ev || ev.kind !== 'verdict' || !ids.has(ev.task_id)) continue;
2045
- const t = Date.parse(ev.ts) || 0;
2046
- if (t < since) continue;
2047
- if (!batch && ev.outcome === 'fail' && BATCH_VERIFY_RE.test(String(ev.reason_code || ''))) batch = ev.task_id;
2048
- if (!reject && isQualityReject(ev)) reject = ev.task_id;
2049
- }
2050
- if (batch) { signals.push('batch-verify'); reasons.push(`verifier gộp báo fail (task ${batch})`); }
2051
- if (reject) { signals.push('reject'); reasons.push(`người dùng reject (task ${reject})`); }
2052
- const fr = fastFailRate(events);
2053
- if (fr.n >= FAST_FAIL_MIN_N && fr.rate >= FAST_FAIL_RATE) {
2054
- signals.push('fail-rate');
2055
- 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)}%)`);
2056
- }
2057
- if (!signals.length) return none;
2058
- return { mode_suggest: 'normal', mode_suggest_reason: reasons.join('; '), mode_suggest_signals: signals };
2059
- }
2060
-
2061
1977
  /** Task ids of a root: the root itself plus the step task ids of its approved plan (if any). */
2062
1978
  function rootTaskIds(plans, root) {
2063
1979
  const out = new Set(root ? [root] : []);
@@ -2330,8 +2246,10 @@ function readHead(start) {
2330
2246
 
2331
2247
  // ------------------------------------------------------------------ core 0.6.0, s3: knowledge freshness
2332
2248
  //
2333
- // A knowledge section is stale when a file matching its `paths` globs differs between the commit it was merged at
2334
- // (`sha`) and the HEAD of the worktree asking. Used by the start block here and by `knowledge get/list/status`
2249
+ // A knowledge section is stale when a file named by one of its plain (non-glob) `paths` entries differs between the
2250
+ // commit it was merged at (`sha`) and the HEAD of the worktree asking. A file that only matches a glob entry (`**`,
2251
+ // `*`, `?`) gives state `drift`: shown lightly, never counted as stale (rule 2, 0.7.4 s5; cache entries carry `rule`).
2252
+ // Used by the start block here and by `knowledge get/list/status`
2335
2253
  // (lib/knowledge.cjs). Lives in hook.cjs because the installed hook is a single file (builtins only). Rules, from
2336
2254
  // measurements on the pixon-fast-forward repos (contract r1-2 §2):
2337
2255
  // - ONE git process per check, whatever the number of distinct section shas: `git diff-tree --stdin` reads one
@@ -2350,8 +2268,69 @@ const FRESH_CACHE_MAX = 12; // HEADs remembered per store (worktrees of one repo
2350
2268
  const DIFF_TIMEOUT_MS = 10000;
2351
2269
  const HOOK_DIFF_TIMEOUT_MS = 1500;
2352
2270
  const DIRTY_TIMEOUT_MS = 2000;
2271
+ // Tests only: DIEU_PHOI_GIT_STATUS_TIMEOUT_MS overrides the `git status` bound of --dirty (a busy Windows box overruns 2 s);
2272
+ // invalid or non-positive values are ignored, the product default stays DIRTY_TIMEOUT_MS.
2273
+ function dirtyTimeoutMs() {
2274
+ const v = Number(process.env.DIEU_PHOI_GIT_STATUS_TIMEOUT_MS);
2275
+ return Number.isFinite(v) && v > 0 ? v : DIRTY_TIMEOUT_MS;
2276
+ }
2353
2277
  const PATHSPEC_MAX_CHARS = 6000; // beyond this: no pathspec (Windows command line limit), filter in JS only
2354
2278
  const STALE_FILES_KEEP = 50; // stale files kept per section (the count stays exact)
2279
+ const FRESH_RULE = 4; // 4 (0.7.4 s9): rule 3 + identifiers compared as nameKey (kebab/camel match), >= 3 chars; cache of rule 3 dropped
2280
+ const GLOB_CHARS_RE = /[*?]/;
2281
+ const LARGE_FILE_BYTES = 30000; // above this a plain path counts only when a changed line names one of the entry's identifiers
2282
+ // A changed line that is only a version bump (package.json, SKILL.md frontmatter, a `const CORE_VERSION = '…'` in code) or blank: routine, never stale.
2283
+ const ROUTINE_LINE_RES = [/^\s*$/, /^\s*"version"\s*:\s*"[^"]*",?\s*$/, /^\s*version\s*:\s*[^\s#]+\s*$/,
2284
+ /^\s*(?:const|let|var)\s+\w*VERSION\w*\s*=\s*(['"])[^'"]*\1;?\s*$/];
2285
+ // Words too common in code to tell an entry's identifiers from ordinary lines.
2286
+ const NAME_STOPWORDS = new Set(['const', 'return', 'function', 'string', 'number', 'object', 'boolean', 'undefined',
2287
+ 'files', 'file', 'json', 'path', 'paths', 'value', 'values', 'state', 'null', 'true', 'false', 'test', 'tests', 'node', 'cli']);
2288
+
2289
+ function plainPathsOf(s) { return (s.paths || []).filter((g) => !GLOB_CHARS_RE.test(g)); }
2290
+ function isChangelogPath(f) { return /(^|\/)CHANGELOG[^/]*$/i.test(f); }
2291
+ function entryAboutChangelog(s) { return [s.id, s.title, ...(s.tags || [])].some((x) => /changelog/i.test(String(x || ''))); }
2292
+ function isRoutineLine(t) { return ROUTINE_LINE_RES.some((re) => re.test(t)); }
2293
+ function isLargeFile(root, f) {
2294
+ try { return fs.statSync(path.join(root, f)).size > LARGE_FILE_BYTES; } catch (_) { return false; }
2295
+ }
2296
+ function readSectionBody(dir, id) {
2297
+ try { return fs.readFileSync(path.join(dir, 'sections', `${id}.md`), 'utf8'); } catch (_) { return ''; }
2298
+ }
2299
+ /** Comparable form of an identifier: lower-case letters and digits only (`plan-table` ~ `cmdPlanTable` ~ `plantable`). */
2300
+ function nameKey(t) { return String(t || '').toLowerCase().replace(/[^a-z0-9]/g, ''); }
2301
+ /** Identifiers an entry names: the pieces of its backtick spans and the words of its id (nameKey form, >= 3 chars). */
2302
+ function namesOf(s, body) {
2303
+ const out = new Set();
2304
+ for (const m of String(body || '').matchAll(/`([^`\n]{2,200})`/g)) {
2305
+ for (const t of m[1].split(/[\s(),;:=<>[\]{}'"|]+/)) {
2306
+ const low = nameKey(t);
2307
+ if (low.length >= 3 && /[a-z]/.test(low) && !NAME_STOPWORDS.has(low)) out.add(low);
2308
+ }
2309
+ }
2310
+ for (const p of String(s.id || '').split('-')) if (p.length >= 5) out.add(nameKey(p));
2311
+ return Array.from(out);
2312
+ }
2313
+ /**
2314
+ * Changed lines (added and removed text, no diff markers) of `files` between sha and head, one git diff with -U0.
2315
+ * Returns { byFile: Map<file, string[]> } or null when git fails.
2316
+ */
2317
+ function changedLinesOf(root, sha, head, files, timeoutMs) {
2318
+ const r = runGit(root, ['--literal-pathspecs', 'diff', '-U0', '--no-renames', '--no-color', '--no-ext-diff', sha, head, '--', ...files], undefined, timeoutMs);
2319
+ if (!r.ok) return null;
2320
+ const byFile = new Map();
2321
+ let cur = null;
2322
+ let inHunk = false;
2323
+ for (const raw of r.stdout.split('\n')) {
2324
+ const line = raw.replace(/\r$/, '');
2325
+ if (line.startsWith('diff --git ')) {
2326
+ const m = / b\/(.*)$/.exec(line);
2327
+ cur = m ? m[1] : null; inHunk = false;
2328
+ if (cur && !byFile.has(cur)) byFile.set(cur, []);
2329
+ } else if (line.startsWith('@@')) inHunk = true;
2330
+ else if (inHunk && cur && (line.startsWith('+') || line.startsWith('-'))) byFile.get(cur).push(line.slice(1));
2331
+ }
2332
+ return { byFile };
2333
+ }
2355
2334
  const FULL_SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
2356
2335
 
2357
2336
  /**
@@ -2444,7 +2423,7 @@ function diffShas(root, shas, head, globs, timeoutMs) {
2444
2423
  function dirtyFiles(root, globs, timeoutMs) {
2445
2424
  const spec = pathspecOf(globs);
2446
2425
  const base = ['status', '--porcelain=v1', '-z', '-uno', '--no-renames'];
2447
- const r = runGit(root, spec ? base.concat('--', spec) : base, undefined, timeoutMs || DIRTY_TIMEOUT_MS);
2426
+ const r = runGit(root, spec ? base.concat('--', spec) : base, undefined, timeoutMs || dirtyTimeoutMs());
2448
2427
  if (!r.ok) return { error: r.error, timedOut: !!r.timedOut, ms: r.ms };
2449
2428
  const files = r.stdout.split('\0').filter((x) => x.length > 3).map((x) => x.slice(3));
2450
2429
  return { files, ms: r.ms };
@@ -2470,7 +2449,7 @@ function readFreshCache(dir) {
2470
2449
 
2471
2450
  function cachedFor(dir, head, stamp) {
2472
2451
  if (!head || !stamp) return null;
2473
- return readFreshCache(dir).entries.find((e) => e.head === head && e.index_mtime === stamp.mtime && e.index_size === stamp.size) || null;
2452
+ return readFreshCache(dir).entries.find((e) => e.rule === FRESH_RULE && e.head === head && e.index_mtime === stamp.mtime && e.index_size === stamp.size) || null;
2474
2453
  }
2475
2454
 
2476
2455
  /** Best effort (tmp+rename, last writer wins); a store that vanished or is read-only only loses the cache. */
@@ -2482,7 +2461,7 @@ function writeFreshCache(dir, entry) {
2482
2461
  }
2483
2462
 
2484
2463
  function freshCounts(sections) {
2485
- const c = { total: 0, fresh: 0, stale: 0, unknown: 0 };
2464
+ const c = { total: 0, fresh: 0, stale: 0, drift: 0, unknown: 0 };
2486
2465
  for (const s of Object.values(sections)) { c.total++; c[s.state] = (c[s.state] || 0) + 1; }
2487
2466
  return c;
2488
2467
  }
@@ -2509,7 +2488,8 @@ function computeFreshness(o) {
2509
2488
  if (!head) why = o.head && o.head.unsupported ? `HEAD không đọc được từ file (${o.head.unsupported})` : 'không đọc được HEAD (repo chưa có commit?)';
2510
2489
  else if (!o.root) why = 'repo không có thư mục làm việc (bare)';
2511
2490
  for (const s of idx.sections) {
2512
- const sha = String(s.sha || '').toLowerCase();
2491
+ // review mark (s6): an entry reviewed at reviewed_sha (full sha) is judged from that sha on; a merge that replaces the entry drops the mark
2492
+ const sha = String(FULL_SHA_RE.test(String(s.reviewed_sha || '')) ? s.reviewed_sha : (s.sha || '')).toLowerCase();
2513
2493
  if (why) sections[s.id] = { state: 'unknown', why };
2514
2494
  else if (!sha || sha === 'none') sections[s.id] = { state: 'unknown', why: 'mục chưa có sha' };
2515
2495
  else if (sha === head) sections[s.id] = { state: 'fresh' };
@@ -2523,19 +2503,65 @@ function computeFreshness(o) {
2523
2503
  const d = diffShas(o.root, Array.from(pending.keys()), head, globs, o.timeoutMs || DIFF_TIMEOUT_MS);
2524
2504
  out.ms_git = d.ms || 0;
2525
2505
  if (d.error) error = d.error;
2506
+ // rule 3 (0.7.4 s5b): read the changed lines of the plain-path files that could make an entry stale (one git
2507
+ // diff per distinct sha, only on a cache miss); CHANGELOG files an entry is not about are never read
2508
+ const timeout = o.timeoutMs || DIFF_TIMEOUT_MS;
2509
+ const want = new Map(); // sha -> Set(file)
2510
+ for (const [sha, list] of pending) {
2511
+ if (d.error || d.missing.has(sha)) continue;
2512
+ for (const s of list) {
2513
+ const plain = plainPathsOf(s);
2514
+ for (const f of d.bySha.get(sha) || []) {
2515
+ if (!plain.some((g) => globMatch(g, f))) continue;
2516
+ if (isChangelogPath(f) && !entryAboutChangelog(s)) continue;
2517
+ if (!want.has(sha)) want.set(sha, new Set());
2518
+ want.get(sha).add(f);
2519
+ }
2520
+ }
2521
+ }
2522
+ const lines = new Map(); // `${sha}\0${file}` -> changed texts, or null when git could not say
2523
+ for (const [sha, set] of want) {
2524
+ const r = changedLinesOf(o.root, sha, head, Array.from(set), timeout);
2525
+ for (const f of set) lines.set(`${sha}\0${f}`, r ? (r.byFile.get(f) || []) : null);
2526
+ }
2527
+ const names = new Map(); // section id -> identifiers it names (read lazily)
2528
+ const namesFor = (s) => {
2529
+ if (!names.has(s.id)) names.set(s.id, namesOf(s, readSectionBody(o.dir, s.id)));
2530
+ return names.get(s.id);
2531
+ };
2532
+ const counts = (sha, s, f) => { // does this changed plain-path file make the entry stale?
2533
+ if (isChangelogPath(f) && !entryAboutChangelog(s)) return false;
2534
+ const texts = lines.get(`${sha}\0${f}`);
2535
+ if (!texts) return true; // content unknown: count the change, never hide it
2536
+ const real = texts.filter((t) => !isRoutineLine(t));
2537
+ if (!real.length) return false; // only version bumps / blank lines
2538
+ if (!isLargeFile(o.root, f)) return true;
2539
+ const nm = namesFor(s);
2540
+ return real.some((t) => { const low = nameKey(t); return nm.some((x) => low.includes(x)); });
2541
+ };
2526
2542
  for (const [sha, list] of pending) {
2527
2543
  for (const s of list) {
2528
2544
  if (d.error) { sections[s.id] = { state: 'unknown', why: `git diff-tree lỗi: ${d.error}` }; continue; }
2529
2545
  if (d.missing.has(sha)) { sections[s.id] = { state: 'unknown', why: `commit ${sha.slice(0, 7)} không có trong repo này (clone/nhánh khác chưa fetch?)` }; continue; }
2530
- const files = d.bySha.get(sha).filter((f) => (s.paths || []).some((g) => globMatch(g, f)));
2531
- sections[s.id] = files.length ? { state: 'stale', files: files.slice(0, STALE_FILES_KEEP), n_files: files.length } : { state: 'fresh' };
2546
+ const changed = d.bySha.get(sha) || [];
2547
+ const plain = plainPathsOf(s);
2548
+ const globPaths = (s.paths || []).filter((g) => GLOB_CHARS_RE.test(g));
2549
+ const named = [];
2550
+ const soft = []; // changed, but not counted as stale: glob-only, routine, CHANGELOG, lines the entry does not name
2551
+ for (const f of changed) {
2552
+ if (plain.some((g) => globMatch(g, f))) (counts(sha, s, f) ? named : soft).push(f);
2553
+ else if (globPaths.some((g) => globMatch(g, f))) soft.push(f);
2554
+ }
2555
+ if (named.length) sections[s.id] = { state: 'stale', files: named.slice(0, STALE_FILES_KEEP), n_files: named.length };
2556
+ else if (soft.length) sections[s.id] = { state: 'drift', files: soft.slice(0, STALE_FILES_KEEP), n_files: soft.length };
2557
+ else sections[s.id] = { state: 'fresh' };
2532
2558
  }
2533
2559
  }
2534
2560
  }
2535
2561
  Object.assign(out, { computed: new Date().toISOString(), sections, counts: freshCounts(sections) });
2536
2562
  if (error) out.error = error;
2537
2563
  if (head && !error && o.cache !== false && stamp) {
2538
- writeFreshCache(o.dir, { head, index_mtime: stamp.mtime, index_size: stamp.size, computed: out.computed, sections });
2564
+ writeFreshCache(o.dir, { rule: FRESH_RULE, head, index_mtime: stamp.mtime, index_size: stamp.size, computed: out.computed, sections });
2539
2565
  }
2540
2566
  return out;
2541
2567
  }
@@ -2560,12 +2586,14 @@ function shortFileList(files, n) {
2560
2586
  return f.slice(0, k).join(', ') + (f.length > k ? ` (+${f.length - k})` : '');
2561
2587
  }
2562
2588
 
2563
- /** "N mục, k stale (ids), u không xác định (ids)". */
2589
+ /** "N mục, k stale (ids), d drift (ids), u không xác định (ids)"; drift only when present (it is not stale). */
2564
2590
  function knowledgeSummary(fr, n) {
2565
2591
  const ids = (st) => Object.keys(fr.sections).filter((id) => fr.sections[id].state === st);
2566
2592
  const stale = ids('stale');
2593
+ const drift = ids('drift');
2567
2594
  const unk = ids('unknown');
2568
2595
  const parts = [`${n} mục`, `${stale.length} stale${stale.length ? ` (${shortFileList(stale, 6)})` : ''}`];
2596
+ if (drift.length) parts.push(`${drift.length} drift (${shortFileList(drift, 4)})`);
2569
2597
  if (unk.length) parts.push(`${unk.length} không xác định (${shortFileList(unk, 4)})`);
2570
2598
  return parts.join(', ');
2571
2599
  }
@@ -4609,7 +4637,7 @@ function verifyOrphanGate(p, ctx, tag) {
4609
4637
  denied: true,
4610
4638
  event: ev,
4611
4639
  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 — `
4612
- + '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`.'),
4640
+ + 'không chạy verifier cho việc chưa giao. Giao exec của task trước; verifier gộp (fast, normal s≤4) dùng thẻ `|batch`.'),
4613
4641
  };
4614
4642
  }
4615
4643
 
@@ -4747,6 +4775,43 @@ function logGate(ctx, p, agentId, st, fields) {
4747
4775
  return ev;
4748
4776
  }
4749
4777
 
4778
+ /**
4779
+ * `--gate-bash` (PreToolUse(Bash|PowerShell) inside dp-* subagents): `knowledge review|merge` writes knowledge
4780
+ * outside the accept gate (a subagent could clear a STALE mark on its own). Deny it; the coordinator runs it.
4781
+ * Matching (0.7.4 S4a): split on `| ; & newline`. In a segment, a token naming the CLI (`dieu-phoi`, `dieu-phoi.cjs|.cmd|.js`,
4782
+ * optional `@version`, optional path: `node D:/x/dieu-phoi.cjs`, `npx @kienntpixon/dieu-phoi@0.7.4`) comes before a `knowledge`
4783
+ * token; between `knowledge` and the subcommand only flags are allowed (`--repo x`, `--repo=x`, before or after `knowledge`,
4784
+ * a flag's one value skipped), then `review|merge`. Known limit: a shell variable or alias hiding the CLI name
4785
+ * (`$DP knowledge review`) is not seen; this gate is a guard for subagents, not a sandbox.
4786
+ */
4787
+ const KNOWLEDGE_WRITE_SUBS = new Set(['review', 'merge']);
4788
+ const DP_CLI_TOKEN_RE = /(?:^|[\/\\])dieu-phoi(?:\.cjs|\.cmd|\.js)?(?:@\S*)?$/;
4789
+ function isKnowledgeWriteCall(cmd) {
4790
+ for (const seg of cmd.split(/[|;&\n]/)) {
4791
+ const toks = (seg.match(/"[^"]*"|'[^']*'|\S+/g) || []).map((t) => t.replace(/^["']|["']$/g, ''));
4792
+ for (let i = 0; i < toks.length; i++) {
4793
+ if (!DP_CLI_TOKEN_RE.test(toks[i])) continue;
4794
+ for (let j = i + 1; j < toks.length; j++) {
4795
+ if (toks[j] !== 'knowledge') continue;
4796
+ let flagValue = false; // previous token was a flag without `=`, so this word may be its value
4797
+ for (let k = j + 1; k < toks.length; k++) {
4798
+ const t = toks[k];
4799
+ if (KNOWLEDGE_WRITE_SUBS.has(t)) return true;
4800
+ if (t.startsWith('-')) { flagValue = !t.includes('='); continue; }
4801
+ if (!flagValue) break;
4802
+ flagValue = false;
4803
+ }
4804
+ }
4805
+ }
4806
+ }
4807
+ return false;
4808
+ }
4809
+ function handleGateBash(p) {
4810
+ const cmd = p.tool_input && typeof p.tool_input.command === 'string' ? p.tool_input.command : '';
4811
+ if (!isKnowledgeWriteCall(cmd)) return null;
4812
+ return { stdout: preToolDeny('dp-*: subagent không được chạy `knowledge review|merge` (đường ghi knowledge ngoài cổng accept). Báo điều phối viên thay vì tự chạy.') };
4813
+ }
4814
+
4750
4815
  /** `--gate-release` (PostToolUseFailure(Agent) inside dp-exec): the spawn failed, give its unconfirmed slot back. */
4751
4816
  function handleGateRelease(p, ctx) {
4752
4817
  if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
@@ -4819,6 +4884,7 @@ function runHook(opts) {
4819
4884
  if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
4820
4885
  if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
4821
4886
  if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
4887
+ if (o.mode === 'gate-bash') return handleGateBash(p); // { stdout } (deny) or null
4822
4888
  if (name === 'PreToolUse') return handleChainGate(p, ctx); // { stdout } (deny / warn) or null
4823
4889
  if (name === 'SubagentStart') return handleSubagentStart(p, ctx); // live record or null, prints nothing
4824
4890
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
@@ -4982,8 +5048,8 @@ async function runSync(ctx, opts) {
4982
5048
 
4983
5049
  module.exports = {
4984
5050
  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,
4985
- modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, MODE_ASK_LINE, isMode, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
4986
- modeSuggestion, modeSuggestLine, fastFailRate, rootTaskIds, FAST_FAIL_WINDOW, FAST_FAIL_MIN_N, FAST_FAIL_RATE,
5051
+ modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, isMode, normMode, LEGACY_MODE, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
5052
+ rootTaskIds,
4987
5053
  testCommandInfo, testTimeOf, testSFromText,
4988
5054
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED, TYPE_ALIASES,
4989
5055
  sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
@@ -5012,7 +5078,8 @@ if (require.main === module) {
5012
5078
  .finally(() => process.exit(0));
5013
5079
  } else {
5014
5080
  const mode = process.argv.includes('--watch') ? 'watch'
5015
- : (process.argv.includes('--gate-release') ? 'gate-release' : (process.argv.includes('--gate') ? 'gate' : null));
5081
+ : (process.argv.includes('--gate-release') ? 'gate-release'
5082
+ : (process.argv.includes('--gate-bash') ? 'gate-bash' : (process.argv.includes('--gate') ? 'gate' : null)));
5016
5083
  const r = runHook({ mode });
5017
5084
  if (r && typeof r.stdout === 'string') process.stdout.write(r.stdout);
5018
5085
  process.exitCode = 0;
package/lib/aggregate.cjs CHANGED
@@ -574,9 +574,27 @@ function analyze(events, opts) {
574
574
  };
575
575
  }
576
576
 
577
+ /**
578
+ * core 0.7.4 (s1): one sample per task that has a first exec attempt (excluded tasks left out). `model`/`effort` are
579
+ * that attempt's (the cell buildCells uses), `handoff` = some segment of that attempt's chain ended on an auto-handoff,
580
+ * `total_cost` = the task's cost over all segments. Only plan-table's TÁCH? warning reads these; it is advice, not a gate.
581
+ */
582
+ function handoffSamples(events) {
583
+ const { agents, userVerdicts, autoVerdicts } = buildAgents(withoutDiscarded(events));
584
+ return buildTasks(agents, userVerdicts, autoVerdicts)
585
+ .filter((t) => t.first && !t.excluded)
586
+ .map((t) => ({
587
+ task_type: t.task_type,
588
+ model: t.first.model,
589
+ effort: t.first.effort,
590
+ handoff: t.first_chain.some((a) => a.handoff),
591
+ total_cost: t.total_cost,
592
+ }));
593
+ }
594
+
577
595
  module.exports = {
578
596
  MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, ABORTED, QUOTA_REASON_RE, HANDOFF_REASON_RE, discardedTaskIds, withoutDiscarded, CANONICAL_CELL_FIELDS, normalizeVerdict, isSelf, isInternalComplete, buildAgents, buildTasks,
579
- buildCells, buildSteers, canonicalCells, isBatchVerify, batchVerifyCells, orchestrationSummary, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
597
+ buildCells, buildSteers, canonicalCells, isBatchVerify, batchVerifyCells, orchestrationSummary, analyze, handoffSamples, cellKey, mean, percentile, tsOf, steerIdOf,
580
598
  };
581
599
 
582
600
  // ------------------------------------------------------------------ core 0.7.0 (s2): stats split by mode
@@ -584,7 +602,7 @@ module.exports = {
584
602
  // Kept apart from the canonical cells (not part of the CLI <-> sink parity yet). A task's mode = `mode` of its first
585
603
  // exec dispatch; tasks dispatched before modes existed (no `mode`) count as normal.
586
604
 
587
- const MODE_KEYS = ['fast', 'normal', 'strict'];
605
+ const MODE_KEYS = ['fast', 'normal']; // legacy 'strict' dispatches count as normal (0.7.4)
588
606
  const NO_CATCH_VERIFY = new Set(['self', 'none']);
589
607
 
590
608
  /**