@kienntpixon/dieu-phoi 0.5.3 → 0.6.1

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
@@ -26,7 +26,7 @@ const path = require('path');
26
26
  const os = require('os');
27
27
  const crypto = require('crypto');
28
28
 
29
- const CORE_VERSION = '0.5.3';
29
+ const CORE_VERSION = '0.6.1';
30
30
  const SCHEMA = 1;
31
31
  const TASK_TYPES = [
32
32
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -92,6 +92,42 @@ function writeFileAtomic(file, content, mode) {
92
92
  renameWithRetry(tmp, file);
93
93
  }
94
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
+
95
131
  // ------------------------------------------------------------------ paths / config
96
132
 
97
133
  /** Claude config dir: CLAUDE_CONFIG_DIR, else ~/.claude. */
@@ -135,6 +171,8 @@ function pathsFor(home) {
135
171
  sinkCheck: path.join(data, 'sink-check.json'),
136
172
  syncRejected: path.join(data, 'sync-rejected.jsonl'),
137
173
  lock: path.join(data, 'sync.lock'),
174
+ eventsLock: path.join(data, 'events.lock'),
175
+ ctxWarnLock: path.join(data, 'ctx-warn.lock'),
138
176
  config: path.join(home, 'config.json'),
139
177
  errlog: path.join(home, 'hook-errors.log'),
140
178
  };
@@ -849,8 +887,17 @@ function findSessionTranscript(claudeDir, sessionId) {
849
887
  * event — readers skip the fragment as one unparsable line. Withdrawing a task = `dieu-phoi discard`, never an
850
888
  * edit of this file.
851
889
  */
890
+ const EVENTS_LOCK_WAIT_MS = 1500;
891
+
852
892
  function appendEvent(ctx, ev) {
853
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) {
854
901
  const fd = fs.openSync(ctx.paths.events, 'a+');
855
902
  try {
856
903
  let lead = '';
@@ -1350,7 +1397,8 @@ function sinkStatusCached(ctx, nowMs) {
1350
1397
  const now = nowMs || Date.now();
1351
1398
  const cands = [];
1352
1399
  const chk = readJsonFile(ctx.paths.sinkCheck, null);
1353
- if (chk && chk.url === sinkUrlKey(cfg) && Date.parse(chk.ts)) {
1400
+ // a timed-out doctor check (`timeout: true`) is "slow/unknown", not a failure: ignored here, so a fresher sync result or `unknown` wins
1401
+ if (chk && !chk.timeout && chk.url === sinkUrlKey(cfg) && Date.parse(chk.ts)) {
1354
1402
  cands.push({ state: chk.ok ? 'ok' : 'fail', ts: chk.ts, source: 'doctor', detail: chk.detail || null });
1355
1403
  }
1356
1404
  const ls = readJsonFile(ctx.paths.lastSync, null);
@@ -1442,13 +1490,29 @@ function stepIds(root, n) {
1442
1490
  return Array.from(ids);
1443
1491
  }
1444
1492
 
1445
- function startInfo(ctx) {
1493
+ /**
1494
+ * core 0.6.0 (s3): project_knowledge of the repo at `cwd` — null outside a repo or without a store. Refreshes that repo
1495
+ * only (one git diff-tree on a HEAD/index cache miss, never git status); related repos report their cache.
1496
+ */
1497
+ function startKnowledgeOf(cwd) {
1498
+ if (!cwd || typeof cwd !== 'string') return null;
1499
+ try { return startKnowledge(cwd); } catch (_) { return null; }
1500
+ }
1501
+
1502
+ function startInfo(ctx, cwd) {
1446
1503
  const run = startRun();
1447
1504
  const health = quickHealth(ctx, run.runs);
1448
- return {
1505
+ const info = {
1449
1506
  id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx),
1450
1507
  orch_lessons: orchLessons(ctx),
1451
1508
  };
1509
+ const k = startKnowledgeOf(cwd);
1510
+ if (k) info.knowledge = k;
1511
+ return info;
1512
+ }
1513
+
1514
+ function knowledgeLines(info) {
1515
+ return info && info.knowledge && Array.isArray(info.knowledge.lines) ? info.knowledge.lines : [];
1452
1516
  }
1453
1517
 
1454
1518
  function orchLessonLines(info) {
@@ -1465,6 +1529,7 @@ function formatStartBlock(info) {
1465
1529
  `health: ${healthLine(info.health)}`,
1466
1530
  `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
1467
1531
  ...orchLessonLines(info),
1532
+ ...knowledgeLines(info),
1468
1533
  '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.',
1469
1534
  ].join('\n');
1470
1535
  }
@@ -1472,7 +1537,7 @@ function formatStartBlock(info) {
1472
1537
  /** UserPromptSubmit: on a dieu-phoi prompt, pre-create the run and inject id/run dir/health/brief. */
1473
1538
  function handleUserPromptSubmit(p, ctx) {
1474
1539
  if (!isDieuPhoiPrompt(p.prompt)) return null;
1475
- const info = startInfo(ctx);
1540
+ const info = startInfo(ctx, typeof p.cwd === 'string' ? p.cwd : null);
1476
1541
  return {
1477
1542
  stdout: JSON.stringify({
1478
1543
  hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: formatStartBlock(info) },
@@ -1587,6 +1652,605 @@ function handoffChainMessage(taskId, n) {
1587
1652
  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\`).`;
1588
1653
  }
1589
1654
 
1655
+ // ------------------------------------------------------------------ core 0.6.0: project identity
1656
+ //
1657
+ // A project (= one git repo, however many worktrees/clones) gets a stable key so every session and worktree can
1658
+ // share one knowledge store (~/.dieu-phoi/projects/<name>-<sha8(key)>/). Everything here is plain file reads —
1659
+ // never spawns git — because it runs in hooks on the hot path.
1660
+ // key = "remote:<host>/<path>" from remote.origin.url (credentials, scheme, .git stripped; host lower-cased)
1661
+ // | "git:<realpath of git-common-dir>" for a repo without remote (worktrees share the common dir)
1662
+ // A repo that later gains a remote changes key: `dieu-phoi knowledge rekey` records the new key as an alias of the
1663
+ // existing store dir (<projects>/aliases.json); the dir itself is never renamed.
1664
+
1665
+ /** Knowledge store root: ~/.dieu-phoi/projects (override: DIEU_PHOI_PROJECTS_DIR, absolute). */
1666
+ function projectsDir() {
1667
+ if (process.env.DIEU_PHOI_PROJECTS_DIR) return path.resolve(process.env.DIEU_PHOI_PROJECTS_DIR);
1668
+ return path.join(os.homedir(), '.dieu-phoi', 'projects');
1669
+ }
1670
+
1671
+ function realDir(p) {
1672
+ try { return fs.realpathSync.native(p); } catch (_) { /* fall through */ }
1673
+ try { return fs.realpathSync(p); } catch (_) { return path.resolve(p); }
1674
+ }
1675
+
1676
+ function toSlash(p) {
1677
+ return String(p).replace(/\\/g, '/');
1678
+ }
1679
+
1680
+ /** Comparable form of a local path: forward slashes, no trailing slash, lower-case on Windows. */
1681
+ function pathId(p) {
1682
+ const s = toSlash(p).replace(/\/+$/, '');
1683
+ return process.platform === 'win32' ? s.toLowerCase() : s;
1684
+ }
1685
+
1686
+ function readTextFile(file) {
1687
+ try { return fs.readFileSync(file, 'utf8'); } catch (_) { return null; }
1688
+ }
1689
+
1690
+ function isDir(p) {
1691
+ try { return fs.statSync(p).isDirectory(); } catch (_) { return false; }
1692
+ }
1693
+
1694
+ function gitDirsOf(root, gitDir) {
1695
+ const cd = readTextFile(path.join(gitDir, 'commondir'));
1696
+ const commonDir = cd && cd.trim() ? path.resolve(gitDir, cd.trim()) : gitDir;
1697
+ return { root, gitDir, commonDir };
1698
+ }
1699
+
1700
+ /**
1701
+ * Walk up from `start` to the nearest repo. `.git` may be a directory (main checkout) or a file `gitdir: <path>`
1702
+ * (linked worktree or submodule); a linked worktree's `commondir` file points at the shared git dir.
1703
+ * Returns { root, gitDir, commonDir } (root null for a bare repo / a git dir passed directly) or null.
1704
+ */
1705
+ function findGit(start) {
1706
+ let dir;
1707
+ try {
1708
+ dir = path.resolve(start);
1709
+ if (!fs.statSync(dir).isDirectory()) dir = path.dirname(dir);
1710
+ } catch (_) { return null; }
1711
+ if (fs.existsSync(path.join(dir, 'HEAD')) && isDir(path.join(dir, 'objects')) && isDir(path.join(dir, 'refs'))) {
1712
+ return gitDirsOf(null, dir); // bare repo or a .git dir itself
1713
+ }
1714
+ for (let i = 0; i < 64; i++) {
1715
+ const dotgit = path.join(dir, '.git');
1716
+ let st = null;
1717
+ try { st = fs.statSync(dotgit); } catch (_) { /* none here */ }
1718
+ if (st && st.isDirectory()) return gitDirsOf(dir, dotgit);
1719
+ if (st && st.isFile()) {
1720
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(readTextFile(dotgit) || '');
1721
+ if (m) return gitDirsOf(dir, path.resolve(dir, m[1]));
1722
+ }
1723
+ const up = path.dirname(dir);
1724
+ if (up === dir) return null;
1725
+ dir = up;
1726
+ }
1727
+ return null;
1728
+ }
1729
+
1730
+ function unquoteGitValue(v) {
1731
+ let s = String(v).trim();
1732
+ if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') s = s.slice(1, -1).replace(/\\(.)/g, '$1');
1733
+ return s.trim();
1734
+ }
1735
+
1736
+ /** remote.origin.url from the text of a git config (INI subset: [remote "origin"] url = ...). */
1737
+ function originUrlFromConfig(text) {
1738
+ let inOrigin = false;
1739
+ for (const line of String(text || '').split(/\r?\n/)) {
1740
+ const t = line.trim();
1741
+ if (!t || t[0] === '#' || t[0] === ';') continue;
1742
+ const h = /^\[\s*([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\](.*)$/.exec(t);
1743
+ if (h) {
1744
+ inOrigin = h[1].toLowerCase() === 'remote' && h[2] === 'origin';
1745
+ const rest = h[3].trim();
1746
+ const u0 = inOrigin && rest ? /^url\s*=\s*(.*)$/i.exec(rest) : null;
1747
+ if (u0) return unquoteGitValue(u0[1]);
1748
+ continue;
1749
+ }
1750
+ if (!inOrigin) continue;
1751
+ const u = /^url\s*=\s*(.*)$/i.exec(t);
1752
+ if (u) return unquoteGitValue(u[1]);
1753
+ }
1754
+ return null;
1755
+ }
1756
+
1757
+ /**
1758
+ * Canonical form of a remote URL: `host/path` for ssh/https/git/scp-like URLs (credentials, scheme, ssh port, query,
1759
+ * trailing `.git` and slashes dropped; host lower-cased; a non-default http(s) port is kept), `file:<path>` for local
1760
+ * remotes. ssh and https clones of the same repo therefore share a key. Null for an empty value.
1761
+ */
1762
+ function normalizeRemoteUrl(raw) {
1763
+ const s = String(raw == null ? '' : raw).trim();
1764
+ if (!s) return null;
1765
+ let host = null;
1766
+ let p = null;
1767
+ const url = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i.exec(s);
1768
+ if (url && url[1].toLowerCase() !== 'file') {
1769
+ const scheme = url[1].toLowerCase();
1770
+ const rest = url[2];
1771
+ const slash = rest.indexOf('/');
1772
+ const auth = slash < 0 ? rest : rest.slice(0, slash);
1773
+ p = slash < 0 ? '' : rest.slice(slash + 1);
1774
+ const hostport = auth.slice(auth.lastIndexOf('@') + 1);
1775
+ const hp = /^(\[[^\]]+\]|[^:]*)(?::(\d*))?$/.exec(hostport) || [null, hostport, ''];
1776
+ host = hp[1].toLowerCase();
1777
+ const port = hp[2];
1778
+ if (port && /^https?$/.test(scheme) && !(scheme === 'http' && port === '80') && !(scheme === 'https' && port === '443')) {
1779
+ host += `:${port}`;
1780
+ }
1781
+ } else {
1782
+ const scp = !url && !/^[A-Za-z]:[\\/]/.test(s) ? /^(?:[^/\\:@]*@)?([^/\\:]+):(?!\/\/)(.*)$/.exec(s) : null;
1783
+ if (scp) {
1784
+ host = scp[1].toLowerCase();
1785
+ p = scp[2];
1786
+ } else {
1787
+ let local = url ? url[2] : s; // file://...
1788
+ if (url) local = local.replace(/^\/(?=[A-Za-z]:)/, '');
1789
+ local = toSlash(local).replace(/[?#].*$/, '').replace(/\/+$/, '').replace(/\.git$/i, '');
1790
+ return `file:${pathId(local)}`;
1791
+ }
1792
+ }
1793
+ p = toSlash(p).replace(/[?#].*$/, '').replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/i, '').replace(/\/+$/, '');
1794
+ if (!host) return null;
1795
+ return p ? `${host}/${p}` : host;
1796
+ }
1797
+
1798
+ function safeName(n) {
1799
+ const s = String(n || '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 40);
1800
+ return s || 'repo';
1801
+ }
1802
+
1803
+ function gitFallbackName(commonDir) {
1804
+ const base = path.basename(commonDir);
1805
+ return safeName(base === '.git' ? path.basename(path.dirname(commonDir)) : base.replace(/\.git$/i, ''));
1806
+ }
1807
+
1808
+ function keyDirName(key, name) {
1809
+ return `${name}-${sha8(key)}`;
1810
+ }
1811
+
1812
+ /** A store dir name as this module makes them (also guards aliases.json / --from against traversal). */
1813
+ function isStoreDirName(n) {
1814
+ return typeof n === 'string' && /^[A-Za-z0-9._-]+$/.test(n) && !/^\.+$/.test(n);
1815
+ }
1816
+
1817
+ function aliasesFile() {
1818
+ return path.join(projectsDir(), 'aliases.json');
1819
+ }
1820
+
1821
+ function loadAliases() {
1822
+ const a = readJsonFile(aliasesFile(), {});
1823
+ return a && typeof a === 'object' && !Array.isArray(a) ? a : {};
1824
+ }
1825
+
1826
+ /**
1827
+ * Project identity of any path inside a repo (main checkout, linked worktree, clone) — file reads only.
1828
+ * `dir` is the store dir (it need not exist yet); `alias` is true when the dir was reached through aliases.json.
1829
+ * With `fallback`, also reports `rekey_from`: the dir name of the remote-less (git-common-dir) key when that store
1830
+ * exists but the remote key's does not (the repo gained a remote since). Null when `start` is not in a repo.
1831
+ */
1832
+ function resolveProject(start, opts) {
1833
+ const g = findGit(start);
1834
+ if (!g) return null;
1835
+ const o = opts || {};
1836
+ const raw = originUrlFromConfig(readTextFile(path.join(g.commonDir, 'config')));
1837
+ const remote = raw ? normalizeRemoteUrl(raw) : null;
1838
+ const gitKey = `git:${pathId(realDir(g.commonDir))}`;
1839
+ const gitName = gitFallbackName(g.commonDir);
1840
+ const key = remote ? `remote:${remote}` : gitKey;
1841
+ const name = remote ? safeName(remote.split('/').pop() || remote) : gitName;
1842
+ const own = keyDirName(key, name);
1843
+ const store = projectsDir();
1844
+ let dirName = own;
1845
+ let alias = false;
1846
+ if (!isDir(path.join(store, own))) {
1847
+ const a = loadAliases()[key];
1848
+ if (isStoreDirName(a) && isDir(path.join(store, a))) { dirName = a; alias = true; }
1849
+ }
1850
+ const out = {
1851
+ root: g.root ? toSlash(g.root) : null,
1852
+ git_dir: toSlash(g.gitDir),
1853
+ common_dir: toSlash(g.commonDir),
1854
+ key,
1855
+ kind: remote ? 'remote' : 'git',
1856
+ remote,
1857
+ name,
1858
+ dir_name: dirName,
1859
+ dir: toSlash(path.join(store, dirName)),
1860
+ exists: isDir(path.join(store, dirName)),
1861
+ alias,
1862
+ };
1863
+ if (o.fallback && remote && !out.exists) {
1864
+ const legacy = keyDirName(gitKey, gitName);
1865
+ if (isDir(path.join(store, legacy))) out.rekey_from = legacy;
1866
+ }
1867
+ return out;
1868
+ }
1869
+
1870
+ const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
1871
+ const PER_WORKTREE_REF_RE = /^refs\/(bisect|worktree|rewritten)\//;
1872
+
1873
+ function resolveRef(g, ref, depth) {
1874
+ if (depth > 5) return null;
1875
+ const base = PER_WORKTREE_REF_RE.test(ref) ? g.gitDir : g.commonDir;
1876
+ const loose = readTextFile(path.join(base, ref));
1877
+ if (loose != null) {
1878
+ const v = loose.trim();
1879
+ if (SHA_RE.test(v)) return v.toLowerCase();
1880
+ const sym = /^ref:\s*(\S+)/.exec(v);
1881
+ return sym ? resolveRef(g, sym[1], depth + 1) : null;
1882
+ }
1883
+ const packed = readTextFile(path.join(g.commonDir, 'packed-refs'));
1884
+ if (packed) {
1885
+ const needle = ` ${ref}`;
1886
+ for (const line of packed.split(/\r?\n/)) {
1887
+ if (line.endsWith(needle) && /^[0-9a-f]{40}/i.test(line)) return line.slice(0, line.length - needle.length).trim().toLowerCase();
1888
+ }
1889
+ }
1890
+ return null;
1891
+ }
1892
+
1893
+ /**
1894
+ * HEAD of the repo around `start` (a path, or the object findGit returned) without spawning git, in < 1 ms:
1895
+ * { branch, sha, detached } | { branch, sha: null, unborn: true } | { sha: null, unsupported: 'reftable' } | null.
1896
+ * `unsupported` means the caller must fall back to `git rev-parse` (CLI only, never in a hook).
1897
+ */
1898
+ function readHead(start) {
1899
+ const g = start && typeof start === 'object' && start.gitDir ? start : findGit(start);
1900
+ if (!g) return null;
1901
+ const txt = readTextFile(path.join(g.gitDir, 'HEAD'));
1902
+ if (txt == null) return null;
1903
+ const t = txt.trim();
1904
+ if (SHA_RE.test(t)) return { branch: null, sha: t.toLowerCase(), detached: true };
1905
+ const m = /^ref:\s*(\S+)/.exec(t);
1906
+ if (!m) return null;
1907
+ const ref = m[1];
1908
+ if (ref === 'refs/heads/.invalid' || isDir(path.join(g.commonDir, 'reftable'))) {
1909
+ return { branch: null, sha: null, detached: false, unsupported: 'reftable' };
1910
+ }
1911
+ const branch = ref.replace(/^refs\/heads\//, '');
1912
+ const sha = resolveRef(g, ref, 0);
1913
+ return sha ? { branch, sha, detached: false } : { branch, sha: null, detached: false, unborn: true };
1914
+ }
1915
+
1916
+ // ------------------------------------------------------------------ core 0.6.0, s3: knowledge freshness
1917
+ //
1918
+ // A knowledge section is stale when a file matching its `paths` globs differs between the commit it was merged at
1919
+ // (`sha`) and the HEAD of the worktree asking. Used by the start block here and by `knowledge get/list/status`
1920
+ // (lib/knowledge.cjs). Lives in hook.cjs because the installed hook is a single file (builtins only). Rules, from
1921
+ // measurements on the pixon-fast-forward repos (contract r1-2 §2):
1922
+ // - ONE git process per check, whatever the number of distinct section shas: `git diff-tree --stdin` reads one
1923
+ // "<sha> <HEAD>" line per sha. Always `--no-renames` (rename detection: 12.8 s vs 77 ms on pff-dressmaker) and a
1924
+ // pathspec = the union of the sections' globs.
1925
+ // - The result is cached per (worktree HEAD, index.json mtime+size) in <store>/cache/freshness.json; HEAD is read
1926
+ // from files (readHead), so an unchanged HEAD costs a stat and a small JSON read, no process.
1927
+ // - A section sha missing from the object store (merged in another clone, branch deleted and gc'd) or abbreviated is
1928
+ // "unknown", never fresh, never a crash. A sha on an unmerged branch that exists locally is diffed like any other
1929
+ // commit: the net diff is what differs between the code the knowledge describes and the code checked out.
1930
+ // - Uncommitted changes only with `--dirty` (CLI): `git --no-optional-locks status -uno --no-renames`, 2 s timeout,
1931
+ // "chưa kiểm" when it runs out (pff-rotate-rings: 67 s cold). Hooks never run git status.
1932
+ // - The start block refreshes the cwd repo only (one diff-tree on a cache miss); related repos show their cache.
1933
+
1934
+ const FRESH_CACHE_MAX = 12; // HEADs remembered per store (worktrees of one repo share the store)
1935
+ const DIFF_TIMEOUT_MS = 10000;
1936
+ const HOOK_DIFF_TIMEOUT_MS = 1500;
1937
+ const DIRTY_TIMEOUT_MS = 2000;
1938
+ const PATHSPEC_MAX_CHARS = 6000; // beyond this: no pathspec (Windows command line limit), filter in JS only
1939
+ const STALE_FILES_KEEP = 50; // stale files kept per section (the count stays exact)
1940
+ const FULL_SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
1941
+
1942
+ /**
1943
+ * Does repo-relative file `rel` match glob `g`? `**` spans directories (`a/**` and `**\/x`), `*`/`?` stay within one
1944
+ * segment, a trailing `/` or a wildcard-free pattern naming a directory matches everything below it.
1945
+ */
1946
+ function globMatch(g, rel) {
1947
+ let p = toSlash(String(g).trim()).replace(/^\.\//, '').replace(/^\/+/, '');
1948
+ const f = toSlash(rel).replace(/^\.\//, '').replace(/^\/+/, '');
1949
+ if (!p) return false;
1950
+ const ci = process.platform === 'win32';
1951
+ const eq = (a, b) => (ci ? a.toLowerCase() === b.toLowerCase() : a === b);
1952
+ if (!/[*?]/.test(p)) {
1953
+ const d = p.replace(/\/+$/, '');
1954
+ return eq(f, d) || eq(f.slice(0, d.length + 1), `${d}/`);
1955
+ }
1956
+ if (p.endsWith('/')) p += '**';
1957
+ let re = '';
1958
+ for (let i = 0; i < p.length; i++) {
1959
+ const c = p[i];
1960
+ if (c === '*') {
1961
+ if (p[i + 1] === '*') {
1962
+ if (p[i + 2] === '/') { re += '(?:.*/)?'; i += 2; } else { re += '.*'; i += 1; }
1963
+ } else re += '[^/]*';
1964
+ } else if (c === '?') re += '[^/]';
1965
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
1966
+ }
1967
+ return new RegExp(`^${re}$`, ci ? 'i' : '').test(f);
1968
+ }
1969
+
1970
+ /**
1971
+ * git pathspecs for a set of globs (a superset filter; the exact match is globMatch). Null = no pathspec (whole
1972
+ * repo): a glob matching everything, one git would refuse (absolute, `..`), or a list too long for a command line.
1973
+ */
1974
+ function pathspecOf(globs) {
1975
+ const magic = process.platform === 'win32' ? ':(glob,icase)' : ':(glob)';
1976
+ const out = new Set();
1977
+ for (const g0 of globs) {
1978
+ const g = toSlash(String(g0).trim()).replace(/^\.\//, '').replace(/^\/+/, '');
1979
+ if (!g || /^[*/]+$/.test(g)) return null;
1980
+ if (/^[A-Za-z]:/.test(g) || g.split('/').includes('..') || g.startsWith(':')) return null;
1981
+ if (g.endsWith('/')) out.add(`${magic}${g}**`);
1982
+ else if (!/[*?[]/.test(g)) { out.add(`${magic}${g}`); out.add(`${magic}${g}/**`); } else out.add(`${magic}${g}`);
1983
+ }
1984
+ const list = Array.from(out);
1985
+ if (!list.length || list.join(' ').length > PATHSPEC_MAX_CHARS) return null;
1986
+ return list;
1987
+ }
1988
+
1989
+ /** One git process (read-only: --no-optional-locks), bounded by a timeout; we started it, we may kill it. */
1990
+ function runGit(cwd, args, input, timeoutMs) {
1991
+ const t0 = Date.now();
1992
+ const r = require('child_process').spawnSync('git', ['--no-optional-locks', '-c', 'core.quotepath=off', ...args], {
1993
+ cwd, input, encoding: 'utf8', windowsHide: true, timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024,
1994
+ });
1995
+ const ms = Date.now() - t0;
1996
+ if (r.error && r.error.code === 'ETIMEDOUT') return { ok: false, timedOut: true, ms, error: `quá ${timeoutMs} ms` };
1997
+ if (r.error) return { ok: false, ms, error: r.error.message };
1998
+ if (r.status !== 0) return { ok: false, ms, error: (r.stderr || '').trim().split(/\r?\n/)[0] || `exit ${r.status}` };
1999
+ return { ok: true, ms, stdout: r.stdout || '' };
2000
+ }
2001
+
2002
+ /**
2003
+ * Files changed between each sha and `head` (one `git diff-tree --stdin` for all of them). `--always` prints the
2004
+ * header line even for an empty diff, so a sha without header is an object this repo does not have (diff-tree skips
2005
+ * those silently, exit 0). Returns { bySha: Map<sha, files[]>, missing: Set<sha>, ms } or { error, timedOut, ms }.
2006
+ */
2007
+ function diffShas(root, shas, head, globs, timeoutMs) {
2008
+ const spec = pathspecOf(globs);
2009
+ const base = ['diff-tree', '--stdin', '--always', '-r', '--name-only', '--no-renames', '-z'];
2010
+ const input = shas.map((s) => `${s} ${head}\n`).join('');
2011
+ let r = runGit(root, spec ? base.concat('--', spec) : base, input, timeoutMs);
2012
+ if (!r.ok && !r.timedOut && spec) r = runGit(root, base, input, timeoutMs); // pathspec refused: filter in JS only
2013
+ if (!r.ok) return { error: r.error, timedOut: !!r.timedOut, ms: r.ms };
2014
+ const want = new Set(shas.map((s) => s.toLowerCase()));
2015
+ const bySha = new Map();
2016
+ let cur = null;
2017
+ for (const tok of r.stdout.split('\0')) {
2018
+ const t = tok.replace(/^\n+/, '');
2019
+ if (!t) continue;
2020
+ const low = t.toLowerCase();
2021
+ if (want.has(low) && !bySha.has(low)) { cur = []; bySha.set(low, cur); continue; }
2022
+ if (cur) cur.push(t);
2023
+ }
2024
+ const missing = new Set(Array.from(want).filter((s) => !bySha.has(s)));
2025
+ return { bySha, missing, ms: r.ms };
2026
+ }
2027
+
2028
+ /** Uncommitted tracked changes (`status -uno`, no index refresh write), bounded by a timeout. CLI only. */
2029
+ function dirtyFiles(root, globs, timeoutMs) {
2030
+ const spec = pathspecOf(globs);
2031
+ const base = ['status', '--porcelain=v1', '-z', '-uno', '--no-renames'];
2032
+ const r = runGit(root, spec ? base.concat('--', spec) : base, undefined, timeoutMs || DIRTY_TIMEOUT_MS);
2033
+ if (!r.ok) return { error: r.error, timedOut: !!r.timedOut, ms: r.ms };
2034
+ const files = r.stdout.split('\0').filter((x) => x.length > 3).map((x) => x.slice(3));
2035
+ return { files, ms: r.ms };
2036
+ }
2037
+
2038
+ function indexStamp(dir) {
2039
+ try {
2040
+ const st = fs.statSync(path.join(dir, 'index.json'));
2041
+ return { mtime: st.mtimeMs, size: st.size };
2042
+ } catch (_) { return null; }
2043
+ }
2044
+
2045
+ function freshnessCacheFile(dir) {
2046
+ return path.join(dir, 'cache', 'freshness.json');
2047
+ }
2048
+
2049
+ function readFreshCache(dir) {
2050
+ try {
2051
+ const c = JSON.parse(fs.readFileSync(freshnessCacheFile(dir), 'utf8'));
2052
+ return c && Array.isArray(c.entries) ? c : { entries: [] };
2053
+ } catch (_) { return { entries: [] }; }
2054
+ }
2055
+
2056
+ function cachedFor(dir, head, stamp) {
2057
+ if (!head || !stamp) return null;
2058
+ return readFreshCache(dir).entries.find((e) => e.head === head && e.index_mtime === stamp.mtime && e.index_size === stamp.size) || null;
2059
+ }
2060
+
2061
+ /** Best effort (tmp+rename, last writer wins); a store that vanished or is read-only only loses the cache. */
2062
+ function writeFreshCache(dir, entry) {
2063
+ try {
2064
+ const rest = readFreshCache(dir).entries.filter((e) => e.head !== entry.head);
2065
+ writeFileAtomic(freshnessCacheFile(dir), `${JSON.stringify({ schema: 1, entries: [entry, ...rest].slice(0, FRESH_CACHE_MAX) })}\n`);
2066
+ } catch (_) { /* cache only */ }
2067
+ }
2068
+
2069
+ function freshCounts(sections) {
2070
+ const c = { total: 0, fresh: 0, stale: 0, unknown: 0 };
2071
+ for (const s of Object.values(sections)) { c.total++; c[s.state] = (c[s.state] || 0) + 1; }
2072
+ return c;
2073
+ }
2074
+
2075
+ /**
2076
+ * Freshness of every section of a store, for the worktree at `root`.
2077
+ * o = { dir (store), idx (parsed index), root (worktree root), head (readHead result), stamp (indexStamp),
2078
+ * cache (default true), timeoutMs }
2079
+ * Returns { head, branch, cached, ms_git, computed, sections: { id: { state: fresh|stale|unknown, files?, n_files?,
2080
+ * why? } }, counts, error? }. Only a complete answer is cached (no git error, HEAD known).
2081
+ */
2082
+ function computeFreshness(o) {
2083
+ const idx = o.idx;
2084
+ const head = o.head && o.head.sha ? o.head.sha.toLowerCase() : null;
2085
+ const out = { head, branch: o.head ? o.head.branch || null : null, cached: false, ms_git: 0 };
2086
+ const stamp = o.stamp || indexStamp(o.dir);
2087
+ if (head && o.cache !== false) {
2088
+ const hit = cachedFor(o.dir, head, stamp);
2089
+ if (hit) return Object.assign(out, { cached: true, computed: hit.computed, sections: hit.sections, counts: freshCounts(hit.sections) });
2090
+ }
2091
+ const sections = {};
2092
+ const pending = new Map(); // sha -> [section]
2093
+ let why = null;
2094
+ 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?)';
2095
+ else if (!o.root) why = 'repo không có thư mục làm việc (bare)';
2096
+ for (const s of idx.sections) {
2097
+ const sha = String(s.sha || '').toLowerCase();
2098
+ if (why) sections[s.id] = { state: 'unknown', why };
2099
+ else if (!sha || sha === 'none') sections[s.id] = { state: 'unknown', why: 'mục chưa có sha' };
2100
+ else if (sha === head) sections[s.id] = { state: 'fresh' };
2101
+ 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 đủ` };
2102
+ else { if (!pending.has(sha)) pending.set(sha, []); pending.get(sha).push(s); }
2103
+ }
2104
+ let error = null;
2105
+ if (pending.size) {
2106
+ const globs = [];
2107
+ for (const list of pending.values()) for (const s of list) globs.push(...(s.paths || []));
2108
+ const d = diffShas(o.root, Array.from(pending.keys()), head, globs, o.timeoutMs || DIFF_TIMEOUT_MS);
2109
+ out.ms_git = d.ms || 0;
2110
+ if (d.error) error = d.error;
2111
+ for (const [sha, list] of pending) {
2112
+ for (const s of list) {
2113
+ if (d.error) { sections[s.id] = { state: 'unknown', why: `git diff-tree lỗi: ${d.error}` }; continue; }
2114
+ 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; }
2115
+ const files = d.bySha.get(sha).filter((f) => (s.paths || []).some((g) => globMatch(g, f)));
2116
+ sections[s.id] = files.length ? { state: 'stale', files: files.slice(0, STALE_FILES_KEEP), n_files: files.length } : { state: 'fresh' };
2117
+ }
2118
+ }
2119
+ }
2120
+ Object.assign(out, { computed: new Date().toISOString(), sections, counts: freshCounts(sections) });
2121
+ if (error) out.error = error;
2122
+ if (head && !error && o.cache !== false && stamp) {
2123
+ writeFreshCache(o.dir, { head, index_mtime: stamp.mtime, index_size: stamp.size, computed: out.computed, sections });
2124
+ }
2125
+ return out;
2126
+ }
2127
+
2128
+ /** Adds `dirty` (uncommitted files matching each section) in place; returns { checked, why?, ms }. */
2129
+ function addDirty(fr, idx, root, timeoutMs) {
2130
+ if (!root) return { checked: false, why: 'repo không có thư mục làm việc' };
2131
+ const globs = [];
2132
+ for (const s of idx.sections) globs.push(...(s.paths || []));
2133
+ const d = dirtyFiles(root, globs, timeoutMs);
2134
+ 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 };
2135
+ for (const s of idx.sections) {
2136
+ const files = d.files.filter((f) => (s.paths || []).some((g) => globMatch(g, f)));
2137
+ if (files.length && fr.sections[s.id]) fr.sections[s.id].dirty = files.slice(0, STALE_FILES_KEEP);
2138
+ }
2139
+ return { checked: true, ms: d.ms, n: d.files.length };
2140
+ }
2141
+
2142
+ function shortFileList(files, n) {
2143
+ const k = n || 3;
2144
+ const f = files || [];
2145
+ return f.slice(0, k).join(', ') + (f.length > k ? ` (+${f.length - k})` : '');
2146
+ }
2147
+
2148
+ /** "N mục, k stale (ids), u không xác định (ids)". */
2149
+ function knowledgeSummary(fr, n) {
2150
+ const ids = (st) => Object.keys(fr.sections).filter((id) => fr.sections[id].state === st);
2151
+ const stale = ids('stale');
2152
+ const unk = ids('unknown');
2153
+ const parts = [`${n} mục`, `${stale.length} stale${stale.length ? ` (${shortFileList(stale, 6)})` : ''}`];
2154
+ if (unk.length) parts.push(`${unk.length} không xác định (${shortFileList(unk, 4)})`);
2155
+ return parts.join(', ');
2156
+ }
2157
+
2158
+ /** index.json of a store, tolerant (null when missing or unreadable). */
2159
+ function readStoreIndex(dir) {
2160
+ try {
2161
+ const idx = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8').replace(/^/, ''));
2162
+ if (!idx || typeof idx !== 'object' || Array.isArray(idx)) return null;
2163
+ if (!Array.isArray(idx.sections)) idx.sections = [];
2164
+ if (!Array.isArray(idx.related)) idx.related = [];
2165
+ return idx;
2166
+ } catch (_) { return null; }
2167
+ }
2168
+
2169
+ /** Cache-only status of a related repo (no git process): "N mục, k stale (cache)" or why it is unchecked. */
2170
+ function relatedStatus(r) {
2171
+ const rp = typeof r.path === 'string' && r.path ? nativePath(r.path) : null;
2172
+ const info = rp && isDir(rp) ? resolveProject(rp) : null;
2173
+ 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' };
2174
+ const stamp = indexStamp(info.dir);
2175
+ const idx = stamp ? readStoreIndex(info.dir) : null;
2176
+ if (!idx) return { name: r.name, rel: r.rel, text: 'chưa có kho' };
2177
+ const h = readHead(rp);
2178
+ const hit = h && h.sha ? cachedFor(info.dir, h.sha.toLowerCase(), stamp) : null;
2179
+ const n = idx.sections.length;
2180
+ 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)` };
2181
+ return { name: r.name, rel: r.rel, n, counts: freshCounts(hit.sections), text: `${knowledgeSummary({ sections: hit.sections }, n)} (cache)` };
2182
+ }
2183
+
2184
+ /**
2185
+ * Knowledge lines for the start block of the repo at `cwd`; null outside a repo or when the repo has no store.
2186
+ * Refreshes this repo only (at most one git diff-tree, on a cache miss, bounded by HOOK_DIFF_TIMEOUT_MS; never git
2187
+ * status); related repos report their cache. Never throws.
2188
+ */
2189
+ function startKnowledge(cwd, opts) {
2190
+ const t0 = Date.now();
2191
+ try {
2192
+ if (!cwd) return null;
2193
+ const info = resolveProject(cwd);
2194
+ if (!info || !info.exists) return null;
2195
+ const stamp = indexStamp(info.dir);
2196
+ if (!stamp) return null;
2197
+ const idx = readStoreIndex(info.dir);
2198
+ if (!idx) return { dir: info.dir, lines: [`project_knowledge: ${info.dir} — index.json không đọc được (\`dieu-phoi knowledge list\` để xem lỗi)`] };
2199
+ const fr = computeFreshness({ dir: info.dir, idx, root: info.root, head: readHead(cwd), stamp, timeoutMs: (opts && opts.timeoutMs) || HOOK_DIFF_TIMEOUT_MS });
2200
+ const related = idx.related.map(relatedStatus);
2201
+ const n = idx.sections.length;
2202
+ const tail = fr.error ? ` — git lỗi (${fr.error}), chạy \`dieu-phoi knowledge status\`` : '';
2203
+ 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>\``];
2204
+ for (const r of related) lines.push(` related: ${r.name} (${r.rel === 'parent' ? 'cha' : 'con'}) — ${r.text}`);
2205
+ return {
2206
+ dir: info.dir, name: info.name, key: info.key, head: fr.head, cached: fr.cached, ms: Date.now() - t0, ms_git: fr.ms_git,
2207
+ counts: fr.counts, stale: Object.keys(fr.sections).filter((id) => fr.sections[id].state === 'stale'),
2208
+ related: related.map((r) => ({ name: r.name, rel: r.rel, counts: r.counts || null, text: r.text })), lines,
2209
+ };
2210
+ } catch (e) {
2211
+ return { lines: [], error: e.message, ms: Date.now() - t0 };
2212
+ }
2213
+ }
2214
+
2215
+ /** git-bash style `/d/x` -> `D:/x` on Windows (other platforms and other paths unchanged). */
2216
+ function nativePath(c) {
2217
+ return process.platform === 'win32' ? String(c).replace(/^\/([a-zA-Z])(?=\/|$)/, '$1:') : String(c);
2218
+ }
2219
+
2220
+ /** First existing dir named by a `repo: <path>` phrase of a brief (git-bash `/d/x` paths accepted). */
2221
+ function repoFromBrief(prompt) {
2222
+ if (typeof prompt !== 'string' || !prompt) return null;
2223
+ const head = prompt.slice(0, 6000);
2224
+ const re = /(?:^|[\s(])repo\s*:\s*(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/gi;
2225
+ let m;
2226
+ while ((m = re.exec(head))) {
2227
+ let c = m[1];
2228
+ if (/^[`"']/.test(c)) c = c.slice(1, -1);
2229
+ c = c.replace(/[.,;:)\]]+$/, '').trim();
2230
+ if (!c || /[<>*?|]/.test(c)) continue;
2231
+ c = nativePath(c);
2232
+ if (isDir(c)) return path.resolve(c);
2233
+ }
2234
+ return null;
2235
+ }
2236
+
2237
+ /**
2238
+ * Project of a hook payload: the brief's `repo:` target wins (a session in the parent repo works on a child repo),
2239
+ * else the payload cwd. Returns { project: <store dir name>, project_src: 'repo'|'cwd' }, or {} when neither is in a repo.
2240
+ */
2241
+ function projectFields(p) {
2242
+ try {
2243
+ const ti = p && p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
2244
+ const cands = [['repo', repoFromBrief(ti.prompt)], ['cwd', p && typeof p.cwd === 'string' && p.cwd ? p.cwd : null]];
2245
+ for (const [src, dir] of cands) {
2246
+ if (!dir) continue;
2247
+ const r = resolveProject(dir);
2248
+ if (r) return { project: r.dir_name, project_src: src };
2249
+ }
2250
+ } catch (_) { /* identity is best effort; never break the dispatch event */ }
2251
+ return {};
2252
+ }
2253
+
1590
2254
  function buildDispatch(p, ctx) {
1591
2255
  const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
1592
2256
  const tr = p.tool_response;
@@ -1631,6 +2295,7 @@ function buildDispatch(p, ctx) {
1631
2295
  orch_compactions: orch.compactions,
1632
2296
  orch_cum_usd: orch.cum_usd,
1633
2297
  }, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
2298
+ projectFields(p),
1634
2299
  tierFields(ctx, tag, normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null, explicitEffort || parentEffort || null),
1635
2300
  // core 0.5.0: an Agent call made inside a subagent (gen-2) carries the caller's agent_id
1636
2301
  typeof p.agent_id === 'string' && p.agent_id ? { parent_agent_id: p.agent_id } : {},
@@ -2546,16 +3211,18 @@ async function runSync(ctx, opts) {
2546
3211
  // ------------------------------------------------------------------ exports / main
2547
3212
 
2548
3213
  module.exports = {
2549
- 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,
3214
+ 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,
2550
3215
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
2551
- sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
3216
+ sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
2552
3217
  logError, computeUserHash, userHash, commonFields, parseTag, resolveTag, knownTaskTypes, tagIssueMessage, DEFAULT_EXTRA_TYPES,
2553
3218
  recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
2554
3219
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
2555
3220
  firstPromptText, tagFromTranscript, summarizeTranscript,
2556
- 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,
3221
+ 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,
2557
3222
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
2558
3223
  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,
3224
+ 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,
3225
+ projectsDir, findGit, originUrlFromConfig, normalizeRemoteUrl, resolveProject, readHead, repoFromBrief, projectFields, nativePath, isStoreDirName, aliasesFile, loadAliases, keyDirName,
2559
3226
  extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
2560
3227
  };
2561
3228