@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/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): khoá knowledge.lock, ghi tmp+rename, bản cũ vào history/, sha = HEAD của repo đó.
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
- const m = /^knowledge-delta-([a-z0-9]{8})(?:-r\d+)?\.md$/i.exec(path.basename(file));
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 = /-r(\d+)\.md$/i.exec(f); return m ? Number(m[1]) : 0; };
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
- function deltaHash(text) {
613
- return crypto.createHash('sha256').update(String(text).replace(/^/, '').replace(/\r\n?/g, '\n')).digest('hex');
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 (changed && !dry && src && !results.some((r) => r.op === 'conflict' || r.op === 'reject')) {
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
- let target = null;
790
- if (delta.repo) {
791
- const p = path.resolve(core.nativePath(delta.repo));
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) { rep.error = `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`; totals.error++; continue; }
805
- Object.assign(rep, { repo: slash(target), store: info.dir, key: info.key, sha });
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 };