@kienntpixon/dieu-phoi 0.1.1 → 0.2.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/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
@@ -55,8 +55,9 @@ function cmdSetup(opts) {
55
55
  const lines = [
56
56
  `Đã cài hook: ${res.hook}`,
57
57
  res.settings_changed
58
- ? `Đã đăng ký PostToolUse(Agent) + SubagentStop trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
58
+ ? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
59
59
  : `settings.json đã có hook, không đổi gì (${res.settings})`,
60
+ `Thư mục báo cáo subagent: ${res.runs_dir} (quyền: ${res.permissions.allow.join(', ')} + additionalDirectories)`,
60
61
  `Phòng ban: ${res.config.dept || '(chưa đặt, dùng --dept)'}`,
61
62
  `Sink: ${res.config.sink_url ? res.config.sink_url : '(chưa có — log chỉ lưu cục bộ)'}`,
62
63
  `Skill: ${res.config.skill_dir || '(không thấy; dùng --skill-dir)'}${res.config.skill_version ? ` v${res.config.skill_version}` : ''}`,
@@ -87,8 +88,12 @@ async function cmdDoctor(opts) {
87
88
  add(major >= 18, 'node', `v${process.versions.node}${major >= 18 ? '' : ' (cần ≥ 18)'}`);
88
89
  add(fs.existsSync(ctx.paths.hook), 'hook', ctx.paths.hook);
89
90
  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})`);
91
+ add(reg.PostToolUse && reg.SubagentStop && reg.SessionStart, 'settings',
92
+ `PostToolUse(Agent)=${reg.PostToolUse ? 'có' : 'thiếu'}, SubagentStop=${reg.SubagentStop ? 'có' : 'thiếu'}, SessionStart(compact)=${reg.SessionStart ? 'có' : 'thiếu'} (${reg.file})`);
93
+ const permOk = reg.permissions && reg.runs_dir_exists && !reg.legacy_permissions;
94
+ add(permOk, 'run-permissions', permOk
95
+ ? `${reg.permission_entries.allow.join(', ')} + additionalDirectories ${reg.permission_entries.dir}`
96
+ : `${!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
97
  const hasCfg = fs.existsSync(ctx.paths.config);
93
98
  add(hasCfg, 'config', hasCfg ? `${ctx.paths.config} dept=${ctx.config.dept || '-'}` : 'chưa có — chạy `dieu-phoi setup`');
94
99
  const sd = ctx.config.skill_dir;
@@ -124,6 +129,10 @@ async function cmdDoctor(opts) {
124
129
  add(true, 'internal-subagents', skipped && skipped.count
125
130
  ? `bỏ qua ${skipped.count} SubagentStop của subagent nội bộ Claude Code (lần cuối ${skipped.last_ts})`
126
131
  : 'chưa bỏ qua SubagentStop nội bộ nào');
132
+ const orch = measureOrch(opts, ctx, profileOrNull(opts, ctx));
133
+ if (orch.ctx_tokens !== null) {
134
+ 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');
135
+ }
127
136
  if (ctx.config.sink_url && ctx.config.sink_token) {
128
137
  try {
129
138
  const u = new URL(ctx.config.sink_url);
@@ -141,7 +150,7 @@ async function cmdDoctor(opts) {
141
150
  add(true, 'sink', 'chưa cấu hình (chỉ lưu cục bộ)');
142
151
  }
143
152
  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) };
153
+ if (opts.json) return { code: failed ? 1 : 0, out: JSON.stringify({ ok: !failed, runs_dir: reg.runs_dir, checks }, null, 2) };
145
154
  const out = checks.map((c) => `[${c.level}] ${c.name}: ${c.detail}`).join('\n');
146
155
  return { code: failed ? 1 : 0, out };
147
156
  }
@@ -150,8 +159,9 @@ async function cmdDoctor(opts) {
150
159
 
151
160
  function cmdHook(opts) {
152
161
  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 };
162
+ const r = core.runHook({ home });
163
+ // SessionStart(compact) returns the JSON to print; every other hook event prints nothing.
164
+ return { code: 0, out: r && typeof r.stdout === 'string' ? r.stdout : null };
155
165
  }
156
166
 
157
167
  async function cmdSync(opts) {
@@ -326,6 +336,76 @@ function cmdSteer(opts) {
326
336
  return { code: 0, out: `Đã ghi steer ${kind} (${steerId}) cho ${target}.` };
327
337
  }
328
338
 
339
+ // ------------------------------------------------------------------ ctx (orchestrator context)
340
+
341
+ /**
342
+ * Measure the orchestrating session: --session, else env CLAUDE_CODE_SESSION_ID; transcript found under
343
+ * <claude dir>/projects/<project>/<id>.jsonl. Never throws; ctx_tokens null when it cannot measure.
344
+ */
345
+ function measureOrch(opts, ctx, prof) {
346
+ const sid = typeof opts.session === 'string' && opts.session ? opts.session
347
+ : (process.env.CLAUDE_CODE_SESSION_ID || null);
348
+ const softCap = (prof && prof.orch_ctx_soft_cap) || chooser.DEFAULT_ORCH_CTX_SOFT_CAP;
349
+ let u = { ctx_tokens: null, ctx_baseline: null, ctx_growth: null, cum_usd: null, compactions: null };
350
+ try {
351
+ const file = sid ? core.findSessionTranscript(claudeDirOf(opts), sid) : null;
352
+ if (file) {
353
+ u = core.orchestratorUsage(file, core.loadPricing(ctx), { cacheFile: core.orchCacheFile(ctx.paths, sid, file) });
354
+ }
355
+ } catch (_) { /* measurement never fails the caller */ }
356
+ const over = typeof u.ctx_growth === 'number' && u.ctx_growth > softCap;
357
+ let advice;
358
+ if (!sid) advice = 'Không đo được: không có session (thiếu CLAUDE_CODE_SESSION_ID, dùng --session <id>).';
359
+ else if (u.ctx_tokens === null) advice = 'Không đo được: không thấy transcript của session này.';
360
+ 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.`;
361
+ 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.`;
362
+ return {
363
+ session_id: sid, ctx_tokens: u.ctx_tokens, ctx_baseline: u.ctx_baseline, ctx_growth: u.ctx_growth,
364
+ cum_usd: u.cum_usd, compactions: u.compactions, soft_cap: softCap, over, advice,
365
+ };
366
+ }
367
+
368
+ function profileOrNull(opts, ctx) {
369
+ try { return chooser.loadProfile(skillDirOf(opts, ctx)); } catch (_) { return null; }
370
+ }
371
+
372
+ function cmdCtx(opts) {
373
+ const ctx = ctxOf(opts);
374
+ const m = measureOrch(opts, ctx, profileOrNull(opts, ctx));
375
+ if (opts.json) return { code: 0, out: JSON.stringify(m) };
376
+ if (m.ctx_tokens === null) return { code: 0, out: m.advice };
377
+ return {
378
+ code: 0,
379
+ 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' : ''}, `
380
+ + `chi phí điều phối ≈ ${fmtUsd(m.cum_usd)}, đã nén ${m.compactions === null ? '?' : m.compactions} lần\n${m.advice}`,
381
+ };
382
+ }
383
+
384
+ function orchLine(o) {
385
+ if (!o || !o.sessions) return 'Điều phối: chưa có dispatch nào ghi session_id (cần core ≥ 0.2.0).';
386
+ 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}), `
387
+ + `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).`;
388
+ }
389
+
390
+ // ------------------------------------------------------------------ state (re-injected after /compact)
391
+
392
+ /**
393
+ * Orchestration state of one session rebuilt purely from events.jsonl (same builder the SessionStart(compact)
394
+ * hook uses). Session = --session, else env CLAUDE_CODE_SESSION_ID. Run dirs are pointed to, never read.
395
+ */
396
+ function cmdState(opts) {
397
+ const ctx = ctxOf(opts);
398
+ const sid = typeof opts.session === 'string' && opts.session ? opts.session
399
+ : (process.env.CLAUDE_CODE_SESSION_ID || null);
400
+ if (!sid) {
401
+ if (opts.json) return { code: 0, out: JSON.stringify({ session_id: null, generated_at: core.nowIso(), runs_dir: null, plans: [], tasks: [] }) };
402
+ return { code: 0, out: 'Không có session (thiếu CLAUDE_CODE_SESSION_ID, dùng --session <id>).' };
403
+ }
404
+ const state = core.buildSessionState(core.readEvents(ctx), sid, { plansDir: ctx.paths.plans });
405
+ if (opts.json) return { code: 0, out: JSON.stringify(state, null, 2) };
406
+ return { code: 0, out: core.formatSessionState(state, core.STATE_TEXT_CAP) };
407
+ }
408
+
329
409
  // ------------------------------------------------------------------ stats / retro
330
410
 
331
411
  function lambdaOf(opts, ctx) {
@@ -340,14 +420,16 @@ function pct(v) {
340
420
  function cmdStats(opts) {
341
421
  const ctx = ctxOf(opts);
342
422
  const { lambda } = lambdaOf(opts, ctx);
343
- const { cells, steers } = agg.analyze(core.readEvents(ctx), { lambda });
423
+ const { cells, steers, orchestration } = agg.analyze(core.readEvents(ctx), { lambda });
344
424
  const type = typeof opts.type === 'string' ? opts.type : null;
345
425
  const rows = type ? cells.filter((c) => c.task_type === type) : cells;
346
426
  const steerRows = type ? steers.filter((s) => s.task_type === type) : steers;
347
427
  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) };
428
+ return { code: 0, out: JSON.stringify({ lambda_usd_per_min: lambda, min_n: agg.MIN_N, cells: rows, steers: steerRows, orchestration }, null, 2) };
429
+ }
430
+ if (!rows.length) {
431
+ return { code: 0, out: orchestration.sessions ? `Chưa có dữ liệu theo ô.\n${orchLine(orchestration)}` : 'Chưa có dữ liệu.' };
349
432
  }
350
- if (!rows.length) return { code: 0, out: 'Chưa có dữ liệu.' };
351
433
  const t = [['task_type', 'model/effort', 'n', 'accept%', 'agent_pass%', 'E[cost]', 'E[time]', 'p50', 'p75', 'score', '']];
352
434
  for (const c of rows) {
353
435
  t.push([
@@ -370,7 +452,7 @@ function cmdStats(opts) {
370
452
  }
371
453
  return {
372
454
  code: 0,
373
- out: `${table(t)}\n\n${table(s)}\n\n`
455
+ out: `${table(t)}\n\n${table(s)}\n\n${orchLine(orchestration)}\n\n`
374
456
  + '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
457
  + '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
458
  + `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 +466,7 @@ function cmdRetro(opts) {
384
466
  const ctx = ctxOf(opts);
385
467
  const { prof, lambda } = lambdaOf(opts, ctx);
386
468
  const events = core.readEvents(ctx);
387
- const { agents, tasks, cells, steers, userVerdicts, overrides } = agg.analyze(events, { lambda });
469
+ const { agents, tasks, cells, steers, userVerdicts, overrides, orchestration } = agg.analyze(events, { lambda });
388
470
  const reasons = {};
389
471
  const outcomes = {};
390
472
  const userOutcomes = {};
@@ -433,6 +515,7 @@ function cmdRetro(opts) {
433
515
  steers,
434
516
  high_steer_task_types: highSteer,
435
517
  overrides: overrides.map((o) => ({ ts: o.ts, task_type: o.task_type, suggested_tier: o.suggested_tier, chosen_tier: o.chosen_tier })),
518
+ orchestration,
436
519
  cells,
437
520
  };
438
521
  if (opts.json) return { code: 0, out: JSON.stringify(summary, null, 2) };
@@ -443,6 +526,7 @@ function cmdRetro(opts) {
443
526
  `Lý do hay gặp: ${summary.reason_codes.slice(0, 8).map((r) => `${r.code}×${r.count}`).join(', ') || '-'}`,
444
527
  `Loại task: ${summary.task_types.map((t) => `${t.task_type}(${t.n})`).join(', ') || '-'}`,
445
528
  `Steer nhiều (brainstorm hỏi quá ít): ${highSteer.map((s) => `${s.task_type} ${pct(s.steer_rate)}`).join(', ') || '-'}`,
529
+ orchLine(orchestration),
446
530
  'Chi tiết theo ô: `dieu-phoi stats`; đưa cho LLM: `dieu-phoi retro --json`.',
447
531
  ];
448
532
  return { code: 0, out: lines.join('\n') };
@@ -475,6 +559,10 @@ function cmdChoose(opts) {
475
559
  }
476
560
  const input = chooseInput(opts, ctx, core.readEvents(ctx));
477
561
  const res = chooser.choose(input);
562
+ const orch = measureOrch(opts, ctx, input.profile);
563
+ res.orch_ctx_tokens = orch.ctx_tokens;
564
+ res.orch_ctx_growth = orch.ctx_growth;
565
+ res.orch_ctx_over = orch.over;
478
566
  if (res.overridden) {
479
567
  const tid = input.taskId;
480
568
  core.appendEvent(ctx, Object.assign({
@@ -489,13 +577,14 @@ function cmdChoose(opts) {
489
577
  }, core.commonFields(ctx)));
490
578
  }
491
579
  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ô'})`;
580
+ const warn = orch.over ? `\nCẢNH BÁO: ${orch.advice}` : '';
581
+ 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
582
  if (res.action === 'ask_user') {
494
- return { code: 0, out: `HỎI NGƯỜI DÙNG — ${res.reason}` };
583
+ return { code: 0, out: `HỎI NGƯỜI DÙNG — ${res.reason}${warn}` };
495
584
  }
496
585
  return {
497
586
  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}`,
587
+ 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
588
  };
500
589
  }
501
590
 
@@ -563,7 +652,11 @@ function buildPlanTable(rawSteps, base) {
563
652
  role: s.role || base.role,
564
653
  seed: base.seed === undefined || base.seed === null ? `plan:${s.step}` : `${base.seed}:${s.step}`,
565
654
  }));
566
- const head = { step: s.step, title: s.title, type: s.type, score: Number(s.score), depends_on: s.deps };
655
+ // Rubric inputs that drive the safety floors: shown to the user at gate 1 so mis-scoring is caught.
656
+ const head = {
657
+ step: s.step, title: s.title, type: s.type, task_id: s.task || null, score: Number(s.score),
658
+ verifiable: s.verifiable, reversible: s.reversible, depends_on: s.deps,
659
+ };
567
660
  try {
568
661
  return Object.assign(head, call());
569
662
  } catch (e) {
@@ -589,12 +682,71 @@ function buildPlanTable(rawSteps, base) {
589
682
  };
590
683
  }
591
684
 
685
+ const ROOT_RE = /^[A-Za-z0-9_-]{1,32}$/;
686
+
687
+ /**
688
+ * Record the plan the user approved at gate 1: <data>/plans/<root>.json (local only — not an event, never
689
+ * synced). Each re-approval increments `approval` (number of approvals for this root) and overwrites the
690
+ * record; earlier approvals move to `history`. Not the user-verdict `round`.
691
+ * tier = exactly what the approved table shows (override applied); refused steps -> null.
692
+ */
693
+ function prevApproval(prev) {
694
+ if (!prev || typeof prev !== 'object') return 0;
695
+ const n = Number.isInteger(prev.approval) ? prev.approval : prev.round; // `round`: 0.2.0 dev records
696
+ return Number.isInteger(n) && n > 0 ? n : 0;
697
+ }
698
+
699
+ /**
700
+ * Steps that cannot be matched to dispatches once approved: missing task_id, a task_id used twice, or a
701
+ * task_id equal to the root. Returns Vietnamese problem strings (empty = ok).
702
+ */
703
+ function planTaskIdProblems(plan, root) {
704
+ const problems = [];
705
+ const missing = plan.steps.filter((r) => !r.task_id).map((r) => r.step);
706
+ if (missing.length) problems.push(`thiếu task_id ở bước ${missing.join(', ')}`);
707
+ const by = new Map();
708
+ for (const r of plan.steps) if (r.task_id) by.set(r.task_id, (by.get(r.task_id) || []).concat(r.step));
709
+ 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(', ')}`);
710
+ if (root) {
711
+ const same = plan.steps.filter((r) => r.task_id === root).map((r) => r.step);
712
+ if (same.length) problems.push(`task_id của bước ${same.join(', ')} trùng --root "${root}" (root dành cho scout/planner)`);
713
+ }
714
+ return problems;
715
+ }
716
+
717
+ function recordApprovedPlan(ctx, root, stepsFile, plan) {
718
+ const file = path.join(ctx.paths.plans, `${root}.json`);
719
+ const prev = core.readJsonFile(file, null);
720
+ let history = [];
721
+ if (prev && typeof prev === 'object' && !Array.isArray(prev)) {
722
+ const old = Object.assign({}, prev);
723
+ delete old.history;
724
+ history = (Array.isArray(prev.history) ? prev.history : []).concat([old]);
725
+ }
726
+ const rec = {
727
+ root_task_id: root,
728
+ steps_file: stepsFile.split(path.sep).join('/'),
729
+ approved_at: core.nowIso(),
730
+ approval: prevApproval(prev) + 1,
731
+ steps: plan.steps.map((r) => ({
732
+ id: r.step, task_id: r.task_id || null, title: r.title === undefined ? null : r.title, type: r.type || null,
733
+ tier: r.action === 'refused' ? null : (r.tier || null), depends_on: r.depends_on,
734
+ })),
735
+ history,
736
+ };
737
+ core.writeFileAtomic(file, `${JSON.stringify(rec, null, 2)}\n`);
738
+ return Object.assign({ file }, rec);
739
+ }
740
+
592
741
  function mdCell(s) {
593
742
  return String(s).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
594
743
  }
