@kienntpixon/dieu-phoi 0.5.2 → 0.6.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 +73 -0
- package/README.md +9 -3
- package/agents/dp-exec.md +2 -0
- package/agents/dp-scout.md +2 -0
- package/agents/dp-verify.md +2 -0
- package/bin/dieu-phoi.cjs +21 -3
- package/hook.cjs +907 -19
- package/lib/args.cjs +19 -2
- package/lib/commands.cjs +109 -22
- package/lib/knowledge.cjs +1002 -0
- package/lib/profile.cjs +204 -0
- package/lib/prune.cjs +57 -20
- package/package.json +1 -1
package/hook.cjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* handoff watchdog), `--gate` (PreToolUse(Agent), gen-2 helper rules) and `--gate-release`. It must stay
|
|
10
10
|
* self-contained: Node built-ins only, no requires of other package files.
|
|
11
11
|
*
|
|
12
|
-
* Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit
|
|
12
|
+
* Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit / a PostToolUse(Agent) warning
|
|
13
|
+
* (core 0.5.3: bad dp tag, handoff chain), always exit 0,
|
|
13
14
|
* no network.
|
|
14
15
|
* Errors go to <home>/hook-errors.log.
|
|
15
16
|
*
|
|
@@ -25,7 +26,7 @@ const path = require('path');
|
|
|
25
26
|
const os = require('os');
|
|
26
27
|
const crypto = require('crypto');
|
|
27
28
|
|
|
28
|
-
const CORE_VERSION = '0.
|
|
29
|
+
const CORE_VERSION = '0.6.0';
|
|
29
30
|
const SCHEMA = 1;
|
|
30
31
|
const TASK_TYPES = [
|
|
31
32
|
'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
|
|
@@ -91,6 +92,42 @@ function writeFileAtomic(file, content, mode) {
|
|
|
91
92
|
renameWithRetry(tmp, file);
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Short advisory lock (O_EXCL file) for read-modify-write of shared state while parallel sessions run hooks.
|
|
97
|
+
* Returns a release function, or null when the lock was not obtained within `timeoutMs` (callers choose whether
|
|
98
|
+
* to go on unlocked or give up). A lock older than `staleMs` (holder died) is taken over. Release only removes
|
|
99
|
+
* the file if it is still ours, so a taken-over lock is never deleted by its old holder.
|
|
100
|
+
*/
|
|
101
|
+
function acquireFileLock(file, o) {
|
|
102
|
+
const timeoutMs = (o && o.timeoutMs) || 5000;
|
|
103
|
+
const staleMs = (o && o.staleMs) || 30000;
|
|
104
|
+
const token = `${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
105
|
+
const deadline = Date.now() + timeoutMs;
|
|
106
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); } catch (_) { /* surfaces below */ }
|
|
107
|
+
for (let spin = 0; ; spin++) {
|
|
108
|
+
try {
|
|
109
|
+
fs.writeFileSync(file, token, { flag: 'wx' });
|
|
110
|
+
return () => {
|
|
111
|
+
try { if (fs.readFileSync(file, 'utf8') === token) fs.unlinkSync(file); } catch (_) { /* gone */ }
|
|
112
|
+
};
|
|
113
|
+
} catch (e) {
|
|
114
|
+
if (e.code !== 'EEXIST' && e.code !== 'EPERM' && e.code !== 'EACCES' && e.code !== 'EBUSY') return null;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
if (Date.now() - fs.statSync(file).mtimeMs > staleMs) { fs.unlinkSync(file); continue; }
|
|
118
|
+
} catch (_) { /* vanished: retry at once */ }
|
|
119
|
+
if (Date.now() >= deadline) return null;
|
|
120
|
+
sleepSync(spin < 20 ? 2 : 10);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Run fn under the lock; `fn` also runs (unlocked) when the lock times out unless o.required. Returns {locked, value}. */
|
|
125
|
+
function withFileLock(file, o, fn) {
|
|
126
|
+
const release = acquireFileLock(file, o);
|
|
127
|
+
if (!release && o && o.required) return { locked: false, skipped: true, value: undefined };
|
|
128
|
+
try { return { locked: !!release, skipped: false, value: fn() }; } finally { if (release) release(); }
|
|
129
|
+
}
|
|
130
|
+
|
|
94
131
|
// ------------------------------------------------------------------ paths / config
|
|
95
132
|
|
|
96
133
|
/** Claude config dir: CLAUDE_CONFIG_DIR, else ~/.claude. */
|
|
@@ -126,6 +163,7 @@ function pathsFor(home) {
|
|
|
126
163
|
events: path.join(data, 'events.jsonl'),
|
|
127
164
|
segments: path.join(data, 'segments'),
|
|
128
165
|
plans: path.join(data, 'plans'),
|
|
166
|
+
suggest: path.join(data, 'suggest'),
|
|
129
167
|
skippedInternal: path.join(data, 'skipped-internal.json'),
|
|
130
168
|
syncOffset: path.join(data, 'sync-offset.json'),
|
|
131
169
|
syncSpawn: path.join(data, 'sync-spawn.json'),
|
|
@@ -133,6 +171,8 @@ function pathsFor(home) {
|
|
|
133
171
|
sinkCheck: path.join(data, 'sink-check.json'),
|
|
134
172
|
syncRejected: path.join(data, 'sync-rejected.jsonl'),
|
|
135
173
|
lock: path.join(data, 'sync.lock'),
|
|
174
|
+
eventsLock: path.join(data, 'events.lock'),
|
|
175
|
+
ctxWarnLock: path.join(data, 'ctx-warn.lock'),
|
|
136
176
|
config: path.join(home, 'config.json'),
|
|
137
177
|
errlog: path.join(home, 'hook-errors.log'),
|
|
138
178
|
};
|
|
@@ -263,6 +303,87 @@ function taskIdError(id) {
|
|
|
263
303
|
return `task_id "${id}" không hợp lệ: cần đúng 8 ký tự a-z0-9 (vd. ${newTaskId()}) — lấy id bằng \`dieu-phoi new-id\``;
|
|
264
304
|
}
|
|
265
305
|
|
|
306
|
+
// ------------------------------------------------------------------ tag check (core 0.5.3, retro 2026-10-22)
|
|
307
|
+
//
|
|
308
|
+
// 76 dp-exec ($407, 25% of 10-19..21 cost) lost their task_id because the brief put the id in the type slot
|
|
309
|
+
// (`[dp:<id>|…]`, no `task=`). parseTag stays strict (the gen-2 gate relies on it); resolveTag diagnoses the tag,
|
|
310
|
+
// flags it (`tag_issue`) and recovers the task_id only when the guess is safe.
|
|
311
|
+
|
|
312
|
+
const DEFAULT_EXTRA_TYPES = ['art-gen'];
|
|
313
|
+
|
|
314
|
+
/** Fixed task types + the profile's extra_task_types (art-gen when no profile is readable). */
|
|
315
|
+
function knownTaskTypes(ctx) {
|
|
316
|
+
const out = TASK_TYPES.concat(DEFAULT_EXTRA_TYPES);
|
|
317
|
+
const sd = ctx && ctx.config && ctx.config.skill_dir;
|
|
318
|
+
if (sd) {
|
|
319
|
+
const prof = readJsonFile(path.join(sd, 'profile', 'profile.json'), null);
|
|
320
|
+
const extra = prof && Array.isArray(prof.extra_task_types) ? prof.extra_task_types : [];
|
|
321
|
+
for (const t of extra) if (typeof t === 'string' && !out.includes(t)) out.push(t);
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* parseTag + diagnosis. null when the first line has no `[dp:` tag at all. Otherwise a tag (never dropped), plus:
|
|
328
|
+
* - `tag_issue`: `type-is-id` (an 8-char [a-z0-9] id in the type slot), `bad-type` (type not a known task type),
|
|
329
|
+
* `missing-task` (no `task=`), joined with `+`;
|
|
330
|
+
* - `task_id_inferred: true` + `task_id` = the type-slot token, only when `task=` is missing, the token matches the
|
|
331
|
+
* task_id format, is not a known type, and either holds a digit or the prompt names it in a run-file path /
|
|
332
|
+
* `task=` (`dp-out-<id>`, `handoff-<id>`) — the same test that tells `type-is-id` from `bad-type` (an 8-letter label
|
|
333
|
+
* such as "planning" stays a label). Then task_type = another tag part that is a known type, else `untagged`.
|
|
334
|
+
*/
|
|
335
|
+
function resolveTag(text, known) {
|
|
336
|
+
if (typeof text !== 'string') return null;
|
|
337
|
+
const body = text.replace(/^/, '');
|
|
338
|
+
const first = body.split(/\r?\n/, 1)[0].trim();
|
|
339
|
+
const m = /^\[dp:([^\]]*)\]/.exec(first);
|
|
340
|
+
if (!m) return null;
|
|
341
|
+
const types = Array.isArray(known) && known.length ? known : TASK_TYPES.concat(DEFAULT_EXTRA_TYPES);
|
|
342
|
+
const parts = m[1].split('|').map((s) => s.trim());
|
|
343
|
+
const head = (parts[0] || '').toLowerCase();
|
|
344
|
+
const rest = parts.slice(1);
|
|
345
|
+
let tag = parseTag(body);
|
|
346
|
+
// type slot unusable for parseTag (e.g. an id starting with a digit): parse the other parts on their own
|
|
347
|
+
if (!tag) tag = parseTag(`[dp:untagged|${rest.join('|')}]`);
|
|
348
|
+
const issues = [];
|
|
349
|
+
const altType = rest.map((p) => p.toLowerCase()).find((p) => types.includes(p)) || null;
|
|
350
|
+
// 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
|
+
const named = new RegExp(`(?:dp-out-|handoff-|task=)${escRe(head)}(?![a-z0-9])`).test(body.slice(first.length));
|
|
352
|
+
const idLike = TASK_ID_RE.test(head) && !types.includes(head) && (/\d/.test(head) || named);
|
|
353
|
+
if (idLike) issues.push('type-is-id');
|
|
354
|
+
else if (!types.includes(head)) {
|
|
355
|
+
issues.push('bad-type');
|
|
356
|
+
tag.bad_type = parts[0]; // the offending token (task_type may be replaced by a valid altType below)
|
|
357
|
+
}
|
|
358
|
+
if (!tag.task_id) {
|
|
359
|
+
issues.push('missing-task');
|
|
360
|
+
if (idLike) {
|
|
361
|
+
tag.task_id = head;
|
|
362
|
+
tag.task_id_inferred = true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (idLike || tag.task_type === 'untagged') tag.task_type = altType || 'untagged';
|
|
366
|
+
else if (!types.includes(tag.task_type) && altType) tag.task_type = altType;
|
|
367
|
+
if (issues.length) tag.tag_issue = issues.join('+');
|
|
368
|
+
return tag;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** One Vietnamese line for the orchestrator about a flagged tag (null when the tag is fine). */
|
|
372
|
+
function tagIssueMessage(tag, known) {
|
|
373
|
+
if (!tag || !tag.tag_issue) return null;
|
|
374
|
+
const what = [];
|
|
375
|
+
const codes = tag.tag_issue.split('+');
|
|
376
|
+
if (codes.includes('no-tag')) what.push('agent dp-* được giao mà không có thẻ `[dp:...]` ở dòng đầu prompt');
|
|
377
|
+
if (codes.includes('type-is-id')) what.push('ô đầu của thẻ là một task id, không phải loại task');
|
|
378
|
+
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
|
+
if (codes.includes('missing-task')) what.push('thiếu `task=<id>`');
|
|
380
|
+
const got = tag.task_id_inferred
|
|
381
|
+
? ` Hook đã suy task_id=${tag.task_id} từ ô loại (log ghi \`task_id_inferred\`), loại ghi là "${tag.task_type}".`
|
|
382
|
+
: (tag.task_id ? '' : ' Lượt này KHÔNG có task_id: không vào stats/choose/retro.');
|
|
383
|
+
const types = (Array.isArray(known) && known.length ? known : TASK_TYPES.concat(DEFAULT_EXTRA_TYPES)).join(', ');
|
|
384
|
+
return `dieu-phoi: thẻ của agent vừa giao sai (tag_issue=${tag.tag_issue}): ${what.join('; ')}.${got} Lần giao sau dùng đúng \`[dp:<task_type>|s<n>|<urgency>|try<n>|task=<id>|role=<role>]\`, task_type là một trong: ${types}; id lấy bằng \`dieu-phoi new-id\`.`;
|
|
385
|
+
}
|
|
386
|
+
|
|
266
387
|
// ------------------------------------------------------------------ models / pricing
|
|
267
388
|
|
|
268
389
|
function normalizeModel(m) {
|
|
@@ -387,10 +508,13 @@ function firstPromptText(lines) {
|
|
|
387
508
|
return null;
|
|
388
509
|
}
|
|
389
510
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
511
|
+
/**
|
|
512
|
+
* Tag from the first user message of the transcript (the original prompt). Core 0.5.3: resolveTag, so a tag with the
|
|
513
|
+
* id in the type slot still yields its task_id (complete events, watchdog handoff file name, inline results).
|
|
514
|
+
*/
|
|
515
|
+
function tagFromTranscript(lines, known) {
|
|
392
516
|
const text = firstPromptText(lines);
|
|
393
|
-
return text === null ? null :
|
|
517
|
+
return text === null ? null : resolveTag(text, known);
|
|
394
518
|
}
|
|
395
519
|
|
|
396
520
|
/**
|
|
@@ -763,8 +887,17 @@ function findSessionTranscript(claudeDir, sessionId) {
|
|
|
763
887
|
* event — readers skip the fragment as one unparsable line. Withdrawing a task = `dieu-phoi discard`, never an
|
|
764
888
|
* edit of this file.
|
|
765
889
|
*/
|
|
890
|
+
const EVENTS_LOCK_WAIT_MS = 1500;
|
|
891
|
+
|
|
766
892
|
function appendEvent(ctx, ev) {
|
|
767
893
|
fs.mkdirSync(ctx.paths.data, { recursive: true });
|
|
894
|
+
// Core 0.5.4: appends take events.lock so `prune --yes` (which rewrites the file under the same lock) can never
|
|
895
|
+
// lose a line that lands between its read and its rename. Lock wait is capped well under the shortest hook timeout
|
|
896
|
+
// (UserPromptSubmit 5 s) so a stuck lock cannot get the hook killed mid-write; on timeout append anyway (never drop telemetry).
|
|
897
|
+
withFileLock(ctx.paths.eventsLock, { timeoutMs: EVENTS_LOCK_WAIT_MS, staleMs: 30000 }, () => appendEventRaw(ctx, ev));
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function appendEventRaw(ctx, ev) {
|
|
768
901
|
const fd = fs.openSync(ctx.paths.events, 'a+');
|
|
769
902
|
try {
|
|
770
903
|
let lead = '';
|
|
@@ -1356,13 +1489,29 @@ function stepIds(root, n) {
|
|
|
1356
1489
|
return Array.from(ids);
|
|
1357
1490
|
}
|
|
1358
1491
|
|
|
1359
|
-
|
|
1492
|
+
/**
|
|
1493
|
+
* core 0.6.0 (s3): project_knowledge of the repo at `cwd` — null outside a repo or without a store. Refreshes that repo
|
|
1494
|
+
* only (one git diff-tree on a HEAD/index cache miss, never git status); related repos report their cache.
|
|
1495
|
+
*/
|
|
1496
|
+
function startKnowledgeOf(cwd) {
|
|
1497
|
+
if (!cwd || typeof cwd !== 'string') return null;
|
|
1498
|
+
try { return startKnowledge(cwd); } catch (_) { return null; }
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function startInfo(ctx, cwd) {
|
|
1360
1502
|
const run = startRun();
|
|
1361
1503
|
const health = quickHealth(ctx, run.runs);
|
|
1362
|
-
|
|
1504
|
+
const info = {
|
|
1363
1505
|
id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx),
|
|
1364
1506
|
orch_lessons: orchLessons(ctx),
|
|
1365
1507
|
};
|
|
1508
|
+
const k = startKnowledgeOf(cwd);
|
|
1509
|
+
if (k) info.knowledge = k;
|
|
1510
|
+
return info;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function knowledgeLines(info) {
|
|
1514
|
+
return info && info.knowledge && Array.isArray(info.knowledge.lines) ? info.knowledge.lines : [];
|
|
1366
1515
|
}
|
|
1367
1516
|
|
|
1368
1517
|
function orchLessonLines(info) {
|
|
@@ -1379,6 +1528,7 @@ function formatStartBlock(info) {
|
|
|
1379
1528
|
`health: ${healthLine(info.health)}`,
|
|
1380
1529
|
`scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
|
|
1381
1530
|
...orchLessonLines(info),
|
|
1531
|
+
...knowledgeLines(info),
|
|
1382
1532
|
'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.',
|
|
1383
1533
|
].join('\n');
|
|
1384
1534
|
}
|
|
@@ -1386,7 +1536,7 @@ function formatStartBlock(info) {
|
|
|
1386
1536
|
/** UserPromptSubmit: on a dieu-phoi prompt, pre-create the run and inject id/run dir/health/brief. */
|
|
1387
1537
|
function handleUserPromptSubmit(p, ctx) {
|
|
1388
1538
|
if (!isDieuPhoiPrompt(p.prompt)) return null;
|
|
1389
|
-
const info = startInfo(ctx);
|
|
1539
|
+
const info = startInfo(ctx, typeof p.cwd === 'string' ? p.cwd : null);
|
|
1390
1540
|
return {
|
|
1391
1541
|
stdout: JSON.stringify({
|
|
1392
1542
|
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: formatStartBlock(info) },
|
|
@@ -1409,11 +1559,704 @@ function extractAgentId(tr) {
|
|
|
1409
1559
|
return m ? m[1] : null;
|
|
1410
1560
|
}
|
|
1411
1561
|
|
|
1562
|
+
// ------------------------------------------------------------------ core 0.5.3: choose's tier per task (retro 2026-10-22)
|
|
1563
|
+
//
|
|
1564
|
+
// `choose --task <id>` and `plan-table --approve` leave data/suggest/<task>-<role>.json; the next dispatch of that
|
|
1565
|
+
// task + role copies it as `suggested_tier` (choose's own pick, before a user override) and `choose_tier` (what
|
|
1566
|
+
// choose answered), next to the tier actually dispatched. Older than SUGGEST_MAX_AGE_MS -> ignored.
|
|
1567
|
+
|
|
1568
|
+
const SUGGEST_MAX_AGE_MS = 14 * 24 * 3600 * 1000;
|
|
1569
|
+
|
|
1570
|
+
function suggestFile(ctx, taskId, role) {
|
|
1571
|
+
return path.join(ctx.paths.suggest, `${safeName(taskId)}-${safeName(role || 'exec')}.json`);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
/**
|
|
1575
|
+
* rec: { task_id, role?, suggested_tier, choose_tier?, source: 'choose'|'plan', keep_suggested? }. keep_suggested:
|
|
1576
|
+
* a `choose --approved` answer carries no Thompson pick, so an earlier record's suggested_tier (from the plan table)
|
|
1577
|
+
* is kept. Best effort: returns the written record or null.
|
|
1578
|
+
*/
|
|
1579
|
+
function recordSuggestion(ctx, rec) {
|
|
1580
|
+
if (!rec || !isValidTaskId(rec.task_id) || typeof rec.suggested_tier !== 'string') return null;
|
|
1581
|
+
const role = rec.role || 'exec';
|
|
1582
|
+
const file = suggestFile(ctx, rec.task_id, role);
|
|
1583
|
+
const prev = rec.keep_suggested ? readSuggestion(ctx, rec.task_id, role) : null;
|
|
1584
|
+
const out = {
|
|
1585
|
+
task_id: rec.task_id,
|
|
1586
|
+
role,
|
|
1587
|
+
suggested_tier: prev && prev.suggested_tier ? prev.suggested_tier : rec.suggested_tier,
|
|
1588
|
+
choose_tier: rec.choose_tier || rec.suggested_tier,
|
|
1589
|
+
source: prev && prev.suggested_tier ? prev.source : (rec.source || 'choose'),
|
|
1590
|
+
ts: nowIso(),
|
|
1591
|
+
};
|
|
1592
|
+
try {
|
|
1593
|
+
fs.mkdirSync(ctx.paths.suggest, { recursive: true });
|
|
1594
|
+
writeFileAtomic(file, `${JSON.stringify(out)}\n`);
|
|
1595
|
+
pruneSuggest(ctx);
|
|
1596
|
+
} catch (e) {
|
|
1597
|
+
logError(ctx.home, e, 'suggest');
|
|
1598
|
+
return null;
|
|
1599
|
+
}
|
|
1600
|
+
return out;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function readSuggestion(ctx, taskId, role) {
|
|
1604
|
+
if (!isValidTaskId(taskId)) return null;
|
|
1605
|
+
const r = readJsonFile(suggestFile(ctx, taskId, role), null);
|
|
1606
|
+
if (!r || typeof r !== 'object' || typeof r.suggested_tier !== 'string') return null;
|
|
1607
|
+
const t = Date.parse(r.ts);
|
|
1608
|
+
if (!(Date.now() - t <= SUGGEST_MAX_AGE_MS)) return null;
|
|
1609
|
+
return r;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function pruneSuggest(ctx) {
|
|
1613
|
+
let names = [];
|
|
1614
|
+
try { names = fs.readdirSync(ctx.paths.suggest); } catch (_) { return; }
|
|
1615
|
+
const now = Date.now();
|
|
1616
|
+
for (const n of names) {
|
|
1617
|
+
const f = path.join(ctx.paths.suggest, n);
|
|
1618
|
+
try { if (now - fs.statSync(f).mtimeMs > 2 * SUGGEST_MAX_AGE_MS) fs.unlinkSync(f); } catch (_) { /* gone */ }
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// ------------------------------------------------------------------ core 0.5.3: handoff chains (retro 2026-10-22)
|
|
1623
|
+
//
|
|
1624
|
+
// 15/63 feature-impl tasks handed off >= 2 times (19% of feature-impl cost); one chain ran 10 handoffs ($70) and
|
|
1625
|
+
// failed. From the 3rd handoff of a task the orchestrator is told to stop continuing and have a planner re-split.
|
|
1626
|
+
|
|
1627
|
+
const HANDOFF_CHAIN_WARN = 3;
|
|
1628
|
+
|
|
1629
|
+
/** Number of agents whose `complete` event has handoff:true for this task (substring scan of events.jsonl). */
|
|
1630
|
+
function countTaskHandoffs(ctx, taskId) {
|
|
1631
|
+
if (!taskId) return 0;
|
|
1632
|
+
let txt;
|
|
1633
|
+
try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { return 0; }
|
|
1634
|
+
const needle = `"task_id":${JSON.stringify(taskId)}`;
|
|
1635
|
+
const agents = new Set();
|
|
1636
|
+
let i = txt.indexOf(needle);
|
|
1637
|
+
while (i >= 0) {
|
|
1638
|
+
const start = txt.lastIndexOf('\n', i) + 1;
|
|
1639
|
+
const end = txt.indexOf('\n', i);
|
|
1640
|
+
const line = txt.slice(start, end < 0 ? undefined : end);
|
|
1641
|
+
if (line.includes('"kind":"complete"') && line.includes('"handoff":true')) {
|
|
1642
|
+
const m = /"agent_id":("(?:[^"\\]|\\.)*")/.exec(line);
|
|
1643
|
+
agents.add(m ? m[1] : `@${start}`);
|
|
1644
|
+
}
|
|
1645
|
+
i = end < 0 ? -1 : txt.indexOf(needle, end);
|
|
1646
|
+
}
|
|
1647
|
+
return agents.size;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function handoffChainMessage(taskId, n) {
|
|
1651
|
+
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
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// ------------------------------------------------------------------ core 0.6.0 (unreleased): project identity
|
|
1655
|
+
//
|
|
1656
|
+
// A project (= one git repo, however many worktrees/clones) gets a stable key so every session and worktree can
|
|
1657
|
+
// share one knowledge store (~/.dieu-phoi/projects/<name>-<sha8(key)>/). Everything here is plain file reads —
|
|
1658
|
+
// never spawns git — because it runs in hooks on the hot path.
|
|
1659
|
+
// key = "remote:<host>/<path>" from remote.origin.url (credentials, scheme, .git stripped; host lower-cased)
|
|
1660
|
+
// | "git:<realpath of git-common-dir>" for a repo without remote (worktrees share the common dir)
|
|
1661
|
+
// A repo that later gains a remote changes key: `dieu-phoi knowledge rekey` records the new key as an alias of the
|
|
1662
|
+
// existing store dir (<projects>/aliases.json); the dir itself is never renamed.
|
|
1663
|
+
|
|
1664
|
+
/** Knowledge store root: ~/.dieu-phoi/projects (override: DIEU_PHOI_PROJECTS_DIR, absolute). */
|
|
1665
|
+
function projectsDir() {
|
|
1666
|
+
if (process.env.DIEU_PHOI_PROJECTS_DIR) return path.resolve(process.env.DIEU_PHOI_PROJECTS_DIR);
|
|
1667
|
+
return path.join(os.homedir(), '.dieu-phoi', 'projects');
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function realDir(p) {
|
|
1671
|
+
try { return fs.realpathSync.native(p); } catch (_) { /* fall through */ }
|
|
1672
|
+
try { return fs.realpathSync(p); } catch (_) { return path.resolve(p); }
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
function toSlash(p) {
|
|
1676
|
+
return String(p).replace(/\\/g, '/');
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/** Comparable form of a local path: forward slashes, no trailing slash, lower-case on Windows. */
|
|
1680
|
+
function pathId(p) {
|
|
1681
|
+
const s = toSlash(p).replace(/\/+$/, '');
|
|
1682
|
+
return process.platform === 'win32' ? s.toLowerCase() : s;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function readTextFile(file) {
|
|
1686
|
+
try { return fs.readFileSync(file, 'utf8'); } catch (_) { return null; }
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function isDir(p) {
|
|
1690
|
+
try { return fs.statSync(p).isDirectory(); } catch (_) { return false; }
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function gitDirsOf(root, gitDir) {
|
|
1694
|
+
const cd = readTextFile(path.join(gitDir, 'commondir'));
|
|
1695
|
+
const commonDir = cd && cd.trim() ? path.resolve(gitDir, cd.trim()) : gitDir;
|
|
1696
|
+
return { root, gitDir, commonDir };
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* Walk up from `start` to the nearest repo. `.git` may be a directory (main checkout) or a file `gitdir: <path>`
|
|
1701
|
+
* (linked worktree or submodule); a linked worktree's `commondir` file points at the shared git dir.
|
|
1702
|
+
* Returns { root, gitDir, commonDir } (root null for a bare repo / a git dir passed directly) or null.
|
|
1703
|
+
*/
|
|
1704
|
+
function findGit(start) {
|
|
1705
|
+
let dir;
|
|
1706
|
+
try {
|
|
1707
|
+
dir = path.resolve(start);
|
|
1708
|
+
if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir);
|
|
1709
|
+
} catch (_) { return null; }
|
|
1710
|
+
if (fs.existsSync(path.join(dir, 'HEAD')) && isDir(path.join(dir, 'objects')) && isDir(path.join(dir, 'refs'))) {
|
|
1711
|
+
return gitDirsOf(null, dir); // bare repo or a .git dir itself
|
|
1712
|
+
}
|
|
1713
|
+
for (let i = 0; i < 64; i++) {
|
|
1714
|
+
const dotgit = path.join(dir, '.git');
|
|
1715
|
+
let st = null;
|
|
1716
|
+
try { st = fs.statSync(dotgit); } catch (_) { /* none here */ }
|
|
1717
|
+
if (st && st.isDirectory()) return gitDirsOf(dir, dotgit);
|
|
1718
|
+
if (st && st.isFile()) {
|
|
1719
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(readTextFile(dotgit) || '');
|
|
1720
|
+
if (m) return gitDirsOf(dir, path.resolve(dir, m[1]));
|
|
1721
|
+
}
|
|
1722
|
+
const up = path.dirname(dir);
|
|
1723
|
+
if (up === dir) return null;
|
|
1724
|
+
dir = up;
|
|
1725
|
+
}
|
|
1726
|
+
return null;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function unquoteGitValue(v) {
|
|
1730
|
+
let s = String(v).trim();
|
|
1731
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') s = s.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
1732
|
+
return s.trim();
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/** remote.origin.url from the text of a git config (INI subset: [remote "origin"] url = ...). */
|
|
1736
|
+
function originUrlFromConfig(text) {
|
|
1737
|
+
let inOrigin = false;
|
|
1738
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
1739
|
+
const t = line.trim();
|
|
1740
|
+
if (!t || t[0] === '#' || t[0] === ';') continue;
|
|
1741
|
+
const h = /^\[\s*([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\](.*)$/.exec(t);
|
|
1742
|
+
if (h) {
|
|
1743
|
+
inOrigin = h[1].toLowerCase() === 'remote' && h[2] === 'origin';
|
|
1744
|
+
const rest = h[3].trim();
|
|
1745
|
+
const u0 = inOrigin && rest ? /^url\s*=\s*(.*)$/i.exec(rest) : null;
|
|
1746
|
+
if (u0) return unquoteGitValue(u0[1]);
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
if (!inOrigin) continue;
|
|
1750
|
+
const u = /^url\s*=\s*(.*)$/i.exec(t);
|
|
1751
|
+
if (u) return unquoteGitValue(u[1]);
|
|
1752
|
+
}
|
|
1753
|
+
return null;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Canonical form of a remote URL: `host/path` for ssh/https/git/scp-like URLs (credentials, scheme, ssh port, query,
|
|
1758
|
+
* trailing `.git` and slashes dropped; host lower-cased; a non-default http(s) port is kept), `file:<path>` for local
|
|
1759
|
+
* remotes. ssh and https clones of the same repo therefore share a key. Null for an empty value.
|
|
1760
|
+
*/
|
|
1761
|
+
function normalizeRemoteUrl(raw) {
|
|
1762
|
+
const s = String(raw == null ? '' : raw).trim();
|
|
1763
|
+
if (!s) return null;
|
|
1764
|
+
let host = null;
|
|
1765
|
+
let p = null;
|
|
1766
|
+
const url = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i.exec(s);
|
|
1767
|
+
if (url && url[1].toLowerCase() !== 'file') {
|
|
1768
|
+
const scheme = url[1].toLowerCase();
|
|
1769
|
+
const rest = url[2];
|
|
1770
|
+
const slash = rest.indexOf('/');
|
|
1771
|
+
const auth = slash < 0 ? rest : rest.slice(0, slash);
|
|
1772
|
+
p = slash < 0 ? '' : rest.slice(slash + 1);
|
|
1773
|
+
const hostport = auth.slice(auth.lastIndexOf('@') + 1);
|
|
1774
|
+
const hp = /^(\[[^\]]+\]|[^:]*)(?::(\d*))?$/.exec(hostport) || [null, hostport, ''];
|
|
1775
|
+
host = hp[1].toLowerCase();
|
|
1776
|
+
const port = hp[2];
|
|
1777
|
+
if (port && /^https?$/.test(scheme) && !(scheme === 'http' && port === '80') && !(scheme === 'https' && port === '443')) {
|
|
1778
|
+
host += `:${port}`;
|
|
1779
|
+
}
|
|
1780
|
+
} else {
|
|
1781
|
+
const scp = !url && !/^[A-Za-z]:[\\/]/.test(s) ? /^(?:[^/\\:@]*@)?([^/\\:]+):(?!\/\/)(.*)$/.exec(s) : null;
|
|
1782
|
+
if (scp) {
|
|
1783
|
+
host = scp[1].toLowerCase();
|
|
1784
|
+
p = scp[2];
|
|
1785
|
+
} else {
|
|
1786
|
+
let local = url ? url[2] : s; // file://...
|
|
1787
|
+
if (url) local = local.replace(/^\/(?=[A-Za-z]:)/, '');
|
|
1788
|
+
local = toSlash(local).replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
1789
|
+
return `file:${pathId(local)}`;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
p = toSlash(p).replace(/[?#].*$/, '').replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/i, '').replace(/\/+$/, '');
|
|
1793
|
+
if (!host) return null;
|
|
1794
|
+
return p ? `${host}/${p}` : host;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function safeName(n) {
|
|
1798
|
+
const s = String(n || '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 40);
|
|
1799
|
+
return s || 'repo';
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function gitFallbackName(commonDir) {
|
|
1803
|
+
const base = path.basename(commonDir);
|
|
1804
|
+
return safeName(base === '.git' ? path.basename(path.dirname(commonDir)) : base.replace(/\.git$/i, ''));
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function keyDirName(key, name) {
|
|
1808
|
+
return `${name}-${sha8(key)}`;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** A store dir name as this module makes them (also guards aliases.json / --from against traversal). */
|
|
1812
|
+
function isStoreDirName(n) {
|
|
1813
|
+
return typeof n === 'string' && /^[A-Za-z0-9._-]+$/.test(n) && !/^\.+$/.test(n);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function aliasesFile() {
|
|
1817
|
+
return path.join(projectsDir(), 'aliases.json');
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function loadAliases() {
|
|
1821
|
+
const a = readJsonFile(aliasesFile(), {});
|
|
1822
|
+
return a && typeof a === 'object' && !Array.isArray(a) ? a : {};
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* Project identity of any path inside a repo (main checkout, linked worktree, clone) — file reads only.
|
|
1827
|
+
* `dir` is the store dir (it need not exist yet); `alias` is true when the dir was reached through aliases.json.
|
|
1828
|
+
* With `fallback`, also reports `rekey_from`: the dir name of the remote-less (git-common-dir) key when that store
|
|
1829
|
+
* exists but the remote key's does not (the repo gained a remote since). Null when `start` is not in a repo.
|
|
1830
|
+
*/
|
|
1831
|
+
function resolveProject(start, opts) {
|
|
1832
|
+
const g = findGit(start);
|
|
1833
|
+
if (!g) return null;
|
|
1834
|
+
const o = opts || {};
|
|
1835
|
+
const raw = originUrlFromConfig(readTextFile(path.join(g.commonDir, 'config')));
|
|
1836
|
+
const remote = raw ? normalizeRemoteUrl(raw) : null;
|
|
1837
|
+
const gitKey = `git:${pathId(realDir(g.commonDir))}`;
|
|
1838
|
+
const gitName = gitFallbackName(g.commonDir);
|
|
1839
|
+
const key = remote ? `remote:${remote}` : gitKey;
|
|
1840
|
+
const name = remote ? safeName(remote.split('/').pop() || remote) : gitName;
|
|
1841
|
+
const own = keyDirName(key, name);
|
|
1842
|
+
const store = projectsDir();
|
|
1843
|
+
let dirName = own;
|
|
1844
|
+
let alias = false;
|
|
1845
|
+
if (!isDir(path.join(store, own))) {
|
|
1846
|
+
const a = loadAliases()[key];
|
|
1847
|
+
if (isStoreDirName(a) && isDir(path.join(store, a))) { dirName = a; alias = true; }
|
|
1848
|
+
}
|
|
1849
|
+
const out = {
|
|
1850
|
+
root: g.root ? toSlash(g.root) : null,
|
|
1851
|
+
git_dir: toSlash(g.gitDir),
|
|
1852
|
+
common_dir: toSlash(g.commonDir),
|
|
1853
|
+
key,
|
|
1854
|
+
kind: remote ? 'remote' : 'git',
|
|
1855
|
+
remote,
|
|
1856
|
+
name,
|
|
1857
|
+
dir_name: dirName,
|
|
1858
|
+
dir: toSlash(path.join(store, dirName)),
|
|
1859
|
+
exists: isDir(path.join(store, dirName)),
|
|
1860
|
+
alias,
|
|
1861
|
+
};
|
|
1862
|
+
if (o.fallback && remote && !out.exists) {
|
|
1863
|
+
const legacy = keyDirName(gitKey, gitName);
|
|
1864
|
+
if (isDir(path.join(store, legacy))) out.rekey_from = legacy;
|
|
1865
|
+
}
|
|
1866
|
+
return out;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
1870
|
+
const PER_WORKTREE_REF_RE = /^refs\/(bisect|worktree|rewritten)\//;
|
|
1871
|
+
|
|
1872
|
+
function resolveRef(g, ref, depth) {
|
|
1873
|
+
if (depth > 5) return null;
|
|
1874
|
+
const base = PER_WORKTREE_REF_RE.test(ref) ? g.gitDir : g.commonDir;
|
|
1875
|
+
const loose = readTextFile(path.join(base, ref));
|
|
1876
|
+
if (loose != null) {
|
|
1877
|
+
const v = loose.trim();
|
|
1878
|
+
if (SHA_RE.test(v)) return v.toLowerCase();
|
|
1879
|
+
const sym = /^ref:\s*(\S+)/.exec(v);
|
|
1880
|
+
return sym ? resolveRef(g, sym[1], depth + 1) : null;
|
|
1881
|
+
}
|
|
1882
|
+
const packed = readTextFile(path.join(g.commonDir, 'packed-refs'));
|
|
1883
|
+
if (packed) {
|
|
1884
|
+
const needle = ` ${ref}`;
|
|
1885
|
+
for (const line of packed.split(/\r?\n/)) {
|
|
1886
|
+
if (line.endsWith(needle) && /^[0-9a-f]{40}/i.test(line)) return line.slice(0, line.length - needle.length).trim().toLowerCase();
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
return null;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/**
|
|
1893
|
+
* HEAD of the repo around `start` (a path, or the object findGit returned) without spawning git, in < 1 ms:
|
|
1894
|
+
* { branch, sha, detached } | { branch, sha: null, unborn: true } | { sha: null, unsupported: 'reftable' } | null.
|
|
1895
|
+
* `unsupported` means the caller must fall back to `git rev-parse` (CLI only, never in a hook).
|
|
1896
|
+
*/
|
|
1897
|
+
function readHead(start) {
|
|
1898
|
+
const g = start && typeof start === 'object' && start.gitDir ? start : findGit(start);
|
|
1899
|
+
if (!g) return null;
|
|
1900
|
+
const txt = readTextFile(path.join(g.gitDir, 'HEAD'));
|
|
1901
|
+
if (txt == null) return null;
|
|
1902
|
+
const t = txt.trim();
|
|
1903
|
+
if (SHA_RE.test(t)) return { branch: null, sha: t.toLowerCase(), detached: true };
|
|
1904
|
+
const m = /^ref:\s*(\S+)/.exec(t);
|
|
1905
|
+
if (!m) return null;
|
|
1906
|
+
const ref = m[1];
|
|
1907
|
+
if (ref === 'refs/heads/.invalid' || isDir(path.join(g.commonDir, 'reftable'))) {
|
|
1908
|
+
return { branch: null, sha: null, detached: false, unsupported: 'reftable' };
|
|
1909
|
+
}
|
|
1910
|
+
const branch = ref.replace(/^refs\/heads\//, '');
|
|
1911
|
+
const sha = resolveRef(g, ref, 0);
|
|
1912
|
+
return sha ? { branch, sha, detached: false } : { branch, sha: null, detached: false, unborn: true };
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// ------------------------------------------------------------------ core 0.6.0 (unreleased), s3: knowledge freshness
|
|
1916
|
+
//
|
|
1917
|
+
// A knowledge section is stale when a file matching its `paths` globs differs between the commit it was merged at
|
|
1918
|
+
// (`sha`) and the HEAD of the worktree asking. Used by the start block here and by `knowledge get/list/status`
|
|
1919
|
+
// (lib/knowledge.cjs). Lives in hook.cjs because the installed hook is a single file (builtins only). Rules, from
|
|
1920
|
+
// measurements on the pixon-fast-forward repos (contract r1-2 §2):
|
|
1921
|
+
// - ONE git process per check, whatever the number of distinct section shas: `git diff-tree --stdin` reads one
|
|
1922
|
+
// "<sha> <HEAD>" line per sha. Always `--no-renames` (rename detection: 12.8 s vs 77 ms on pff-dressmaker) and a
|
|
1923
|
+
// pathspec = the union of the sections' globs.
|
|
1924
|
+
// - The result is cached per (worktree HEAD, index.json mtime+size) in <store>/cache/freshness.json; HEAD is read
|
|
1925
|
+
// from files (readHead), so an unchanged HEAD costs a stat and a small JSON read, no process.
|
|
1926
|
+
// - A section sha missing from the object store (merged in another clone, branch deleted and gc'd) or abbreviated is
|
|
1927
|
+
// "unknown", never fresh, never a crash. A sha on an unmerged branch that exists locally is diffed like any other
|
|
1928
|
+
// commit: the net diff is what differs between the code the knowledge describes and the code checked out.
|
|
1929
|
+
// - Uncommitted changes only with `--dirty` (CLI): `git --no-optional-locks status -uno --no-renames`, 2 s timeout,
|
|
1930
|
+
// "chưa kiểm" when it runs out (pff-rotate-rings: 67 s cold). Hooks never run git status.
|
|
1931
|
+
// - The start block refreshes the cwd repo only (one diff-tree on a cache miss); related repos show their cache.
|
|
1932
|
+
|
|
1933
|
+
const FRESH_CACHE_MAX = 12; // HEADs remembered per store (worktrees of one repo share the store)
|
|
1934
|
+
const DIFF_TIMEOUT_MS = 10000;
|
|
1935
|
+
const HOOK_DIFF_TIMEOUT_MS = 1500;
|
|
1936
|
+
const DIRTY_TIMEOUT_MS = 2000;
|
|
1937
|
+
const PATHSPEC_MAX_CHARS = 6000; // beyond this: no pathspec (Windows command line limit), filter in JS only
|
|
1938
|
+
const STALE_FILES_KEEP = 50; // stale files kept per section (the count stays exact)
|
|
1939
|
+
const FULL_SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* Does repo-relative file `rel` match glob `g`? `**` spans directories (`a/**` and `**\/x`), `*`/`?` stay within one
|
|
1943
|
+
* segment, a trailing `/` or a wildcard-free pattern naming a directory matches everything below it.
|
|
1944
|
+
*/
|
|
1945
|
+
function globMatch(g, rel) {
|
|
1946
|
+
let p = toSlash(String(g).trim()).replace(/^\.\//, '').replace(/^\/+/, '');
|
|
1947
|
+
const f = toSlash(rel).replace(/^\.\//, '').replace(/^\/+/, '');
|
|
1948
|
+
if (!p) return false;
|
|
1949
|
+
const ci = process.platform === 'win32';
|
|
1950
|
+
const eq = (a, b) => (ci ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
1951
|
+
if (!/[*?]/.test(p)) {
|
|
1952
|
+
const d = p.replace(/\/+$/, '');
|
|
1953
|
+
return eq(f, d) || eq(f.slice(0, d.length + 1), `${d}/`);
|
|
1954
|
+
}
|
|
1955
|
+
if (p.endsWith('/')) p += '**';
|
|
1956
|
+
let re = '';
|
|
1957
|
+
for (let i = 0; i < p.length; i++) {
|
|
1958
|
+
const c = p[i];
|
|
1959
|
+
if (c === '*') {
|
|
1960
|
+
if (p[i + 1] === '*') {
|
|
1961
|
+
if (p[i + 2] === '/') { re += '(?:.*/)?'; i += 2; } else { re += '.*'; i += 1; }
|
|
1962
|
+
} else re += '[^/]*';
|
|
1963
|
+
} else if (c === '?') re += '[^/]';
|
|
1964
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
1965
|
+
}
|
|
1966
|
+
return new RegExp(`^${re}$`, ci ? 'i' : '').test(f);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/**
|
|
1970
|
+
* git pathspecs for a set of globs (a superset filter; the exact match is globMatch). Null = no pathspec (whole
|
|
1971
|
+
* repo): a glob matching everything, one git would refuse (absolute, `..`), or a list too long for a command line.
|
|
1972
|
+
*/
|
|
1973
|
+
function pathspecOf(globs) {
|
|
1974
|
+
const magic = process.platform === 'win32' ? ':(glob,icase)' : ':(glob)';
|
|
1975
|
+
const out = new Set();
|
|
1976
|
+
for (const g0 of globs) {
|
|
1977
|
+
const g = toSlash(String(g0).trim()).replace(/^\.\//, '').replace(/^\/+/, '');
|
|
1978
|
+
if (!g || /^[*/]+$/.test(g)) return null;
|
|
1979
|
+
if (/^[A-Za-z]:/.test(g) || g.split('/').includes('..') || g.startsWith(':')) return null;
|
|
1980
|
+
if (g.endsWith('/')) out.add(`${magic}${g}**`);
|
|
1981
|
+
else if (!/[*?[]/.test(g)) { out.add(`${magic}${g}`); out.add(`${magic}${g}/**`); } else out.add(`${magic}${g}`);
|
|
1982
|
+
}
|
|
1983
|
+
const list = Array.from(out);
|
|
1984
|
+
if (!list.length || list.join(' ').length > PATHSPEC_MAX_CHARS) return null;
|
|
1985
|
+
return list;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/** One git process (read-only: --no-optional-locks), bounded by a timeout; we started it, we may kill it. */
|
|
1989
|
+
function runGit(cwd, args, input, timeoutMs) {
|
|
1990
|
+
const t0 = Date.now();
|
|
1991
|
+
const r = require('child_process').spawnSync('git', ['--no-optional-locks', '-c', 'core.quotepath=off', ...args], {
|
|
1992
|
+
cwd, input, encoding: 'utf8', windowsHide: true, timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024,
|
|
1993
|
+
});
|
|
1994
|
+
const ms = Date.now() - t0;
|
|
1995
|
+
if (r.error && r.error.code === 'ETIMEDOUT') return { ok: false, timedOut: true, ms, error: `quá ${timeoutMs} ms` };
|
|
1996
|
+
if (r.error) return { ok: false, ms, error: r.error.message };
|
|
1997
|
+
if (r.status !== 0) return { ok: false, ms, error: (r.stderr || '').trim().split(/\r?\n/)[0] || `exit ${r.status}` };
|
|
1998
|
+
return { ok: true, ms, stdout: r.stdout || '' };
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* Files changed between each sha and `head` (one `git diff-tree --stdin` for all of them). `--always` prints the
|
|
2003
|
+
* header line even for an empty diff, so a sha without header is an object this repo does not have (diff-tree skips
|
|
2004
|
+
* those silently, exit 0). Returns { bySha: Map<sha, files[]>, missing: Set<sha>, ms } or { error, timedOut, ms }.
|
|
2005
|
+
*/
|
|
2006
|
+
function diffShas(root, shas, head, globs, timeoutMs) {
|
|
2007
|
+
const spec = pathspecOf(globs);
|
|
2008
|
+
const base = ['diff-tree', '--stdin', '--always', '-r', '--name-only', '--no-renames', '-z'];
|
|
2009
|
+
const input = shas.map((s) => `${s} ${head}\n`).join('');
|
|
2010
|
+
let r = runGit(root, spec ? base.concat('--', spec) : base, input, timeoutMs);
|
|
2011
|
+
if (!r.ok && !r.timedOut && spec) r = runGit(root, base, input, timeoutMs); // pathspec refused: filter in JS only
|
|
2012
|
+
if (!r.ok) return { error: r.error, timedOut: !!r.timedOut, ms: r.ms };
|
|
2013
|
+
const want = new Set(shas.map((s) => s.toLowerCase()));
|
|
2014
|
+
const bySha = new Map();
|
|
2015
|
+
let cur = null;
|
|
2016
|
+
for (const tok of r.stdout.split('\0')) {
|
|
2017
|
+
const t = tok.replace(/^\n+/, '');
|
|
2018
|
+
if (!t) continue;
|
|
2019
|
+
const low = t.toLowerCase();
|
|
2020
|
+
if (want.has(low) && !bySha.has(low)) { cur = []; bySha.set(low, cur); continue; }
|
|
2021
|
+
if (cur) cur.push(t);
|
|
2022
|
+
}
|
|
2023
|
+
const missing = new Set(Array.from(want).filter((s) => !bySha.has(s)));
|
|
2024
|
+
return { bySha, missing, ms: r.ms };
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/** Uncommitted tracked changes (`status -uno`, no index refresh write), bounded by a timeout. CLI only. */
|
|
2028
|
+
function dirtyFiles(root, globs, timeoutMs) {
|
|
2029
|
+
const spec = pathspecOf(globs);
|
|
2030
|
+
const base = ['status', '--porcelain=v1', '-z', '-uno', '--no-renames'];
|
|
2031
|
+
const r = runGit(root, spec ? base.concat('--', spec) : base, undefined, timeoutMs || DIRTY_TIMEOUT_MS);
|
|
2032
|
+
if (!r.ok) return { error: r.error, timedOut: !!r.timedOut, ms: r.ms };
|
|
2033
|
+
const files = r.stdout.split('\0').filter((x) => x.length > 3).map((x) => x.slice(3));
|
|
2034
|
+
return { files, ms: r.ms };
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function indexStamp(dir) {
|
|
2038
|
+
try {
|
|
2039
|
+
const st = fs.statSync(path.join(dir, 'index.json'));
|
|
2040
|
+
return { mtime: st.mtimeMs, size: st.size };
|
|
2041
|
+
} catch (_) { return null; }
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function freshnessCacheFile(dir) {
|
|
2045
|
+
return path.join(dir, 'cache', 'freshness.json');
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
function readFreshCache(dir) {
|
|
2049
|
+
try {
|
|
2050
|
+
const c = JSON.parse(fs.readFileSync(freshnessCacheFile(dir), 'utf8'));
|
|
2051
|
+
return c && Array.isArray(c.entries) ? c : { entries: [] };
|
|
2052
|
+
} catch (_) { return { entries: [] }; }
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function cachedFor(dir, head, stamp) {
|
|
2056
|
+
if (!head || !stamp) return null;
|
|
2057
|
+
return readFreshCache(dir).entries.find((e) => e.head === head && e.index_mtime === stamp.mtime && e.index_size === stamp.size) || null;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/** Best effort (tmp+rename, last writer wins); a store that vanished or is read-only only loses the cache. */
|
|
2061
|
+
function writeFreshCache(dir, entry) {
|
|
2062
|
+
try {
|
|
2063
|
+
const rest = readFreshCache(dir).entries.filter((e) => e.head !== entry.head);
|
|
2064
|
+
writeFileAtomic(freshnessCacheFile(dir), `${JSON.stringify({ schema: 1, entries: [entry, ...rest].slice(0, FRESH_CACHE_MAX) })}\n`);
|
|
2065
|
+
} catch (_) { /* cache only */ }
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
function freshCounts(sections) {
|
|
2069
|
+
const c = { total: 0, fresh: 0, stale: 0, unknown: 0 };
|
|
2070
|
+
for (const s of Object.values(sections)) { c.total++; c[s.state] = (c[s.state] || 0) + 1; }
|
|
2071
|
+
return c;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* Freshness of every section of a store, for the worktree at `root`.
|
|
2076
|
+
* o = { dir (store), idx (parsed index), root (worktree root), head (readHead result), stamp (indexStamp),
|
|
2077
|
+
* cache (default true), timeoutMs }
|
|
2078
|
+
* Returns { head, branch, cached, ms_git, computed, sections: { id: { state: fresh|stale|unknown, files?, n_files?,
|
|
2079
|
+
* why? } }, counts, error? }. Only a complete answer is cached (no git error, HEAD known).
|
|
2080
|
+
*/
|
|
2081
|
+
function computeFreshness(o) {
|
|
2082
|
+
const idx = o.idx;
|
|
2083
|
+
const head = o.head && o.head.sha ? o.head.sha.toLowerCase() : null;
|
|
2084
|
+
const out = { head, branch: o.head ? o.head.branch || null : null, cached: false, ms_git: 0 };
|
|
2085
|
+
const stamp = o.stamp || indexStamp(o.dir);
|
|
2086
|
+
if (head && o.cache !== false) {
|
|
2087
|
+
const hit = cachedFor(o.dir, head, stamp);
|
|
2088
|
+
if (hit) return Object.assign(out, { cached: true, computed: hit.computed, sections: hit.sections, counts: freshCounts(hit.sections) });
|
|
2089
|
+
}
|
|
2090
|
+
const sections = {};
|
|
2091
|
+
const pending = new Map(); // sha -> [section]
|
|
2092
|
+
let why = null;
|
|
2093
|
+
if (!head) why = o.head && o.head.unsupported ? `HEAD không đọc được từ file (${o.head.unsupported})` : 'không đọc được HEAD (repo chưa có commit?)';
|
|
2094
|
+
else if (!o.root) why = 'repo không có thư mục làm việc (bare)';
|
|
2095
|
+
for (const s of idx.sections) {
|
|
2096
|
+
const sha = String(s.sha || '').toLowerCase();
|
|
2097
|
+
if (why) sections[s.id] = { state: 'unknown', why };
|
|
2098
|
+
else if (!sha || sha === 'none') sections[s.id] = { state: 'unknown', why: 'mục chưa có sha' };
|
|
2099
|
+
else if (sha === head) sections[s.id] = { state: 'fresh' };
|
|
2100
|
+
else if (!FULL_SHA_RE.test(sha)) sections[s.id] = { state: 'unknown', why: `sha rút gọn ${sha} — merge lại với head: đầy đủ` };
|
|
2101
|
+
else { if (!pending.has(sha)) pending.set(sha, []); pending.get(sha).push(s); }
|
|
2102
|
+
}
|
|
2103
|
+
let error = null;
|
|
2104
|
+
if (pending.size) {
|
|
2105
|
+
const globs = [];
|
|
2106
|
+
for (const list of pending.values()) for (const s of list) globs.push(...(s.paths || []));
|
|
2107
|
+
const d = diffShas(o.root, Array.from(pending.keys()), head, globs, o.timeoutMs || DIFF_TIMEOUT_MS);
|
|
2108
|
+
out.ms_git = d.ms || 0;
|
|
2109
|
+
if (d.error) error = d.error;
|
|
2110
|
+
for (const [sha, list] of pending) {
|
|
2111
|
+
for (const s of list) {
|
|
2112
|
+
if (d.error) { sections[s.id] = { state: 'unknown', why: `git diff-tree lỗi: ${d.error}` }; continue; }
|
|
2113
|
+
if (d.missing.has(sha)) { sections[s.id] = { state: 'unknown', why: `commit ${sha.slice(0, 7)} không có trong repo này (clone/nhánh khác chưa fetch?)` }; continue; }
|
|
2114
|
+
const files = d.bySha.get(sha).filter((f) => (s.paths || []).some((g) => globMatch(g, f)));
|
|
2115
|
+
sections[s.id] = files.length ? { state: 'stale', files: files.slice(0, STALE_FILES_KEEP), n_files: files.length } : { state: 'fresh' };
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
Object.assign(out, { computed: new Date().toISOString(), sections, counts: freshCounts(sections) });
|
|
2120
|
+
if (error) out.error = error;
|
|
2121
|
+
if (head && !error && o.cache !== false && stamp) {
|
|
2122
|
+
writeFreshCache(o.dir, { head, index_mtime: stamp.mtime, index_size: stamp.size, computed: out.computed, sections });
|
|
2123
|
+
}
|
|
2124
|
+
return out;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/** Adds `dirty` (uncommitted files matching each section) in place; returns { checked, why?, ms }. */
|
|
2128
|
+
function addDirty(fr, idx, root, timeoutMs) {
|
|
2129
|
+
if (!root) return { checked: false, why: 'repo không có thư mục làm việc' };
|
|
2130
|
+
const globs = [];
|
|
2131
|
+
for (const s of idx.sections) globs.push(...(s.paths || []));
|
|
2132
|
+
const d = dirtyFiles(root, globs, timeoutMs);
|
|
2133
|
+
if (d.error) return { checked: false, why: d.timedOut ? `chưa kiểm (git status ${d.error})` : `git status lỗi: ${d.error}`, ms: d.ms };
|
|
2134
|
+
for (const s of idx.sections) {
|
|
2135
|
+
const files = d.files.filter((f) => (s.paths || []).some((g) => globMatch(g, f)));
|
|
2136
|
+
if (files.length && fr.sections[s.id]) fr.sections[s.id].dirty = files.slice(0, STALE_FILES_KEEP);
|
|
2137
|
+
}
|
|
2138
|
+
return { checked: true, ms: d.ms, n: d.files.length };
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function shortFileList(files, n) {
|
|
2142
|
+
const k = n || 3;
|
|
2143
|
+
const f = files || [];
|
|
2144
|
+
return f.slice(0, k).join(', ') + (f.length > k ? ` (+${f.length - k})` : '');
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
/** "N mục, k stale (ids), u không xác định (ids)". */
|
|
2148
|
+
function knowledgeSummary(fr, n) {
|
|
2149
|
+
const ids = (st) => Object.keys(fr.sections).filter((id) => fr.sections[id].state === st);
|
|
2150
|
+
const stale = ids('stale');
|
|
2151
|
+
const unk = ids('unknown');
|
|
2152
|
+
const parts = [`${n} mục`, `${stale.length} stale${stale.length ? ` (${shortFileList(stale, 6)})` : ''}`];
|
|
2153
|
+
if (unk.length) parts.push(`${unk.length} không xác định (${shortFileList(unk, 4)})`);
|
|
2154
|
+
return parts.join(', ');
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/** index.json of a store, tolerant (null when missing or unreadable). */
|
|
2158
|
+
function readStoreIndex(dir) {
|
|
2159
|
+
try {
|
|
2160
|
+
const idx = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8').replace(/^/, ''));
|
|
2161
|
+
if (!idx || typeof idx !== 'object' || Array.isArray(idx)) return null;
|
|
2162
|
+
if (!Array.isArray(idx.sections)) idx.sections = [];
|
|
2163
|
+
if (!Array.isArray(idx.related)) idx.related = [];
|
|
2164
|
+
return idx;
|
|
2165
|
+
} catch (_) { return null; }
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/** Cache-only status of a related repo (no git process): "N mục, k stale (cache)" or why it is unchecked. */
|
|
2169
|
+
function relatedStatus(r) {
|
|
2170
|
+
const rp = typeof r.path === 'string' && r.path ? nativePath(r.path) : null;
|
|
2171
|
+
const info = rp && isDir(rp) ? resolveProject(rp) : null;
|
|
2172
|
+
if (!info || info.key !== r.key || !info.exists) return { name: r.name, rel: r.rel, text: 'chưa có kho / không thấy repo' };
|
|
2173
|
+
const stamp = indexStamp(info.dir);
|
|
2174
|
+
const idx = stamp ? readStoreIndex(info.dir) : null;
|
|
2175
|
+
if (!idx) return { name: r.name, rel: r.rel, text: 'chưa có kho' };
|
|
2176
|
+
const h = readHead(rp);
|
|
2177
|
+
const hit = h && h.sha ? cachedFor(info.dir, h.sha.toLowerCase(), stamp) : null;
|
|
2178
|
+
const n = idx.sections.length;
|
|
2179
|
+
if (!hit) return { name: r.name, rel: r.rel, n, text: `${n} mục, độ tươi chưa kiểm (làm tươi khi get --follow / --repo)` };
|
|
2180
|
+
return { name: r.name, rel: r.rel, n, counts: freshCounts(hit.sections), text: `${knowledgeSummary({ sections: hit.sections }, n)} (cache)` };
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/**
|
|
2184
|
+
* Knowledge lines for the start block of the repo at `cwd`; null outside a repo or when the repo has no store.
|
|
2185
|
+
* Refreshes this repo only (at most one git diff-tree, on a cache miss, bounded by HOOK_DIFF_TIMEOUT_MS; never git
|
|
2186
|
+
* status); related repos report their cache. Never throws.
|
|
2187
|
+
*/
|
|
2188
|
+
function startKnowledge(cwd, opts) {
|
|
2189
|
+
const t0 = Date.now();
|
|
2190
|
+
try {
|
|
2191
|
+
if (!cwd) return null;
|
|
2192
|
+
const info = resolveProject(cwd);
|
|
2193
|
+
if (!info || !info.exists) return null;
|
|
2194
|
+
const stamp = indexStamp(info.dir);
|
|
2195
|
+
if (!stamp) return null;
|
|
2196
|
+
const idx = readStoreIndex(info.dir);
|
|
2197
|
+
if (!idx) return { dir: info.dir, lines: [`project_knowledge: ${info.dir} — index.json không đọc được (\`dieu-phoi knowledge list\` để xem lỗi)`] };
|
|
2198
|
+
const fr = computeFreshness({ dir: info.dir, idx, root: info.root, head: readHead(cwd), stamp, timeoutMs: (opts && opts.timeoutMs) || HOOK_DIFF_TIMEOUT_MS });
|
|
2199
|
+
const related = idx.related.map(relatedStatus);
|
|
2200
|
+
const n = idx.sections.length;
|
|
2201
|
+
const tail = fr.error ? ` — git lỗi (${fr.error}), chạy \`dieu-phoi knowledge status\`` : '';
|
|
2202
|
+
const lines = [`project_knowledge: ${info.dir} — ${knowledgeSummary(fr, n)}${fr.head ? ` · HEAD ${fr.head.slice(0, 7)}` : ''}${tail} · \`dieu-phoi knowledge list\` rồi \`get <id>\``];
|
|
2203
|
+
for (const r of related) lines.push(` related: ${r.name} (${r.rel === 'parent' ? 'cha' : 'con'}) — ${r.text}`);
|
|
2204
|
+
return {
|
|
2205
|
+
dir: info.dir, name: info.name, key: info.key, head: fr.head, cached: fr.cached, ms: Date.now() - t0, ms_git: fr.ms_git,
|
|
2206
|
+
counts: fr.counts, stale: Object.keys(fr.sections).filter((id) => fr.sections[id].state === 'stale'),
|
|
2207
|
+
related: related.map((r) => ({ name: r.name, rel: r.rel, counts: r.counts || null, text: r.text })), lines,
|
|
2208
|
+
};
|
|
2209
|
+
} catch (e) {
|
|
2210
|
+
return { lines: [], error: e.message, ms: Date.now() - t0 };
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/** git-bash style `/d/x` -> `D:/x` on Windows (other platforms and other paths unchanged). */
|
|
2215
|
+
function nativePath(c) {
|
|
2216
|
+
return process.platform === 'win32' ? String(c).replace(/^\/([a-zA-Z])(?=\/|$)/, '$1:') : String(c);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/** First existing dir named by a `repo: <path>` phrase of a brief (git-bash `/d/x` paths accepted). */
|
|
2220
|
+
function repoFromBrief(prompt) {
|
|
2221
|
+
if (typeof prompt !== 'string' || !prompt) return null;
|
|
2222
|
+
const head = prompt.slice(0, 6000);
|
|
2223
|
+
const re = /(?:^|[\s(])repo\s*:\s*(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/gi;
|
|
2224
|
+
let m;
|
|
2225
|
+
while ((m = re.exec(head))) {
|
|
2226
|
+
let c = m[1];
|
|
2227
|
+
if (/^[`"']/.test(c)) c = c.slice(1, -1);
|
|
2228
|
+
c = c.replace(/[.,;:)\]]+$/, '').trim();
|
|
2229
|
+
if (!c || /[<>*?|]/.test(c)) continue;
|
|
2230
|
+
c = nativePath(c);
|
|
2231
|
+
if (isDir(c)) return path.resolve(c);
|
|
2232
|
+
}
|
|
2233
|
+
return null;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
/**
|
|
2237
|
+
* Project of a hook payload: the brief's `repo:` target wins (a session in the parent repo works on a child repo),
|
|
2238
|
+
* else the payload cwd. Returns { project: <store dir name>, project_src: 'repo'|'cwd' }, or {} when neither is in a repo.
|
|
2239
|
+
*/
|
|
2240
|
+
function projectFields(p) {
|
|
2241
|
+
try {
|
|
2242
|
+
const ti = p && p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
2243
|
+
const cands = [['repo', repoFromBrief(ti.prompt)], ['cwd', p && typeof p.cwd === 'string' && p.cwd ? p.cwd : null]];
|
|
2244
|
+
for (const [src, dir] of cands) {
|
|
2245
|
+
if (!dir) continue;
|
|
2246
|
+
const r = resolveProject(dir);
|
|
2247
|
+
if (r) return { project: r.dir_name, project_src: src };
|
|
2248
|
+
}
|
|
2249
|
+
} catch (_) { /* identity is best effort; never break the dispatch event */ }
|
|
2250
|
+
return {};
|
|
2251
|
+
}
|
|
2252
|
+
|
|
1412
2253
|
function buildDispatch(p, ctx) {
|
|
1413
2254
|
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
1414
2255
|
const tr = p.tool_response;
|
|
1415
2256
|
const trObj = tr && typeof tr === 'object' && !Array.isArray(tr) ? tr : {};
|
|
1416
|
-
const
|
|
2257
|
+
const subagentType = typeof ti.subagent_type === 'string' && ti.subagent_type ? ti.subagent_type : 'general-purpose';
|
|
2258
|
+
let tag = resolveTag(ti.prompt, knownTaskTypes(ctx));
|
|
2259
|
+
if (!tag) tag = /^dp-/.test(subagentType) ? Object.assign({}, UNTAGGED, { tag_issue: 'no-tag' }) : UNTAGGED;
|
|
1417
2260
|
const explicitEffort = typeof ti.effort === 'string' && ti.effort ? ti.effort : null;
|
|
1418
2261
|
const parentEffort = p.effort && typeof p.effort === 'object' ? p.effort.level : null;
|
|
1419
2262
|
// agent_id must never be empty (the sink keys on it): agentId -> tool_use_id -> random.
|
|
@@ -1432,6 +2275,8 @@ function buildDispatch(p, ctx) {
|
|
|
1432
2275
|
session: p.session_id ? sha8(p.session_id) : null,
|
|
1433
2276
|
task_id: tag.task_id,
|
|
1434
2277
|
...(tag.task_id && !isValidTaskId(tag.task_id) ? { task_id_invalid: true } : {}),
|
|
2278
|
+
...(tag.task_id_inferred ? { task_id_inferred: true } : {}),
|
|
2279
|
+
...(tag.tag_issue ? { tag_issue: tag.tag_issue } : {}),
|
|
1435
2280
|
task_type: tag.task_type,
|
|
1436
2281
|
score: tag.score,
|
|
1437
2282
|
urgency: tag.urgency,
|
|
@@ -1440,7 +2285,7 @@ function buildDispatch(p, ctx) {
|
|
|
1440
2285
|
model: normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null,
|
|
1441
2286
|
effort: explicitEffort || parentEffort || null,
|
|
1442
2287
|
effort_inherited: !explicitEffort,
|
|
1443
|
-
subagent_type:
|
|
2288
|
+
subagent_type: subagentType,
|
|
1444
2289
|
background: ti.run_in_background === true || trObj.isAsync === true,
|
|
1445
2290
|
// Orchestrator (main session) at the moment of this dispatch. Raw session_id joins with `dieu-phoi ctx`.
|
|
1446
2291
|
session_id: typeof p.session_id === 'string' && p.session_id ? p.session_id : null,
|
|
@@ -1449,15 +2294,55 @@ function buildDispatch(p, ctx) {
|
|
|
1449
2294
|
orch_compactions: orch.compactions,
|
|
1450
2295
|
orch_cum_usd: orch.cum_usd,
|
|
1451
2296
|
}, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
|
|
2297
|
+
projectFields(p),
|
|
2298
|
+
tierFields(ctx, tag, normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null, explicitEffort || parentEffort || null),
|
|
1452
2299
|
// core 0.5.0: an Agent call made inside a subagent (gen-2) carries the caller's agent_id
|
|
1453
2300
|
typeof p.agent_id === 'string' && p.agent_id ? { parent_agent_id: p.agent_id } : {},
|
|
1454
2301
|
commonFields(ctx));
|
|
1455
2302
|
}
|
|
1456
2303
|
|
|
2304
|
+
/** suggested_tier / choose_tier / suggested_source / dispatched_tier / tier_mismatch, only when choose left a record. */
|
|
2305
|
+
function tierFields(ctx, tag, model, effort) {
|
|
2306
|
+
const s = tag.task_id ? readSuggestion(ctx, tag.task_id, tag.role) : null;
|
|
2307
|
+
if (!s) return {};
|
|
2308
|
+
const dispatched = tierOf(model, effort);
|
|
2309
|
+
const out = {
|
|
2310
|
+
suggested_tier: s.suggested_tier,
|
|
2311
|
+
choose_tier: s.choose_tier || s.suggested_tier,
|
|
2312
|
+
suggested_source: s.source || null,
|
|
2313
|
+
dispatched_tier: dispatched,
|
|
2314
|
+
};
|
|
2315
|
+
if (dispatched && !dispatched.includes('?')) out.tier_mismatch = dispatched !== out.choose_tier;
|
|
2316
|
+
return out;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* Core 0.5.3: besides the dispatch event, a dieu-phoi dispatch with a flagged tag (tag_issue) or a task already on
|
|
2321
|
+
* its HANDOFF_CHAIN_WARN-th handoff gets a warning back to the caller as PostToolUse additionalContext (the
|
|
2322
|
+
* returned event carries it as a non-enumerable `stdout`, never written to the log).
|
|
2323
|
+
*/
|
|
1457
2324
|
function handlePostToolUse(p, ctx) {
|
|
1458
2325
|
if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
|
|
1459
2326
|
const ev = buildDispatch(p, ctx);
|
|
2327
|
+
const notes = [];
|
|
2328
|
+
if (ev.tag_issue) {
|
|
2329
|
+
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
2330
|
+
const known = knownTaskTypes(ctx);
|
|
2331
|
+
const tag = resolveTag(ti.prompt, known) || { tag_issue: ev.tag_issue, task_id: null, task_type: ev.task_type };
|
|
2332
|
+
notes.push(tagIssueMessage(tag, known));
|
|
2333
|
+
}
|
|
2334
|
+
if (ev.task_id && ev.role === 'exec') {
|
|
2335
|
+
const n = countTaskHandoffs(ctx, ev.task_id);
|
|
2336
|
+
if (n) ev.task_handoffs = n;
|
|
2337
|
+
if (n >= HANDOFF_CHAIN_WARN) notes.push(handoffChainMessage(ev.task_id, n));
|
|
2338
|
+
}
|
|
1460
2339
|
appendEvent(ctx, ev);
|
|
2340
|
+
if (notes.length) {
|
|
2341
|
+
Object.defineProperty(ev, 'stdout', {
|
|
2342
|
+
enumerable: false,
|
|
2343
|
+
value: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: notes.join('\n') } }),
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
1461
2346
|
return ev;
|
|
1462
2347
|
}
|
|
1463
2348
|
|
|
@@ -1626,7 +2511,7 @@ function handleSubagentStop(p, ctx) {
|
|
|
1626
2511
|
const last = lastUuidOf(lines);
|
|
1627
2512
|
if (last && seen.has(last)) return null; // same segment reported twice
|
|
1628
2513
|
sum = summarizeTranscript(lines, { afterUuids: seen, pricing: loadPricing(ctx) });
|
|
1629
|
-
tag = tagFromTranscript(lines);
|
|
2514
|
+
tag = tagFromTranscript(lines, knownTaskTypes(ctx));
|
|
1630
2515
|
}
|
|
1631
2516
|
const t = tag || UNTAGGED;
|
|
1632
2517
|
const ev = Object.assign({
|
|
@@ -1648,7 +2533,7 @@ function handleSubagentStop(p, ctx) {
|
|
|
1648
2533
|
tokens: sum ? sum.tokens : null,
|
|
1649
2534
|
cost_usd: sum ? sum.cost_usd : null,
|
|
1650
2535
|
model: sum ? sum.model : null,
|
|
1651
|
-
}, commonFields(ctx));
|
|
2536
|
+
}, t.task_id_inferred ? { task_id_inferred: true } : {}, t.tag_issue ? { tag_issue: t.tag_issue } : {}, commonFields(ctx));
|
|
1652
2537
|
// Core 0.5.2 (retro 2026-10-18): how the segment ended, measured here instead of re-reading transcripts later.
|
|
1653
2538
|
// Labels and numbers only, never text.
|
|
1654
2539
|
const why = exists ? abortReasonOf(lines) : null;
|
|
@@ -1821,7 +2706,7 @@ function readSlice(fd, start, len) {
|
|
|
1821
2706
|
* Tag of the subagent's first prompt, read from the head of its transcript only.
|
|
1822
2707
|
* Returns { found: false } while no user line is on disk yet (transcript written async), else { found: true, tag|null }.
|
|
1823
2708
|
*/
|
|
1824
|
-
function readHeadTag(file) {
|
|
2709
|
+
function readHeadTag(file, known) {
|
|
1825
2710
|
let fd;
|
|
1826
2711
|
try { fd = fs.openSync(file, 'r'); } catch (_) { return { found: false, tag: null }; }
|
|
1827
2712
|
try {
|
|
@@ -1831,7 +2716,7 @@ function readHeadTag(file) {
|
|
|
1831
2716
|
const text = readSlice(fd, 0, len).toString('utf8');
|
|
1832
2717
|
const complete = len === size ? text : text.slice(0, text.lastIndexOf('\n') + 1);
|
|
1833
2718
|
const lines = parseJsonl(complete);
|
|
1834
|
-
if (lines.some((l) => l && l.type === 'user' && l.message)) return { found: true, tag: tagFromTranscript(lines), prompt: firstPromptText(lines) };
|
|
2719
|
+
if (lines.some((l) => l && l.type === 'user' && l.message)) return { found: true, tag: tagFromTranscript(lines, known), prompt: firstPromptText(lines) };
|
|
1835
2720
|
if (len >= size || len >= WATCH_HEAD_MAX) return { found: false, tag: null };
|
|
1836
2721
|
}
|
|
1837
2722
|
} finally {
|
|
@@ -1902,7 +2787,7 @@ function watchState(p, ctx, agentId) {
|
|
|
1902
2787
|
if (!st || typeof st !== 'object') {
|
|
1903
2788
|
st = null;
|
|
1904
2789
|
if (tpath) {
|
|
1905
|
-
const h = readHeadTag(tpath);
|
|
2790
|
+
const h = readHeadTag(tpath, knownTaskTypes(ctx));
|
|
1906
2791
|
if (h.found) {
|
|
1907
2792
|
st = h.tag
|
|
1908
2793
|
? { dp: true, task_id: h.tag.task_id, run_id: h.tag.task_id ? null : runIdFromPrompt(h.prompt), role: h.tag.role, parent_task_id: h.tag.parent_task_id || null, fired: 0, t: Date.now() }
|
|
@@ -2325,15 +3210,18 @@ async function runSync(ctx, opts) {
|
|
|
2325
3210
|
// ------------------------------------------------------------------ exports / main
|
|
2326
3211
|
|
|
2327
3212
|
module.exports = {
|
|
2328
|
-
runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, 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,
|
|
3213
|
+
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,
|
|
2329
3214
|
CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
|
|
2330
|
-
sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
|
|
2331
|
-
logError, computeUserHash, userHash, commonFields, parseTag,
|
|
3215
|
+
sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
|
|
3216
|
+
logError, computeUserHash, userHash, commonFields, parseTag, resolveTag, knownTaskTypes, tagIssueMessage, DEFAULT_EXTRA_TYPES,
|
|
3217
|
+
recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
|
|
2332
3218
|
priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
|
|
2333
3219
|
firstPromptText, tagFromTranscript, summarizeTranscript,
|
|
2334
|
-
RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents, readEventsBuffer, archiveFiles, readArchiveLines,
|
|
3220
|
+
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,
|
|
2335
3221
|
loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
|
|
2336
3222
|
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,
|
|
3223
|
+
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,
|
|
3224
|
+
projectsDir, findGit, originUrlFromConfig, normalizeRemoteUrl, resolveProject, readHead, repoFromBrief, projectFields, nativePath, isStoreDirName, aliasesFile, loadAliases, keyDirName,
|
|
2337
3225
|
extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
|
|
2338
3226
|
};
|
|
2339
3227
|
|