@kienntpixon/dieu-phoi 0.7.1 → 0.7.3

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/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 gì`;
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 keep = keepSet(names, (n) => { try { return fs.lstatSync(path.join(dir, n)).mtimeMs; } catch (_) { return 0; } });
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,10 +316,13 @@ 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
  };
287
323
 
324
+ let removedLines = 0; // lines actually dropped from events.jsonl under the lock (what the message reports)
325
+ let freshCount = 0; // of those, lines newly written into the archive (the rest were there from an earlier run)
288
326
  if (yes) {
289
327
  // 0. a session other than this one touched the log recently => refuse (a live hook could be mid-append)
290
328
  const busy = opts.force === true ? null : otherSessionActive(ctx, opts, allEvents);
@@ -299,6 +337,7 @@ function cmdPrune(opts) {
299
337
  // 1. events → archive (archive first + verified, then rewrite events.jsonl via temp + rename)
300
338
  if (moving.length) {
301
339
  const fresh = moving.filter((l) => !archived.has(l.text));
340
+ freshCount = fresh.length;
302
341
  const lock = core.acquireLock(ctx.paths.lock);
303
342
  if (!lock) return { code: 1, out: 'prune: sync đang giữ khoá — thử lại sau' };
304
343
  try {
@@ -315,6 +354,7 @@ function cmdPrune(opts) {
315
354
  for (const l of core.eventLinesFrom(buf, 0)) {
316
355
  parts.push(buf.subarray(pos, l.start)); // blank lines between
317
356
  if (drop.get(l.start) === l.text) {
357
+ removedLines++;
318
358
  if (l.end <= off) removedBeforeOffset += l.end - l.start;
319
359
  } else parts.push(buf.subarray(l.start, l.end));
320
360
  pos = l.end;
@@ -331,6 +371,20 @@ function cmdPrune(opts) {
331
371
  try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
332
372
  }
333
373
  }
374
+ report.events.moved = removedLines;
375
+ report.events.archived_new = freshCount;
376
+ // 2a. unmerged deltas older than 30 d without a user verdict -> runs/_archive/deltas/<run>/ (moved, not deleted)
377
+ for (const r of rp.prune) {
378
+ for (const n of r.archive) {
379
+ const from = path.join(r.dir, n);
380
+ const toDir = path.join(runs, '_archive', 'deltas', r.id);
381
+ if (!inside(r.dir, from) || !inside(runs, toDir)) continue;
382
+ fs.mkdirSync(toDir, { recursive: true });
383
+ let to = path.join(toDir, n);
384
+ if (fs.existsSync(to)) to = path.join(toDir, `${Date.now()}-${n}`);
385
+ core.renameWithRetry(from, to);
386
+ }
387
+ }
334
388
  // 2. run dirs
335
389
  for (const r of rp.prune) {
336
390
  for (const f of r.del) {
@@ -348,9 +402,12 @@ function cmdPrune(opts) {
348
402
  const L = [];
349
403
  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
404
  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)})`);
405
+ 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
406
  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
