@kienntpixon/dieu-phoi 0.2.2 → 0.4.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'];
@@ -63,7 +63,7 @@ function cmdSetup(opts) {
63
63
  const lines = [
64
64
  `Đã cài hook: ${res.hook}`,
65
65
  res.settings_changed
66
- ? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
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
68
  `Thư mục báo cáo subagent: ${res.runs_dir} (quyền: ${res.permissions.allow.join(', ')} + additionalDirectories)`,
69
69
  `Phòng ban: ${res.config.dept || '(chưa đặt, dùng --dept)'}`,
@@ -98,6 +98,9 @@ async function cmdDoctor(opts) {
98
98
  const reg = setupLib.registration(claudeDir);
99
99
  add(reg.PostToolUse && reg.SubagentStop && reg.SessionStart, 'settings',
100
100
  `PostToolUse(Agent)=${reg.PostToolUse ? 'có' : 'thiếu'}, SubagentStop=${reg.SubagentStop ? 'có' : 'thiếu'}, SessionStart(compact)=${reg.SessionStart ? 'có' : 'thiếu'} (${reg.file})`);
101
+ add(reg.UserPromptSubmit, 'prompt-hook', reg.UserPromptSubmit
102
+ ? 'UserPromptSubmit có — prompt "điều phối" được tạo sẵn task_id + run dir, lượt đầu là scout'
103
+ : 'thiếu hook UserPromptSubmit — mỗi lần điều phối phải chạy `dieu-phoi start` trước scout; chạy lại `dieu-phoi setup`', 'CẢNH BÁO');
101
104
  const permOk = reg.permissions && reg.runs_dir_exists && !reg.legacy_permissions;
102
105
  add(permOk, 'run-permissions', permOk
103
106
  ? `${reg.permission_entries.allow.join(', ')} + additionalDirectories ${reg.permission_entries.dir}`
@@ -142,22 +145,26 @@ async function cmdDoctor(opts) {
142
145
  add(!badIds.length, 'task-ids', badIds.length
143
146
  ? `${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\``
144
147
  : `mọi dispatch trong ${INVALID_ID_WINDOW_DAYS} ngày gần đây có task_id đúng định dạng`, 'CẢNH BÁO');
148
+ const tmp = sweepStaleTmp(ctx);
149
+ add(!tmp.left.length, 'tmp-files', tmp.left.length
150
+ ? `${tmp.left.length} file tạm còn mới (đang ghi?): ${tmp.left.slice(0, 3).join(', ')}`
151
+ : `không còn file *.tmp-* bỏ dở${tmp.removed ? ` (đã xoá ${tmp.removed} file cũ hơn 1 giờ)` : ''}`, 'CẢNH BÁO');
152
+ let badPlans = [];
153
+ try { badPlans = fs.readdirSync(ctx.paths.plans).filter((n) => n.endsWith('.json') && !core.isValidTaskId(n.slice(0, -5))); } catch (_) { /* none */ }
154
+ add(!badPlans.length, 'plans', badPlans.length
155
+ ? `${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}`
156
+ : 'mọi kế hoạch đã duyệt có root id đúng định dạng', 'CẢNH BÁO');
145
157
  const orch = measureOrch(opts, ctx, profileOrNull(opts, ctx));
146
158
  if (orch.ctx_tokens !== null) {
147
159
  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');
148
160
  }
149
161
  if (ctx.config.sink_url && ctx.config.sink_token) {
150
- try {
151
- const u = new URL(ctx.config.sink_url);
152
- u.searchParams.set('token', ctx.config.sink_token);
153
- u.searchParams.set('view', 'stats');
154
- const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(8000) });
155
- const txt = await res.text();
156
- let ok = res.ok;
157
- try { const b = JSON.parse(txt); if (b && b.ok === false) ok = false; } catch (_) { ok = false; }
158
- add(ok, 'sink', `${ctx.config.sink_url} → HTTP ${res.status}`);
159
- } catch (e) {
160
- add(false, 'sink', `${ctx.config.sink_url} → ${e.message}`);
162
+ const cached = opts.net ? null : core.sinkStatusCached(ctx);
163
+ if (cached && (cached.state === 'ok' || cached.state === 'fail')) {
164
+ 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)`);
165
+ } else {
166
+ const live = await checkSinkLive(ctx);
167
+ add(live.ok, 'sink', `${ctx.config.sink_url} → ${live.detail}`);
161
168
  }
162
169
  } else {
163
170
  add(true, 'sink', 'chưa cấu hình (chỉ lưu cục bộ)');
@@ -168,6 +175,83 @@ async function cmdDoctor(opts) {
168
175
  return { code: failed ? 1 : 0, out };
169
176
  }
170
177
 
178
+ const TMP_RE = /\.tmp-\d+-[0-9a-f]+$/;
179
+ const TMP_STALE_MS = 3600 * 1000;
180
+
181
+ /**
182
+ * Leftovers of writeFileAtomic (tmp written, rename never happened: process killed, or rename lost a race on
183
+ * Windows) under data/, data/orch, data/segments, data/plans. Older than 1 h → removed; newer → reported.
184
+ */
185
+ function sweepStaleTmp(ctx) {
186
+ let removed = 0;
187
+ const left = [];
188
+ const now = Date.now();
189
+ for (const dir of [ctx.paths.data, path.join(ctx.paths.data, 'orch'), ctx.paths.segments, ctx.paths.plans]) {
190
+ let names = [];
191
+ try { names = fs.readdirSync(dir); } catch (_) { continue; }
192
+ for (const n of names) {
193
+ if (!TMP_RE.test(n)) continue;
194
+ const f = path.join(dir, n);
195
+ try {
196
+ const st = fs.statSync(f);
197
+ if (!st.isFile()) continue; // a directory (or anything else) with a tmp-like name: not ours, leave it alone
198
+ if (now - st.mtimeMs > TMP_STALE_MS) { fs.unlinkSync(f); removed++; } else left.push(n);
199
+ } catch (_) { left.push(n); }
200
+ }
201
+ }
202
+ return { removed, left };
203
+ }
204
+
205
+ /** Live sink check (view=stats); result cached in data/sink-check.json for 24 h. */
206
+ async function checkSinkLive(ctx) {
207
+ let ok = false;
208
+ let detail;
209
+ try {
210
+ const u = new URL(ctx.config.sink_url);
211
+ u.searchParams.set('token', ctx.config.sink_token);
212
+ u.searchParams.set('view', 'stats');
213
+ const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(8000) });
214
+ const txt = await res.text();
215
+ ok = res.ok;
216
+ try { const b = JSON.parse(txt); if (b && b.ok === false) ok = false; } catch (_) { ok = false; }
217
+ detail = `HTTP ${res.status}`;
218
+ } catch (e) {
219
+ detail = e.message;
220
+ }
221
+ try {
222
+ core.writeFileAtomic(ctx.paths.sinkCheck, `${JSON.stringify({ ts: core.nowIso(), ok, detail, url: core.sinkUrlKey(ctx.config) })}\n`);
223
+ } catch (_) { /* cache is best effort */ }
224
+ return { ok, detail };
225
+ }
226
+
227
+ // ------------------------------------------------------------------ start
228
+
229
+ /**
230
+ * `dieu-phoi start [--json]`: one fast, network-free call for gate 1 when the UserPromptSubmit hook
231
+ * did not run: local health (sink from cache), fresh root task id, run dir created, scout brief path.
232
+ */
233
+ function cmdStart(opts) {
234
+ const ctx = ctxOf(opts);
235
+ const reg = setupLib.registration(claudeDirOf(opts));
236
+ const info = core.startInfo(ctx);
237
+ const h = info.health;
238
+ 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`);
239
+ 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');
240
+ if (!reg.UserPromptSubmit) h.problems.push('thiếu hook UserPromptSubmit — chạy lại `dieu-phoi setup` để bỏ được lệnh này');
241
+ 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)`);
242
+ h.ok = !h.problems.length;
243
+ if (opts.json) return { code: 0, out: JSON.stringify(info, null, 2) };
244
+ const out = [
245
+ `root_task_id: ${info.id}`,
246
+ `run_dir: ${info.run_dir}/ (đã tạo)`,
247
+ `step_ids: ${info.step_ids.join(' ')}`,
248
+ `health: ${core.healthLine(h)}`,
249
+ `scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
250
+ ...core.orchLessonLines(info),
251
+ ];
252
+ return { code: 0, out: out.join('\n') };
253
+ }
254
+
171
255
  // ------------------------------------------------------------------ hook / sync
172
256
 
173
257
  function cmdHook(opts) {
@@ -201,7 +285,10 @@ function resolveAgentId(events, ref, now) {
201
285
  const verdicted = new Set();
202
286
  events.forEach((e, i) => {
203
287
  if (!e.agent_id) return;
204
- if (e.kind === 'verdict' && agg.normalizeVerdict(e).by === 'agent') verdicted.add(e.agent_id);
288
+ if (e.kind === 'verdict') {
289
+ const nv = agg.normalizeVerdict(e);
290
+ if (nv.by === 'agent' && nv.outcome !== agg.ABORTED) verdicted.add(e.agent_id); // aborted still needs a verdict
291
+ }
205
292
  if (e.kind !== 'complete') return;
206
293
  const t = Date.parse(e.ts) || 0;
207
294
  const cur = latest.get(e.agent_id);
@@ -238,8 +325,9 @@ function kebab(s) {
238
325
  function verdictAgent(opts, ctx, events) {
239
326
  const [ref, outcome] = opts._;
240
327
  if (!ref || !outcome) {
241
- throw new Error('Cú pháp: dieu-phoi verdict <agent_id|last> <pass|fail|timeout> [--verify V] [--reason R]\n'
242
- + ' dieu-phoi verdict --task <task_id> <accept|reject> [--cause quality|scope] [--round n]');
328
+ throw new Error('Cú pháp: dieu-phoi verdict <agent_id|last> <pass|fail|timeout|aborted> [--verify V] [--reason R]\n'
329
+ + ' dieu-phoi verdict --task <task_id> <accept|reject> [--cause quality|scope] [--round n]\n'
330
+ + ' dieu-phoi verdict --root <root_task_id> accept [--steps a,b]');
243
331
  }
244
332
  if (outcome === 'rework') {
245
333
  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');
@@ -297,9 +385,46 @@ function verdictUser(opts, ctx, events) {
297
385
  }, core.commonFields(ctx));
298
386
  }
299
387
 
388
+ /**
389
+ * `verdict --root <root> accept [--steps a,b]` (core 0.4.0): the user accepts the steps of the latest approved plan
390
+ * in one call. Covers every step whose task has an exec dispatch and no user verdict yet (a step already rejected
391
+ * or accepted is left alone: reject the bad ones per task first, then accept the rest by root).
392
+ */
393
+ function verdictRoot(opts, ctx, events) {
394
+ const root = requireTaskId(String(opts.root));
395
+ if (opts._[0] !== 'accept') {
396
+ throw new Error('verdict --root chỉ nhận accept; bước không đạt → dieu-phoi verdict --task <task_id> reject --cause quality|scope');
397
+ }
398
+ const plan = core.readJsonFile(path.join(ctx.paths.plans, `${root}.json`), null);
399
+ 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}).`);
400
+ let steps = plan.steps.filter((x) => x && x.task_id);
401
+ if (typeof opts.steps === 'string') {
402
+ const want = new Set(opts.steps.split(',').map((x) => x.trim()).filter(Boolean));
403
+ const ids = new Set(steps.map((x) => String(x.id)));
404
+ const missing = Array.from(want).filter((x) => !ids.has(x));
405
+ if (missing.length) throw new Error(`--steps: không có bước ${missing.join(', ')} trong kế hoạch ${root}`);
406
+ steps = steps.filter((x) => want.has(String(x.id)));
407
+ }
408
+ const execTasks = new Set(events.filter((e) => e.kind === 'dispatch' && (e.role || 'exec') === 'exec').map((e) => e.task_id));
409
+ const judged = new Set(events.filter((e) => e.kind === 'verdict' && agg.normalizeVerdict(e).by === 'user').map((e) => e.task_id));
410
+ const gone = agg.discardedTaskIds(events);
411
+ const todo = steps.filter((x) => execTasks.has(x.task_id) && !judged.has(x.task_id) && !gone.has(x.task_id));
412
+ 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).`);
413
+ return todo.map((x) => ({
414
+ step: String(x.id),
415
+ ev: verdictUser(Object.assign({}, opts, { task: x.task_id, _: ['accept'], round: undefined, cause: undefined }), ctx, events),
416
+ }));
417
+ }
418
+
300
419
  function cmdVerdict(opts) {
301
420
  const ctx = ctxOf(opts);
302
421
  const events = core.readEvents(ctx);
422
+ if (typeof opts.root === 'string' && typeof opts.task !== 'string') {
423
+ const list = verdictRoot(opts, ctx, events);
424
+ for (const x of list) core.appendEvent(ctx, x.ev);
425
+ if (opts.json) return { code: 0, out: JSON.stringify(list.map((x) => x.ev)) };
426
+ 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(', ')}.` };
427
+ }
303
428
  const ev = typeof opts.task === 'string' ? verdictUser(opts, ctx, events) : verdictAgent(opts, ctx, events);
304
429
  core.appendEvent(ctx, ev);
305
430
  if (opts.json) return { code: 0, out: JSON.stringify(ev) };
@@ -309,6 +434,29 @@ function cmdVerdict(opts) {
309
434
  return { code: 0, out: `Đã ghi ${ev.outcome} cho ${ev.agent_id}${ev.task_id ? ` (task ${ev.task_id})` : ''}.` };
310
435
  }
311
436
 
437
+ // ------------------------------------------------------------------ discard
438
+
439
+ /**
440
+ * `dieu-phoi discard --task <task_id> [--reason R]` (core 0.4.0): withdraw a mistaken task. Appends one
441
+ * `discard` event (synced like any other); stats, choose, state and the sink then leave out every event of the
442
+ * task and of its agents. events.jsonl is never edited — a hand edit once lost a dispatch that the sink kept.
443
+ */
444
+ function cmdDiscard(opts) {
445
+ if (typeof opts.task !== 'string') throw new Error('Cú pháp: dieu-phoi discard --task <task_id> [--reason R]');
446
+ const taskId = requireTaskId(opts.task);
447
+ const ctx = ctxOf(opts);
448
+ const events = core.readEvents(ctx);
449
+ if (!events.some((e) => e.task_id === taskId)) throw new Error(`Không thấy task "${taskId}" trong log.`);
450
+ 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 đó.` };
451
+ const ev = Object.assign({
452
+ schema: core.SCHEMA, kind: 'discard', ts: core.nowIso(), agent_id: `task:${taskId}`, task_id: taskId, reason_code: kebab(opts.reason),
453
+ }, core.commonFields(ctx));
454
+ core.appendEvent(ctx, ev);
455
+ const n = events.filter((e) => e.task_id === taskId).length;
456
+ if (opts.json) return { code: 0, out: JSON.stringify(ev) };
457
+ 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.` };
458
+ }
459
+
312
460
  // ------------------------------------------------------------------ steer
313
461
 
314
462
  /**
@@ -336,7 +484,8 @@ function cmdSteer(opts) {
336
484
  const taskId = String(opts.task);
337
485
  const mine = agents.filter((a) => a.task_id === taskId);
338
486
  if (!mine.length && !events.some((e) => e.task_id === taskId)) throw new Error(`Không thấy task ${taskId} trong log`);
339
- const inFlight = mine.filter((a) => a.dispatched && !(a.completed && a.outcome));
487
+ // core 0.4.0: verifiers are never marked (a steer is about the work, not its check)
488
+ const inFlight = mine.filter((a) => a.dispatched && a.role !== 'verify' && !(a.completed && a.outcome));
340
489
  const type = (mine.find((a) => a.role === 'exec') || mine[0] || {}).task_type || null;
341
490
  out = inFlight.length ? inFlight.map((a) => mk(a.agent_id, taskId, a.task_type)) : [mk(`task:${taskId}`, taskId, type)];
342
491
  } else {
@@ -380,6 +529,40 @@ function measureOrch(opts, ctx, prof) {
380
529
  };
381
530
  }
382
531
 
532
+ const CTX_WARN_KEEP_MS = 14 * 24 * 3600 * 1000;
533
+
534
+ /** 0 = within the cap; 1 = over the cap, 2 = over 2×, 3 = over 4×, … */
535
+ function ctxWarnLevel(growth, cap) {
536
+ if (!(typeof growth === 'number' && cap > 0 && growth > cap)) return 0;
537
+ return 1 + Math.floor(Math.log2(growth / cap));
538
+ }
539
+
540
+ /**
541
+ * Core 0.4.0: the over-cap advice fires once per threshold crossing (cap, 2×, 4×, …) per session, and again after
542
+ * a /compact that left the session over. State: data/ctx-warn.json {session_id: {level, compactions, t}}.
543
+ * Never throws (no state → warn).
544
+ */
545
+ function ctxWarnOnce(ctx, m) {
546
+ if (!m || !m.over || !m.session_id) return false;
547
+ const file = path.join(ctx.paths.data, 'ctx-warn.json');
548
+ const level = ctxWarnLevel(m.ctx_growth, m.soft_cap);
549
+ try {
550
+ const st = core.readJsonFile(file, {}) || {};
551
+ const cur = st[m.session_id];
552
+ const comp = typeof m.compactions === 'number' ? m.compactions : null;
553
+ const warn = !cur || cur.compactions !== comp || level > (Number(cur.level) || 0);
554
+ if (warn) {
555
+ const now = Date.now();
556
+ st[m.session_id] = { level, compactions: comp, t: now };
557
+ for (const k of Object.keys(st)) if (!(now - Number(st[k] && st[k].t) < CTX_WARN_KEEP_MS)) delete st[k];
558
+ core.writeFileAtomic(file, `${JSON.stringify(st)}\n`);
559
+ }
560
+ return warn;
561
+ } catch (_) {
562
+ return true;
563
+ }
564
+ }
565
+
383
566
  function profileOrNull(opts, ctx) {
384
567
  try { return chooser.loadProfile(skillDirOf(opts, ctx)); } catch (_) { return null; }
385
568
  }
@@ -461,9 +644,9 @@ function cmdStats(opts) {
461
644
  c.note,
462
645
  ]);
463
646
  }
464
- const s = [['task_type', 'task', 'task có steer', 'lần steer', 'minor', 'plan', 'redirect', 'tỉ lệ steer']];
647
+ const s = [['task_type', 'task', 'task có steer', 'lần steer', 'minor', 'plan', 'redirect', 'tỉ lệ steer', 'ngoài exec (không tính)']];
465
648
  for (const r of steerRows) {
466
- s.push([r.task_type, r.tasks, r.steered_tasks, r.actions, r.minor, r.plan, r.redirect, pct(r.steer_rate)]);
649
+ 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]);
467
650
  }
468
651
  return {
469
652
  code: 0,
@@ -487,6 +670,7 @@ function cmdRetro(opts) {
487
670
  const userOutcomes = {};
488
671
  for (const a of agents) {
489
672
  if (a.outcome) outcomes[a.outcome] = (outcomes[a.outcome] || 0) + 1;
673
+ if (a.aborted) outcomes.aborted = (outcomes.aborted || 0) + 1;
490
674
  if (a.reason_code) reasons[a.reason_code] = (reasons[a.reason_code] || 0) + 1;
491
675
  }
492
676
  for (const v of userVerdicts) {
@@ -518,6 +702,7 @@ function cmdRetro(opts) {
518
702
  no_verdict_agents: agents.filter((a) => a.completed && !a.outcome && a.role !== 'verify').length,
519
703
  no_user_verdict_tasks: tasks.filter((t) => !t.user_verdicts.length).length,
520
704
  multi_attempt_tasks: escalations,
705
+ discarded_tasks: agg.discardedTaskIds(events).size,
521
706
  overrides: overrides.length,
522
707
  cost_usd: agents.reduce((s, a) => s + (a.cost_usd || 0), 0),
523
708
  },
@@ -567,6 +752,19 @@ function chooseInput(opts, ctx, events) {
567
752
  };
568
753
  }
569
754
 
755
+ function overrideEvent(ctx, taskId, taskType, suggested, chosen) {
756
+ return Object.assign({
757
+ schema: core.SCHEMA,
758
+ kind: 'override',
759
+ ts: core.nowIso(),
760
+ agent_id: taskId ? `task:${taskId}` : `override-${crypto.randomBytes(6).toString('hex')}`,
761
+ task_id: taskId || null,
762
+ task_type: taskType,
763
+ suggested_tier: suggested,
764
+ chosen_tier: chosen,
765
+ }, core.commonFields(ctx));
766
+ }
767
+
570
768
  function cmdChoose(opts) {
571
769
  const ctx = ctxOf(opts);
572
770
  if (opts.override !== undefined && typeof opts.task !== 'string') {
@@ -579,21 +777,14 @@ function cmdChoose(opts) {
579
777
  res.orch_ctx_tokens = orch.ctx_tokens;
580
778
  res.orch_ctx_growth = orch.ctx_growth;
581
779
  res.orch_ctx_over = orch.over;
780
+ res.orch_ctx_warn = ctxWarnOnce(ctx, orch); // over AND a new threshold crossed since the last warning
582
781
  if (res.overridden) {
583
782
  const tid = input.taskId;
584
- core.appendEvent(ctx, Object.assign({
585
- schema: core.SCHEMA,
586
- kind: 'override',
587
- ts: core.nowIso(),
588
- agent_id: tid ? `task:${tid}` : `override-${require('crypto').randomBytes(6).toString('hex')}`,
589
- task_id: tid,
590
- task_type: String(input.type).toLowerCase(),
591
- suggested_tier: res.suggested_tier,
592
- chosen_tier: res.tier,
593
- }, core.commonFields(ctx)));
783
+ core.appendEvent(ctx, overrideEvent(ctx, tid, res.task_type || String(input.type).toLowerCase(), res.suggested_tier, res.tier));
594
784
  }
595
785
  if (opts.json) return { code: 0, out: JSON.stringify(res) };
596
- const warn = orch.over ? `\nCẢNH BÁO: ${orch.advice}` : '';
786
+ const typeWarn = res.type_warning && !String(res.reason).includes(res.type_warning) ? `\nCẢNH BÁO: ${res.type_warning}` : '';
787
+ const warn = `${typeWarn}${res.orch_ctx_warn ? `\nCẢNH BÁO: ${orch.advice}` : ''}`;
597
788
  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ô'})`;
598
789
  if (res.action === 'ask_user') {
599
790
  return { code: 0, out: `HỎI NGƯỜI DÙNG — ${res.reason}${warn}` };
@@ -728,9 +919,35 @@ function planTaskIdProblems(plan, root) {
728
919
  const same = plan.steps.filter((r) => r.task_id === root).map((r) => r.step);
729
920
  if (same.length) problems.push(`task_id của bước ${same.join(', ')} trùng --root "${root}" (root dành cho scout/planner)`);
730
921
  }
922
+ // core 0.4.0: a label outside the fixed list (and without an alias) would never pool its stats
923
+ for (const r of plan.steps) if (r.unknown_type) problems.push(`bước ${r.step}: ${r.type_warning}`);
731
924
  return problems;
732
925
  }
733
926
 
927
+ /** Mapped ad-hoc labels (bug-fix → debug, …): not an error, but the tag must carry the mapped type. */
928
+ function planTypeNotes(plan) {
929
+ return plan.steps.filter((r) => r.mapped_from).map((r) => `bước ${r.step}: ${r.type_warning}`);
930
+ }
931
+
932
+ /**
933
+ * Overrides chosen in the steps file (step `override`) become override events when the plan is approved —
934
+ * also on a re-approval that changes a step's tier — so the override learning sees them (core 0.4.0; before,
935
+ * only `choose --override` logged one). One event per (task_id, chosen tier): re-approving the same tier or a
936
+ * `choose --override` already logged adds nothing.
937
+ */
938
+ function planOverrideEvents(ctx, plan, events) {
939
+ const seen = new Set(events.filter((e) => e.kind === 'override').map((e) => `${e.task_id}|${e.chosen_tier}`));
940
+ const out = [];
941
+ for (const r of plan.steps) {
942
+ if (!r.overridden || !r.task_id || !r.tier || !r.suggested_tier) continue;
943
+ const k = `${r.task_id}|${r.tier}`;
944
+ if (seen.has(k)) continue;
945
+ seen.add(k);
946
+ out.push(overrideEvent(ctx, r.task_id, r.task_type || r.type, r.suggested_tier, r.tier));
947
+ }
948
+ return out;
949
+ }
950
+
734
951
  function recordApprovedPlan(ctx, root, stepsFile, plan) {
735
952
  const file = path.join(ctx.paths.plans, `${root}.json`);
736
953
  const prev = core.readJsonFile(file, null);
@@ -746,7 +963,7 @@ function recordApprovedPlan(ctx, root, stepsFile, plan) {
746
963
  approved_at: core.nowIso(),
747
964
  approval: prevApproval(prev) + 1,
748
965
  steps: plan.steps.map((r) => ({
749
- id: r.step, task_id: r.task_id || null, title: r.title === undefined ? null : r.title, type: r.type || null,
966
+ id: r.step, task_id: r.task_id || null, title: r.title === undefined ? null : r.title, type: r.task_type || r.type || null,
750
967
  tier: r.action === 'refused' ? null : (r.tier || null), depends_on: r.depends_on,
751
968
  })),
752
969
  history,
@@ -793,9 +1010,15 @@ function cmdPlanTable(opts) {
793
1010
  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.`);
794
1011
  }
795
1012
  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`);
1013
+ const typeNotes = planTypeNotes(plan);
1014
+ if (typeNotes.length) process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${typeNotes.join('; ')}\n`);
796
1015
  const rec = opts.approve ? recordApprovedPlan(ctx, String(opts.root), path.resolve(opts.file), plan) : null;
1016
+ const ovs = rec ? planOverrideEvents(ctx, plan, base.events) : [];
1017
+ for (const ev of ovs) core.appendEvent(ctx, ev);
797
1018
  if (opts.json) {
798
- const out = rec ? Object.assign({}, plan, { approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file } }) : plan;
1019
+ const out = rec ? Object.assign({}, plan, {
1020
+ approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file, overrides_logged: ovs.length },
1021
+ }) : plan;
799
1022
  return { code: 0, out: JSON.stringify(out, null, 2) };
800
1023
  }
801
1024
  const lines = ['| Bước | Model/effort | Rubric (điểm · kiểm chứng · đảo ngược) | Lý do | Ước tính |', '|---|---|---|---|---|'];
@@ -815,11 +1038,12 @@ function cmdPlanTable(opts) {
815
1038
  process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${plan.refused.length} bước bị từ chối
816
1039
  `);
817
1040
  }
818
- if (rec) lines.push('', `Đã ghi kế hoạch đã duyệt (root ${rec.root_task_id}, lần duyệt ${rec.approval})`);
1041
+ if (typeNotes.length) lines.push('', `**Loại task:** ${typeNotes.join('; ')}`);
1042
+ 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` : ''}`);
819
1043
  return { code: 0, out: lines.join('\n') };
820
1044
  }
821
1045
 
822
1046
  module.exports = {
823
- cmdSetup, cmdUninstall, cmdDoctor, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
1047
+ cmdSetup, cmdUninstall, cmdDoctor, cmdStart, checkSinkLive, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdDiscard, ctxWarnOnce, ctxWarnLevel, sweepStaleTmp, planOverrideEvents, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
824
1048
  cmdPlanTable, cmdNewId, requireTaskId, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
825
1049
  };
package/lib/setup.cjs CHANGED
@@ -57,7 +57,7 @@ function stripOurHooks(settings) {
57
57
  return removed;
58
58
  }
59
59
 
60
- /** True when settings already hold exactly our two entries with this command. */
60
+ /** True when settings already hold exactly our four entries with this command. */
61
61
  function alreadyRegistered(settings, cmd) {
62
62
  const hooks = settings && settings.hooks;
63
63
  if (!hooks || typeof hooks !== 'object') return false;
@@ -72,10 +72,11 @@ function alreadyRegistered(settings, cmd) {
72
72
  if (event === 'PostToolUse' && g.matcher === 'Agent') good++;
73
73
  else if (event === 'SubagentStop') good++;
74
74
  else if (event === 'SessionStart' && g.matcher === 'compact') good++;
75
+ else if (event === 'UserPromptSubmit' && !g.matcher) good++;
75
76
  }
76
77
  }
77
78
  }
