@kienntpixon/dieu-phoi 0.7.2 → 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/lib/commands.cjs CHANGED
@@ -77,7 +77,7 @@ function cmdSetup(opts) {
77
77
  const lines = [
78
78
  `Đã cài hook: ${res.hook}`,
79
79
  res.settings_changed
80
- ? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) + UserPromptSubmit + PreToolUse(Agent) trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
80
+ ? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SubagentStart + SessionStart(compact) + UserPromptSubmit + PreToolUse(Agent) trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
81
81
  : `settings.json đã có hook, không đổi gì (${res.settings})`,
82
82
  `Agent dp-*: ${res.agents.map((a) => `${a.name}=${a.action}`).join(', ')}${res.agents.some((a) => a.action === 'skipped-foreign') ? ' (file trùng tên không phải của dieu-phoi: giữ nguyên, đổi tên rồi chạy lại setup)' : ''}`,
83
83
  `Thư mục báo cáo subagent: ${res.runs_dir} (quyền: ${res.permissions.allow.join(', ')} + additionalDirectories)`,
@@ -144,6 +144,7 @@ async function cmdDoctor(opts) {
144
144
  add(reg.PreToolUse, 'chain-gate', reg.PreToolUse
145
145
  ? 'PreToolUse(Agent) có — chặn exec tiếp nối từ handoff thứ 3 (trừ thẻ chain_ok)'
146
146
  : 'thiếu hook PreToolUse(Agent) (core 0.7.1) — chuỗi handoff dài không bị chặn; chạy lại `dieu-phoi setup`', 'CẢNH BÁO');
147
+ add(...liveCheck(ctx, reg));
147
148
  const permOk = reg.permissions && reg.runs_dir_exists && !reg.legacy_permissions;
148
149
  add(permOk, 'run-permissions', permOk
149
150
  ? `${reg.permission_entries.allow.join(', ')} + additionalDirectories ${reg.permission_entries.dir}`
@@ -245,22 +246,51 @@ function orphanScan(list, now) {
245
246
  return Array.isArray(list) ? procs.staleOrphans(list, { minAgeMs: ORPHAN_MIN_AGE_MS, now: now === undefined ? Date.now() : now }) : null;
246
247
  }
247
248
 
249
+ /**
250
+ * doctor item `live-agents` (core 0.7.3 s1): live register data/live/ + caps. Warns when the SubagentStart hook is missing
251
+ * (the register then only fills from --watch) or the machine count reached warn_parallel_machine. "Toàn máy" = this
252
+ * DIEU_PHOI_HOME: sessions with another home have their own register.
253
+ */
254
+ function liveCheck(ctx, reg) {
255
+ const s = core.liveSummary(ctx, null);
256
+ const lim = s.limits;
257
+ const caps = `trần phiên ${lim.session || 'tắt'}, toàn máy ${lim.machine || 'tắt'}, cảnh báo ${lim.warn || 'tắt'}`;
258
+ const scope = `"toàn máy" = mọi phiên dùng ${ctx.home}`;
259
+ if (reg && !reg.SubagentStart) {
260
+ return [false, 'live-agents', `thiếu hook SubagentStart (core 0.7.3) — sổ agent sống chỉ cập nhật từ --watch; chạy lại dieu-phoi setup (${s.machine} agent sống; ${caps})`, 'CẢNH BÁO'];
261
+ }
262
+ const warn = lim.warn > 0 && s.machine >= lim.warn;
263
+ const list = s.labels.length ? `: ${s.labels.slice(0, 6).join('; ')}${s.labels.length > 6 ? ` (+${s.labels.length - 6})` : ''}` : '';
264
+ return [!warn, 'live-agents', `${s.machine} agent dieu-phoi đang chạy${list} — ${caps}; ${scope}`, 'CẢNH BÁO'];
265
+ }
266
+
248
267
  /** doctor item `orphan-processes`: warns with pid + age + working set and a Stop-Process hint; never kills. Returns the args of add(). */
249
268
  function orphanCheck(ctx, deps) {
250
269
  const d = deps || {};
251
270
  const now = d.now === undefined ? Date.now() : d.now;
252
- const found = orphanScan((d.listProcesses || procs.listProcesses)({ timeoutMs: 4000 }), now);
271
+ const list = (d.listProcesses || procs.listProcesses)({ timeoutMs: 4000 });
272
+ const found = orphanScan(list, now);
253
273
  if (found === null) return [true, 'orphan-processes', 'không đọc được danh sách process (bỏ qua)'];
254
274
  try {
255
275
  core.writeFileAtomic(path.join(ctx.paths.data, 'orphan-procs.json'), `${JSON.stringify({ ts: new Date(now).toISOString(), n: found.length })}\n`);
256
276
  } catch (_) { /* the cache is optional */ }
257
- if (!found.length) return [true, 'orphan-processes', 'không có claude.exe / codebase-memory-mcp mồ côi quá 6 giờ'];
277
+ // s7: tail / sleep / Claude Code shells left orphaned (Monitor, run_in_background): reported only; `reap --orphans` cleans
278
+ const minK = orphanKillMin(ctx);
279
+ const watchers = Array.isArray(list) ? orphanTargets(list, now, minK, d).found : [];
280
+ const wMb = watchers.reduce((n, p) => n + (p.ws ? Math.round(p.ws / 1048576) : 0), 0);
281
+ const wTxt = watchers.length
282
+ ? `${watchers.length} tail/sleep/shell mồ côi quá ${minK} phút (${wMb} MB) — xem rồi dọn: \`dieu-phoi reap --orphans\` (dry-run) rồi thêm --yes (doctor không tự kill)`
283
+ : '';
284
+ if (!found.length) {
285
+ return wTxt ? [false, 'orphan-processes', `không có claude.exe / codebase-memory-mcp mồ côi quá 6 giờ; ${wTxt}`, 'CẢNH BÁO']
286
+ : [true, 'orphan-processes', 'không có claude.exe / codebase-memory-mcp mồ côi quá 6 giờ'];
287
+ }
258
288
  const mb = found.reduce((n, p) => n + (p.ws_mb || 0), 0);
259
289
  const shown = found.slice(0, 8);
260
290
  const items = shown.map((p) => `${p.name} pid ${p.pid} (${p.age_h} giờ${p.ws_mb ? `, ${p.ws_mb} MB` : ''})`).join('; ');
261
291
  return [false, 'orphan-processes',
262
292
  `${found.length} tiến trình mồ côi (cha đã thoát) quá 6 giờ, ${mb} MB: ${items}${found.length > shown.length ? '; …' : ''}`
263
- + ` — kiểm tra rồi dọn bằng \`Stop-Process -Id ${shown.map((p) => p.pid).join(',')}\` (doctor không tự kill)`, 'CẢNH BÁO'];
293
+ + ` — kiểm tra rồi dọn bằng \`Stop-Process -Id ${shown.map((p) => p.pid).join(',')}\` (doctor không tự kill)${wTxt ? `; ${wTxt}` : ''}`, 'CẢNH BÁO'];
264
294
  }
265
295
 
266
296
  /** `state` line: orphan count from a 10-minute cache (data/orphan-procs.json), else one live scan. null = unknown (skip the line). */
@@ -381,7 +411,7 @@ function cmdStart(opts) {
381
411
  core.modeLine(info),
382
412
  ...core.orchLessonLines(info),
383
413
  ...core.knowledgeLines(info),
384
- ];
414
+ ].filter((l) => l !== '');
385
415
  return { code: 0, out: out.join('\n') };
386
416
  }
