@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/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, always exit 0,
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.5.2';
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
- /** Tag from the first user message of the transcript (the original prompt). */
391
- function tagFromTranscript(lines) {
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 : parseTag(text);
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
- function startInfo(ctx) {
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
- return {
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 tag = parseTag(ti.prompt) || UNTAGGED;
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: typeof ti.subagent_type === 'string' && ti.subagent_type ? ti.subagent_type : 'general-purpose',
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, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
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