78
- return ours === 3 && good === 3;
79
+ return ours === 4 && good === 4;
79
80
  }
80
81
 
81
82
  function addOurHooks(settings, hookPath) {
@@ -89,6 +90,9 @@ function addOurHooks(settings, hookPath) {
89
90
  // after /compact or auto-compact: re-inject the orchestration state rebuilt from the log
90
91
  if (!Array.isArray(h.SessionStart)) h.SessionStart = [];
91
92
  h.SessionStart.push({ matcher: 'compact', hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
93
+ // on a dieu-phoi prompt: pre-create the run dir + id so the first tool call can be the scout (no shell)
94
+ if (!Array.isArray(h.UserPromptSubmit)) h.UserPromptSubmit = [];
95
+ h.UserPromptSubmit.push({ hooks: [{ type: 'command', command: cmd, timeout: 5 }] });
92
96
  }
93
97
 
94
98
  // ------------------------------------------------------------------ run-dir permissions
@@ -340,7 +344,7 @@ function registration(claudeDir) {
340
344
  const perms = permissionEntries(runsDir());
341
345
  const legacy = permissionEntries(legacyRunsDirOf(claudeDir));
342
346
  const out = {
343
- file, PostToolUse: false, SubagentStop: false, SessionStart: false, commands: [], permissions: false, permission_entries: perms,
347
+ file, PostToolUse: false, SubagentStop: false, SessionStart: false, UserPromptSubmit: false, commands: [], permissions: false, permission_entries: perms,
344
348
  runs_dir: perms.dir, runs_dir_exists: fs.existsSync(perms.dir), legacy_permissions: false,
345
349
  };
346
350
  let obj;
@@ -350,7 +354,7 @@ function registration(claudeDir) {
350
354
  out.legacy_permissions = legacy.allow.some((r) => Array.isArray(lp.allow) && lp.allow.includes(r))
351
355
  || (Array.isArray(lp.additionalDirectories) && lp.additionalDirectories.includes(legacy.dir));
352
356
  const hooks = obj.hooks || {};
353
- for (const ev of ['PostToolUse', 'SubagentStop', 'SessionStart']) {
357
+ for (const ev of ['PostToolUse', 'SubagentStop', 'SessionStart', 'UserPromptSubmit']) {
354
358
  for (const g of Array.isArray(hooks[ev]) ? hooks[ev] : []) {
355
359
  for (const h of (g && Array.isArray(g.hooks) ? g.hooks : [])) {
356
360
  if (!isOurs(h)) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kienntpixon/dieu-phoi",
3
- "version": "0.2.2",
3
+ "version": "0.4.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>",