@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/aggregate.cjs CHANGED
@@ -9,6 +9,12 @@ const core = require('../hook.cjs');
9
9
  const MIN_N = 5;
10
10
  const FAIL_OUTCOMES = new Set(['fail', 'timeout']);
11
11
  const HEAVY_STEERS = new Set(['plan', 'redirect']);
12
+ // core 0.4.0: an agent stopped by a usage-limit/quota pause is `aborted` — neither pass nor fail. Legacy
13
+ // rows recorded such pauses as `timeout` with a quota-ish reason_code; they are read as `aborted` too.
14
+ const ABORTED = 'aborted';
15
+ // Exact markers only (anchored): `quota-pause`, `usage-limit`, the hook's `auto-*` codes and the legacy
16
+ // `paused-for-quota-*` codes the orchestrator wrote before 0.4.0. A reason that merely mentions a rate limit stays a timeout.
17
+ const QUOTA_REASON_RE = /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/;
12
18
 
13
19
  function tsOf(ev) {
14
20
  const t = Date.parse(ev && ev.ts);
@@ -56,7 +62,33 @@ function normalizeVerdict(ev) {
56
62
  const by = ev.by === 'user' ? 'user' : 'agent';
57
63
  let cause = ev.cause || null;
58
64
  if (by === 'user' && ev.outcome === 'reject' && cause !== 'scope') cause = 'quality';
59
- return { by, outcome: ev.outcome, cause, round: Number(ev.round) || null };
65
+ let outcome = ev.outcome;
66
+ if (by === 'agent' && outcome === 'timeout' && typeof ev.reason_code === 'string' && QUOTA_REASON_RE.test(ev.reason_code)) {
67
+ outcome = ABORTED;
68
+ }
69
+ return { by, outcome, cause, round: Number(ev.round) || null };
70
+ }
71
+
72
+ /**
73
+ * Task ids withdrawn with `dieu-phoi discard --task` (kind=discard). Every event of such a task — and every
74
+ * event of an agent dispatched for it — is left out of tasks, cells and steers (CONTRACT "Sự kiện cục bộ").
75
+ */
76
+ function discardedTaskIds(events) {
77
+ const out = new Set();
78
+ for (const ev of events) if (ev && ev.kind === 'discard' && typeof ev.task_id === 'string' && ev.task_id) out.add(ev.task_id);
79
+ return out;
80
+ }
81
+
82
+ /** Events with discarded tasks (and the discard records themselves) replaced by null; order kept. */
83
+ function withoutDiscarded(events) {
84
+ const gone = discardedTaskIds(events);
85
+ const agents = new Set(events.filter((ev) => ev && ev.kind === 'dispatch' && gone.has(ev.task_id)).map((ev) => ev.agent_id));
86
+ return events.map((ev) => {
87
+ if (!ev || typeof ev !== 'object' || ev.kind === 'discard') return null;
88
+ if (gone.has(ev.task_id) || agents.has(ev.agent_id)) return null;
89
+ if (typeof ev.agent_id === 'string' && ev.agent_id.startsWith('task:') && gone.has(ev.agent_id.slice(5))) return null;
90
+ return ev;
91
+ });
60
92
  }
61
93
 
62
94
  function blank(v) {
@@ -111,7 +143,9 @@ function buildAgents(events) {
111
143
  const d = a.dispatch || {};
112
144
  const c0 = a.completes[0] || {};
113
145
  const cl = a.completes[a.completes.length - 1] || {};
114
- const v = a.verdicts.slice().sort((x, y) => tsOf(x) - tsOf(y) || x._idx - y._idx);
146
+ const vAll = a.verdicts.slice().sort((x, y) => tsOf(x) - tsOf(y) || x._idx - y._idx);
147
+ // `aborted` (quota pause) is neither pass nor fail: it never sets the outcome nor counts as a result.
148
+ const v = vAll.filter((x) => x.outcome !== ABORTED);
115
149
  const verdict = v.length ? v[v.length - 1] : null;
116
150
  const firstVerdictTs = v.length ? tsOf(v[0]) : Infinity;
117
151
  const pick = (k) => (d[k] !== undefined && d[k] !== null ? d[k] : (c0[k] !== undefined && c0[k] !== null ? c0[k] : null));
@@ -137,6 +171,9 @@ function buildAgents(events) {
137
171
  tool_uses: a.completes.length ? sumOrNull(a.completes.map((x) => x.tool_uses)) : null,
138
172
  outcome: verdict ? verdict.outcome : null,
139
173
  first_outcome: v.length ? v[0].outcome : null,
174
+ // every non-aborted agent verdict in time order: a retry by SendMessage to the same agent is a new result
175
+ verdict_seq: v.map((x) => ({ outcome: x.outcome, ts: tsOf(x), idx: x._idx })),
176
+ aborted: vAll.length > 0 && vAll[vAll.length - 1].outcome === ABORTED,
140
177
  verify: verdict ? verdict.verify : null,
141
178
  reason_code: verdict ? verdict.reason_code : null,
142
179
  steers: a.steers.map((s) => s.steer_kind).filter(Boolean),
@@ -175,16 +212,25 @@ function buildTasks(agents, userVerdicts) {
175
212
  const first = t.agents.filter((a) => a.role === 'exec').sort(byTry)[0] || null;
176
213
  const user = t.user.slice().sort((x, y) => ((x.round || 0) - (y.round || 0)) || (tsOf(x) - tsOf(y)) || (x._idx - y._idx));
177
214
 
178
- // Effective per-attempt outcome for escalation: agent verdict, overridden by a user verdict
179
- // landing on the latest attempt dispatched before it (reject/quality = fail, accept = pass).
180
- for (const a of attempts) a.eff_outcome = a.outcome === 'pass' ? 'pass' : (FAIL_OUTCOMES.has(a.outcome) ? 'fail' : null);
215
+ // Results for escalation. Each agent verdict of an attempt is one result at its tier (a retry by
216
+ // SendMessage to the same agent is a new result); a user verdict adds a result to the latest attempt
217
+ // dispatched before it (reject/quality = fail, accept = pass). eff_outcome = the attempt's latest result.
218
+ for (const a of attempts) {
219
+ a.results = (a.verdict_seq || [])
220
+ .filter((x) => x.outcome === 'pass' || FAIL_OUTCOMES.has(x.outcome))
221
+ .map((x) => ({ outcome: x.outcome === 'pass' ? 'pass' : 'fail', ts: x.ts, idx: x.idx }));
222
+ a.eff_outcome = a.results.length ? a.results[a.results.length - 1].outcome : null;
223
+ }
181
224
  for (const v of user) {
182
225
  if (v.cause === 'scope') continue;
183
226
  const vt = tsOf(v);
184
227
  let target = null;
185
228
  for (const a of attempts) if (a.ts <= vt && (!target || a.ts >= target.ts)) target = a;
186
229
  if (!target) target = attempts[attempts.length - 1] || null;
187
- if (target) target.eff_outcome = v.outcome === 'accept' ? 'pass' : 'fail';
230
+ if (target) {
231
+ target.eff_outcome = v.outcome === 'accept' ? 'pass' : 'fail';
232
+ target.results.push({ outcome: target.eff_outcome, ts: vt, idx: v._idx, user: true });
233
+ }
188
234
  }
189
235
 
190
236
  const firstUser = user[0] || null;
@@ -307,13 +353,19 @@ function canonicalCells(cells) {
307
353
  return cells.map((c) => Object.fromEntries(CANONICAL_CELL_FIELDS.map((k) => [k, c[k] === undefined ? null : c[k]])));
308
354
  }
309
355
 
310
- /** Steering per task_type, counted in distinct user actions (steer_id), not events. */
356
+ /**
357
+ * Steering per task_type, counted in distinct user actions (steer_id), not events. Core 0.4.0: only
358
+ * exec-phase steers count — a steer on a role=exec agent, or a task-level steer on a task that has an exec
359
+ * attempt. Steers on scouts/planners (gate-1 Q&A) and on verifiers go to `plan_phase_actions` and never
360
+ * touch actions / steer_rate.
361
+ */
311
362
  function buildSteers(tasks, steers, agents) {
312
363
  const typeOfTask = new Map(tasks.map((t) => [t.task_id, t.task_type]));
364
+ const execTask = new Set(tasks.filter((t) => t.first && t.task_id).map((t) => t.task_id));
313
365
  const agentById = new Map(agents.map((a) => [a.agent_id, a]));
314
366
  const by = {};
315
367
  const row = (type) => by[type] || (by[type] = {
316
- task_type: type, tasks: 0, steered_tasks: 0, actions: 0, minor: 0, plan: 0, redirect: 0, _tasks: new Set(), _ids: new Set(),
368
+ task_type: type, tasks: 0, steered_tasks: 0, actions: 0, minor: 0, plan: 0, redirect: 0, _tasks: new Set(), _ids: new Set(), _plan: new Set(),
317
369
  });
318
370
  for (const t of tasks) if (t.first) row(t.task_type).tasks++;
319
371
  for (const s of steers) {
@@ -322,6 +374,8 @@ function buildSteers(tasks, steers, agents) {
322
374
  const type = (taskId && typeOfTask.get(taskId)) || s.task_type || (a && a.task_type) || 'untagged';
323
375
  const r = row(type);
324
376
  const id = steerIdOf(s);
377
+ const execPhase = a ? a.role === 'exec' : !(taskId && typeOfTask.has(taskId) && !execTask.has(taskId));
378
+ if (!execPhase) { r._plan.add(id); continue; }
325
379
  if (r._ids.has(id)) continue;
326
380
  r._ids.add(id);
327
381
  r.actions++;
@@ -334,6 +388,7 @@ function buildSteers(tasks, steers, agents) {
334
388
  task_type: r.task_type, tasks: r.tasks, steered_tasks: steered, actions: r.actions,
335
389
  minor: r.minor, plan: r.plan, redirect: r.redirect,
336
390
  steer_rate: r.tasks ? round6(Math.min(1, steered / r.tasks)) : 0,
391
+ plan_phase_actions: Array.from(r._plan).filter((x) => !r._ids.has(x)).length,
337
392
  };
338
393
  return outRow;
339
394
  }).sort((a, b) => b.steer_rate - a.steer_rate || a.task_type.localeCompare(b.task_type));
@@ -345,7 +400,8 @@ function finiteNum(v) {
345
400
 
346
401
  /**
347
402
  * 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
403
+ * dispatch events. Per session: ctx = max orch_ctx_tokens, usd = max orch_cum_usd minus orch_cum_usd of the
404
+ * session's first dispatch carrying a number (core 0.4.0; never negative), growth = max of
349
405
  * max(0, orch_ctx_tokens - orch_ctx_baseline) over dispatches carrying both, comp = max orch_compactions
350
406
  * (numbers only). compactions_total = sum of per-session comp, compactions_max = max.
351
407
  * Each agent belongs to the session of its FIRST dispatch (file order); sub_usd_total sums the
@@ -357,11 +413,14 @@ function orchestrationSummary(events) {
357
413
  for (const ev of events) {
358
414
  if (!ev || ev.kind !== 'dispatch' || typeof ev.session_id !== 'string' || !ev.session_id) continue;
359
415
  const sid = ev.session_id;
360
- if (!sessions.has(sid)) sessions.set(sid, { ctx: null, usd: null, growth: null, comp: null });
416
+ if (!sessions.has(sid)) sessions.set(sid, { ctx: null, usd: null, base: null, growth: null, comp: null });
361
417
  const s = sessions.get(sid);
362
418
  const c = finiteNum(ev.orch_ctx_tokens);
363
419
  const u = finiteNum(ev.orch_cum_usd);
364
420
  if (c !== null && (s.ctx === null || c > s.ctx)) s.ctx = c;
421
+ // core 0.4.0: orchestrator cost counts from the session's FIRST dispatch carrying a number (file order),
422
+ // not from session start — whatever the session did before it started dispatching is not orchestration.
423
+ if (u !== null && s.base === null) s.base = u;
365
424
  if (u !== null && (s.usd === null || u > s.usd)) s.usd = u;
366
425
  const k = finiteNum(ev.orch_compactions);
367
426
  if (k !== null && (s.comp === null || k > s.comp)) s.comp = k;
@@ -386,7 +445,7 @@ function orchestrationSummary(events) {
386
445
  if (s.ctx !== null) ctxs.push(s.ctx);
387
446
  if (s.growth !== null) growths.push(s.growth);
388
447
  if (s.comp !== null) comps.push(s.comp);
389
- if (s.usd !== null) orch += s.usd;
448
+ if (s.usd !== null) orch += Math.max(0, s.usd - s.base);
390
449
  }
391
450
  return {
392
451
  sessions: sessions.size,
@@ -403,7 +462,7 @@ function orchestrationSummary(events) {
403
462
  }
404
463
 
405
464
  function analyze(events, opts) {
406
- const { agents, userVerdicts, overrides, steers } = buildAgents(events);
465
+ const { agents, userVerdicts, overrides, steers } = buildAgents(withoutDiscarded(events));
407
466
  const tasks = buildTasks(agents, userVerdicts);
408
467
  const cells = buildCells(tasks, opts);
409
468
  return {
@@ -413,6 +472,6 @@ function analyze(events, opts) {
413
472
  }
414
473
 
415
474
  module.exports = {
416
- MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
475
+ MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, ABORTED, QUOTA_REASON_RE, discardedTaskIds, withoutDiscarded, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
417
476
  buildCells, buildSteers, canonicalCells, orchestrationSummary, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
418
477
  };
package/lib/choose.cjs CHANGED
@@ -19,7 +19,8 @@ const LADDER = [
19
19
  const TOP = LADDER.length - 1;
20
20
  const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
21
21
 
22
- // Built-in defaults = skill/profile/profile.json (v1.0.0); used only when no profile is found.
22
+ // Built-in defaults = skill/profile/profile.json for the fixed task types (time caps as tuned 2026-10-12: feature-impl 80,
23
+ // content-write 40, debug 55 — the old ones sat below the observed p50); used only when no profile is found.
23
24
  const DEFAULT_PRIORS = {
24
25
  search: 'haiku/medium',
25
26
  extract: 'haiku/medium',
@@ -38,13 +39,13 @@ const DEFAULT_TIME_CAP_MIN = {
38
39
  search: 3,
39
40
  extract: 5,
40
41
  'mechanical-edit': 10,
41
- 'feature-impl': 25,
42
- debug: 25,
42
+ 'feature-impl': 80,
43
+ debug: 55,
43
44
  refactor: 30,
44
45
  'test-write': 15,
45
46
  review: 10,
46
47
  'research-synth': 20,
47
- 'content-write': 15,
48
+ 'content-write': 40,
48
49
  'plan-design': 15,
49
50
  'data-analysis': 15,
50
51
  };
@@ -187,25 +188,35 @@ const OPUS_MEDIUM = 5;
187
188
  * - one fail at a tier -> same tier again (retry with the error);
188
189
  * - two fails at the same tier -> next tier up;
189
190
  * - if that next tier would exceed the opus/medium ceiling -> ask the user (no auto re-plan).
190
- * Fail = agent fail|timeout or user reject cause=quality (eff_outcome). Attempts steered
191
- * plan/redirect by the user are ignored.
191
+ * Fail = agent fail|timeout or user reject cause=quality. Core 0.4.0: every result counts, so a retry by
192
+ * SendMessage to the same agent (fail, then fail again) is two fails at that tier; `aborted` (quota pause)
193
+ * is never a result. Attempts steered plan/redirect by the user are ignored.
192
194
  */
193
195
  function escalation(task, role) {
194
196
  const none = { floor: 0, ask: false, note: null, fails: 0, tried: [] };
195
197
  if (!task) return none;
196
198
  const att = task.attempts
197
- .filter((a) => a.eff_outcome && a.role !== 'verify' && !a.steered)
198
- .map((a) => ({ a, i: tierIndex(a.model, a.effort) }))
199
- .filter((x) => x.i !== null)
199
+ .filter((a) => a.role !== 'verify' && !a.steered)
200
+ .map((a) => ({
201
+ a,
202
+ i: tierIndex(a.model, a.effort),
203
+ results: Array.isArray(a.results) ? a.results : (a.eff_outcome ? [{ outcome: a.eff_outcome, ts: a.ts, idx: 0 }] : []),
204
+ }))
205
+ .filter((x) => x.i !== null && x.results.length)
200
206
  .sort((x, y) => (x.a.try - y.a.try) || (x.a.ts - y.a.ts));
201
207
  if (!att.length) return none;
208
+ const res = [];
209
+ for (const x of att) {
210
+ const rs = x.results.slice().sort((p, q) => (p.ts - q.ts) || ((p.idx || 0) - (q.idx || 0)));
211
+ for (const r of rs) res.push({ i: x.i, outcome: r.outcome });
212
+ }
202
213
  const tried = Array.from(new Set(att.map((x) => tierName(x.i))));
203
- const totalFails = att.filter((x) => x.a.eff_outcome === 'fail').length;
204
- const last = att[att.length - 1];
214
+ const totalFails = res.filter((r) => r.outcome === 'fail').length;
215
+ const last = res[res.length - 1];
205
216
  const cur = last.i;
206
217
  const base = { tried, fails: totalFails, ask: false };
207
- if (last.a.eff_outcome !== 'fail') return Object.assign(base, { floor: cur, note: null });
208
- const fails = att.filter((x) => x.i === cur && x.a.eff_outcome === 'fail').length;
218
+ if (last.outcome !== 'fail') return Object.assign(base, { floor: cur, note: null });
219
+ const fails = res.filter((r) => r.i === cur && r.outcome === 'fail').length;
209
220
  if (fails < 2) {
210
221
  return Object.assign(base, { floor: cur, note: `task fail 1 lần ở ${tierName(cur)} → làm lại cùng tier kèm lỗi` });
211
222
  }
@@ -287,6 +298,40 @@ function estimateFor(idx, cell, pricing) {
287
298
  return roughEstimate(idx, pricing);
288
299
  }
289
300
 
301
+ // ------------------------------------------------------------------ task type guard
302
+
303
+ // Ad-hoc labels seen in real logs → the closest fixed type (core 0.4.0). A label maps only when its target
304
+ // is known (fixed list or the profile's extra_task_types), e.g. art-fix → art-gen needs art-gen in the profile.
305
+ const TYPE_ALIASES = {
306
+ 'bug-fix': 'debug', bugfix: 'debug', fix: 'debug', bug: 'debug',
307
+ config: 'mechanical-edit', shell: 'mechanical-edit', deploy: 'mechanical-edit', ops: 'mechanical-edit', edit: 'mechanical-edit',
308
+ docs: 'content-write', doc: 'content-write', documentation: 'content-write', writing: 'content-write', copy: 'content-write',
309
+ design: 'plan-design', plan: 'plan-design', planning: 'plan-design', architecture: 'plan-design',
310
+ research: 'research-synth', synth: 'research-synth',
311
+ test: 'test-write', tests: 'test-write',
312
+ analysis: 'data-analysis', data: 'data-analysis',
313
+ impl: 'feature-impl', feature: 'feature-impl', implement: 'feature-impl',
314
+ 'code-review': 'review',
315
+ 'art-fix': 'art-gen', art: 'art-gen',
316
+ };
317
+
318
+ /**
319
+ * { type, known, mapped_from, warning }: a fixed/extra type passes; an ad-hoc label with a known alias target
320
+ * maps to it (with a warning); anything else stays unknown (choose asks the user to pick a fixed type).
321
+ */
322
+ function resolveTaskType(raw, profile) {
323
+ const t = String(raw || '').toLowerCase();
324
+ const extra = (profile && profile.extra_task_types) || [];
325
+ const isKnown = (x) => core.TASK_TYPES.includes(x) || extra.includes(x);
326
+ if (isKnown(t)) return { type: t, known: true, mapped_from: null, warning: null };
327
+ const target = TYPE_ALIASES[t];
328
+ if (target && isKnown(target)) {
329
+ return { type: target, known: true, mapped_from: t, warning: `loại "${t}" không cố định → dùng "${target}" (ghi --type ${target} trong thẻ)` };
330
+ }
331
+ const list = core.TASK_TYPES.concat(extra).join(', ');
332
+ return { type: t, known: false, mapped_from: null, warning: `loại "${t}" không có trong danh sách cố định (${list}) — chọn loại gần nhất hoặc hỏi người dùng` };
333
+ }
334
+
290
335
  // ------------------------------------------------------------------ choose
291
336
 
292
337
  /**
@@ -296,8 +341,8 @@ function estimateFor(idx, cell, pricing) {
296
341
  * Throws when an override goes below a safety floor or off the ladder.
297
342
  */
298
343
  function choose(opts) {
299
- const type = String(opts.type || '').toLowerCase();
300
- if (!type) throw new Error('Thiếu --type');
344
+ const rawType = String(opts.type || '').toLowerCase();
345
+ if (!rawType) throw new Error('Thiếu --type');
301
346
  const score = Number(opts.score);
302
347
  if (opts.score === undefined || opts.score === null || opts.score === true || !isFinite(score)
303
348
  || score < 0 || score > 12) throw new Error('--score phải trong 0–12');
@@ -327,12 +372,15 @@ function choose(opts) {
327
372
  const notes = [];
328
373
 
329
374
  const analysis = agg.analyze(opts.events || [], { lambda });
330
- const known = core.TASK_TYPES.includes(type) || profile.extra_task_types.includes(type);
375
+ const rt = resolveTaskType(rawType, profile);
376
+ const type = rt.type;
377
+ const known = rt.known;
378
+ if (rt.warning) notes.push(rt.warning);
331
379
  let prior = profile.priors[type];
332
380
  let priorWhy = 'prior';
333
381
  if (prior === undefined || prior === null) {
334
382
  prior = OPUS_MEDIUM;
335
- notes.push(known ? 'không có prior, dùng opus/medium' : `loại "${type}" lạ, dùng opus/medium`);
383
+ if (known) notes.push('không có prior, dùng opus/medium');
336
384
  }
337
385
  const ov = overridePrior(analysis.overrides, type);
338
386
  if (ov && ov.tier !== prior) {
@@ -382,10 +430,17 @@ function choose(opts) {
382
430
  explored,
383
431
  tier: tierName(idx),
384
432
  role,
433
+ task_type: type,
385
434
  budget_s: budgetFor(cell),
386
- }, estimateFor(idx, cell, opts.pricing), { profile_source: src }, extra || {});
435
+ }, estimateFor(idx, cell, opts.pricing), { profile_source: src },
436
+ rt.mapped_from ? { mapped_from: rt.mapped_from } : {}, rt.warning ? { type_warning: rt.warning } : {}, extra || {});
387
437
  };
388
438
 
439
+ // Unknown task type (not fixed, not in the profile, no alias): its stats would never pool — ask first.
440
+ if (!known) {
441
+ return finish(Math.max(prior, safetyFloor), rt.warning, false, { action: 'ask_user', unknown_type: true });
442
+ }
443
+
389
444
  if (overrideIdx !== null && overrideIdx < safetyFloor) {
390
445
  throw new Error(`Từ chối override ${tierName(overrideIdx)}: dưới sàn an toàn ${tierName(safetyFloor)} (${floorWhy.join(', ')}).`);
391
446
  }
@@ -474,4 +529,5 @@ module.exports = {
474
529
  DEFAULT_ORCH_CTX_SOFT_CAP,
475
530
  LADDER, DEFAULT_PRIORS, DEFAULT_LAMBDA, DEFAULT_TIME_CAP_MIN, ROUGH, tierIndex, tierName, parsePrior,
476
531
  parseTier, loadProfile, makeRng, betaSample, escalation, overridePrior, roughEstimate, choose, shortModel,
532
+ TYPE_ALIASES, resolveTaskType,
477
533
  };