595
744
 
596
745
  function cmdPlanTable(opts) {
597
- if (typeof opts.file !== 'string') throw new Error('Cú pháp: dieu-phoi plan-table --file <steps.json> [--json]');
746
+ if (typeof opts.file !== 'string') throw new Error('Cú pháp: dieu-phoi plan-table --file <steps.json> [--json] [--approve --root <root_task_id>]');
747
+ if (opts.approve && (typeof opts.root !== 'string' || !ROOT_RE.test(opts.root))) {
748
+ throw new Error('--approve cần --root <root_task_id> (chữ, số, - _; tối đa 32 ký tự)');
749
+ }
598
750
  let raw;
599
751
  try {
600
752
  raw = JSON.parse(fs.readFileSync(path.resolve(opts.file), 'utf8').replace(/^/, ''));
@@ -605,13 +757,24 @@ function cmdPlanTable(opts) {
605
757
  const base = chooseInput(Object.assign({}, opts, { type: 'x', score: 0, override: undefined }), ctx, core.readEvents(ctx));
606
758
  delete base.override;
607
759
  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 |', '|---|---|---|---|'];
760
+ const problems = planTaskIdProblems(plan, opts.approve ? String(opts.root) : null);
761
+ if (opts.approve && problems.length) {
762
+ throw new Error(`plan-table --approve: không ghi kế hoạch — ${problems.join('; ')}. Mỗi bước cần task_id riêng, khác --root.`);
763
+ }
764
+ 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`);
765
+ const rec = opts.approve ? recordApprovedPlan(ctx, String(opts.root), path.resolve(opts.file), plan) : null;
766
+ if (opts.json) {
767
+ const out = rec ? Object.assign({}, plan, { approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file } }) : plan;
768
+ return { code: 0, out: JSON.stringify(out, null, 2) };
769
+ }
770
+ const lines = ['| Bước | Model/effort | Rubric (điểm · kiểm chứng · đảo ngược) | Lý do | Ước tính |', '|---|---|---|---|---|'];
610
771
  for (const r of plan.steps) {
611
772
  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: .*$/, '')}`;
773
+ const yn = (v) => (v ? 'y' : 'n');
774
+ const rubric = `${Number.isFinite(r.score) ? r.score : '?'} · ${yn(r.verifiable)} · ${yn(r.reversible)}`;
775
+ const why = `${r.type}${r.depends_on.length ? `, sau ${r.depends_on.join(', ')}` : ''} — ${r.reason.replace(/; profile: .*$/, '')}`;
613
776
  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)} |`);
777
+ lines.push(`| ${mdCell(r.title ? `${r.step}. ${r.title}` : r.step)} | ${mdCell(tierCell)} | ${mdCell(rubric)} | ${mdCell(why)} | ${mdCell(est)} |`);
615
778
  }
616
779
  lines.push('');
617
780
  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 +784,11 @@ function cmdPlanTable(opts) {
621
784
  process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${plan.refused.length} bước bị từ chối
622
785
  `);
