@kienntpixon/dieu-phoi 0.3.0 → 0.5.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/lib/commands.cjs CHANGED
@@ -10,7 +10,7 @@ const chooser = require('./choose.cjs');
10
10
  const setupLib = require('./setup.cjs');
11
11
  const { claudeDirOf, ctxOf, yesNo } = require('./args.cjs');
12
12
 
13
- const AGENT_OUTCOMES = ['pass', 'fail', 'timeout'];
13
+ const AGENT_OUTCOMES = ['pass', 'fail', 'timeout', 'aborted'];
14
14
  const USER_OUTCOMES = ['accept', 'reject'];
15
15
  const CAUSES = ['quality', 'scope'];
16
16
  const STEER_KINDS = ['minor', 'plan', 'redirect'];
@@ -65,6 +65,7 @@ function cmdSetup(opts) {
65
65
  res.settings_changed
66
66
  ? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) + UserPromptSubmit trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
67
67
  : `settings.json đã có hook, không đổi gì (${res.settings})`,
68
+ `Agent dp-*: ${res.agents.map((a) => `${a.name}=${a.action}`).join(', ')}${res.agents.some((a) => a.action === 'skipped-foreign') ? ' (file trùng tên không phải của dieu-phoi: giữ nguyên, đổi tên rồi chạy lại setup)' : ''}`,
68
69
  `Thư mục báo cáo subagent: ${res.runs_dir} (quyền: ${res.permissions.allow.join(', ')} + additionalDirectories)`,
69
70
  `Phòng ban: ${res.config.dept || '(chưa đặt, dùng --dept)'}`,
70
71
  `Sink: ${res.config.sink_url ? res.config.sink_url : '(chưa có — log chỉ lưu cục bộ)'}`,
@@ -80,7 +81,7 @@ function cmdUninstall(opts) {
80
81
  return {
81
82
  code: 0,
82
83
  out: res.removed
83
- ? `Đã gỡ ${res.removed} hook khỏi ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}. Dữ liệu giữ ở ${res.data_kept}.`
84
+ ? `Đã gỡ ${res.removed} hook khỏi ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}${res.agents_removed.length ? `, agent ${res.agents_removed.join(', ')}` : ''}. Dữ liệu giữ ở ${res.data_kept}.`
84
85
  : `Không thấy hook dieu-phoi trong ${res.settings}.`,
85
86
  };
86
87
  }
