@kienntpixon/dieu-phoi 0.5.3 → 0.6.1

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.
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+ // `dieu-phoi profile base|apply` (core 0.6.0): apply an approved retro to <skill>/profile/lessons.md and
3
+ // profile.json without overwriting what other sessions wrote meanwhile.
4
+ //
5
+ // profile base --save <dir> snapshot both files into <dir>/profile-base/ (+ base.json with their sha256) before the
6
+ // retro agent reads them; the agent builds its proposed files from this snapshot.
7
+ // profile apply --base <dir> [--lessons <file>] [--profile <file>] [--dry-run] [--json]
8
+ // under <skill>/profile/profile.lock:
9
+ // - file unchanged since base → write the proposal;
10
+ // - lessons.md changed, but only by ADDED lines (other sessions `>>` a lesson) → write
11
+ // the proposal plus those lines (each after the line it followed, else at the end);
12
+ // - lessons.md lines edited/deleted since base, or profile.json changed at all → stop,
13
+ // exit 2, write nothing (the user decides).
14
+ // `>>` appenders take no lock: the live file is re-read right before the renames and the merge redone if it moved
15
+ // (up to 5 times). What is left is the sub-millisecond gap between that last read and the rename.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+ const core = require('../hook.cjs');
21
+
22
+ const BASE_DIR = 'profile-base';
23
+ const FILES = { lessons: 'lessons.md', profile: 'profile.json' };
24
+ const LOCK_TRIES = 200; // x 50 ms = 10 s
25
+ const REREAD_TRIES = 5;
26
+
27
+ const HELP = `Cách dùng: dieu-phoi profile <lệnh> [--skill-dir P] [--json]
28
+
29
+ base --save <thư mục> chụp profile/lessons.md + profile.json vào <thư mục>/profile-base/ (kèm base.json = sha256 lúc đọc).
30
+ Agent retro đọc và dựng bản đề xuất từ bản chụp này, không từ file sống
31
+ apply --base <thư mục> [--lessons <file đề xuất>] [--profile <file đề xuất>] [--dry-run]
32
+ áp retro đã duyệt, dưới khoá profile/profile.lock. File chưa đổi từ base → ghi đề xuất.
33
+ 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ì`;
35
+
36
+ const sha = (txt) => crypto.createHash('sha256').update(txt, 'utf8').digest('hex');
37
+ const lf = (txt) => String(txt).replace(/^/, '').replace(/\r\n/g, '\n');
38
+ const slash = (p) => String(p).replace(/\\/g, '/');
39
+
40
+ function readText(file) {
41
+ try { return lf(fs.readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
42
+ }
43
+
44
+ function skillDirOf(opts) {
45
+ const c = require('./commands.cjs');
46
+ return c.skillDirOf(opts, require('./args.cjs').ctxOf(opts));
47
+ }
48
+
49
+ function profileDir(opts) {
50
+ const sd = skillDirOf(opts);
51
+ if (!sd) throw new Error('không thấy skill dieu-phoi (dùng --skill-dir)');
52
+ const dir = path.join(sd, 'profile');
53
+ if (!fs.existsSync(dir)) throw new Error(`không thấy ${slash(dir)}`);
54
+ return dir;
55
+ }
56
+
57
+ function cmdBase(opts) {
58
+ if (typeof opts.save !== 'string' || !opts.save.trim()) return { code: 1, out: 'profile base cần --save <thư mục> (vd. run dir của retro).' };
59
+ const dir = profileDir(opts);
60
+ const out = path.join(path.resolve(core.nativePath(opts.save)), BASE_DIR);
61
+ const meta = { at: new Date().toISOString(), profile_dir: slash(dir) };
62
+ for (const [k, f] of Object.entries(FILES)) {
63
+ const txt = readText(path.join(dir, f));
64
+ if (txt === null) { meta[k] = null; continue; }
65
+ core.writeFileAtomic(path.join(out, f), txt);
66
+ meta[k] = sha(txt);
67
+ }
68
+ core.writeFileAtomic(path.join(out, 'base.json'), `${JSON.stringify(meta, null, 2)}\n`);
69
+ if (opts.json) return { code: 0, out: JSON.stringify(Object.assign({ dir: slash(out) }, meta), null, 2) };
70
+ return { code: 0, out: `Đã chụp base vào ${slash(out)}: lessons=${String(meta.lessons).slice(0, 12)} profile=${String(meta.profile).slice(0, 12)}` };
71
+ }
72
+
73
+ /**
74
+ * Lines the live file added on top of base, each with the base line it followed (-1 = file start).
75
+ * null when a base line is missing from the live file in order (edited or deleted since base).
76
+ */
77
+ function addedSince(baseTxt, curTxt) {
78
+ const b = baseTxt.split('\n');
79
+ const c = curTxt.split('\n');
80
+ const added = [];
81
+ let i = 0;
82
+ for (let j = 0; j < c.length; j++) {
83
+ if (i < b.length && c[j] === b[i]) { i++; continue; }
84
+ added.push({ line: c[j], after: i - 1 });
85
+ }
86
+ if (i < b.length) {
87
+ // a trailing empty element is just the final newline
88
+ const rest = b.slice(i).filter((x) => x !== '');
89
+ if (rest.length) return { missing: rest };
90
+ }
91
+ return { added };
92
+ }
93
+
94
+ /** Put the lines others added into the proposal: after the same anchor line when the proposal kept it, else at the end. */
95
+ function mergeLessons(baseTxt, curTxt, propTxt) {
96
+ if (curTxt === baseTxt) return { text: propTxt, merged: [] };
97
+ const d = addedSince(baseTxt, curTxt);
98
+ if (d.missing) return { conflict: `lessons.md: ${d.missing.length} dòng có sẵn lúc base đã bị sửa/xoá (vd. "${d.missing[0].slice(0, 80)}")` };
99
+ const base = baseTxt.split('\n');
100
+ const out = propTxt.replace(/\n+$/, '').split('\n');
101
+ const have = new Set(out);
102
+ const merged = [];
103
+ const tail = [];
104
+ const count = new Map(); // anchor text → lines already inserted after it
105
+ for (const a of d.added) {
106
+ if (!a.line.trim() || have.has(a.line)) continue;
107
+ let k = a.after;
108
+ while (k >= 0 && !base[k].trim()) k--; // anchor on the nearest non-blank line: blank lines repeat
109
+ const key = k < 0 ? null : base[k];
110
+ const at = key === null ? -1 : out.indexOf(key);
111
+ if (key !== null && at < 0) tail.push(a.line); // the proposal changed that line: append at the end
112
+ else {
113
+ const n = count.get(key) || 0;
114
+ out.splice(at + 1 + n, 0, a.line);
115
+ count.set(key, n + 1);
116
+ }
117
+ merged.push(a.line);
118
+ have.add(a.line);
119
+ }
120
+ return { text: `${out.concat(tail).join('\n')}\n`, merged };
121
+ }
122
+
123
+ function plan(dir, baseDir, props) {
124
+ const res = { conflicts: [], writes: [] };
125
+ for (const [k, f] of Object.entries(FILES)) {
126
+ if (props[k] === undefined) continue;
127
+ const baseTxt = readText(path.join(baseDir, f));
128
+ const cur = readText(path.join(dir, f));
129
+ if (baseTxt === null || cur === null) { res.conflicts.push(`${f}: thiếu ${baseTxt === null ? 'bản base' : 'file sống'}`); continue; }
130
+ if (cur === props[k]) { res.writes.push({ k, f, same: true }); continue; }
131
+ if (k === 'profile') {
132
+ if (sha(cur) !== sha(baseTxt)) { res.conflicts.push('profile.json đã đổi sau base (phiên khác/sửa tay) — không tự gộp'); continue; }
133
+ res.writes.push({ k, f, text: props[k], cur, merged: [] });
134
+ continue;
135
+ }
136
+ const m = mergeLessons(baseTxt, cur, props[k]);
137
+ if (m.conflict) { res.conflicts.push(m.conflict); continue; }
138
+ res.writes.push({ k, f, text: m.text, cur, merged: m.merged });
139
+ }
140
+ return res;
141
+ }
142
+
143
+ function cmdApply(opts) {
144
+ if (typeof opts.base !== 'string') return { code: 1, out: 'profile apply cần --base <thư mục đã chụp bằng profile base --save>.' };
145
+ const baseDir = path.join(path.resolve(core.nativePath(opts.base)), BASE_DIR);
146
+ const meta = core.readJsonFile(path.join(baseDir, 'base.json'), null);
147
+ if (!meta) return { code: 1, out: `không thấy ${slash(path.join(baseDir, 'base.json'))} — chạy profile base --save trước khi retro đọc profile.` };
148
+ const props = {};
149
+ for (const k of Object.keys(FILES)) {
150
+ if (typeof opts[k] !== 'string') continue;
151
+ const t = readText(path.resolve(core.nativePath(opts[k])));
152
+ if (t === null) return { code: 1, out: `không thấy file đề xuất --${k} ${opts[k]}` };
153
+ if (k === 'profile') { try { JSON.parse(t); } catch (e) { return { code: 1, out: `--profile không phải JSON hợp lệ: ${e.message}` }; } }
154
+ props[k] = t.endsWith('\n') ? t : `${t}\n`;
155
+ }
156
+ if (!Object.keys(props).length) return { code: 1, out: 'cần --lessons và/hoặc --profile (file đề xuất đã được duyệt).' };
157
+ for (const k of Object.keys(FILES)) {
158
+ const t = readText(path.join(baseDir, FILES[k]));
159
+ if (props[k] !== undefined && (t === null || sha(t) !== meta[k])) return { code: 1, out: `bản base ${FILES[k]} không khớp base.json (đã bị sửa?) — chụp lại base.` };
160
+ }
161
+ const dir = profileDir(opts);
162
+ const dry = !!opts['dry-run'];
163
+ const lockFile = path.join(dir, 'profile.lock');
164
+ let got = false;
165
+ for (let i = 0; i < LOCK_TRIES && !got; i++) {
166
+ got = core.acquireLock(lockFile);
167
+ if (!got) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
168
+ }
169
+ if (!got) return { code: 1, out: `profile đang bị khoá (${slash(lockFile)}) — thử lại.` };
170
+ let res;
171
+ let written = false;
172
+ try {
173
+ for (let t = 0; t < REREAD_TRIES; t++) {
174
+ res = plan(dir, baseDir, props);
175
+ if (res.conflicts.length || dry) break;
176
+ const todo = res.writes.filter((w) => !w.same);
177
+ const hook = process.env.DIEU_PHOI_TEST_PROFILE_APPEND_ONCE; // test hook: a `>>` landing inside the merge window
178
+ if (hook && t === 0) fs.appendFileSync(path.join(dir, FILES.lessons), `${hook}
179
+ `);
180
+ if (todo.some((w) => readText(path.join(dir, w.f)) !== w.cur)) continue; // a `>>` landed while we merged: redo
181
+ for (const w of todo) core.writeFileAtomic(path.join(dir, w.f), w.text);
182
+ written = true;
183
+ break;
184
+ }
185
+ if (!written && !dry && !res.conflicts.length) res.conflicts.push(`lessons.md đổi liên tục trong lúc gộp (${REREAD_TRIES} lần) — thử lại`);
186
+ } finally {
187
+ try { fs.unlinkSync(lockFile); } catch (_) { /* gone */ }
188
+ }
189
+ const code = res.conflicts.length ? 2 : 0;
190
+ if (opts.json) return { code, out: JSON.stringify({ dry_run: dry, conflicts: res.conflicts, writes: res.writes.map((w) => ({ file: w.f, same: !!w.same, merged: w.merged || [] })) }, null, 2) };
191
+ if (code) return { code, out: `DỪNG — không ghi gì:\n${res.conflicts.map((c) => ` ! ${c}`).join('\n')}\nBáo người dùng: đọc lại file sống, dựng lại đề xuất (profile base --save lần nữa) rồi apply.` };
192
+ const lines = res.writes.map((w) => (w.same ? ` = ${w.f} (không đổi)` : ` ${dry ? '~' : '✓'} ${w.f}${w.merged.length ? ` (gộp thêm ${w.merged.length} dòng phiên khác thêm sau base)` : ''}`));
193
+ return { code: 0, out: `${dry ? '(dry-run — không ghi gì)\n' : ''}profile ${slash(dir)}:\n${lines.join('\n')}` };
194
+ }
195
+
196
+ function cmdProfile(opts) {
197
+ const sub = opts._.shift();
198
+ if (!sub || opts.help || opts.h || sub === 'help') return { code: sub || opts.help || opts.h ? 0 : 1, out: HELP };
199
+ if (sub === 'base') return cmdBase(opts);
200
+ if (sub === 'apply') return cmdApply(opts);
201
+ return { code: 1, out: `Lệnh profile lạ: ${sub}\n\n${HELP}` };
202
+ }
203
+
204
+ module.exports = { cmdProfile, mergeLessons, addedSince, HELP };
package/lib/prune.cjs CHANGED
@@ -218,6 +218,28 @@ function writeArchive(ctx, texts) {
218
218
  return file;
219
219
  }
220
220
 
221
+ const ACTIVE_WINDOW_MIN = 15;
222
+
223
+ /**
224
+ * Events of another session inside the last ACTIVE_WINDOW_MIN minutes. Own session = --session, else
225
+ * $CLAUDE_CODE_SESSION_ID (events carry `session` = sha8 of the raw id); unknown own session => every session counts.
226
+ * Events without a session (verdict/steer/discard by the user) are not attributable and are ignored.
227
+ */
228
+ function otherSessionActive(ctx, opts, events) {
229
+ const rawOwn = typeof opts.session === 'string' && opts.session ? opts.session : (process.env.CLAUDE_CODE_SESSION_ID || '');
230
+ const own = rawOwn ? core.sha8(rawOwn) : null;
231
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
232
+ const since = now - ACTIVE_WINDOW_MIN * 60000;
233
+ const sessions = new Set(); let count = 0; let last = 0;
234
+ for (const e of events) {
235
+ if (typeof e.session !== 'string' || !e.session || e.session === own) continue;
236
+ const t = Date.parse(e.ts);
237
+ if (!(t >= since)) continue;
238
+ sessions.add(e.session); count++; if (t > last) last = t;
239
+ }
240
+ return count ? { count, sessions: Array.from(sessions), last: new Date(last).toISOString(), ownKnown: !!own } : null;
241
+ }
242
+
221
243
  function cmdPrune(opts) {
222
244
  const ctx = ctxOf(opts);
223
245
  const retro = typeof opts['after-retro'] === 'string' ? path.resolve(opts['after-retro']) : null;
@@ -264,6 +286,16 @@ function cmdPrune(opts) {
264
286
  };
265
287
 
266
288
  if (yes) {
289
+ // 0. a session other than this one touched the log recently => refuse (a live hook could be mid-append)
290
+ const busy = opts.force === true ? null : otherSessionActive(ctx, opts, allEvents);
291
+ if (busy) {
292
+ return { code: 1, out: [
293
+ `prune: từ chối — ${busy.count} sự kiện của ${busy.sessions.length} phiên khác trong ${ACTIVE_WINDOW_MIN} phút gần nhất (mới nhất ${busy.last}); phiên khác đang chạy có thể ghi events.jsonl cùng lúc.`,
294
+ busy.ownKnown ? 'Chờ phiên đó xong rồi chạy lại, hoặc thêm --force nếu chắc chắn.'
295
+ : 'Không biết phiên hiện tại (thiếu CLAUDE_CODE_SESSION_ID): thêm --session <id> để loại trừ phiên của bạn, hoặc --force.',
296
+ 'Không có gì bị thay đổi.',
297
+ ].join('\n') };
298
+ }
267
299
  // 1. events → archive (archive first + verified, then rewrite events.jsonl via temp + rename)
268
300
  if (moving.length) {
269
301
  const fresh = moving.filter((l) => !archived.has(l.text));
@@ -271,25 +303,30 @@ function cmdPrune(opts) {
271
303
  if (!lock) return { code: 1, out: 'prune: sync đang giữ khoá — thử lại sau' };
272
304
  try {
273
305
  if (fresh.length) writeArchive(ctx, fresh.map((l) => l.text));
274
- const buf = core.readEventsBuffer(ctx);
275
- const drop = new Map(moving.map((l) => [l.start, l.text]));
276
- const parts = []; let removedBeforeOffset = 0;
277
- let pos = 0;
278
- for (const l of core.eventLinesFrom(buf, 0)) {
279
- parts.push(buf.subarray(pos, l.start)); // blank lines between
280
- if (drop.get(l.start) === l.text) {
281
- if (l.end <= unsynced.offset) removedBeforeOffset += l.end - l.start;
282
- } else parts.push(buf.subarray(l.start, l.end));
283
- pos = l.end;
284
- }
285
- parts.push(buf.subarray(pos));
286
- let next = Buffer.concat(parts);
287
- const cur = core.readEventsBuffer(ctx);
288
- if (cur.length > buf.length) next = Buffer.concat([next, cur.subarray(buf.length)]); // appended meanwhile
289
- const tmp = `${ctx.paths.events}.tmp-${process.pid}`;
290
- fs.writeFileSync(tmp, next);
291
- core.renameWithRetry(tmp, ctx.paths.events);
292
- if (unsynced.offset > 0) core.writeFileAtomic(ctx.paths.syncOffset, `${JSON.stringify({ offset: Math.max(0, unsynced.offset - removedBeforeOffset), ts: core.nowIso() })}\n`);
306
+ // Core 0.5.4: the read-rewrite-rename below runs under events.lock, the same lock appendEvent takes, so no
307
+ // event can land between the read and the rename (the old code re-read then renamed with a gap, and the
308
+ // line appended inside it was lost). Lines appended after the first snapshot are kept untouched.
309
+ const rewritten = core.withFileLock(ctx.paths.eventsLock, { timeoutMs: Number(opts['lock-timeout-ms']) > 0 ? Number(opts['lock-timeout-ms']) : 15000, staleMs: 60000, required: true }, () => {
310
+ const off = core.unsyncedInfo(ctx).offset; // sync is stopped (sync.lock) but may have advanced since the first read
311
+ const buf = core.readEventsBuffer(ctx);
312
+ const drop = new Map(moving.map((l) => [l.start, l.text]));
313
+ const parts = []; let removedBeforeOffset = 0;
314
+ let pos = 0;
315
+ for (const l of core.eventLinesFrom(buf, 0)) {
316
+ parts.push(buf.subarray(pos, l.start)); // blank lines between
317
+ if (drop.get(l.start) === l.text) {
318
+ if (l.end <= off) removedBeforeOffset += l.end - l.start;
319
+ } else parts.push(buf.subarray(l.start, l.end));
320
+ pos = l.end;
321
+ }
322
+ parts.push(buf.subarray(pos)); // blank tail + an unterminated line still being written
323
+ const tmp = `${ctx.paths.events}.tmp-${process.pid}`;
324
+ fs.writeFileSync(tmp, Buffer.concat(parts));
325
+ core.renameWithRetry(tmp, ctx.paths.events);
326
+ if (off > 0) core.writeFileAtomic(ctx.paths.syncOffset, `${JSON.stringify({ offset: Math.max(0, off - removedBeforeOffset), ts: core.nowIso() })}\n`);
327
+ return true;
328
+ });
329
+ if (rewritten.skipped) return { code: 1, out: 'prune: events.jsonl đang bận (khoá events.lock) — archive đã ghi, events.jsonl giữ nguyên; thử lại sau' };
293
330
  } finally {
294
331
  try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
295
332
  }
@@ -318,4 +355,4 @@ function cmdPrune(opts) {
318
355
  return { code: 0, out: L.join('\n') };
319
356
  }
320
357
 
321
- module.exports = { cmdPrune, keepSet, cutoffOf };
358
+ module.exports = { cmdPrune, keepSet, cutoffOf, otherSessionActive };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kienntpixon/dieu-phoi",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Hook + CLI ghi log subagent của Claude Code và chọn model/effort theo chi phí + thời gian tới lúc nghiệm thu.",
5
5
  "license": "MIT",
6
6
  "author": "kiennt <kiennt@pixon.games>",