623
786
  }
787
+ if (rec) lines.push('', `Đã ghi kế hoạch đã duyệt (root ${rec.root_task_id}, lần duyệt ${rec.approval})`);
624
788
  return { code: 0, out: lines.join('\n') };
625
789
  }
626
790
 
627
791
  module.exports = {
628
- cmdSetup, cmdUninstall, cmdDoctor, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdStats, cmdRetro, cmdChoose,
629
- cmdPlanTable, buildPlanTable, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
792
+ cmdSetup, cmdUninstall, cmdDoctor, cmdHook, cmdSync, cmdVerdict, cmdSteer, cmdStats, cmdRetro, cmdChoose, cmdCtx, cmdState, measureOrch,
793
+ cmdPlanTable, buildPlanTable, recordApprovedPlan, planTaskIdProblems, criticalPath, resolveAgentId, AGENT_OUTCOMES, USER_OUTCOMES, VERIFY,
630
794
  };
package/lib/setup.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const os = require('os');
4
5
  const path = require('path');
5
6
  const core = require('../hook.cjs');
6
7
 
@@ -70,10 +71,11 @@ function alreadyRegistered(settings, cmd) {
70
71
  if (h.command !== cmd) continue;
71
72
  if (event === 'PostToolUse' && g.matcher === 'Agent') good++;
72
73
  else if (event === 'SubagentStop') good++;
74
+ else if (event === 'SessionStart' && g.matcher === 'compact') good++;
73
75
  }
