@kienntpixon/dieu-phoi 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +3 -0
- package/agents/dp-exec.md +2 -0
- package/agents/dp-helper.md +2 -0
- package/agents/dp-scout.md +2 -0
- package/agents/dp-verify.md +2 -0
- package/bin/dieu-phoi.cjs +14 -2
- package/hook.cjs +788 -34
- package/lib/aggregate.cjs +49 -6
- package/lib/args.cjs +1 -1
- package/lib/choose.cjs +41 -25
- package/lib/commands.cjs +212 -42
- package/lib/knowledge.cjs +77 -22
- package/lib/procs.cjs +223 -0
- package/lib/profile-scan.cjs +85 -0
- package/lib/profile-seed.cjs +147 -0
- package/lib/profile.cjs +141 -1
- package/lib/prune.cjs +56 -8
- package/lib/setup.cjs +47 -15
- package/lib/worktree.cjs +186 -0
- package/package.json +3 -1
- package/profile/lessons.md +83 -0
- package/profile/unity-lanes.md +316 -0
package/lib/knowledge.cjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Delta (written by agents into a run dir as knowledge-delta*.md, merged at gate 3):
|
|
16
16
|
// repo: <path of the repo/worktree the delta was checked against> (header, before the first `## `)
|
|
17
|
+
// or a short name (`pff-tailor`) that matches exactly one known store (its name / dir name / a recorded root)
|
|
17
18
|
// head: <sha> (optional; else HEAD read from repo:)
|
|
18
19
|
// ## +<id> new section ## <id> replace an existing one
|
|
19
20
|
// title: / tags: a, b / paths: glob, glob / base: <sha>#<rev> (meta lines right under the heading)
|
|
@@ -27,7 +28,6 @@
|
|
|
27
28
|
const fs = require('fs');
|
|
28
29
|
const path = require('path');
|
|
29
30
|
const cp = require('child_process');
|
|
30
|
-
const crypto = require('crypto');
|
|
31
31
|
const core = require('../hook.cjs');
|
|
32
32
|
const { repoOf } = require('./args.cjs');
|
|
33
33
|
|
|
@@ -55,7 +55,8 @@ const KNOWLEDGE_HELP = `Cách dùng: dieu-phoi knowledge <lệnh> [--repo <đư
|
|
|
55
55
|
merge --run <id|thư mục run> [--task <id>[,<id>] [--untagged]] [--dry-run] [--json]
|
|
56
56
|
gộp mọi knowledge-delta*.md của run (--task: chỉ knowledge-delta-<task_id>[-r<n>].md của các
|
|
57
57
|
task đó, --untagged: thêm file không mang task_id; verdict accept tự gọi bước này) vào kho của repo ghi ở header "repo:" (không có thì
|
|
58
|
-
--repo/cwd
|
|
58
|
+
--repo/cwd; "repo:" là tên ngắn như pff-tailor thì khớp kho đã biết nếu duy nhất — HEAD lấy từ repo đã ghi
|
|
59
|
+
nhớ (meta.json roots), --repo hoặc dòng head:): khoá knowledge.lock, ghi tmp+rename, bản cũ vào history/, sha = HEAD của repo đó.
|
|
59
60
|
"## +id" thêm mục (bắt buộc paths:), "## id" thay mục (bắt buộc base: <sha>#<rev>); base cũ
|
|
60
61
|
→ xung đột, không ghi đè; mục > 40 dòng bị từ chối. Có xung đột/từ chối → exit 2.
|
|
61
62
|
Idempotent: file delta đã gộp (hash trong meta.json) và mục trùng một rev cũ trong history/ → "không đổi"
|
|
@@ -573,10 +574,9 @@ function runDirOf(v) {
|
|
|
573
574
|
return isDir(p) ? { dir: p } : { error: `Không thấy run ${s} (${slash(p)}).` };
|
|
574
575
|
}
|
|
575
576
|
|
|
576
|
-
/** Task id a delta file belongs to: knowledge-delta-<task_id>[-r<n>].md (8 chars a-z0-9); null = untagged. */
|
|
577
|
+
/** Task id a delta file belongs to: knowledge-delta-<task_id>[-r<n>|-h<n>].md (8 chars a-z0-9; -h<n> = handoff); null = untagged. */
|
|
577
578
|
function deltaTaskOf(file) {
|
|
578
|
-
|
|
579
|
-
return m ? m[1].toLowerCase() : null;
|
|
579
|
+
return core.deltaTagOf(file);
|
|
580
580
|
}
|
|
581
581
|
|
|
582
582
|
/**
|
|
@@ -584,7 +584,7 @@ function deltaTaskOf(file) {
|
|
|
584
584
|
* tagged with one of them (plus untagged ones when untagged is true).
|
|
585
585
|
*/
|
|
586
586
|
function deltaFiles(runDir, filter) {
|
|
587
|
-
const num = (f) => { const m = /-
|
|
587
|
+
const num = (f) => { const m = /-[rh](\d+)\.md$/i.exec(f); return m ? Number(m[1]) : 0; };
|
|
588
588
|
return fs.readdirSync(runDir)
|
|
589
589
|
.filter((f) => /^knowledge-delta.*\.md$/i.test(f))
|
|
590
590
|
.filter((f) => {
|
|
@@ -609,8 +609,25 @@ function sameContent(cur, body, next) {
|
|
|
609
609
|
const APPLIED_MAX = 300;
|
|
610
610
|
|
|
611
611
|
/** sha256 of the delta text, BOM and CRLF normalised so a CRLF checkout hashes like the LF original. */
|
|
612
|
-
|
|
613
|
-
|
|
612
|
+
const deltaHash = core.deltaHash; // shared with the unmerged-delta scan in hook.cjs
|
|
613
|
+
|
|
614
|
+
/** Main checkout of the repo `info` describes (a linked worktree is deleted later; the checkout stays). */
|
|
615
|
+
function mainRootOf(info) {
|
|
616
|
+
const common = info && info.common_dir ? String(info.common_dir) : '';
|
|
617
|
+
if (common && path.basename(common) === '.git') return slash(path.dirname(common));
|
|
618
|
+
return info && info.root ? slash(info.root) : null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Remember where a store's repo lives (meta.json `roots`, newest first, <= 5) so a bare `repo: <name>` can find HEAD. */
|
|
622
|
+
function noteRoot(dir, info) {
|
|
623
|
+
const root = mainRootOf(info);
|
|
624
|
+
if (!root || !isDir(dir)) return;
|
|
625
|
+
const file = path.join(dir, 'meta.json');
|
|
626
|
+
const meta = core.readJsonFile(file, {}) || {};
|
|
627
|
+
const roots = Array.isArray(meta.roots) ? meta.roots : [];
|
|
628
|
+
if (roots[0] === root) return;
|
|
629
|
+
const next = [root].concat(roots.filter((r) => r !== root)).slice(0, 5);
|
|
630
|
+
core.writeFileAtomic(file, `${JSON.stringify(Object.assign({}, meta, { roots: next }), null, 2)}\n`);
|
|
614
631
|
}
|
|
615
632
|
|
|
616
633
|
function appliedOf(dir) {
|
|
@@ -741,9 +758,11 @@ function applyDelta(delta, info, sha, dry, src) {
|
|
|
741
758
|
core.writeFileAtomic(path.join(dir, 'index.json'), `${JSON.stringify(idx, null, 2)}\n`);
|
|
742
759
|
ensureMeta(dir, info);
|
|
743
760
|
}
|
|
744
|
-
if (
|
|
761
|
+
if (!dry && src && isDir(dir) && !results.some((r) => r.op === 'conflict' || r.op === 'reject')) {
|
|
762
|
+
// also when every section was already merged ("same"): the file counts as handled, not as an unmerged delta
|
|
745
763
|
recordApplied(dir, info, { sha256: src.hash, file: src.file, run: src.run, at: now.toISOString() });
|
|
746
764
|
}
|
|
765
|
+
if (!dry && changed && info.common_dir) noteRoot(dir, info);
|
|
747
766
|
return { results };
|
|
748
767
|
}
|
|
749
768
|
|
|
@@ -766,6 +785,45 @@ function describe(r) {
|
|
|
766
785
|
return `${OP_MARK[r.op] || '?'} ${r.id} BỊ TỪ CHỐI: ${r.why}`;
|
|
767
786
|
}
|
|
768
787
|
|
|
788
|
+
/**
|
|
789
|
+
* Where a delta goes: { target, info } — target = repo dir to read HEAD from (null when only the store is known),
|
|
790
|
+
* info = project identity (resolveProject, or the bare store entry for a short `repo:` name). Order: a short name that
|
|
791
|
+
* matches exactly one known store, then the path, then --repo as the fallback for a repo that no longer exists.
|
|
792
|
+
*/
|
|
793
|
+
function targetOf(delta, opts, rep) {
|
|
794
|
+
const err = (error) => ({ error });
|
|
795
|
+
if (!delta.repo) return finish(repoOf(opts));
|
|
796
|
+
if (core.isBareRepoName(delta.repo)) {
|
|
797
|
+
const hit = core.storeByName(delta.repo);
|
|
798
|
+
if (hit.matches.length > 1) {
|
|
799
|
+
return err(`repo: ${delta.repo} khớp ${hit.matches.length} kho (${hit.matches.map((m) => m.dir_name).join(', ')}) — ghi đường dẫn đầy đủ hoặc chạy với --repo`);
|
|
800
|
+
}
|
|
801
|
+
if (hit.store) {
|
|
802
|
+
const st = hit.store;
|
|
803
|
+
rep.note = `repo: ${delta.repo} → kho ${st.dir_name}`;
|
|
804
|
+
const cands = st.roots.map((r) => core.nativePath(r)).concat(opts.repo !== undefined ? [repoOf(opts)] : []);
|
|
805
|
+
for (const c of cands) {
|
|
806
|
+
if (!isDir(c)) continue;
|
|
807
|
+
const pi = core.resolveProject(c);
|
|
808
|
+
if (pi && pi.dir === st.dir) return { target: path.resolve(c), info: pi };
|
|
809
|
+
}
|
|
810
|
+
return { target: null, info: { dir: st.dir, dir_name: st.dir_name, key: st.key, name: st.name } };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (delta.repo) {
|
|
814
|
+
const p = path.resolve(core.nativePath(delta.repo));
|
|
815
|
+
if (isDir(p)) return finish(p);
|
|
816
|
+
if (opts.repo !== undefined) { rep.note = `repo: ${delta.repo} không còn — dùng --repo`; return finish(repoOf(opts)); }
|
|
817
|
+
const bare = core.isBareRepoName(delta.repo);
|
|
818
|
+
return err(`repo: ${delta.repo} ${bare ? 'không khớp kho nào đã biết và không phải thư mục' : 'không tồn tại (worktree đã xoá?)'} — chạy lại với --repo <đường dẫn repo>`);
|
|
819
|
+
}
|
|
820
|
+
return err('không xác định được repo');
|
|
821
|
+
function finish(target) {
|
|
822
|
+
const info = core.resolveProject(target);
|
|
823
|
+
return info ? { target, info } : err(`không phải repo git: ${slash(target)}`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
769
827
|
function cmdMerge(opts) {
|
|
770
828
|
const rd = runDirOf(opts.run);
|
|
771
829
|
if (rd.error) return { code: 1, out: rd.error };
|
|
@@ -786,23 +844,20 @@ function cmdMerge(opts) {
|
|
|
786
844
|
src = { hash: deltaHash(text), file: path.basename(file), run: path.basename(rd.dir) };
|
|
787
845
|
} catch (e) { rep.error = `không đọc được: ${e.message}`; totals.error++; continue; }
|
|
788
846
|
if (!delta.sections.length) { rep.error = 'không có mục "## <id>" nào'; totals.error++; continue; }
|
|
789
|
-
|
|
790
|
-
if (
|
|
791
|
-
|
|
792
|
-
if (isDir(p)) target = p;
|
|
793
|
-
else if (opts.repo !== undefined) { target = repoOf(opts); rep.note = `repo: ${delta.repo} không còn — dùng --repo`; } else {
|
|
794
|
-
rep.error = `repo: ${delta.repo} không tồn tại (worktree đã xoá?) — chạy lại với --repo <đường dẫn repo>`; totals.error++; continue;
|
|
795
|
-
}
|
|
796
|
-
} else target = repoOf(opts);
|
|
797
|
-
const info = core.resolveProject(target);
|
|
798
|
-
if (!info) { rep.error = `không phải repo git: ${slash(target)}`; totals.error++; continue; }
|
|
847
|
+
const t = targetOf(delta, opts, rep);
|
|
848
|
+
if (t.error) { rep.error = t.error; totals.error++; continue; }
|
|
849
|
+
const { target, info } = t;
|
|
799
850
|
let sha = null;
|
|
800
851
|
if (delta.head) {
|
|
801
852
|
if (!/^[0-9a-f]{7,64}$/i.test(delta.head)) { rep.error = `head: không phải sha: ${delta.head}`; totals.error++; continue; }
|
|
802
853
|
sha = delta.head.toLowerCase();
|
|
803
|
-
} else sha = headShaOf(target);
|
|
804
|
-
if (!sha) {
|
|
805
|
-
|
|
854
|
+
} else if (target) sha = headShaOf(target);
|
|
855
|
+
if (!sha) {
|
|
856
|
+
rep.error = target ? `không xác định được HEAD của ${slash(target)} (repo chưa có commit?) — thêm dòng "head: <sha>" vào delta`
|
|
857
|
+
: `repo: ${delta.repo} khớp kho ${info.dir_name} nhưng chưa biết repo nằm đâu để đọc HEAD — thêm dòng "head: <sha>" vào delta hoặc chạy với --repo <đường dẫn repo>`;
|
|
858
|
+
totals.error++; continue;
|
|
859
|
+
}
|
|
860
|
+
Object.assign(rep, { repo: target ? slash(target) : null, store: info.dir, key: info.key, sha });
|
|
806
861
|
let out;
|
|
807
862
|
if (dry) out = applyDelta(delta, info, sha, true, src);
|
|
808
863
|
else {
|
package/lib/procs.cjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// s16 (core 0.7.2): read-only look at the process table. Everything here that decides something is a pure function over a
|
|
3
|
+
// plain list [{pid, ppid, name, created (epoch ms | null), ws (bytes | null), cmd}], so tests feed fixture lists and never
|
|
4
|
+
// touch the system. Only listProcesses() and killTree() reach the OS; listProcesses never throws (null on any failure),
|
|
5
|
+
// killTree is called by the hook only when config kill_leftovers is true.
|
|
6
|
+
|
|
7
|
+
const cp = require('child_process');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 4000;
|
|
10
|
+
const ORPHAN_NAMES = ['claude.exe', 'codebase-memory-mcp.exe', 'node.exe', 'bash.exe'];
|
|
11
|
+
// never reported as a leftover of one agent: the Claude Code processes themselves and the code-graph server (shared)
|
|
12
|
+
const LEFTOVER_SKIP_NAMES = ['claude.exe', 'claude', 'codebase-memory-mcp.exe', 'codebase-memory-mcp', 'conhost.exe'];
|
|
13
|
+
|
|
14
|
+
const PS_WIN = [
|
|
15
|
+
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,',
|
|
16
|
+
"@{n='Created';e={if($_.CreationDate){[int64]($_.CreationDate.ToUniversalTime()-[datetime]'1970-01-01').TotalMilliseconds}else{$null}}},",
|
|
17
|
+
'WorkingSetSize,',
|
|
18
|
+
"@{n='Cmd';e={if($_.CommandLine){$_.CommandLine.Substring(0,[Math]::Min(200,$_.CommandLine.Length))}else{$null}}}",
|
|
19
|
+
' | ConvertTo-Json -Compress',
|
|
20
|
+
].join('');
|
|
21
|
+
|
|
22
|
+
const lower = (s) => String(s || '').toLowerCase();
|
|
23
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : null));
|
|
24
|
+
|
|
25
|
+
/** Windows JSON from PS_WIN (also a single object when only one process) -> list, or null when it is not that shape. */
|
|
26
|
+
function parseWindowsJson(text) {
|
|
27
|
+
let j;
|
|
28
|
+
try { j = JSON.parse(String(text).replace(/^/, '')); } catch (_) { return null; }
|
|
29
|
+
const arr = Array.isArray(j) ? j : (j && typeof j === 'object' ? [j] : null);
|
|
30
|
+
if (!arr) return null;
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const r of arr) {
|
|
33
|
+
if (!r || typeof r !== 'object') continue;
|
|
34
|
+
const pid = num(r.ProcessId);
|
|
35
|
+
if (pid === null) continue;
|
|
36
|
+
out.push({
|
|
37
|
+
pid,
|
|
38
|
+
ppid: num(r.ParentProcessId) === null ? 0 : num(r.ParentProcessId),
|
|
39
|
+
name: typeof r.Name === 'string' ? r.Name : '',
|
|
40
|
+
created: num(r.Created),
|
|
41
|
+
ws: num(r.WorkingSetSize),
|
|
42
|
+
cmd: typeof r.Cmd === 'string' ? r.Cmd : null,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return out.length ? out : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
|
|
49
|
+
|
|
50
|
+
/** `ps -eo pid,ppid,lstart,comm,args` (lstart = "Wed Nov 5 21:06:01 2026", local time) -> list, or null. */
|
|
51
|
+
function parsePs(text) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
54
|
+
const m = /^\s*(\d+)\s+(\d+)\s+\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\s+(\S+)\s*(.*)$/.exec(line);
|
|
55
|
+
if (!m) continue;
|
|
56
|
+
const t = new Date(Number(m[8]), MONTHS[m[3]], Number(m[4]), Number(m[5]), Number(m[6]), Number(m[7])).getTime();
|
|
57
|
+
out.push({ pid: Number(m[1]), ppid: Number(m[2]), name: m[9].replace(/^.*\//, ''), created: Number.isFinite(t) ? t : null, ws: null, cmd: m[10] ? m[10].slice(0, 200) : null });
|
|
58
|
+
}
|
|
59
|
+
return out.length ? out : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Process list of this machine, or null (any error, timeout, empty or unreadable output). Never throws.
|
|
64
|
+
* `exec(file, args, {timeout})` -> stdout string is injectable for tests.
|
|
65
|
+
*/
|
|
66
|
+
function listProcesses(opts) {
|
|
67
|
+
const o = opts || {};
|
|
68
|
+
const timeout = typeof o.timeoutMs === 'number' && o.timeoutMs > 0 ? o.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
69
|
+
const platform = o.platform || process.platform;
|
|
70
|
+
const exec = typeof o.exec === 'function' ? o.exec : defaultExec;
|
|
71
|
+
if (process.env.DIEU_PHOI_NO_PROCS === '1' && typeof o.exec !== 'function') return null; // tests: never look at the real machine
|
|
72
|
+
try {
|
|
73
|
+
if (platform === 'win32') {
|
|
74
|
+
const out = exec('powershell', ['-NoProfile', '-NonInteractive', '-Command', PS_WIN], { timeout });
|
|
75
|
+
return typeof out === 'string' ? parseWindowsJson(out) : null;
|
|
76
|
+
}
|
|
77
|
+
const out = exec('ps', ['-eo', 'pid,ppid,lstart,comm,args'], { timeout });
|
|
78
|
+
return typeof out === 'string' ? parsePs(out) : null;
|
|
79
|
+
} catch (_) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function defaultExec(file, args, o) {
|
|
85
|
+
const r = cp.spawnSync(file, args, { encoding: 'utf8', timeout: o.timeout, windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
|
|
86
|
+
if (r.error || r.status !== 0 || r.signal) return null;
|
|
87
|
+
return r.stdout || '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function byPid(list) {
|
|
91
|
+
const m = new Map();
|
|
92
|
+
for (const p of list || []) m.set(p.pid, p);
|
|
93
|
+
return m;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** All descendants of rootPid (children, grandchildren, ...; not rootPid itself). Cycle-safe (pid reuse). */
|
|
97
|
+
function descendants(list, rootPid) {
|
|
98
|
+
const kids = new Map();
|
|
99
|
+
for (const p of list || []) {
|
|
100
|
+
if (p.pid === p.ppid) continue;
|
|
101
|
+
if (!kids.has(p.ppid)) kids.set(p.ppid, []);
|
|
102
|
+
kids.get(p.ppid).push(p);
|
|
103
|
+
}
|
|
104
|
+
const out = [];
|
|
105
|
+
const seen = new Set([rootPid]);
|
|
106
|
+
const queue = [rootPid];
|
|
107
|
+
while (queue.length) {
|
|
108
|
+
const cur = queue.shift();
|
|
109
|
+
for (const c of kids.get(cur) || []) {
|
|
110
|
+
if (seen.has(c.pid)) continue;
|
|
111
|
+
seen.add(c.pid);
|
|
112
|
+
out.push(c);
|
|
113
|
+
queue.push(c.pid);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Ancestors of pid, nearest first (stops at a missing parent, a cycle or 64 hops). */
|
|
120
|
+
function ancestors(list, pid) {
|
|
121
|
+
const idx = byPid(list);
|
|
122
|
+
const out = [];
|
|
123
|
+
const seen = new Set([pid]);
|
|
124
|
+
let cur = idx.get(pid);
|
|
125
|
+
for (let i = 0; cur && i < 64; i++) {
|
|
126
|
+
const par = idx.get(cur.ppid);
|
|
127
|
+
if (!par || seen.has(par.pid)) break;
|
|
128
|
+
seen.add(par.pid);
|
|
129
|
+
out.push(par);
|
|
130
|
+
cur = par;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Nearest ancestor of pid named claude(.exe), or null. */
|
|
136
|
+
function claudeAncestor(list, pid) {
|
|
137
|
+
return ancestors(list, pid).find((p) => /^claude(\.exe)?$/.test(lower(p.name))) || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A parent that is gone, or whose pid was reused by a process created after the child. */
|
|
141
|
+
function isOrphan(p, idx) {
|
|
142
|
+
if (!p.ppid) return false; // pid 0: the system's own roots
|
|
143
|
+
const par = idx.get(p.ppid);
|
|
144
|
+
if (!par) return true;
|
|
145
|
+
return Boolean(par.created && p.created && par.created > p.created);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Processes whose parent is no longer in the list. `names` (case-insensitive; default claude.exe, codebase-memory-mcp.exe,
|
|
150
|
+
* node.exe, bash.exe), `minAgeMs` + `now` to keep only old ones, `skip(p)` to drop known-legitimate ones.
|
|
151
|
+
*/
|
|
152
|
+
function orphans(list, o) {
|
|
153
|
+
const opt = o || {};
|
|
154
|
+
const names = new Set((opt.names || ORPHAN_NAMES).map(lower));
|
|
155
|
+
const now = typeof opt.now === 'number' ? opt.now : Date.now();
|
|
156
|
+
const idx = byPid(list);
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const p of list || []) {
|
|
159
|
+
if (!names.has(lower(p.name))) continue;
|
|
160
|
+
if (!isOrphan(p, idx)) continue;
|
|
161
|
+
if (opt.minAgeMs && !(p.created && now - p.created >= opt.minAgeMs)) continue;
|
|
162
|
+
if (typeof opt.skip === 'function' && opt.skip(p)) continue;
|
|
163
|
+
out.push(p);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The code-graph daemon is meant to outlive any session (shared by all of them): never an orphan to clean. */
|
|
169
|
+
function isCbmDaemon(p) {
|
|
170
|
+
return /codebase-memory-mcp/i.test(p.name) && /daemon/i.test(p.cmd || '');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Orphaned Claude Code / code-graph processes older than minAgeMs (doctor and state): [{pid, name, age_h, ws_mb}] oldest first.
|
|
175
|
+
*/
|
|
176
|
+
function staleOrphans(list, o) {
|
|
177
|
+
const opt = o || {};
|
|
178
|
+
const now = typeof opt.now === 'number' ? opt.now : Date.now();
|
|
179
|
+
return orphans(list, { names: ['claude.exe', 'claude', 'codebase-memory-mcp.exe', 'codebase-memory-mcp'], minAgeMs: opt.minAgeMs, now, skip: isCbmDaemon })
|
|
180
|
+
.sort((a, b) => (a.created || 0) - (b.created || 0))
|
|
181
|
+
.map((p) => ({ pid: p.pid, name: p.name, age_h: Math.round(((now - (p.created || now)) / 3600000) * 10) / 10, ws_mb: p.ws ? Math.round(p.ws / 1048576) : null }));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Processes an agent left behind: descendants of the Claude Code process (rootPid) created at or after startMs, still in the
|
|
186
|
+
* list, not on the hook's own chain (selfPid and its ancestors), not claude/code-graph/conhost. Pure. Returns the list items;
|
|
187
|
+
* `roots` = those whose parent is not itself a leftover (what a kill must target; taskkill /T takes their children).
|
|
188
|
+
*/
|
|
189
|
+
function leftoverProcesses(list, o) {
|
|
190
|
+
const opt = o || {};
|
|
191
|
+
if (!Array.isArray(list) || !opt.rootPid || !opt.startMs) return { all: [], roots: [] };
|
|
192
|
+
const own = new Set([opt.selfPid, ...ancestors(list, opt.selfPid).map((p) => p.pid)].filter(Boolean));
|
|
193
|
+
const skip = new Set((opt.skipNames || LEFTOVER_SKIP_NAMES).map(lower));
|
|
194
|
+
const all = descendants(list, opt.rootPid).filter((p) => !own.has(p.pid) && p.created && p.created >= opt.startMs && !skip.has(lower(p.name)));
|
|
195
|
+
const ids = new Set(all.map((p) => p.pid));
|
|
196
|
+
return { all, roots: all.filter((p) => !ids.has(p.ppid)) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Kill process trees. Windows `taskkill /PID n /T /F`, else SIGTERM. `deadline` (epoch ms) stops early. Returns the pids stopped; never throws. */
|
|
200
|
+
function killTree(pids, o) {
|
|
201
|
+
const opt = o || {};
|
|
202
|
+
const done = [];
|
|
203
|
+
for (const pid of pids || []) {
|
|
204
|
+
const left = opt.deadline ? opt.deadline - Date.now() : 3000;
|
|
205
|
+
if (left <= 0) break; // out of time budget: the rest stay reported, not killed
|
|
206
|
+
try {
|
|
207
|
+
if ((opt.platform || process.platform) === 'win32') {
|
|
208
|
+
const r = cp.spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { timeout: Math.min(3000, left), windowsHide: true });
|
|
209
|
+
if (!r.error && r.status === 0) done.push(pid);
|
|
210
|
+
} else {
|
|
211
|
+
process.kill(pid, 'SIGTERM');
|
|
212
|
+
done.push(pid);
|
|
213
|
+
}
|
|
214
|
+
} catch (_) { /* gone already or not ours */ }
|
|
215
|
+
}
|
|
216
|
+
return done;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
PS_WIN, ORPHAN_NAMES, LEFTOVER_SKIP_NAMES,
|
|
221
|
+
parseWindowsJson, parsePs, listProcesses,
|
|
222
|
+
descendants, ancestors, claudeAncestor, orphans, staleOrphans, isCbmDaemon, leftoverProcesses, killTree,
|
|
223
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// `dieu-phoi profile scan` (core 0.7.2): look for secrets / machine-specific data in the profile files before they are
|
|
3
|
+
// published (Skill Hub zip, repo). Regex-only: it cannot see customer, person or secret-project NAMES - the user still
|
|
4
|
+
// reads the lessons diff (see profile publish). It never prints a matched value, only file:line:kind.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const SCAN_FILES = ['lessons.md', 'profile.json', 'unity-lanes.md'];
|
|
10
|
+
|
|
11
|
+
const PLACEHOLDER = /^(?:<[^>]*>?|\$\{?\w+\}?|%\w+%|\*+|\.{2,}|…|x{3,}|["'`]?[.…]*["'`]?|["'`]?<.*)$/i;
|
|
12
|
+
|
|
13
|
+
// kind → [regex, optional filter(matchText, line) → boolean]. Every regex needs the g flag.
|
|
14
|
+
const RULES = [
|
|
15
|
+
['abs-path', /(?<![A-Za-z0-9])[A-Za-z]:[\\/](?![\\/])[^\s`'")\]]*|(?<![\w.])\/(?:Users|home)\/[^\s/`'")]+/g],
|
|
16
|
+
['ipv4', /(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.]*\d)/g, (m) => {
|
|
17
|
+
const o = m.split('.').map(Number);
|
|
18
|
+
if (o.some((n) => n > 255)) return false;
|
|
19
|
+
return !(m === '127.0.0.1' || m === '0.0.0.0'); // loopback / any-address: not machine data
|
|
20
|
+
}],
|
|
21
|
+
['email', /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g],
|
|
22
|
+
['signed-url', /https?:\/\/[^\s)>"'`]*[?&](?:sig|signature|token|access_token|key|X-Amz-[A-Za-z-]+|X-Goog-[A-Za-z-]+)=[^\s)>"'`]*/gi],
|
|
23
|
+
['token-prefix', /\b(?:sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|glpat-[A-Za-z0-9_-]{8,}|npm_[A-Za-z0-9]{8,}|xox[abprso]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,})|\bBearer\s+[A-Za-z0-9._~+/-]{8,}/g],
|
|
24
|
+
['secret-assign', /\b[A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*\s*=\s*(\S+)/gi, (m) => {
|
|
25
|
+
const v = m.slice(m.indexOf('=') + 1).trim();
|
|
26
|
+
return !!v && !PLACEHOLDER.test(v);
|
|
27
|
+
}],
|
|
28
|
+
['long-hex', /\b[0-9a-fA-F]{32,}\b/g],
|
|
29
|
+
['long-base64', /(?<![A-Za-z0-9+/_-])[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9+/_-])/g, (m) => /[a-z]/.test(m) && /[A-Z]/.test(m) && /\d/.test(m)],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Known false positives. Match is the literal matched text (never a secret); `reason` is shown in the output.
|
|
33
|
+
const ALLOW = [
|
|
34
|
+
{ file: 'unity-lanes.md', kind: 'abs-path', match: 'D:/', reason: 'ví dụ gốc ổ đĩa "D:/" cho PFF_DATA_ROOT, không có đường dẫn thật' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function allowed(file, kind, match) {
|
|
38
|
+
return ALLOW.find((a) => a.file === file && a.kind === kind && a.match === match) || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Findings of one text: [{line, kind, allowed: reason|null}] (values are not returned). */
|
|
42
|
+
function scanText(file, text) {
|
|
43
|
+
const out = [];
|
|
44
|
+
const lines = String(text).replace(/^/, '').split(/\r?\n/);
|
|
45
|
+
lines.forEach((ln, i) => {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
for (const [kind, re, filter] of RULES) {
|
|
48
|
+
re.lastIndex = 0;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = re.exec(ln))) {
|
|
51
|
+
if (m[0] === '') { re.lastIndex++; continue; }
|
|
52
|
+
if (filter && !filter(m[0], ln)) continue;
|
|
53
|
+
if (seen.has(kind)) continue; // one finding per kind per line is enough to point at it
|
|
54
|
+
seen.add(kind);
|
|
55
|
+
const al = allowed(file, kind, m[0]);
|
|
56
|
+
out.push({ file, line: i + 1, kind, allowed: al ? al.reason : null });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Scan files of a profile dir (or {file: text} map). → {findings, files, clean}. */
|
|
64
|
+
function scanProfile(source) {
|
|
65
|
+
const texts = {};
|
|
66
|
+
if (typeof source === 'string') {
|
|
67
|
+
for (const f of SCAN_FILES) {
|
|
68
|
+
try { texts[f] = fs.readFileSync(path.join(source, f), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
69
|
+
}
|
|
70
|
+
} else Object.assign(texts, source);
|
|
71
|
+
const findings = [];
|
|
72
|
+
for (const [f, t] of Object.entries(texts)) findings.push(...scanText(f, t));
|
|
73
|
+
const hits = findings.filter((x) => !x.allowed);
|
|
74
|
+
return { files: Object.keys(texts), findings, hits, skipped: findings.filter((x) => x.allowed), clean: hits.length === 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatScan(r, dir) {
|
|
78
|
+
const L = [`profile scan ${dir ? `${String(dir).replace(/\\/g, '/')} ` : ''}— ${r.files.join(', ') || '(không có file)'}`];
|
|
79
|
+
for (const x of r.hits) L.push(` ! ${x.file}:${x.line}: ${x.kind}`);
|
|
80
|
+
for (const x of r.skipped) L.push(` ~ ${x.file}:${x.line}: ${x.kind} (bỏ qua — ${x.allowed})`);
|
|
81
|
+
L.push(r.clean ? ` sạch: ${r.skipped.length} dương tính giả đã bỏ qua` : ` ${r.hits.length} chỗ nghi ngờ (giá trị không in). Xem dòng đó, sửa hoặc xác nhận dương tính giả, rồi quét lại.`);
|
|
82
|
+
return L.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { scanText, scanProfile, formatScan, SCAN_FILES, ALLOW };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Gói npm mang bản tuyển của profile (cli/profile/, do scripts/pack-profile.cjs chép từ skill/profile lúc prepack).
|
|
3
|
+
// `setup` gộp vào profile đã cài (<skill>/profile):
|
|
4
|
+
// - lessons.md: dòng gạch đầu dòng nào chưa có → nối vào CUỐI file; dòng có sẵn và dòng riêng của máy không đụng tới;
|
|
5
|
+
// - .lessons.published.md: cộng dồn các dòng đã tuyển (dòng riêng máy không bao giờ vào đây, nên publish vẫn nhận ra chúng);
|
|
6
|
+
// - unity-lanes.md: chưa có → chép; đã có → không ghi đè, chỉ báo số dòng khác;
|
|
7
|
+
// - profile.json: không đụng.
|
|
8
|
+
// Idempotent: chạy lại không thêm gì. So khớp dòng theo normLine = norm() của tools/lessons_sync.cjs
|
|
9
|
+
// (bỏ gạch đầu dòng, [ngày] đầu, khoảng trắng thừa, chữ hoa, dấu chấm cuối) → dòng chỉ khác ngày/dấu chấm không nhân đôi.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const core = require('../hook.cjs');
|
|
14
|
+
|
|
15
|
+
const PUBLISHED = '.lessons.published.md';
|
|
16
|
+
const UNITY = 'unity-lanes.md';
|
|
17
|
+
const BULLET_RE = /^\s*[-*] +/;
|
|
18
|
+
|
|
19
|
+
const lf = (txt) => String(txt).replace(/^/, '').replace(/\r\n/g, '\n');
|
|
20
|
+
|
|
21
|
+
function readLF(file) {
|
|
22
|
+
try { return lf(fs.readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Bullet lines outside ``` fences, trimmed (same reading as lessons_sync.cjs). */
|
|
26
|
+
function bulletsOf(txt) {
|
|
27
|
+
const out = [];
|
|
28
|
+
let fence = false;
|
|
29
|
+
for (const raw of String(txt || '').split(/\r?\n/)) {
|
|
30
|
+
if (/^\s*```/.test(raw)) { fence = !fence; continue; }
|
|
31
|
+
if (fence || !BULLET_RE.test(raw)) continue;
|
|
32
|
+
out.push(raw.trim());
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Comparison key, same as lessons_sync.cjs norm(): no bullet, no leading [date], lower-case, single spaces, no final period. */
|
|
38
|
+
function normLine(line) {
|
|
39
|
+
return String(line).replace(BULLET_RE, '').replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '')
|
|
40
|
+
.replace(/\s+/g, ' ').trim().replace(/[.\s]+$/, '').toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Bullets of srcTxt whose key is not in `have` (and not repeated inside srcTxt). */
|
|
44
|
+
function newBullets(srcTxt, have) {
|
|
45
|
+
const seen = new Set(have);
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const l of bulletsOf(srcTxt)) {
|
|
48
|
+
const k = normLine(l);
|
|
49
|
+
if (!k || seen.has(k)) continue;
|
|
50
|
+
seen.add(k);
|
|
51
|
+
out.push(l);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Text with `lines` appended after its last line (one newline between). */
|
|
57
|
+
function appendLines(txt, lines) {
|
|
58
|
+
return `${txt.replace(/\n*$/, '\n')}${lines.join('\n')}\n`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* seedProfile(srcDir, dstDir, { dryRun }) — srcDir: <pkg>/profile, dstDir: <skill>/profile (phải đã tồn tại).
|
|
63
|
+
* → { src, dst, dry_run, skipped?, lessons: {action, added}, published: {action}, unity_lanes: {action, diff} }
|
|
64
|
+
* action: lessons copied|appended|unchanged; published written|appended|unchanged; unity_lanes copied|kept|unchanged.
|
|
65
|
+
*/
|
|
66
|
+
function seedProfile(srcDir, dstDir, opts) {
|
|
67
|
+
const dry = !!(opts && opts.dryRun);
|
|
68
|
+
const res = {
|
|
69
|
+
src: srcDir, dst: dstDir, dry_run: dry,
|
|
70
|
+
lessons: { action: 'unchanged', added: 0 }, published: { action: 'unchanged' }, unity_lanes: { action: 'unchanged', diff: 0 },
|
|
71
|
+
};
|
|
72
|
+
const srcL = readLF(path.join(srcDir, 'lessons.md'));
|
|
73
|
+
if (srcL === null) return Object.assign(res, { skipped: 'no-package-lessons' });
|
|
74
|
+
if (!fs.existsSync(dstDir) || !fs.statSync(dstDir).isDirectory()) return Object.assign(res, { skipped: 'no-skill-profile' });
|
|
75
|
+
|
|
76
|
+
// lessons.md
|
|
77
|
+
const dstFile = path.join(dstDir, 'lessons.md');
|
|
78
|
+
const dstL = readLF(dstFile);
|
|
79
|
+
let lessonsOut = null;
|
|
80
|
+
if (dstL === null) {
|
|
81
|
+
lessonsOut = srcL;
|
|
82
|
+
res.lessons = { action: 'copied', added: bulletsOf(srcL).length };
|
|
83
|
+
} else {
|
|
84
|
+
const add = newBullets(srcL, new Set(bulletsOf(dstL).map(normLine)));
|
|
85
|
+
if (add.length) {
|
|
86
|
+
lessonsOut = appendLines(dstL, add);
|
|
87
|
+
res.lessons = { action: 'appended', added: add.length };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// .lessons.published.md
|
|
92
|
+
const pubFile = path.join(dstDir, PUBLISHED);
|
|
93
|
+
const pubOld = readLF(pubFile);
|
|
94
|
+
let pubOut = null;
|
|
95
|
+
if (pubOld === null) {
|
|
96
|
+
pubOut = srcL;
|
|
97
|
+
res.published = { action: 'written' };
|
|
98
|
+
} else {
|
|
99
|
+
const add = newBullets(srcL, new Set(bulletsOf(pubOld).map(normLine)));
|
|
100
|
+
if (add.length) {
|
|
101
|
+
pubOut = appendLines(pubOld, add);
|
|
102
|
+
res.published = { action: 'appended' };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// unity-lanes.md
|
|
107
|
+
const srcU = readLF(path.join(srcDir, UNITY));
|
|
108
|
+
let unityOut = null;
|
|
109
|
+
if (srcU !== null) {
|
|
110
|
+
const dstU = readLF(path.join(dstDir, UNITY));
|
|
111
|
+
if (dstU === null) {
|
|
112
|
+
unityOut = srcU;
|
|
113
|
+
res.unity_lanes = { action: 'copied', diff: srcU.split('\n').filter((l) => l.trim()).length };
|
|
114
|
+
} else if (dstU !== srcU) {
|
|
115
|
+
const have = new Set(dstU.split('\n'));
|
|
116
|
+
res.unity_lanes = { action: 'kept', diff: srcU.split('\n').filter((l) => l.trim() && !have.has(l)).length };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!dry) {
|
|
121
|
+
if (lessonsOut !== null) core.writeFileAtomic(dstFile, lessonsOut);
|
|
122
|
+
if (pubOut !== null) core.writeFileAtomic(pubFile, pubOut);
|
|
123
|
+
if (unityOut !== null) core.writeFileAtomic(path.join(dstDir, UNITY), unityOut);
|
|
124
|
+
}
|
|
125
|
+
return res;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Dòng tóm tắt tiếng Việt cho `setup` (không in nội dung dòng). */
|
|
129
|
+
function seedSummary(res) {
|
|
130
|
+
if (res.skipped) {
|
|
131
|
+
return res.skipped === 'no-package-lessons'
|
|
132
|
+
? 'Profile gói npm: không có (chạy từ bản đã đóng gói mới có lessons đã tuyển)'
|
|
133
|
+
: 'Profile gói npm: bỏ qua (không thấy profile/ của skill)';
|
|
134
|
+
}
|
|
135
|
+
const tag = res.dry_run ? ' (dry-run, không ghi)' : '';
|
|
136
|
+
const L = res.lessons.action === 'copied' ? `lessons.md chép mới (${res.lessons.added} dòng bài học)`
|
|
137
|
+
: res.lessons.action === 'appended' ? `lessons.md thêm ${res.lessons.added} dòng mới của bản tuyển`
|
|
138
|
+
: 'lessons.md không đổi';
|
|
139
|
+
const U = res.unity_lanes.action === 'copied' ? 'unity-lanes.md chép mới'
|
|
140
|
+
: res.unity_lanes.action === 'kept' ? `unity-lanes.md giữ bản cài (khác ${res.unity_lanes.diff} dòng so với gói; không ghi đè)`
|
|
141
|
+
: 'unity-lanes.md không đổi';
|
|
142
|
+
const P = res.published.action === 'appended' ? '; .lessons.published.md cộng thêm dòng đã tuyển'
|
|
143
|
+
: res.published.action === 'written' ? '; .lessons.published.md ghi mới' : '';
|
|
144
|
+
return `Profile: ${L}; ${U}${P}${tag}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { PUBLISHED, BULLET_RE, bulletsOf, normLine, seedProfile, seedSummary };
|