@kienntpixon/dieu-phoi 0.7.1 → 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 +21 -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 +576 -44
- package/lib/aggregate.cjs +5 -4
- package/lib/args.cjs +1 -1
- package/lib/commands.cjs +168 -35
- package/lib/knowledge.cjs +3 -4
- 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 +31 -7
- package/lib/worktree.cjs +186 -0
- package/package.json +3 -1
- package/profile/lessons.md +83 -0
- package/profile/unity-lanes.md +316 -0
|
@@ -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 };
|
package/lib/profile.cjs
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
// the proposal plus those lines (each after the line it followed, else at the end);
|
|
12
12
|
// - lessons.md lines edited/deleted since base, or profile.json changed at all → stop,
|
|
13
13
|
// exit 2, write nothing (the user decides).
|
|
14
|
+
// profile scan [--dir <profile>] regex scan for secrets / machine data (profile-scan.cjs); exit 2 on a hit.
|
|
15
|
+
// profile publish --to <repo>/skill/profile [--curated <file>] [--include-local] [--dry-run]
|
|
16
|
+
// copy the installed curated profile into the repo, minus this machine's own lines, after a scan.
|
|
17
|
+
// `apply` records what the retro approved in profile/.lessons.curated.md; a lessons.md line
|
|
18
|
+
// that is in neither .lessons.published.md nor that record is machine-local and is left out.
|
|
14
19
|
// `>>` appenders take no lock: the live file is re-read right before the renames and the merge redone if it moved
|
|
15
20
|
// (up to 5 times). What is left is the sub-millisecond gap between that last read and the rename.
|
|
16
21
|
|
|
@@ -18,6 +23,7 @@ const fs = require('fs');
|
|
|
18
23
|
const path = require('path');
|
|
19
24
|
const crypto = require('crypto');
|
|
20
25
|
const core = require('../hook.cjs');
|
|
26
|
+
const { BULLET_RE, bulletsOf, normLine } = require('./profile-seed.cjs');
|
|
21
27
|
|
|
22
28
|
const BASE_DIR = 'profile-base';
|
|
23
29
|
const FILES = { lessons: 'lessons.md', profile: 'profile.json' };
|
|
@@ -31,7 +37,16 @@ const HELP = `Cách dùng: dieu-phoi profile <lệnh> [--skill-dir P] [--json]
|
|
|
31
37
|
apply --base <thư mục> [--lessons <file đề xuất>] [--profile <file đề xuất>] [--dry-run]
|
|
32
38
|
áp retro đã duyệt, dưới khoá profile/profile.lock. File chưa đổi từ base → ghi đề xuất.
|
|
33
39
|
lessons.md chỉ có dòng THÊM MỚI (phiên khác >> bài học) → tự gộp các dòng đó vào đề xuất.
|
|
34
|
-
lessons.md có dòng cũ bị sửa/xoá, hoặc profile.json đã đổi → dừng, exit 2, không ghi
|
|
40
|
+
lessons.md có dòng cũ bị sửa/xoá, hoặc profile.json đã đổi → dừng, exit 2, không ghi gì
|
|
41
|
+
(ghi thêm profile/.lessons.curated.md = các dòng bài học retro đã duyệt, cho profile publish)
|
|
42
|
+
scan [--dir <profile>] quét lessons.md, profile.json, unity-lanes.md tìm đường dẫn tuyệt đối, IPv4, email, URL ký, tiền tố
|
|
43
|
+
token, KEY/TOKEN/SECRET/PASSWORD=<giá trị>, chuỗi hex/base64 dài. In file:dòng:loại, KHÔNG in giá trị;
|
|
44
|
+
exit 2 khi có chỗ nghi ngờ. Dương tính giả đã biết nằm trong ALLOW của lib/profile-scan.cjs (kèm lý do)
|
|
45
|
+
publish --to <repo>/skill/profile [--curated <file>[,<file>]] [--include-local] [--dry-run]
|
|
46
|
+
chép profile đã cài (lessons.md, profile.json, unity-lanes.md) sang repo: bỏ dòng bài học riêng của máy
|
|
47
|
+
(không có trong .lessons.published.md và không nằm trong .lessons.curated.md / --curated), chuẩn hoá LF,
|
|
48
|
+
quét secret TRƯỚC khi ghi (có chỗ nghi ngờ → exit 2, không ghi gì), in tóm tắt khác biệt.
|
|
49
|
+
Không commit, không push, không đụng Hub: người dùng đọc \`git diff -- skill/profile\` rồi quyết định`;
|
|
35
50
|
|
|
36
51
|
const sha = (txt) => crypto.createHash('sha256').update(txt, 'utf8').digest('hex');
|
|
37
52
|
const lf = (txt) => String(txt).replace(/^/, '').replace(/\r\n/g, '\n');
|
|
@@ -179,6 +194,7 @@ function cmdApply(opts) {
|
|
|
179
194
|
`);
|
|
180
195
|
if (todo.some((w) => readText(path.join(dir, w.f)) !== w.cur)) continue; // a `>>` landed while we merged: redo
|
|
181
196
|
for (const w of todo) core.writeFileAtomic(path.join(dir, w.f), w.text);
|
|
197
|
+
if (props.lessons !== undefined) recordCurated(dir, props.lessons);
|
|
182
198
|
written = true;
|
|
183
199
|
break;
|
|
184
200
|
}
|
|
@@ -193,11 +209,135 @@ function cmdApply(opts) {
|
|
|
193
209
|
return { code: 0, out: `${dry ? '(dry-run — không ghi gì)\n' : ''}profile ${slash(dir)}:\n${lines.join('\n')}` };
|
|
194
210
|
}
|
|
195
211
|
|
|
212
|
+
// ---------------------------------------------------------------- scan / publish (0.7.2)
|
|
213
|
+
|
|
214
|
+
const PUBLISHED = '.lessons.published.md'; // written by tools/lessons_sync.cjs update
|
|
215
|
+
const CURATED = '.lessons.curated.md'; // written by profile apply: the bullets a retro approved
|
|
216
|
+
const PUBLISH_FILES = ['lessons.md', 'profile.json', 'unity-lanes.md'];
|
|
217
|
+
const LOCAL_MARK = /^\s*<!--\s*dòng riêng của máy/;
|
|
218
|
+
|
|
219
|
+
/** apply → remember which lessons bullets the retro approved, so publish can tell them from machine-local `>>` lines. */
|
|
220
|
+
function recordCurated(dir, proposalTxt) {
|
|
221
|
+
const file = path.join(dir, CURATED);
|
|
222
|
+
const old = readText(file);
|
|
223
|
+
const have = new Set(bulletsOf(old).map(normLine));
|
|
224
|
+
const add = bulletsOf(proposalTxt).filter((l) => !have.has(normLine(l)));
|
|
225
|
+
if (!add.length && old !== null) return;
|
|
226
|
+
const head = old === null ? '# dòng bài học retro đã duyệt (profile apply ghi; profile publish đọc) — đừng sửa tay\n' : old.replace(/\n*$/, '\n');
|
|
227
|
+
core.writeFileAtomic(file, `${head}${add.join('\n')}${add.length ? '\n' : ''}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function cmdScan(opts) {
|
|
231
|
+
const sc = require('./profile-scan.cjs');
|
|
232
|
+
const dir = typeof opts.dir === 'string' ? path.resolve(core.nativePath(opts.dir)) : profileDir(opts);
|
|
233
|
+
if (!fs.existsSync(dir)) return { code: 1, out: `không thấy thư mục ${slash(dir)}` };
|
|
234
|
+
const r = sc.scanProfile(dir);
|
|
235
|
+
const code = r.clean ? 0 : 2;
|
|
236
|
+
if (opts.json) return { code, out: JSON.stringify({ dir: slash(dir), files: r.files, clean: r.clean, hits: r.hits, skipped: r.skipped }, null, 2) };
|
|
237
|
+
return { code, out: sc.formatScan(r, dir) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Multiset line diff: how many lines of `next` are new / gone compared with `prev` (order ignored). */
|
|
241
|
+
function lineDelta(prev, next) {
|
|
242
|
+
const count = new Map();
|
|
243
|
+
for (const l of String(prev || '').split('\n')) count.set(l, (count.get(l) || 0) + 1);
|
|
244
|
+
let added = 0;
|
|
245
|
+
for (const l of String(next).split('\n')) {
|
|
246
|
+
const n = count.get(l) || 0;
|
|
247
|
+
if (n > 0) count.set(l, n - 1); else added++;
|
|
248
|
+
}
|
|
249
|
+
let removed = 0;
|
|
250
|
+
for (const n of count.values()) removed += n;
|
|
251
|
+
return { added, removed };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function dropLines(txt, drop) {
|
|
255
|
+
const out = [];
|
|
256
|
+
let after = false; // the previous line was dropped: do not leave two blank lines behind
|
|
257
|
+
for (const raw of txt.split('\n')) {
|
|
258
|
+
const key = BULLET_RE.test(raw) ? raw.trim() : null;
|
|
259
|
+
if (LOCAL_MARK.test(raw) || (key !== null && drop.has(key))) { after = true; continue; }
|
|
260
|
+
if (after && raw.trim() === '' && out.length && out[out.length - 1].trim() === '') continue;
|
|
261
|
+
after = false;
|
|
262
|
+
out.push(raw);
|
|
263
|
+
}
|
|
264
|
+
return out.join('\n');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function cmdPublish(opts) {
|
|
268
|
+
if (typeof opts.to !== 'string' || !opts.to.trim()) return { code: 1, out: 'profile publish cần --to <repo>/skill/profile (thư mục profile của repo).' };
|
|
269
|
+
const to = path.resolve(core.nativePath(opts.to));
|
|
270
|
+
if (!fs.existsSync(to) || !fs.statSync(to).isDirectory()) return { code: 1, out: `--to không phải thư mục: ${slash(to)}` };
|
|
271
|
+
const from = profileDir(opts);
|
|
272
|
+
if (path.resolve(from).toLowerCase() === to.toLowerCase()) return { code: 1, out: '--to trùng thư mục profile đã cài.' };
|
|
273
|
+
const sc = require('./profile-scan.cjs');
|
|
274
|
+
const dry = !!opts['dry-run'];
|
|
275
|
+
|
|
276
|
+
const installed = readText(path.join(from, 'lessons.md'));
|
|
277
|
+
if (installed === null) return { code: 1, out: `không thấy ${slash(path.join(from, 'lessons.md'))}` };
|
|
278
|
+
const published = readText(path.join(from, PUBLISHED));
|
|
279
|
+
const keys = new Set(bulletsOf(published === null ? installed : published).map(normLine));
|
|
280
|
+
const curatedFiles = [path.join(from, CURATED)];
|
|
281
|
+
if (typeof opts.curated === 'string') curatedFiles.push(...opts.curated.split(',').map((f) => path.resolve(core.nativePath(f.trim()))).filter(Boolean));
|
|
282
|
+
for (const f of curatedFiles) {
|
|
283
|
+
const t = readText(f);
|
|
284
|
+
if (t === null) { if (f !== curatedFiles[0]) return { code: 1, out: `không thấy file --curated ${slash(f)}` }; continue; }
|
|
285
|
+
for (const l of bulletsOf(t)) keys.add(normLine(l));
|
|
286
|
+
}
|
|
287
|
+
const local = [];
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
for (const l of bulletsOf(installed)) {
|
|
290
|
+
const k = normLine(l);
|
|
291
|
+
if (keys.has(k) || seen.has(k)) continue;
|
|
292
|
+
seen.add(k);
|
|
293
|
+
local.push(l);
|
|
294
|
+
}
|
|
295
|
+
const drop = opts['include-local'] ? new Set() : new Set(local);
|
|
296
|
+
|
|
297
|
+
const texts = {};
|
|
298
|
+
texts['lessons.md'] = dropLines(installed, drop);
|
|
299
|
+
for (const f of PUBLISH_FILES.slice(1)) {
|
|
300
|
+
const t = readText(path.join(from, f));
|
|
301
|
+
if (t !== null) texts[f] = t;
|
|
302
|
+
}
|
|
303
|
+
const scan = sc.scanProfile(texts);
|
|
304
|
+
const files = Object.entries(texts).map(([f, t]) => {
|
|
305
|
+
const prev = readText(path.join(to, f));
|
|
306
|
+
return Object.assign({ file: f, same: prev === t, new: prev === null }, prev === t ? { added: 0, removed: 0 } : lineDelta(prev, t));
|
|
307
|
+
});
|
|
308
|
+
const res = {
|
|
309
|
+
from: slash(from), to: slash(to), dry_run: dry, published_record: published !== null,
|
|
310
|
+
local_lines: local.length, local_excluded: opts['include-local'] ? 0 : local.length,
|
|
311
|
+
local_preview: local.map((l) => `${l.replace(BULLET_RE, '').replace(/\s+/g, ' ').slice(0, 70)}…`),
|
|
312
|
+
scan: { clean: scan.clean, hits: scan.hits, skipped: scan.skipped }, files, written: false,
|
|
313
|
+
};
|
|
314
|
+
if (!scan.clean) {
|
|
315
|
+
res.stopped = 'scan';
|
|
316
|
+
if (opts.json) return { code: 2, out: JSON.stringify(res, null, 2) };
|
|
317
|
+
return { code: 2, out: `DỪNG — không ghi gì:\n${sc.formatScan(scan, from)}\nSửa file đã cài (hoặc thêm dương tính giả có lý do vào ALLOW) rồi publish lại.` };
|
|
318
|
+
}
|
|
319
|
+
if (!dry) {
|
|
320
|
+
for (const f of files.filter((x) => !x.same)) core.writeFileAtomic(path.join(to, f.file), texts[f.file]);
|
|
321
|
+
res.written = true;
|
|
322
|
+
}
|
|
323
|
+
if (opts.json) return { code: 0, out: JSON.stringify(res, null, 2) };
|
|
324
|
+
const L = [`${dry ? '(dry-run — không ghi gì)\n' : ''}profile publish ${slash(from)} → ${slash(to)}`];
|
|
325
|
+
for (const f of files) L.push(` ${f.same ? '=' : (dry ? '~' : '✓')} ${f.file}${f.same ? ' (không đổi)' : ` (+${f.added} −${f.removed} dòng${f.new ? ', file mới' : ''})`}`);
|
|
326
|
+
if (!res.published_record) L.push(` ! thiếu ${PUBLISHED}: không phân biệt được dòng riêng của máy — coi mọi dòng là đã tuyển`);
|
|
327
|
+
L.push(` dòng riêng của máy: ${local.length}${opts['include-local'] ? ' (--include-local: vẫn chép)' : local.length ? ' — bỏ ra, không chép:' : ''}`);
|
|
328
|
+
if (!opts['include-local']) for (const p of res.local_preview) L.push(` - ${p}`);
|
|
329
|
+
L.push(` scan: ${scan.clean ? 'sạch' : 'CÓ'}${scan.skipped.length ? `, bỏ qua ${scan.skipped.length} dương tính giả (${scan.skipped.map((x) => `${x.file}:${x.line}:${x.kind}`).join(', ')})` : ''}`);
|
|
330
|
+
L.push(' Kế tiếp: đọc `git diff -- skill/profile` trong repo (tên khách hàng/người/dự án mật mà regex không bắt được), rồi mới commit.');
|
|
331
|
+
return { code: 0, out: L.join('\n') };
|
|
332
|
+
}
|
|
333
|
+
|
|
196
334
|
function cmdProfile(opts) {
|
|
197
335
|
const sub = opts._.shift();
|
|
198
336
|
if (!sub || opts.help || opts.h || sub === 'help') return { code: sub || opts.help || opts.h ? 0 : 1, out: HELP };
|
|
199
337
|
if (sub === 'base') return cmdBase(opts);
|
|
200
338
|
if (sub === 'apply') return cmdApply(opts);
|
|
339
|
+
if (sub === 'scan') return cmdScan(opts);
|
|
340
|
+
if (sub === 'publish') return cmdPublish(opts);
|
|
201
341
|
return { code: 1, out: `Lệnh profile lạ: ${sub}\n\n${HELP}` };
|
|
202
342
|
}
|
|
203
343
|
|
package/lib/prune.cjs
CHANGED
|
@@ -55,17 +55,24 @@ function human(n) {
|
|
|
55
55
|
return `${n} B`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// contract/steps/plan/handoff files of a run dir: the record of what was planned and handed over (never pruned)
|
|
59
|
+
const KEEP_RE = /^(?:contract-r.*\.md|steps-r.*\.json|plan.*\.md|handoff-.*\.md)$/;
|
|
60
|
+
const DELTA_RE = /^knowledge-delta.*\.md$/i;
|
|
61
|
+
|
|
58
62
|
/**
|
|
59
63
|
* Files to keep in a run dir: retro-*.md + per task/role the highest-try result AND the newest-by-mtime result
|
|
60
64
|
* (dp-out, legacy or .inline.md). A re-run can write a lower try number later (e.g. try1 after a blocked try5),
|
|
61
65
|
* so the try number alone can pick a stale attempt. `mtimeOf(name)` is optional; without it only the highest try is kept.
|
|
62
66
|
*/
|
|
63
|
-
function keepSet(names, mtimeOf) {
|
|
67
|
+
function keepSet(names, mtimeOf, unmerged) {
|
|
64
68
|
const keep = new Set();
|
|
65
69
|
const best = new Map(); // task|role -> max try
|
|
66
70
|
const parsed = [];
|
|
67
71
|
for (const n of names) {
|
|
68
72
|
if (/^retro-.*\.md$/.test(n)) { keep.add(n); continue; }
|
|
73
|
+
if (KEEP_RE.test(n)) { keep.add(n); continue; }
|
|
74
|
+
// a delta no store has applied yet is still work to merge; a merged one is only history
|
|
75
|
+
if (DELTA_RE.test(n)) { if (typeof unmerged !== 'function' || unmerged(n)) keep.add(n); continue; }
|
|
69
76
|
const inline = /\.inline\.md$/.test(n); // hook fallback copy: only written when the agent wrote no dp-out
|
|
70
77
|
let m = OUT_RE.exec(n);
|
|
71
78
|
let task; let role; let tr;
|
|
@@ -113,7 +120,10 @@ function taskIdsOfDir(id, names, plansDir) {
|
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
/** Eligible run dirs: every event of its tasks ≤ cutoff and every dispatched task has a user verdict (or was discarded). */
|
|
116
|
-
function planRuns(ctx, events, cutoffMs, protect) {
|
|
123
|
+
function planRuns(ctx, events, cutoffMs, protect, popts) {
|
|
124
|
+
const po = popts || {};
|
|
125
|
+
const nowMs = typeof po.now === 'number' ? po.now : Date.now();
|
|
126
|
+
const cls = core.deltaClassifier(events, ctx.paths.plans);
|
|
117
127
|
const runs = core.runsDir();
|
|
118
128
|
const byTask = new Map();
|
|
119
129
|
for (const e of events) {
|
|
@@ -124,9 +134,17 @@ function planRuns(ctx, events, cutoffMs, protect) {
|
|
|
124
134
|
const out = { prune: [], skipped: [] };
|
|
125
135
|
let dirs = [];
|
|
126
136
|
try { dirs = fs.readdirSync(runs, { withFileTypes: true }); } catch (_) { return out; }
|
|
137
|
+
let applied = null; // sha256 of every delta a knowledge store has applied (read once, lazily)
|
|
138
|
+
const appliedSet = () => {
|
|
139
|
+
if (!applied) {
|
|
140
|
+
applied = new Set();
|
|
141
|
+
try { for (const st of core.storeCatalog()) for (const h of st.applied) applied.add(h); } catch (_) { /* none known: keep every delta */ }
|
|
142
|
+
}
|
|
143
|
+
return applied;
|
|
144
|
+
};
|
|
127
145
|
for (const d of dirs) {
|
|
128
146
|
const id = d.name;
|
|
129
|
-
if (!d.isDirectory() || !RUN_ID_RE.test(id)) continue;
|
|
147
|
+
if (!d.isDirectory() || !RUN_ID_RE.test(id) || id.startsWith('_')) continue; // _archive is ours, not a run
|
|
130
148
|
const dir = path.join(runs, id);
|
|
131
149
|
if (!inside(runs, dir)) continue;
|
|
132
150
|
const names = fs.readdirSync(dir);
|
|
@@ -155,17 +173,34 @@ function planRuns(ctx, events, cutoffMs, protect) {
|
|
|
155
173
|
}
|
|
156
174
|
}
|
|
157
175
|
if (reason) { out.skipped.push({ id, reason }); continue; }
|
|
158
|
-
const
|
|
176
|
+
const unmerged = (n) => {
|
|
177
|
+
let text = null;
|
|
178
|
+
try { text = fs.readFileSync(path.join(dir, n), 'utf8'); } catch (_) { return true; }
|
|
179
|
+
return !appliedSet().has(core.deltaHash(text));
|
|
180
|
+
};
|
|
181
|
+
const keep = keepSet(names, (n) => { try { return fs.lstatSync(path.join(dir, n)).mtimeMs; } catch (_) { return 0; } }, unmerged);
|
|
182
|
+
// retro 11-05 K1: an unmerged delta older than 30 d that the user never judged is history, not work: move it to
|
|
183
|
+
// runs/_archive/deltas/<run>/ (never deleted) so it stops being kept here and warned about.
|
|
184
|
+
const archive = [];
|
|
185
|
+
for (const n of names) {
|
|
186
|
+
if (!DELTA_RE.test(n) || !keep.has(n) || !unmerged(n)) continue;
|
|
187
|
+
let m = 0;
|
|
188
|
+
try { m = fs.lstatSync(path.join(dir, n)).mtimeMs; } catch (_) { continue; }
|
|
189
|
+
if (nowMs - m > core.DELTA_WINDOW_MS && cls.deltaState(id, core.deltaTagOf(n)) === null) archive.push(n);
|
|
190
|
+
}
|
|
191
|
+
for (const n of archive) keep.delete(n);
|
|
192
|
+
const keepDeltas = names.filter((n) => DELTA_RE.test(n) && keep.has(n)).length;
|
|
159
193
|
const del = [];
|
|
160
194
|
let delBytes = 0; let keepBytes = 0;
|
|
161
195
|
for (const n of names) {
|
|
162
196
|
const p = path.join(dir, n);
|
|
197
|
+
if (archive.includes(n)) continue;
|
|
163
198
|
if (keep.has(n) || (protect && path.resolve(p) === protect)) { keepBytes += sizeOf(p); continue; }
|
|
164
199
|
const b = sizeOf(p);
|
|
165
200
|
del.push({ name: n, bytes: b });
|
|
166
201
|
delBytes += b;
|
|
167
202
|
}
|
|
168
|
-
out.prune.push({ id, dir, tasks, keep: Array.from(keep).sort(), del, delete_bytes: delBytes, keep_bytes: keepBytes });
|
|
203
|
+
out.prune.push({ id, dir, tasks, keep: Array.from(keep).sort(), keep_deltas: keepDeltas, archive, del, delete_bytes: delBytes, keep_bytes: keepBytes });
|
|
169
204
|
}
|
|
170
205
|
return out;
|
|
171
206
|
}
|
|
@@ -255,7 +290,7 @@ function cmdPrune(opts) {
|
|
|
255
290
|
const cut = cutoffOf(retro);
|
|
256
291
|
const { lines } = fs.existsSync(ctx.paths.events) ? eventLines(ctx) : { lines: [] };
|
|
257
292
|
const allEvents = core.readEvents(ctx);
|
|
258
|
-
const rp = planRuns(ctx, allEvents, cut.ms, retro);
|
|
293
|
+
const rp = planRuns(ctx, allEvents, cut.ms, retro, { now: typeof opts.now === 'number' ? opts.now : undefined });
|
|
259
294
|
const prunedTasks = new Set();
|
|
260
295
|
for (const r of rp.prune) for (const t of r.tasks) prunedTasks.add(t);
|
|
261
296
|
const plans = badPlans(ctx);
|
|
@@ -273,7 +308,7 @@ function cmdPrune(opts) {
|
|
|
273
308
|
|
|
274
309
|
const report = {
|
|
275
310
|
mode: yes ? 'apply' : 'dry-run', retro, cutoff: new Date(cut.ms).toISOString(), cutoff_source: cut.source,
|
|
276
|
-
runs: rp.prune.map((r) => ({ id: r.id, delete_files: r.del.length, delete_bytes: r.delete_bytes, keep: r.keep, keep_bytes: r.keep_bytes })),
|
|
311
|
+
runs: rp.prune.map((r) => ({ id: r.id, delete_files: r.del.length, delete_bytes: r.delete_bytes, keep: r.keep, keep_deltas: r.keep_deltas, archive_deltas: r.archive.length, keep_bytes: r.keep_bytes })),
|
|
277
312
|
runs_skipped: rp.skipped.length,
|
|
278
313
|
skipped_reasons: rp.skipped.reduce((a, s) => { const k = s.reason.replace(/task \S+/, 'task *').replace(/\(task \S+\)/, ''); a[k] = (a[k] || 0) + 1; return a; }, {}),
|
|
279
314
|
bad_plans: plans.map((p) => ({ name: p.name, bytes: p.bytes })),
|
|
@@ -281,6 +316,7 @@ function cmdPrune(opts) {
|
|
|
281
316
|
};
|
|
282
317
|
report.totals = {
|
|
283
318
|
run_dirs: rp.prune.length,
|
|
319
|
+
archive_deltas: rp.prune.reduce((n, r) => n + r.archive.length, 0),
|
|
284
320
|
files: rp.prune.reduce((n, r) => n + r.del.length, 0),
|
|
285
321
|
bytes: rp.prune.reduce((n, r) => n + r.delete_bytes, 0) + plans.reduce((n, p) => n + p.bytes, 0),
|
|
286
322
|
};
|
|
@@ -331,6 +367,18 @@ function cmdPrune(opts) {
|
|
|
331
367
|
try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
|
|
332
368
|
}
|
|
333
369
|
}
|
|
370
|
+
// 2a. unmerged deltas older than 30 d without a user verdict -> runs/_archive/deltas/<run>/ (moved, not deleted)
|
|
371
|
+
for (const r of rp.prune) {
|
|
372
|
+
for (const n of r.archive) {
|
|
373
|
+
const from = path.join(r.dir, n);
|
|
374
|
+
const toDir = path.join(runs, '_archive', 'deltas', r.id);
|
|
375
|
+
if (!inside(r.dir, from) || !inside(runs, toDir)) continue;
|
|
376
|
+
fs.mkdirSync(toDir, { recursive: true });
|
|
377
|
+
let to = path.join(toDir, n);
|
|
378
|
+
if (fs.existsSync(to)) to = path.join(toDir, `${Date.now()}-${n}`);
|
|
379
|
+
core.renameWithRetry(from, to);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
334
382
|
// 2. run dirs
|
|
335
383
|
for (const r of rp.prune) {
|
|
336
384
|
for (const f of r.del) {
|
|
@@ -348,7 +396,7 @@ function cmdPrune(opts) {
|
|
|
348
396
|
const L = [];
|
|
349
397
|
L.push(`prune ${yes ? '(ĐÃ THỰC HIỆN)' : '(dry-run — thêm --yes để thực hiện)'}: mốc cắt ${report.cutoff} (${cut.source}) từ ${retro}`);
|
|
350
398
|
L.push(`Run dir: ${rp.prune.length} sẽ dọn, ${rp.skipped.length} giữ nguyên (còn mở / sau mốc)`);
|
|
351
|
-
for (const r of rp.prune) L.push(` ${r.id}: xoá ${r.del.length} mục (${human(r.delete_bytes)}), giữ ${r.keep.length} (${human(r.keep_bytes)})`);
|
|
399
|
+
for (const r of rp.prune) L.push(` ${r.id}: xoá ${r.del.length} mục (${human(r.delete_bytes)}), giữ ${r.keep.length} (${human(r.keep_bytes)}), trong đó delta chưa gộp ${r.keep_deltas}${r.archive.length ? `; chuyển ${r.archive.length} delta >30 ngày chưa có verdict sang _archive/deltas/` : ''}`);
|
|
352
400
|
L.push(`Plan hỏng: ${plans.length} (${human(plans.reduce((n, p) => n + p.bytes, 0))})${plans.length ? ` — ${plans.map((p) => p.name).join(', ')}` : ''}`);
|
|
353
401
|
L.push(`events.jsonl: ${moving.length}/${lines.length} dòng → archive/events-${archiveToday()}.jsonl.gz (${human(evBytes)})${heldBack ? `; ${heldBack} dòng chưa đẩy lên sink nên giữ lại` : ''}; không xoá dòng nào`);
|
|
354
402
|
L.push(`Tổng xoá khỏi đĩa: ${report.totals.files} mục run dir + ${plans.length} plan = ${human(report.totals.bytes)}`);
|
package/lib/setup.cjs
CHANGED
|
@@ -4,10 +4,16 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const core = require('../hook.cjs');
|
|
7
|
+
const { seedProfile } = require('./profile-seed.cjs');
|
|
7
8
|
|
|
8
9
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
9
10
|
const MARKER = 'dieu-phoi/bin/hook.cjs';
|
|
10
11
|
|
|
12
|
+
/** <pkg>/profile (mang lessons đã tuyển); DIEU_PHOI_TEST_PROFILE_SRC chỉ để test trỏ tới thư mục khác. */
|
|
13
|
+
function profileSrcDir() {
|
|
14
|
+
return process.env.DIEU_PHOI_TEST_PROFILE_SRC || path.join(PKG_ROOT, 'profile');
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
function tsStamp(d) {
|
|
12
18
|
const x = d || new Date();
|
|
13
19
|
const p = (n) => String(n).padStart(2, '0');
|
|
@@ -322,17 +328,36 @@ function removeAgents(claudeDir) {
|
|
|
322
328
|
* Install: copy hook + pricing, register hooks, write config.
|
|
323
329
|
* opts: { claudeDir, dept, sinkUrl, sinkToken, skillDir }
|
|
324
330
|
*/
|
|
331
|
+
/** Skill dir của lần cài này: --skill-dir, rồi skill_dir đã nhớ trong config (còn hợp lệ), rồi tìm dưới claudeDir. */
|
|
332
|
+
function skillDirOfInstall(opts, claudeDir, home) {
|
|
333
|
+
let skillDir = opts.skillDir ? path.resolve(opts.skillDir) : null;
|
|
334
|
+
if (!skillDir) {
|
|
335
|
+
const cfg = core.loadConfig(home);
|
|
336
|
+
if (cfg.skill_dir && fs.existsSync(path.join(cfg.skill_dir, 'profile', 'profile.json'))) skillDir = cfg.skill_dir;
|
|
337
|
+
}
|
|
338
|
+
return skillDir || findSkillDir(claudeDir);
|
|
339
|
+
}
|
|
340
|
+
|
|
325
341
|
function setup(opts) {
|
|
326
342
|
const claudeDir = path.resolve(opts.claudeDir);
|
|
327
343
|
const home = path.join(claudeDir, 'dieu-phoi');
|
|
328
344
|
const paths = core.pathsFor(home);
|
|
345
|
+
if (opts.dryRun) {
|
|
346
|
+
// --dry-run: chỉ báo phần profile sẽ gộp; không ghi hook, settings, agent hay config
|
|
347
|
+
const sd = skillDirOfInstall(opts, claudeDir, home);
|
|
348
|
+
return {
|
|
349
|
+
dry_run: true, home, settings: path.join(claudeDir, 'settings.json'), skill_dir: sd ? toSlash(sd) : null,
|
|
350
|
+
profile: sd ? seedProfile(profileSrcDir(), path.join(sd, 'profile'), { dryRun: true }) : { skipped: 'no-skill-dir' },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
329
353
|
fs.mkdirSync(paths.bin, { recursive: true });
|
|
330
354
|
fs.mkdirSync(paths.data, { recursive: true });
|
|
331
355
|
|
|
332
356
|
// 1. hook + pricing (LF, self-contained)
|
|
333
|
-
|
|
357
|
+
// s16: lib/procs.cjs rides along as bin/procs.cjs (the hook requires ./procs.cjs for the leftover-process scan)
|
|
358
|
+
for (const [f, dest] of [['hook.cjs', 'hook.cjs'], ['pricing.json', 'pricing.json'], [path.join('lib', 'procs.cjs'), 'procs.cjs']]) {
|
|
334
359
|
const src = fs.readFileSync(path.join(PKG_ROOT, f), 'utf8').replace(/\r\n/g, '\n');
|
|
335
|
-
core.writeFileAtomic(path.join(paths.bin,
|
|
360
|
+
core.writeFileAtomic(path.join(paths.bin, dest), src);
|
|
336
361
|
}
|
|
337
362
|
|
|
338
363
|
// 2. config
|
|
@@ -340,15 +365,13 @@ function setup(opts) {
|
|
|
340
365
|
if (opts.dept !== undefined) cfg.dept = String(opts.dept);
|
|
341
366
|
if (opts.sinkUrl !== undefined) cfg.sink_url = String(opts.sinkUrl);
|
|
342
367
|
if (opts.sinkToken !== undefined) cfg.sink_token = String(opts.sinkToken);
|
|
343
|
-
|
|
344
|
-
if (!skillDir && cfg.skill_dir && fs.existsSync(path.join(cfg.skill_dir, 'profile', 'profile.json'))) {
|
|
345
|
-
skillDir = cfg.skill_dir;
|
|
346
|
-
}
|
|
347
|
-
if (!skillDir) skillDir = findSkillDir(claudeDir);
|
|
368
|
+
const skillDir = skillDirOfInstall(opts, claudeDir, home);
|
|
348
369
|
if (skillDir) {
|
|
349
370
|
cfg.skill_dir = toSlash(skillDir);
|
|
350
371
|
cfg.skill_version = skillVersionOf(skillDir) || cfg.skill_version || null;
|
|
351
372
|
}
|
|
373
|
+
// 2b. lessons đã tuyển của gói → profile đã cài (chỉ thêm dòng mới; dòng riêng của máy giữ nguyên)
|
|
374
|
+
const profile = skillDir ? seedProfile(profileSrcDir(), path.join(skillDir, 'profile'), {}) : { skipped: 'no-skill-dir' };
|
|
352
375
|
if (!cfg.user_hash) cfg.user_hash = core.computeUserHash();
|
|
353
376
|
cfg.core_version = core.CORE_VERSION;
|
|
354
377
|
if (!cfg.installed_at) cfg.installed_at = core.nowIso();
|
|
@@ -391,6 +414,7 @@ function setup(opts) {
|
|
|
391
414
|
permissions: perms,
|
|
392
415
|
legacy_permissions_removed: legacyRemoved,
|
|
393
416
|
config: cfg,
|
|
417
|
+
profile,
|
|
394
418
|
};
|
|
395
419
|
}
|
|
396
420
|
|