74
76
  }
75
77
  }
76
- return ours === 2 && good === 2;
78
+ return ours === 3 && good === 3;
77
79
  }
78
80
 
79
81
  function addOurHooks(settings, hookPath) {
@@ -84,6 +86,84 @@ function addOurHooks(settings, hookPath) {
84
86
  h.PostToolUse.push({ matcher: 'Agent', hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
85
87
  if (!Array.isArray(h.SubagentStop)) h.SubagentStop = [];
86
88
  h.SubagentStop.push({ hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
89
+ // after /compact or auto-compact: re-inject the orchestration state rebuilt from the log
90
+ if (!Array.isArray(h.SessionStart)) h.SessionStart = [];
91
+ h.SessionStart.push({ matcher: 'compact', hooks: [{ type: 'command', command: cmd, timeout: 10 }] });
92
+ }
93
+
94
+ // ------------------------------------------------------------------ run-dir permissions
95
+
96
+ /**
97
+ * Run directory for subagent reports: ~/.dieu-phoi/runs (override: DIEU_PHOI_RUNS_DIR, absolute).
98
+ * Deliberately OUTSIDE ~/.claude: `.claude` is a Claude Code protected path, where allow rules never
99
+ * pre-approve writes, so background agents could not write their reports there.
100
+ */
101
+ function runsDir() {
102
+ return core.runsDir();
103
+ }
104
+
105
+ /** Run dir used by 0.2.0 dev builds (<claudeDir>/dieu-phoi/runs); its rules are removed by setup/uninstall. */
106
+ function legacyRunsDirOf(claudeDir) {
107
+ return path.join(path.resolve(claudeDir), 'dieu-phoi', 'runs');
108
+ }
109
+
110
+ /**
111
+ * Permission entries that let background subagents (default permission mode) read and write reports in
112
+ * `runs`: Read(<p>/**) + Edit(<p>/**) allow rules (Edit rules cover every file-writing tool; Claude
113
+ * Code ignores path rules on Write and warns about them, so no Write(...) rule) and the dir itself in
114
+ * permissions.additionalDirectories. <p> = ~/<rel> under the home dir, else //<abs> (Windows: //c/...).
115
+ */
116
+ function permissionEntries(runs, homeDir) {
117
+ const home = path.resolve(homeDir || os.homedir());
118
+ const rel = path.relative(home, runs);
119
+ let pat;
120
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
121
+ pat = `~/${toSlash(rel)}/**`;
122
+ } else {
123
+ const abs = toSlash(runs);
124
+ const m = /^([A-Za-z]):\/(.*)$/.exec(abs);
125
+ pat = m ? `//${m[1].toLowerCase()}/${m[2]}/**` : `/${abs}/**`;
126
+ }
127
+ return { allow: [`Read(${pat})`, `Edit(${pat})`], dir: toSlash(runs) };
128
+ }
129
+
130
+ /** Add our permission entries (idempotent). Returns true when settings changed. */
131
+ function addOurPermissions(settings, entries) {
132
+ let changed = false;
133
+ if (!settings.permissions || typeof settings.permissions !== 'object' || Array.isArray(settings.permissions)) {
134
+ settings.permissions = {};
135
+ changed = true;
136
+ }
137
+ const p = settings.permissions;
138
+ if (!Array.isArray(p.allow)) { p.allow = []; changed = true; }
139
+ for (const r of entries.allow) if (!p.allow.includes(r)) { p.allow.push(r); changed = true; }
140
+ if (!Array.isArray(p.additionalDirectories)) { p.additionalDirectories = []; changed = true; }
141
+ if (!p.additionalDirectories.includes(entries.dir)) { p.additionalDirectories.push(entries.dir); changed = true; }
142
+ return changed;
143
+ }
144
+
145
+ /** Remove exactly our permission entries; a list we emptied is deleted. Returns number removed. */
146
+ function stripOurPermissions(settings, entries) {
147
+ const p = settings.permissions;
148
+ if (!p || typeof p !== 'object' || Array.isArray(p)) return 0;
149
+ let removed = 0;
150
+ const strip = (key, items) => {
151
+ if (!Array.isArray(p[key])) return;
152
+ const kept = p[key].filter((x) => !items.includes(x));
153
+ if (kept.length === p[key].length) return;
154
+ removed += p[key].length - kept.length;
155
+ if (kept.length) p[key] = kept; else delete p[key];
156
+ };
157
+ strip('allow', entries.allow);
158
+ strip('additionalDirectories', [entries.dir]);
159
+ return removed;
160
+ }
161
+
162
+ function hasOurPermissions(settings, entries) {
163
+ const p = settings && settings.permissions;
164
+ if (!p || typeof p !== 'object') return false;
165
+ return entries.allow.every((r) => Array.isArray(p.allow) && p.allow.includes(r))
166
+ && Array.isArray(p.additionalDirectories) && p.additionalDirectories.includes(entries.dir);
87
167
  }
88
168
 
89
169
  function readSettings(file) {
@@ -204,11 +284,20 @@ function setup(opts) {
204
284
  const cmd = hookCommand(paths.hook);
205
285
  let removed = 0;
206
286
  let res = { changed: false, backup: null };
287
+ let dirty = false;
207
288
  if (!(before.exists && alreadyRegistered(obj, cmd))) {
208
289
  removed = stripOurHooks(obj);
209
290
  addOurHooks(obj, paths.hook);
210
- res = writeSettings(settingsFile, before, obj);
291
+ dirty = true;
211
292
  }
293
+ // 4. run dir + permissions so background subagents can write their reports there
294
+ const perms = permissionEntries(runsDir());
295
+ fs.mkdirSync(perms.dir, { recursive: true });
296
+ // rules a 0.2.0 dev build added for <claudeDir>/dieu-phoi/runs (protected path, never worked) go away
297
+ const legacyRemoved = stripOurPermissions(obj, permissionEntries(legacyRunsDirOf(claudeDir)));
298
+ if (legacyRemoved) dirty = true;
299
+ if (addOurPermissions(obj, perms)) dirty = true;
300
+ if (dirty) res = writeSettings(settingsFile, before, obj);
212
301
 
213
302
  return {
214
303
  home,
@@ -218,6 +307,9 @@ function setup(opts) {
218
307
  settings_changed: res.changed,
219
308
  backup: res.backup,
220
309
  replaced_entries: removed,
310
+ runs_dir: perms.dir,
311
+ permissions: perms,
312
+ legacy_permissions_removed: legacyRemoved,
221
313
  config: cfg,
222
314
  };
223
315
  }
@@ -229,29 +321,41 @@ function uninstall(opts) {
229
321
  const before = readSettings(settingsFile);
230
322
  const obj = JSON.parse(JSON.stringify(before.obj));
231
323
  const removed = stripOurHooks(obj);
324
+ const permsRemoved = stripOurPermissions(obj, permissionEntries(runsDir()))
325
+ + stripOurPermissions(obj, permissionEntries(legacyRunsDirOf(claudeDir)));
232
326
  let res = { changed: false, backup: null };
233
- if (removed) res = writeSettings(settingsFile, before, obj);
327
+ if (removed || permsRemoved) res = writeSettings(settingsFile, before, obj);
234
328
  const hookFile = core.pathsFor(home).hook;
235
329
  let hookRemoved = false;
236
330
  if (opts.purge && fs.existsSync(hookFile)) {
237
331
  fs.unlinkSync(hookFile);
238
332
  hookRemoved = true;
239
333
  }
240
- return { removed, settings: settingsFile, backup: res.backup, hook_removed: hookRemoved, data_kept: core.pathsFor(home).data };
334
+ return { removed, permissions_removed: permsRemoved, settings: settingsFile, backup: res.backup, hook_removed: hookRemoved, data_kept: core.pathsFor(home).data };
241
335
  }
242
336
 
243
337
  /** Inspect settings for our registration. */
244
338
  function registration(claudeDir) {
245
339
  const file = path.join(path.resolve(claudeDir), 'settings.json');
246
- const out = { file, PostToolUse: false, SubagentStop: false, commands: [] };
340
+ const perms = permissionEntries(runsDir());
341
+ const legacy = permissionEntries(legacyRunsDirOf(claudeDir));
342
+ const out = {
343
+ file, PostToolUse: false, SubagentStop: false, SessionStart: false, commands: [], permissions: false, permission_entries: perms,
344
+ runs_dir: perms.dir, runs_dir_exists: fs.existsSync(perms.dir), legacy_permissions: false,
345
+ };
247
346
  let obj;
248
347
  try { obj = readSettings(file).obj; } catch (_) { return out; }
348
+ out.permissions = hasOurPermissions(obj, perms);
349
+ const lp = obj.permissions || {};
350
+ out.legacy_permissions = legacy.allow.some((r) => Array.isArray(lp.allow) && lp.allow.includes(r))
351
+ || (Array.isArray(lp.additionalDirectories) && lp.additionalDirectories.includes(legacy.dir));
249
352
  const hooks = obj.hooks || {};
250
- for (const ev of ['PostToolUse', 'SubagentStop']) {
353
+ for (const ev of ['PostToolUse', 'SubagentStop', 'SessionStart']) {
251
354
  for (const g of Array.isArray(hooks[ev]) ? hooks[ev] : []) {
252
355
  for (const h of (g && Array.isArray(g.hooks) ? g.hooks : [])) {
253
356
  if (!isOurs(h)) continue;
254
357
  if (ev === 'PostToolUse' && g.matcher && !/\bAgent\b/.test(g.matcher)) continue;
358
+ if (ev === 'SessionStart' && g.matcher !== 'compact') continue;
255
359
  out[ev] = true;
256
360
  out.commands.push(h.command);
257
361
  }
@@ -262,5 +366,5 @@ function registration(claudeDir) {
262
366
 
263
367
  module.exports = {
264
368
  MARKER, setup, uninstall, registration, stripOurHooks, addOurHooks, alreadyRegistered, hookCommand,
265
- skillVersionOf, findSkillDir, tsStamp,
369
+ skillVersionOf, findSkillDir, tsStamp, runsDir, legacyRunsDirOf, permissionEntries, addOurPermissions, stripOurPermissions, hasOurPermissions,
266
370
  };