- 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`);
407
+ const evTxt = yes
408
+ ? `đã chuyển ${removedLines}/${lines.length} dòng vào archive và xoá khỏi events.jsonl (${freshCount} dòng ghi mới${freshCount ? ` vào archive/events-${archiveToday()}.jsonl.gz` : ''}; ${human(evBytes)})`
409
+ : `sẽ chuyển ${moving.length}/${lines.length} dòng vào archive/events-${archiveToday()}.jsonl.gz và xoá khỏi events.jsonl (${human(evBytes)})`;
410
+ L.push(`events.jsonl: ${evTxt}${heldBack ? `; ${heldBack} dòng chưa đẩy lên sink nên giữ lại` : ''}`);
354
411
  L.push(`Tổng xoá khỏi đĩa: ${report.totals.files} mục run dir + ${plans.length} plan = ${human(report.totals.bytes)}`);
355
412
  return { code: 0, out: L.join('\n') };
356
413
  }
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');
@@ -61,7 +67,7 @@ function stripOurHooks(settings) {
61
67
  // (an old `Agent`-only registration is not "already registered", so `setup` rewrites it).
62
68
  const AGENT_MATCHER = 'Agent|Task';
63
69
 
64
- /** True when settings already hold exactly our five entries with this command. */
70
+ /** True when settings already hold exactly our six entries with this command. */
65
71
  function alreadyRegistered(settings, cmd) {
66
72
  const hooks = settings && settings.hooks;
67
73
  if (!hooks || typeof hooks !== 'object') return false;
@@ -75,13 +81,14 @@ function alreadyRegistered(settings, cmd) {
75
81
  if (h.command !== cmd) continue;
76
82
  if (event === 'PostToolUse' && g.matcher === AGENT_MATCHER) good++;
77
83
  else if (event === 'SubagentStop') good++;
84
+ else if (event === 'SubagentStart' && !g.matcher) good++;
78
85
  else if (event === 'SessionStart' && g.matcher === 'compact') good++;
79
86
  else if (event === 'UserPromptSubmit' && !g.matcher) good++;
80
87
  else if (event === 'PreToolUse' && g.matcher === AGENT_MATCHER) good++;
81
88
  }
82
89
  }
83
90
  }
84
- return ours === 5 && good === 5;
91
+ return ours === 6 && good === 6;
85
92
  }
86
93
 
87
94
  function addOurHooks(settings, hookPath) {
@@ -92,6 +99,9 @@ function addOurHooks(settings, hookPath) {
92
99
  h.PostToolUse.push({ matcher: AGENT_MATCHER, hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
93
100
  if (!Array.isArray(h.SubagentStop)) h.SubagentStop = [];
94
101
  h.SubagentStop.push({ hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
102
+ // core 0.7.3 (s1): live-agent register (data/live/<agent_id>.json) for the parallel cap; no matcher = every subagent
103
+ if (!Array.isArray(h.SubagentStart)) h.SubagentStart = [];
104
+ h.SubagentStart.push({ hooks: [{ type: 'command', command: cmd, timeout: 5 }] });
95
105
  // after /compact or auto-compact: re-inject the orchestration state rebuilt from the log
96
106
  if (!Array.isArray(h.SessionStart)) h.SessionStart = [];
97
107
  h.SessionStart.push({ matcher: 'compact', hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
@@ -276,6 +286,22 @@ function nodePathOf(cmd) {
276
286
  return m ? m[1] : undefined;
277
287
  }
278
288
 
289
+ function realOf(p) {
290
+ try { return fs.realpathSync.native(p); } catch (_) { return path.resolve(p); }
291
+ }
292
+
293
+ /**
294
+ * Rewrite hook commands inside an installed agent file whose node/hook paths resolve (symlink/junction, e.g. an
295
+ * Orca profile linking ~/.claude) to the same real files as ours, so the comparison ignores path spelling only.
296
+ */
297
+ function normalizeHookPaths(cur, hookPath, nodePath) {
298
+ const myNode = realOf(nodePath || process.execPath);
299
+ const myHook = realOf(hookPath);
300
+ const mine = hookCommand(hookPath, nodePath).replace(/'/g, "''");
301
+ return cur.replace(/'"([^"']+)" "([^"']+)"( --[A-Za-z-]+)'/g, (m, n, h, flag) =>
302
+ (realOf(n) === myNode && realOf(h) === myHook ? `'${mine}${flag}'` : m));
303
+ }
304
+
279
305
  /**
280
306
  * State of each dp-* agent file: ok (ours, identical), outdated (ours, differs), missing, foreign (a file with
281
307
  * that name we did not write — never touched).
@@ -289,7 +315,10 @@ function agentStatus(claudeDir, hookPath, nodePath) {
289
315
  let state = 'missing';
290
316
  if (cur !== null) {
291
317
  if (!cur.includes(AGENT_MARKER)) state = 'foreign';
292
- else state = cur === renderAgent(name, hookPath, nodePath) ? 'ok' : 'outdated';
318
+ else {
319
+ const want = renderAgent(name, hookPath, nodePath);
320
+ state = (cur === want || normalizeHookPaths(cur, hookPath, nodePath) === want) ? 'ok' : 'outdated';
321
+ }
293
322
  }
294
323
  return { name, file, state };
295
324
  });
@@ -322,17 +351,36 @@ function removeAgents(claudeDir) {
322
351
  * Install: copy hook + pricing, register hooks, write config.
323
352
  * opts: { claudeDir, dept, sinkUrl, sinkToken, skillDir }
324
353
  */
354
+ /** 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. */
355
+ function skillDirOfInstall(opts, claudeDir, home) {
356
+ let skillDir = opts.skillDir ? path.resolve(opts.skillDir) : null;
357
+ if (!skillDir) {
358
+ const cfg = core.loadConfig(home);
359
+ if (cfg.skill_dir && fs.existsSync(path.join(cfg.skill_dir, 'profile', 'profile.json'))) skillDir = cfg.skill_dir;
360
+ }
361
+ return skillDir || findSkillDir(claudeDir);
362
+ }
363
+
325
364
  function setup(opts) {
326
365
  const claudeDir = path.resolve(opts.claudeDir);
327
366
  const home = path.join(claudeDir, 'dieu-phoi');
328
367
  const paths = core.pathsFor(home);
368
+ if (opts.dryRun) {
369
+ // --dry-run: chỉ báo phần profile sẽ gộp; không ghi hook, settings, agent hay config
370
+ const sd = skillDirOfInstall(opts, claudeDir, home);
371
+ return {
372
+ dry_run: true, home, settings: path.join(claudeDir, 'settings.json'), skill_dir: sd ? toSlash(sd) : null,
373
+ profile: sd ? seedProfile(profileSrcDir(), path.join(sd, 'profile'), { dryRun: true }) : { skipped: 'no-skill-dir' },
374
+ };
375
+ }
329
376
  fs.mkdirSync(paths.bin, { recursive: true });
330
377
  fs.mkdirSync(paths.data, { recursive: true });
331
378
 
332
379
  // 1. hook + pricing (LF, self-contained)
333
- for (const f of ['hook.cjs', 'pricing.json']) {
380
+ // s16: lib/procs.cjs rides along as bin/procs.cjs (the hook requires ./procs.cjs for the leftover-process scan)
381
+ for (const [f, dest] of [['hook.cjs', 'hook.cjs'], ['pricing.json', 'pricing.json'], [path.join('lib', 'procs.cjs'), 'procs.cjs']]) {
334
382
  const src = fs.readFileSync(path.join(PKG_ROOT, f), 'utf8').replace(/\r\n/g, '\n');
335
- core.writeFileAtomic(path.join(paths.bin, f), src);
383
+ core.writeFileAtomic(path.join(paths.bin, dest), src);
336
384
  }
337
385
 
338
386
  // 2. config
@@ -340,15 +388,13 @@ function setup(opts) {
340
388
  if (opts.dept !== undefined) cfg.dept = String(opts.dept);
341
389
  if (opts.sinkUrl !== undefined) cfg.sink_url = String(opts.sinkUrl);
342
390
  if (opts.sinkToken !== undefined) cfg.sink_token = String(opts.sinkToken);
343
- let skillDir = opts.skillDir ? path.resolve(opts.skillDir) : null;
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);
391
+ const skillDir = skillDirOfInstall(opts, claudeDir, home);
348
392
  if (skillDir) {
349
393
  cfg.skill_dir = toSlash(skillDir);
350
394
  cfg.skill_version = skillVersionOf(skillDir) || cfg.skill_version || null;
351
395
  }
396
+ // 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)
397
+ const profile = skillDir ? seedProfile(profileSrcDir(), path.join(skillDir, 'profile'), {}) : { skipped: 'no-skill-dir' };
352
398
  if (!cfg.user_hash) cfg.user_hash = core.computeUserHash();
353
399
  cfg.core_version = core.CORE_VERSION;
354
400
  if (!cfg.installed_at) cfg.installed_at = core.nowIso();
@@ -391,6 +437,7 @@ function setup(opts) {
391
437
  permissions: perms,
392
438
  legacy_permissions_removed: legacyRemoved,
393
439
  config: cfg,
440
+ profile,
394
441
  };
395
442
  }
396
443
 
@@ -421,7 +468,7 @@ function registration(claudeDir) {
421
468
  const perms = permissionEntries(runsDir());
422
469
  const legacy = permissionEntries(legacyRunsDirOf(claudeDir));
423
470
  const out = {
424
- file, PostToolUse: false, SubagentStop: false, SessionStart: false, UserPromptSubmit: false, PreToolUse: false, commands: [], permissions: false, permission_entries: perms,
471
+ file, PostToolUse: false, SubagentStop: false, SubagentStart: false, SessionStart: false, UserPromptSubmit: false, PreToolUse: false, commands: [], permissions: false, permission_entries: perms,
425
472
  runs_dir: perms.dir, runs_dir_exists: fs.existsSync(perms.dir), legacy_permissions: false,
426
473
  };
427
474
  let obj;
@@ -431,7 +478,7 @@ function registration(claudeDir) {
431
478
  out.legacy_permissions = legacy.allow.some((r) => Array.isArray(lp.allow) && lp.allow.includes(r))
432
479
  || (Array.isArray(lp.additionalDirectories) && lp.additionalDirectories.includes(legacy.dir));
433
480
  const hooks = obj.hooks || {};
434
- for (const ev of ['PostToolUse', 'SubagentStop', 'SessionStart', 'UserPromptSubmit', 'PreToolUse']) {
481
+ for (const ev of ['PostToolUse', 'SubagentStop', 'SubagentStart', 'SessionStart', 'UserPromptSubmit', 'PreToolUse']) {
435
482
  for (const g of Array.isArray(hooks[ev]) ? hooks[ev] : []) {
436
483
  for (const h of (g && Array.isArray(g.hooks) ? g.hooks : [])) {
437
484
  if (!isOurs(h)) continue;