@kienntpixon/dieu-phoi 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/aggregate.cjs CHANGED
@@ -9,6 +9,15 @@ 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)(?:-|$)/;
18
+ // core 0.5.0: `aborted` with this reason = the agent stopped on the context watchdog and wrote handoff.md. Its
19
+ // continuation (next exec agent of the task with the same try) carries the attempt's agent-layer result.
20
+ const HANDOFF_REASON_RE = /^(?:auto-)?handoff(?:-|$)/;
12
21
 
13
22
  function tsOf(ev) {
14
23
  const t = Date.parse(ev && ev.ts);
@@ -56,7 +65,33 @@ function normalizeVerdict(ev) {
56
65
  const by = ev.by === 'user' ? 'user' : 'agent';
57
66
  let cause = ev.cause || null;
58
67
  if (by === 'user' && ev.outcome === 'reject' && cause !== 'scope') cause = 'quality';
59
- return { by, outcome: ev.outcome, cause, round: Number(ev.round) || null };
68
+ let outcome = ev.outcome;
69
+ if (by === 'agent' && outcome === 'timeout' && typeof ev.reason_code === 'string' && QUOTA_REASON_RE.test(ev.reason_code)) {
70
+ outcome = ABORTED;
71
+ }
72
+ return { by, outcome, cause, round: Number(ev.round) || null };
73
+ }
74
+
75
+ /**
76
+ * Task ids withdrawn with `dieu-phoi discard --task` (kind=discard). Every event of such a task — and every
77
+ * event of an agent dispatched for it — is left out of tasks, cells and steers (CONTRACT "Sự kiện cục bộ").
78
+ */
79
+ function discardedTaskIds(events) {
80
+ const out = new Set();
81
+ for (const ev of events) if (ev && ev.kind === 'discard' && typeof ev.task_id === 'string' && ev.task_id) out.add(ev.task_id);
82
+ return out;
83
+ }
84
+
85
+ /** Events with discarded tasks (and the discard records themselves) replaced by null; order kept. */
86
+ function withoutDiscarded(events) {
87
+ const gone = discardedTaskIds(events);
88
+ const agents = new Set(events.filter((ev) => ev && ev.kind === 'dispatch' && gone.has(ev.task_id)).map((ev) => ev.agent_id));
89
+ return events.map((ev) => {
90
+ if (!ev || typeof ev !== 'object' || ev.kind === 'discard') return null;
91
+ if (gone.has(ev.task_id) || agents.has(ev.agent_id)) return null;
92
+ if (typeof ev.agent_id === 'string' && ev.agent_id.startsWith('task:') && gone.has(ev.agent_id.slice(5))) return null;
93
+ return ev;
94
+ });
60
95
  }
61
96
 
62
97
  function blank(v) {
@@ -111,7 +146,9 @@ function buildAgents(events) {
111
146
  const d = a.dispatch || {};
112
147
  const c0 = a.completes[0] || {};
113
148
  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);
149
+ const vAll = a.verdicts.slice().sort((x, y) => tsOf(x) - tsOf(y) || x._idx - y._idx);
150
+ // `aborted` (quota pause) is neither pass nor fail: it never sets the outcome nor counts as a result.
151
+ const v = vAll.filter((x) => x.outcome !== ABORTED);
115
152
  const verdict = v.length ? v[v.length - 1] : null;
116
153
  const firstVerdictTs = v.length ? tsOf(v[0]) : Infinity;
117
154
  const pick = (k) => (d[k] !== undefined && d[k] !== null ? d[k] : (c0[k] !== undefined && c0[k] !== null ? c0[k] : null));
@@ -137,6 +174,10 @@ function buildAgents(events) {
137
174
  tool_uses: a.completes.length ? sumOrNull(a.completes.map((x) => x.tool_uses)) : null,
138
175
  outcome: verdict ? verdict.outcome : null,
139
176
  first_outcome: v.length ? v[0].outcome : null,
177
+ // every non-aborted agent verdict in time order: a retry by SendMessage to the same agent is a new result
178
+ verdict_seq: v.map((x) => ({ outcome: x.outcome, ts: tsOf(x), idx: x._idx })),
179
+ aborted: vAll.length > 0 && vAll[vAll.length - 1].outcome === ABORTED,
180
+ handoff: vAll.some((x) => x.outcome === ABORTED && HANDOFF_REASON_RE.test(String(x.reason_code || ''))),
140
181
  verify: verdict ? verdict.verify : null,
141
182
  reason_code: verdict ? verdict.reason_code : null,
142
183
  steers: a.steers.map((s) => s.steer_kind).filter(Boolean),
@@ -169,22 +210,41 @@ function buildTasks(agents, userVerdicts) {
169
210
  const tasks = [];
170
211
  for (const t of map.values()) {
171
212
  const byTry = (x, y) => (x.try - y.try) || (x.ts - y.ts) || (x._idx - y._idx);
172
- const attempts = t.agents.filter((a) => a.role !== 'verify').sort(byTry);
213
+ // gen-2 helpers (role=helper, own task_id) are never attempts
214
+ const attempts = t.agents.filter((a) => a.role !== 'verify' && a.role !== 'helper').sort(byTry);
173
215
  const verifies = t.agents.filter((a) => a.role === 'verify');
174
216
  // First exec attempt: lowest try among role=exec (ties: earliest). No exec attempt -> no sample.
175
- const first = t.agents.filter((a) => a.role === 'exec').sort(byTry)[0] || null;
217
+ const execs = t.agents.filter((a) => a.role === 'exec').sort(byTry);
218
+ const first = execs[0] || null;
219
+ // core 0.5.0: a first exec that stopped on a context handoff (no result of its own) passes the agent layer to its
220
+ // continuation(s) with the same try; the tier stays the first exec's.
221
+ let firstRes = first;
222
+ const chain = first ? [first] : [];
223
+ for (let i = 1; firstRes && !firstRes.first_outcome && firstRes.handoff && i < execs.length && execs[i].try === first.try; i++) {
224
+ firstRes = execs[i];
225
+ chain.push(firstRes);
226
+ }
176
227
  const user = t.user.slice().sort((x, y) => ((x.round || 0) - (y.round || 0)) || (tsOf(x) - tsOf(y)) || (x._idx - y._idx));
177
228
 
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);
229
+ // Results for escalation. Each agent verdict of an attempt is one result at its tier (a retry by
230
+ // SendMessage to the same agent is a new result); a user verdict adds a result to the latest attempt
231
+ // dispatched before it (reject/quality = fail, accept = pass). eff_outcome = the attempt's latest result.
232
+ for (const a of attempts) {
233
+ a.results = (a.verdict_seq || [])
234
+ .filter((x) => x.outcome === 'pass' || FAIL_OUTCOMES.has(x.outcome))
235
+ .map((x) => ({ outcome: x.outcome === 'pass' ? 'pass' : 'fail', ts: x.ts, idx: x.idx }));
236
+ a.eff_outcome = a.results.length ? a.results[a.results.length - 1].outcome : null;
237
+ }
181
238
  for (const v of user) {
182
239
  if (v.cause === 'scope') continue;
183
240
  const vt = tsOf(v);
184
241
  let target = null;
185
242
  for (const a of attempts) if (a.ts <= vt && (!target || a.ts >= target.ts)) target = a;
186
243
  if (!target) target = attempts[attempts.length - 1] || null;
187
- if (target) target.eff_outcome = v.outcome === 'accept' ? 'pass' : 'fail';
244
+ if (target) {
245
+ target.eff_outcome = v.outcome === 'accept' ? 'pass' : 'fail';
246
+ target.results.push({ outcome: target.eff_outcome, ts: vt, idx: v._idx, user: true });
247
+ }
188
248
  }
189
249
 
190
250
  const firstUser = user[0] || null;
@@ -194,8 +254,8 @@ function buildTasks(agents, userVerdicts) {
194
254
  else userOutcome = firstUser.cause === 'scope' ? 'reject_scope' : 'reject_quality';
195
255
  }
196
256
  // Agent layer of the first exec attempt: its first agent verdict.
197
- const agentFirst = first && first.first_outcome && (first.first_outcome === 'pass' || FAIL_OUTCOMES.has(first.first_outcome))
198
- ? first.first_outcome === 'pass' : null;
257
+ const agentFirst = firstRes && firstRes.first_outcome && (firstRes.first_outcome === 'pass' || FAIL_OUTCOMES.has(firstRes.first_outcome))
258
+ ? firstRes.first_outcome === 'pass' : null;
199
259
  let success = null;
200
260
  let source = null;
201
261
  if (userOutcome === 'accept') { success = true; source = 'user'; } else if (userOutcome === 'reject_quality') { success = false; source = 'user'; } else if (!userOutcome && agentFirst !== null) {
@@ -209,6 +269,11 @@ function buildTasks(agents, userVerdicts) {
209
269
  task_id: t.task_id,
210
270
  task_type: first ? first.task_type : (t.agents[0] && t.agents[0].task_type) || 'untagged',
211
271
  first,
272
+ // the first attempt as a whole: its exec segment plus every handoff continuation (time/cost summed over all
273
+ // segments; the result comes from the last one, firstRes)
274
+ first_chain: chain,
275
+ first_cost: chain.length ? sumOrNull(chain.map((a) => a.cost_usd)) : null,
276
+ first_time_s: chain.length ? sumOrNull(chain.map((a) => a.duration_s)) : null,
212
277
  attempts,
213
278
  verifies,
214
279
  user_verdicts: user,
@@ -258,8 +323,9 @@ function buildCells(tasks, opts) {
258
323
  const passes = c.tasks.filter((t) => t.success).length;
259
324
  const costs = c.tasks.map((t) => t.total_cost);
260
325
  const timesMin = c.tasks.map((t) => (t.total_time_s === null ? null : t.total_time_s / 60));
261
- const attemptTimes = c.tasks.map((t) => (t.first.duration_s === null ? null : t.first.duration_s / 60));
262
- const attemptCosts = c.tasks.map((t) => t.first.cost_usd);
326
+ // a handoff chain is one attempt: all its segments count (t.first alone would bill only the first segment)
327
+ const attemptTimes = c.tasks.map((t) => (t.first_time_s === null || t.first_time_s === undefined ? null : t.first_time_s / 60));
328
+ const attemptCosts = c.tasks.map((t) => (t.first_cost === undefined ? null : t.first_cost));
263
329
  const costMean = mean(costs);
264
330
  const timeMean = mean(timesMin);
265
331
  out.push({
@@ -307,13 +373,19 @@ function canonicalCells(cells) {
307
373
  return cells.map((c) => Object.fromEntries(CANONICAL_CELL_FIELDS.map((k) => [k, c[k] === undefined ? null : c[k]])));
308
374
  }
309
375
 
310
- /** Steering per task_type, counted in distinct user actions (steer_id), not events. */
376
+ /**
377
+ * Steering per task_type, counted in distinct user actions (steer_id), not events. Core 0.4.0: only
378
+ * exec-phase steers count — a steer on a role=exec agent, or a task-level steer on a task that has an exec
379
+ * attempt. Steers on scouts/planners (gate-1 Q&A) and on verifiers go to `plan_phase_actions` and never
380
+ * touch actions / steer_rate.
381
+ */
311
382
  function buildSteers(tasks, steers, agents) {
312
383
  const typeOfTask = new Map(tasks.map((t) => [t.task_id, t.task_type]));
384
+ const execTask = new Set(tasks.filter((t) => t.first && t.task_id).map((t) => t.task_id));
313
385
  const agentById = new Map(agents.map((a) => [a.agent_id, a]));
314
386
  const by = {};
315
387
  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(),
388
+ 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
389
  });
318
390
  for (const t of tasks) if (t.first) row(t.task_type).tasks++;
319
391
  for (const s of steers) {
@@ -322,6 +394,8 @@ function buildSteers(tasks, steers, agents) {
322
394
  const type = (taskId && typeOfTask.get(taskId)) || s.task_type || (a && a.task_type) || 'untagged';
323
395
  const r = row(type);
324
396
  const id = steerIdOf(s);
397
+ const execPhase = a ? a.role === 'exec' : !(taskId && typeOfTask.has(taskId) && !execTask.has(taskId));
398
+ if (!execPhase) { r._plan.add(id); continue; }
325
399
  if (r._ids.has(id)) continue;
326
400
  r._ids.add(id);
327
401
  r.actions++;
@@ -334,6 +408,7 @@ function buildSteers(tasks, steers, agents) {
334
408
  task_type: r.task_type, tasks: r.tasks, steered_tasks: steered, actions: r.actions,
335
409
  minor: r.minor, plan: r.plan, redirect: r.redirect,
336
410
  steer_rate: r.tasks ? round6(Math.min(1, steered / r.tasks)) : 0,
411
+ plan_phase_actions: Array.from(r._plan).filter((x) => !r._ids.has(x)).length,
337
412
  };
338
413
  return outRow;
339
414
  }).sort((a, b) => b.steer_rate - a.steer_rate || a.task_type.localeCompare(b.task_type));
@@ -345,7 +420,8 @@ function finiteNum(v) {
345
420
 
346
421
  /**
347
422
  * 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
423
+ * dispatch events. Per session: ctx = max orch_ctx_tokens, usd = max orch_cum_usd minus orch_cum_usd of the
424
+ * session's first dispatch carrying a number (core 0.4.0; never negative), growth = max of
349
425
  * max(0, orch_ctx_tokens - orch_ctx_baseline) over dispatches carrying both, comp = max orch_compactions
350
426
  * (numbers only). compactions_total = sum of per-session comp, compactions_max = max.
351
427
  * Each agent belongs to the session of its FIRST dispatch (file order); sub_usd_total sums the
@@ -357,11 +433,14 @@ function orchestrationSummary(events) {
357
433
  for (const ev of events) {
358
434
  if (!ev || ev.kind !== 'dispatch' || typeof ev.session_id !== 'string' || !ev.session_id) continue;
359
435
  const sid = ev.session_id;
360
- if (!sessions.has(sid)) sessions.set(sid, { ctx: null, usd: null, growth: null, comp: null });
436
+ if (!sessions.has(sid)) sessions.set(sid, { ctx: null, usd: null, base: null, growth: null, comp: null });
361
437
  const s = sessions.get(sid);
362
438
  const c = finiteNum(ev.orch_ctx_tokens);
363
439
  const u = finiteNum(ev.orch_cum_usd);
364
440
  if (c !== null && (s.ctx === null || c > s.ctx)) s.ctx = c;
441
+ // core 0.4.0: orchestrator cost counts from the session's FIRST dispatch carrying a number (file order),
442
+ // not from session start — whatever the session did before it started dispatching is not orchestration.
443
+ if (u !== null && s.base === null) s.base = u;
365
444
  if (u !== null && (s.usd === null || u > s.usd)) s.usd = u;
366
445
  const k = finiteNum(ev.orch_compactions);
367
446
  if (k !== null && (s.comp === null || k > s.comp)) s.comp = k;
@@ -386,7 +465,7 @@ function orchestrationSummary(events) {
386
465
  if (s.ctx !== null) ctxs.push(s.ctx);
387
466
  if (s.growth !== null) growths.push(s.growth);
388
467
  if (s.comp !== null) comps.push(s.comp);
389
- if (s.usd !== null) orch += s.usd;
468
+ if (s.usd !== null) orch += Math.max(0, s.usd - s.base);
390
469
  }
391
470
  return {
392
471
  sessions: sessions.size,
@@ -403,7 +482,7 @@ function orchestrationSummary(events) {
403
482
  }
404
483
 
405
484
  function analyze(events, opts) {
406
- const { agents, userVerdicts, overrides, steers } = buildAgents(events);
485
+ const { agents, userVerdicts, overrides, steers } = buildAgents(withoutDiscarded(events));
407
486
  const tasks = buildTasks(agents, userVerdicts);
408
487
  const cells = buildCells(tasks, opts);
409
488
  return {
@@ -413,6 +492,6 @@ function analyze(events, opts) {
413
492
  }
414
493
 
415
494
  module.exports = {
416
- MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
495
+ MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, ABORTED, QUOTA_REASON_RE, HANDOFF_REASON_RE, discardedTaskIds, withoutDiscarded, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
417
496
  buildCells, buildSteers, canonicalCells, orchestrationSummary, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
418
497
  };
package/lib/choose.cjs CHANGED
@@ -17,9 +17,11 @@ const LADDER = [
17
17
  { model: 'opus', effort: 'medium' },
18
18
  ];
19
19
  const TOP = LADDER.length - 1;
20
+ const HELPER_TOP = 4; // sonnet/high: ceiling for role=helper (gen-2)
20
21
  const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
21
22
 
22
- // Built-in defaults = skill/profile/profile.json (v1.0.0); used only when no profile is found.
23
+ // Built-in defaults = skill/profile/profile.json for the fixed task types (time caps as tuned 2026-10-12: feature-impl 80,
24
+ // content-write 40, debug 55 — the old ones sat below the observed p50); used only when no profile is found.
23
25
  const DEFAULT_PRIORS = {
24
26
  search: 'haiku/medium',
25
27
  extract: 'haiku/medium',
@@ -38,13 +40,13 @@ const DEFAULT_TIME_CAP_MIN = {
38
40
  search: 3,
39
41
  extract: 5,
40
42
  'mechanical-edit': 10,
41
- 'feature-impl': 25,
42
- debug: 25,
43
+ 'feature-impl': 80,
44
+ debug: 55,
43
45
  refactor: 30,
44
46
  'test-write': 15,
45
47
  review: 10,
46
48
  'research-synth': 20,
47
- 'content-write': 15,
49
+ 'content-write': 40,
48
50
  'plan-design': 15,
49
51
  'data-analysis': 15,
50
52
  };
@@ -187,25 +189,35 @@ const OPUS_MEDIUM = 5;
187
189
  * - one fail at a tier -> same tier again (retry with the error);
188
190
  * - two fails at the same tier -> next tier up;
189
191
  * - 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.
192
+ * Fail = agent fail|timeout or user reject cause=quality. Core 0.4.0: every result counts, so a retry by
193
+ * SendMessage to the same agent (fail, then fail again) is two fails at that tier; `aborted` (quota pause)
194
+ * is never a result. Attempts steered plan/redirect by the user are ignored.
192
195
  */
193
196
  function escalation(task, role) {
194
197
  const none = { floor: 0, ask: false, note: null, fails: 0, tried: [] };
195
198
  if (!task) return none;
196
199
  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)
200
+ .filter((a) => a.role !== 'verify' && !a.steered)
201
+ .map((a) => ({
202
+ a,
203
+ i: tierIndex(a.model, a.effort),
204
+ results: Array.isArray(a.results) ? a.results : (a.eff_outcome ? [{ outcome: a.eff_outcome, ts: a.ts, idx: 0 }] : []),
205
+ }))
206
+ .filter((x) => x.i !== null && x.results.length)
200
207
  .sort((x, y) => (x.a.try - y.a.try) || (x.a.ts - y.a.ts));
201
208
  if (!att.length) return none;
209
+ const res = [];
210
+ for (const x of att) {
211
+ const rs = x.results.slice().sort((p, q) => (p.ts - q.ts) || ((p.idx || 0) - (q.idx || 0)));
212
+ for (const r of rs) res.push({ i: x.i, outcome: r.outcome });
213
+ }
202
214
  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];
215
+ const totalFails = res.filter((r) => r.outcome === 'fail').length;
216
+ const last = res[res.length - 1];
205
217
  const cur = last.i;
206
218
  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;
219
+ if (last.outcome !== 'fail') return Object.assign(base, { floor: cur, note: null });
220
+ const fails = res.filter((r) => r.i === cur && r.outcome === 'fail').length;
209
221
  if (fails < 2) {
210
222
  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
223
  }
@@ -287,6 +299,40 @@ function estimateFor(idx, cell, pricing) {
287
299
  return roughEstimate(idx, pricing);
288
300
  }
289
301
 
302
+ // ------------------------------------------------------------------ task type guard
303
+
304
+ // Ad-hoc labels seen in real logs → the closest fixed type (core 0.4.0). A label maps only when its target
305
+ // is known (fixed list or the profile's extra_task_types), e.g. art-fix → art-gen needs art-gen in the profile.
306
+ const TYPE_ALIASES = {
307
+ 'bug-fix': 'debug', bugfix: 'debug', fix: 'debug', bug: 'debug',
308
+ config: 'mechanical-edit', shell: 'mechanical-edit', deploy: 'mechanical-edit', ops: 'mechanical-edit', edit: 'mechanical-edit',
309
+ docs: 'content-write', doc: 'content-write', documentation: 'content-write', writing: 'content-write', copy: 'content-write',
310
+ design: 'plan-design', plan: 'plan-design', planning: 'plan-design', architecture: 'plan-design',
311
+ research: 'research-synth', synth: 'research-synth',
312
+ test: 'test-write', tests: 'test-write',
313
+ analysis: 'data-analysis', data: 'data-analysis',
314
+ impl: 'feature-impl', feature: 'feature-impl', implement: 'feature-impl',
315
+ 'code-review': 'review',
316
+ 'art-fix': 'art-gen', art: 'art-gen',
317
+ };
318
+
319
+ /**
320
+ * { type, known, mapped_from, warning }: a fixed/extra type passes; an ad-hoc label with a known alias target
321
+ * maps to it (with a warning); anything else stays unknown (choose asks the user to pick a fixed type).
322
+ */
323
+ function resolveTaskType(raw, profile) {
324
+ const t = String(raw || '').toLowerCase();
325
+ const extra = (profile && profile.extra_task_types) || [];
326
+ const isKnown = (x) => core.TASK_TYPES.includes(x) || extra.includes(x);
327
+ if (isKnown(t)) return { type: t, known: true, mapped_from: null, warning: null };
328
+ const target = TYPE_ALIASES[t];
329
+ if (target && isKnown(target)) {
330
+ return { type: target, known: true, mapped_from: t, warning: `loại "${t}" không cố định → dùng "${target}" (ghi --type ${target} trong thẻ)` };
331
+ }
332
+ const list = core.TASK_TYPES.concat(extra).join(', ');
333
+ 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` };
334
+ }
335
+
290
336
  // ------------------------------------------------------------------ choose
291
337
 
292
338
  /**
@@ -296,8 +342,8 @@ function estimateFor(idx, cell, pricing) {
296
342
  * Throws when an override goes below a safety floor or off the ladder.
297
343
  */
298
344
  function choose(opts) {
299
- const type = String(opts.type || '').toLowerCase();
300
- if (!type) throw new Error('Thiếu --type');
345
+ const rawType = String(opts.type || '').toLowerCase();
346
+ if (!rawType) throw new Error('Thiếu --type');
301
347
  const score = Number(opts.score);
302
348
  if (opts.score === undefined || opts.score === null || opts.score === true || !isFinite(score)
303
349
  || score < 0 || score > 12) throw new Error('--score phải trong 0–12');
@@ -327,12 +373,15 @@ function choose(opts) {
327
373
  const notes = [];
328
374
 
329
375
  const analysis = agg.analyze(opts.events || [], { lambda });
330
- const known = core.TASK_TYPES.includes(type) || profile.extra_task_types.includes(type);
376
+ const rt = resolveTaskType(rawType, profile);
377
+ const type = rt.type;
378
+ const known = rt.known;
379
+ if (rt.warning) notes.push(rt.warning);
331
380
  let prior = profile.priors[type];
332
381
  let priorWhy = 'prior';
333
382
  if (prior === undefined || prior === null) {
334
383
  prior = OPUS_MEDIUM;
335
- notes.push(known ? 'không có prior, dùng opus/medium' : `loại "${type}" lạ, dùng opus/medium`);
384
+ if (known) notes.push('không có prior, dùng opus/medium');
336
385
  }
337
386
  const ov = overridePrior(analysis.overrides, type);
338
387
  if (ov && ov.tier !== prior) {
@@ -370,7 +419,10 @@ function choose(opts) {
370
419
  && shortModel(c.model) === LADDER[i].model && c.effort === LADDER[i].effort) || null;
371
420
 
372
421
  const src = profile.source || 'builtin';
373
- const finish = (idx, reason, explored, extra) => {
422
+ const finish = (idx0, reason0, explored, extra) => {
423
+ // core 0.5.0: a gen-2 helper (spawned by a dp-exec) is haiku|sonnet only — clamp to sonnet/high
424
+ const idx = role === 'helper' ? Math.min(idx0, HELPER_TOP) : idx0;
425
+ const reason = idx === idx0 ? reason0 : `${reason0}; helper: trần ${tierName(HELPER_TOP)}`;
374
426
  const t = LADDER[idx];
375
427
  const cell = cellAt(idx);
376
428
  return Object.assign({
@@ -382,10 +434,17 @@ function choose(opts) {
382
434
  explored,
383
435
  tier: tierName(idx),
384
436
  role,
437
+ task_type: type,
385
438
  budget_s: budgetFor(cell),
386
- }, estimateFor(idx, cell, opts.pricing), { profile_source: src }, extra || {});
439
+ }, estimateFor(idx, cell, opts.pricing), { profile_source: src },
440
+ rt.mapped_from ? { mapped_from: rt.mapped_from } : {}, rt.warning ? { type_warning: rt.warning } : {}, extra || {});
387
441
  };
388
442
 
443
+ // Unknown task type (not fixed, not in the profile, no alias): its stats would never pool — ask first.
444
+ if (!known) {
445
+ return finish(Math.max(prior, safetyFloor), rt.warning, false, { action: 'ask_user', unknown_type: true });
446
+ }
447
+
389
448
  if (overrideIdx !== null && overrideIdx < safetyFloor) {
390
449
  throw new Error(`Từ chối override ${tierName(overrideIdx)}: dưới sàn an toàn ${tierName(safetyFloor)} (${floorWhy.join(', ')}).`);
391
450
  }
@@ -474,4 +533,5 @@ module.exports = {
474
533
  DEFAULT_ORCH_CTX_SOFT_CAP,
475
534
  LADDER, DEFAULT_PRIORS, DEFAULT_LAMBDA, DEFAULT_TIME_CAP_MIN, ROUGH, tierIndex, tierName, parsePrior,
476
535
  parseTier, loadProfile, makeRng, betaSample, escalation, overridePrior, roughEstimate, choose, shortModel,
536
+ TYPE_ALIASES, resolveTaskType,
477
537
  };