@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/CHANGELOG.md +33 -0
- package/agents/dp-exec.md +6 -1
- package/agents/dp-helper.md +6 -0
- package/agents/dp-scout.md +7 -1
- package/agents/dp-verify.md +6 -0
- package/bin/dieu-phoi.cjs +29 -13
- package/hook.cjs +854 -154
- package/lib/aggregate.cjs +20 -2
- package/lib/args.cjs +1 -1
- package/lib/cbm.cjs +8 -2
- package/lib/choose.cjs +27 -22
- package/lib/commands.cjs +448 -39
- package/lib/deps.cjs +162 -0
- package/lib/knowledge.cjs +47 -4
- package/lib/load.cjs +332 -0
- package/lib/procs.cjs +200 -1
- package/lib/prune.cjs +91 -8
- package/lib/setup.cjs +28 -5
- package/lib/worktree.cjs +250 -19
- package/package.json +1 -1
- package/profile/lessons.md +2 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
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.
|
|
1188
|
-
*
|
|
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
|
|
1191
|
-
const
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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 (
|
|
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
|
|
1241
|
-
|
|
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
|
-
|
|
1489
|
-
fileMode
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
1534
|
-
|
|
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
|
};
|