@kienntpixon/dieu-phoi 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.md +9 -3
- package/agents/dp-exec.md +2 -0
- package/agents/dp-scout.md +2 -0
- package/agents/dp-verify.md +2 -0
- package/bin/dieu-phoi.cjs +21 -3
- package/hook.cjs +673 -7
- package/lib/args.cjs +19 -2
- package/lib/commands.cjs +86 -20
- package/lib/knowledge.cjs +1002 -0
- package/lib/profile.cjs +204 -0
- package/lib/prune.cjs +57 -20
- package/package.json +1 -1
package/hook.cjs
CHANGED
|
@@ -26,7 +26,7 @@ const path = require('path');
|
|
|
26
26
|
const os = require('os');
|
|
27
27
|
const crypto = require('crypto');
|
|
28
28
|
|
|
29
|
-
const CORE_VERSION = '0.
|
|
29
|
+
const CORE_VERSION = '0.6.0';
|
|
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 = '';
|
|
@@ -1442,13 +1489,29 @@ function stepIds(root, n) {
|
|
|
1442
1489
|
return Array.from(ids);
|
|
1443
1490
|
}
|
|
1444
1491
|
|
|
1445
|
-
|
|
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) {
|
|
1446
1502
|
const run = startRun();
|
|
1447
1503
|
const health = quickHealth(ctx, run.runs);
|
|
1448
|
-
|
|
1504
|
+
const info = {
|
|
1449
1505
|
id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx),
|
|
1450
1506
|
orch_lessons: orchLessons(ctx),
|
|
1451
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 : [];
|
|
1452
1515
|
}
|
|
1453
1516
|
|
|
1454
1517
|
function orchLessonLines(info) {
|
|
@@ -1465,6 +1528,7 @@ function formatStartBlock(info) {
|
|
|
1465
1528
|
`health: ${healthLine(info.health)}`,
|
|
1466
1529
|
`scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
|
|
1467
1530
|
...orchLessonLines(info),
|
|
1531
|
+
...knowledgeLines(info),
|
|
1468
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.',
|
|
1469
1533
|
].join('\n');
|
|
1470
1534
|
}
|
|
@@ -1472,7 +1536,7 @@ function formatStartBlock(info) {
|
|
|
1472
1536
|
/** UserPromptSubmit: on a dieu-phoi prompt, pre-create the run and inject id/run dir/health/brief. */
|
|
1473
1537
|
function handleUserPromptSubmit(p, ctx) {
|
|
1474
1538
|
if (!isDieuPhoiPrompt(p.prompt)) return null;
|
|
1475
|
-
const info = startInfo(ctx);
|
|
1539
|
+
const info = startInfo(ctx, typeof p.cwd === 'string' ? p.cwd : null);
|
|
1476
1540
|
return {
|
|
1477
1541
|
stdout: JSON.stringify({
|
|
1478
1542
|
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: formatStartBlock(info) },
|
|
@@ -1587,6 +1651,605 @@ function handoffChainMessage(taskId, n) {
|
|
|
1587
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\`).`;
|
|
1588
1652
|
}
|
|
1589
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
|
+
|
|
1590
2253
|
function buildDispatch(p, ctx) {
|
|
1591
2254
|
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
1592
2255
|
const tr = p.tool_response;
|
|
@@ -1631,6 +2294,7 @@ function buildDispatch(p, ctx) {
|
|
|
1631
2294
|
orch_compactions: orch.compactions,
|
|
1632
2295
|
orch_cum_usd: orch.cum_usd,
|
|
1633
2296
|
}, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
|
|
2297
|
+
projectFields(p),
|
|
1634
2298
|
tierFields(ctx, tag, normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null, explicitEffort || parentEffort || null),
|
|
1635
2299
|
// core 0.5.0: an Agent call made inside a subagent (gen-2) carries the caller's agent_id
|
|
1636
2300
|
typeof p.agent_id === 'string' && p.agent_id ? { parent_agent_id: p.agent_id } : {},
|
|
@@ -2546,16 +3210,18 @@ async function runSync(ctx, opts) {
|
|
|
2546
3210
|
// ------------------------------------------------------------------ exports / main
|
|
2547
3211
|
|
|
2548
3212
|
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,
|
|
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,
|
|
2550
3214
|
CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
|
|
2551
|
-
sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
|
|
3215
|
+
sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
|
|
2552
3216
|
logError, computeUserHash, userHash, commonFields, parseTag, resolveTag, knownTaskTypes, tagIssueMessage, DEFAULT_EXTRA_TYPES,
|
|
2553
3217
|
recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
|
|
2554
3218
|
priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
|
|
2555
3219
|
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,
|
|
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,
|
|
2557
3221
|
loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
|
|
2558
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,
|
|
2559
3225
|
extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
|
|
2560
3226
|
};
|
|
2561
3227
|
|