@kienntpixon/dieu-phoi 0.1.2 → 0.2.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/aggregate.cjs CHANGED
@@ -339,14 +339,80 @@ function buildSteers(tasks, steers, agents) {
339
339
  }).sort((a, b) => b.steer_rate - a.steer_rate || a.task_type.localeCompare(b.task_type));
340
340
  }
341
341
 
342
+ function finiteNum(v) {
343
+ return typeof v === 'number' && isFinite(v) ? v : null;
344
+ }
345
+
346
+ /**
347
+ * Orchestration cost (CONTRACT "Thước đo" mục 6). Sessions = distinct non-empty `session_id` over
348
+ * dispatch events. Per session: ctx = max orch_ctx_tokens, usd = max orch_cum_usd, growth = max of
349
+ * max(0, orch_ctx_tokens - orch_ctx_baseline) over dispatches carrying both, comp = max orch_compactions
350
+ * (numbers only). compactions_total = sum of per-session comp, compactions_max = max.
351
+ * Each agent belongs to the session of its FIRST dispatch (file order); sub_usd_total sums the
352
+ * cost_usd of every complete of those agents (internal completes never have a dispatch, so never count).
353
+ */
354
+ function orchestrationSummary(events) {
355
+ const sessions = new Map();
356
+ const agentSession = new Map();
357
+ for (const ev of events) {
358
+ if (!ev || ev.kind !== 'dispatch' || typeof ev.session_id !== 'string' || !ev.session_id) continue;
359
+ const sid = ev.session_id;
360
+ if (!sessions.has(sid)) sessions.set(sid, { ctx: null, usd: null, growth: null, comp: null });
361
+ const s = sessions.get(sid);
362
+ const c = finiteNum(ev.orch_ctx_tokens);
363
+ const u = finiteNum(ev.orch_cum_usd);
364
+ if (c !== null && (s.ctx === null || c > s.ctx)) s.ctx = c;
365
+ if (u !== null && (s.usd === null || u > s.usd)) s.usd = u;
366
+ const k = finiteNum(ev.orch_compactions);
367
+ if (k !== null && (s.comp === null || k > s.comp)) s.comp = k;
368
+ const b = finiteNum(ev.orch_ctx_baseline);
369
+ if (c !== null && b !== null) {
370
+ const g = Math.max(0, c - b);
371
+ if (s.growth === null || g > s.growth) s.growth = g;
372
+ }
373
+ if (typeof ev.agent_id === 'string' && ev.agent_id && !agentSession.has(ev.agent_id)) agentSession.set(ev.agent_id, sid);
374
+ }
375
+ let sub = 0;
376
+ for (const ev of events) {
377
+ if (!ev || ev.kind !== 'complete' || !agentSession.has(ev.agent_id)) continue;
378
+ const c = finiteNum(ev.cost_usd);
379
+ if (c !== null) sub += c;
380
+ }
381
+ const ctxs = [];
382
+ const growths = [];
383
+ const comps = [];
384
+ let orch = 0;
385
+ for (const s of sessions.values()) {
386
+ if (s.ctx !== null) ctxs.push(s.ctx);
387
+ if (s.growth !== null) growths.push(s.growth);
388
+ if (s.comp !== null) comps.push(s.comp);
389
+ if (s.usd !== null) orch += s.usd;
390
+ }
391
+ return {
392
+ sessions: sessions.size,
393
+ ctx_p50: round6(percentile(ctxs, 0.5)),
394
+ ctx_max: ctxs.length ? Math.max(...ctxs) : null,
395
+ growth_p50: round6(percentile(growths, 0.5)),
396
+ growth_max: growths.length ? Math.max(...growths) : null,
397
+ compactions_total: comps.reduce((x, y) => x + y, 0),
398
+ compactions_max: comps.length ? Math.max(...comps) : null,
399
+ orch_usd_total: round6(orch),
400
+ sub_usd_total: round6(sub),
401
+ orch_share: orch + sub > 0 ? round6(orch / (orch + sub)) : null,
402
+ };
403
+ }
404
+
342
405
  function analyze(events, opts) {
343
406
  const { agents, userVerdicts, overrides, steers } = buildAgents(events);
344
407
  const tasks = buildTasks(agents, userVerdicts);
345
408
  const cells = buildCells(tasks, opts);
346
- return { agents, tasks, cells, overrides, userVerdicts, steerEvents: steers, steers: buildSteers(tasks, steers, agents) };
409
+ return {
410
+ agents, tasks, cells, overrides, userVerdicts, steerEvents: steers, steers: buildSteers(tasks, steers, agents),
411
+ orchestration: orchestrationSummary(events),
412
+ };
347
413
  }
348
414
 
