@kienntpixon/dieu-phoi 0.5.2 → 0.6.0
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 +73 -0
- package/README.md +9 -3
- package/agents/dp-exec.md +2 -0
- package/agents/dp-scout.md +2 -0
- package/agents/dp-verify.md +2 -0
- package/bin/dieu-phoi.cjs +21 -3
- package/hook.cjs +907 -19
- package/lib/args.cjs +19 -2
- package/lib/commands.cjs +109 -22
- package/lib/knowledge.cjs +1002 -0
- package/lib/profile.cjs +204 -0
- package/lib/prune.cjs +57 -20
- package/package.json +1 -1
package/lib/profile.cjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// `dieu-phoi profile base|apply` (core 0.6.0, unreleased): 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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
3
|
+
"version": "0.6.0",
|
|
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>",
|