387
417
 
@@ -405,23 +435,24 @@ function modeEvent(ctx, root, mode, source, reason, session) {
405
435
  }
406
436
 
407
437
  /**
408
- * `dieu-phoi mode --root <root_task_id> [fast|normal|strict] [--reason R] [--session S]`: show or change the mode of a
438
+ * `dieu-phoi mode --root <root_task_id> [fast|normal] [--reason R] [--session S]`: show or change the mode of a
409
439
  * request. One event per change; the same mode again writes nothing. Steps not yet dispatched follow the new mode,
410
440
  * dispatched steps keep the mode they were given. Never changes a mode by itself.
411
441
  */
412
442
  function cmdMode(opts) {
413
- const usage = 'Cú pháp: dieu-phoi mode --root <root_task_id> [fast|normal|strict] [--reason R] [--session S] [--json]';
443
+ const usage = 'Cú pháp: dieu-phoi mode --root <root_task_id> [fast|normal] [--reason R] [--session S] [--json]';
414
444
  if (typeof opts.root !== 'string') throw new Error(usage);
415
445
  const root = requireTaskId(opts.root);
416
446
  const ctx = ctxOf(opts);
417
447
  const cur = core.rootModeFromEvents(core.readEvents(ctx), root);
418
448
  const was = cur ? cur.mode : core.DEFAULT_MODE;
419
- const want = opts._[0];
449
+ const wantRaw = opts._[0];
420
450
  const show = (extra) => (opts.json ? JSON.stringify(Object.assign({ root_task_id: root, mode: was, previous: was, changed: false }, extra)) : null);
421
- if (want === undefined) {
451
+ if (wantRaw === undefined) {
422
452
  return { code: 0, out: show({}) || `root ${root}: mode=${was}${cur ? ` (từ ${cur.ts}, nguồn ${cur.source || '?'})` : ' (mặc định, chưa đặt)'}` };
423
453
  }
424
- if (!core.isMode(want)) throw new Error(`mode phải là ${core.MODES.join('|')} (nhận "${want}"). ${usage}`);
454
+ const want = userMode(wantRaw);
455
+ if (!want) throw new Error(`mode phải là ${core.MODES.join('|')} (nhận "${wantRaw}"). ${usage}`);
425
456
  if (cur && was === want) {
426
457
  return { code: 0, out: show({}) || `root ${root}: vẫn ${want} (không đổi, không ghi sự kiện)` };
427
458
  }
@@ -937,6 +968,41 @@ function orchLine(o) {
937
968
  * Orchestration state of one session rebuilt purely from events.jsonl (same builder the SessionStart(compact)
938
969
  * hook uses). Session = --session, else env CLAUDE_CODE_SESSION_ID. Run dirs are pointed to, never read.
939
970
  */
971
+ /** Free slots under a cap (null = cap off). */
972
+ function freeSlots(cap, n) {
973
+ return cap > 0 ? Math.max(0, cap - (Number(n) || 0)) : null;
974
+ }
975
+
976
+ /** `state` line: live agents of this session / machine and free slots under the parallel caps. */
977
+ function liveLine(live) {
978
+ const lim = live.limits;
979
+ const part = (n, cap) => `${n}/${cap > 0 ? cap : 'tắt'}${cap > 0 ? ` (trống ${freeSlots(cap, n)})` : ''}`;
980
+ const tasks = live.agents.filter((a) => a.task_id).slice(0, 6).map((a) => `${a.task_id}:${a.role}`).join(', ');
981
+ return `live: phiên ${part(live.session || 0, lim.session)} · máy ${part(live.machine, lim.machine)}${tasks ? ` — ${tasks}` : ''}`;
982
+ }
983
+
984
+ /**
985
+ * core 0.7.4 (S1): steps of an approved plan (last 7 days) that run self+batch, whose exec has completed, and that have
986
+ * neither a batch verifier verdict (reason_code batch-verify) nor a user verdict yet. Gate 3 must not see them unchecked.
987
+ */
988
+ function batchPendingOf(ctx, events, now) {
989
+ let files = [];
990
+ try { files = fs.readdirSync(ctx.paths.plans).filter((f) => f.endsWith('.json')); } catch (_) { return []; }
991
+ const out = [];
992
+ for (const f of files) {
993
+ const rec = core.readJsonFile(path.join(ctx.paths.plans, f), null);
994
+ if (!rec || !Array.isArray(rec.steps)) continue;
995
+ const fresh = Date.parse(rec.approved_at) > now - 7 * 24 * 3600 * 1000;
996
+ if (!fresh) continue;
997
+ const pending = rec.steps.filter((s) => s && s.verify_plan === 'self+batch' && s.task_id
998
+ && events.some((e) => e.kind === 'complete' && e.task_id === s.task_id)
999
+ && !events.some((e) => e.kind === 'verdict' && e.task_id === s.task_id
1000
+ && (String(e.reason_code || '').startsWith('batch-verify') || e.by === 'user')));
1001
+ if (pending.length) out.push({ root_task_id: rec.root_task_id || f.replace(/\.json$/, ''), steps: pending.map((s) => s.id) });
1002
+ }
1003
+ return out;
1004
+ }
1005
+
940
1006
  function cmdState(opts) {
941
1007
  const ctx = ctxOf(opts);
942
1008
  const sid = typeof opts.session === 'string' && opts.session ? opts.session
@@ -953,10 +1019,217 @@ function cmdState(opts) {
953
1019
  }
954
1020
  const state = core.buildSessionState(core.readEvents(ctx), sid, { plansDir: ctx.paths.plans });
955
1021
  const orphans = orphanCount(ctx); // s16: null (unknown) leaves the line out
1022
+ const live = core.liveSummary(ctx, sid); // core 0.7.3 (s1)
1023
+ const stuck = core.stuckAgents(ctx, { sessionId: sid }); // s6
1024
+ const batchPending = batchPendingOf(ctx, core.readEvents(ctx), Date.now()); // core 0.7.4 (S1)
956
1025
  if (opts.json) {
957
- return { code: 0, out: JSON.stringify(Object.assign({}, state, hasDeltas ? { unmerged_deltas: deltas } : {}, orphans === null ? {} : { orphan_procs: orphans }), null, 2) };
1026
+ const liveJ = {
1027
+ session: live.session, machine: live.machine, limits: live.limits, free_session: freeSlots(live.limits.session, live.session), free_machine: freeSlots(live.limits.machine, live.machine), agents: live.agents,
1028
+ stuck: stuck.map((a) => ({ agent_id: a.agent_id, task_id: a.task_id, role: a.role, gen: a.gen, state: a.state, reasons: a.reasons, idle_min: a.idle_min, age_min: a.age_min })),
1029
+ };
1030
+ return { code: 0, out: JSON.stringify(Object.assign({}, state, hasDeltas ? { unmerged_deltas: deltas } : {}, orphans === null ? {} : { orphan_procs: orphans }, { live: liveJ }, batchPending.length ? { batch_pending: batchPending } : {}), null, 2) };
958
1031
  }
959
- return { code: 0, out: [core.formatSessionState(state, core.STATE_TEXT_CAP)].concat(deltaLines, orphans === null ? [] : [`orphan_procs: ${orphans}`]).join('\n') };
1032
+ const batchLines = batchPending.map((p) => `CẢNH BÁO: ${p.root_task_id}: ${p.steps.length} bước self+batch (${p.steps.join(', ')}) chưa có verdict verifier gộp (batch-verify) — giao verifier gộp (brief H) trước cổng 3`);
1033
+ const stuckLines = stuck.length
1034
+ ? [`stuck: ${stuck.slice(0, 6).map((a) => `${a.task_id || a.agent_id}:${a.role} ${a.state === 'orphan' ? 'mồ côi' : 'kẹt'} ${a.idle_min}'`).join(', ')} → TaskStop rồi \`dieu-phoi reap --yes\``]
1035
+ : [];
1036
+ return { code: 0, out: [core.formatSessionState(state, core.STATE_TEXT_CAP)].concat(deltaLines, orphans === null ? [] : [`orphan_procs: ${orphans}`], [liveLine(live)], stuckLines, batchLines).join('\n') };
1037
+ }
1038
+
1039
+ // ------------------------------------------------------------------ reap (core 0.7.3 s6)
1040
+
1041
+ const posixPath = (p) => String(p || '').replace(/\\/g, '/');
1042
+ const normPath = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
1043
+
1044
+ /**
1045
+ * Child processes attributable to a stuck agent: created after it started, command line inside its cwd (worktree), not a
1046
+ * Claude Code / code-graph / conhost process, not this process' own chain. Only when no other live agent shares that cwd —
1047
+ * otherwise attribution is a guess. s7: `targets` / `skips` = what the guarded kill may take / keeps and why. Pure.
1048
+ */
1049
+ function reapProcsOf(a, list, sharedCwds, selfPid) {
1050
+ if (!Array.isArray(list) || !a.cwd) return { procs: [], attributable: false };
1051
+ const cwd = normPath(a.cwd);
1052
+ if (!cwd || sharedCwds.has(cwd)) return { procs: [], attributable: false };
1053
+ const own = new Set([selfPid, ...procs.ancestors(list, selfPid).map((p) => p.pid)]);
1054
+ const skip = new Set(procs.LEFTOVER_SKIP_NAMES.map((n) => n.toLowerCase()));
1055
+ const hits = list.filter((p) => !own.has(p.pid) && p.created && p.created >= a.start_ms && !skip.has(String(p.name || '').toLowerCase())
1056
+ && normPath(p.cmd).includes(cwd));
1057
+ const ids = new Set(hits.map((p) => p.pid));
1058
+ // s7 guarded: of those, what may be killed — never-kill names, nothing hosting a claude.exe, and under a live claude.exe
1059
+ // only through an agent shell (bash / powershell), never through an MCP server. Below no claude.exe = the agent's shell
1060
+ // died (TaskStop) and left it; the worktree cmd already ties it to this agent.
1061
+ const targets = [];
1062
+ const skips = [];
1063
+ for (const p of hits) {
1064
+ const c = procs.claudeAncestor(list, p.pid);
1065
+ if (c) {
1066
+ const sel = procs.guardedSelect(list, [p], { rootPid: c.pid, exclusive: true });
1067
+ if (sel.kill.length) targets.push(p); else skips.push(sel.skip[0]);
1068
+ } else if (procs.isNeverKill(p)) skips.push({ pid: p.pid, name: p.name, reason: 'never-kill' });
1069
+ else if (procs.hostsClaude(list, p.pid)) skips.push({ pid: p.pid, name: p.name, reason: 'hosts-claude' });
1070
+ else targets.push(p);
1071
+ }
1072
+ return { procs: hits, roots: hits.filter((p) => !ids.has(p.ppid)), attributable: true, targets, skips };
1073
+ }
1074
+
1075
+ /**
1076
+ * Orphans that `reap --orphans` may kill (procs.killableOrphans), and on Windows only those the MSYS table also calls
1077
+ * orphaned (Git-bash exec makes live `bash -c "tail -f"` look parentless). deps.msys = table|null for tests; otherwise
1078
+ * read on win32 only. Returns { found, skipped: [{pid, name, reason}] }.
1079
+ */
1080
+ function orphanTargets(list, now, minMin, d) {
1081
+ const cands = procs.killableOrphans(list, { now, minAgeMs: minMin * 60000 });
1082
+ const useMsys = Object.prototype.hasOwnProperty.call(d || {}, 'msys') || process.platform === 'win32';
1083
+ if (!useMsys || !cands.length) return { found: cands, skipped: [] };
1084
+ const msys = Object.prototype.hasOwnProperty.call(d || {}, 'msys') ? d.msys : procs.msysTable({ hints: cands.map((p) => p.cmd) });
1085
+ const r = procs.msysConfirm(cands, msys);
1086
+ return { found: r.keep, skipped: r.skip };
1087
+ }
1088
+
1089
+ /** `orphan_kill_min` (s7): env DIEU_PHOI_ORPHAN_KILL_MIN → config → 60. Minutes ≥ 1 (0 / junk = the default). */
1090
+ const ORPHAN_KILL_MIN_DEFAULT = 60;
1091
+ function orphanKillMin(ctx) {
1092
+ for (const v of [process.env.DIEU_PHOI_ORPHAN_KILL_MIN, ctx.config && ctx.config.orphan_kill_min]) {
1093
+ if (v === undefined || v === null || v === '') continue;
1094
+ const n = Number(v);
1095
+ if (Number.isFinite(n) && n >= 1) return n;
1096
+ }
1097
+ return ORPHAN_KILL_MIN_DEFAULT;
1098
+ }
1099
+
1100
+ /**
1101
+ * `dieu-phoi reap [--yes] [--session <id>] [--json]`: stuck / orphaned live agents (data/live) of every session on this
1102
+ * DIEU_PHOI_HOME, or one session. Dry-run by default. --yes deletes their live files (frees the parallel-cap slot; a live
1103
+ * agent's next --watch re-registers it), writes one `reap` event each (local only), and kills their child processes only
1104
+ * when attributable to that agent alone: kill_leftovers 'guarded' (default, s7) = reapProcsOf targets, each re-checked
1105
+ * (pid + creation time) right before; true = the 0.7.2 tree kill; false = never. Worktrees are never removed.
1106
+ * --orphans adds tail / sleep / Claude Code shells orphaned for over orphan_kill_min (60) minutes (killed with --yes).
1107
+ * deps (tests): { procs: list|null, relist() -> list (default: procs again), kill(pids) -> pids, now }.
1108
+ */
1109
+ function cmdReap(opts, deps) {
1110
+ const d = deps || {};
1111
+ const ctx = ctxOf(opts);
1112
+ const yes = opts.yes === true || opts.yes === 'y' || opts.yes === 'yes';
1113
+ const sid = typeof opts.session === 'string' && opts.session ? opts.session : null;
1114
+ const now = typeof d.now === 'number' ? d.now : Date.now();
1115
+ const lim = core.stuckLimits(ctx);
1116
+ const all = core.readLive(ctx, now).agents;
1117
+ const cands = core.stuckAgents(ctx, { now, limits: lim, sessionId: sid });
1118
+ const candIds = new Set(cands.map((a) => a.agent_id));
1119
+ // cwd shared with another agent (live and not reaped, or a second candidate) = processes cannot be told apart
1120
+ const cwdCount = new Map();
1121
+ for (const r of all) { const c = normPath(r.cwd); if (c) cwdCount.set(c, (cwdCount.get(c) || 0) + 1); }
1122
+ const shared = new Set(Array.from(cwdCount).filter(([, n]) => n > 1).map(([c]) => c));
1123
+ const wantOrphans = opts.orphans === true;
1124
+ const readList = () => (Object.prototype.hasOwnProperty.call(d, 'procs') ? d.procs : procs.listProcesses({ timeoutMs: 5000 }));
1125
+ const list = cands.length || wantOrphans ? readList() : null;
1126
+ // re-read right before killing (pid reuse); tests pass d.relist (a later process table) or reuse d.procs
1127
+ const relist = typeof d.relist === 'function' ? d.relist : readList;
1128
+ const mode = procs.killMode(ctx.config);
1129
+ const killOn = mode !== 'off';
1130
+ const killVal = procs.killModeValue(mode);
1131
+ const kill = typeof d.kill === 'function' ? d.kill : null;
1132
+ const rows = [];
1133
+ for (const a of cands) {
1134
+ const pr = reapProcsOf(a, list, shared, process.pid);
1135
+ const row = Object.assign({}, a, {
1136
+ procs: pr.procs.map((p) => ({ pid: p.pid, name: p.name })), procs_attributable: pr.attributable,
1137
+ kill_targets: mode === 'guarded' && pr.targets ? pr.targets.map((p) => p.pid) : [],
1138
+ kill_skips: mode === 'guarded' && pr.skips ? pr.skips : [],
1139
+ killed: [], removed: false,
1140
+ });
1141
+ delete row.start_ms; delete row.notified;
1142
+ if (yes) {
1143
+ if (mode === 'tree' && pr.roots && pr.roots.length) {
1144
+ row.killed = (kill ? kill(pr.roots.map((p) => p.pid)) : procs.killTree(pr.roots.map((p) => p.pid), { deadline: Date.now() + 10000 })) || [];
1145
+ } else if (mode === 'guarded' && pr.targets && pr.targets.length) {
1146
+ const res = procs.killVerified(pr.targets, { relist, kill, deadline: Date.now() + 10000 });
1147
+ row.killed = res.killed;
1148
+ row.kill_skips = row.kill_skips.concat(res.skipped);
1149
+ }
1150
+ row.removed = core.removeLive(ctx, a.agent_id);
1151
+ try {
1152
+ core.appendEvent(ctx, Object.assign({
1153
+ schema: core.SCHEMA, kind: 'reap', ts: core.nowIso(), agent_id: a.agent_id, task_id: a.task_id, role: a.role, gen: a.gen,
1154
+ parent_task_id: a.parent_task_id, session: a.session_id ? core.sha8(a.session_id) : null, reap_state: a.state,
1155
+ reasons: a.reasons, idle_min: a.idle_min, age_min: a.age_min, worktree: a.cwd ? posixPath(a.cwd) : null,
1156
+ procs: row.procs.length, killed: row.killed.length, killed_pids: row.killed.slice(0, 20), kill_leftovers: killVal, removed: row.removed,
1157
+ }, row.kill_skips.length ? { kill_skips: row.kill_skips.slice(0, 20).map((s) => ({ pid: s.pid, name: String(s.name || '').toLowerCase(), reason: s.reason })) } : {},
1158
+ core.commonFields(ctx)));
1159
+ } catch (e) { core.logError(ctx.home, e, 'reap-event'); }
1160
+ }
1161
+ rows.push(row);
1162
+ }
1163
+ const ok = all.filter((r) => !candIds.has(r.agent_id) && (!sid || r.session_id === sid)).length;
1164
+ // s7: `--orphans` — tail / sleep / Claude Code shells whose parent is gone, older than orphan_kill_min. Dry-run lists them;
1165
+ // --yes kills each after re-checking pid + creation time (never with kill_leftovers:false). One local event reap_orphans.
1166
+ let orph = null;
1167
+ if (wantOrphans) {
1168
+ const minMin = orphanKillMin(ctx);
1169
+ const ot = Array.isArray(list) ? orphanTargets(list, now, minMin, d) : { found: [], skipped: [] };
1170
+ const found = ot.found;
1171
+ orph = {
1172
+ min_age_min: minMin, listed: Array.isArray(list), n: found.length,
1173
+ procs: found.map((p) => ({ pid: p.pid, name: p.name, age_min: Math.round((now - p.created) / 60000), cmd: p.cmd ? String(p.cmd).slice(0, 100) : null })),
1174
+ killed: [], skipped: [], kept: ot.skipped,
1175
+ };
1176
+ if (yes && killOn && found.length) {
1177
+ const res = procs.killVerified(found, { relist, kill, deadline: Date.now() + Math.max(20000, found.length * 3000) });
1178
+ orph.killed = res.killed;
1179
+ orph.skipped = res.skipped;
1180
+ }
1181
+ if (yes) {
1182
+ try {
1183
+ core.appendEvent(ctx, Object.assign({
1184
+ schema: core.SCHEMA, kind: 'reap_orphans', ts: core.nowIso(), n: found.length, killed: orph.killed.length,
1185
+ killed_pids: orph.killed.slice(0, 50), skipped: orph.skipped.slice(0, 20).map((s) => ({ pid: s.pid, reason: s.reason })),
1186
+ names: Array.from(new Set(found.map((p) => String(p.name || '').toLowerCase()))).slice(0, 8), min_age_min: minMin, kill_leftovers: killVal,
1187
+ }, core.commonFields(ctx)));
1188
+ } catch (e) { core.logError(ctx.home, e, 'reap-orphans-event'); }
1189
+ }
1190
+ }
1191
+ if (opts.json) {
1192
+ return { code: 0, out: JSON.stringify(Object.assign({ dry_run: !yes, limits: lim, kill_leftovers: killVal, procs_listed: Array.isArray(list), live_ok: ok, agents: rows }, orph ? { orphans: orph } : {}), null, 2) };
1193
+ }
1194
+ const head = `reap${yes ? '' : ' (dry-run)'}: ${rows.length} agent kẹt/mồ côi${sid ? ` trong phiên ${core.sha8(sid)}` : ' trên máy'}; ${ok} agent live còn bình thường `
1195
+ + `(ngưỡng: im ${lim.idle || 'tắt'} phút · tổng ${lim.hard || 'tắt'} phút · sau STATUS ${lim.after_status || 'tắt'} phút).`;
1196
+ const lines = [head];
1197
+ for (const r of rows) {
1198
+ const who = `agent ${r.agent_id} task ${r.task_id || '?'} (${r.role || '?'}, gen ${r.gen}${r.session_id ? `, phiên ${core.sha8(r.session_id)}` : ''})`;
1199
+ const procTxt = !Array.isArray(list) ? 'tiến trình: không đọc được bảng process'
1200
+ : (!r.procs_attributable ? 'tiến trình: không quy được về riêng agent này (không có cwd / cwd dùng chung)'
1201
+ : `tiến trình con: ${r.procs.length ? r.procs.slice(0, 6).map((p) => `${p.name} ${p.pid}`).join(', ') + (r.procs.length > 6 ? ` (+${r.procs.length - 6})` : '') : 'không có'}`);
1202
+ const guardTxt = mode === 'guarded' && r.procs.length
1203
+ ? `; sẽ kill ${r.kill_targets.length}${r.kill_targets.length ? ` (${r.kill_targets.slice(0, 6).join(', ')})` : ''}${r.kill_skips.length ? `, giữ ${r.kill_skips.length} [${Array.from(new Set(r.kill_skips.map((s) => s.reason))).join(', ')}]` : ''}`
1204
+ : '';
1205
+ const act = !yes ? '' : ` → ${r.removed ? 'đã xoá file live' : 'file live đã mất'}${r.killed.length ? `, đã kill ${r.killed.length}` : (r.procs.length ? (killOn ? ', không kill được / không có gì quy chắc về agent' : ', không kill (kill_leftovers=false)') : '')}`;
1206
+ lines.push(`- ${who}: ${r.state === 'orphan' ? 'mồ côi' : 'kẹt'} [${core.stuckReasonText(r)}]; chạy ${r.age_min} phút, im ${r.idle_min} phút; ${procTxt}${guardTxt}${r.cwd ? `; worktree ${posixPath(r.cwd)} giữ nguyên` : ''}${act}`);
1207
+ }
1208
+ if (rows.length && !yes) {
1209
+ lines.push('Agent còn sống thì TaskStop <agent_id> trong phiên của nó trước; rồi `dieu-phoi reap --yes` xoá file live (trả chỗ trần) + ghi event reap.'
1210
+ + (mode === 'guarded' ? ' Chỉ tiến trình ghi "sẽ kill" bị kill (kill_leftovers=guarded: kiểm lại pid + giờ tạo ngay trước khi kill).'
1211
+ : mode === 'tree' ? ' Cây tiến trình con quy được về agent sẽ bị kill (kill_leftovers=true).' : ' Tiến trình con chỉ được liệt kê (kill_leftovers=false).'));
1212
+ }
1213
+ if (orph) {
1214
+ const groups = new Map();
1215
+ for (const p of orph.procs) groups.set(String(p.name).toLowerCase(), (groups.get(String(p.name).toLowerCase()) || 0) + 1);
1216
+ const oldest = orph.procs.length ? Math.max(...orph.procs.map((p) => p.age_min)) : 0;
1217
+ lines.push(!orph.listed ? 'orphans: không đọc được bảng process'
1218
+ : `orphans${yes ? '' : ' (dry-run)'}: ${orph.n} tiến trình mồ côi (cha đã thoát) quá ${orph.min_age_min} phút`
1219
+ + (orph.n ? ` — ${Array.from(groups).map(([n, c]) => `${n} ×${c}`).join(', ')}; cũ nhất ${Math.round(oldest / 60)} giờ` : ''));
1220
+ if (orph.kept.length) {
1221
+ const why = new Map();
1222
+ for (const k of orph.kept) why.set(k.reason, (why.get(k.reason) || 0) + 1);
1223
+ lines.push(` giữ ${orph.kept.length} cái trông mồ côi nhưng chưa chắc: ${Array.from(why).map(([r, c]) => `${r} ×${c}`).join(', ')} (msys-parent-alive = bash cha còn sống theo MSYS, việc nền đang chạy)`);
1224
+ }
1225
+ for (const p of orph.procs.slice(0, 10)) lines.push(` ${p.name} ${p.pid} (${p.age_min} phút)${p.cmd ? `: ${p.cmd}` : ''}`);
1226
+ if (orph.procs.length > 10) lines.push(` … +${orph.procs.length - 10}`);
1227
+ if (yes) {
1228
+ lines.push(!killOn ? ' → không kill (kill_leftovers=false)'
1229
+ : ` → đã kill ${orph.killed.length}/${orph.n}${orph.skipped.length ? `; bỏ qua ${orph.skipped.length} [${Array.from(new Set(orph.skipped.map((s) => s.reason))).join(', ')}]` : ''}; event reap_orphans`);
1230
+ } else if (orph.n) lines.push(' `dieu-phoi reap --orphans --yes` để kill (kiểm lại pid + giờ tạo ngay trước khi kill).');
1231
+ }
1232
+ return { code: 0, out: lines.join('\n') };
960
1233
  }
961
1234
 
962
1235
  // ------------------------------------------------------------------ stats / retro
@@ -1142,11 +1415,24 @@ function cmdRetro(opts) {
1142
1415
 
1143
1416
  // ------------------------------------------------------------------ choose
1144
1417
 
1418
+ /**
1419
+ * A user-supplied mode value: fast | normal; the removed `strict` (0.7.4) is accepted with a warning and becomes
1420
+ * normal; anything else null.
1421
+ */
1422
+ function userMode(raw) {
1423
+ if (raw === core.LEGACY_MODE) {
1424
+ process.stderr.write('dieu-phoi: mode strict đã bỏ từ 0.7.4 — dùng normal (sàn an toàn vẫn luôn có verifier độc lập chặn).\n');
1425
+ return core.DEFAULT_MODE;
1426
+ }
1427
+ return core.isMode(raw) ? raw : null;
1428
+ }
1429
+
1145
1430
  /** --mode value (validated) or undefined; a bare --mode is an error. */
1146
1431
  function modeOpt(opts) {
1147
1432
  if (opts.mode === undefined) return undefined;
1148
- if (typeof opts.mode !== 'string' || !core.isMode(opts.mode)) throw new Error(`--mode phải là ${core.MODES.join('|')}`);
1149
- return opts.mode;
1433
+ const m = typeof opts.mode === 'string' ? userMode(opts.mode) : null;
1434
+ if (!m) throw new Error(`--mode phải là ${core.MODES.join('|')}`);
1435
+ return m;
1150
1436
  }
1151
1437
 
1152
1438
  function chooseInput(opts, ctx, events) {
@@ -1184,15 +1470,39 @@ function overrideEvent(ctx, taskId, taskType, suggested, chosen) {
1184
1470
  }
1185
1471
 
1186
1472
  /**
1187
- * core 0.7.0 (s2): mode_suggest for choose (exec only). The root's CURRENT mode decides (a dispatched step keeps its
1188
- * fast mode, but once the user moved the root back there is nothing to suggest). Never changes the mode.
1473
+ * core 0.7.4 (B1): the step of the approved plan (<plans>/<root>.json) whose task_id is taskId, or null. Its rubric
1474
+ * (score, verifiable, reversible, publish, dependents) is what `choose --task` takes the stricter of.
1475
+ */
1476
+ function approvedPlanStep(ctx, taskId) {
1477
+ let files = [];
1478
+ try { files = fs.readdirSync(ctx.paths.plans).filter((f) => f.endsWith('.json')); } catch (_) { return null; }
1479
+ for (const f of files) {
1480
+ const rec = core.readJsonFile(path.join(ctx.paths.plans, f), null);
1481
+ if (!rec || !Array.isArray(rec.steps)) continue;
1482
+ const step = rec.steps.find((s) => s && s.task_id === taskId);
1483
+ if (!step) continue;
1484
+ const dependents = step.dependents === true
1485
+ || rec.steps.some((o) => o && o !== step && Array.isArray(o.depends_on) && o.depends_on.includes(step.id));
1486
+ return Object.assign({}, step, { root: rec.root_task_id || f.replace(/\.json$/, ''), dependents });
1487
+ }
1488
+ return null;
1489
+ }
1490
+
1491
+ /**
1492
+ * core 0.7.4 (B1): the stricter of the CLI flags and the approved step, field by field — publish, dependents, not
1493
+ * reversible, not verifiable, higher score. Mutates `input`; returns the names it raised (empty = flags already as strict).
1494
+ * Omitting a flag can therefore never loosen a step the plan made blocking.
1189
1495
  */
1190
- function chooseModeSuggest(ctx, input, res, mi) {
1191
- const root = mi && mi.root ? mi.root : null;
1192
- const rootEv = root ? core.rootModeFromEvents(input.events, root) : null;
1193
- const mode = rootEv ? rootEv.mode : res.mode;
1194
- const sug = core.modeSuggestion(input.events, root, { mode, taskIds: core.rootTaskIds(core.loadPlans(ctx.paths.plans), root) });
1195
- return { mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason, mode_suggest_root: sug.mode_suggest ? root : null };
1496
+ function applyApprovedStep(input, step) {
1497
+ const raised = [];
1498
+ if (step.publish === true && input.publish !== true) { raised.push('publish'); input.publish = true; }
1499
+ if (step.dependents === true && input.dependents !== true) { raised.push('dependents'); input.dependents = true; }
1500
+ if (step.reversible === false && input.reversible !== false) { raised.push('reversible'); input.reversible = false; }
1501
+ if (step.verifiable === false && input.verifiable !== false) { raised.push('verifiable'); input.verifiable = false; }
1502
+ const planScore = Number(step.score);
1503
+ const flagScore = Number(input.score);
1504
+ if (Number.isFinite(planScore) && Number.isFinite(flagScore) && planScore > flagScore) { raised.push('score'); input.score = planScore; }
1505
+ return raised;
1196
1506
  }
1197
1507
 
1198
1508
  function cmdChoose(opts) {
@@ -1202,6 +1512,8 @@ function cmdChoose(opts) {
1202
1512
  }
1203
1513
  if (opts.task !== undefined) requireTaskId(typeof opts.task === 'string' ? opts.task : '');
1204
1514
  const input = chooseInput(opts, ctx, core.readEvents(ctx));
1515
+ const planStep = input.taskId ? approvedPlanStep(ctx, input.taskId) : null;
1516
+ const raised = planStep ? applyApprovedStep(input, planStep) : [];
1205
1517
  let modeSource = 'flag';
1206
1518
  const mi = input.taskId ? core.taskModeInfo(ctx, input.taskId, input.events) : null;
1207
1519
  if (input.mode === undefined) {
@@ -1215,7 +1527,12 @@ function cmdChoose(opts) {
1215
1527
  }
1216
1528
  const res = chooser.choose(input);
1217
1529
  res.mode_source = modeSource;
1218
- if (res.verify_plan) Object.assign(res, chooseModeSuggest(ctx, input, res, mi));
1530
+ if (planStep) {
1531
+ res.plan_step = { root_task_id: planStep.root, step: planStep.id, verify_plan: planStep.verify_plan || null, raised };
1532
+ if (planStep.verify_plan && res.verify_plan && planStep.verify_plan !== res.verify_plan) {
1533
+ res.plan_deviation = `${planStep.verify_plan} → ${res.verify_plan}`;
1534
+ }
1535
+ }
1219
1536
  const orch = measureOrch(opts, ctx, input.profile);
1220
1537
  res.orch_ctx_tokens = orch.ctx_tokens;
1221
1538
  res.orch_ctx_growth = orch.ctx_growth;
@@ -1237,8 +1554,10 @@ function cmdChoose(opts) {
1237
1554
  }
1238
1555
  if (opts.json) return { code: 0, out: JSON.stringify(res) };
1239
1556
  const typeWarn = res.type_warning && !String(res.reason).includes(res.type_warning) ? `\nCẢNH BÁO: ${res.type_warning}` : '';
1240
- const sugLine = res.mode_suggest ? `\n${core.modeSuggestLine(res.mode_suggest_root, 'fast', res.mode_suggest_reason)}` : '';
1241
- const warn = `${typeWarn}${res.orch_ctx_warn ? `\nCẢNH BÁO: ${orch.advice}` : ''}${sugLine}`;
1557
+ const planWarn = res.plan_step && res.plan_step.raised.length
1558
+ ? `\nKế hoạch đã duyệt (${res.plan_step.root_task_id} bước ${res.plan_step.step}): nâng theo ${res.plan_step.raised.join(', ')}` : '';
1559
+ const deviationWarn = res.plan_deviation ? `\nLỆCH kiểm khỏi kế hoạch đã duyệt: ${res.plan_deviation}` : '';
1560
+ const warn = `${typeWarn}${planWarn}${deviationWarn}${res.orch_ctx_warn ? `\nCẢNH BÁO: ${orch.advice}` : ''}`;
1242
1561
  const est =`~${fmtUsd(res.est_cost_usd)} · ~${fmtNum(res.est_time_min)} phút (${res.est_source === 'data' ? 'dữ liệu' : 'ước lượng thô'})`;
1243
1562
  if (res.action === 'ask_user') {
1244
1563
  return { code: 0, out: `HỎI NGƯỜI DÙNG — ${res.reason}${warn}` };
@@ -1293,7 +1612,7 @@ function buildPlanTable(rawSteps, base) {
1293
1612
  verifiable: s.verifiable === undefined ? true : yesNo(s.verifiable, true),
1294
1613
  reversible: s.reversible === undefined ? true : yesNo(s.reversible, true),
1295
1614
  publish: s.publish === undefined ? false : yesNo(s.publish, false),
1296
- mode: s.mode === undefined || s.mode === null ? null : s.mode,
1615
+ mode: s.mode === undefined || s.mode === null ? null : (userMode(s.mode) || s.mode),
1297
1616
  urgency: s.urgency,
1298
1617
  deps: deps.map(String),
1299
1618
  };
@@ -1322,6 +1641,8 @@ function buildPlanTable(rawSteps, base) {
1322
1641
  const head = {
1323
1642
  step: s.step, title: s.title, type: s.type, task_id: s.task || null, score: Number(s.score),
1324
1643
  verifiable: s.verifiable, reversible: s.reversible, depends_on: s.deps,
1644
+ publish: s.publish === true || base.publish === true,
1645
+ dependents: steps.some((o) => o !== s && o.deps.includes(s.step)),
1325
1646
  mode: s.mode || base.mode || core.DEFAULT_MODE, mode_override: s.mode || null,
1326
1647
  };
1327
1648
  try {
@@ -1387,6 +1708,78 @@ function planTaskIdProblems(plan, root) {
1387
1708
  return problems;
1388
1709
  }
1389
1710
 
1711
+ // core 0.7.4 (s1): TÁCH? — a step that is likely to hand off (score ≥ 6; its task_type × tier has handoff rate ≥ 20%
1712
+ // over ≥ 10 tasks) is flagged in the table so the planner splits it. Advice only: never blocks --approve.
1713
+ const SPLIT_MIN_SCORE = 6;
1714
+ const SPLIT_MIN_RATE = 0.2;
1715
+ const SPLIT_MIN_N = 10;
1716
+
1717
+ /** Model family of a tier or a recorded model id (opus | sonnet | haiku | fable), as choose.cjs matches cells. */
1718
+ function modelFamily(m) {
1719
+ const s = String(m || '').toLowerCase();
1720
+ return ['opus', 'sonnet', 'haiku', 'fable'].find((k) => s.includes(k)) || null;
1721
+ }
1722
+
1723
+ function planSplitWarnings(steps, events) {
1724
+ const samples = agg.handoffSamples(events);
1725
+ const out = [];
1726
+ for (const r of steps) {
1727
+ if (r.action === 'refused' || !r.tier || !(Number(r.score) >= SPLIT_MIN_SCORE)) continue;
1728
+ const type = r.task_type || r.type;
1729
+ const [model, effort] = String(r.tier).split('/');
1730
+ const same = samples.filter((s) => s.task_type === type && modelFamily(s.model) === modelFamily(model) && s.effort === effort);
1731
+ if (same.length < SPLIT_MIN_N) continue;
1732
+ const hand = same.filter((s) => s.handoff);
1733
+ const rate = hand.length / same.length;
1734
+ if (rate < SPLIT_MIN_RATE) continue;
1735
+ const clean = same.filter((s) => !s.handoff);
1736
+ out.push({
1737
+ step: r.step,
1738
+ task_type: type,
1739
+ tier: r.tier,
1740
+ n: same.length,
1741
+ handoff_rate: Math.round(rate * 1000) / 1000,
1742
+ cost_handoff_p50_usd: agg.percentile(hand.map((s) => s.total_cost), 0.5),
1743
+ cost_clean_p50_usd: agg.percentile(clean.map((s) => s.total_cost), 0.5),
1744
+ });
1745
+ }
1746
+ return out;
1747
+ }
1748
+
1749
+ // core 0.7.4 (s2): TIER↑ — a mechanical type whose override sits above the tier choose would pick is flagged
1750
+ // (advice only; --approve is not blocked). The $p50 column and the warning read the same stats cells as `stats`.
1751
+ const TIER_UP_TYPES = ['mechanical-edit', 'extract', 'search', 'test-write'];
1752
+
1753
+ /** The stats cell (buildCells row) of one task_type × tier, or null. */
1754
+ function statCellOf(cells, type, tier) {
1755
+ const [model, effort] = String(tier).split('/');
1756
+ return cells.find((c) => c.task_type === type && modelFamily(c.model) === modelFamily(model) && c.effort === effort) || null;
1757
+ }
1758
+
1759
+ function planTierWarnings(steps, cells) {
1760
+ const out = [];
1761
+ for (const r of steps) {
1762
+ if (r.action === 'refused' || !r.overridden || !r.suggested_tier || !r.tier) continue;
1763
+ if (!TIER_UP_TYPES.includes(r.task_type)) continue;
1764
+ const hi = chooser.parseTier(r.tier);
1765
+ const lo = chooser.parseTier(r.suggested_tier);
1766
+ if (hi === null || lo === null || hi <= lo) continue;
1767
+ const a = statCellOf(cells, r.task_type, r.tier);
1768
+ const b = statCellOf(cells, r.task_type, r.suggested_tier);
1769
+ out.push({
1770
+ step: r.step,
1771
+ task_type: r.task_type,
1772
+ tier: r.tier,
1773
+ suggested_tier: r.suggested_tier,
1774
+ cost_p50_usd: a && a.n ? a.cost_p50_usd : null,
1775
+ suggested_cost_p50_usd: b && b.n ? b.cost_p50_usd : null,
1776
+ accept_rate: a && a.n ? a.accept_rate : null,
1777
+ suggested_accept_rate: b && b.n ? b.accept_rate : null,
1778
+ });
1779
+ }
1780
+ return out;
1781
+ }
1782
+
1390
1783
  /** Mapped ad-hoc labels (bug-fix → debug, …): not an error, but the tag must carry the mapped type. */
1391
1784
  function planTypeNotes(plan) {
1392
1785
  return plan.steps.filter((r) => r.mapped_from).map((r) => `bước ${r.step}: ${r.type_warning}`);
@@ -1444,6 +1837,10 @@ function recordApprovedPlan(ctx, root, stepsFile, plan) {
1444
1837
  steps: plan.steps.map((r) => Object.assign({
1445
1838
  id: r.step, task_id: r.task_id || null, title: r.title === undefined ? null : r.title, type: r.task_type || r.type || null,
1446
1839
  tier: r.action === 'refused' ? null : (r.tier || null), depends_on: r.depends_on,
1840
+ // core 0.7.4 (B1): the rubric `choose --task` takes the stricter of its flags against
1841
+ score: Number.isFinite(Number(r.score)) ? Number(r.score) : null, verifiable: r.verifiable !== false,
1842
+ reversible: r.reversible !== false, publish: r.publish === true, dependents: r.dependents === true,
1843
+ verify_plan: r.verify_plan || null,
1447
1844
  }, r.mode_override ? { mode: r.mode_override } : {})),
1448
1845
  history,
1449
1846
  };
@@ -1485,8 +1882,8 @@ function cmdPlanTable(opts) {
1485
1882
  let fileMode;
1486
1883
  if (raw && !Array.isArray(raw) && typeof raw === 'object' && Array.isArray(raw.steps)) {
1487
1884
  if (raw.mode !== undefined && raw.mode !== null) {
1488
- if (!core.isMode(raw.mode)) throw new Error(`plan-table: "mode" trong file phải là ${core.MODES.join('|')} (nhận "${raw.mode}")`);
1489
- fileMode = raw.mode;
1885
+ fileMode = userMode(raw.mode);
1886
+ if (!fileMode) throw new Error(`plan-table: "mode" trong file phải là ${core.MODES.join('|')} (nhận "${raw.mode}")`);
1490
1887
  }
1491
1888
  raw = raw.steps;
1492
1889
  }
@@ -1503,6 +1900,9 @@ function cmdPlanTable(opts) {
1503
1900
  if (problems.length) process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${problems.join('; ')} (cần sửa trước khi --approve)\n`);
1504
1901
  const typeNotes = planTypeNotes(plan);
1505
1902
  if (typeNotes.length) process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${typeNotes.join('; ')}\n`);
1903
+ const splitWarn = planSplitWarnings(plan.steps, base.events);
1904
+ const statCells = agg.analyze(base.events, {}).cells;
1905
+ const tierWarn = planTierWarnings(plan.steps, statCells);
1506
1906
  const rec = opts.approve ? recordApprovedPlan(ctx, String(opts.root), path.resolve(opts.file), plan) : null;
1507
1907
  let modeLogged = false;
1508
1908
  if (rec && (rootEv ? rootEv.mode !== plan.mode : plan.mode !== core.DEFAULT_MODE)) {
@@ -1513,29 +1913,31 @@ function cmdPlanTable(opts) {
1513
1913
  for (const ev of ovs) core.appendEvent(ctx, ev);
1514
1914
  if (rec) recordPlanSuggestions(ctx, plan);
1515
1915
  if (opts.json) {
1516
- const out = rec ? Object.assign({}, plan, {
1916
+ const steps = plan.steps.map((r) => {
1917
+ const c = r.action === 'refused' || !r.tier ? null : statCellOf(statCells, r.task_type || r.type, r.tier);
1918
+ return Object.assign({}, r, { stats_cost_p50_usd: c && c.n ? c.cost_p50_usd : null, stats_n: c ? c.n : 0 });
1919
+ });
1920
+ const out = Object.assign({}, plan, { steps, split_warn: splitWarn, tier_warn: tierWarn }, rec ? {
1517
1921
  approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file, overrides_logged: ovs.length, mode_logged: modeLogged },
1518
- }) : plan;
1922
+ } : {});
1519
1923
  return { code: 0, out: JSON.stringify(out, null, 2) };
1520
1924
  }
1521
- const lines = ['| Bước | Model/effort | Rubric (điểm · kiểm chứng · đảo ngược) | Lý do | Ước tính | Kiểm |', '|---|---|---|---|---|---|'];
1925
+ const lines = ['| Bước | Model/effort | Rubric (điểm · kiểm chứng · đảo ngược) | Lý do | Ước tính | $p50 | Kiểm |', '|---|---|---|---|---|---|---|'];
1522
1926
  for (const r of plan.steps) {
1523
1927
  const tierCell = r.action === 'refused' ? '—' : (r.action === 'ask_user' ? `${r.tier} (hỏi người dùng)` : r.tier);
1524
1928
  const yn = (v) => (v ? 'y' : 'n');
1525
1929
  const rubric = `${Number.isFinite(r.score) ? r.score : '?'} · ${yn(r.verifiable)} · ${yn(r.reversible)}`;
1526
1930
  const why = `${r.type}${r.depends_on.length ? `, sau ${r.depends_on.join(', ')}` : ''} — ${r.reason.replace(/; profile: .*$/, '')}`;
1527
1931
  const est = r.action === 'refused' ? '-' : `~${fmtUsd(r.est_cost_usd)} · ~${fmtNum(r.est_time_min)} phút${r.est_source === 'rough' ? ' (thô)' : ''}`;
1932
+ const sc = r.action === 'refused' || !r.tier ? null : statCellOf(statCells, r.task_type || r.type, r.tier);
1933
+ const p50 = r.action === 'refused' ? '—' : (sc && sc.n ? `${fmtUsd(sc.cost_p50_usd)} (n ${sc.n})` : '-');
1528
1934
  const kiem = r.action === 'refused' || !r.verify_plan ? '—' : `${r.verify_plan}${r.mode_override ? ` (mode ${r.mode_override})` : ''}`;
1529
- lines.push(`| ${mdCell(r.title ? `${r.step}. ${r.title}` : r.step)} | ${mdCell(tierCell)} | ${mdCell(rubric)} | ${mdCell(why)} | ${mdCell(est)} | ${mdCell(kiem)} |`);
1935
+ lines.push(`| ${mdCell(r.title ? `${r.step}. ${r.title}` : r.step)} | ${mdCell(tierCell)} | ${mdCell(rubric)} | ${mdCell(why)} | ${mdCell(est)} | ${mdCell(p50)} | ${mdCell(kiem)} |`);
1530
1936
  }
1531
1937
  lines.push('');
1532
1938
  lines.push(`**Tổng chi phí ước tính:** ~${fmtUsd(plan.total_cost_usd)} · **Thời gian (đường găng, chạy song song khi không phụ thuộc):** ~${fmtNum(plan.critical_path_min)} phút (cộng dồn ${fmtNum(plan.sum_time_min)} phút)`);
1533
- // Mode được hỏi ở cổng 1 chỉ khi root chưa có mode và kế hoạch có ≥4 bước s≤6 (0.7.2); còn lại không hỏi
1534
- const shortSteps = plan.steps.filter((r) => r.action !== 'refused' && Number.isFinite(r.score) && r.score <= 6).length;
1535
- const modeText = modeChosen ? plan.mode : (shortSteps >= 4
1536
- ? `HỎI người dùng chọn mode (${shortSteps} bước s≤6): fast | normal | strict (mặc định normal); đặt bằng \`dieu-phoi mode --root ${typeof opts.root === 'string' ? opts.root : '<root_task_id>'} <mode>\``
1537
- : 'normal (không hỏi)');
1538
- lines.push(`**Mode:** ${modeText}${plan.self_batch_steps ? ` · Kiểm (fast): bỏ ${plan.self_batch_steps} verifier riêng, 1 verify gộp (cả suite một lần) trước khi trình người dùng` : ''}`);
1939
+ const modeText = modeChosen ? plan.mode : 'normal (mặc định)';
1940
+ lines.push(`**Mode:** ${modeText}${plan.self_batch_steps ? ` · Kiểm (self+batch): bỏ ${plan.self_batch_steps} verifier riêng, 1 verify gộp (test chọn lọc các file cả nhóm; full suite ở cổng phát hành) trước khi trình người dùng` : ''}`);
1539
1941
  if (plan.rough) lines.push('', '_(thô) = ô chưa đủ 5 task, ước lượng theo giả định token của từng tier._');
1540
1942
  if (plan.refused.length) {
1541
1943
  lines.push('', `**Cảnh báo:** ${plan.refused.length} bước bị từ chối (${plan.refused.map((x) => x.step).join(', ')}), không tính vào tổng — sửa rồi chạy lại.`);
@@ -1543,11 +1945,18 @@ function cmdPlanTable(opts) {
1543
1945
  `);
1544
1946
  }
1545
1947
  if (typeNotes.length) lines.push('', `**Loại task:** ${typeNotes.join('; ')}`);
1948
+ for (const w of splitWarn) {
1949
+ lines.push('', `TÁCH?: ${w.step} — ${w.task_type} ${w.tier} handoff ${Math.round(w.handoff_rate * 100)}% (${w.n}), median ${fmtUsd(w.cost_handoff_p50_usd)} có handoff / ${fmtUsd(w.cost_clean_p50_usd)} không → cân nhắc tách thành ≥2 bước nhỏ hơn`);
1950
+ }
1951
+ const fmtPct = (v) => (v === null || v === undefined ? '-' : `${Math.round(v * 100)}%`);
1952
+ for (const w of tierWarn) {
1953
+ lines.push('', `TIER↑: ${w.step} ${w.tier} vs đề xuất ${w.suggested_tier} ($p50 ${fmtUsd(w.cost_p50_usd)} vs ${fmtUsd(w.suggested_cost_p50_usd)}, accept ${fmtPct(w.accept_rate)} vs ${fmtPct(w.suggested_accept_rate)}) — tier cao hơn choose đề xuất, kiểm lý do trước khi duyệt`);
1954
+ }
1546
1955
  if (rec) lines.push('', `Đã ghi kế hoạch đã duyệt (root ${rec.root_task_id}, lần duyệt ${rec.approval})${ovs.length ? `; ghi ${ovs.length} override` : ''}${modeLogged ? `; ghi mode ${plan.mode}` : ''}`);
1547
1956
  return { code: 0, out: lines.join('\n') };
1548
1957
  }
1549
1958
 
1550
1959
  module.exports = {
1551
- ORCH_NEW_SESSION_TOKENS, ORCH_NEW_SESSION_COMPACTIONS, skillDirOf, cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, recordPlanSuggestions, cmdStats, cmdRetro, cmdPrune: (o) => require('./prune.cjs').cmdPrune(o), cmdChoose, cmdMode, modeEvent, cmdCtx, cmdState, orphanCheck, orphanCount, measureOrch,
1960
+ ORCH_NEW_SESSION_TOKENS, ORCH_NEW_SESSION_COMPACTIONS, skillDirOf, cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, recordPlanSuggestions, cmdStats, cmdRetro, cmdPrune: (o) => require('./prune.cjs').cmdPrune(o), cmdChoose, cmdMode, modeEvent, cmdCtx, cmdState, cmdReap, reapProcsOf, orphanKillMin, orphanCheck, orphanCount, liveCheck, liveLine, freeSlots, measureOrch,
1552
1961
  cmdPlanTable, cmdNewId, requireTaskId, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
1553
1962
  };