@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.
package/lib/commands.cjs CHANGED
@@ -8,7 +8,8 @@ const core = require('../hook.cjs');
8
8
  const agg = require('./aggregate.cjs');
9
9
  const chooser = require('./choose.cjs');
10
10
  const setupLib = require('./setup.cjs');
11
- const { claudeDirOf, ctxOf, yesNo } = require('./args.cjs');
11
+ const cbm = require('./cbm.cjs');
12
+ const { claudeDirOf, ctxOf, yesNo, repoOf } = require('./args.cjs');
12
13
 
13
14
  const AGENT_OUTCOMES = ['pass', 'fail', 'timeout', 'aborted'];
14
15
  const USER_OUTCOMES = ['accept', 'reject'];
@@ -48,6 +49,7 @@ function table(rows) {
48
49
  // ------------------------------------------------------------------ setup / uninstall
49
50
 
50
51
  function cmdSetup(opts) {
52
+ if (opts['with-codebase-memory']) return cmdSetupWithCbm(opts);
51
53
  const res = setupLib.setup({
52
54
  claudeDir: claudeDirOf(opts),
53
55
  dept: typeof opts.dept === 'string' ? opts.dept : undefined,
@@ -75,6 +77,30 @@ function cmdSetup(opts) {
75
77
  return { code: 0, out: lines.join('\n') };
76
78
  }
77
79
 
80
+ /** `setup --with-codebase-memory [--force] [--cbm-dir D]`: normal setup, then install + register codebase-memory-mcp. */
81
+ async function cmdSetupWithCbm(opts) {
82
+ const base = cmdSetup(Object.assign({}, opts, { 'with-codebase-memory': false, json: false }));
83
+ let res;
84
+ let err = null;
85
+ try {
86
+ res = await cbm.install({ claudeDir: claudeDirOf(opts), force: !!opts.force, dir: typeof opts['cbm-dir'] === 'string' ? opts['cbm-dir'] : undefined, allowCustomSource: !!opts['allow-custom-source'] });
87
+ } catch (e) {
88
+ err = e.message;
89
+ }
90
+ if (opts.json) return { code: err ? 1 : 0, out: JSON.stringify({ setup: 'ok', codebase_memory: err ? { ok: false, error: err } : res }, null, 2) };
91
+ const lines = [base.out];
92
+ if (err) {
93
+ lines.push(`codebase-memory-mcp: LỖI — ${err} (setup dieu-phoi vẫn xong; không có gì bị ghi đè)`);
94
+ } else {
95
+ const act = { kept: 'đã có, giữ nguyên (--force để cài lại)', installed: 'đã cài', replaced: 'đã thay' }[res.action];
96
+ lines.push(`codebase-memory-mcp: ${act} v${res.version || '?'} tại ${res.binary.replace(/\\/g, '/')}${res.backup ? ` (bản cũ: ${path.basename(res.backup)})` : ''}`);
97
+ lines.push(`MCP (user scope): ${{ kept: 'đã đăng ký, giữ nguyên', 'kept-other': 'đã đăng ký (binary khác), giữ nguyên', added: 'đã đăng ký bằng `claude mcp add`', replaced: 'đã đăng ký lại', restored: 'đăng ký mới lỗi — đã khôi phục đăng ký cũ', manual: 'chưa đăng ký (xem ghi chú)' }[res.mcp]}`);
98
+ for (const n of res.notes) lines.push(` - ${n}`);
99
+ if (res.action !== 'kept' || res.mcp === 'added' || res.mcp === 'replaced') lines.push('Khởi động lại các phiên Claude Code để nạp MCP; kiểm bằng `dieu-phoi doctor`.');
100
+ }
101
+ return { code: err ? 1 : 0, out: lines.join('\n') };
102
+ }
103
+
78
104
  function cmdUninstall(opts) {
79
105
  const res = setupLib.uninstall({ claudeDir: claudeDirOf(opts), purge: !!opts.purge });
80
106
  if (opts.json) return { code: 0, out: JSON.stringify(res, null, 2) };
@@ -116,6 +142,14 @@ async function cmdDoctor(opts) {
116
142
  const sd = ctx.config.skill_dir;
117
143
  add(!!sd && fs.existsSync(path.join(sd, 'profile', 'profile.json')), 'profile',
118
144
  sd ? path.join(sd, 'profile', 'profile.json') : 'không có skill_dir — choose dùng priors mặc định', 'CẢNH BÁO');
145
+ // codebase-memory-mcp (optional): started here so its `daemon status` (~2.7 s, only with --cbm/--net; else a 10-minute
146
+ // cache) overlaps the hook-run check below
147
+ let cbmP = Promise.resolve([]);
148
+ try {
149
+ cbmP = cbm.doctorChecks({ claudeDir, repo: repoOf(opts), net: !!opts.net, deep: !!opts.cbm, cacheFile: path.join(ctx.paths.data, 'cbm-daemon.json') }).catch((e) => [{ ok: true, level: 'OK', name: 'codebase-memory', detail: `bỏ qua kiểm (${e.message})` }]);
150
+ } catch (e) {
151
+ cbmP = Promise.resolve([{ ok: true, level: 'OK', name: 'codebase-memory', detail: `bỏ qua kiểm (${e.message})` }]);
152
+ }
119
153
  if (reg.commands.length) {
120
154
  const cmd = reg.commands[0];
121
155
  const t0 = Date.now();
@@ -168,13 +202,17 @@ async function cmdDoctor(opts) {
168
202
  const cached = opts.net ? null : core.sinkStatusCached(ctx);
169
203
  if (cached && (cached.state === 'ok' || cached.state === 'fail')) {
170
204
  add(cached.state === 'ok', 'sink', `${ctx.config.sink_url} → ${cached.state === 'ok' ? 'ok' : `lỗi: ${cached.detail}`} (bộ nhớ đệm: ${cached.source} lúc ${cached.ts}; \`doctor --net\` để kiểm trực tiếp)`);
205
+ } else if (!opts.net && recentSinkTimeout(ctx)) {
206
+ const t = recentSinkTimeout(ctx);
207
+ add(false, 'sink', `${ctx.config.sink_url} → chậm/không xác định: lần kiểm lúc ${t.ts} không phản hồi kịp (chưa coi là lỗi); \`doctor --net\` để thử lại`, 'CẢNH BÁO');
171
208
  } else {
172
209
  const live = await checkSinkLive(ctx);
173
- add(live.ok, 'sink', `${ctx.config.sink_url} → ${live.detail}`);
210
+ add(live.ok, 'sink', `${ctx.config.sink_url} → ${live.detail}`, live.slow ? 'CẢNH BÁO' : undefined);
174
211
  }
175
212
  } else {
176
213
  add(true, 'sink', 'chưa cấu hình (chỉ lưu cục bộ)');
177
214
  }
215
+ for (const c of await cbmP) checks.push(c);
178
216
  const failed = checks.some((c) => c.level === 'LỖI');
179
217
  if (opts.json) return { code: failed ? 1 : 0, out: JSON.stringify({ ok: !failed, runs_dir: reg.runs_dir, checks }, null, 2) };
180
218
  const out = checks.map((c) => `[${c.level}] ${c.name}: ${c.detail}`).join('\n');
@@ -208,26 +246,45 @@ function sweepStaleTmp(ctx) {
208
246
  return { removed, left };
209
247
  }
210
248
 
211
- /** Live sink check (view=stats); result cached in data/sink-check.json for 24 h. */
249
+ /** The doctor's own timed-out sink check for the current sink, if less than 1 h old (else null): plain `doctor` does not retry a 20 s wait each time. */
250
+ function recentSinkTimeout(ctx) {
251
+ const chk = core.readJsonFile(ctx.paths.sinkCheck, null);
252
+ if (!chk || !chk.timeout || chk.url !== core.sinkUrlKey(ctx.config)) return null;
253
+ const t = Date.parse(chk.ts);
254
+ return t && Date.now() - t < 3600 * 1000 && t <= Date.now() + 60000 ? { ts: chk.ts } : null;
255
+ }
256
+
257
+ /**
258
+ * Live sink check (view=stats); result cached in data/sink-check.json for 24 h. Apps Script often needs 8-15 s, so the
259
+ * timeout is 20 s (DIEU_PHOI_SINK_CHECK_TIMEOUT_MS overrides, for tests). A timeout is not a hard failure: it is
260
+ * reported as slow/unknown (CẢNH BÁO) and cached with `timeout: true`, which the cache readers ignore.
261
+ */
212
262
  async function checkSinkLive(ctx) {
213
263
  let ok = false;
264
+ let slow = false;
214
265
  let detail;
266
+ const ms = Number(process.env.DIEU_PHOI_SINK_CHECK_TIMEOUT_MS) > 0 ? Number(process.env.DIEU_PHOI_SINK_CHECK_TIMEOUT_MS) : 20000;
215
267
  try {
216
268
  const u = new URL(ctx.config.sink_url);
217
269
  u.searchParams.set('token', ctx.config.sink_token);
218
270
  u.searchParams.set('view', 'stats');
219
- const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(8000) });
271
+ const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(ms) });
220
272
  const txt = await res.text();
221
273
  ok = res.ok;
222
274
  try { const b = JSON.parse(txt); if (b && b.ok === false) ok = false; } catch (_) { ok = false; }
223
275
  detail = `HTTP ${res.status}`;
224
276
  } catch (e) {
225
- detail = e.message;
277
+ if (e && (e.name === 'TimeoutError' || e.name === 'AbortError')) {
278
+ slow = true;
279
+ detail = `chậm/không xác định: không phản hồi trong ${Math.round(ms / 1000)} s (Apps Script thường mất 8-15 s; chạy lại \`doctor --net\`, hoặc xem kết quả sync nền)`;
280
+ } else {
281
+ detail = e.message;
282
+ }
226
283
  }
227
284
  try {
228
- core.writeFileAtomic(ctx.paths.sinkCheck, `${JSON.stringify({ ts: core.nowIso(), ok, detail, url: core.sinkUrlKey(ctx.config) })}\n`);
285
+ core.writeFileAtomic(ctx.paths.sinkCheck, `${JSON.stringify({ ts: core.nowIso(), ok, detail, url: core.sinkUrlKey(ctx.config), ...(slow ? { timeout: true } : {}) })}\n`);
229
286
  } catch (_) { /* cache is best effort */ }
230
- return { ok, detail };
287
+ return { ok, detail, slow };
231
288
  }
232
289
 
233
290
  // ------------------------------------------------------------------ start
@@ -239,13 +296,17 @@ async function checkSinkLive(ctx) {
239
296
  function cmdStart(opts) {
240
297
  const ctx = ctxOf(opts);
241
298
  const reg = setupLib.registration(claudeDirOf(opts));
242
- const info = core.startInfo(ctx);
299
+ const info = core.startInfo(ctx, repoOf(opts));
243
300
  const h = info.health;
244
301
  if (!fs.existsSync(ctx.paths.hook)) h.problems.unshift(`chưa cài hook (${ctx.paths.hook}) — chạy \`dieu-phoi setup --dept <phòng>\`, log không được ghi`);
245
302
  else if (!(reg.PostToolUse && reg.SubagentStop && reg.SessionStart)) h.problems.unshift('hook chưa đăng ký đủ trong settings.json — chạy lại `dieu-phoi setup`, log/trạng thái không được ghi');
246
303
  if (!reg.UserPromptSubmit) h.problems.push('thiếu hook UserPromptSubmit — chạy lại `dieu-phoi setup` để bỏ được lệnh này');
247
304
  if (!reg.permissions || reg.legacy_permissions) h.problems.push(`thiếu quyền ghi cho ${reg.permission_entries.dir} — chạy lại \`dieu-phoi setup\` (agent nền có thể không ghi được report)`);
248
305
  h.ok = !h.problems.length;
306
+ // core 0.6.0: project of --repo / cwd (file reads only; null outside a git repo)
307
+ let proj = null;
308
+ try { proj = core.resolveProject(repoOf(opts)); } catch (_) { /* no project */ }
309
+ info.project = proj ? { dir_name: proj.dir_name, key: proj.key, kind: proj.kind, dir: proj.dir, exists: proj.exists } : null;
249
310
  if (opts.json) return { code: 0, out: JSON.stringify(info, null, 2) };
250
311
  const out = [
251
312
  `root_task_id: ${info.id}`,
@@ -254,6 +315,7 @@ function cmdStart(opts) {
254
315
  `health: ${core.healthLine(h)}`,
255
316
  `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
256
317
  ...core.orchLessonLines(info),
318
+ ...core.knowledgeLines(info),
257
319
  ];
258
320
  return { code: 0, out: out.join('\n') };
259
321
  }
@@ -415,11 +477,49 @@ function verdictRoot(opts, ctx, events) {
415
477
  const judged = new Set(events.filter((e) => e.kind === 'verdict' && agg.normalizeVerdict(e).by === 'user').map((e) => e.task_id));
416
478
  const gone = agg.discardedTaskIds(events);
417
479
  const todo = steps.filter((x) => execTasks.has(x.task_id) && !judged.has(x.task_id) && !gone.has(x.task_id));
418
- if (!todo.length) throw new Error(`Không có bước nào của ${root} cần nghiệm thu (chưa giao exec, hoặc đã có verdict người dùng).`);
419
- return todo.map((x) => ({
480
+ const list = todo.map((x) => ({
420
481
  step: String(x.id),
421
482
  ev: verdictUser(Object.assign({}, opts, { task: x.task_id, _: ['accept'], round: undefined, cause: undefined }), ctx, events),
422
483
  }));
484
+ // No step waiting is not an error by itself: the root's own and untagged deltas still need merging (cmdVerdict decides).
485
+ if (!list.length) list.emptyMsg = `Không có bước nào của ${root} cần nghiệm thu (chưa giao exec, hoặc đã có verdict người dùng).`;
486
+ return list;
487
+ }
488
+
489
+ /** Root task of a step: the approved plan that lists it, else the task itself (a loose task is its own run). */
490
+ function rootOfTask(ctx, taskId) {
491
+ let files = [];
492
+ try { files = fs.readdirSync(ctx.paths.plans).filter((f) => f.endsWith('.json')); } catch (_) { return taskId; }
493
+ for (const f of files) {
494
+ const p = core.readJsonFile(path.join(ctx.paths.plans, f), null);
495
+ if (p && typeof p.root_task_id === 'string' && Array.isArray(p.steps) && p.steps.some((x) => x && x.task_id === taskId)) return p.root_task_id;
496
+ }
497
+ return taskId;
498
+ }
499
+
500
+ /**
501
+ * Gate 3 (core 0.6.0): a user accept merges the run's knowledge deltas of the accepted tasks
502
+ * (knowledge-delta-<task_id>[-r<n>].md; on --root also the root's own and untagged ones). Never fails the verdict:
503
+ * conflicts/rejections are reported with the command to retry. --no-knowledge skips it.
504
+ */
505
+ function knowledgeOnAccept(opts, ctx, root, tasks, untagged) {
506
+ if (opts['no-knowledge']) return null;
507
+ const kn = require('./knowledge.cjs');
508
+ const runDir = path.join(core.runsDir(), root);
509
+ const filter = { tasks: new Set(tasks), untagged };
510
+ try {
511
+ if (!fs.statSync(runDir).isDirectory() || !kn.deltaFiles(runDir, filter).length) return null;
512
+ } catch (_) { return null; }
513
+ const taskArg = tasks.join(',');
514
+ const retry = `dieu-phoi knowledge merge --run ${root} --task ${taskArg}${untagged ? ' --untagged' : ''}`;
515
+ let r;
516
+ try {
517
+ r = kn.cmdMerge({ _: [], run: runDir, task: taskArg, untagged, repo: opts.repo });
518
+ } catch (e) {
519
+ return { code: 1, out: `knowledge: gộp lỗi (${e.message}) — chạy lại: ${retry}` };
520
+ }
521
+ const tail = r.code ? `\n→ có mục chưa gộp: get lại mục đó, chép dòng base: mới vào delta (hoặc bỏ mục), rồi ${retry}` : '';
522
+ return { code: r.code, out: `knowledge (tự gộp khi accept):\n${r.out}${tail}` };
423
523
  }
424
524
 
425
525
  function cmdVerdict(opts) {
@@ -428,14 +528,33 @@ function cmdVerdict(opts) {
428
528
  if (typeof opts.root === 'string' && typeof opts.task !== 'string') {
429
529
  const list = verdictRoot(opts, ctx, events);
430
530
  for (const x of list) core.appendEvent(ctx, x.ev);
431
- if (opts.json) return { code: 0, out: JSON.stringify(list.map((x) => x.ev)) };
432
- return { code: 0, out: `Đã ghi người dùng accept cho ${list.length} bước của ${opts.root}: ${list.map((x) => `${x.step}=${x.ev.task_id}`).join(', ')}.` };
531
+ const root = String(opts.root);
532
+ const kn = knowledgeOnAccept(opts, ctx, root, [root].concat(list.map((x) => x.ev.task_id)), true);
533
+ if (!list.length) {
534
+ // Every step was already judged (e.g. accepted one by one with --task): still merge the root + untagged deltas.
535
+ // Exit code follows the merge (0 merged or nothing new, 2 conflict or reject, 1 error); no delta at all keeps the old "nothing to do" error.
536
+ if (!kn) throw new Error(list.emptyMsg);
537
+ const msg = `Không còn bước nào của ${root} chờ nghiệm thu; chỉ gộp delta của root và delta không thẻ.`;
538
+ if (opts.json) { process.stderr.write(`${msg}\n${kn.out}\n`); return { code: kn.code, out: '[]' }; }
539
+ return { code: kn.code, out: `${msg}\n${kn.out}` };
540
+ }
541
+ if (opts.json) {
542
+ if (kn) process.stderr.write(`${kn.out}\n`);
543
+ return { code: 0, out: JSON.stringify(list.map((x) => x.ev)) };
544
+ }
545
+ const msg = `Đã ghi người dùng accept cho ${list.length} bước của ${opts.root}: ${list.map((x) => `${x.step}=${x.ev.task_id}`).join(', ')}.`;
546
+ return { code: 0, out: kn ? `${msg}\n${kn.out}` : msg };
433
547
  }
434
548
  const ev = typeof opts.task === 'string' ? verdictUser(opts, ctx, events) : verdictAgent(opts, ctx, events);
435
549
  core.appendEvent(ctx, ev);
436
- if (opts.json) return { code: 0, out: JSON.stringify(ev) };
550
+ const kn = ev.by === 'user' && ev.outcome === 'accept' ? knowledgeOnAccept(opts, ctx, rootOfTask(ctx, ev.task_id), [ev.task_id], false) : null;
551
+ if (opts.json) {
552
+ if (kn) process.stderr.write(`${kn.out}\n`);
553
+ return { code: 0, out: JSON.stringify(ev) };
554
+ }
437
555
  if (ev.by === 'user') {
438
- return { code: 0, out: `Đã ghi người dùng ${ev.outcome}${ev.cause ? ` (${ev.cause})` : ''} cho task ${ev.task_id}, vòng ${ev.round}.` };
556
+ const msg = `Đã ghi người dùng ${ev.outcome}${ev.cause ? ` (${ev.cause})` : ''} cho task ${ev.task_id}, vòng ${ev.round}.`;
557
+ return { code: 0, out: kn ? `${msg}\n${kn.out}` : msg };
439
558
  }
440
559
  return { code: 0, out: `Đã ghi ${ev.outcome} cho ${ev.agent_id}${ev.task_id ? ` (task ${ev.task_id})` : ''}.` };
441
560
  }
@@ -553,17 +672,21 @@ function ctxWarnOnce(ctx, m) {
553
672
  const file = path.join(ctx.paths.data, 'ctx-warn.json');
554
673
  const level = ctxWarnLevel(m.ctx_growth, m.soft_cap);
555
674
  try {
556
- const st = core.readJsonFile(file, {}) || {};
557
- const cur = st[m.session_id];
558
- const comp = typeof m.compactions === 'number' ? m.compactions : null;
559
- const warn = !cur || cur.compactions !== comp || level > (Number(cur.level) || 0);
560
- if (warn) {
561
- const now = Date.now();
562
- st[m.session_id] = { level, compactions: comp, t: now };
563
- for (const k of Object.keys(st)) if (!(now - Number(st[k] && st[k].t) < CTX_WARN_KEEP_MS)) delete st[k];
564
- core.writeFileAtomic(file, `${JSON.stringify(st)}\n`);
565
- }
566
- return warn;
675
+ // Core 0.5.4: read-modify-write under ctx-warn.lock — two sessions warning at once no longer overwrite each
676
+ // other's entry (a lost entry made the warning repeat). Lock timeout => go on unlocked (state is advisory).
677
+ return core.withFileLock(ctx.paths.ctxWarnLock, { timeoutMs: 2000, staleMs: 15000 }, () => {
678
+ const st = core.readJsonFile(file, {}) || {};
679
+ const cur = st[m.session_id];
680
+ const comp = typeof m.compactions === 'number' ? m.compactions : null;
681
+ const warn = !cur || cur.compactions !== comp || level > (Number(cur.level) || 0);
682
+ if (warn) {
683
+ const now = Date.now();
684
+ st[m.session_id] = { level, compactions: comp, t: now };
685
+ for (const k of Object.keys(st)) if (!(now - Number(st[k] && st[k].t) < CTX_WARN_KEEP_MS)) delete st[k];
686
+ core.writeFileAtomic(file, `${JSON.stringify(st)}\n`);
687
+ }
688
+ return warn;
689
+ }).value;
567
690
  } catch (_) {
568
691
  return true;
569
692
  }
@@ -1071,6 +1194,6 @@ function cmdPlanTable(opts) {
1071
1194
  }
1072
1195
 
1073
1196
  module.exports = {
1074
- cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, recordPlanSuggestions, cmdStats, cmdRetro, cmdPrune: (o) => require('./prune.cjs').cmdPrune(o), cmdChoose, cmdCtx, cmdState, measureOrch,
1197
+ skillDirOf, cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, recordPlanSuggestions, cmdStats, cmdRetro, cmdPrune: (o) => require('./prune.cjs').cmdPrune(o), cmdChoose, cmdCtx, cmdState, measureOrch,
1075
1198
  cmdPlanTable, cmdNewId, requireTaskId, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
1076
1199
  };