@@ -105,6 +106,11 @@ async function cmdDoctor(opts) {
105
106
  add(permOk, 'run-permissions', permOk
106
107
  ? `${reg.permission_entries.allow.join(', ')} + additionalDirectories ${reg.permission_entries.dir}`
107
108
  : `${!reg.permissions ? `thiếu quyền cho thư mục báo cáo ${reg.permission_entries.dir} (subagent nền sẽ không ghi được)` : (!reg.runs_dir_exists ? `chưa có thư mục ${reg.runs_dir}` : 'còn quyền cũ cho ~/.claude/dieu-phoi/runs (đường dẫn được bảo vệ, không dùng được)')} — chạy dieu-phoi setup`, 'CẢNH BÁO');
109
+ // compare with the node binary setup wrote into settings.json (and the agent files), not this process's node: a doctor run
110
+ // under another node (nvm, upgrade) must not report a false "outdated"
111
+ const agentSt = setupLib.agentStatus(claudeDir, ctx.paths.hook, setupLib.nodePathOf(reg.commands[0]));
112
+ const agentsOk = agentSt.every((a) => a.state === 'ok');
113
+ add(agentsOk, 'agents', `${agentSt.map((a) => `${a.name}=${a.state}`).join(', ')}${agentsOk ? '' : ' — chạy `dieu-phoi setup` (foreign = file trùng tên không phải của dieu-phoi, không bị ghi đè)'}`, 'CẢNH BÁO');
108
114
  const hasCfg = fs.existsSync(ctx.paths.config);
109
115
  add(hasCfg, 'config', hasCfg ? `${ctx.paths.config} dept=${ctx.config.dept || '-'}` : 'chưa có — chạy `dieu-phoi setup`');
110
116
  const sd = ctx.config.skill_dir;
@@ -145,6 +151,15 @@ async function cmdDoctor(opts) {
145
151
  add(!badIds.length, 'task-ids', badIds.length
146
152
  ? `${badIds.length} dispatch trong ${INVALID_ID_WINDOW_DAYS} ngày gần đây có task_id sai định dạng (${Array.from(new Set(badIds.map((e) => e.task_id))).slice(0, 3).map((x) => `"${x}"`).join(', ')}) — task_id phải đúng 8 ký tự a-z0-9, lấy bằng \`dieu-phoi new-id\``
147
153
  : `mọi dispatch trong ${INVALID_ID_WINDOW_DAYS} ngày gần đây có task_id đúng định dạng`, 'CẢNH BÁO');
154
+ const tmp = sweepStaleTmp(ctx);
155
+ add(!tmp.left.length, 'tmp-files', tmp.left.length
156
+ ? `${tmp.left.length} file tạm còn mới (đang ghi?): ${tmp.left.slice(0, 3).join(', ')}`
157
+ : `không còn file *.tmp-* bỏ dở${tmp.removed ? ` (đã xoá ${tmp.removed} file cũ hơn 1 giờ)` : ''}`, 'CẢNH BÁO');
158
+ let badPlans = [];
159
+ try { badPlans = fs.readdirSync(ctx.paths.plans).filter((n) => n.endsWith('.json') && !core.isValidTaskId(n.slice(0, -5))); } catch (_) { /* none */ }
160
+ add(!badPlans.length, 'plans', badPlans.length
161
+ ? `${badPlans.length} kế hoạch có root id sai định dạng (${badPlans.slice(0, 3).join(', ')}) — \`state\` vẫn đọc; loại task bằng \`dieu-phoi discard --task <id>\` hoặc xoá file trong ${ctx.paths.plans}`
162
+ : 'mọi kế hoạch đã duyệt có root id đúng định dạng', 'CẢNH BÁO');
148
163
  const orch = measureOrch(opts, ctx, profileOrNull(opts, ctx));
149
164
  if (orch.ctx_tokens !== null) {
150
165
  add(!orch.over, 'orch-ctx', `session ${orch.session_id}: ${orch.ctx_tokens} token, tăng ${orch.ctx_growth}/${orch.soft_cap}, ≈ ${fmtUsd(orch.cum_usd)} — ${orch.advice}`, 'CẢNH BÁO');
@@ -166,6 +181,33 @@ async function cmdDoctor(opts) {
166
181
  return { code: failed ? 1 : 0, out };
167
182
  }
168
183
 
184
+ const TMP_RE = /\.tmp-\d+-[0-9a-f]+$/;
185
+ const TMP_STALE_MS = 3600 * 1000;
186
+
187
+ /**
188
+ * Leftovers of writeFileAtomic (tmp written, rename never happened: process killed, or rename lost a race on
189
+ * Windows) under data/, data/orch, data/segments, data/plans. Older than 1 h → removed; newer → reported.
190
+ */
191
+ function sweepStaleTmp(ctx) {
192
+ let removed = 0;
193
+ const left = [];
194
+ const now = Date.now();
195
+ for (const dir of [ctx.paths.data, path.join(ctx.paths.data, 'orch'), ctx.paths.segments, ctx.paths.plans]) {
196
+ let names = [];
197
+ try { names = fs.readdirSync(dir); } catch (_) { continue; }
198
+ for (const n of names) {
199
+ if (!TMP_RE.test(n)) continue;
200
+ const f = path.join(dir, n);
201
+ try {
202
+ const st = fs.statSync(f);
203
+ if (!st.isFile()) continue; // a directory (or anything else) with a tmp-like name: not ours, leave it alone
204
+ if (now - st.mtimeMs > TMP_STALE_MS) { fs.unlinkSync(f); removed++; } else left.push(n);
205
+ } catch (_) { left.push(n); }
206
+ }
207
+ }
208
+ return { removed, left };
209
+ }
210
+
169
211
  /** Live sink check (view=stats); result cached in data/sink-check.json for 24 h. */
170
212
  async function checkSinkLive(ctx) {
171
213
  let ok = false;
@@ -211,6 +253,7 @@ function cmdStart(opts) {
211
253
  `step_ids: ${info.step_ids.join(' ')}`,
212
254
  `health: ${core.healthLine(h)}`,
213
255
  `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
256
+ ...core.orchLessonLines(info),
214
257
  ];
215
258
  return { code: 0, out: out.join('\n') };
216
259
  }
@@ -248,7 +291,10 @@ function resolveAgentId(events, ref, now) {
248
291
  const verdicted = new Set();
249
292
  events.forEach((e, i) => {
250
293
  if (!e.agent_id) return;
251
- if (e.kind === 'verdict' && agg.normalizeVerdict(e).by === 'agent') verdicted.add(e.agent_id);
294
+ if (e.kind === 'verdict') {
295
+ const nv = agg.normalizeVerdict(e);
296
+ if (nv.by === 'agent' && nv.outcome !== agg.ABORTED) verdicted.add(e.agent_id); // aborted still needs a verdict
297
+ }
252
298
  if (e.kind !== 'complete') return;
253
299
  const t = Date.parse(e.ts) || 0;
254
300
  const cur = latest.get(e.agent_id);
@@ -285,8 +331,9 @@ function kebab(s) {
285
331
  function verdictAgent(opts, ctx, events) {
286
332
  const [ref, outcome] = opts._;
287
333
  if (!ref || !outcome) {
288
- throw new Error('Cú pháp: dieu-phoi verdict <agent_id|last> <pass|fail|timeout> [--verify V] [--reason R]\n'
289
- + ' dieu-phoi verdict --task <task_id> <accept|reject> [--cause quality|scope] [--round n]');
334
+ throw new Error('Cú pháp: dieu-phoi verdict <agent_id|last> <pass|fail|timeout|aborted> [--verify V] [--reason R]\n'
335
+ + ' dieu-phoi verdict --task <task_id> <accept|reject> [--cause quality|scope] [--round n]\n'
336
+ + ' dieu-phoi verdict --root <root_task_id> accept [--steps a,b]');
290
337
  }
291
338
  if (outcome === 'rework') {
292
339
  throw new Error('"rework" đã bỏ. Người dùng không nhận kết quả → dieu-phoi verdict --task <task_id> reject --cause quality');
@@ -344,9 +391,46 @@ function verdictUser(opts, ctx, events) {
344
391
  }, core.commonFields(ctx));
345
392
  }
346
393
 
394
+ /**
395
+ * `verdict --root <root> accept [--steps a,b]` (core 0.4.0): the user accepts the steps of the latest approved plan
396
+ * in one call. Covers every step whose task has an exec dispatch and no user verdict yet (a step already rejected
397
+ * or accepted is left alone: reject the bad ones per task first, then accept the rest by root).
398
+ */
399
+ function verdictRoot(opts, ctx, events) {
400
+ const root = requireTaskId(String(opts.root));
401
+ if (opts._[0] !== 'accept') {
402
+ throw new Error('verdict --root chỉ nhận accept; bước không đạt → dieu-phoi verdict --task <task_id> reject --cause quality|scope');
403
+ }
404
+ const plan = core.readJsonFile(path.join(ctx.paths.plans, `${root}.json`), null);
405
+ if (!plan || !Array.isArray(plan.steps)) throw new Error(`Không thấy kế hoạch đã duyệt của root ${root} (plan-table --approve --root ${root}).`);
406
+ let steps = plan.steps.filter((x) => x && x.task_id);
407
+ if (typeof opts.steps === 'string') {
408
+ const want = new Set(opts.steps.split(',').map((x) => x.trim()).filter(Boolean));
409
+ const ids = new Set(steps.map((x) => String(x.id)));
410
+ const missing = Array.from(want).filter((x) => !ids.has(x));
411
+ if (missing.length) throw new Error(`--steps: không có bước ${missing.join(', ')} trong kế hoạch ${root}`);
412
+ steps = steps.filter((x) => want.has(String(x.id)));
413
+ }
414
+ const execTasks = new Set(events.filter((e) => e.kind === 'dispatch' && (e.role || 'exec') === 'exec').map((e) => e.task_id));
415
+ const judged = new Set(events.filter((e) => e.kind === 'verdict' && agg.normalizeVerdict(e).by === 'user').map((e) => e.task_id));
416
+ const gone = agg.discardedTaskIds(events);
417
+ 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) => ({
420
+ step: String(x.id),
421
+ ev: verdictUser(Object.assign({}, opts, { task: x.task_id, _: ['accept'], round: undefined, cause: undefined }), ctx, events),
422
+ }));
423
+ }
424
+
347
425
  function cmdVerdict(opts) {
348
426
  const ctx = ctxOf(opts);
349
427
  const events = core.readEvents(ctx);
428
+ if (typeof opts.root === 'string' && typeof opts.task !== 'string') {
429
+ const list = verdictRoot(opts, ctx, events);
430
+ 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(', ')}.` };
433
+ }
350
434
  const ev = typeof opts.task === 'string' ? verdictUser(opts, ctx, events) : verdictAgent(opts, ctx, events);
351
435
  core.appendEvent(ctx, ev);
352
436
  if (opts.json) return { code: 0, out: JSON.stringify(ev) };
@@ -356,6 +440,29 @@ function cmdVerdict(opts) {
356
440
  return { code: 0, out: `Đã ghi ${ev.outcome} cho ${ev.agent_id}${ev.task_id ? ` (task ${ev.task_id})` : ''}.` };
357
441
  }
358
442
 
443
+ // ------------------------------------------------------------------ discard
444
+
445
+ /**
446
+ * `dieu-phoi discard --task <task_id> [--reason R]` (core 0.4.0): withdraw a mistaken task. Appends one
447
+ * `discard` event (synced like any other); stats, choose, state and the sink then leave out every event of the
448
+ * task and of its agents. events.jsonl is never edited — a hand edit once lost a dispatch that the sink kept.
449
+ */
450
+ function cmdDiscard(opts) {
451
+ if (typeof opts.task !== 'string') throw new Error('Cú pháp: dieu-phoi discard --task <task_id> [--reason R]');
452
+ const taskId = requireTaskId(opts.task);
453
+ const ctx = ctxOf(opts);
454
+ const events = core.readEvents(ctx);
455
+ if (!events.some((e) => e.task_id === taskId)) throw new Error(`Không thấy task "${taskId}" trong log.`);
456
+ if (agg.discardedTaskIds(events).has(taskId)) return { code: 0, out: opts.json ? JSON.stringify({ already: true, task_id: taskId }) : `Task ${taskId} đã bị loại trước đó.` };
457
+ const ev = Object.assign({
458
+ schema: core.SCHEMA, kind: 'discard', ts: core.nowIso(), agent_id: `task:${taskId}`, task_id: taskId, reason_code: kebab(opts.reason),
459
+ }, core.commonFields(ctx));
460
+ core.appendEvent(ctx, ev);
461
+ const n = events.filter((e) => e.task_id === taskId).length;
462
+ if (opts.json) return { code: 0, out: JSON.stringify(ev) };
463
+ return { code: 0, out: `Đã loại task ${taskId} (${n} bản ghi) khỏi thống kê, choose và state. Log không bị sửa; sink nhận bản ghi discard ở lần sync kế tiếp.` };
464
+ }
465
+
359
466
  // ------------------------------------------------------------------ steer
360
467
 
361
468
  /**
@@ -383,7 +490,8 @@ function cmdSteer(opts) {
383
490
  const taskId = String(opts.task);
384
491
  const mine = agents.filter((a) => a.task_id === taskId);
385
492
  if (!mine.length && !events.some((e) => e.task_id === taskId)) throw new Error(`Không thấy task ${taskId} trong log`);
386
- const inFlight = mine.filter((a) => a.dispatched && !(a.completed && a.outcome));
493
+ // core 0.4.0: verifiers are never marked (a steer is about the work, not its check)
494
+ const inFlight = mine.filter((a) => a.dispatched && a.role !== 'verify' && !(a.completed && a.outcome));
387
495
  const type = (mine.find((a) => a.role === 'exec') || mine[0] || {}).task_type || null;
388
496
  out = inFlight.length ? inFlight.map((a) => mk(a.agent_id, taskId, a.task_type)) : [mk(`task:${taskId}`, taskId, type)];
389
497
  } else {
@@ -427,6 +535,40 @@ function measureOrch(opts, ctx, prof) {
427
535
  };
428
536
  }
429
537
 
538
+ const CTX_WARN_KEEP_MS = 14 * 24 * 3600 * 1000;
539
+
540
+ /** 0 = within the cap; 1 = over the cap, 2 = over 2×, 3 = over 4×, … */
541
+ function ctxWarnLevel(growth, cap) {
542
+ if (!(typeof growth === 'number' && cap > 0 && growth > cap)) return 0;
543
+ return 1 + Math.floor(Math.log2(growth / cap));
544
+ }
545
+
546
+ /**
547
+ * Core 0.4.0: the over-cap advice fires once per threshold crossing (cap, 2×, 4×, …) per session, and again after
548
+ * a /compact that left the session over. State: data/ctx-warn.json {session_id: {level, compactions, t}}.
549
+ * Never throws (no state → warn).
550
+ */
551
+ function ctxWarnOnce(ctx, m) {
552
+ if (!m || !m.over || !m.session_id) return false;
553
+ const file = path.join(ctx.paths.data, 'ctx-warn.json');
554
+ const level = ctxWarnLevel(m.ctx_growth, m.soft_cap);
555
+ 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;
567
+ } catch (_) {
568
+ return true;
569
+ }
570
+ }
571
+
430
572
  function profileOrNull(opts, ctx) {
431
573
  try { return chooser.loadProfile(skillDirOf(opts, ctx)); } catch (_) { return null; }
432
574
  }
@@ -508,9 +650,9 @@ function cmdStats(opts) {
508
650
  c.note,
509
651
  ]);
510
652
  }
