@kienntpixon/dieu-phoi 0.7.3 → 0.7.4

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/choose.cjs CHANGED
@@ -145,6 +145,7 @@ function loadProfile(skillDir) {
145
145
  return {
146
146
  source, dept: r.dept || null, priors, lambda, explore_rate: explore, time_cap_min: timeCap, extra_task_types: extra,
147
147
  orch_ctx_soft_cap: softCap, tier_floor: tierFloor,
148
+ batch_verify_normal: r.batch_verify_normal !== false,
148
149
  };
149
150
  }
150
151
 
@@ -334,23 +335,26 @@ function resolveTaskType(raw, profile) {
334
335
 
335
336
  // ------------------------------------------------------------------ verify plan (core 0.7.0, mode per request)
336
337
 
337
- const VERIFY_PLANS = ['independent-blocking', 'independent-nonblocking', 'self+batch', 'spot'];
338
+ const VERIFY_PLANS = ['independent-blocking', 'independent-nonblocking', 'self+batch'];
338
339
  /** fast: highest score still allowed to drop its own blocking verifier (s<=6 self+batch, s7-8 independent-nonblocking). */
339
340
  const FAST_SELF_MAX = 6;
340
341
  const FAST_SOFT_MAX = 8;
341
- /** strict: share of steps that also get a random Opus reviewer spot check. */
342
- const STRICT_SPOT_RATE = 1 / 3;
343
-
342
+ /** normal (0.7.4, user decision Q1): s<=4 with no step depending on it -> self+batch (one verifier per group, not per step). */
343
+ const NORMAL_BATCH_MAX = 4;
344
344
  /**
345
345
  * How one exec step is checked, from the request mode and the step's rubric inputs.
346
346
  * The safety floor is evaluated FIRST and wins in every mode: reversible=n, publish/deploy, verifiable=n, score>=9
347
- * always get an independent blocking verifier. Only after that does the mode loosen (fast) or tighten (strict).
348
- * opts: { mode, score, verifiable, reversible, publish, dependents, seed }
349
- * Returns { verify_plan, verify_reason, verify_floor: string[], test_scope: 'selective'|'full' }.
347
+ * always get an independent blocking verifier. Only after that does fast loosen it, and normal batch the s<=4 steps.
348
+ * Legacy mode `strict` (removed 0.7.4) reads as normal.
349
+ * opts: { mode, score, verifiable, reversible, publish, dependents, batchNormal, seed }
350
+ * batchNormal === false (profile.batch_verify_normal=false) switches the normal batch off (rollback switch).
351
+ * Returns { verify_plan, verify_reason, verify_floor: string[], test_scope: 'full' | 'selective' }. Step verification (exec, independent
352
+ * verifier, batch verifier) always runs the touched-module tests; the full suite runs once at the release gate, not here.
353
+ * test_scope is 'full' only for a publish/deploy step (that step is the release gate's trigger); every other step is 'selective'.
350
354
  */
351
355
  function verifyPlanFor(opts) {
352
- const mode = opts.mode || core.DEFAULT_MODE;
353
- if (!core.MODES.includes(mode)) throw new Error(`--mode phải là ${core.MODES.join('|')}`);
356
+ const mode = core.normMode(opts.mode || core.DEFAULT_MODE);
357
+ if (!mode) throw new Error(`--mode phải là ${core.MODES.join('|')}`);
354
358
  const score = Number(opts.score);
355
359
  const floor = [];
356
360
  if (opts.reversible === false) floor.push('khó hoàn tác');
@@ -358,23 +362,24 @@ function verifyPlanFor(opts) {
358
362
  if (opts.verifiable === false) floor.push('không kiểm chứng bằng máy');
359
363
  if (score >= 9) floor.push(`score ${score}≥9`);
360
364
  const out = (plan, reason, scope) => ({ verify_plan: plan, verify_reason: reason, verify_floor: floor, test_scope: scope });
361
- if (floor.length) return out('independent-blocking', `sàn an toàn (${floor.join(', ')}) → verifier độc lập chặn bước, mọi mode`, 'full');
365
+ if (floor.length) return out('independent-blocking', `sàn an toàn (${floor.join(', ')}) → verifier độc lập chặn bước, mọi mode`, opts.publish === true ? 'full' : 'selective');
362
366
  if (mode === 'fast') {
363
367
  if (score <= FAST_SELF_MAX) {
364
- return out('self+batch', `fast, s${score}≤${FAST_SELF_MAX}: exec tự kiểm (test chọn lọc theo file đổi, --verify self); verifier gộp chạy cả suite một lần trước cổng 3`, 'selective');
368
+ return out('self+batch', `fast, s${score}≤${FAST_SELF_MAX}: exec tự kiểm (test chọn lọc theo file đổi, --verify self); verifier gộp chạy test chọn lọc của các file cả nhóm đã đụng; full suite chỉ ở cổng phát hành`, 'selective');
365
369
  }
366
370
  if (score <= FAST_SOFT_MAX) {
367
371
  return opts.dependents === true
368
- ? out('independent-blocking', `fast, s${score}: bước sau phụ thuộc (depends_on) vào bước này → verifier độc lập vẫn chặn`, 'full')
369
- : out('independent-nonblocking', `fast, s${score}: verifier độc lập nhưng không chặn bước sau (không bước nào depends_on)`, 'full');
372
+ ? out('independent-blocking', `fast, s${score}: bước sau phụ thuộc (depends_on) vào bước này → verifier độc lập vẫn chặn`, 'selective')
373
+ : out('independent-nonblocking', `fast, s${score}: verifier độc lập nhưng không chặn bước sau (không bước nào depends_on)`, 'selective');
370
374
  }
371
375
  }
372
- if (mode === 'strict') {
373
- const u = makeRng(opts.seed === undefined || opts.seed === null || opts.seed === true ? undefined : `spot:${opts.seed}`)();
374
- if (u < STRICT_SPOT_RATE) return out('spot', 'strict: verifier độc lập chặn + reviewer Opus kiểm ngẫu nhiên (1/3), luôn chạy cả suite', 'full');
375
- return out('independent-blocking', 'strict: verifier độc lập chặn, luôn chạy cả suite', 'full');
376
+ if (mode === 'normal' && score <= NORMAL_BATCH_MAX && opts.batchNormal !== false) {
377
+ if (opts.dependents !== true) {
378
+ return out('self+batch', `normal, s${score}≤${NORMAL_BATCH_MAX}, không bước nào phụ thuộc: exec tự kiểm (test chọn lọc theo file đổi, --verify self); verifier gộp chạy test chọn lọc của các file cả nhóm đã đụng (một lần cuối nhóm, trước cổng 3); full suite chỉ ở cổng phát hành`, 'selective');
379
+ }
380
+ return out('independent-blocking', `normal, s${score}: bước sau phụ thuộc (depends_on) vào bước này → verifier độc lập chặn`, 'selective');
376
381
  }
377
- return out('independent-blocking', `${mode}: verifier độc lập chặn bước`, 'full');
382
+ return out('independent-blocking', `${mode}: verifier độc lập chặn bước`, 'selective');
378
383
  }
379
384
 
380
385
  // ------------------------------------------------------------------ choose
@@ -412,14 +417,14 @@ function choose(opts) {
412
417
  }
413
418
  const reversible = opts.reversible !== false;
414
419
  const verifiable = opts.verifiable !== false;
415
- if (opts.mode !== undefined && opts.mode !== null && !core.MODES.includes(opts.mode)) {
420
+ if (opts.mode !== undefined && opts.mode !== null && !core.normMode(opts.mode)) {
416
421
  throw new Error(`--mode phải là ${core.MODES.join('|')} (nhận "${opts.mode}")`);
417
422
  }
418
- const mode = opts.mode || core.DEFAULT_MODE;
423
+ const mode = core.normMode(opts.mode || core.DEFAULT_MODE);
424
+ const profile = opts.profile || loadProfile(null);
419
425
  const vp = role === 'exec'
420
- ? verifyPlanFor({ mode, score, verifiable, reversible, publish: opts.publish === true, dependents: opts.dependents === true, seed: opts.seed })
426
+ ? verifyPlanFor({ mode, score, verifiable, reversible, publish: opts.publish === true, dependents: opts.dependents === true, batchNormal: profile.batch_verify_normal, seed: opts.seed })
421
427
  : null;
422
- const profile = opts.profile || loadProfile(null);
423
428
  const lambda = profile.lambda[urgency];
424
429
  const rng = makeRng(opts.seed);
425
430
  const notes = [];
package/lib/commands.cjs CHANGED
@@ -411,7 +411,7 @@ function cmdStart(opts) {
411
411
  core.modeLine(info),
412
412
  ...core.orchLessonLines(info),
413
413
  ...core.knowledgeLines(info),
414
- ];
414
+ ].filter((l) => l !== '');
415
415
  return { code: 0, out: out.join('\n') };
416
416
  }
417
417
 
@@ -435,23 +435,24 @@ function modeEvent(ctx, root, mode, source, reason, session) {
435
435
  }
436
436
 
437
437
  /**
438
- * `dieu-phoi mode --root <root_task_id> [fast|normal|strict] [--reason R] [--session S]`: show or change the mode of a
438
+ * `dieu-phoi mode --root <root_task_id> [fast|normal] [--reason R] [--session S]`: show or change the mode of a
439
439
  * request. One event per change; the same mode again writes nothing. Steps not yet dispatched follow the new mode,
440
440
  * dispatched steps keep the mode they were given. Never changes a mode by itself.
441
441
  */
442
442
  function cmdMode(opts) {
443
- const usage = 'Cú pháp: dieu-phoi mode --root <root_task_id> [fast|normal|strict] [--reason R] [--session S] [--json]';
443
+ const usage = 'Cú pháp: dieu-phoi mode --root <root_task_id> [fast|normal] [--reason R] [--session S] [--json]';
444
444
  if (typeof opts.root !== 'string') throw new Error(usage);
445
445
  const root = requireTaskId(opts.root);
446
446
  const ctx = ctxOf(opts);
447
447
  const cur = core.rootModeFromEvents(core.readEvents(ctx), root);
448
448
  const was = cur ? cur.mode : core.DEFAULT_MODE;
449
- const want = opts._[0];
449
+ const wantRaw = opts._[0];
450
450
  const show = (extra) => (opts.json ? JSON.stringify(Object.assign({ root_task_id: root, mode: was, previous: was, changed: false }, extra)) : null);
451
- if (want === undefined) {
451
+ if (wantRaw === undefined) {
452
452
  return { code: 0, out: show({}) || `root ${root}: mode=${was}${cur ? ` (từ ${cur.ts}, nguồn ${cur.source || '?'})` : ' (mặc định, chưa đặt)'}` };
453
453
  }
454
- if (!core.isMode(want)) throw new Error(`mode phải là ${core.MODES.join('|')} (nhận "${want}"). ${usage}`);
454
+ const want = userMode(wantRaw);
455
+ if (!want) throw new Error(`mode phải là ${core.MODES.join('|')} (nhận "${wantRaw}"). ${usage}`);
455
456
  if (cur && was === want) {
456
457
  return { code: 0, out: show({}) || `root ${root}: vẫn ${want} (không đổi, không ghi sự kiện)` };
457
458
  }
@@ -980,6 +981,28 @@ function liveLine(live) {
980
981
  return `live: phiên ${part(live.session || 0, lim.session)} · máy ${part(live.machine, lim.machine)}${tasks ? ` — ${tasks}` : ''}`;
981
982
  }
982
983
 
984
+ /**
985
+ * core 0.7.4 (S1): steps of an approved plan (last 7 days) that run self+batch, whose exec has completed, and that have
986
+ * neither a batch verifier verdict (reason_code batch-verify) nor a user verdict yet. Gate 3 must not see them unchecked.
987
+ */
988
+ function batchPendingOf(ctx, events, now) {
989
+ let files = [];
990
+ try { files = fs.readdirSync(ctx.paths.plans).filter((f) => f.endsWith('.json')); } catch (_) { return []; }
991
+ const out = [];
992
+ for (const f of files) {
993
+ const rec = core.readJsonFile(path.join(ctx.paths.plans, f), null);
994
+ if (!rec || !Array.isArray(rec.steps)) continue;
995
+ const fresh = Date.parse(rec.approved_at) > now - 7 * 24 * 3600 * 1000;
996
+ if (!fresh) continue;
997
+ const pending = rec.steps.filter((s) => s && s.verify_plan === 'self+batch' && s.task_id
998
+ && events.some((e) => e.kind === 'complete' && e.task_id === s.task_id)
999
+ && !events.some((e) => e.kind === 'verdict' && e.task_id === s.task_id
1000
+ && (String(e.reason_code || '').startsWith('batch-verify') || e.by === 'user')));
1001
+ if (pending.length) out.push({ root_task_id: rec.root_task_id || f.replace(/\.json$/, ''), steps: pending.map((s) => s.id) });
1002
+ }
1003
+ return out;
1004
+ }
1005
+
983
1006
  function cmdState(opts) {
984
1007
  const ctx = ctxOf(opts);
985
1008
  const sid = typeof opts.session === 'string' && opts.session ? opts.session
@@ -998,17 +1021,19 @@ function cmdState(opts) {
998
1021
  const orphans = orphanCount(ctx); // s16: null (unknown) leaves the line out
999
1022
  const live = core.liveSummary(ctx, sid); // core 0.7.3 (s1)
1000
1023
  const stuck = core.stuckAgents(ctx, { sessionId: sid }); // s6
1024
+ const batchPending = batchPendingOf(ctx, core.readEvents(ctx), Date.now()); // core 0.7.4 (S1)
1001
1025
  if (opts.json) {
1002
1026
  const liveJ = {
1003
1027
  session: live.session, machine: live.machine, limits: live.limits, free_session: freeSlots(live.limits.session, live.session), free_machine: freeSlots(live.limits.machine, live.machine), agents: live.agents,
1004
1028
  stuck: stuck.map((a) => ({ agent_id: a.agent_id, task_id: a.task_id, role: a.role, gen: a.gen, state: a.state, reasons: a.reasons, idle_min: a.idle_min, age_min: a.age_min })),
1005
1029
  };
1006
- return { code: 0, out: JSON.stringify(Object.assign({}, state, hasDeltas ? { unmerged_deltas: deltas } : {}, orphans === null ? {} : { orphan_procs: orphans }, { live: liveJ }), null, 2) };
1030
+ return { code: 0, out: JSON.stringify(Object.assign({}, state, hasDeltas ? { unmerged_deltas: deltas } : {}, orphans === null ? {} : { orphan_procs: orphans }, { live: liveJ }, batchPending.length ? { batch_pending: batchPending } : {}), null, 2) };
1007
1031
  }
1032
+ const batchLines = batchPending.map((p) => `CẢNH BÁO: ${p.root_task_id}: ${p.steps.length} bước self+batch (${p.steps.join(', ')}) chưa có verdict verifier gộp (batch-verify) — giao verifier gộp (brief H) trước cổng 3`);
1008
1033
  const stuckLines = stuck.length
1009
1034
  ? [`stuck: ${stuck.slice(0, 6).map((a) => `${a.task_id || a.agent_id}:${a.role} ${a.state === 'orphan' ? 'mồ côi' : 'kẹt'} ${a.idle_min}'`).join(', ')} → TaskStop rồi \`dieu-phoi reap --yes\``]
1010
1035
  : [];
1011
- return { code: 0, out: [core.formatSessionState(state, core.STATE_TEXT_CAP)].concat(deltaLines, orphans === null ? [] : [`orphan_procs: ${orphans}`], [liveLine(live)], stuckLines).join('\n') };
1036
+ return { code: 0, out: [core.formatSessionState(state, core.STATE_TEXT_CAP)].concat(deltaLines, orphans === null ? [] : [`orphan_procs: ${orphans}`], [liveLine(live)], stuckLines, batchLines).join('\n') };
1012
1037
  }
1013
1038
 
1014
1039
  // ------------------------------------------------------------------ reap (core 0.7.3 s6)
@@ -1390,11 +1415,24 @@ function cmdRetro(opts) {
1390
1415
 
1391
1416
  // ------------------------------------------------------------------ choose
1392
1417
 
1418
+ /**
1419
+ * A user-supplied mode value: fast | normal; the removed `strict` (0.7.4) is accepted with a warning and becomes
1420
+ * normal; anything else null.
1421
+ */
1422
+ function userMode(raw) {
1423
+ if (raw === core.LEGACY_MODE) {
1424
+ process.stderr.write('dieu-phoi: mode strict đã bỏ từ 0.7.4 — dùng normal (sàn an toàn vẫn luôn có verifier độc lập chặn).\n');
1425
+ return core.DEFAULT_MODE;
1426
+ }
1427
+ return core.isMode(raw) ? raw : null;
1428
+ }
1429
+
1393
1430
  /** --mode value (validated) or undefined; a bare --mode is an error. */
1394
1431
  function modeOpt(opts) {
1395
1432
  if (opts.mode === undefined) return undefined;
1396
- if (typeof opts.mode !== 'string' || !core.isMode(opts.mode)) throw new Error(`--mode phải là ${core.MODES.join('|')}`);
1397
- return opts.mode;
1433
+ const m = typeof opts.mode === 'string' ? userMode(opts.mode) : null;
1434
+ if (!m) throw new Error(`--mode phải là ${core.MODES.join('|')}`);
1435
+ return m;
1398
1436
  }
1399
1437
 
1400
1438
  function chooseInput(opts, ctx, events) {
@@ -1432,15 +1470,39 @@ function overrideEvent(ctx, taskId, taskType, suggested, chosen) {
1432
1470
  }
1433
1471
 
1434
1472
  /**
1435
- * core 0.7.0 (s2): mode_suggest for choose (exec only). The root's CURRENT mode decides (a dispatched step keeps its
1436
- * fast mode, but once the user moved the root back there is nothing to suggest). Never changes the mode.
1473
+ * core 0.7.4 (B1): the step of the approved plan (<plans>/<root>.json) whose task_id is taskId, or null. Its rubric
1474
+ * (score, verifiable, reversible, publish, dependents) is what `choose --task` takes the stricter of.
1475
+ */
1476
+ function approvedPlanStep(ctx, taskId) {
1477
+ let files = [];
1478
+ try { files = fs.readdirSync(ctx.paths.plans).filter((f) => f.endsWith('.json')); } catch (_) { return null; }
1479
+ for (const f of files) {
1480
+ const rec = core.readJsonFile(path.join(ctx.paths.plans, f), null);
1481
+ if (!rec || !Array.isArray(rec.steps)) continue;
1482
+ const step = rec.steps.find((s) => s && s.task_id === taskId);
1483
+ if (!step) continue;
1484
+ const dependents = step.dependents === true
1485
+ || rec.steps.some((o) => o && o !== step && Array.isArray(o.depends_on) && o.depends_on.includes(step.id));
1486
+ return Object.assign({}, step, { root: rec.root_task_id || f.replace(/\.json$/, ''), dependents });
1487
+ }
1488
+ return null;
1489
+ }
1490
+
1491
+ /**
1492
+ * core 0.7.4 (B1): the stricter of the CLI flags and the approved step, field by field — publish, dependents, not
1493
+ * reversible, not verifiable, higher score. Mutates `input`; returns the names it raised (empty = flags already as strict).
1494
+ * Omitting a flag can therefore never loosen a step the plan made blocking.
1437
1495
  */
1438
- function chooseModeSuggest(ctx, input, res, mi) {
1439
- const root = mi && mi.root ? mi.root : null;
1440
- const rootEv = root ? core.rootModeFromEvents(input.events, root) : null;
1441
- const mode = rootEv ? rootEv.mode : res.mode;
1442
- const sug = core.modeSuggestion(input.events, root, { mode, taskIds: core.rootTaskIds(core.loadPlans(ctx.paths.plans), root) });
1443
- return { mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason, mode_suggest_root: sug.mode_suggest ? root : null };
1496
+ function applyApprovedStep(input, step) {
1497
+ const raised = [];
1498
+ if (step.publish === true && input.publish !== true) { raised.push('publish'); input.publish = true; }
1499
+ if (step.dependents === true && input.dependents !== true) { raised.push('dependents'); input.dependents = true; }
1500
+ if (step.reversible === false && input.reversible !== false) { raised.push('reversible'); input.reversible = false; }
1501
+ if (step.verifiable === false && input.verifiable !== false) { raised.push('verifiable'); input.verifiable = false; }
1502
+ const planScore = Number(step.score);
1503
+ const flagScore = Number(input.score);
1504
+ if (Number.isFinite(planScore) && Number.isFinite(flagScore) && planScore > flagScore) { raised.push('score'); input.score = planScore; }
1505
+ return raised;
1444
1506
  }
1445
1507
 
1446
1508
  function cmdChoose(opts) {
@@ -1450,6 +1512,8 @@ function cmdChoose(opts) {
1450
1512
  }
1451
1513
  if (opts.task !== undefined) requireTaskId(typeof opts.task === 'string' ? opts.task : '');
1452
1514
  const input = chooseInput(opts, ctx, core.readEvents(ctx));
1515
+ const planStep = input.taskId ? approvedPlanStep(ctx, input.taskId) : null;
1516
+ const raised = planStep ? applyApprovedStep(input, planStep) : [];
1453
1517
  let modeSource = 'flag';
1454
1518
  const mi = input.taskId ? core.taskModeInfo(ctx, input.taskId, input.events) : null;
1455
1519
  if (input.mode === undefined) {
@@ -1463,7 +1527,12 @@ function cmdChoose(opts) {
1463
1527
  }
1464
1528
  const res = chooser.choose(input);
1465
1529
  res.mode_source = modeSource;
1466
- if (res.verify_plan) Object.assign(res, chooseModeSuggest(ctx, input, res, mi));
1530
+ if (planStep) {
1531
+ res.plan_step = { root_task_id: planStep.root, step: planStep.id, verify_plan: planStep.verify_plan || null, raised };
1532
+ if (planStep.verify_plan && res.verify_plan && planStep.verify_plan !== res.verify_plan) {
1533
+ res.plan_deviation = `${planStep.verify_plan} → ${res.verify_plan}`;
1534
+ }
1535
+ }
1467
1536
  const orch = measureOrch(opts, ctx, input.profile);
1468
1537
  res.orch_ctx_tokens = orch.ctx_tokens;
1469
1538
  res.orch_ctx_growth = orch.ctx_growth;
@@ -1485,8 +1554,10 @@ function cmdChoose(opts) {
1485
1554
  }
1486
1555
  if (opts.json) return { code: 0, out: JSON.stringify(res) };
1487
1556
  const typeWarn = res.type_warning && !String(res.reason).includes(res.type_warning) ? `\nCẢNH BÁO: ${res.type_warning}` : '';
1488
- const sugLine = res.mode_suggest ? `\n${core.modeSuggestLine(res.mode_suggest_root, 'fast', res.mode_suggest_reason)}` : '';
1489
- const warn = `${typeWarn}${res.orch_ctx_warn ? `\nCẢNH BÁO: ${orch.advice}` : ''}${sugLine}`;
1557
+ const planWarn = res.plan_step && res.plan_step.raised.length
1558
+ ? `\nKế hoạch đã duyệt (${res.plan_step.root_task_id} bước ${res.plan_step.step}): nâng theo ${res.plan_step.raised.join(', ')}` : '';
1559
+ const deviationWarn = res.plan_deviation ? `\nLỆCH kiểm khỏi kế hoạch đã duyệt: ${res.plan_deviation}` : '';
1560
+ const warn = `${typeWarn}${planWarn}${deviationWarn}${res.orch_ctx_warn ? `\nCẢNH BÁO: ${orch.advice}` : ''}`;
1490
1561
  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ô'})`;
1491
1562
  if (res.action === 'ask_user') {
1492
1563
  return { code: 0, out: `HỎI NGƯỜI DÙNG — ${res.reason}${warn}` };
@@ -1541,7 +1612,7 @@ function buildPlanTable(rawSteps, base) {
1541
1612
  verifiable: s.verifiable === undefined ? true : yesNo(s.verifiable, true),
1542
1613
  reversible: s.reversible === undefined ? true : yesNo(s.reversible, true),
1543
1614
  publish: s.publish === undefined ? false : yesNo(s.publish, false),
1544
- mode: s.mode === undefined || s.mode === null ? null : s.mode,
1615
+ mode: s.mode === undefined || s.mode === null ? null : (userMode(s.mode) || s.mode),
1545
1616
  urgency: s.urgency,
1546
1617
  deps: deps.map(String),
1547
1618
  };
@@ -1570,6 +1641,8 @@ function buildPlanTable(rawSteps, base) {
1570
1641
  const head = {
1571
1642
  step: s.step, title: s.title, type: s.type, task_id: s.task || null, score: Number(s.score),
1572
1643
  verifiable: s.verifiable, reversible: s.reversible, depends_on: s.deps,
1644
+ publish: s.publish === true || base.publish === true,
1645
+ dependents: steps.some((o) => o !== s && o.deps.includes(s.step)),
1573
1646
  mode: s.mode || base.mode || core.DEFAULT_MODE, mode_override: s.mode || null,
1574
1647
  };
1575
1648
  try {
@@ -1635,6 +1708,78 @@ function planTaskIdProblems(plan, root) {
1635
1708
  return problems;
1636
1709
  }
1637
1710
 
1711
+ // core 0.7.4 (s1): TÁCH? — a step that is likely to hand off (score ≥ 6; its task_type × tier has handoff rate ≥ 20%
1712
+ // over ≥ 10 tasks) is flagged in the table so the planner splits it. Advice only: never blocks --approve.
1713
+ const SPLIT_MIN_SCORE = 6;
1714
+ const SPLIT_MIN_RATE = 0.2;
1715
+ const SPLIT_MIN_N = 10;
1716
+
1717
+ /** Model family of a tier or a recorded model id (opus | sonnet | haiku | fable), as choose.cjs matches cells. */
1718
+ function modelFamily(m) {
1719
+ const s = String(m || '').toLowerCase();
1720
+ return ['opus', 'sonnet', 'haiku', 'fable'].find((k) => s.includes(k)) || null;
1721
+ }
1722
+
1723
+ function planSplitWarnings(steps, events) {
1724
+ const samples = agg.handoffSamples(events);
1725
+ const out = [];
1726
+ for (const r of steps) {
1727
+ if (r.action === 'refused' || !r.tier || !(Number(r.score) >= SPLIT_MIN_SCORE)) continue;
1728
+ const type = r.task_type || r.type;
1729
+ const [model, effort] = String(r.tier).split('/');
1730
+ const same = samples.filter((s) => s.task_type === type && modelFamily(s.model) === modelFamily(model) && s.effort === effort);
1731
+ if (same.length < SPLIT_MIN_N) continue;
1732
+ const hand = same.filter((s) => s.handoff);
1733
+ const rate = hand.length / same.length;
1734
+ if (rate < SPLIT_MIN_RATE) continue;
1735
+ const clean = same.filter((s) => !s.handoff);
1736
+ out.push({
1737
+ step: r.step,
1738
+ task_type: type,
1739
+ tier: r.tier,
1740
+ n: same.length,
1741
+ handoff_rate: Math.round(rate * 1000) / 1000,
1742
+ cost_handoff_p50_usd: agg.percentile(hand.map((s) => s.total_cost), 0.5),
1743
+ cost_clean_p50_usd: agg.percentile(clean.map((s) => s.total_cost), 0.5),
1744
+ });
1745
+ }
1746
+ return out;
1747
+ }
1748
+
1749
+ // core 0.7.4 (s2): TIER↑ — a mechanical type whose override sits above the tier choose would pick is flagged
1750
+ // (advice only; --approve is not blocked). The $p50 column and the warning read the same stats cells as `stats`.
1751
+ const TIER_UP_TYPES = ['mechanical-edit', 'extract', 'search', 'test-write'];
1752
+
1753
+ /** The stats cell (buildCells row) of one task_type × tier, or null. */
1754
+ function statCellOf(cells, type, tier) {
1755
+ const [model, effort] = String(tier).split('/');
1756
+ return cells.find((c) => c.task_type === type && modelFamily(c.model) === modelFamily(model) && c.effort === effort) || null;
1757
+ }
1758
+
1759
+ function planTierWarnings(steps, cells) {
1760
+ const out = [];
1761
+ for (const r of steps) {
1762
+ if (r.action === 'refused' || !r.overridden || !r.suggested_tier || !r.tier) continue;
1763
+ if (!TIER_UP_TYPES.includes(r.task_type)) continue;
1764
+ const hi = chooser.parseTier(r.tier);
1765
+ const lo = chooser.parseTier(r.suggested_tier);
1766
+ if (hi === null || lo === null || hi <= lo) continue;
1767
+ const a = statCellOf(cells, r.task_type, r.tier);
1768
+ const b = statCellOf(cells, r.task_type, r.suggested_tier);
1769
+ out.push({
1770
+ step: r.step,
1771
+ task_type: r.task_type,
1772
+ tier: r.tier,
1773
+ suggested_tier: r.suggested_tier,
1774
+ cost_p50_usd: a && a.n ? a.cost_p50_usd : null,
1775
+ suggested_cost_p50_usd: b && b.n ? b.cost_p50_usd : null,
1776
+ accept_rate: a && a.n ? a.accept_rate : null,
1777
+ suggested_accept_rate: b && b.n ? b.accept_rate : null,
1778
+ });
1779
+ }
1780
+ return out;
1781
+ }
1782
+
1638
1783
  /** Mapped ad-hoc labels (bug-fix → debug, …): not an error, but the tag must carry the mapped type. */
1639
1784
  function planTypeNotes(plan) {
1640
1785
  return plan.steps.filter((r) => r.mapped_from).map((r) => `bước ${r.step}: ${r.type_warning}`);
@@ -1692,6 +1837,10 @@ function recordApprovedPlan(ctx, root, stepsFile, plan) {
1692
1837
  steps: plan.steps.map((r) => Object.assign({
1693
1838
  id: r.step, task_id: r.task_id || null, title: r.title === undefined ? null : r.title, type: r.task_type || r.type || null,
1694
1839
  tier: r.action === 'refused' ? null : (r.tier || null), depends_on: r.depends_on,
1840
+ // core 0.7.4 (B1): the rubric `choose --task` takes the stricter of its flags against
1841
+ score: Number.isFinite(Number(r.score)) ? Number(r.score) : null, verifiable: r.verifiable !== false,
1842
+ reversible: r.reversible !== false, publish: r.publish === true, dependents: r.dependents === true,
1843
+ verify_plan: r.verify_plan || null,
1695
1844
  }, r.mode_override ? { mode: r.mode_override } : {})),
1696
1845
  history,
1697
1846
  };
@@ -1733,8 +1882,8 @@ function cmdPlanTable(opts) {
1733
1882
  let fileMode;
1734
1883
  if (raw && !Array.isArray(raw) && typeof raw === 'object' && Array.isArray(raw.steps)) {
1735
1884
  if (raw.mode !== undefined && raw.mode !== null) {
1736
- if (!core.isMode(raw.mode)) throw new Error(`plan-table: "mode" trong file phải là ${core.MODES.join('|')} (nhận "${raw.mode}")`);
1737
- fileMode = raw.mode;
1885
+ fileMode = userMode(raw.mode);
1886
+ if (!fileMode) throw new Error(`plan-table: "mode" trong file phải là ${core.MODES.join('|')} (nhận "${raw.mode}")`);
1738
1887
  }
1739
1888
  raw = raw.steps;
1740
1889
  }
@@ -1751,6 +1900,9 @@ function cmdPlanTable(opts) {
1751
1900
  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`);
1752
1901
  const typeNotes = planTypeNotes(plan);
1753
1902
  if (typeNotes.length) process.stderr.write(`dieu-phoi plan-table: cảnh báo — ${typeNotes.join('; ')}\n`);
1903
+ const splitWarn = planSplitWarnings(plan.steps, base.events);
1904
+ const statCells = agg.analyze(base.events, {}).cells;
1905
+ const tierWarn = planTierWarnings(plan.steps, statCells);
1754
1906
  const rec = opts.approve ? recordApprovedPlan(ctx, String(opts.root), path.resolve(opts.file), plan) : null;
1755
1907
  let modeLogged = false;
1756
1908
  if (rec && (rootEv ? rootEv.mode !== plan.mode : plan.mode !== core.DEFAULT_MODE)) {
@@ -1761,29 +1913,31 @@ function cmdPlanTable(opts) {
1761
1913
  for (const ev of ovs) core.appendEvent(ctx, ev);
1762
1914
  if (rec) recordPlanSuggestions(ctx, plan);
1763
1915
  if (opts.json) {
1764
- const out = rec ? Object.assign({}, plan, {
1916
+ const steps = plan.steps.map((r) => {
1917
+ const c = r.action === 'refused' || !r.tier ? null : statCellOf(statCells, r.task_type || r.type, r.tier);
1918
+ return Object.assign({}, r, { stats_cost_p50_usd: c && c.n ? c.cost_p50_usd : null, stats_n: c ? c.n : 0 });
1919
+ });
1920
+ const out = Object.assign({}, plan, { steps, split_warn: splitWarn, tier_warn: tierWarn }, rec ? {
1765
1921
  approved: { root_task_id: rec.root_task_id, approval: rec.approval, record: rec.file, overrides_logged: ovs.length, mode_logged: modeLogged },
1766
- }) : plan;
1922
+ } : {});
1767
1923
  return { code: 0, out: JSON.stringify(out, null, 2) };
1768
1924
  }
1769
- const lines = ['| Bước | Model/effort | Rubric (điểm · kiểm chứng · đảo ngược) | Lý do | Ước tính | Kiểm |', '|---|---|---|---|---|---|'];
1925
+ const lines = ['| Bước | Model/effort | Rubric (điểm · kiểm chứng · đảo ngược) | Lý do | Ước tính | $p50 | Kiểm |', '|---|---|---|---|---|---|---|'];
1770
1926
  for (const r of plan.steps) {
1771
1927
  const tierCell = r.action === 'refused' ? '—' : (r.action === 'ask_user' ? `${r.tier} (hỏi người dùng)` : r.tier);
1772
1928
  const yn = (v) => (v ? 'y' : 'n');
1773
1929
  const rubric = `${Number.isFinite(r.score) ? r.score : '?'} · ${yn(r.verifiable)} · ${yn(r.reversible)}`;
1774
1930
  const why = `${r.type}${r.depends_on.length ? `, sau ${r.depends_on.join(', ')}` : ''} — ${r.reason.replace(/; profile: .*$/, '')}`;
1775
1931
  const est = r.action === 'refused' ? '-' : `~${fmtUsd(r.est_cost_usd)} · ~${fmtNum(r.est_time_min)} phút${r.est_source === 'rough' ? ' (thô)' : ''}`;
1932
+ const sc = r.action === 'refused' || !r.tier ? null : statCellOf(statCells, r.task_type || r.type, r.tier);
1933
+ const p50 = r.action === 'refused' ? '—' : (sc && sc.n ? `${fmtUsd(sc.cost_p50_usd)} (n ${sc.n})` : '-');
1776
1934
  const kiem = r.action === 'refused' || !r.verify_plan ? '—' : `${r.verify_plan}${r.mode_override ? ` (mode ${r.mode_override})` : ''}`;
1777
- lines.push(`| ${mdCell(r.title ? `${r.step}. ${r.title}` : r.step)} | ${mdCell(tierCell)} | ${mdCell(rubric)} | ${mdCell(why)} | ${mdCell(est)} | ${mdCell(kiem)} |`);
1935
+ lines.push(`| ${mdCell(r.title ? `${r.step}. ${r.title}` : r.step)} | ${mdCell(tierCell)} | ${mdCell(rubric)} | ${mdCell(why)} | ${mdCell(est)} | ${mdCell(p50)} | ${mdCell(kiem)} |`);
1778
1936
  }
1779
1937
  lines.push('');
1780
1938
  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)`);
1781
- // Mode được hỏi ở cổng 1 chỉ khi root chưa có mode và kế hoạch có ≥4 bước s≤6 (0.7.2); còn lại không hỏi
1782
- const shortSteps = plan.steps.filter((r) => r.action !== 'refused' && Number.isFinite(r.score) && r.score <= 6).length;
1783
- const modeText = modeChosen ? plan.mode : (shortSteps >= 4
1784
- ? `HỎI người dùng chọn mode (${shortSteps} bước s≤6): fast | normal | strict (mặc định normal); đặt bằng \`dieu-phoi mode --root ${typeof opts.root === 'string' ? opts.root : '<root_task_id>'} <mode>\``
1785
- : 'normal (không hỏi)');
1786
- lines.push(`**Mode:** ${modeText}${plan.self_batch_steps ? ` · Kiểm (fast): bỏ ${plan.self_batch_steps} verifier riêng, 1 verify gộp (cả suite một lần) trước khi trình người dùng` : ''}`);
1939
+ const modeText = modeChosen ? plan.mode : 'normal (mặc định)';
1940
+ lines.push(`**Mode:** ${modeText}${plan.self_batch_steps ? ` · Kiểm (self+batch): bỏ ${plan.self_batch_steps} verifier riêng, 1 verify gộp (test chọn lọc các file cả nhóm; full suite ở cổng phát hành) trước khi trình người dùng` : ''}`);
1787
1941
  if (plan.rough) lines.push('', '_(thô) = ô chưa đủ 5 task, ước lượng theo giả định token của từng tier._');
1788
1942
  if (plan.refused.length) {
1789
1943
  lines.push('', `**Cảnh báo:** ${plan.refused.length} bước bị từ chối (${plan.refused.map((x) => x.step).join(', ')}), không tính vào tổng — sửa rồi chạy lại.`);
@@ -1791,6 +1945,13 @@ function cmdPlanTable(opts) {
1791
1945
  `);
1792
1946
  }
1793
1947
  if (typeNotes.length) lines.push('', `**Loại task:** ${typeNotes.join('; ')}`);
1948
+ for (const w of splitWarn) {
1949
+ lines.push('', `TÁCH?: ${w.step} — ${w.task_type} ${w.tier} handoff ${Math.round(w.handoff_rate * 100)}% (${w.n}), median ${fmtUsd(w.cost_handoff_p50_usd)} có handoff / ${fmtUsd(w.cost_clean_p50_usd)} không → cân nhắc tách thành ≥2 bước nhỏ hơn`);
1950
+ }
1951
+ const fmtPct = (v) => (v === null || v === undefined ? '-' : `${Math.round(v * 100)}%`);
1952
+ for (const w of tierWarn) {
1953
+ lines.push('', `TIER↑: ${w.step} ${w.tier} vs đề xuất ${w.suggested_tier} ($p50 ${fmtUsd(w.cost_p50_usd)} vs ${fmtUsd(w.suggested_cost_p50_usd)}, accept ${fmtPct(w.accept_rate)} vs ${fmtPct(w.suggested_accept_rate)}) — tier cao hơn choose đề xuất, kiểm lý do trước khi duyệt`);
1954
+ }
1794
1955
  if (rec) lines.push('', `Đã ghi kế hoạch đã duyệt (root ${rec.root_task_id}, lần duyệt ${rec.approval})${ovs.length ? `; ghi ${ovs.length} override` : ''}${modeLogged ? `; ghi mode ${plan.mode}` : ''}`);
1795
1956
  return { code: 0, out: lines.join('\n') };
1796
1957
  }
package/lib/knowledge.cjs CHANGED
@@ -40,7 +40,9 @@ const KNOWLEDGE_HELP = `Cách dùng: dieu-phoi knowledge <lệnh> [--repo <đư
40
40
  thư mục). Không --from thì tự lấy kho của khoá git-common-dir nếu có
41
41
  list [--follow] [--dirty] [--json]
42
42
  các mục của kho (id, tiêu đề, số dòng, sha#rev, tags, paths) + repo liên quan (related),
43
- mỗi mục gắn ok / STALE / ? (độ tươi so với HEAD của worktree đang đứng)
43
+ mỗi mục gắn ok / STALE / drift / ? (độ tươi so với HEAD của worktree đang đứng; drift = chỉ file
44
+ file đổi nhưng không làm mục stale: glob, bản version, CHANGELOG, dòng không nêu tên mục);
45
+ cuối dòng tổng: số mục không stale
44
46
  status [--follow] [--dirty] [--no-cache] [--json]
45
47
  chỉ độ tươi: mục nào stale (file nào đổi sau sha của mục), mục nào không xác định (sha không
46
48
  có trong repo này…). Một tiến trình git (diff-tree --no-renames, pathspec = paths của các mục),
@@ -60,6 +62,8 @@ const KNOWLEDGE_HELP = `Cách dùng: dieu-phoi knowledge <lệnh> [--repo <đư
60
62
  "## +id" thêm mục (bắt buộc paths:), "## id" thay mục (bắt buộc base: <sha>#<rev>); base cũ
61
63
  → xung đột, không ghi đè; mục > 40 dòng bị từ chối. Có xung đột/từ chối → exit 2.
62
64
  Idempotent: file delta đã gộp (hash trong meta.json) và mục trùng một rev cũ trong history/ → "không đổi"
65
+ review <id> [--repo] đánh dấu mục đã xem xét: reviewed_sha = HEAD hiện tại (ghi dưới khoá kho); độ tươi chỉ tính thay đổi sau sha đó.
66
+ Mục bị thay bằng merge sẽ mất dấu này. Không đổi nội dung mục.
63
67
  bootstrap [--children <mẫu tên>[,…]] [--json]
64
68
  chỉ đọc: liệt kê nguồn để seed kho (CLAUDE.md/README/AGENTS.md của repo, context-r/contract/
65
69
  handoff cũ trong runs có nhắc repo), mục đã có, mục nên có và mẫu delta. --children: repo anh em
@@ -272,7 +276,10 @@ function sectionOut(repo, sec, body, fr) {
272
276
  const f = fr && fr.sections && fr.sections[sec.id];
273
277
  if (f) {
274
278
  out.fresh = f.state;
275
- if (f.files) { out.stale = f.files; out.stale_count = f.n_files || f.files.length; }
279
+ if (f.files) {
280
+ const k = f.state === 'drift' ? 'drift' : 'stale';
281
+ out[k] = f.files; out[`${k}_count`] = f.n_files || f.files.length;
282
+ }
276
283
  if (f.why) out.fresh_why = f.why;
277
284
  if (f.dirty) out.dirty = f.dirty;
278
285
  }
@@ -299,6 +306,7 @@ function freshnessOf(storeDir, idx, start, opts) {
299
306
 
300
307
  function freshLine(s) {
301
308
  if (s.fresh === 'stale') return `STALE — ${s.stale_count} file đổi sau sha của mục: ${core.shortFileList(s.stale, 5)} (kiểm lại trước khi dùng)`;
309
+ if (s.fresh === 'drift') return `drift — ${s.drift_count} file đổi nhưng không tính stale: ${core.shortFileList(s.drift, 5)}`;
302
310
  if (s.fresh === 'unknown') return `độ tươi: không xác định — ${s.fresh_why}`;
303
311
  if (s.fresh === 'fresh') return 'độ tươi: tươi';
304
312
  return null;
@@ -415,7 +423,7 @@ function cmdGet(opts) {
415
423
  return { code, out: parts.join('\n\n') };
416
424
  }
417
425
 
418
- const FRESH_MARK = { fresh: 'ok ', stale: 'STALE', unknown: '? ' };
426
+ const FRESH_MARK = { fresh: 'ok ', stale: 'STALE', drift: 'drift', unknown: '? ' };
419
427
 
420
428
  function listLines(idx, fr, dir) {
421
429
  return idx.sections.map((s) => {
@@ -429,7 +437,8 @@ function listLines(idx, fr, dir) {
429
437
  function freshHeader(fr, n) {
430
438
  if (!fr) return '';
431
439
  const head = fr.head ? `HEAD ${fr.head.slice(0, 7)}${fr.branch ? ` (${fr.branch})` : ''}` : 'HEAD ?';
432
- return `${head} · ${core.knowledgeSummary(fr, n)} · ${fr.cached ? 'cache' : `tính mới${fr.ms_git ? ` (git ${fr.ms_git} ms)` : ''}`}${fr.error ? ` · git lỗi: ${fr.error}` : ''}`;
440
+ const notStale = n - ((fr.counts && fr.counts.stale) || 0);
441
+ return `${head} · ${core.knowledgeSummary(fr, n)} · ${notStale} không stale · ${fr.cached ? 'cache' : `tính mới${fr.ms_git ? ` (git ${fr.ms_git} ms)` : ''}`}${fr.error ? ` · git lỗi: ${fr.error}` : ''}`;
433
442
  }
434
443
 
435
444
  /** Details under list/status: stale files, unknown reasons, uncommitted files (--dirty). */
@@ -440,6 +449,7 @@ function freshDetails(idx, fr) {
440
449
  const f = fr.sections[s.id];
441
450
  if (!f) continue;
442
451
  if (f.state === 'stale') out.push(` ${s.id}: ${f.n_files || f.files.length} file đổi — ${core.shortFileList(f.files, 5)}`);
452
+ else if (f.state === 'drift') out.push(` ${s.id}: drift (không tính stale) — ${f.n_files || f.files.length} file đổi — ${core.shortFileList(f.files, 5)}`);
443
453
  else if (f.state === 'unknown') out.push(` ${s.id}: không xác định — ${f.why}`);
444
454
  if (f.dirty) out.push(` ${s.id}: chưa commit — ${core.shortFileList(f.dirty, 5)}`);
445
455
  }
@@ -1035,6 +1045,38 @@ function cmdLink(opts) {
1035
1045
  return { code: 0, out: `${verb}: ${other.name} là ${opts.rel === 'child' ? 'con' : 'cha'} của ${info.name}${a.changed || b.changed ? '' : ' (không đổi)'}.` };
1036
1046
  }
1037
1047
 
1048
+ /**
1049
+ * Review mark (s6): records reviewed_sha = HEAD of the checkout around --repo on one section, under the store lock.
1050
+ * freshness then counts only the changes after that sha. Body, paths and base are untouched; a merge that replaces the
1051
+ * section writes a new entry without the mark, so new content is never shown as reviewed.
1052
+ */
1053
+ function cmdReview(opts) {
1054
+ const { start, info } = infoOf(opts, false);
1055
+ if (!info) return notRepo(start);
1056
+ const ids = opts._.map(String).filter((t) => t.trim());
1057
+ if (ids.length !== 1 || !ID_RE.test(ids[0])) return { code: 1, out: `Cần đúng một <id> mục.\n\n${KNOWLEDGE_HELP.trimEnd()}` };
1058
+ const id = ids[0];
1059
+ const g = core.findGit(start);
1060
+ const h = g ? core.readHead(g) : null;
1061
+ const sha = h && h.sha ? h.sha : (h && h.unsupported ? headShaOf(start) : null);
1062
+ if (!sha || !/^[0-9a-f]{40,64}$/i.test(sha)) return { code: 1, out: 'Không đọc được HEAD đầy đủ của repo (chưa có commit?).' };
1063
+ const ri = readIndex(info.dir);
1064
+ if (ri.error) return { code: 1, out: ri.error };
1065
+ if (!ri.exists) return { code: 1, out: `Kho chưa có mục nào: ${info.dir}.` };
1066
+ const lk = withLock(path.join(info.dir, 'knowledge.lock'), () => {
1067
+ const cur = readIndex(info.dir);
1068
+ if (cur.error) return { code: 1, out: cur.error };
1069
+ const at = cur.idx.sections.findIndex((s) => s.id === id);
1070
+ if (at < 0) return { code: 1, out: `Chưa có mục ${id} trong kho ${info.name}.` };
1071
+ const prev = cur.idx.sections[at].reviewed_sha || null;
1072
+ cur.idx.sections[at] = Object.assign({}, cur.idx.sections[at], { reviewed_sha: sha.toLowerCase(), reviewed_at: new Date().toISOString() });
1073
+ core.writeFileAtomic(path.join(info.dir, 'index.json'), `${JSON.stringify(cur.idx, null, 2)}\n`);
1074
+ return { code: 0, out: `review ${id}: reviewed_sha ${shortSha(sha)}${prev ? ` (trước: ${shortSha(prev)})` : ''}` };
1075
+ }, LOCK_TRIES);
1076
+ if (lk.locked) return { code: 1, out: 'Kho đang bị khoá (knowledge.lock), thử lại.' };
1077
+ return lk.value;
1078
+ }
1079
+
1038
1080
  function cmdKnowledge(opts) {
1039
1081
  const sub = opts._.shift();
1040
1082
  if (!sub || opts.help || opts.h || sub === 'help') {
@@ -1045,6 +1087,7 @@ function cmdKnowledge(opts) {
1045
1087
  if (sub === 'get') return cmdGet(opts);
1046
1088
  if (sub === 'list') return cmdList(opts);
1047
1089
  if (sub === 'status') return cmdStatus(opts);
1090
+ if (sub === 'review') return cmdReview(opts);
1048
1091
  if (sub === 'merge') return cmdMerge(opts);
1049
1092
  if (sub === 'link') return cmdLink(opts);
1050
1093
  if (sub === 'bootstrap') return cmdBootstrap(opts);