349
415
  module.exports = {
350
416
  MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
351
- buildCells, buildSteers, canonicalCells, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
417
+ buildCells, buildSteers, canonicalCells, orchestrationSummary, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
352
418
  };
package/lib/args.cjs CHANGED
@@ -3,7 +3,7 @@
3
3
  const path = require('path');
4
4
  const core = require('../hook.cjs');
5
5
 
6
- const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync']);
6
+ const BOOLEAN_FLAGS = new Set(['json', 'help', 'h', 'purge', 'version', 'v', 'dry-run', 'no-sync', 'approve']);
7
7
 
8
8
  /** Minimal argv parser: positionals, --k v, --k=v, boolean flags. */
9
9
  function parseArgs(argv) {
package/lib/choose.cjs CHANGED
@@ -50,6 +50,9 @@ const DEFAULT_TIME_CAP_MIN = {
50
50
  };
51
51
  const DEFAULT_LAMBDA = { relaxed: 0.02, normal: 0.1, urgent: 1.0 };
52
52
  const DEFAULT_EXPLORE = 0.1;
53
+ // Orchestrator context soft cap = allowed GROWTH in tokens over the session's first request (baseline:
54
+ // system prompt + tools, ~60-70k). Core default = ceiling: a profile may only lower it.
55
+ const DEFAULT_ORCH_CTX_SOFT_CAP = 60000;
53
56
 
54
57
  function tierName(i) {
55
58
  return `${LADDER[i].model}/${LADDER[i].effort}`;
@@ -121,7 +124,15 @@ function loadProfile(skillDir) {
121
124
  const timeCap = Object.assign({}, DEFAULT_TIME_CAP_MIN,
122
125
  r.time_cap_min && typeof r.time_cap_min === 'object' ? r.time_cap_min : {});
123
126
  const extra = Array.isArray(r.extra_task_types) ? r.extra_task_types.map(String) : [];
124
- return { source, dept: r.dept || null, priors, lambda, explore_rate: explore, time_cap_min: timeCap, extra_task_types: extra };
127
+ let softCap = DEFAULT_ORCH_CTX_SOFT_CAP;
128
+ if (r.orch_ctx_soft_cap !== undefined && r.orch_ctx_soft_cap !== null) {
129
+ const v = Number(r.orch_ctx_soft_cap);
130
+ if (isFinite(v) && v > 0) softCap = Math.min(DEFAULT_ORCH_CTX_SOFT_CAP, Math.floor(v));
131
+ }
132
+ return {
133
+ source, dept: r.dept || null, priors, lambda, explore_rate: explore, time_cap_min: timeCap, extra_task_types: extra,
134
+ orch_ctx_soft_cap: softCap,
135
+ };
125
136
  }
126
137
 
127
138
  // ------------------------------------------------------------------ seeded RNG
@@ -461,6 +472,7 @@ function choose(opts) {
461
472
  }
462
473
 
463
474
  module.exports = {
475
+ DEFAULT_ORCH_CTX_SOFT_CAP,
464
476
  LADDER, DEFAULT_PRIORS, DEFAULT_LAMBDA, DEFAULT_TIME_CAP_MIN, ROUGH, tierIndex, tierName, parsePrior,
465
477
  parseTier, loadProfile, makeRng, betaSample, escalation, overridePrior, roughEstimate, choose, shortModel,
466
478
  };
package/lib/commands.cjs CHANGED
@@ -15,6 +15,14 @@ const USER_OUTCOMES = ['accept', 'reject'];
15
15
  const CAUSES = ['quality', 'scope'];
16
16
  const STEER_KINDS = ['minor', 'plan', 'redirect'];
17
17
  const VERIFY = ['tests', 'lint', 'review', 'judgment', 'none'];
18
+ const INVALID_ID_WINDOW_DAYS = 7;
19
+ const NEW_ID_MAX = 1000;
20
+
21
+ /** Throw the Vietnamese task_id error (with a fresh suggestion) unless `id` is a valid task_id. */
22
+ function requireTaskId(id) {
23
+ if (!core.isValidTaskId(id)) throw new Error(core.taskIdError(id));
24
+ return id;
25
+ }
18
26
 
19
27
  function skillDirOf(opts, ctx) {
20
28
  if (typeof opts['skill-dir'] === 'string') return path.resolve(opts['skill-dir']);
@@ -55,8 +63,9 @@ function cmdSetup(opts) {
55
63
  const lines = [
56
64
  `Đã cài hook: ${res.hook}`,
57
65
  res.settings_changed
58
- ? `Đã đăng ký PostToolUse(Agent) + SubagentStop trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
66
+ ? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
59
67
  : `settings.json đã có hook, không đổi gì (${res.settings})`,
68
+ `Thư mục báo cáo subagent: ${res.runs_dir} (quyền: ${res.permissions.allow.join(', ')} + additionalDirectories)`,
60
69
  `Phòng ban: ${res.config.dept || '(chưa đặt, dùng --dept)'}`,
61
70
  `Sink: ${res.config.sink_url ? res.config.sink_url : '(chưa có — log chỉ lưu cục bộ)'}`,
62
71
  `Skill: ${res.config.skill_dir || '(không thấy; dùng --skill-dir)'}${res.config.skill_version ? ` v${res.config.skill_version}` : ''}`,
@@ -87,8 +96,12 @@ async function cmdDoctor(opts) {
87
96
  add(major >= 18, 'node', `v${process.versions.node}${major >= 18 ? '' : ' (cần ≥ 18)'}`);
88
97
  add(fs.existsSync(ctx.paths.hook), 'hook', ctx.paths.hook);
89
98
  const reg = setupLib.registration(claudeDir);
90
- add(reg.PostToolUse && reg.SubagentStop, 'settings',
91
- `PostToolUse(Agent)=${reg.PostToolUse ? 'có' : 'thiếu'}, SubagentStop=${reg.SubagentStop ? 'có' : 'thiếu'} (${reg.file})`);
99
+ add(reg.PostToolUse && reg.SubagentStop && reg.SessionStart, 'settings',
100
+ `PostToolUse(Agent)=${reg.PostToolUse ? 'có' : 'thiếu'}, SubagentStop=${reg.SubagentStop ? 'có' : 'thiếu'}, SessionStart(compact)=${reg.SessionStart ? 'có' : 'thiếu'} (${reg.file})`);
101
+ const permOk = reg.permissions && reg.runs_dir_exists && !reg.legacy_permissions;
102
+ add(permOk, 'run-permissions', permOk
103
+ ? `${reg.permission_entries.allow.join(', ')} + additionalDirectories ${reg.permission_entries.dir}`
104
+ : `${!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');
92
105
  const hasCfg = fs.existsSync(ctx.paths.config);
93
106
  add(hasCfg, 'config', hasCfg ? `${ctx.paths.config} dept=${ctx.config.dept || '-'}` : 'chưa có — chạy `dieu-phoi setup`');
94
107
  const sd = ctx.config.skill_dir;
@@ -124,6 +137,15 @@ async function cmdDoctor(opts) {
124
137
  add(true, 'internal-subagents', skipped && skipped.count
125
138
  ? `bỏ qua ${skipped.count} SubagentStop của subagent nội bộ Claude Code (lần cuối ${skipped.last_ts})`
126
139
  : 'chưa bỏ qua SubagentStop nội bộ nào');
140
+ const since = Date.now() - INVALID_ID_WINDOW_DAYS * 86400000;
141
+ const badIds = events.filter((e) => e.kind === 'dispatch' && e.task_id_invalid === true && !(Date.parse(e.ts) < since));
142
+ add(!badIds.length, 'task-ids', badIds.length
143
+ ? `${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
+ : `mọi dispatch trong ${INVALID_ID_WINDOW_DAYS} ngày gần đây có task_id đúng định dạng`, 'CẢNH BÁO');
145
+ const orch = measureOrch(opts, ctx, profileOrNull(opts, ctx));
146
+ if (orch.ctx_tokens !== null) {
147
+ 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
+ }
127
149
  if (ctx.config.sink_url && ctx.config.sink_token) {
128
150
  try {
129
151
  const u = new URL(ctx.config.sink_url);
@@ -141,7 +163,7 @@ async function cmdDoctor(opts) {
141
163
  add(true, 'sink', 'chưa cấu hình (chỉ lưu cục bộ)');
142
164
  }
143
165
  const failed = checks.some((c) => c.level === 'LỖI');
144
- if (opts.json) return { code: failed ? 1 : 0, out: JSON.stringify({ ok: !failed, checks }, null, 2) };
166
+ if (opts.json) return { code: failed ? 1 : 0, out: JSON.stringify({ ok: !failed, runs_dir: reg.runs_dir, checks }, null, 2) };
145
167
  const out = checks.map((c) => `[${c.level}] ${c.name}: ${c.detail}`).join('\n');
146
168
  return { code: failed ? 1 : 0, out };
147
169
  }
@@ -150,8 +172,9 @@ async function cmdDoctor(opts) {
150
172
 
151
173
  function cmdHook(opts) {
152
174
  const home = typeof opts['claude-dir'] === 'string' ? path.join(path.resolve(opts['claude-dir']), 'dieu-phoi') : undefined;
153
- core.runHook({ home });
154
- return { code: 0, out: null };
175
+ const r = core.runHook({ home });
176
+ // SessionStart(compact) returns the JSON to print; every other hook event prints nothing.
177
+ return { code: 0, out: r && typeof r.stdout === 'string' ? r.stdout : null };
155
178
  }
156
179
 
157
180
  async function cmdSync(opts) {
@@ -245,6 +268,7 @@ function verdictUser(opts, ctx, events) {
245
268
  const taskId = opts.task;
246
269
  const outcome = opts._[0];
247
270
  if (typeof taskId !== 'string' || !taskId) throw new Error('--task cần task_id');
271
+ requireTaskId(taskId);
248
272
  if (!USER_OUTCOMES.includes(outcome)) throw new Error(`verdict --task: outcome phải là ${USER_OUTCOMES.join('|')}`);
249
273
  let cause = typeof opts.cause === 'string' ? opts.cause : null;
250
274
  if (outcome === 'reject' && !CAUSES.includes(cause)) throw new Error(`reject cần --cause ${CAUSES.join('|')} (scope = yêu cầu đổi/thiếu, không phải lỗi model)`);
@@ -297,6 +321,7 @@ function cmdSteer(opts) {
297
321
  const kind = opts.kind;
298
322
  if (!ref && !opts.task) throw new Error('Cú pháp: dieu-phoi steer <agent_id> | --task <task_id> --kind minor|plan|redirect [--reason R]');
299
323
  if (!STEER_KINDS.includes(kind)) throw new Error(`--kind phải là ${STEER_KINDS.join('|')}`);
324
+ if (!ref) requireTaskId(String(opts.task));
300
325
  const ctx = ctxOf(opts);
301
326
  const events = core.readEvents(ctx);
302
327
  const steerId = crypto.randomBytes(4).toString('hex');
@@ -326,6 +351,76 @@ function cmdSteer(opts) {
326
351
  return { code: 0, out: `Đã ghi steer ${kind} (${steerId}) cho ${target}.` };
327
352
  }
328
353
 
354
+ // ------------------------------------------------------------------ ctx (orchestrator context)
355
+
356
+ /**
357
+ * Measure the orchestrating session: --session, else env CLAUDE_CODE_SESSION_ID; transcript found under
358
+ * <claude dir>/projects/<project>/<id>.jsonl. Never throws; ctx_tokens null when it cannot measure.
359
+ */
360
+ function measureOrch(opts, ctx, prof) {
361
+ const sid = typeof opts.session === 'string' && opts.session ? opts.session
362
+ : (process.env.CLAUDE_CODE_SESSION_ID || null);
363
+ const softCap = (prof && prof.orch_ctx_soft_cap) || chooser.DEFAULT_ORCH_CTX_SOFT_CAP;
364
+ let u = { ctx_tokens: null, ctx_baseline: null, ctx_growth: null, cum_usd: null, compactions: null };
365
+ try {
366
+ const file = sid ? core.findSessionTranscript(claudeDirOf(opts), sid) : null;
367
+ if (file) {
368
+ u = core.orchestratorUsage(file, core.loadPricing(ctx), { cacheFile: core.orchCacheFile(ctx.paths, sid, file) });
369
+ }
370
+ } catch (_) { /* measurement never fails the caller */ }
371
+ const over = typeof u.ctx_growth === 'number' && u.ctx_growth > softCap;
372
+ let advice;
373
+ if (!sid) advice = 'Không đo được: không có session (thiếu CLAUDE_CODE_SESSION_ID, dùng --session <id>).';
374
+ else if (u.ctx_tokens === null) advice = 'Không đo được: không thấy transcript của session này.';
375
+ else if (over) advice = `Context điều phối đã tăng ${u.ctx_growth} token (nền ${u.ctx_baseline}, nay ${u.ctx_tokens}) vượt trần mềm ${softCap}: gõ /compact để nén phiên — trạng thái điều phối sẽ được nạp lại tự động.`;
376
+ else advice = `Context điều phối tăng ${u.ctx_growth}/${softCap} token so với nền ${u.ctx_baseline}, còn trong trần mềm.`;
377
+ return {
378
+ session_id: sid, ctx_tokens: u.ctx_tokens, ctx_baseline: u.ctx_baseline, ctx_growth: u.ctx_growth,
379
+ cum_usd: u.cum_usd, compactions: u.compactions, soft_cap: softCap, over, advice,
380
+ };
381
+ }
382
+
383
+ function profileOrNull(opts, ctx) {
384
+ try { return chooser.loadProfile(skillDirOf(opts, ctx)); } catch (_) { return null; }
385
+ }
386
+
387
+ function cmdCtx(opts) {
388
+ const ctx = ctxOf(opts);
389
+ const m = measureOrch(opts, ctx, profileOrNull(opts, ctx));
390
+ if (opts.json) return { code: 0, out: JSON.stringify(m) };
391
+ if (m.ctx_tokens === null) return { code: 0, out: m.advice };
392
+ return {
393
+ code: 0,
394
+ out: `session ${m.session_id}: context ${m.ctx_tokens} token, nền ${m.ctx_baseline}, tăng ${m.ctx_growth} (trần mềm ${m.soft_cap})${m.over ? ' — VƯỢT' : ''}, `
395
+ + `chi phí điều phối ≈ ${fmtUsd(m.cum_usd)}, đã nén ${m.compactions === null ? '?' : m.compactions} lần\n${m.advice}`,
396
+ };
397
+ }
398
+
399
+ function orchLine(o) {
400
+ if (!o || !o.sessions) return 'Điều phối: chưa có dispatch nào ghi session_id (cần core ≥ 0.2.0).';
401
+ return `Điều phối: ${o.sessions} phiên, context p50 ${o.ctx_p50 === null ? '-' : Math.round(o.ctx_p50)} / max ${o.ctx_max === null ? '-' : o.ctx_max} token (tăng so với nền p50 ${o.growth_p50 === null ? '-' : Math.round(o.growth_p50)} / max ${o.growth_max === null ? '-' : o.growth_max}), `
402
+ + `chi phí điều phối ${fmtUsd(o.orch_usd_total)} vs subagent ${fmtUsd(o.sub_usd_total)} (điều phối chiếm ${pct(o.orch_share)}); nén context ${o.compactions_total} lần (tối đa ${o.compactions_max === null ? '-' : o.compactions_max}/phiên).`;
403
+ }
404
+
405
+ // ------------------------------------------------------------------ state (re-injected after /compact)
406
+
407
+ /**
408
+ * Orchestration state of one session rebuilt purely from events.jsonl (same builder the SessionStart(compact)
409
+ * hook uses). Session = --session, else env CLAUDE_CODE_SESSION_ID. Run dirs are pointed to, never read.
410
+ */
411
+ function cmdState(opts) {
412
+ const ctx = ctxOf(opts);
413
+ const sid = typeof opts.session === 'string' && opts.session ? opts.session
414
+ : (process.env.CLAUDE_CODE_SESSION_ID || null);
415
+ if (!sid) {
416
+ if (opts.json) return { code: 0, out: JSON.stringify({ session_id: null, generated_at: core.nowIso(), runs_dir: null, plans: [], tasks: [] }) };
417
+ return { code: 0, out: 'Không có session (thiếu CLAUDE_CODE_SESSION_ID, dùng --session <id>).' };
418
+ }
419
+ const state = core.buildSessionState(core.readEvents(ctx), sid, { plansDir: ctx.paths.plans });
420
+ if (opts.json) return { code: 0, out: JSON.stringify(state, null, 2) };
421
+ return { code: 0, out: core.formatSessionState(state, core.STATE_TEXT_CAP) };
422
+ }
423
+
329
424
  // ------------------------------------------------------------------ stats / retro
330
425
 
331
426
  function lambdaOf(opts, ctx) {
@@ -340,14 +435,16 @@ function pct(v) {
340
435
  function cmdStats(opts) {
341
436
  const ctx = ctxOf(opts);
342
437
  const { lambda } = lambdaOf(opts, ctx);
343
- const { cells, steers } = agg.analyze(core.readEvents(ctx), { lambda });
438
+ const { cells, steers, orchestration } = agg.analyze(core.readEvents(ctx), { lambda });
344
439
  const type = typeof opts.type === 'string' ? opts.type : null;
345
440
  const rows = type ? cells.filter((c) => c.task_type === type) : cells;
346
441
  const steerRows = type ? steers.filter((s) => s.task_type === type) : steers;
347
442
  if (opts.json) {
348
- return { code: 0, out: JSON.stringify({ lambda_usd_per_min: lambda, min_n: agg.MIN_N, cells: rows, steers: steerRows }, null, 2) };
443
+ return { code: 0, out: JSON.stringify({ lambda_usd_per_min: lambda, min_n: agg.MIN_N, cells: rows, steers: steerRows, orchestration }, null, 2) };
444
+ }
445
+ if (!rows.length) {
446
+ return { code: 0, out: orchestration.sessions ? `Chưa có dữ liệu theo ô.\n${orchLine(orchestration)}` : 'Chưa có dữ liệu.' };
349
447
  }
350
- if (!rows.length) return { code: 0, out: 'Chưa có dữ liệu.' };
351
448
  const t = [['task_type', 'model/effort', 'n', 'accept%', 'agent_pass%', 'E[cost]', 'E[time]', 'p50', 'p75', 'score', '']];
352
449
  for (const c of rows) {
353
450
  t.push([
@@ -370,7 +467,7 @@ function cmdStats(opts) {
370
467
  }
371
468
  return {
372
469
  code: 0,
373
- out: `${table(t)}\n\n${table(s)}\n\n`
470
+ out: `${table(t)}\n\n${table(s)}\n\n${orchLine(orchestration)}\n\n`
374
471
  + 'n = task tính tỉ lệ (/số đã giao, (-k) = bị loại: reject-scope hoặc lượt exec đầu bị steer plan/redirect). '
375
472
  + 'accept% = thành công theo verdict người dùng đầu tiên, chưa có thì verdict agent; agent_pass% = chỉ tầng agent. '
376
473
  + `E[cost], E[time] tính trọn mọi lượt + review của task. score = E[cost] + λ·E[time], λ=${lambda} USD/phút.`,
@@ -384,7 +481,7 @@ function cmdRetro(opts) {
384
481
  const ctx = ctxOf(opts);
385
482
  const { prof, lambda } = lambdaOf(opts, ctx);
386
483
  const events = core.readEvents(ctx);
387
- const { agents, tasks, cells, steers, userVerdicts, overrides } = agg.analyze(events, { lambda });
484
+ const { agents, tasks, cells, steers, userVerdicts, overrides, orchestration } = agg.analyze(events, { lambda });
388
485
  const reasons = {};
389
486
  const outcomes = {};
390
487
  const userOutcomes = {};
@@ -433,6 +530,7 @@ function cmdRetro(opts) {
433
530
  steers,
434
531
  high_steer_task_types: highSteer,
435
532
  overrides: overrides.map((o) => ({ ts: o.ts, task_type: o.task_type, suggested_tier: o.suggested_tier, chosen_tier: o.chosen_tier })),
533
+ orchestration,
436
534
  cells,
437
535
  };
438
536
  if (opts.json) return { code: 0, out: JSON.stringify(summary, null, 2) };
@@ -443,6 +541,7 @@ function cmdRetro(opts) {
443
541
  `Lý do hay gặp: ${summary.reason_codes.slice(0, 8).map((r) => `${r.code}×${r.count}`).join(', ') || '-'}`,
444
542
  `Loại task: ${summary.task_types.map((t) => `${t.task_type}(${t.n})`).join(', ') || '-'}`,
445
543
  `Steer nhiều (brainstorm hỏi quá ít): ${highSteer.map((s) => `${s.task_type} ${pct(s.steer_rate)}`).join(', ') || '-'}`,
544
+ orchLine(orchestration),
446
545
  'Chi tiết theo ô: `dieu-phoi stats`; đưa cho LLM: `dieu-phoi retro --json`.',
447
546
  ];
448
547
  return { code: 0, out: lines.join('\n') };
@@ -473,8 +572,13 @@ function cmdChoose(opts) {
473
572
  if (opts.override !== undefined && typeof opts.task !== 'string') {
474
573
  throw new Error('--override cần --task <task_id> (để học override theo từng task)');
475
574
  }
575
+ if (opts.task !== undefined) requireTaskId(typeof opts.task === 'string' ? opts.task : '');
476
576
  const input = chooseInput(opts, ctx, core.readEvents(ctx));
477
577
  const res = chooser.choose(input);
578
+ const orch = measureOrch(opts, ctx, input.profile);
579
+ res.orch_ctx_tokens = orch.ctx_tokens;
580
+ res.orch_ctx_growth = orch.ctx_growth;
581
+ res.orch_ctx_over = orch.over;
478
582
  if (res.overridden) {
479
583
  const tid = input.taskId;
480
584
  core.appendEvent(ctx, Object.assign({
@@ -489,13 +593,14 @@ function cmdChoose(opts) {
489
593
  }, core.commonFields(ctx)));
490
594
  }
491
595
  if (opts.json) return { code: 0, out: JSON.stringify(res) };
492
- 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ô'})`;
596
+ const warn = orch.over ? `\nCẢNH BÁO: ${orch.advice}` : '';
597
+ 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ô'})`;
493
598
  if (res.action === 'ask_user') {
494
- return { code: 0, out: `HỎI NGƯỜI DÙNG — ${res.reason}` };
599
+ return { code: 0, out: `HỎI NGƯỜI DÙNG — ${res.reason}${warn}` };
495
600
  }
496
601
  return {
497
602
  code: 0,
498
- out: `model=${res.model} effort=${res.effort}${res.explored ? ' (thăm dò)' : ''}${res.overridden ? ' (override)' : ''}${res.deviation ? ' (LỆCH khỏi tier đã duyệt)' : ''} ${est} — ${res.reason}`,
603
+ out: `model=${res.model} effort=${res.effort}${res.explored ? ' (thăm dò)' : ''}${res.overridden ? ' (override)' : ''}${res.deviation ? ' (LỆCH khỏi tier đã duyệt)' : ''} ${est} — ${res.reason}${warn}`,
499
604
  };
500
605
  }
501
606
 
@@ -563,7 +668,11 @@ function buildPlanTable(rawSteps, base) {
563
668
  role: s.role || base.role,
564
669
  seed: base.seed === undefined || base.seed === null ? `plan:${s.step}` : `${base.seed}:${s.step}`,
565
670
  }));
566
- const head = { step: s.step, title: s.title, type: s.type, score: Number(s.score), depends_on: s.deps };
671
+ // Rubric inputs that drive the safety floors: shown to the user at gate 1 so mis-scoring is caught.
672
+ const head = {
673
+ step: s.step, title: s.title, type: s.type, task_id: s.task || null, score: Number(s.score),
674
+ verifiable: s.verifiable, reversible: s.reversible, depends_on: s.deps,
675
+ };
567
676
  try {
568
677
  return Object.assign(head, call());
569
678
  } catch (e) {
@@ -589,12 +698,86 @@ function buildPlanTable(rawSteps, base) {
589
698
  };
590
699
  }
591
700
 
701
+ /**
702
+ * Record the plan the user approved at gate 1: <data>/plans/<root>.json (local only — not an event, never
703
+ * synced). Each re-approval increments `approval` (number of approvals for this root) and overwrites the
704
+ * record; earlier approvals move to `history`. Not the user-verdict `round`.
705
+ * tier = exactly what the approved table shows (override applied); refused steps -> null.
706
+ */
707
+ function prevApproval(prev) {
708
+ if (!prev || typeof prev !== 'object') return 0;
709
+ const n = Number.isInteger(prev.approval) ? prev.approval : prev.round; // `round`: 0.2.0 dev records
710
+ return Number.isInteger(n) && n > 0 ? n : 0;
711
+ }
712
+
713
+ /**
714
+ * Steps that cannot be matched to dispatches once approved: missing task_id, a task_id used twice, or a
715
+ * task_id equal to the root. Returns Vietnamese problem strings (empty = ok).
716
+ */
717
+ function planTaskIdProblems(plan, root) {
718
+ const problems = [];
719
+ const missing = plan.steps.filter((r) => !r.task_id).map((r) => r.step);
720
+ if (missing.length) problems.push(`thiếu task_id ở bước ${missing.join(', ')}`);
721
+ for (const r of plan.steps) {
722
+ if (r.task_id && !core.isValidTaskId(r.task_id)) problems.push(`bước ${r.step}: ${core.taskIdError(r.task_id)}`);
723
+ }
724
+ const by = new Map();
725
+ for (const r of plan.steps) if (r.task_id) by.set(r.task_id, (by.get(r.task_id) || []).concat(r.step));
726
+ for (const [id, steps] of by) if (steps.length > 1) problems.push(`task_id "${id}" bị dùng lại ở các bước ${steps.join(', ')}`);
727
+ if (root) {
728
+ const same = plan.steps.filter((r) => r.task_id === root).map((r) => r.step);
729
+ if (same.length) problems.push(`task_id của bước ${same.join(', ')} trùng --root "${root}" (root dành cho scout/planner)`);
730
+ }
731
+ return problems;
732
+ }
733
+
734
+ function recordApprovedPlan(ctx, root, stepsFile, plan) {
735
+ const file = path.join(ctx.paths.plans, `${root}.json`);
736
+ const prev = core.readJsonFile(file, null);
737
+ let history = [];
738
+ if (prev && typeof prev === 'object' && !Array.isArray(prev)) {
739
+ const old = Object.assign({}, prev);
740
+ delete old.history;
741
+ history = (Array.isArray(prev.history) ? prev.history : []).concat([old]);
742
+ }
743
+ const rec = {
744
+ root_task_id: root,
745
+ steps_file: stepsFile.split(path.sep).join('/'),
746
+ approved_at: core.nowIso(),
747
+ approval: prevApproval(prev) + 1,
748
+ 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,
750
+ tier: r.action === 'refused' ? null : (r.tier || null), depends_on: r.depends_on,
751
+ })),
752
+ history,
753
+ };
754
+ core.writeFileAtomic(file, `${JSON.stringify(rec, null, 2)}\n`);
755
+ return Object.assign({ file }, rec);
756
+ }
757
+
758
+ // ------------------------------------------------------------------ new-id
759
+
760
+ /** `dieu-phoi new-id [--count n]`: fresh valid task_ids, one per line (the orchestrator never invents ids). */
761
+ function cmdNewId(opts) {
762
+ const raw = opts.count === undefined ? '1' : String(opts.count);
763
+ const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
764
+ if (!Number.isInteger(n) || n < 1 || n > NEW_ID_MAX) throw new Error(`--count phải là số nguyên 1–${NEW_ID_MAX}`);
765
+ const ids = new Set();
766
+ while (ids.size < n) ids.add(core.newTaskId());
767
+ const list = Array.from(ids);
768
+ return { code: 0, out: opts.json ? JSON.stringify(list) : list.join('\n') };
769
+ }
770
+
592
771
  function mdCell(s) {
593
772
  return String(s).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
594
773
  }
595
774
 
596
775
  function cmdPlanTable(opts) {
597
- if (typeof opts.file !== 'string') throw new Error('Cú pháp: dieu-phoi plan-table --file <steps.json> [--json]');
776
+ if (typeof opts.file !== 'string') throw new Error('Cú pháp: dieu-phoi plan-table --file <steps.json> [--json] [--approve --root <root_task_id>]');
777
+ if (opts.approve && typeof opts.root !== 'string') {
778
+ throw new Error(`--approve cần --root <root_task_id> (đúng 8 ký tự a-z0-9, vd. ${core.newTaskId()})`);
779
+ }
780
+ if (opts.approve) requireTaskId(opts.root);
598
781
  let raw;
599
782
  try {
600
783
  raw = JSON.parse(fs.readFileSync(path.resolve(opts.file), 'utf8').replace(/^/, ''));
@@ -605,13 +788,24 @@ function cmdPlanTable(opts) {
605
788
  const base = chooseInput(Object.assign({}, opts, { type: 'x', score: 0, override: undefined }), ctx, core.readEvents(ctx));
606
789
  delete base.override;
607
790
  const plan = buildPlanTable(raw, base);
608
- if (opts.json) return { code: 0, out: JSON.stringify(plan, null, 2) };
609
- const lines = ['| Bước | Model/effort | Lý do | Ước tính |', '|---|---|---|---|'];
791
+ const problems = planTaskIdProblems(plan, opts.approve ? String(opts.root) : null);
792
+ if (opts.approve && problems.length) {
793
+ 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
+ }
795
+ 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`);
796
+ const rec = opts.approve ? recordApprovedPlan(ctx, String(opts.root), path.resolve(opts.file), plan) : null;
797
+ 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;
799
+ return { code: 0, out: JSON.stringify(out, null, 2) };
800
+ }
801
+ const lines = ['| Bước | Model/effort | Rubric (điểm · kiểm chứng · đảo ngược) | Lý do | Ước tính |', '|---|---|---|---|---|'];
610
802
  for (const r of plan.steps) {
611
803
  const tierCell = r.action === 'refused' ? '—' : (r.action === 'ask_user' ? `${r.tier} (hỏi người dùng)` : r.tier);
612
- const why = `${r.type}, điểm ${r.score}${r.depends_on.length ? `, sau ${r.depends_on.join(', ')}` : ''} — ${r.reason.replace(/; profile: .*$/, '')}`;
804
+ const yn = (v) => (v ? 'y' : 'n');
805
+ const rubric = `${Number.isFinite(r.score) ? r.score : '?'} · ${yn(r.verifiable)} · ${yn(r.reversible)}`;
806
+ const why = `${r.type}${r.depends_on.length ? `, sau ${r.depends_on.join(', ')}` : ''} — ${r.reason.replace(/; profile: .*$/, '')}`;
613
807
  const est = r.action === 'refused' ? '-' : `~${fmtUsd(r.est_cost_usd)} · ~${fmtNum(r.est_time_min)} phút${r.est_source === 'rough' ? ' (thô)' : ''}`;
614
- lines.push(`| ${mdCell(r.title ? `${r.step}. ${r.title}` : r.step)} | ${mdCell(tierCell)} | ${mdCell(why)} | ${mdCell(est)} |`);
808
+ lines.push(`| ${mdCell(r.title ? `${r.step}. ${r.title}` : r.step)} | ${mdCell(tierCell)} | ${mdCell(rubric)} | ${mdCell(why)} | ${mdCell(est)} |`);
615
809
  }
616
810
  lines.push('');
617
811
  lines.push(`**Tổng chi phí ước tính:** ~${fmtUsd(plan.total_cost_usd)} · **Thời gian (đường găng, chạy song song khi không phụ thuộc):** ~${fmtNum(plan.critical_path_min)} phút (cộng dồn ${fmtNum(plan.sum_time_min)} phút)`);
@@ -621,10 +815,11 @@ function cmdPlanTable(opts) {
621
815
  process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${plan.refused.length} bước bị từ chối
622
816
  `);
623
817
  }
818
+ if (rec) lines.push('', `Đã ghi kế hoạch đã duyệt (root ${rec.root_task_id}, lần duyệt ${rec.approval})`);
624
819
  return { code: 0, out: lines.join('\n') };
625
820
  }
626
821
 
627
822
  module.exports = {
628
- cmdSetup, cmdUninstall, cmdDoctor, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdStats, cmdRetro, cmdChoose,
629
- cmdPlanTable, buildPlanTable, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
823
+ cmdSetup, cmdUninstall, cmdDoctor, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
824
+ cmdPlanTable, cmdNewId, requireTaskId, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
630
825
  };