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