511
- const s = [['task_type', 'task', 'task có steer', 'lần steer', 'minor', 'plan', 'redirect', 'tỉ lệ steer']];
653
+ const s = [['task_type', 'task', 'task có steer', 'lần steer', 'minor', 'plan', 'redirect', 'tỉ lệ steer', 'ngoài exec (không tính)']];
512
654
  for (const r of steerRows) {
513
- s.push([r.task_type, r.tasks, r.steered_tasks, r.actions, r.minor, r.plan, r.redirect, pct(r.steer_rate)]);
655
+ s.push([r.task_type, r.tasks, r.steered_tasks, r.actions, r.minor, r.plan, r.redirect, pct(r.steer_rate), r.plan_phase_actions || 0]);
514
656
  }
515
657
  return {
516
658
  code: 0,
@@ -534,6 +676,7 @@ function cmdRetro(opts) {
534
676
  const userOutcomes = {};
535
677
  for (const a of agents) {
536
678
  if (a.outcome) outcomes[a.outcome] = (outcomes[a.outcome] || 0) + 1;
679
+ if (a.aborted) outcomes.aborted = (outcomes.aborted || 0) + 1;
537
680
  if (a.reason_code) reasons[a.reason_code] = (reasons[a.reason_code] || 0) + 1;
538
681
  }
539
682
  for (const v of userVerdicts) {
@@ -565,6 +708,7 @@ function cmdRetro(opts) {
565
708
  no_verdict_agents: agents.filter((a) => a.completed && !a.outcome && a.role !== 'verify').length,
566
709
  no_user_verdict_tasks: tasks.filter((t) => !t.user_verdicts.length).length,
567
710
  multi_attempt_tasks: escalations,
711
+ discarded_tasks: agg.discardedTaskIds(events).size,
568
712
  overrides: overrides.length,
569
713
  cost_usd: agents.reduce((s, a) => s + (a.cost_usd || 0), 0),
570
714
  },
@@ -614,6 +758,19 @@ function chooseInput(opts, ctx, events) {
614
758
  };
615
759
  }
616
760
 
761
+ function overrideEvent(ctx, taskId, taskType, suggested, chosen) {
762
+ return Object.assign({
763
+ schema: core.SCHEMA,
764
+ kind: 'override',
765
+ ts: core.nowIso(),
766
+ agent_id: taskId ? `task:${taskId}` : `override-${crypto.randomBytes(6).toString('hex')}`,
767
+ task_id: taskId || null,
768
+ task_type: taskType,
769
+ suggested_tier: suggested,
770
+ chosen_tier: chosen,
771
+ }, core.commonFields(ctx));
772
+ }
773
+
617
774
  function cmdChoose(opts) {
618
775
  const ctx = ctxOf(opts);
619
776
  if (opts.override !== undefined && typeof opts.task !== 'string') {
@@ -626,21 +783,14 @@ function cmdChoose(opts) {
626
783
  res.orch_ctx_tokens = orch.ctx_tokens;
627
784
  res.orch_ctx_growth = orch.ctx_growth;
628
785
  res.orch_ctx_over = orch.over;
786
+ res.orch_ctx_warn = ctxWarnOnce(ctx, orch); // over AND a new threshold crossed since the last warning
629
787
  if (res.overridden) {
630
788
  const tid = input.taskId;
631
- core.appendEvent(ctx, Object.assign({
632
- schema: core.SCHEMA,
633
- kind: 'override',
634
- ts: core.nowIso(),
635
- agent_id: tid ? `task:${tid}` : `override-${require('crypto').randomBytes(6).toString('hex')}`,
636
- task_id: tid,
637
- task_type: String(input.type).toLowerCase(),
638
- suggested_tier: res.suggested_tier,
639
- chosen_tier: res.tier,
640
- }, core.commonFields(ctx)));
789
+ core.appendEvent(ctx, overrideEvent(ctx, tid, res.task_type || String(input.type).toLowerCase(), res.suggested_tier, res.tier));
641
790
  }
642
791
  if (opts.json) return { code: 0, out: JSON.stringify(res) };
643
- const warn = orch.over ? `\nCẢNH BÁO: ${orch.advice}` : '';
792
+ const typeWarn = res.type_warning && !String(res.reason).includes(res.type_warning) ? `\nCẢNH BÁO: ${res.type_warning}` : '';
793
+ const warn = `${typeWarn}${res.orch_ctx_warn ? `\nCẢNH BÁO: ${orch.advice}` : ''}`;
644
794
  const est =`~${fmtUsd(res.est_cost_usd)} · ~${fmtNum(res.est_time_min)} phút (${res.est_source === 'data' ? 'dữ liệu' : 'ước lượng thô'})`;
645
795
  if (res.action === 'ask_user') {
646
796
  return { code: 0, out: `HỎI NGƯỜI DÙNG — ${res.reason}${warn}` };
@@ -775,9 +925,35 @@ function planTaskIdProblems(plan, root) {
775
925
  const same = plan.steps.filter((r) => r.task_id === root).map((r) => r.step);
776
926
  if (same.length) problems.push(`task_id của bước ${same.join(', ')} trùng --root "${root}" (root dành cho scout/planner)`);
777
927
  }
928
+ // core 0.4.0: a label outside the fixed list (and without an alias) would never pool its stats
929
+ for (const r of plan.steps) if (r.unknown_type) problems.push(`bước ${r.step}: ${r.type_warning}`);
778
930
  return problems;
779
931
  }
780
932
 
933
+ /** Mapped ad-hoc labels (bug-fix → debug, …): not an error, but the tag must carry the mapped type. */
934
+ function planTypeNotes(plan) {
935
+ return plan.steps.filter((r) => r.mapped_from).map((r) => `bước ${r.step}: ${r.type_warning}`);
936
+ }
937
+
938
+ /**
939
+ * Overrides chosen in the steps file (step `override`) become override events when the plan is approved —
940
+ * also on a re-approval that changes a step's tier — so the override learning sees them (core 0.4.0; before,
941
+ * only `choose --override` logged one). One event per (task_id, chosen tier): re-approving the same tier or a
942
+ * `choose --override` already logged adds nothing.
943
+ */
944
+ function planOverrideEvents(ctx, plan, events) {
945
+ const seen = new Set(events.filter((e) => e.kind === 'override').map((e) => `${e.task_id}|${e.chosen_tier}`));
946
+ const out = [];
947
+ for (const r of plan.steps) {
948
+ if (!r.overridden || !r.task_id || !r.tier || !r.suggested_tier) continue;
949
+ const k = `${r.task_id}|${r.tier}`;
950
+ if (seen.has(k)) continue;
951
+ seen.add(k);
952
+ out.push(overrideEvent(ctx, r.task_id, r.task_type || r.type, r.suggested_tier, r.tier));
953
+ }
954
+ return out;
955
+ }
956
+
781
957
  function recordApprovedPlan(ctx, root, stepsFile, plan) {
782
958
  const file = path.join(ctx.paths.plans, `${root}.json`);
783
959
  const prev = core.readJsonFile(file, null);
@@ -793,7 +969,7 @@ function recordApprovedPlan(ctx, root, stepsFile, plan) {
793
969
  approved_at: core.nowIso(),
794
970
  approval: prevApproval(prev) + 1,
795
971
  steps: plan.steps.map((r) => ({
796
- id: r.step, task_id: r.task_id || null, title: r.title === undefined ? null : r.title, type: r.type || null,
972
+ id: r.step, task_id: r.task_id || null, title: r.title === undefined ? null : r.title, type: r.task_type || r.type || null,
797
973
  tier: r.action === 'refused' ? null : (r.tier || null), depends_on: r.depends_on,
798
974
  })),
799
975
  history,
@@ -840,9 +1016,15 @@ function cmdPlanTable(opts) {
840
1016
  throw new Error(`plan-table --approve: không ghi kế hoạch — ${problems.join('; ')}. Mỗi bước cần task_id riêng (8 ký tự a-z0-9, lấy bằng \`dieu-phoi new-id\`), khác --root.`);
841
1017
  }
842
1018
  if (problems.length) process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${problems.join('; ')} (cần sửa trước khi --approve)\n`);
1019
+ const typeNotes = planTypeNotes(plan);
1020
+ if (typeNotes.length) process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${typeNotes.join('; ')}\n`);
843
1021
  const rec = opts.approve ? recordApprovedPlan(ctx, String(opts.root), path.resolve(opts.file), plan) : null;
1022
+ const ovs = rec ? planOverrideEvents(ctx, plan, base.events) : [];
1023
+ for (const ev of ovs) core.appendEvent(ctx, ev);
844
1024
  if (opts.json) {
845
- const out = rec ? Object.assign({}, plan, { approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file } }) : plan;
1025
+ const out = rec ? Object.assign({}, plan, {
1026
+ approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file, overrides_logged: ovs.length },
1027
+ }) : plan;
846
1028
  return { code: 0, out: JSON.stringify(out, null, 2) };
847
1029
  }
848
1030
  const lines = ['| Bước | Model/effort | Rubric (điểm · kiểm chứng · đảo ngược) | Lý do | Ước tính |', '|---|---|---|---|---|'];
@@ -862,11 +1044,12 @@ function cmdPlanTable(opts) {
862
1044
  process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${plan.refused.length} bước bị từ chối
863
1045
  `);
864
1046
  }
865
- if (rec) lines.push('', `Đã ghi kế hoạch đã duyệt (root ${rec.root_task_id}, lần duyệt ${rec.approval})`);
1047
+ if (typeNotes.length) lines.push('', `**Loại task:** ${typeNotes.join('; ')}`);
1048
+ if (rec) lines.push('', `Đã ghi kế hoạch đã duyệt (root ${rec.root_task_id}, lần duyệt ${rec.approval})${ovs.length ? `; ghi ${ovs.length} override` : ''}`);
866
1049
  return { code: 0, out: lines.join('\n') };
867
1050
  }
868
1051
 
869
1052
  module.exports = {
870
- cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
1053
+ cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
871
1054
  cmdPlanTable, cmdNewId, requireTaskId, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
872
1055
  };
package/lib/setup.cjs CHANGED
@@ -245,6 +245,71 @@ function skillVersionOf(dir) {
245
245
  }
246
246
  }
247
247
 
248
+ // ------------------------------------------------------------------ dp-* agent files (core 0.5.0)
249
+
250
+ const AGENT_NAMES = ['dp-exec', 'dp-verify', 'dp-scout', 'dp-helper'];
251
+ const AGENT_MARKER = '# dieu-phoi-managed:';
252
+ const AGENT_HOOK_PLACEHOLDER = '__DP_HOOK__';
253
+
254
+ function agentsDirOf(claudeDir) {
255
+ return path.join(path.resolve(claudeDir), 'agents');
256
+ }
257
+
258
+ /** Agent file content for this install: template with the absolute hook command (YAML single-quoted). */
259
+ function renderAgent(name, hookPath, nodePath) {
260
+ const tpl = fs.readFileSync(path.join(PKG_ROOT, 'agents', `${name}.md`), 'utf8').replace(/\r\n/g, '\n');
261
+ const cmd = hookCommand(hookPath, nodePath).replace(/'/g, "''");
262
+ return tpl.split(AGENT_HOOK_PLACEHOLDER).join(cmd);
263
+ }
264
+
265
+ /** Node binary of a registered hook command (`"<node>" "<hook>" ...`), or undefined. */
266
+ function nodePathOf(cmd) {
267
+ const m = typeof cmd === 'string' ? /^\s*"([^"]+)"\s+"[^"]+"/.exec(cmd) : null;
268
+ return m ? m[1] : undefined;
269
+ }
270
+
271
+ /**
272
+ * State of each dp-* agent file: ok (ours, identical), outdated (ours, differs), missing, foreign (a file with
273
+ * that name we did not write — never touched).
274
+ */
275
+ function agentStatus(claudeDir, hookPath, nodePath) {
276
+ const dir = agentsDirOf(claudeDir);
277
+ return AGENT_NAMES.map((name) => {
278
+ const file = path.join(dir, `${name}.md`);
279
+ let cur = null;
280
+ try { cur = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); } catch (_) { /* missing */ }
281
+ let state = 'missing';
282
+ if (cur !== null) {
283
+ if (!cur.includes(AGENT_MARKER)) state = 'foreign';
284
+ else state = cur === renderAgent(name, hookPath, nodePath) ? 'ok' : 'outdated';
285
+ }
286
+ return { name, file, state };
287
+ });
288
+ }
289
+
290
+ /** Write missing/outdated dp-* agent files (LF). Foreign files are reported, never overwritten. */
291
+ function installAgents(claudeDir, hookPath, nodePath) {
292
+ const dir = agentsDirOf(claudeDir);
293
+ fs.mkdirSync(dir, { recursive: true });
294
+ return agentStatus(claudeDir, hookPath, nodePath).map((a) => {
295
+ if (a.state === 'ok' || a.state === 'foreign') return Object.assign({ action: a.state === 'ok' ? 'unchanged' : 'skipped-foreign' }, a);
296
+ core.writeFileAtomic(a.file, renderAgent(a.name, hookPath, nodePath));
297
+ return Object.assign({}, a, { action: a.state === 'missing' ? 'installed' : 'updated', state: 'ok' });
298
+ });
299
+ }
300
+
301
+ /** Remove only our dp-* agent files (marker present). */
302
+ function removeAgents(claudeDir) {
303
+ const removed = [];
304
+ for (const name of AGENT_NAMES) {
305
+ const file = path.join(agentsDirOf(claudeDir), `${name}.md`);
306
+ try {
307
+ if (fs.readFileSync(file, 'utf8').includes(AGENT_MARKER)) { fs.unlinkSync(file); removed.push(name); }
308
+ } catch (_) { /* missing */ }
309
+ }
310
+ return removed;
311
+ }
312
+
248
313
  /**
249
314
  * Install: copy hook + pricing, register hooks, write config.
250
315
  * opts: { claudeDir, dept, sinkUrl, sinkToken, skillDir }
@@ -302,8 +367,11 @@ function setup(opts) {
302
367
  if (legacyRemoved) dirty = true;
303
368
  if (addOurPermissions(obj, perms)) dirty = true;
304
369
  if (dirty) res = writeSettings(settingsFile, before, obj);
370
+ // 5. dp-* agent types (~/.claude/agents): only used when the orchestrator dispatches with subagent_type dp-*
371
+ const agents = installAgents(claudeDir, paths.hook, opts.nodePath);
305
372
 
306
373
  return {
374
+ agents,
307
375
  home,
308
376
  hook: paths.hook,
309
377
  command: cmd,
@@ -329,13 +397,14 @@ function uninstall(opts) {
329
397
  + stripOurPermissions(obj, permissionEntries(legacyRunsDirOf(claudeDir)));
330
398
  let res = { changed: false, backup: null };
331
399
  if (removed || permsRemoved) res = writeSettings(settingsFile, before, obj);
400
+ const agentsRemoved = removeAgents(claudeDir);
332
401
  const hookFile = core.pathsFor(home).hook;
333
402
  let hookRemoved = false;
334
403
  if (opts.purge && fs.existsSync(hookFile)) {
335
404
  fs.unlinkSync(hookFile);
336
405
  hookRemoved = true;
337
406
  }
338
- return { removed, permissions_removed: permsRemoved, settings: settingsFile, backup: res.backup, hook_removed: hookRemoved, data_kept: core.pathsFor(home).data };
407
+ return { removed, agents_removed: agentsRemoved, permissions_removed: permsRemoved, settings: settingsFile, backup: res.backup, hook_removed: hookRemoved, data_kept: core.pathsFor(home).data };
339
408
  }
340
409
 
341
410
  /** Inspect settings for our registration. */
@@ -369,6 +438,6 @@ function registration(claudeDir) {
369
438
  }
370
439
 
371
440
  module.exports = {
372
- MARKER, setup, uninstall, registration, stripOurHooks, addOurHooks, alreadyRegistered, hookCommand,
441
+ MARKER, AGENT_NAMES, AGENT_MARKER, renderAgent, agentStatus, nodePathOf, installAgents, removeAgents, agentsDirOf, setup, uninstall, registration, stripOurHooks, addOurHooks, alreadyRegistered, hookCommand,
373
442
  skillVersionOf, findSkillDir, tsStamp, runsDir, legacyRunsDirOf, permissionEntries, addOurPermissions, stripOurPermissions, hasOurPermissions,
374
443
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kienntpixon/dieu-phoi",
3
- "version": "0.3.0",
3
+ "version": "0.5.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>",
@@ -20,6 +20,7 @@
20
20
  "main": "hook.cjs",
21
21
  "files": [
22
22
  "bin/",
23
+ "agents/",
23
24
  "lib/",
24
25
  "hook.cjs",
25
26
  "pricing.json",