@kienntpixon/dieu-phoi 0.7.0 → 0.7.2
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 +57 -0
- package/README.md +3 -0
- package/agents/dp-exec.md +2 -0
- package/agents/dp-helper.md +2 -0
- package/agents/dp-scout.md +2 -0
- package/agents/dp-verify.md +2 -0
- package/bin/dieu-phoi.cjs +14 -2
- package/hook.cjs +788 -34
- package/lib/aggregate.cjs +49 -6
- package/lib/args.cjs +1 -1
- package/lib/choose.cjs +41 -25
- package/lib/commands.cjs +212 -42
- package/lib/knowledge.cjs +77 -22
- package/lib/procs.cjs +223 -0
- package/lib/profile-scan.cjs +85 -0
- package/lib/profile-seed.cjs +147 -0
- package/lib/profile.cjs +141 -1
- package/lib/prune.cjs +56 -8
- package/lib/setup.cjs +47 -15
- package/lib/worktree.cjs +186 -0
- package/package.json +3 -1
- package/profile/lessons.md +83 -0
- package/profile/unity-lanes.md +316 -0
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.7.
|
|
29
|
+
const CORE_VERSION = '0.7.2';
|
|
30
30
|
const SCHEMA = 1;
|
|
31
31
|
const TASK_TYPES = [
|
|
32
32
|
'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
|
|
@@ -270,6 +270,12 @@ function parseTag(text) {
|
|
|
270
270
|
tag.role = mm[1];
|
|
271
271
|
} else if ((mm = /^parent=([A-Za-z0-9_-]{1,32})$/.exec(p))) {
|
|
272
272
|
tag.parent_task_id = mm[1]; // core 0.5.0: gen-2 helper -> the dp-exec step that spawned it
|
|
273
|
+
} else if (p === 'chain_ok') {
|
|
274
|
+
tag.chain_ok = true; // core 0.7.1 (C1): the user agreed to continue a long handoff chain
|
|
275
|
+
} else if (p === 'session_ok') {
|
|
276
|
+
tag.session_ok = true; // core 0.7.2 (K5): the user agreed to open a new root in an oversized session
|
|
277
|
+
} else if (p === 'batch') {
|
|
278
|
+
tag.batch = true; // core 0.7.1 (C5): the one batched verifier of a fast request
|
|
273
279
|
}
|
|
274
280
|
}
|
|
275
281
|
return tag;
|
|
@@ -311,6 +317,27 @@ function taskIdError(id) {
|
|
|
311
317
|
|
|
312
318
|
const DEFAULT_EXTRA_TYPES = ['art-gen'];
|
|
313
319
|
|
|
320
|
+
// Ad-hoc labels seen in real logs → the closest fixed type (core 0.4.0 in choose; core 0.7.1: shared with the hook so a
|
|
321
|
+
// dispatch tag with such a label is mapped too, tag_issue=aliased instead of bad-type). A label maps only when its target
|
|
322
|
+
// is known (fixed list or the profile's extra_task_types), e.g. art-fix → art-gen needs art-gen in the profile.
|
|
323
|
+
const TYPE_ALIASES = {
|
|
324
|
+
'bug-fix': 'debug', bugfix: 'debug', fix: 'debug', bug: 'debug',
|
|
325
|
+
config: 'mechanical-edit', shell: 'mechanical-edit', deploy: 'mechanical-edit', ops: 'mechanical-edit', edit: 'mechanical-edit',
|
|
326
|
+
docs: 'content-write', doc: 'content-write', documentation: 'content-write', writing: 'content-write', copy: 'content-write',
|
|
327
|
+
design: 'plan-design', plan: 'plan-design', planning: 'plan-design', architecture: 'plan-design',
|
|
328
|
+
research: 'research-synth', synth: 'research-synth',
|
|
329
|
+
test: 'test-write', tests: 'test-write',
|
|
330
|
+
analysis: 'data-analysis', data: 'data-analysis',
|
|
331
|
+
impl: 'feature-impl', feature: 'feature-impl', implement: 'feature-impl',
|
|
332
|
+
'code-review': 'review',
|
|
333
|
+
'art-fix': 'art-gen', art: 'art-gen',
|
|
334
|
+
// retro 2026-10-26 (C2): scout/read labels
|
|
335
|
+
scan: 'search', read: 'search', 'code-read': 'search', explore: 'search', scout: 'search', lookup: 'search',
|
|
336
|
+
// core 0.7.2 (K6, retro 2026-11-05): labels that still arrived as bad-type in the 11-05 logs
|
|
337
|
+
'test-run': 'test-write', 'test-verify': 'test-write', 'device-qa': 'test-write',
|
|
338
|
+
'git-ops': 'mechanical-edit', 'release-build': 'mechanical-edit', 'read-scan': 'search',
|
|
339
|
+
};
|
|
340
|
+
|
|
314
341
|
/** Fixed task types + the profile's extra_task_types (art-gen when no profile is readable). */
|
|
315
342
|
function knownTaskTypes(ctx) {
|
|
316
343
|
const out = TASK_TYPES.concat(DEFAULT_EXTRA_TYPES);
|
|
@@ -350,10 +377,19 @@ function resolveTag(text, known) {
|
|
|
350
377
|
// an id, not a label: id format, not a known type, and a digit or a run-file / task= mention ("planning" is a label)
|
|
351
378
|
const named = new RegExp(`(?:dp-out-|handoff-|task=)${escRe(head)}(?![a-z0-9])`).test(body.slice(first.length));
|
|
352
379
|
const idLike = TASK_ID_RE.test(head) && !types.includes(head) && (/\d/.test(head) || named);
|
|
380
|
+
let aliased = null;
|
|
353
381
|
if (idLike) issues.push('type-is-id');
|
|
354
382
|
else if (!types.includes(head)) {
|
|
355
|
-
|
|
356
|
-
|
|
383
|
+
const target = Object.prototype.hasOwnProperty.call(TYPE_ALIASES, head) ? TYPE_ALIASES[head] : null;
|
|
384
|
+
if (target && types.includes(target)) {
|
|
385
|
+
// core 0.7.1 (C2): a known ad-hoc label maps to its fixed type; the data pools instead of being lost
|
|
386
|
+
issues.push('aliased');
|
|
387
|
+
aliased = target;
|
|
388
|
+
tag.type_alias_from = parts[0];
|
|
389
|
+
} else {
|
|
390
|
+
issues.push('bad-type');
|
|
391
|
+
tag.bad_type = parts[0]; // the offending token (task_type may be replaced by a valid altType below)
|
|
392
|
+
}
|
|
357
393
|
}
|
|
358
394
|
if (!tag.task_id) {
|
|
359
395
|
issues.push('missing-task');
|
|
@@ -362,7 +398,8 @@ function resolveTag(text, known) {
|
|
|
362
398
|
tag.task_id_inferred = true;
|
|
363
399
|
}
|
|
364
400
|
}
|
|
365
|
-
if (
|
|
401
|
+
if (aliased) tag.task_type = aliased;
|
|
402
|
+
else if (idLike || tag.task_type === 'untagged') tag.task_type = altType || 'untagged';
|
|
366
403
|
else if (!types.includes(tag.task_type) && altType) tag.task_type = altType;
|
|
367
404
|
if (issues.length) tag.tag_issue = issues.join('+');
|
|
368
405
|
return tag;
|
|
@@ -375,6 +412,7 @@ function tagIssueMessage(tag, known) {
|
|
|
375
412
|
const codes = tag.tag_issue.split('+');
|
|
376
413
|
if (codes.includes('no-tag')) what.push('agent dp-* được giao mà không có thẻ `[dp:...]` ở dòng đầu prompt');
|
|
377
414
|
if (codes.includes('type-is-id')) what.push('ô đầu của thẻ là một task id, không phải loại task');
|
|
415
|
+
if (codes.includes('aliased')) what.push(`loại "${tag.type_alias_from}" không cố định — hook đã ghi thành "${tag.task_type}" (dùng đúng loại này trong thẻ)`);
|
|
378
416
|
if (codes.includes('bad-type')) what.push(`loại "${tag.bad_type || tag.task_type}" không nằm trong danh sách loại hợp lệ`);
|
|
379
417
|
if (codes.includes('missing-task')) what.push('thiếu `task=<id>`');
|
|
380
418
|
const got = tag.task_id_inferred
|
|
@@ -601,7 +639,7 @@ function summarizeTranscript(lines, opts) {
|
|
|
601
639
|
// ------------------------------------------------------------------ orchestrator (main session) usage
|
|
602
640
|
|
|
603
641
|
const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
604
|
-
const ORCH_CACHE_V = 2; // 2: + compactions
|
|
642
|
+
const ORCH_CACHE_V = 2; // 2: + compactions (0.7.2: a cold-probe cache with compactions null is re-probed, see orchestratorUsage)
|
|
605
643
|
const ORCH_CHUNK = 1 << 20; // read granularity; memory = chunk + longest line
|
|
606
644
|
const ORCH_SEEN_KEEP = 64; // requestIds kept for last-line-wins dedupe across scans
|
|
607
645
|
const ORCH_HEAD_BYTES = 4096; // fingerprint of the file start (rotation / replacement check)
|
|
@@ -649,8 +687,9 @@ function ctxOfBuckets(b) {
|
|
|
649
687
|
* passed to onLine (return true to stop). Returns { consumed: byte just past the last complete line
|
|
650
688
|
* handled, read: bytes read }.
|
|
651
689
|
*/
|
|
652
|
-
function scanUsageLines(fd, start, end, onLine) {
|
|
690
|
+
function scanUsageLines(fd, start, end, onLine, needle) {
|
|
653
691
|
const buf = Buffer.allocUnsafe(ORCH_CHUNK);
|
|
692
|
+
const one = needle || null; // null: usage or compaction lines; else only lines holding `needle`
|
|
654
693
|
let carry = null;
|
|
655
694
|
let pos = start;
|
|
656
695
|
let consumed = start;
|
|
@@ -667,8 +706,9 @@ function scanUsageLines(fd, start, end, onLine) {
|
|
|
667
706
|
while ((nl = data.indexOf(0x0a, s)) >= 0) {
|
|
668
707
|
const line = data.subarray(s, nl);
|
|
669
708
|
s = nl + 1;
|
|
670
|
-
|
|
671
|
-
|
|
709
|
+
const hit = one ? line.indexOf(one) >= 0
|
|
710
|
+
: (line.indexOf(USAGE_NEEDLE) >= 0 || line.indexOf(COMPACT_NEEDLE) >= 0);
|
|
711
|
+
if (hit && onLine(line.toString('utf8')) === true) {
|
|
672
712
|
return { consumed: base + s, read };
|
|
673
713
|
}
|
|
674
714
|
}
|
|
@@ -739,13 +779,22 @@ function edgeProbe(fd, size, st) {
|
|
|
739
779
|
}
|
|
740
780
|
st.cum_known = false;
|
|
741
781
|
st.requests = null;
|
|
742
|
-
st.
|
|
782
|
+
st.offset = lastNl >= 0 ? lastNl + 1 : 0;
|
|
783
|
+
// core 0.7.2 (K5): count compactions over [0, offset) too (byte scan, JSON only on hits). The incremental
|
|
784
|
+
// scan takes over from offset, so nothing is counted twice; before 0.7.2 this stayed null for the session.
|
|
785
|
+
st.compactions = countCompactions(fd, st.offset);
|
|
743
786
|
if (first) {
|
|
744
787
|
st.baseline = ctxOfBuckets(usageBuckets(first.usage));
|
|
745
788
|
st.baseline_req = first.key;
|
|
746
789
|
}
|
|
747
790
|
if (last) st.last_ctx = ctxOfBuckets(usageBuckets(last.usage));
|
|
748
|
-
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Main-thread compaction markers in [0, end) (sidechain and look-alike text ignored). Never throws. */
|
|
794
|
+
function countCompactions(fd, end) {
|
|
795
|
+
let n = 0;
|
|
796
|
+
if (end > 0) scanUsageLines(fd, 0, end, (t) => { const u = parseMainLine(t); if (u && u.compact) n++; return false; }, COMPACT_NEEDLE);
|
|
797
|
+
return n;
|
|
749
798
|
}
|
|
750
799
|
|
|
751
800
|
/** Drop stale session caches. Only ever touches <data>/orch/*.json (never another directory). */
|
|
@@ -781,8 +830,9 @@ function orchCacheFile(paths, sessionId, transcriptPath) {
|
|
|
781
830
|
* opts.cacheFile: per-session state ({offset, cost, seen requestIds, baseline, …}); each call then parses
|
|
782
831
|
* only the bytes appended since the last call. A shrunk or replaced file (offset > size or a different
|
|
783
832
|
* head fingerprint) is recomputed from 0.
|
|
784
|
-
* opts.coldMaxBytes: no usable cache and the file is larger -> head/tail probe
|
|
785
|
-
*
|
|
833
|
+
* opts.coldMaxBytes: no usable cache and the file is larger -> head/tail probe (ctx + baseline, cum_usd null)
|
|
834
|
+
* plus a byte scan of the compaction markers before the probe offset; a later call without the limit
|
|
835
|
+
* (`dieu-phoi ctx`) recomputes the full sum.
|
|
786
836
|
* Reads in chunks (memory = chunk + longest line), never the whole file at once. Never throws.
|
|
787
837
|
*/
|
|
788
838
|
function orchestratorUsage(transcriptPath, pricing, opts) {
|
|
@@ -801,7 +851,8 @@ function orchestratorUsage(transcriptPath, pricing, opts) {
|
|
|
801
851
|
const cached = o.cacheFile ? readJsonFile(o.cacheFile, null) : null;
|
|
802
852
|
if (cached && cached.v === ORCH_CACHE_V && Number.isFinite(cached.offset) && cached.offset <= size
|
|
803
853
|
&& cached.head_len <= size && Array.isArray(cached.seen)
|
|
804
|
-
&& (cached.cum_known || o.coldMaxBytes)) {
|
|
854
|
+
&& (cached.cum_known || (o.coldMaxBytes && typeof cached.compactions === 'number'))) {
|
|
855
|
+
// a probe state written before 0.7.2 holds compactions null: not reused, so the probe counts it (cum_known kept)
|
|
805
856
|
const now = cached.head_len === fp.head_len ? fp : (() => {
|
|
806
857
|
const b = Buffer.alloc(cached.head_len);
|
|
807
858
|
if (cached.head_len) fs.readSync(fd, b, 0, cached.head_len, 0);
|
|
@@ -1151,7 +1202,8 @@ function buildSessionState(events, sessionId, opts) {
|
|
|
1151
1202
|
const id = ev.task_id || agentTask.get(ev.agent_id);
|
|
1152
1203
|
if (per.has(id)) per.get(id).steers.push(ev);
|
|
1153
1204
|
}
|
|
1154
|
-
|
|
1205
|
+
// core 0.7.2 (K4): an implicit override is a recorded deviation, not the user's choice: it never moves the approved tier
|
|
1206
|
+
if (ev.kind === 'override' && ev.source !== 'implicit' && per.has(ev.task_id)) per.get(ev.task_id).overrides.push(ev);
|
|
1155
1207
|
}
|
|
1156
1208
|
const byTime = (a, b) => evMs(a) - evMs(b) || a._i - b._i;
|
|
1157
1209
|
const tasks = [];
|
|
@@ -1255,7 +1307,8 @@ function buildSessionState(events, sessionId, opts) {
|
|
|
1255
1307
|
mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason,
|
|
1256
1308
|
};
|
|
1257
1309
|
});
|
|
1258
|
-
|
|
1310
|
+
const orphans = findOrphanAgents(events, taskIds.filter((id) => !discarded.has(id)), { nowMs: o.nowMs });
|
|
1311
|
+
return Object.assign({
|
|
1259
1312
|
session_id: sessionId,
|
|
1260
1313
|
generated_at: nowIso(),
|
|
1261
1314
|
runs_dir: `${runs}/`,
|
|
@@ -1267,7 +1320,85 @@ function buildSessionState(events, sessionId, opts) {
|
|
|
1267
1320
|
steps: p.steps.length, run_dir: `${runs}/${p.root_task_id}/`,
|
|
1268
1321
|
})),
|
|
1269
1322
|
tasks,
|
|
1270
|
-
};
|
|
1323
|
+
}, orphans.length ? { orphans } : {});
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// retro 2026-11-05 K2: orphan agents. An exec/verify/plan agent whose LAST segment ended without a STATUS:/VERDICT: line
|
|
1327
|
+
// (`complete.ended_without_status`; older events: status "none"), or that never reported ending, and that has no agent
|
|
1328
|
+
// verdict, no newer dispatch of the same task+role and no user/auto accept, is listed so the orchestrator re-dispatches
|
|
1329
|
+
// (try+1) or drops it instead of waiting forever. Pure function of the events; never reads run-dir files.
|
|
1330
|
+
const ORPHAN_MIN_MS = 15 * 60 * 1000;
|
|
1331
|
+
// An agent with no `complete` at all may simply still be running in the background (state is rebuilt right after /compact):
|
|
1332
|
+
// only after this long is it called orphaned, and the line says to ignore it when the agent is still alive.
|
|
1333
|
+
const ORPHAN_NO_COMPLETE_MS = 2 * 3600 * 1000;
|
|
1334
|
+
const ORPHAN_MAX_LINES = 6;
|
|
1335
|
+
|
|
1336
|
+
function findOrphanAgents(events, taskIds, opts) {
|
|
1337
|
+
const now = (opts && opts.nowMs) || Date.now();
|
|
1338
|
+
const want = new Set(taskIds || []);
|
|
1339
|
+
const seenAgent = new Set();
|
|
1340
|
+
const latest = new Map(); // "task|role" -> newest dispatch
|
|
1341
|
+
const lastComplete = new Map();
|
|
1342
|
+
const judged = new Set(); // agent ids with any verdict
|
|
1343
|
+
const accepted = new Set();
|
|
1344
|
+
for (const ev of events) {
|
|
1345
|
+
if (!ev || typeof ev !== 'object') continue;
|
|
1346
|
+
if (ev.kind === 'dispatch' && want.has(ev.task_id) && !seenAgent.has(ev.agent_id)) {
|
|
1347
|
+
const role = ev.role || 'exec';
|
|
1348
|
+
if (!RESULT_ROLES.includes(role)) continue;
|
|
1349
|
+
seenAgent.add(ev.agent_id);
|
|
1350
|
+
latest.set(`${ev.task_id}|${role}`, ev);
|
|
1351
|
+
} else if (ev.kind === 'complete') {
|
|
1352
|
+
lastComplete.set(ev.agent_id, ev);
|
|
1353
|
+
} else if (ev.kind === 'verdict') {
|
|
1354
|
+
if (ev.agent_id) judged.add(ev.agent_id);
|
|
1355
|
+
if (ev.task_id && ev.outcome === 'accept') accepted.add(ev.task_id);
|
|
1356
|
+
else if (ev.task_id && (ev.outcome === 'reject' || ev.outcome === 'rework')) accepted.delete(ev.task_id);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const out = [];
|
|
1360
|
+
for (const d of latest.values()) {
|
|
1361
|
+
if (judged.has(d.agent_id) || accepted.has(d.task_id)) continue;
|
|
1362
|
+
const c = lastComplete.get(d.agent_id);
|
|
1363
|
+
let reason;
|
|
1364
|
+
let since;
|
|
1365
|
+
if (c) {
|
|
1366
|
+
const ended = c.ended_without_status === true || (c.ended_without_status === undefined && c.status === 'none');
|
|
1367
|
+
if (!ended) continue;
|
|
1368
|
+
since = evMs(c);
|
|
1369
|
+
if (!since || now - since < ORPHAN_MIN_MS) continue;
|
|
1370
|
+
reason = c.inline_result === true ? 'inline' : 'no-status';
|
|
1371
|
+
} else {
|
|
1372
|
+
since = evMs(d);
|
|
1373
|
+
if (!since || now - since < ORPHAN_NO_COMPLETE_MS) continue;
|
|
1374
|
+
reason = 'no-complete';
|
|
1375
|
+
}
|
|
1376
|
+
out.push({
|
|
1377
|
+
task_id: d.task_id, agent_id: d.agent_id, role: d.role || 'exec', try: Number(d.try) || 1, task_type: d.task_type || null,
|
|
1378
|
+
score: Number.isFinite(d.score) ? d.score : null, urgency: d.urgency || 'normal', reason, age_min: Math.round((now - since) / 60000),
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
return out;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const ORPHAN_REASON_TEXT = {
|
|
1385
|
+
'no-status': 'kết thúc mà báo cáo cuối không có dòng STATUS:/VERDICT:',
|
|
1386
|
+
inline: 'không ghi file dp-out (hook chỉ lưu .inline.md) và không có dòng STATUS:/VERDICT:',
|
|
1387
|
+
'no-complete': 'không thấy kết thúc (nếu agent còn chạy nền thì bỏ qua dòng này)',
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
/** Lines telling the orchestrator what to do with each orphan agent (at most ORPHAN_MAX_LINES). [] when none. */
|
|
1391
|
+
function orphanLines(orphans) {
|
|
1392
|
+
const list = Array.isArray(orphans) ? orphans : [];
|
|
1393
|
+
if (!list.length) return [];
|
|
1394
|
+
const lines = [`agent mồ côi: ${list.length} lượt giao chưa có kết quả dùng được, chưa verdict, chưa giao lại — đọc dp-out/.inline.md của bước (nếu có) rồi giao lại hoặc bỏ:`];
|
|
1395
|
+
for (const x of list.slice(0, ORPHAN_MAX_LINES)) {
|
|
1396
|
+
lines.push(` - ${x.task_id} [${x.task_type || '?'}] ${x.role} try${x.try} (${x.agent_id}) — ${ORPHAN_REASON_TEXT[x.reason] || x.reason}, ${x.age_min} phút trước; `
|
|
1397
|
+
+ `giao lại: [dp:${x.task_type || '<type>'}|s${x.score === null ? '<n>' : x.score}|${x.urgency}|try${x.try + 1}|task=${x.task_id}|role=${x.role}]; `
|
|
1398
|
+
+ `bỏ: dieu-phoi verdict ${x.agent_id} fail --reason ${x.reason === 'no-complete' ? 'no-complete' : 'ended-without-status'}`);
|
|
1399
|
+
}
|
|
1400
|
+
if (list.length > ORPHAN_MAX_LINES) lines.push(` (+${list.length - ORPHAN_MAX_LINES} nữa — xem đủ: dieu-phoi state --json, khoá orphans)`);
|
|
1401
|
+
return lines;
|
|
1271
1402
|
}
|
|
1272
1403
|
|
|
1273
1404
|
function toPosix(p) {
|
|
@@ -1329,7 +1460,8 @@ function stateCounts(state) {
|
|
|
1329
1460
|
/** One short Vietnamese line shown to the user after /compact re-injected the state. */
|
|
1330
1461
|
function compactSystemMessage(state) {
|
|
1331
1462
|
const c = stateCounts(state);
|
|
1332
|
-
|
|
1463
|
+
const orph = Array.isArray(state.orphans) && state.orphans.length ? `, ${state.orphans.length} agent mồ côi` : '';
|
|
1464
|
+
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${orph})`;
|
|
1333
1465
|
}
|
|
1334
1466
|
|
|
1335
1467
|
/** "\nMode: <root>=<mode> …" for the roots of a session (last 6); '' when the state carries none. */
|
|
@@ -1360,7 +1492,8 @@ function formatSessionState(state, cap) {
|
|
|
1360
1492
|
const render = (omitted) => {
|
|
1361
1493
|
const body = stateBody(state, kept);
|
|
1362
1494
|
const tail = omitted ? `\n(bỏ bớt ${omitted} task cũ nhất để vừa ${limit} ký tự; xem đủ: dieu-phoi state --json)` : '';
|
|
1363
|
-
|
|
1495
|
+
const orph = orphanLines(state.orphans);
|
|
1496
|
+
return `${header}\n${body.join('\n')}${tail}${orph.length ? `\n${orph.join('\n')}` : ''}`;
|
|
1364
1497
|
};
|
|
1365
1498
|
let omitted = 0;
|
|
1366
1499
|
let text = render(0);
|
|
@@ -1545,11 +1678,14 @@ function startInfo(ctx, cwd) {
|
|
|
1545
1678
|
};
|
|
1546
1679
|
const k = startKnowledgeOf(cwd);
|
|
1547
1680
|
if (k) info.knowledge = k;
|
|
1681
|
+
const d = scanUnmergedDeltasFor(ctx);
|
|
1682
|
+
if (hasUnmergedDeltas(d)) info.unmerged_deltas = d;
|
|
1548
1683
|
return info;
|
|
1549
1684
|
}
|
|
1550
1685
|
|
|
1551
1686
|
function knowledgeLines(info) {
|
|
1552
|
-
|
|
1687
|
+
const own = info && info.knowledge && Array.isArray(info.knowledge.lines) ? info.knowledge.lines : [];
|
|
1688
|
+
return own.concat(unmergedDeltaLines(info && info.unmerged_deltas));
|
|
1553
1689
|
}
|
|
1554
1690
|
|
|
1555
1691
|
function orchLessonLines(info) {
|
|
@@ -1633,6 +1769,12 @@ function recordSuggestion(ctx, rec) {
|
|
|
1633
1769
|
source: prev && prev.suggested_tier ? prev.source : (rec.source || 'choose'),
|
|
1634
1770
|
ts: nowIso(),
|
|
1635
1771
|
};
|
|
1772
|
+
// core 0.7.1 (retro 2026-10-26, C3): why choose answered that tier, copied onto the dispatch as choose_*
|
|
1773
|
+
for (const [k, ok] of [['choose_task_type', (v) => typeof v === 'string' && v.length <= 40],
|
|
1774
|
+
['choose_score', (v) => typeof v === 'number' && isFinite(v)], ['explored', (v) => typeof v === 'boolean'],
|
|
1775
|
+
['reason_code', (v) => typeof v === 'string' && v.length <= 24], ['thompson_score', (v) => typeof v === 'number' && isFinite(v)]]) {
|
|
1776
|
+
if (ok(rec[k])) out[k] = rec[k];
|
|
1777
|
+
}
|
|
1636
1778
|
try {
|
|
1637
1779
|
fs.mkdirSync(ctx.paths.suggest, { recursive: true });
|
|
1638
1780
|
writeFileAtomic(file, `${JSON.stringify(out)}\n`);
|
|
@@ -1666,11 +1808,35 @@ function pruneSuggest(ctx) {
|
|
|
1666
1808
|
// ------------------------------------------------------------------ core 0.5.3: handoff chains (retro 2026-10-22)
|
|
1667
1809
|
//
|
|
1668
1810
|
// 15/63 feature-impl tasks handed off >= 2 times (19% of feature-impl cost); one chain ran 10 handoffs ($70) and
|
|
1669
|
-
// failed.
|
|
1811
|
+
// failed. Core 0.7.1 (retro 2026-10-26, C1): the warning moves to the 2nd handoff (matching the lesson "stop after the
|
|
1812
|
+
// 2nd"), and from the 3rd the global PreToolUse(Agent) gate denies another exec continuation unless the tag has chain_ok.
|
|
1670
1813
|
|
|
1671
|
-
const HANDOFF_CHAIN_WARN =
|
|
1814
|
+
const HANDOFF_CHAIN_WARN = 2;
|
|
1815
|
+
const HANDOFF_CHAIN_DENY = 3;
|
|
1672
1816
|
|
|
1673
1817
|
/** Number of agents whose `complete` event has handoff:true for this task (substring scan of events.jsonl). */
|
|
1818
|
+
/**
|
|
1819
|
+
* Core 0.7.2 (K3/K5): whether events.jsonl holds a dispatch for any task in `taskIds` (optionally only of `role`).
|
|
1820
|
+
* Same substring scan as countTaskHandoffs: rare path, one read of the file.
|
|
1821
|
+
*/
|
|
1822
|
+
function hasDispatchFor(ctx, taskIds, role) {
|
|
1823
|
+
let txt;
|
|
1824
|
+
try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { return false; }
|
|
1825
|
+
for (const id of taskIds) {
|
|
1826
|
+
if (!id) continue;
|
|
1827
|
+
const needle = `"task_id":${JSON.stringify(id)}`;
|
|
1828
|
+
let i = txt.indexOf(needle);
|
|
1829
|
+
while (i >= 0) {
|
|
1830
|
+
const start = txt.lastIndexOf('\n', i) + 1;
|
|
1831
|
+
const end = txt.indexOf('\n', i);
|
|
1832
|
+
const line = txt.slice(start, end < 0 ? undefined : end);
|
|
1833
|
+
if (line.includes('"kind":"dispatch"') && (!role || line.includes(`"role":${JSON.stringify(role)}`))) return true;
|
|
1834
|
+
i = end < 0 ? -1 : txt.indexOf(needle, end);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
return false;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1674
1840
|
function countTaskHandoffs(ctx, taskId) {
|
|
1675
1841
|
if (!taskId) return 0;
|
|
1676
1842
|
let txt;
|
|
@@ -1692,7 +1858,7 @@ function countTaskHandoffs(ctx, taskId) {
|
|
|
1692
1858
|
}
|
|
1693
1859
|
|
|
1694
1860
|
function handoffChainMessage(taskId, n) {
|
|
1695
|
-
return `dieu-phoi: task ${taskId} đã handoff ${n} lần (
|
|
1861
|
+
return `dieu-phoi: task ${taskId} đã handoff ${n} lần (cảnh báo từ ${HANDOFF_CHAIN_WARN}; từ ${HANDOFF_CHAIN_DENY} hook chặn lượt exec tiếp nối, trừ thẻ có \`chain_ok\` khi người dùng đồng ý). Đừng nối tiếp thêm: nếu agent vừa giao là agent tiếp nối, cân nhắc dừng nó và giao planner (dp-scout) đọc handoff-${taskId}.md rồi chia phần còn lại thành bước mới (task_id mới từ \`dieu-phoi new-id\`).`;
|
|
1696
1862
|
}
|
|
1697
1863
|
|
|
1698
1864
|
// ------------------------------------------------------------------ core 0.7.0: mode per request (fast | normal | strict)
|
|
@@ -1705,9 +1871,9 @@ function handoffChainMessage(taskId, n) {
|
|
|
1705
1871
|
const MODES = ['fast', 'normal', 'strict'];
|
|
1706
1872
|
const DEFAULT_MODE = 'normal';
|
|
1707
1873
|
/** 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:
|
|
1710
|
-
+ '
|
|
1874
|
+
const LOCAL_ONLY_KINDS = new Set(['mode', 'knowledge_merge', 'leftover', 'worktree']);
|
|
1875
|
+
const MODE_ASK_LINE = 'mode: chưa chọn — chỉ hỏi ở cổng 1 khi plan-table báo ≥4 bước s≤6 (fast | normal | strict, mặc định normal); '
|
|
1876
|
+
+ 'đặt bằng `dieu-phoi mode --root <root_task_id> <mode>`';
|
|
1711
1877
|
|
|
1712
1878
|
function isMode(m) {
|
|
1713
1879
|
return typeof m === 'string' && MODES.includes(m);
|
|
@@ -2421,6 +2587,209 @@ function relatedStatus(r) {
|
|
|
2421
2587
|
return { name: r.name, rel: r.rel, n, counts: freshCounts(hit.sections), text: `${knowledgeSummary({ sections: hit.sections }, n)} (cache)` };
|
|
2422
2588
|
}
|
|
2423
2589
|
|
|
2590
|
+
// ---- retro 2026-10-26 (C6): knowledge deltas that nobody merged
|
|
2591
|
+
|
|
2592
|
+
const DELTA_WARN_MS = 24 * 3600 * 1000; // younger than this: the session may still be about to accept/merge
|
|
2593
|
+
const DELTA_WINDOW_MS = 30 * 24 * 3600 * 1000; // older than this: stale history, not a warning any more
|
|
2594
|
+
const DELTA_SCAN_MAX = 300;
|
|
2595
|
+
|
|
2596
|
+
/** sha256 of a delta file as `knowledge merge` records it in meta.json `applied` (BOM and CRLF ignored). */
|
|
2597
|
+
function deltaHash(text) {
|
|
2598
|
+
return crypto.createHash('sha256').update(String(text).replace(/^/, '').replace(/\r\n?/g, '\n')).digest('hex');
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
/** The `repo:` header of a delta text (before its first `## `), unquoted; null when absent. */
|
|
2602
|
+
function deltaRepoOf(text) {
|
|
2603
|
+
for (const line of String(text).replace(/^/, '').split(/\r?\n/)) {
|
|
2604
|
+
if (/^##\s/.test(line)) return null;
|
|
2605
|
+
const m = /^\s*repo\s*:\s*(.+?)\s*$/i.exec(line);
|
|
2606
|
+
if (m) return m[1].replace(/[.;,]+$/, '').replace(/^[`"'](.*)[`"']$/, '$1').trim() || null;
|
|
2607
|
+
}
|
|
2608
|
+
return null;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
/** A `repo:` value that is a short name (no path separator, no drive), e.g. `pff-tailor`. */
|
|
2612
|
+
function isBareRepoName(v) {
|
|
2613
|
+
return typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v.trim());
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
/** Every knowledge store under projectsDir(): { dir_name, dir, name, key, roots, applied:Set<sha256> } — file reads only. */
|
|
2617
|
+
function storeCatalog() {
|
|
2618
|
+
const root = projectsDir();
|
|
2619
|
+
let names;
|
|
2620
|
+
try { names = fs.readdirSync(root); } catch (_) { return []; }
|
|
2621
|
+
const out = [];
|
|
2622
|
+
for (const n of names) {
|
|
2623
|
+
const dir = path.join(root, n);
|
|
2624
|
+
if (!isStoreDirName(n) || !isDir(dir)) continue;
|
|
2625
|
+
const meta = readJsonFile(path.join(dir, 'meta.json'), null);
|
|
2626
|
+
const idx = readJsonFile(path.join(dir, 'index.json'), null);
|
|
2627
|
+
if (!meta && !idx) continue;
|
|
2628
|
+
const m = meta && typeof meta === 'object' ? meta : {};
|
|
2629
|
+
const i = idx && typeof idx === 'object' ? idx : {};
|
|
2630
|
+
out.push({
|
|
2631
|
+
dir_name: n,
|
|
2632
|
+
dir: toSlash(dir),
|
|
2633
|
+
name: String(i.name || m.name || n.replace(/-[0-9a-f]{8}$/, '')),
|
|
2634
|
+
key: i.key || (Array.isArray(m.keys) ? m.keys[0] : null) || null,
|
|
2635
|
+
roots: Array.isArray(m.roots) ? m.roots.filter((r) => typeof r === 'string' && r) : [],
|
|
2636
|
+
applied: new Set((Array.isArray(m.applied) ? m.applied : []).map((a) => a && a.sha256).filter(Boolean)),
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
return out;
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
/**
|
|
2643
|
+
* Known store a bare repo name refers to: its `name`, its directory name, or the basename of a recorded root equals
|
|
2644
|
+
* `name` (case-insensitive). { matches, store } — store only when exactly one store matches.
|
|
2645
|
+
*/
|
|
2646
|
+
function storeByName(name, stores) {
|
|
2647
|
+
const want = String(name).trim().toLowerCase();
|
|
2648
|
+
const matches = (stores || storeCatalog()).filter((s) => s.name.toLowerCase() === want || s.dir_name.toLowerCase() === want
|
|
2649
|
+
|| s.roots.some((r) => path.basename(r).toLowerCase() === want));
|
|
2650
|
+
return { matches, store: matches.length === 1 ? matches[0] : null };
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
/** Task id a delta file belongs to: knowledge-delta-<task_id>[-r<n>|-h<n>].md (8 chars a-z0-9); null = untagged. */
|
|
2654
|
+
function deltaTagOf(file) {
|
|
2655
|
+
const m = /^knowledge-delta-([a-z0-9]{8})(?:-[rh]\d+)?\.md$/i.exec(path.basename(String(file)));
|
|
2656
|
+
return m ? m[1].toLowerCase() : null;
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
/**
|
|
2660
|
+
* retro 11-05 (K1): who has judged what, from events + approved plans. `deltaState(run, tag)` is the user's decision on
|
|
2661
|
+
* a delta file in run dir `run` ('accept' | 'reject' | null): a tagged file follows the LAST user verdict of its task;
|
|
2662
|
+
* an untagged one (or one tagged with the run id) follows the tasks living in that run (own id, plan members,
|
|
2663
|
+
* dispatch `run`): accept when any of them was accepted, reject when all were rejected, else null.
|
|
2664
|
+
*/
|
|
2665
|
+
function deltaClassifier(events, plansDir) {
|
|
2666
|
+
const last = new Map();
|
|
2667
|
+
const runOf = new Map();
|
|
2668
|
+
for (const e of events || []) {
|
|
2669
|
+
if (!e || !e.task_id) continue;
|
|
2670
|
+
if (e.kind === 'verdict' && e.by === 'user' && (e.outcome === 'accept' || e.outcome === 'reject')) last.set(e.task_id, e.outcome);
|
|
2671
|
+
if (e.kind === 'dispatch' && typeof e.run === 'string' && e.run) runOf.set(e.task_id, e.run);
|
|
2672
|
+
}
|
|
2673
|
+
const planRoot = new Map();
|
|
2674
|
+
for (const p of loadPlans(plansDir)) for (const x of p.steps) if (x && x.task_id) planRoot.set(x.task_id, p.root_task_id);
|
|
2675
|
+
const byRun = new Map();
|
|
2676
|
+
const add = (run, t) => { if (!byRun.has(run)) byRun.set(run, new Set()); byRun.get(run).add(t); };
|
|
2677
|
+
for (const t of last.keys()) { add(t, t); if (planRoot.has(t)) add(planRoot.get(t), t); if (runOf.has(t)) add(runOf.get(t), t); }
|
|
2678
|
+
return {
|
|
2679
|
+
state: (t) => last.get(t) || null,
|
|
2680
|
+
deltaState(run, tag) {
|
|
2681
|
+
if (tag && tag !== run) return last.get(tag) || null;
|
|
2682
|
+
const ts = Array.from(byRun.get(run) || []).map((t) => last.get(t));
|
|
2683
|
+
if (!ts.length) return null;
|
|
2684
|
+
return ts.includes('accept') ? 'accept' : 'reject';
|
|
2685
|
+
},
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
/**
|
|
2690
|
+
* Delta files in run dirs that are 24 h .. 30 d old and that no store has applied (hash in meta.json `applied`).
|
|
2691
|
+
* { count, runs: [{ run, n, oldest_ms, repos: { name: n } }], truncated } — never throws; count 0 when all is merged.
|
|
2692
|
+
* With opts.events (+ opts.plansDir) the files are split by what the user decided: count/runs = no user verdict yet
|
|
2693
|
+
* (24 h .. 30 d old); `accepted` = { count, runs: [{ run, n, oldest_ms, repos, tasks, untagged }] } = ALREADY ACCEPTED but
|
|
2694
|
+
* not merged (an error: no age floor, only the 30 d window); a task the user rejected is left out of both.
|
|
2695
|
+
*/
|
|
2696
|
+
function scanUnmergedDeltas(opts) {
|
|
2697
|
+
const o = opts || {};
|
|
2698
|
+
const now = o.now || Date.now();
|
|
2699
|
+
const warnMs = o.warnMs === undefined ? DELTA_WARN_MS : o.warnMs;
|
|
2700
|
+
const windowMs = o.windowMs === undefined ? DELTA_WINDOW_MS : o.windowMs;
|
|
2701
|
+
const res = { count: 0, runs: [], truncated: false };
|
|
2702
|
+
const cls = Array.isArray(o.events) ? deltaClassifier(o.events, o.plansDir) : null;
|
|
2703
|
+
const acc = { count: 0, runs: [] };
|
|
2704
|
+
try {
|
|
2705
|
+
const runs = runsDir();
|
|
2706
|
+
const applied = new Set();
|
|
2707
|
+
for (const s of storeCatalog()) for (const h of s.applied) applied.add(h);
|
|
2708
|
+
let seen = 0;
|
|
2709
|
+
for (const run of fs.readdirSync(runs)) {
|
|
2710
|
+
const dir = path.join(runs, run);
|
|
2711
|
+
let st;
|
|
2712
|
+
try { st = fs.statSync(dir); } catch (_) { continue; }
|
|
2713
|
+
if (!st.isDirectory() || now - st.mtimeMs > windowMs) continue;
|
|
2714
|
+
let names;
|
|
2715
|
+
try { names = fs.readdirSync(dir); } catch (_) { continue; }
|
|
2716
|
+
const row = { run, n: 0, oldest_ms: 0, repos: {} };
|
|
2717
|
+
const arow = { run, n: 0, oldest_ms: 0, repos: {}, tasks: [], untagged: false };
|
|
2718
|
+
for (const f of names) {
|
|
2719
|
+
if (!/^knowledge-delta.*\.md$/i.test(f)) continue;
|
|
2720
|
+
const file = path.join(dir, f);
|
|
2721
|
+
let fst;
|
|
2722
|
+
try { fst = fs.statSync(file); } catch (_) { continue; }
|
|
2723
|
+
const age = now - fst.mtimeMs;
|
|
2724
|
+
if (age > windowMs) continue;
|
|
2725
|
+
const tag = deltaTagOf(f);
|
|
2726
|
+
const verdict = cls ? cls.deltaState(run, tag) : null;
|
|
2727
|
+
if (verdict === 'reject') continue;
|
|
2728
|
+
const into = verdict === 'accept' ? arow : row;
|
|
2729
|
+
if (into === row && age < warnMs) continue;
|
|
2730
|
+
if (seen++ >= DELTA_SCAN_MAX) { res.truncated = true; break; }
|
|
2731
|
+
const text = readTextFile(file);
|
|
2732
|
+
if (text == null || applied.has(deltaHash(text))) continue;
|
|
2733
|
+
const repo = deltaRepoOf(text);
|
|
2734
|
+
const label = repo ? (isBareRepoName(repo) ? repo : path.basename(repo.replace(/[\\/]+$/, ''))) : '?';
|
|
2735
|
+
into.n++;
|
|
2736
|
+
into.oldest_ms = Math.max(into.oldest_ms, age);
|
|
2737
|
+
into.repos[label] = (into.repos[label] || 0) + 1;
|
|
2738
|
+
if (into === arow) { if (tag && tag !== run) { if (!arow.tasks.includes(tag)) arow.tasks.push(tag); } else arow.untagged = true; }
|
|
2739
|
+
}
|
|
2740
|
+
if (row.n) { res.runs.push(row); res.count += row.n; }
|
|
2741
|
+
if (arow.n) { acc.runs.push(arow); acc.count += arow.n; }
|
|
2742
|
+
if (res.truncated) break;
|
|
2743
|
+
}
|
|
2744
|
+
res.runs.sort((a, b) => b.n - a.n || b.oldest_ms - a.oldest_ms);
|
|
2745
|
+
acc.runs.sort((a, b) => b.n - a.n || b.oldest_ms - a.oldest_ms);
|
|
2746
|
+
if (acc.count) res.accepted = acc;
|
|
2747
|
+
} catch (e) {
|
|
2748
|
+
res.error = e.message;
|
|
2749
|
+
}
|
|
2750
|
+
return res;
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function ageText(ms) {
|
|
2754
|
+
return ms >= 48 * 3600 * 1000 ? `${Math.floor(ms / 86400000)} ngày` : `${Math.floor(ms / 3600000)} giờ`;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/** One warning line (as an array, empty when nothing is pending) for the start block and `state`. */
|
|
2758
|
+
function unmergedDeltaLines(scan) {
|
|
2759
|
+
const out = [];
|
|
2760
|
+
const a = scan && scan.accepted;
|
|
2761
|
+
if (a && a.count) {
|
|
2762
|
+
const r0 = a.runs[0];
|
|
2763
|
+
const cmd = `dieu-phoi knowledge merge --run ${r0.run}${r0.tasks.length ? ` --task ${r0.tasks.slice(0, 8).join(',')}` : ''}${r0.untagged ? ' --untagged' : ''}`;
|
|
2764
|
+
const runText = a.runs.slice(0, 3).map((r) => `${r.run} ×${r.n}`).join(', ') + (a.runs.length > 3 ? ` (+${a.runs.length - 3} run)` : '');
|
|
2765
|
+
out.push(`knowledge_delta_accepted: LỖI — ${a.count} file delta của task ĐÃ nghiệm thu mà chưa vào kho (gộp lúc accept thất bại hoặc bị bỏ sót) — run ${runText}; cũ nhất ${ageText(Math.max(...a.runs.map((r) => r.oldest_ms)))}. `
|
|
2766
|
+
+ `Gộp: \`${cmd} --dry-run\` rồi bỏ --dry-run.`);
|
|
2767
|
+
}
|
|
2768
|
+
if (!scan || !scan.count) return out;
|
|
2769
|
+
const oldest = Math.max(...scan.runs.map((r) => r.oldest_ms));
|
|
2770
|
+
const repos = {};
|
|
2771
|
+
for (const r of scan.runs) for (const [k, v] of Object.entries(r.repos)) repos[k] = (repos[k] || 0) + v;
|
|
2772
|
+
const repoText = Object.entries(repos).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ×${v}`).join(', ');
|
|
2773
|
+
const runText = scan.runs.slice(0, 3).map((r) => `${r.run} ×${r.n}`).join(', ') + (scan.runs.length > 3 ? ` (+${scan.runs.length - 3} run)` : '');
|
|
2774
|
+
const more = scan.truncated ? ' (chỉ quét một phần)' : '';
|
|
2775
|
+
const pending = scan.accepted ? ' (chưa có verdict người dùng)' : '';
|
|
2776
|
+
out.push(`knowledge_delta: ${scan.count} file delta chưa gộp quá 24h${pending}${more} — run ${runText}; kho ${repoText}; cũ nhất ${ageText(oldest)}. `
|
|
2777
|
+
+ `Gộp: \`dieu-phoi knowledge merge --run ${scan.runs[0].run} --dry-run\` rồi bỏ --dry-run (hoặc verdict accept); bỏ qua nếu cố ý không gộp.`);
|
|
2778
|
+
return out;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
/** Whether a scan has anything to show (undecided and/or accepted-but-unmerged deltas). */
|
|
2782
|
+
function hasUnmergedDeltas(scan) {
|
|
2783
|
+
return !!(scan && (scan.count || (scan.accepted && scan.accepted.count)));
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
/** scanUnmergedDeltas with the user's verdicts (events + approved plans of ctx), so accepted-unmerged is told apart. */
|
|
2787
|
+
function scanUnmergedDeltasFor(ctx) {
|
|
2788
|
+
let events = [];
|
|
2789
|
+
try { events = readEvents(ctx); } catch (_) { /* no log: everything stays undecided */ }
|
|
2790
|
+
return scanUnmergedDeltas({ events, plansDir: ctx && ctx.paths && ctx.paths.plans });
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2424
2793
|
/**
|
|
2425
2794
|
* Knowledge lines for the start block of the repo at `cwd`; null outside a repo or when the repo has no store.
|
|
2426
2795
|
* Refreshes this repo only (at most one git diff-tree, on a cache miss, bounded by HOOK_DIFF_TIMEOUT_MS; never git
|
|
@@ -2491,6 +2860,16 @@ function projectFields(p) {
|
|
|
2491
2860
|
return {};
|
|
2492
2861
|
}
|
|
2493
2862
|
|
|
2863
|
+
/** Run dir id the brief points at (retro 11-05 K1: accept finds the delta there whatever the plan/root says), else null. */
|
|
2864
|
+
function dispatchRun(prompt) {
|
|
2865
|
+
try {
|
|
2866
|
+
const probe = 'norundir0'; // resultRunDir falls back to runs/<task_id>: the probe id tells "named in the brief" from that fallback
|
|
2867
|
+
const d = resultRunDir(prompt, probe);
|
|
2868
|
+
const id = path.basename(d);
|
|
2869
|
+
return path.dirname(d) === path.resolve(runsDir()) && id !== probe && /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
|
|
2870
|
+
} catch (_) { return null; }
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2494
2873
|
function buildDispatch(p, ctx) {
|
|
2495
2874
|
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
2496
2875
|
const tr = p.tool_response;
|
|
@@ -2518,6 +2897,11 @@ function buildDispatch(p, ctx) {
|
|
|
2518
2897
|
...(tag.task_id && !isValidTaskId(tag.task_id) ? { task_id_invalid: true } : {}),
|
|
2519
2898
|
...(tag.task_id_inferred ? { task_id_inferred: true } : {}),
|
|
2520
2899
|
...(tag.tag_issue ? { tag_issue: tag.tag_issue } : {}),
|
|
2900
|
+
...(tag.type_alias_from ? { type_alias_from: tag.type_alias_from } : {}),
|
|
2901
|
+
...(tag.chain_ok ? { chain_ok: true } : {}),
|
|
2902
|
+
...(tag.session_ok ? { session_ok: true } : {}),
|
|
2903
|
+
...(tag.batch ? { batch: true } : {}),
|
|
2904
|
+
...(dispatchRun(ti.prompt) ? { run: dispatchRun(ti.prompt) } : {}),
|
|
2521
2905
|
task_type: tag.task_type,
|
|
2522
2906
|
score: tag.score,
|
|
2523
2907
|
urgency: tag.urgency,
|
|
@@ -2543,6 +2927,11 @@ function buildDispatch(p, ctx) {
|
|
|
2543
2927
|
commonFields(ctx));
|
|
2544
2928
|
}
|
|
2545
2929
|
|
|
2930
|
+
/** core 0.7.1 (C3): the tier came from `choose` for another task type than the tag carries. */
|
|
2931
|
+
function chooseTypeMismatchMessage(ev) {
|
|
2932
|
+
return `dieu-phoi: task ${ev.task_id} được \`choose\` tính cho loại "${ev.choose_task_type}" nhưng thẻ ghi "${ev.task_type}" — tier ${ev.choose_tier} có thể không hợp loại này. Lần sau gọi \`choose --type ${ev.task_type}\` (hoặc sửa thẻ cho đúng loại choose trả).`;
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2546
2935
|
/** 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
2936
|
function modeFields(ctx, tag) {
|
|
2548
2937
|
if (!tag || !tag.task_id || !isValidTaskId(tag.task_id)) return {};
|
|
@@ -2561,6 +2950,15 @@ function tierFields(ctx, tag, model, effort) {
|
|
|
2561
2950
|
dispatched_tier: dispatched,
|
|
2562
2951
|
};
|
|
2563
2952
|
if (dispatched && !dispatched.includes('?')) out.tier_mismatch = dispatched !== out.choose_tier;
|
|
2953
|
+
// core 0.7.1 (C3): choose's own inputs/reason, and whether choose was asked about another task type
|
|
2954
|
+
if (typeof s.choose_task_type === 'string') {
|
|
2955
|
+
out.choose_task_type = s.choose_task_type;
|
|
2956
|
+
if (tag.task_type && tag.task_type !== s.choose_task_type) out.choose_type_mismatch = true;
|
|
2957
|
+
}
|
|
2958
|
+
if (typeof s.choose_score === 'number') out.choose_score = s.choose_score;
|
|
2959
|
+
if (typeof s.explored === 'boolean') out.choose_explored = s.explored;
|
|
2960
|
+
if (typeof s.reason_code === 'string') out.choose_reason = s.reason_code;
|
|
2961
|
+
if (typeof s.thompson_score === 'number') out.choose_thompson_score = s.thompson_score;
|
|
2564
2962
|
return out;
|
|
2565
2963
|
}
|
|
2566
2964
|
|
|
@@ -2579,12 +2977,21 @@ function handlePostToolUse(p, ctx) {
|
|
|
2579
2977
|
const tag = resolveTag(ti.prompt, known) || { tag_issue: ev.tag_issue, task_id: null, task_type: ev.task_type };
|
|
2580
2978
|
notes.push(tagIssueMessage(tag, known));
|
|
2581
2979
|
}
|
|
2980
|
+
if (ev.choose_type_mismatch) notes.push(chooseTypeMismatchMessage(ev));
|
|
2582
2981
|
if (ev.task_id && ev.role === 'exec') {
|
|
2583
2982
|
const n = countTaskHandoffs(ctx, ev.task_id);
|
|
2584
2983
|
if (n) ev.task_handoffs = n;
|
|
2585
2984
|
if (n >= HANDOFF_CHAIN_WARN) notes.push(handoffChainMessage(ev.task_id, n));
|
|
2586
2985
|
}
|
|
2986
|
+
// core 0.7.2 (K4): an exec with no choose record gets a reminder; a tier that differs from choose's answer without an
|
|
2987
|
+
// override logged for it leaves an implicit override event right after the dispatch.
|
|
2988
|
+
let implicit = null;
|
|
2989
|
+
if (ev.task_id && ev.role === 'exec' && isValidTaskId(ev.task_id)) {
|
|
2990
|
+
if (!ev.suggested_tier) notes.push(noChooseMessage(ev.task_id));
|
|
2991
|
+
else implicit = implicitOverrideEvent(ctx, ev);
|
|
2992
|
+
}
|
|
2587
2993
|
appendEvent(ctx, ev);
|
|
2994
|
+
if (implicit) appendEvent(ctx, implicit);
|
|
2588
2995
|
if (notes.length) {
|
|
2589
2996
|
Object.defineProperty(ev, 'stdout', {
|
|
2590
2997
|
enumerable: false,
|
|
@@ -2594,6 +3001,51 @@ function handlePostToolUse(p, ctx) {
|
|
|
2594
3001
|
return ev;
|
|
2595
3002
|
}
|
|
2596
3003
|
|
|
3004
|
+
/** core 0.7.2 (K4): reminder for an exec whose task has no choose record (data/suggest) when it was dispatched. */
|
|
3005
|
+
function noChooseMessage(taskId) {
|
|
3006
|
+
return `dieu-phoi: exec của task ${taskId} được giao mà chưa có kết quả \`choose\` (không có tier trong data/suggest). Lần giao sau chạy \`dieu-phoi choose --type <task_type> --task ${taskId} --json\` trước khi giao; tier giao khác tier choose trả mà không có --override sẽ được ghi là override implicit.`;
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
/**
|
|
3010
|
+
* core 0.7.2 (K4): the implicit override for an exec dispatch whose tier differs from what choose answered
|
|
3011
|
+
* (choose_tier, else suggested_tier) while no override of this task to that tier is logged yet. Null otherwise.
|
|
3012
|
+
*/
|
|
3013
|
+
function implicitOverrideEvent(ctx, ev) {
|
|
3014
|
+
const dispatched = ev.dispatched_tier;
|
|
3015
|
+
const said = ev.choose_tier || ev.suggested_tier;
|
|
3016
|
+
if (!dispatched || dispatched.includes('?') || dispatched === said) return null;
|
|
3017
|
+
if (overrideLoggedFor(ctx, ev.task_id, dispatched)) return null;
|
|
3018
|
+
return Object.assign({
|
|
3019
|
+
schema: SCHEMA,
|
|
3020
|
+
kind: 'override',
|
|
3021
|
+
ts: nowIso(),
|
|
3022
|
+
agent_id: `task:${ev.task_id}`,
|
|
3023
|
+
task_id: ev.task_id,
|
|
3024
|
+
task_type: ev.task_type || null,
|
|
3025
|
+
suggested_tier: said,
|
|
3026
|
+
chosen_tier: dispatched,
|
|
3027
|
+
source: 'implicit',
|
|
3028
|
+
}, commonFields(ctx));
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
/** Whether events.jsonl already holds an override (explicit or implicit) of this task to this tier. */
|
|
3032
|
+
function overrideLoggedFor(ctx, taskId, tier) {
|
|
3033
|
+
let txt;
|
|
3034
|
+
try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { return false; }
|
|
3035
|
+
const needle = `"task_id":${JSON.stringify(taskId)}`;
|
|
3036
|
+
let i = txt.indexOf('"kind":"override"');
|
|
3037
|
+
while (i >= 0) {
|
|
3038
|
+
const start = txt.lastIndexOf('\n', i) + 1;
|
|
3039
|
+
const end = txt.indexOf('\n', i);
|
|
3040
|
+
const line = txt.slice(start, end < 0 ? undefined : end);
|
|
3041
|
+
if (line.includes(needle)) {
|
|
3042
|
+
try { if (JSON.parse(line).chosen_tier === tier) return true; } catch (_) { /* a junk line never matches */ }
|
|
3043
|
+
}
|
|
3044
|
+
i = end < 0 ? -1 : txt.indexOf('"kind":"override"', end);
|
|
3045
|
+
}
|
|
3046
|
+
return false;
|
|
3047
|
+
}
|
|
3048
|
+
|
|
2597
3049
|
function hasAssistantUsage(lines) {
|
|
2598
3050
|
return lines.some((l) => l && l.type === 'assistant' && l.message && l.message.usage
|
|
2599
3051
|
&& l.message.model !== '<synthetic>');
|
|
@@ -2739,6 +3191,101 @@ function persistInlineResult(lines, tag, opts) {
|
|
|
2739
3191
|
return file;
|
|
2740
3192
|
}
|
|
2741
3193
|
|
|
3194
|
+
// s15 (core 0.7.2): tools that start background work an agent may leave running, and the tools that stop it.
|
|
3195
|
+
// s16 (core 0.7.2): the formats below were checked against real subagent transcripts (Claude Code, 2026-11):
|
|
3196
|
+
// - Bash/PowerShell result "Command running in background with ID: <id>. Output is being written to: ..." appears for
|
|
3197
|
+
// run_in_background:true and also when a long foreground command is moved to the background by itself (no flag);
|
|
3198
|
+
// - Monitor result "Monitor started (task <id>, timeout ...)" (no "ID:"); a refused Monitor is a tool_result is_error:true;
|
|
3199
|
+
// - TaskStop input {task_id}, the same id;
|
|
3200
|
+
// - task finished = a <task-notification> carrying <status> (+ <task-id>, <tool-use-id>). It is delivered mostly as an
|
|
3201
|
+
// `attachment` line (attachment.type "queued_command", attachment.origin "task-notification", XML in attachment.prompt),
|
|
3202
|
+
// sometimes as a user line with string content. A <task-notification> without <status> is a Monitor progress <event>.
|
|
3203
|
+
const BG_START_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor']);
|
|
3204
|
+
const BG_SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
|
|
3205
|
+
const BG_STOP_TOOLS = new Set(['TaskStop', 'KillShell']);
|
|
3206
|
+
const BG_ID_RE = /\bID:\s*([A-Za-z0-9_-]{2,64})/;
|
|
3207
|
+
const BG_MONITOR_ID_RE = /\(task\s+([A-Za-z0-9_-]{2,64})[,)]/;
|
|
3208
|
+
const BG_AUTO_RE = /^\s*Command running in background with ID:/; // anchored: test/grep output may quote the phrase
|
|
3209
|
+
const NOTIF_BLOCK_RE = /<task-notification>[\s\S]*?<\/task-notification>/g;
|
|
3210
|
+
const TASK_ID_TAG_RE = /<task-id>\s*([A-Za-z0-9_-]{2,64})\s*<\/task-id>/;
|
|
3211
|
+
const TOOL_USE_ID_TAG_RE = /<tool-use-id>\s*([A-Za-z0-9_-]{2,80})\s*<\/tool-use-id>/;
|
|
3212
|
+
|
|
3213
|
+
function textOfContent(c) {
|
|
3214
|
+
if (typeof c === 'string') return c;
|
|
3215
|
+
if (!Array.isArray(c)) return '';
|
|
3216
|
+
return c.map((b) => (b && b.type === 'text' && typeof b.text === 'string' ? b.text : '')).join('\n');
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
/**
|
|
3220
|
+
* s15/s16: background work an agent opened and did not stop, read from its transcript (pure; returns ids, never a command).
|
|
3221
|
+
* Opened = a Bash/PowerShell tool_use with run_in_background:true, or whose result says "Command running in background
|
|
3222
|
+
* with ID:" (a long command moved to the background by itself), or a Monitor tool_use whose result is not an error. The id
|
|
3223
|
+
* comes from the tool_result ("... ID: <id>" / "(task <id>, ..."); without one the tool_use id stands in. Closed = a
|
|
3224
|
+
* TaskStop/KillShell whose input names the id, or a finished-task <task-notification> (one with <status>) naming the id
|
|
3225
|
+
* or the tool_use id, wherever it sits (queued_command attachment, user text). Returns the ids still open, in start order.
|
|
3226
|
+
*/
|
|
3227
|
+
function openBackgroundTasks(lines) {
|
|
3228
|
+
const open = new Map(); // tool_use id -> task id, null while no id is known
|
|
3229
|
+
const finished = new Set(); // task ids and tool_use ids named by a stop or a finished notice (may precede the start result)
|
|
3230
|
+
const kinds = new Map(); // tool_use id -> tool name of every candidate start
|
|
3231
|
+
const flagged = new Set(); // tool_use ids that asked for the background or are Monitors
|
|
3232
|
+
const close = (id) => {
|
|
3233
|
+
finished.add(id);
|
|
3234
|
+
for (const [k, v] of open) if (k === id || v === id) open.delete(k);
|
|
3235
|
+
};
|
|
3236
|
+
const closeNotified = (text) => {
|
|
3237
|
+
if (!text || !text.includes('<task-notification>')) return;
|
|
3238
|
+
for (const blk of text.match(NOTIF_BLOCK_RE) || []) {
|
|
3239
|
+
if (!blk.includes('<status>')) continue; // a Monitor <event> notice: the task is still running
|
|
3240
|
+
const t = TASK_ID_TAG_RE.exec(blk);
|
|
3241
|
+
const u = TOOL_USE_ID_TAG_RE.exec(blk);
|
|
3242
|
+
if (t) close(t[1]);
|
|
3243
|
+
if (u) close(u[1]);
|
|
3244
|
+
}
|
|
3245
|
+
};
|
|
3246
|
+
for (const l of Array.isArray(lines) ? lines : []) {
|
|
3247
|
+
if (!l) continue;
|
|
3248
|
+
if (l.type === 'attachment') {
|
|
3249
|
+
const a = l.attachment;
|
|
3250
|
+
if (a && a.type === 'queued_command' && typeof a.prompt === 'string') closeNotified(a.prompt);
|
|
3251
|
+
continue;
|
|
3252
|
+
}
|
|
3253
|
+
const msg = l.message;
|
|
3254
|
+
if (!msg) continue;
|
|
3255
|
+
const isUser = l.type === 'user';
|
|
3256
|
+
if (typeof msg.content === 'string') {
|
|
3257
|
+
if (isUser) closeNotified(msg.content);
|
|
3258
|
+
continue;
|
|
3259
|
+
}
|
|
3260
|
+
if (!Array.isArray(msg.content)) continue;
|
|
3261
|
+
for (const b of msg.content) {
|
|
3262
|
+
if (!b || typeof b !== 'object') continue;
|
|
3263
|
+
if (b.type === 'tool_use' && typeof b.id === 'string') {
|
|
3264
|
+
const input = b.input && typeof b.input === 'object' ? b.input : {};
|
|
3265
|
+
if (BG_START_TOOLS.has(b.name)) {
|
|
3266
|
+
kinds.set(b.id, b.name);
|
|
3267
|
+
if (b.name === 'Monitor' || input.run_in_background === true) flagged.add(b.id);
|
|
3268
|
+
} else if (BG_STOP_TOOLS.has(b.name)) {
|
|
3269
|
+
const id = input.task_id || input.shell_id;
|
|
3270
|
+
if (typeof id === 'string' && id) close(id);
|
|
3271
|
+
}
|
|
3272
|
+
} else if (b.type === 'tool_result' && kinds.has(b.tool_use_id)) {
|
|
3273
|
+
const text = textOfContent(b.content);
|
|
3274
|
+
const kind = kinds.get(b.tool_use_id);
|
|
3275
|
+
const auto = BG_SHELL_TOOLS.has(kind) && BG_AUTO_RE.test(text);
|
|
3276
|
+
if ((!flagged.has(b.tool_use_id) && !auto) || b.is_error === true) continue; // foreground, or refused: nothing started
|
|
3277
|
+
const m = (kind === 'Monitor' ? BG_MONITOR_ID_RE.exec(text) : null) || BG_ID_RE.exec(text);
|
|
3278
|
+
const id = m ? m[1] : null;
|
|
3279
|
+
if (finished.has(b.tool_use_id) || (id && finished.has(id))) continue;
|
|
3280
|
+
open.set(b.tool_use_id, id);
|
|
3281
|
+
} else if (b.type === 'text' && typeof b.text === 'string' && isUser) {
|
|
3282
|
+
closeNotified(b.text);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
return Array.from(open, ([k, v]) => v || k);
|
|
3287
|
+
}
|
|
3288
|
+
|
|
2742
3289
|
function handleSubagentStop(p, ctx) {
|
|
2743
3290
|
const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
|
|
2744
3291
|
if (!agentId) return null;
|
|
@@ -2761,6 +3308,14 @@ function handleSubagentStop(p, ctx) {
|
|
|
2761
3308
|
sum = summarizeTranscript(lines, { afterUuids: seen, pricing: loadPricing(ctx) });
|
|
2762
3309
|
tag = tagFromTranscript(lines, knownTaskTypes(ctx));
|
|
2763
3310
|
}
|
|
3311
|
+
// s15 (core 0.7.2): a tagged dp-* agent that stops while a background task it opened still runs is sent back once
|
|
3312
|
+
// (decision block: TaskStop it, then report). That second stop has stop_hook_active true: recorded, never blocked again.
|
|
3313
|
+
// The blocked stop writes nothing, so the next stop still counts the whole segment.
|
|
3314
|
+
const leftover = tag && tag.task_id ? openBackgroundTasks(lines) : [];
|
|
3315
|
+
if (leftover.length && p.stop_hook_active !== true && ctx.config.block_leftover_tasks !== false) {
|
|
3316
|
+
const reason = `Còn task nền chưa dừng: ${leftover.slice(0, 5).join(', ')}. TaskStop từng cái rồi trả lại STATUS.`;
|
|
3317
|
+
return { stdout: `${JSON.stringify({ decision: 'block', reason })}\n` };
|
|
3318
|
+
}
|
|
2764
3319
|
const t = tag || UNTAGGED;
|
|
2765
3320
|
const ev = Object.assign({
|
|
2766
3321
|
schema: SCHEMA,
|
|
@@ -2789,12 +3344,18 @@ function handleSubagentStop(p, ctx) {
|
|
|
2789
3344
|
ev.handoff = ev.status === 'handoff';
|
|
2790
3345
|
ev.peak_ctx = sum ? sum.peak_ctx : null;
|
|
2791
3346
|
ev.compactions = sum ? sum.compactions : null;
|
|
3347
|
+
// s15: background tasks still open when the segment ended (after the one allowed block): a count, never the commands
|
|
3348
|
+
if (leftover.length) ev.leftover_tasks = leftover.length;
|
|
2792
3349
|
let inline = null;
|
|
2793
3350
|
if (exists && tag) {
|
|
2794
3351
|
try { inline = persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
|
|
2795
3352
|
}
|
|
2796
3353
|
// exec/verify/plan only: true = it returned without writing its dp-out file (the hook saved the .inline.md)
|
|
2797
|
-
if (tag && RESULT_ROLES.includes(tag.role))
|
|
3354
|
+
if (tag && RESULT_ROLES.includes(tag.role)) {
|
|
3355
|
+
ev.inline_result = Boolean(inline);
|
|
3356
|
+
// retro 2026-11-05 K2: the segment ended with no STATUS:/VERDICT: line (or no final text) and was neither a quota pause nor a handoff
|
|
3357
|
+
ev.ended_without_status = exists && (ev.status === 'none' || ev.status === null);
|
|
3358
|
+
}
|
|
2798
3359
|
// core 0.7.0 (s2): seconds spent running tests in this segment (transcript; else the dp-out `TEST_S:` line)
|
|
2799
3360
|
const tt = exists && sum ? testTimeOf(lines.slice(sum.segment_start)) : null;
|
|
2800
3361
|
ev.test_s = tt ? tt.test_s : null;
|
|
@@ -2822,6 +3383,100 @@ function handleSubagentStop(p, ctx) {
|
|
|
2822
3383
|
verify: 'none', reason_code: `auto-${why}`, cause: null, round: null,
|
|
2823
3384
|
}, commonFields(ctx)));
|
|
2824
3385
|
}
|
|
3386
|
+
// s16 (core 0.7.2): processes the agent left behind (report; kill only with config kill_leftovers). Never throws, bounded to ~5 s.
|
|
3387
|
+
if (tag && tag.task_id && exists) {
|
|
3388
|
+
try { scanLeftoverProcs(ctx, { agentId, taskId: tag.task_id, lines }); } catch (e) { logError(ctx.home, e, 'leftover-procs'); }
|
|
3389
|
+
}
|
|
3390
|
+
return ev;
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
// ------------------------------------------------------------------ leftover processes (s16, core 0.7.2)
|
|
3394
|
+
|
|
3395
|
+
// The process-table helpers live in lib/procs.cjs. The installed hook is a single copied file, so `setup` puts the same
|
|
3396
|
+
// module next to it as bin/procs.cjs; the repo layout finds it under ./lib. Missing module = no scan, never an error.
|
|
3397
|
+
function loadProcs() {
|
|
3398
|
+
for (const f of ['./procs.cjs', './lib/procs.cjs']) {
|
|
3399
|
+
try { return require(f); } catch (_) { /* try the next place */ }
|
|
3400
|
+
}
|
|
3401
|
+
return null;
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
const LEFTOVER_BUDGET_MS = 5000;
|
|
3405
|
+
const LEFTOVER_LIST_TIMEOUT_MS = 4000;
|
|
3406
|
+
const LEFTOVER_OVERLAP_MAX_AGE_MS = 24 * 3600 * 1000;
|
|
3407
|
+
|
|
3408
|
+
/** Start of an agent transcript (first line timestamp, epoch ms) or null. */
|
|
3409
|
+
function transcriptStartMs(lines) {
|
|
3410
|
+
for (const l of Array.isArray(lines) ? lines : []) {
|
|
3411
|
+
const t = l && typeof l.timestamp === 'string' ? Date.parse(l.timestamp) : NaN;
|
|
3412
|
+
if (Number.isFinite(t)) return t;
|
|
3413
|
+
}
|
|
3414
|
+
return null;
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
/**
|
|
3418
|
+
* Is another dp agent alive during [startMs, endMs]? From dispatch/complete events: dispatched at or before endMs and not
|
|
3419
|
+
* completed before startMs (a foreground agent's dispatch is logged when it returns, so it counts too: the safe side).
|
|
3420
|
+
* Dispatches older than 24 h without a complete are ignored (crashed agents).
|
|
3421
|
+
*/
|
|
3422
|
+
function otherAgentsRunning(events, agentId, startMs, endMs) {
|
|
3423
|
+
const disp = new Map();
|
|
3424
|
+
const done = new Map();
|
|
3425
|
+
for (const ev of Array.isArray(events) ? events : []) {
|
|
3426
|
+
if (!ev || !ev.agent_id || ev.agent_id === agentId) continue;
|
|
3427
|
+
const t = Date.parse(ev.ts);
|
|
3428
|
+
if (!Number.isFinite(t)) continue;
|
|
3429
|
+
if (ev.kind === 'dispatch') disp.set(ev.agent_id, Math.max(disp.get(ev.agent_id) || 0, t));
|
|
3430
|
+
else if (ev.kind === 'complete') done.set(ev.agent_id, Math.max(done.get(ev.agent_id) || 0, t));
|
|
3431
|
+
}
|
|
3432
|
+
for (const [id, d] of disp) {
|
|
3433
|
+
if (d > endMs) continue;
|
|
3434
|
+
const c = done.get(id);
|
|
3435
|
+
if (c === undefined) { if (endMs - d <= LEFTOVER_OVERLAP_MAX_AGE_MS) return true; continue; }
|
|
3436
|
+
if (c >= startMs) return true;
|
|
3437
|
+
}
|
|
3438
|
+
return false;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
/**
|
|
3442
|
+
* Looks at the process table after a dp agent stopped. Descendants of this session's Claude Code process created since the
|
|
3443
|
+
* agent started and still alive (not the hook's own chain, not claude/code-graph) -> one local `leftover` event
|
|
3444
|
+
* {task_id, n, names}. They are only reported, unless config kill_leftovers is true AND no other dp agent ran in the
|
|
3445
|
+
* meantime (then the leftover trees are killed). deps = { procs, now, selfPid, events } for tests. Returns the event or null.
|
|
3446
|
+
*/
|
|
3447
|
+
function scanLeftoverProcs(ctx, a, deps) {
|
|
3448
|
+
const d = deps || {};
|
|
3449
|
+
if (ctx.config.scan_leftover_procs === false || (!d.procs && process.env.DIEU_PHOI_NO_PROCS === '1')) return null;
|
|
3450
|
+
const procs = d.procs || loadProcs();
|
|
3451
|
+
if (!procs) return null;
|
|
3452
|
+
const startMs = transcriptStartMs(a.lines);
|
|
3453
|
+
const now = typeof d.now === 'number' ? d.now : Date.now();
|
|
3454
|
+
// an agent that started over 24 h ago (a long-resumed or odd transcript) has no meaningful "created since" window
|
|
3455
|
+
if (startMs === null || now - startMs > LEFTOVER_OVERLAP_MAX_AGE_MS) return null;
|
|
3456
|
+
const t0 = Date.now();
|
|
3457
|
+
const list = procs.listProcesses({ timeoutMs: LEFTOVER_LIST_TIMEOUT_MS });
|
|
3458
|
+
if (!Array.isArray(list)) return null;
|
|
3459
|
+
const selfPid = d.selfPid || process.pid;
|
|
3460
|
+
const root = procs.claudeAncestor(list, selfPid);
|
|
3461
|
+
if (!root) return null;
|
|
3462
|
+
const left = procs.leftoverProcesses(list, { rootPid: root.pid, startMs, selfPid });
|
|
3463
|
+
if (!left.all.length) return null;
|
|
3464
|
+
const counts = new Map();
|
|
3465
|
+
for (const p of left.all) { const n = String(p.name || '?').toLowerCase(); counts.set(n, (counts.get(n) || 0) + 1); }
|
|
3466
|
+
const names = Array.from(counts).sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1)).slice(0, 8).map((x) => x[0]);
|
|
3467
|
+
const killOn = ctx.config.kill_leftovers === true;
|
|
3468
|
+
let killed = 0;
|
|
3469
|
+
let skipped = null;
|
|
3470
|
+
if (killOn) {
|
|
3471
|
+
const events = d.events || readEvents(ctx);
|
|
3472
|
+
if (otherAgentsRunning(events, a.agentId, startMs, now)) skipped = 'other-agent-running';
|
|
3473
|
+
else killed = procs.killTree(left.roots.map((p) => p.pid), { deadline: t0 + LEFTOVER_BUDGET_MS }).length;
|
|
3474
|
+
}
|
|
3475
|
+
const ev = Object.assign({
|
|
3476
|
+
schema: SCHEMA, kind: 'leftover', ts: nowIso(), agent_id: a.agentId, task_id: a.taskId,
|
|
3477
|
+
n: left.all.length, names, kill_leftovers: killOn, killed,
|
|
3478
|
+
}, skipped ? { kill_skipped: skipped } : {}, commonFields(ctx));
|
|
3479
|
+
appendEvent(ctx, ev);
|
|
2825
3480
|
return ev;
|
|
2826
3481
|
}
|
|
2827
3482
|
|
|
@@ -3287,6 +3942,101 @@ function gateReserve(dir, child) {
|
|
|
3287
3942
|
return 0;
|
|
3288
3943
|
}
|
|
3289
3944
|
|
|
3945
|
+
/**
|
|
3946
|
+
* Core 0.7.1 (C1): global PreToolUse(Agent) in the orchestrating session. An exec dispatch of a task that already
|
|
3947
|
+
* handed off HANDOFF_CHAIN_DENY times is denied (the 4th, 5th… segment of one chain cost $15-36 per task in session
|
|
3948
|
+
* 36cee993) unless the tag carries `chain_ok`. Inside a subagent (agent_id) this does nothing: --gate handles gen-2.
|
|
3949
|
+
*/
|
|
3950
|
+
function handleChainGate(p, ctx) {
|
|
3951
|
+
if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
|
|
3952
|
+
if (watchAgentId(p)) return null;
|
|
3953
|
+
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
3954
|
+
const tag = resolveTag(ti.prompt, knownTaskTypes(ctx));
|
|
3955
|
+
if (!tag || !tag.task_id || !isValidTaskId(tag.task_id)) return null;
|
|
3956
|
+
if (tag.role === 'verify') return verifyOrphanGate(p, ctx, tag);
|
|
3957
|
+
if (tag.role !== 'exec') return null;
|
|
3958
|
+
const chain = tag.chain_ok ? null : chainGateDenial(p, ctx, tag);
|
|
3959
|
+
if (chain) return chain;
|
|
3960
|
+
return sessionCapGate(p, ctx, tag);
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
function preToolDeny(reason) {
|
|
3964
|
+
return JSON.stringify({
|
|
3965
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
|
|
3966
|
+
});
|
|
3967
|
+
}
|
|
3968
|
+
|
|
3969
|
+
/** Core 0.7.1 (C1): exec continuation of a task that already handed off HANDOFF_CHAIN_DENY times. */
|
|
3970
|
+
function chainGateDenial(p, ctx, tag) {
|
|
3971
|
+
const n = countTaskHandoffs(ctx, tag.task_id);
|
|
3972
|
+
if (n < HANDOFF_CHAIN_DENY) return null;
|
|
3973
|
+
const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'exec' },
|
|
3974
|
+
{ gate_action: 'deny', reason_code: 'handoff-chain', children: n });
|
|
3975
|
+
return {
|
|
3976
|
+
denied: true,
|
|
3977
|
+
event: ev,
|
|
3978
|
+
stdout: preToolDeny(`dieu-phoi: task ${tag.task_id} đã handoff ${n} lần (trần ${HANDOFF_CHAIN_DENY}) — không giao thêm exec tiếp nối. `
|
|
3979
|
+
+ `Giao planner (dp-scout) đọc handoff-${tag.task_id}.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\`). `
|
|
3980
|
+
+ 'Chỉ khi người dùng đồng ý nối tiếp: thêm `|chain_ok` vào thẻ.'),
|
|
3981
|
+
};
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
/**
|
|
3985
|
+
* Core 0.7.2 (K3, retro 2026-11-05): a `role=verify` dispatch whose task has no exec dispatch in events.jsonl and sits
|
|
3986
|
+
* in no approved plan is a verifier for work that was never dispatched -> denied (`verify-orphan`). `|batch` (the one
|
|
3987
|
+
* verifier of a fast request, covering several steps) is exempt.
|
|
3988
|
+
*/
|
|
3989
|
+
function verifyOrphanGate(p, ctx, tag) {
|
|
3990
|
+
if (tag.batch) return null;
|
|
3991
|
+
if (hasDispatchFor(ctx, [tag.task_id], 'exec')) return null;
|
|
3992
|
+
if (planOfTask(ctx, tag.task_id)) return null;
|
|
3993
|
+
const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'verify' },
|
|
3994
|
+
{ gate_action: 'deny', reason_code: 'verify-orphan' });
|
|
3995
|
+
return {
|
|
3996
|
+
denied: true,
|
|
3997
|
+
event: ev,
|
|
3998
|
+
stdout: preToolDeny(`dieu-phoi: verifier của task ${tag.task_id} không có lượt exec nào trong phiên này và không nằm trong kế hoạch đã duyệt — `
|
|
3999
|
+
+ 'không chạy verifier cho việc chưa giao. Giao exec của task trước; verifier gộp của mode fast dùng thẻ `|batch`.'),
|
|
4000
|
+
};
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
/** Core 0.7.2 (K5): trần phiên điều phối cứng. Vượt một trong hai → exec mở ROOT MỚI bị chặn (session-cap). */
|
|
4004
|
+
const SESSION_CAP_TOKENS = 800000;
|
|
4005
|
+
const SESSION_CAP_COMPACTIONS = 10;
|
|
4006
|
+
|
|
4007
|
+
/**
|
|
4008
|
+
* Core 0.7.2 (K5, retro 2026-11-05): the orchestrating session is past SESSION_CAP_TOKENS of context or
|
|
4009
|
+
* SESSION_CAP_COMPACTIONS compactions. An exec that opens a new root is denied (`session-cap`); an exec of a root that
|
|
4010
|
+
* already has a dispatch (a continuation) passes; `|session_ok` lets the user accept the risk. Main-session only.
|
|
4011
|
+
*/
|
|
4012
|
+
function sessionCapGate(p, ctx, tag) {
|
|
4013
|
+
if (tag.session_ok) return null;
|
|
4014
|
+
const plans = loadPlans(ctx.paths.plans);
|
|
4015
|
+
const step = planOfTask(ctx, tag.task_id);
|
|
4016
|
+
const root = step ? step.root : tag.task_id;
|
|
4017
|
+
if (hasDispatchFor(ctx, rootTaskIds(plans, root), null)) return null;
|
|
4018
|
+
const u = orchestratorUsage(p.transcript_path, loadPricing(ctx), {
|
|
4019
|
+
cacheFile: orchCacheFile(ctx.paths, p.session_id, p.transcript_path),
|
|
4020
|
+
coldMaxBytes: HOOK_COLD_MAX_BYTES,
|
|
4021
|
+
});
|
|
4022
|
+
const tokens = typeof u.ctx_tokens === 'number' ? u.ctx_tokens : null;
|
|
4023
|
+
const comp = typeof u.compactions === 'number' ? u.compactions : null;
|
|
4024
|
+
const over = (tokens !== null && tokens > SESSION_CAP_TOKENS) || (comp !== null && comp >= SESSION_CAP_COMPACTIONS);
|
|
4025
|
+
if (!over) return null;
|
|
4026
|
+
const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'exec' },
|
|
4027
|
+
{ gate_action: 'deny', reason_code: 'session-cap' });
|
|
4028
|
+
const sid = p.session_id || '<session>';
|
|
4029
|
+
return {
|
|
4030
|
+
denied: true,
|
|
4031
|
+
event: ev,
|
|
4032
|
+
stdout: preToolDeny(`dieu-phoi: phiên điều phối đã chạm trần cứng (context ${tokens === null ? '?' : tokens} token, `
|
|
4033
|
+
+ `${comp === null ? '?' : comp} lần nén; trần ${SESSION_CAP_TOKENS} token hoặc ${SESSION_CAP_COMPACTIONS} lần nén) — `
|
|
4034
|
+
+ `không mở root mới trong phiên này. Đừng /compact thêm: ghi trạng thái (\`dieu-phoi state --session ${sid}\` + file trạng thái trong run dir), `
|
|
4035
|
+
+ 'trình phần đã xong, rồi MỞ PHIÊN MỚI và nạp lại từ state; giao root mới từ phiên đó. '
|
|
4036
|
+
+ 'Chỉ khi người dùng đồng ý giao ngay ở đây: thêm `|session_ok` vào thẻ.'),
|
|
4037
|
+
};
|
|
4038
|
+
}
|
|
4039
|
+
|
|
3290
4040
|
/** Mark the reservation of `child` as a real spawn. Returns true when its slot was found. */
|
|
3291
4041
|
function gateConfirm(ctx, key, child) {
|
|
3292
4042
|
if (!key || !child) return false;
|
|
@@ -3443,10 +4193,14 @@ function runHook(opts) {
|
|
|
3443
4193
|
if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
|
|
3444
4194
|
if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
|
|
3445
4195
|
if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
|
|
4196
|
+
if (name === 'PreToolUse') return handleChainGate(p, ctx); // { stdout } (deny) or null
|
|
3446
4197
|
if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
|
|
3447
4198
|
if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
|
|
3448
|
-
if (name === 'SubagentStop')
|
|
3449
|
-
|
|
4199
|
+
if (name === 'SubagentStop') {
|
|
4200
|
+
const r = handleSubagentStop(p, ctx);
|
|
4201
|
+
if (r && typeof r.stdout === 'string') return r; // s15: block decision, printed as the hook's JSON answer
|
|
4202
|
+
ev = r;
|
|
4203
|
+
} else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
|
|
3450
4204
|
if (ev) {
|
|
3451
4205
|
try { maybeSpawnSync(ctx); } catch (e) { logError(home, e, 'spawn-sync'); }
|
|
3452
4206
|
}
|
|
@@ -3596,22 +4350,22 @@ async function runSync(ctx, opts) {
|
|
|
3596
4350
|
// ------------------------------------------------------------------ exports / main
|
|
3597
4351
|
|
|
3598
4352
|
module.exports = {
|
|
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,
|
|
4353
|
+
runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, startKnowledgeOf, knowledgeLines, orchLessons, orchLessonLines, abortReasonOf, completionStatus, logGate, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, findOrphanAgents, orphanLines, ORPHAN_MIN_MS, ORPHAN_NO_COMPLETE_MS, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
|
|
3600
4354
|
modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, MODE_ASK_LINE, isMode, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
|
|
3601
4355
|
modeSuggestion, modeSuggestLine, fastFailRate, rootTaskIds, FAST_FAIL_WINDOW, FAST_FAIL_MIN_N, FAST_FAIL_RATE,
|
|
3602
4356
|
testCommandInfo, testTimeOf, testSFromText,
|
|
3603
|
-
CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
|
|
4357
|
+
CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED, TYPE_ALIASES,
|
|
3604
4358
|
sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
|
|
3605
4359
|
logError, computeUserHash, userHash, commonFields, parseTag, resolveTag, knownTaskTypes, tagIssueMessage, DEFAULT_EXTRA_TYPES,
|
|
3606
|
-
recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
|
|
4360
|
+
recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, HANDOFF_CHAIN_DENY, handleChainGate, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
|
|
3607
4361
|
priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
|
|
3608
4362
|
firstPromptText, tagFromTranscript, summarizeTranscript,
|
|
3609
|
-
RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, EVENTS_LOCK_WAIT_MS, eventLinesFrom, readEventLines, readEvents, readEventsBuffer, archiveFiles, readArchiveLines,
|
|
4363
|
+
RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, countCompactions, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, SESSION_CAP_TOKENS, SESSION_CAP_COMPACTIONS, appendEvent, EVENTS_LOCK_WAIT_MS, eventLinesFrom, readEventLines, readEvents, readEventsBuffer, archiveFiles, readArchiveLines,
|
|
3610
4364
|
loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
|
|
3611
4365
|
HANDOFF_MARKER, HANDOFF_THRESHOLDS, watchThresholds, GATE_MAX_CHILDREN, HELPER_AGENT_TYPE, watchAgentId, subagentTranscriptPath, readHeadTag, lastContextTokens, watchState, handoffMessage, handleWatch, handleGate, handleGateRelease, gateDir, gateSlots, gateReserve, gateConfirm, GATE_PENDING_MS,
|
|
3612
|
-
globMatch, pathspecOf, diffShas, dirtyFiles, computeFreshness, addDirty, indexStamp, freshnessCacheFile, readFreshCache, cachedFor, freshCounts, knowledgeSummary, shortFileList, readStoreIndex, relatedStatus, startKnowledge, FRESH_CACHE_MAX, DIFF_TIMEOUT_MS, HOOK_DIFF_TIMEOUT_MS, DIRTY_TIMEOUT_MS,
|
|
4366
|
+
globMatch, pathspecOf, diffShas, dirtyFiles, computeFreshness, addDirty, indexStamp, freshnessCacheFile, readFreshCache, cachedFor, freshCounts, knowledgeSummary, shortFileList, readStoreIndex, relatedStatus, startKnowledge, deltaHash, deltaRepoOf, isBareRepoName, storeCatalog, storeByName, scanUnmergedDeltas, scanUnmergedDeltasFor, unmergedDeltaLines, hasUnmergedDeltas, deltaTagOf, deltaClassifier, dispatchRun, DELTA_WARN_MS, DELTA_WINDOW_MS, FRESH_CACHE_MAX, DIFF_TIMEOUT_MS, HOOK_DIFF_TIMEOUT_MS, DIRTY_TIMEOUT_MS,
|
|
3613
4367
|
projectsDir, findGit, originUrlFromConfig, normalizeRemoteUrl, resolveProject, readHead, repoFromBrief, projectFields, nativePath, isStoreDirName, aliasesFile, loadAliases, keyDirName,
|
|
3614
|
-
extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
|
|
4368
|
+
extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, openBackgroundTasks, scanLeftoverProcs, otherAgentsRunning, maybeSpawnSync, runHook, runSync,
|
|
3615
4369
|
};
|
|
3616
4370
|
|
|
3617
4371
|
if (require.main === module) {
|