@educa-corp/sdd-framework 0.9.5 → 0.9.7

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.
Files changed (113) hide show
  1. package/bin/build.js +11 -1
  2. package/bin/lint-trace.js +397 -28
  3. package/bin/self-check.js +623 -16
  4. package/bin/trace-schema.json +3187 -1981
  5. package/core/FRAMEWORK_VERSION +1 -1
  6. package/core/commands/amend-prd.md +7 -1
  7. package/core/commands/debug.md +8 -2
  8. package/core/commands/define-product.md +38 -1
  9. package/core/commands/dev-gen-test.md +70 -2
  10. package/core/commands/dev-run-test.md +8 -2
  11. package/core/commands/dev-smoke-test.md +7 -1
  12. package/core/commands/extend-prd.md +7 -1
  13. package/core/commands/fix-bug.md +11 -5
  14. package/core/commands/generate-architecture.md +9 -1
  15. package/core/commands/generate-bdd.md +45 -5
  16. package/core/commands/generate-code.md +44 -5
  17. package/core/commands/generate-design-spec.md +7 -1
  18. package/core/commands/generate-prd.md +9 -1
  19. package/core/commands/generate-spec-manifest.md +7 -1
  20. package/core/commands/generate-tech-docs.md +44 -4
  21. package/core/commands/learn.md +7 -1
  22. package/core/commands/map-testids.md +96 -13
  23. package/core/commands/propose-scenario.md +7 -1
  24. package/core/commands/qc-analyze.md +516 -426
  25. package/core/commands/qc-automation-assess.md +356 -0
  26. package/core/commands/qc-design-script.md +400 -0
  27. package/core/commands/qc-design-test.md +482 -248
  28. package/core/commands/qc-plan.md +141 -94
  29. package/core/commands/qc-report.md +9 -3
  30. package/core/commands/{qc-review.md → qc-review-script.md} +172 -132
  31. package/core/commands/qc-review-testcase.md +409 -0
  32. package/core/commands/qc-run-manualtest.md +401 -0
  33. package/core/commands/{qc-run-test.md → qc-run-script.md} +200 -232
  34. package/core/commands/refine-prd.md +7 -1
  35. package/core/commands/report-bug.md +9 -3
  36. package/core/commands/review-code.md +9 -3
  37. package/core/commands/review-context.md +11 -3
  38. package/core/commands/review-tech-docs.md +11 -3
  39. package/core/commands/setup-ai-first.md +7 -1
  40. package/core/commands/validate-traces.md +27 -6
  41. package/core/modules/qc-playwright/stack-profile.yaml +1 -1
  42. package/core/rules/workflow.md +42 -2
  43. package/core/skills/qc/_shared/self-review-principles.md +2 -2
  44. package/core/skills/qc/qa-analyst/DOC_GAP.template.md +10 -2
  45. package/core/skills/qc/qa-analyst/spec-issue-reporter.md +1 -1
  46. package/core/skills/qc/qa-automation-assess/matrix.md +120 -0
  47. package/core/skills/qc/qa-designer/e2e/journey.md +1 -1
  48. package/core/skills/qc/qa-designer/exploratory/explore-to-functional.md +1 -1
  49. package/core/skills/qc/qa-designer/functional/api.md +1 -1
  50. package/core/skills/qc/qa-designer/functional/gui-feature.md +1 -1
  51. package/core/skills/qc/qa-designer/functional/gui-screen.md +1 -1
  52. package/core/skills/qc/qa-designer/integration/api.md +1 -1
  53. package/core/skills/qc/qa-designer/integration/db.md +1 -1
  54. package/core/skills/qc/qa-designer/integration/gui.md +1 -1
  55. package/core/skills/qc/qa-designer/integration/kafka.md +1 -1
  56. package/core/skills/qc/qa-designer/non-functional.md +1 -1
  57. package/core/skills/qc/qa-designer/shared/duplicate-check-procedure.md +33 -5
  58. package/core/skills/qc/qa-designer/shared/tc-metadata-format.md +34 -5
  59. package/core/skills/qc/qa-planner/test-plan.md +7 -0
  60. package/core/skills/qc/qa-reviewer/script/e2e.md +1 -1
  61. package/core/skills/qc/qa-reviewer/script/exploratory.md +1 -1
  62. package/core/skills/qc/qa-reviewer/script/functional.md +1 -1
  63. package/core/skills/qc/qa-reviewer/script/integration.md +1 -1
  64. package/core/skills/qc/qa-reviewer/script/non-functional.md +1 -1
  65. package/core/skills/qc/qa-reviewer/shared/review-file-template.md +3 -3
  66. package/core/skills/qc/qa-reviewer/test-case/e2e.md +1 -1
  67. package/core/skills/qc/qa-reviewer/test-case/functional.md +1 -1
  68. package/core/skills/qc/qa-reviewer/test-case/integration.md +1 -1
  69. package/core/skills/qc/qa-reviewer/test-case/non-functional.md +1 -1
  70. package/core/skills/qc/qa-runner/e2e.md +2 -2
  71. package/core/skills/qc/qa-runner/functional/gui-feature.md +4 -4
  72. package/core/skills/qc/qa-runner/functional/gui-screen.md +4 -4
  73. package/core/skills/qc/qa-runner/integration.md +1 -1
  74. package/core/skills/qc/qa-runner/non-functional.md +1 -1
  75. package/core/steps/context-loader.md +1 -1
  76. package/core/steps/gate.md +7 -1
  77. package/core/steps/qc-scope.md +67 -11
  78. package/core/steps/qc-stamp.md +142 -0
  79. package/core/steps/report-footer.md +19 -10
  80. package/core/templates/tech-design.template.md +3 -3
  81. package/docs/01-getting-started/quickstart.md +4 -3
  82. package/docs/02-concepts/architecture.md +14 -0
  83. package/docs/02-concepts/glossary.md +8 -0
  84. package/docs/02-concepts/overview.md +3 -2
  85. package/docs/02-concepts/pipeline-steps/04-bdd.md +1 -1
  86. package/docs/02-concepts/pipeline-steps/05-tech-docs.md +21 -5
  87. package/docs/02-concepts/pipeline-steps/06-code.md +12 -2
  88. package/docs/02-concepts/pipeline-steps/07-dev-selftest.md +1 -1
  89. package/docs/02-concepts/pipeline-steps/08-qc-automation.md +65 -16
  90. package/docs/02-concepts/pipeline-steps/10-feedback-loop.md +3 -3
  91. package/docs/02-concepts/pipeline-steps/README.md +4 -3
  92. package/docs/02-concepts/traceability.md +2 -2
  93. package/docs/03-guides/architect.md +2 -2
  94. package/docs/03-guides/developer.md +6 -3
  95. package/docs/03-guides/tester-qa.md +23 -10
  96. package/docs/04-reference/commands.md +9 -4
  97. package/docs/04-reference/trace-schema.md +5 -5
  98. package/docs/explain/07-generate-tech-docs.md +5 -3
  99. package/docs/explain/08-review-tech-docs.md +15 -3
  100. package/docs/explain/09-generate-code.md +30 -4
  101. package/docs/explain/10-review-code.md +1 -1
  102. package/docs/explain/11-map-testids.md +72 -70
  103. package/docs/explain/12-dev-gen-test.md +1 -1
  104. package/docs/explain/15-qc-analyze.md +14 -2
  105. package/docs/explain/16-qc-plan.md +5 -1
  106. package/docs/explain/17-qc-design-test.md +30 -7
  107. package/docs/explain/18-qc-review.md +43 -17
  108. package/docs/explain/19-qc-run-test.md +38 -12
  109. package/docs/explain/20-qc-report.md +8 -5
  110. package/docs/explain/23-fix-bug.md +2 -2
  111. package/docs/explain/README.md +6 -3
  112. package/docs/plans/qc-surgery/01-checklist.md +70 -17
  113. package/package.json +1 -1
package/bin/self-check.js CHANGED
@@ -431,22 +431,69 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
431
431
  // schema đó chỉ là chữ. Đây là nửa "máy canh" của luật "khai tường minh + để máy canh" —
432
432
  // và hợp đồng test-id là chỗ luật đó bị thiếu lâu nhất (3 lệnh đọc, 2 lệnh ghi, 0 nơi kiểm).
433
433
  {
434
- const TC = schema.testid_contract;
435
- if (TC && Array.isArray(TC.lint_rules)) {
436
- const emits = new Set((lintText.match(/\b(?:err|warn)\('(T\d+)'/g) || [])
437
- .map(m => /T\d+/.exec(m)[0]));
438
- for (const r of TC.lint_rules) {
439
- if (!r.why) {
440
- err('R8', `\`testid_contract.lint_rules\` mục \`${r.rule}\` thiếu \`why\``,
441
- 'Một rule không nói được SAO sẽ bị người sau coi tuỳ ý gỡ đi.');
434
+ const emits = new Set((lintText.match(/\b(?:err|warn)\('(T\d+)'/g) || [])
435
+ .map(m => /T\d+/.exec(m)[0]));
436
+
437
+ // Gom MỌI chỗ trong schema khai một lint rule cho hợp đồng test-id.
438
+ // `testid_fourth_leg.lint_rule` vào đây G76: khối đó khai `T19` và — trước bản sửa này —
439
+ // KHÔNG chỗ nào ép nó tồn tại. Gỡ `warn('T19')` khỏi lint-trace vẫn build xanh, tức là
440
+ // khối schema mới thêm lại đúng khuôn B ("luật ĐÚNG, viết RÕ, KHÔNG AI CANH") mà nó sinh
441
+ // ra để chống. Một allowlist đọc-từ-một-khoá-cứng chỉ phủ đúng khoá đó cùng bài học R18.
442
+ const declaredRules = [
443
+ ...((schema.testid_contract || {}).lint_rules || [])
444
+ .map(r => ({ ...r, at: 'testid_contract.lint_rules' })),
445
+ ...((schema.testid_fourth_leg || {}).lint_rule
446
+ ? [{ ...schema.testid_fourth_leg.lint_rule, at: 'testid_fourth_leg.lint_rule' }]
447
+ : []),
448
+ ];
449
+
450
+ // ── R8f — qc_artifact_stamp: mỗi field phải khai `probe` + `artifact`, và T20 phải tồn tại ──
451
+ //
452
+ // G85: `fields.bdd_version` khai nguồn "| **Version** | của header .feature" — cú pháp hợp lệ,
453
+ // mô tả rõ ràng, và KHÔNG THỂ ĐÚNG (.feature là Gherkin, không có bảng). Một rule chỉ canh
454
+ // "mô tả có tồn tại" sẽ cho nó qua. Nên phần canh chia hai tầng, đúng khuôn self-check ↔ lint:
455
+ // R8f (đây) — canh CONTRACT: field có khai probe/artifact hợp lệ không
456
+ // T20 (lint-trace) — canh DỮ LIỆU : probe đó có TÌM ĐƯỢC ở artifact thật không
457
+ {
458
+ const QS = schema.qc_artifact_stamp;
459
+ if (QS && QS.fields) {
460
+ const KINDS = new Set(['prd', 'feature', 'tech-design', 'design-spec']);
461
+ for (const [name, f] of Object.entries(QS.fields)) {
462
+ if (!f || typeof f !== 'object' || !f.probe) {
463
+ err('R8', `\`qc_artifact_stamp.fields.${name}\` thiếu \`probe\``,
464
+ 'Không có probe thì không ai kiểm được mô tả nguồn có TÌM ĐƯỢC ở artifact thật\n'
465
+ + ' hay không — đúng khe mà G85 lọt qua.');
466
+ continue;
467
+ }
468
+ try { new RegExp(f.probe); }
469
+ catch { err('R8', `\`qc_artifact_stamp.fields.${name}.probe\` không phải regex hợp lệ`,
470
+ `Giá trị: ${f.probe}`); }
471
+ if (!KINDS.has(f.artifact)) {
472
+ err('R8', `\`qc_artifact_stamp.fields.${name}.artifact\` không hợp lệ: ${f.artifact}`,
473
+ `Phải là một trong: ${[...KINDS].join(' · ')}. Sai loại thì T20 soi nhầm tập file\n`
474
+ + ' và báo sạch trên một thứ chưa từng được kiểm.');
475
+ }
442
476
  }
443
- if (!emits.has(r.rule)) {
444
- err('R8', `schema khai \`testid_contract\` rule \`${r.rule}\` ${LINT_REL} KHÔNG phát ra`,
445
- 'Khai rule rồi mà checker không có nó = hợp đồng test-id không được kiểm ở đâu cả,\n'
446
- + ' trong khi bảng §4.5.6 3 lệnh đọc 2 lệnh ghi.');
477
+ const lr = QS.lint_rule;
478
+ if (lr && !new Set((lintText.match(/\b(?:err|warn)\('(T\d+)'/g) || [])
479
+ .map(m => /T\d+/.exec(m)[0])).has(lr.rule)) {
480
+ err('R8', `schema khai \`qc_artifact_stamp.lint_rule\` rule \`${lr.rule}\` ${LINT_REL} KHÔNG phát ra`,
481
+ 'Khai rule rồi mà checker không có nó = mô tả nguồn không được kiểm ở đâu cả.');
447
482
  }
448
483
  }
449
484
  }
485
+
486
+ for (const r of declaredRules) {
487
+ if (!r.why) {
488
+ err('R8', `\`${r.at}\` mục \`${r.rule}\` thiếu \`why\``,
489
+ 'Một rule không nói được VÌ SAO sẽ bị người sau coi là tuỳ ý và gỡ đi.');
490
+ }
491
+ if (!emits.has(r.rule)) {
492
+ err('R8', `schema khai \`${r.at}\` rule \`${r.rule}\` mà ${LINT_REL} KHÔNG phát ra`,
493
+ 'Khai rule rồi mà checker không có nó = hợp đồng test-id không được kiểm ở đâu cả,\n'
494
+ + ' trong khi bảng §4.5.6 có 3 lệnh đọc và 2 lệnh ghi.');
495
+ }
496
+ }
450
497
  }
451
498
 
452
499
  // ── R8c — `n` phải là 1..N liền mạch, không trùng ──
@@ -1049,7 +1096,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1049
1096
  // ── R14 — quyền khẳng định `pass`: chủ cột phải ĐỌC status, và T12 phải tồn tại ─
1050
1097
  //
1051
1098
  // Vì sao cần rule riêng (G55): schema khai cột 21 `status` có `read_by` gồm dev-run-test và
1052
- // qc-run-test — và R3 xác nhận ✅ vì cả hai file CÓ nhắc `status`… trong đúng câu khai rằng
1099
+ // qc-run-script — và R3 xác nhận ✅ vì cả hai file CÓ nhắc `status`… trong đúng câu khai rằng
1053
1100
  // chúng "trực giao" với nó, tức cố ý KHÔNG dùng. Số lần đọc thật: 0.
1054
1101
  //
1055
1102
  // Đây là lần thứ ba cùng một điểm yếu xuất hiện (R3 `mentions()` không phân biệt "đọc" với
@@ -1071,10 +1118,15 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1071
1118
  }
1072
1119
 
1073
1120
  for (const g of PAG.guards || []) {
1074
- const rel = `commands/${g.owner}.tmpl`;
1121
+ // `owner` CHUỖI khi cột có một chủ, MẢNG khi có nhiều (F2 · Đợt 2 · b2: cột
1122
+ // `qc_status` có HAI chủ — /qc-run-script và /qc-run-manualtest). Chuẩn hoá về mảng
1123
+ // thay vì khai hai entry guard cho cùng một cột: hai entry làm `blocked_when_status`
1124
+ // và `downgrade_to` bị chép đôi, và hai nơi nói một chuyện thì một nơi sẽ lệch (R19).
1125
+ for (const owner of (Array.isArray(g.owner) ? g.owner : [g.owner])) {
1126
+ const rel = `commands/${owner}.tmpl`;
1075
1127
  const abs = path.join(ROOT, rel);
1076
1128
  if (!fs.existsSync(abs)) {
1077
- err('R14', `\`positive_assertion_guards\` khai chủ cột \`${g.column}\` là \`${g.owner}\` — ${rel} không tồn tại`);
1129
+ err('R14', `\`positive_assertion_guards\` khai chủ cột \`${g.column}\` là \`${owner}\` — ${rel} không tồn tại`);
1078
1130
  continue;
1079
1131
  }
1080
1132
  const text = fs.readFileSync(abs, 'utf8');
@@ -1096,6 +1148,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1096
1148
  'Biết phải chặn mà không nói ghi gì thay thì agent tự chọn, và lựa chọn rẻ nhất là\n'
1097
1149
  + ' giữ nguyên giá trị cũ — tức guard không có tác dụng.');
1098
1150
  }
1151
+ }
1099
1152
  // (c) `fail` KHÔNG được nằm trong positive_values. Chặn tin xấu là biến một guard
1100
1153
  // chống-báo-cáo-sai thành một guard CHE tin xấu — hỏng theo hướng nguy hiểm hơn.
1101
1154
  if ((g.positive_values || []).includes('fail')) {
@@ -1140,7 +1193,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1140
1193
  //
1141
1194
  // R3 hỏi "file có nhắc tên field không". Đó là câu SAI cho những field mà trả lời sai
1142
1195
  // gây hại im lặng. Ca thật: schema khai cột `status` có read_by gồm dev-run-test và
1143
- // qc-run-test; R3 xác nhận ✅ vì cả hai file CÓ nhắc `status` — trong đúng câu khai rằng
1196
+ // qc-run-script; R3 xác nhận ✅ vì cả hai file CÓ nhắc `status` — trong đúng câu khai rằng
1144
1197
  // chúng "trực giao" với nó, tức CỐ Ý KHÔNG dùng. Số lần đọc thật: 0. Đó là G55, và R3
1145
1198
  // xác nhận contract bằng đúng câu văn đang phủ nhận contract.
1146
1199
  //
@@ -1382,6 +1435,553 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1382
1435
  }
1383
1436
  }
1384
1437
 
1438
+ // ── R17 — cờ bỏ qua điều kiện chỉ được mang MỘT tên: --force ──────────────────
1439
+ //
1440
+ // Vì sao: trước `gate.bypass_flags`, mỗi chỗ bypass đặt một tên riêng — `--force`
1441
+ // (generate-code), `--include-draft` (qc-scope), và exec-d0-b5 còn đang đề xuất
1442
+ // `--no-testid-contract` "theo đúng tinh thần --include-draft". Cái thứ ba chưa kịp sinh ra
1443
+ // đã thấy nó sẽ là cái thứ ba. Luật gom về một tên đã viết; đây là phần MÁY CANH.
1444
+ //
1445
+ // USE-CHECK, KHÔNG PHẢI MENTION-CHECK — cùng bài học R12. Một rule grep thô sẽ báo oan mỗi
1446
+ // lần file NHẮC TÊN một cờ cũ trong câu giải thích, mà rules/workflow.md đang cố ý nhắc
1447
+ // `--include-draft` bốn lần để giải thích tên đã bỏ. Nên chỉ quét DÒNG ĐỊNH NGHĨA cờ (hàng
1448
+ // bảng mở đầu bằng một cờ trong backtick), và BỎ QUA mọi dòng blockquote — nơi chứa toàn bộ
1449
+ // phần biện minh.
1450
+ {
1451
+ const BF = (schema.gate || {}).bypass_flags;
1452
+ if (BF) {
1453
+ const canonical = BF.canonical_name || '--force';
1454
+
1455
+ // Cờ KHÔNG phải bypass — chúng chọn chế độ/phạm vi, không bỏ qua điều kiện nào.
1456
+ const NON_BYPASS = new Set([
1457
+ '--yes', '--force', '--resume', '--fix', '--all', '--api', '--atomic-max', '--phase',
1458
+ '--from-code', '--uc', '--prd', '--domain', '--full', '--remote', '--init', '--apply',
1459
+ '--retire', '--review', '--interview', '--from', '--comment', '--code', '--trace',
1460
+ '--specs', '--flag', '--lint-trace', '--migrate-specs', '--warn-only',
1461
+ '--realign', '--realign-prd-version', '--realign-techdoc-revision', '--reconcile-code',
1462
+ ]);
1463
+ // Hình dạng một cờ BYPASS mới.
1464
+ const BYPASS_SHAPE = /^--(no|skip|ignore|include|allow|accept|bypass|override)-/;
1465
+
1466
+ for (const cmd of Object.keys(schema.actors || {})) {
1467
+ const rel = schema.actors[cmd];
1468
+ if (!/^commands\//.test(rel)) continue;
1469
+ const abs = path.join(ROOT, rel);
1470
+ if (!fs.existsSync(abs)) continue;
1471
+
1472
+ for (const line of fs.readFileSync(abs, 'utf8').split(/\r?\n/)) {
1473
+ if (/^\s*>/.test(line)) continue; // blockquote = phần giải thích
1474
+ const m = /^\s*\|\s*`(--[a-z0-9-]+)`/.exec(line); // hàng bảng định nghĩa cờ
1475
+ if (!m) continue;
1476
+ const flag = m[1];
1477
+ if (NON_BYPASS.has(flag) || !BYPASS_SHAPE.test(flag)) continue;
1478
+ err('R17', `${rel} định nghĩa cờ bypass riêng \`${flag}\``,
1479
+ `Mọi chỗ "biết mà vẫn chạy" dùng CHUNG một tên: \`${canonical}\`\n`
1480
+ + ` (schema → gate.bypass_flags · rules/workflow.md §Cờ bỏ qua điều kiện).\n`
1481
+ + ` Mỗi lệnh một tên riêng thì người dùng gặp cổng chặn, không nhớ lệnh này dùng\n`
1482
+ + ` từ nào, rồi đi SỬA SPEC cho hợp lệ giả thay vì khai mình đang chạy sớm.`);
1483
+ }
1484
+ }
1485
+
1486
+ for (const e of BF.declared_by || []) {
1487
+ if (!e.bypasses) {
1488
+ err('R17', `\`bypass_flags.declared_by\` mục \`${e.cmd}\` thiếu \`bypasses\``,
1489
+ 'Điều khoản (1): phạm vi khai TỪNG LỆNH, không bao giờ bao trùm. Không nói rõ nó bỏ\n'
1490
+ + ' qua ĐÚNG điều kiện nào thì `--force` thành "ghi đè tất cả".');
1491
+ }
1492
+ const rel = (schema.actors || {})[e.cmd] || `steps/${e.cmd}.md`;
1493
+ const abs = path.join(ROOT, rel);
1494
+ if (fs.existsSync(abs) && !fs.readFileSync(abs, 'utf8').includes(canonical)) {
1495
+ err('R17', `\`bypass_flags.declared_by\` khai \`${e.cmd}\` nhưng ${rel} không nhắc \`${canonical}\``,
1496
+ 'Khai một producer không tồn tại thì luật trông như đã được áp, trong khi không ai áp.');
1497
+ }
1498
+ }
1499
+ }
1500
+ }
1501
+
1502
+ // ── R18 — lệnh ghi artifact phải KHAI, và cái ĐÈ MẤT KHÔNG DỰNG LẠI ĐƯỢC thì phải `hard` ──
1503
+ //
1504
+ // Vì sao: `gate.checkpoint_levels` là ALLOWLIST với mặc định `normal` (mức LỎNG NHẤT), và
1505
+ // R11 duyệt TỪ SCHEMA RA FILE — nó chỉ kiểm lệnh ĐÃ liệt kê. Lệnh chưa khai thì không vòng
1506
+ // lặp nào chạm tới. Nên "quên khai" = "mở cửa", và nó đã xảy ra BỐN lần: G67 · G77 · G78 · G79.
1507
+ //
1508
+ // R18 duyệt CHIỀU NGƯỢC LẠI. Đó là toàn bộ giá trị của nó: một checker duyệt allowlist chỉ
1509
+ // thấy được cái ĐÃ KHAI, mà cái CHƯA KHAI mới là chỗ lỗi sống.
1510
+ //
1511
+ // HAI LUẬT ÉP `hard`, KHÔNG PHẢI MỘT:
1512
+ // (A) `has_human_content: true` — cấp LỆNH
1513
+ // (B) artifact `mode !== 'append'` + `regenerable: false` + `when: 'always'` — cấp ARTIFACT
1514
+ //
1515
+ // Luật (B) sinh ra vì (A) không phủ được SỔ TÍCH LUỸ: `trace-history.jsonl` KHÔNG ai gõ tay
1516
+ // vào — nên (A) trả lời "false", ĐÚNG CHỮ — trong khi chính /validate-traces gọi nó là
1517
+ // AUTHORITATIVE, không regenerate được. Đè lên là mất vĩnh viễn.
1518
+ //
1519
+ // VÌ SAO `mode !== 'append'` CHỨ KHÔNG PHẢI `mode === 'overwrite'`:
1520
+ // `merge` KHÔNG an toàn. /amend-prd là merge, và nó tự khai là "thao tác ghi DUY NHẤT trong
1521
+ // framework được phép làm output KHÔNG phải superset của bản cũ". Chỉ `append` an toàn vô
1522
+ // điều kiện. Gộp `merge` vào diện miễn là mở lại đúng cánh cửa vừa đóng.
1523
+ //
1524
+ // VÌ SAO (B) THÊM ĐIỀU KIỆN `when === 'always'` (D4):
1525
+ // Hành vi ghi thuộc về CHẾ ĐỘ, không thuộc về lệnh. NĂM lệnh tự khai "read-only" và cả năm
1526
+ // đều có ghi (/review-context · /review-tech-docs · /validate-traces · /review-code · /debug).
1527
+ // Ghi có điều kiện thì người dùng đã phải gõ THÊM một cờ hoặc trả lời Y — bản thân việc đó
1528
+ // đã là một cổng. Ép `hard` cho cả lệnh vì một nhánh hiếm là phạt nhầm chế độ đọc:
1529
+ // /validate-traces chạy hàng ngày ở chế độ đọc, và dựng cổng chặn lên nó vì nhánh
1530
+ // `--reconcile-code` sẽ dạy người ta thêm `--yes` vào script — rồi `--yes` đó nằm lại vĩnh
1531
+ // viễn, kể cả cho ngày họ chạy `--reconcile-code`.
1532
+ //
1533
+ // ĐỔI LẠI, ĐIỀU KIỆN PHẢI KIỂM CHỨNG ĐƯỢC — nếu không thì `when` thành cửa thoát cho mọi
1534
+ // lệnh lười khai. `when` là tên cờ ⇒ tmpl PHẢI nhắc đúng cờ đó; `when: 'confirm'` ⇒ tmpl
1535
+ // PHẢI có câu hỏi (Y/N). Khai một điều kiện không tồn tại thì luật trông như đã được áp
1536
+ // trong khi không ai áp — đúng bài học R17.
1537
+ {
1538
+ const AW = schema.artifact_writers;
1539
+ const CL = (schema.gate || {}).checkpoint_levels;
1540
+ if (AW && CL) {
1541
+ const hardSet = new Set((CL.hard || []).filter(e => e.cmd).map(e => e.cmd));
1542
+ const enrolled = AW.enrolled || {};
1543
+ const pending = (AW.pending_enrollment || {}).cmds || [];
1544
+ // Từ vựng đọc TỪ SCHEMA, không hard-code — thêm giá trị mới mà quên dạy checker là đúng
1545
+ // kiểu lỗi R18 sinh ra để bắt.
1546
+ const MODES = Object.keys(AW.write_modes || {}).filter(k => k !== '$comment');
1547
+ // Mode được miễn luật (B) — ĐỌC TỪ SCHEMA, không hard-code (xem artifact_writers.safe_modes).
1548
+ const SAFE = new Set(((AW.safe_modes || {}).modes) || ['append']);
1549
+ const ALWAYS = 'always';
1550
+ const CONFIRM = 'confirm';
1551
+ const isFlag = w => /^--[a-z0-9][a-z0-9-]*$/.test(w);
1552
+ const validWhen = w => w === ALWAYS || w === CONFIRM || isFlag(w);
1553
+
1554
+ for (const cmd of Object.keys(enrolled)) {
1555
+ const e = enrolled[cmd];
1556
+ const rel = `commands/${cmd}.tmpl`;
1557
+ const abs = path.join(ROOT, rel);
1558
+
1559
+ if (!fs.existsSync(abs)) {
1560
+ err('R18', `\`artifact_writers.enrolled\` khai \`${cmd}\` — ${rel} không tồn tại`,
1561
+ 'Lệnh bị đổi tên/bỏ? Khai một lệnh không có nghĩa là registry đang phủ nó.');
1562
+ continue;
1563
+ }
1564
+ const tmpl = fs.readFileSync(abs, 'utf8');
1565
+
1566
+ if (!Array.isArray(e.writes) || !e.writes.length) {
1567
+ err('R18', `\`artifact_writers.enrolled.${cmd}\` thiếu \`writes\``,
1568
+ 'Không liệt kê ghi cái gì thì không ai kiểm được "cái đó có người nhập tay không".');
1569
+ } else {
1570
+ e.writes.forEach((w, i) => {
1571
+ const at = `artifact_writers.enrolled.${cmd}.writes[${i}]`;
1572
+ if (typeof w !== 'object' || w === null || Array.isArray(w)) {
1573
+ err('R18', `\`${at}\` phải là object, không phải chuỗi`,
1574
+ 'Khuôn cũ (list chuỗi) không chứa được \`mode\`/\`regenerable\`/\`when\`. Một lệnh đụng\n'
1575
+ + ' nhiều artifact khác tính chất thì cờ phẳng ở cấp lệnh luôn nói dối một nửa.');
1576
+ return;
1577
+ }
1578
+ if (!w.path) {
1579
+ err('R18', `\`${at}\` thiếu \`path\``,
1580
+ 'Không có tên thì không đối chiếu ngược được với tmpl.');
1581
+ }
1582
+ if (!MODES.includes(w.mode)) {
1583
+ err('R18', `\`${at}\` có \`mode\` không hợp lệ: ${JSON.stringify(w.mode)}`,
1584
+ `Phải là một trong: ${MODES.join(' · ')} — định nghĩa ở \`artifact_writers.write_modes\`.`);
1585
+ }
1586
+ if (typeof w.regenerable !== 'boolean') {
1587
+ err('R18', `\`${at}\` thiếu \`regenerable\` (boolean)`,
1588
+ 'Câu hỏi mà \`has_human_content\` KHÔNG hỏi được: "đè mất thì dựng lại được không?".\n'
1589
+ + ' Sổ tích luỹ không ai gõ tay vào, nên \`has_human_content\` trả lời false — đúng\n'
1590
+ + ' chữ, và để lọt đúng thứ mà mất là mất vĩnh viễn.');
1591
+ }
1592
+ if (!validWhen(w.when)) {
1593
+ err('R18', `\`${at}\` thiếu/sai \`when\`: ${JSON.stringify(w.when)}`,
1594
+ 'Phải là \`always\`, \`confirm\`, hoặc một tên cờ dạng \`--ten-co\`. Hành vi ghi thuộc\n'
1595
+ + ' về CHẾ ĐỘ, không thuộc về lệnh — năm lệnh đã khai "read-only" trong khi cả năm\n'
1596
+ + ' đều có ghi, chính vì nhãn đặt theo chế độ mặc định rồi không ai sửa.');
1597
+ return;
1598
+ }
1599
+ // Điều kiện phải KIỂM CHỨNG ĐƯỢC, nếu không `when` thành cửa thoát.
1600
+ if (isFlag(w.when) && !tmpl.includes(w.when)) {
1601
+ err('R18', `\`${at}\` khai \`when: "${w.when}"\` nhưng ${rel} KHÔNG nhắc cờ đó`,
1602
+ 'Khai một điều kiện không tồn tại thì luật trông như đã được áp trong khi không ai\n'
1603
+ + ' áp — đúng bài học R17. Và nó miễn luôn artifact này khỏi luật (B).');
1604
+ }
1605
+ if (w.when === CONFIRM && !/\(Y\/N\)/.test(tmpl)) {
1606
+ err('R18', `\`${at}\` khai \`when: "confirm"\` nhưng ${rel} không có câu hỏi \`(Y/N)\``,
1607
+ '`confirm` nghĩa là NGƯỜI đã bấm Y — đó là cái thay cho CHECKPOINT. Không tìm thấy\n'
1608
+ + ' câu hỏi nào thì thao tác ghi đang chạy thẳng, mà vẫn được miễn luật (B).');
1609
+ }
1610
+ });
1611
+ }
1612
+ if (typeof e.has_human_content !== 'boolean') {
1613
+ err('R18', `\`artifact_writers.enrolled.${cmd}\` thiếu \`has_human_content\` (boolean)`,
1614
+ 'Đây là CÂU HỎI mà bốn lần trước không ai hỏi: "file này có phần người nhập tay\n'
1615
+ + ' không?". Bỏ trống là quay lại đúng mặc định lỏng mà rule này sinh ra để đảo.');
1616
+ }
1617
+ // `why` BẮT BUỘC cả khi false — đó chính là G79.
1618
+ if (!e.why) {
1619
+ err('R18', `\`artifact_writers.enrolled.${cmd}\` thiếu \`why\``,
1620
+ 'Bắt buộc CẢ KHI `has_human_content: false`. Ghi đè an toàn mà không ai khai là an\n'
1621
+ + ' toàn thì một ngoại lệ CÓ CHỦ Ý trông y hệt một chỗ sót — và sẽ bị copy đi (G79).');
1622
+ }
1623
+
1624
+ // ── Luật (B) — cấp ARTIFACT, chỉ cho thao tác ghi VÔ ĐIỀU KIỆN ──
1625
+ const doomed = (Array.isArray(e.writes) ? e.writes : [])
1626
+ .filter(w => w && typeof w === 'object'
1627
+ && !SAFE.has(w.mode) && w.regenerable === false && w.when === ALWAYS);
1628
+ if (doomed.length && !hardSet.has(cmd)) {
1629
+ const w = doomed[0];
1630
+ err('R18', `\`${cmd}\` ghi \`${w.mode}\` lên artifact KHÔNG dựng lại được ở MỌI lần chạy, nhưng KHÔNG ở \`checkpoint_levels.hard\``,
1631
+ `Artifact: ${w.path}\n`
1632
+ + ` Khai: "${String(w.note || e.why || '').slice(0, 110)}…"\n`
1633
+ + ' Không ai gõ tay vào nó thì `has_human_content` vẫn là false — đúng chữ. Nhưng\n'
1634
+ + ` \`${w.mode}\` lên thứ không dựng lại được là mất VĨNH VIỄN, và \`--yes\` ở mức\n`
1635
+ + ' `normal` làm việc đó TRONG IM LẶNG.\n'
1636
+ + ` Sửa: thêm \`${cmd}\` vào gate.checkpoint_levels.hard · HOẶC \`mode: "append"\` nếu\n`
1637
+ + ' thao tác thật sự chỉ thêm bản ghi mới · HOẶC `when` là cờ/confirm nếu nó CÓ điều kiện.');
1638
+ }
1639
+
1640
+ // ── Luật (A) — cấp LỆNH ──
1641
+ if (e.has_human_content === true && !hardSet.has(cmd)) {
1642
+ // `has_human_content` là cờ CẤP LỆNH. Khi một lệnh ghi nhiều artifact mà chỉ MỘT trong
1643
+ // số đó có phần người nhập tay, cờ cấp lệnh không nói được điều đó — và luật (A) sẽ ép
1644
+ // `hard` vì một tiền đề SAI. Gặp thật ở /fix-bug (N3): 4 artifact, chỉ `{lessons_path}`
1645
+ // có phần người nhập tay, và nó nằm sau `(Y/N)`.
1646
+ // Nên artifact ĐƯỢC PHÉP tự khai `human_content: true`; khi có khai, luật (A) chỉ xét
1647
+ // đúng những artifact đó. Không khai thì giữ hành vi cũ (xét mọi artifact) — an toàn hơn.
1648
+ // Miễn cho mode AN TOÀN, y như luật (B). Gặp thật ở /qc-run-manualtest (Đợt 2 · b2):
1649
+ // MANUAL_EVIDENCE.md CÓ phần người nhập tay và ghi ở MỌI lần chạy — nhưng `append`,
1650
+ // nên không lần ghi nào làm mất dòng nào. Ép `hard` ở đó là chặn một thao tác không
1651
+ // mất gì, tức đúng loại ma sát vô ích mà `unconditional_hard_budget` sinh ra để chống.
1652
+ const flagged = (e.writes || []).filter(w => w && w.human_content === true);
1653
+ const scope = (flagged.length ? flagged : (e.writes || [])).filter(w => w && !SAFE.has(w.mode));
1654
+ const conditional = scope.every(w => w && w.when && w.when !== ALWAYS);
1655
+ if (!conditional) {
1656
+ err('R18', `\`${cmd}\` ghi artifact CÓ phần người nhập tay nhưng KHÔNG ở \`checkpoint_levels.hard\``,
1657
+ `Khai: "${String(e.why || '').slice(0, 110)}…"\n`
1658
+ + ` Ghi đè thứ người nhập tay mà chỉ ở mức \`normal\` thì \`--yes\` xoá nó TRONG IM LẶNG.\n`
1659
+ + ` Sửa: thêm \`${cmd}\` vào gate.checkpoint_levels.hard + viết §luật chạy lại trong lệnh.`);
1660
+ }
1661
+ }
1662
+ }
1663
+
1664
+ // `non_writers` — lệnh đã rà và kết luận KHÔNG ghi gì. Một lệnh vắng mặt ở cả ba danh sách
1665
+ // trông y hệt một lệnh bị quên; ghi ra đây biến "không có gì" thành kết luận kiểm chứng được.
1666
+ const NW = AW.non_writers || {};
1667
+ for (const c of NW.cmds || []) {
1668
+ if (!fs.existsSync(path.join(ROOT, `commands/${c}.tmpl`))) {
1669
+ err('R18', `\`non_writers\` khai \`${c}\` — commands/${c}.tmpl không tồn tại`,
1670
+ 'Khai một lệnh không có thật làm danh sách trông đã phủ rộng hơn thực tế.');
1671
+ }
1672
+ if (enrolled[c]) {
1673
+ err('R18', `\`${c}\` ở CẢ \`non_writers\` LẪN \`enrolled\``,
1674
+ 'Mâu thuẫn trực tiếp: vừa khai không ghi gì, vừa liệt kê nó ghi cái gì. Chọn một.');
1675
+ }
1676
+ if (!(NW.why || {})[c]) {
1677
+ err('R18', `\`non_writers\` khai \`${c}\` nhưng thiếu lý do trong \`why\``,
1678
+ '"Không ghi gì" phải là một KẾT LUẬN kiểm chứng được, không phải một khoảng trống —\n'
1679
+ + ' cùng lập luận đã bắt `why` phải khai cả khi `has_human_content: false` (G79).');
1680
+ }
1681
+ }
1682
+
1683
+ // Chiều ngược: lệnh đã ở `hard` VÌ LÝ DO GHI ĐÈ phải có mặt trong registry.
1684
+ for (const h of CL.hard || []) {
1685
+ if (!h.cmd) continue; // mục $comment
1686
+ if (!/ghi đè/i.test(String(h.why || ''))) continue; // `hard` vì lý do khác (vd --resume)
1687
+ if (!enrolled[h.cmd] && !pending.includes(h.cmd)) {
1688
+ err('R18', `\`${h.cmd}\` ở \`checkpoint_levels.hard\` vì ghi đè, nhưng không có trong \`artifact_writers\``,
1689
+ 'Hai nơi cùng nói về một chuyện mà chỉ một nơi biết — nơi kia sẽ lệch.\n'
1690
+ + ' Thêm vào `enrolled` (đã rà) hoặc `pending_enrollment` (chưa rà, nợ được KHAI).');
1691
+ }
1692
+ }
1693
+ }
1694
+ }
1695
+
1696
+ // ── R19 — bảng mức gate CHÉP TAY trong steps/ và rules/ phải khớp schema ─────
1697
+ //
1698
+ // Vì sao: cùng một danh sách "lệnh nào chặn, lệnh nào không" tồn tại ở BA nơi, xếp thành
1699
+ // một chuỗi "nguồn" mà KHÔNG AI THỰC THI:
1700
+ // rules/workflow.md — tự nhận "đây chỉ là bản tóm tắt, gate là nguồn"
1701
+ // steps/gate.md — tự nhận "Nguồn máy đọc: bin/trace-schema.json"
1702
+ // bin/trace-schema.json — nguồn thật
1703
+ // Mỗi bản trỏ sang bản sau nói "kia mới là nguồn", và cả hai vẫn CHÉP CỨNG tên lệnh. Một
1704
+ // con trỏ tới nguồn KHÔNG ngăn được bản sao lệch — nó chỉ làm người sửa yên tâm rằng có ai
1705
+ // đó đang canh.
1706
+ //
1707
+ // Đã lệch thật (2026-09-16): /review-context và /review-tech-docs chuyển `none` → `hard`,
1708
+ // schema + commands/*.tmpl + README đều sửa, build XANH — mà hai bảng kia vẫn liệt chúng ở
1709
+ // "Không chặn". R11 canh `commands/*.tmpl` ↔ schema nên người sửa tin là "đã có máy canh
1710
+ // mức gate"; phạm vi nó canh hẹp hơn thế. BIẾT CÓ MÁY CANH KHÔNG BẰNG BIẾT MÁY CANH ĐẾN ĐÂU.
1711
+ //
1712
+ // QUÉT THEO DÒNG, KHÔNG THEO FILE. Mục đích là GIỮ được ví dụ cụ thể (thứ làm bảng dễ đọc)
1713
+ // mà vẫn buộc ví dụ phải ĐÚNG. Cấm hẳn việc nêu tên sẽ làm bảng khó hiểu hơn, và rồi người
1714
+ // ta chép lại ở chỗ khác — xa hơn, khó canh hơn.
1715
+ //
1716
+ // KHÔNG quét `skills/`: ở đó "read-only" nói về ARTIFACT ĐANG XEM (vd /review-code không
1717
+ // sửa code nó review), không nói về mức CHECKPOINT. Hai nghĩa khác nhau của cùng một chữ.
1718
+ {
1719
+ const CL = (schema.gate || {}).checkpoint_levels;
1720
+ if (CL) {
1721
+ const hardSet = new Set((CL.hard || []).filter(e => e.cmd).map(e => e.cmd));
1722
+ const noneSet = new Set((CL.none || []).filter(e => e.cmd).map(e => e.cmd));
1723
+ const realLevel = c => hardSet.has(c) ? 'hard' : (noneSet.has(c) ? 'none' : 'normal');
1724
+
1725
+ // Từ-mức ↔ khoá schema. `normal` là MẶC ĐỊNH (không liệt kê trong schema — xem R11).
1726
+ const ROW = [
1727
+ [/^\|\s*\*\*Không chặn\*\*/i, 'none'],
1728
+ [/^\|\s*\*\*Chặn thường\*\*/i, 'normal'],
1729
+ [/^\|\s*\*\*Chặn CỨNG\*\*/i, 'hard'],
1730
+ ];
1731
+
1732
+ for (const dir of ['steps', 'rules']) {
1733
+ const abs = path.join(ROOT, dir);
1734
+ if (!fs.existsSync(abs)) continue;
1735
+ for (const f of fs.readdirSync(abs).filter(x => x.endsWith('.md'))) {
1736
+ const rel = `${dir}/${f}`;
1737
+ const lines = fs.readFileSync(path.join(abs, f), 'utf8').split(/\r?\n/);
1738
+ lines.forEach((line, i) => {
1739
+ const hit = ROW.find(([re]) => re.test(line));
1740
+ if (!hit) return; // không phải hàng bảng mức
1741
+ const declared = hit[1];
1742
+ // Lookbehind chặn báo oan: "sinh/sửa artifact" từng bị cắt ra thành lệnh `/s`.
1743
+ const names = [...line.matchAll(/(?<!\p{L})\/([a-z][a-z0-9-]{2,})(?![\p{L}\d-])/gu)].map(m => m[1]);
1744
+ for (const c of new Set(names)) {
1745
+ if (!fs.existsSync(path.join(ROOT, 'commands', `${c}.tmpl`))) {
1746
+ err('R19', `${rel}:${i + 1} nêu \`/${c}\` — commands/${c}.tmpl không tồn tại`,
1747
+ 'Một ví dụ trỏ vào lệnh không có thật làm cả hàng mất giá trị: người đọc không\n'
1748
+ + ' phân biệt được đâu là tên cũ chưa dọn, đâu là tên đang sai.');
1749
+ continue;
1750
+ }
1751
+ const real = realLevel(c);
1752
+ if (real !== declared) {
1753
+ err('R19', `${rel}:${i + 1} xếp \`/${c}\` vào mức \`${declared}\`, schema nói \`${real}\``,
1754
+ 'Hai file này được nạp vào MỌI phiên chạy. gate.md tự viết: "Hai file cùng được\n'
1755
+ + ' nạp vào mọi lệnh mà nói ngược nhau; agent theo cái nào là TUỲ LÚC".\n'
1756
+ + ` Sửa hàng bảng cho khớp \`gate.checkpoint_levels\`, đừng sửa schema cho khớp bảng.`);
1757
+ }
1758
+ }
1759
+ });
1760
+ }
1761
+ }
1762
+
1763
+ // Chiều ngược: lệnh ở `hard`/`none` không được nằm nhầm hàng ở bất kỳ bảng nào — đã phủ
1764
+ // bởi vòng trên. Nhưng một lệnh `none` mà KHÔNG bảng nào nêu thì người đọc không có ví
1765
+ // dụ nào — đó là mất mát tài liệu, không phải lỗi contract, nên chỉ cảnh báo.
1766
+ const shown = new Set();
1767
+ for (const dir of ['steps', 'rules']) {
1768
+ const abs = path.join(ROOT, dir);
1769
+ if (!fs.existsSync(abs)) continue;
1770
+ for (const f of fs.readdirSync(abs).filter(x => x.endsWith('.md'))) {
1771
+ const txt = fs.readFileSync(path.join(abs, f), 'utf8');
1772
+ for (const line of txt.split(/\r?\n/)) {
1773
+ if (!ROW.some(([re]) => re.test(line))) continue;
1774
+ for (const m of line.matchAll(/(?<!\p{L})\/([a-z][a-z0-9-]{2,})(?![\p{L}\d-])/gu)) shown.add(m[1]);
1775
+ }
1776
+ }
1777
+ }
1778
+ const missing = [...noneSet].filter(c => !shown.has(c));
1779
+ if (missing.length) {
1780
+ warn('R19', `mức \`none\` có lệnh không bảng nào nêu làm ví dụ: ${missing.join(', ')}`,
1781
+ 'Không phải lỗi contract — nhưng `none` là mức LỎNG NHẤT và cũng là mức dễ bị copy\n'
1782
+ + ' nhầm nhất. Một ví dụ trong bảng rẻ hơn một lần copy sai.');
1783
+ }
1784
+ }
1785
+ }
1786
+
1787
+ // ── R20 — mỗi cổng CỨNG phải khai ĐIỀU KIỆN nó nổ, và số cổng vô điều kiện có HẠN MỨC ──
1788
+ //
1789
+ // Vì sao: `hard` là mức `--yes` KHÔNG bỏ qua được. Khi nó trở nên phiền, người dùng không
1790
+ // thêm `--yes` — họ GỠ LỆNH KHỎI `hard`. Và lúc đó mất luôn những cổng đáng giữ. Một cơ chế
1791
+ // cổng chặn quá dày không làm hệ thống an toàn hơn rồi chậm hơn; nó làm hệ thống KÉM an toàn
1792
+ // hơn, vì nó tiêu huỷ chính cơ chế nó thuộc về. gate.md Bước 3b tồn tại vì đúng rủi ro đó:
1793
+ // "cổng luôn in ra một bảng giống hệt nhau … nên Y thành phản xạ và cổng hỏng âm thầm".
1794
+ //
1795
+ // ĐO 2026-09-16 — 12 lệnh ở `hard`, nhưng chỉ 3 trong đó nổ ở MỌI lần chạy:
1796
+ // 7 lệnh khai "chỉ áp khi file đã tồn tại" → lần chạy đầu đi thẳng
1797
+ // 2 lệnh CÓ điều kiện y hệt nhưng KHÔNG khai (generate-prd · generate-architecture)
1798
+ // 3 lệnh không có lần chạy vô hại (extend/amend/refine-prd — chỉ chạy trên PRD đã duyệt)
1799
+ // Con số gây lo lắng là 12; con số ĐÚNG là 3. R20 làm con số 3 đó đo được và giữ được.
1800
+ //
1801
+ // BẮT KHAI `null` KÈM LÝ DO: "không có điều kiện" và "quên nghĩ tới điều kiện" trông y hệt
1802
+ // nhau — đúng lập luận đã bắt `why` phải khai cả khi `false` (G79).
1803
+ //
1804
+ // ĐIỀU KIỆN PHẢI CÓ Ở CẢ HAI NƠI: khai trong schema mà tmpl không nói thì agent đọc tmpl sẽ
1805
+ // chặn mọi lần — hình dạng G41, hai nguồn nói khác nhau.
1806
+ {
1807
+ const CL = (schema.gate || {}).checkpoint_levels;
1808
+ if (CL) {
1809
+ const B = CL.unconditional_hard_budget;
1810
+ // Khuôn câu thu hẹp trong tmpl. Chấp nhận cả `*Mức cứng chỉ áp …*` lẫn
1811
+ // `*Mức cứng **chỉ áp** …*` — hai biến thể đang cùng tồn tại.
1812
+ const NARROW_LINE = /Mức cứng\s*(?:\*\*)?\s*chỉ áp/i;
1813
+ const unconditional = [];
1814
+
1815
+ for (const e of CL.hard || []) {
1816
+ if (!e.cmd) continue;
1817
+ const rel = `commands/${e.cmd}.tmpl`;
1818
+
1819
+ if (!('narrowing' in e)) {
1820
+ err('R20', `\`checkpoint_levels.hard\` mục \`${e.cmd}\` thiếu \`narrowing\``,
1821
+ 'Phải khai điều kiện cổng nổ — hoặc `null` nếu KHÔNG có lần chạy vô hại.\n'
1822
+ + ' Bỏ trống thì "cố ý không có điều kiện" trông y hệt "quên nghĩ tới điều kiện",\n'
1823
+ + ' và người sau sẽ copy chỗ sót đi (G79).');
1824
+ continue;
1825
+ }
1826
+
1827
+ if (e.narrowing === null) {
1828
+ unconditional.push(e.cmd);
1829
+ if (!e.narrowing_why) {
1830
+ err('R20', `\`${e.cmd}\` khai \`narrowing: null\` nhưng thiếu \`narrowing_why\``,
1831
+ 'Cổng nổ ở MỌI lần chạy là thứ đắt nhất trong cơ chế này. Nói ra vì sao nó ĐÚNG,\n'
1832
+ + ' nếu không người sau sẽ tưởng là chỗ sót rồi thêm điều kiện cho "đỡ phiền".');
1833
+ }
1834
+ continue;
1835
+ }
1836
+
1837
+ if (typeof e.narrowing !== 'string' || !e.narrowing.trim()) {
1838
+ err('R20', `\`${e.cmd}\`.\`narrowing\` phải là chuỗi điều kiện hoặc \`null\``,
1839
+ `Thấy: ${JSON.stringify(e.narrowing)}`);
1840
+ continue;
1841
+ }
1842
+
1843
+ const abs = path.join(ROOT, rel);
1844
+ if (fs.existsSync(abs) && !NARROW_LINE.test(fs.readFileSync(abs, 'utf8'))) {
1845
+ err('R20', `\`${e.cmd}\` khai \`narrowing\` trong schema nhưng ${rel} KHÔNG có câu thu hẹp`,
1846
+ `Khai: "${e.narrowing.slice(0, 95)}…"\n`
1847
+ + ' Agent đọc TMPL, không đọc schema. Thiếu câu đó thì nó chặn MỌI lần chạy —\n'
1848
+ + ' đúng thứ điều kiện này sinh ra để tránh. Thêm một dòng ngay dưới nhãn\n'
1849
+ + ' Checkpoint theo khuôn: *Mức cứng chỉ áp khi …*');
1850
+ }
1851
+ }
1852
+
1853
+ // ── Hạn mức — bánh cóc cho số cổng nổ vô điều kiện ──
1854
+ if (!B || typeof B.max !== 'number') {
1855
+ err('R20', '`checkpoint_levels.unconditional_hard_budget.max` chưa khai',
1856
+ 'Với mọi cơ chế mà "thêm một cái nữa" luôn hợp lý từng lần, hạn mức phải nằm TRONG\n'
1857
+ + ' contract để việc vượt nó là một lần sửa NHÌN THẤY trong diff, không phải một\n'
1858
+ + ' hệ quả cộng dồn.');
1859
+ } else if (unconditional.length > B.max) {
1860
+ err('R20', `${unconditional.length} cổng cứng nổ VÔ ĐIỀU KIỆN, vượt hạn mức ${B.max}`,
1861
+ `Đang vượt: ${unconditional.join(', ')}\n`
1862
+ + ' `--yes` KHÔNG bỏ qua được `hard`. Nên khi `hard` quá phiền, người ta không thêm\n'
1863
+ + ' `--yes` — họ GỠ LỆNH KHỎI `hard`, và mất luôn cổng đáng giữ.\n'
1864
+ + ' Sửa: thêm `narrowing` cho một lệnh, HOẶC nâng `max` — nâng là một QUYẾT ĐỊNH,\n'
1865
+ + ' phải kèm lý do trong `why` của khối ngân sách.');
1866
+ } else if (Array.isArray(B.current)) {
1867
+ const a = [...unconditional].sort().join(',');
1868
+ const b = [...B.current].sort().join(',');
1869
+ if (a !== b) {
1870
+ err('R20', '`unconditional_hard_budget.current` lệch với thực tế',
1871
+ `khai: ${b || '(rỗng)'}\n thật: ${a || '(rỗng)'}\n`
1872
+ + ' Danh sách chép tay cạnh một con số sẽ lệch — đúng bài học R19. Cập nhật nó.');
1873
+ }
1874
+ }
1875
+
1876
+ if (B && typeof B.max === 'number' && !B.why) {
1877
+ err('R20', '`unconditional_hard_budget` thiếu `why`',
1878
+ 'Một con số không nói được vì sao sẽ bị người sau nâng lên cho tiện.');
1879
+ }
1880
+ }
1881
+ }
1882
+
1883
+ // ── R21 — mọi `checked_by[].cmd` phải trỏ vào một lệnh CÓ THẬT ───────────────
1884
+ //
1885
+ // Vì sao: nhiều khối contract khai "ai canh điều kiện này" bằng một danh sách `checked_by`.
1886
+ // Khi một lệnh bị ĐỔI TÊN hoặc TÁCH ĐÔI, danh sách đó lệch mà KHÔNG máy nào bắt — và hậu quả
1887
+ // tệ hơn một tham chiếu treo thường: contract vẫn khai điều kiện đó "đã được canh", trong khi
1888
+ // không lệnh nào còn canh nó. Người đọc contract thấy một cái lưới; cái lưới không có ở đó.
1889
+ //
1890
+ // GẶP THẬT (Đợt 2 · b1 · 2026-09-16): tách /qc-review → /qc-review-testcase + /qc-review-script.
1891
+ // `testid_fourth_leg.checked_by` — CHÂN THỨ TƯ của hợp đồng test-id (G64/G76) — trỏ vào tên cũ.
1892
+ // Bản kế hoạch của bước đó khai "tách không đụng schema, grep → 0"; grep thật ra 3. Nếu tin bản
1893
+ // kế hoạch, chân thứ tư mất người canh và build vẫn XANH.
1894
+ //
1895
+ // Đây là hình dạng đã gặp ở R17 ("khai một producer không tồn tại thì luật trông như đã được áp,
1896
+ // trong khi không ai áp") — R21 là cùng luật đó, áp cho `checked_by` thay vì `declared_by`.
1897
+ //
1898
+ // QUÉT ĐỆ QUY, không liệt kê tên khối: hôm nay có 2 khối (`testid_fourth_leg`, `qc_artifact_stamp`);
1899
+ // khối thứ ba thêm vào mà quên dạy checker là đúng kiểu lỗi rule này sinh ra để bắt.
1900
+ {
1901
+ const seen = [];
1902
+ (function walk(node, at) {
1903
+ if (!node || typeof node !== 'object') return;
1904
+ for (const k of Object.keys(node)) {
1905
+ const v = node[k];
1906
+ if (k === 'checked_by' && Array.isArray(v)) seen.push([at + '.' + k, v]);
1907
+ if (v && typeof v === 'object') walk(v, at + '.' + k);
1908
+ }
1909
+ })(schema, '');
1910
+
1911
+ if (!seen.length) {
1912
+ warn('R21', 'không tìm thấy khối `checked_by` nào',
1913
+ 'Rule này sinh ra để canh chúng. Không còn khối nào thì hoặc contract đã đổi hình,\n'
1914
+ + ' hoặc rule đang quét sai chỗ — cả hai đều đáng nhìn lại.');
1915
+ }
1916
+
1917
+ for (const [at, list] of seen) {
1918
+ list.forEach((e, i) => {
1919
+ if (!e || !e.cmd) {
1920
+ err('R21', `\`${at}[${i}]\` thiếu \`cmd\``,
1921
+ 'Một mục "ai canh" không nói được AI canh thì nó không khai gì cả.');
1922
+ return;
1923
+ }
1924
+ const rel = `commands/${e.cmd}.tmpl`;
1925
+ if (!fs.existsSync(path.join(ROOT, rel))) {
1926
+ err('R21', `\`${at}\` khai \`${e.cmd}\` — ${rel} không tồn tại`,
1927
+ 'Lệnh bị đổi tên hoặc TÁCH ĐÔI? Contract vẫn khai điều kiện này "đã được canh", nhưng\n'
1928
+ + ' không lệnh nào còn canh nó — tệ hơn một lỗ hổng ai cũng biết là chưa canh.\n'
1929
+ + ' Sửa: trỏ sang lệnh THỪA HƯỞNG vai đó, đừng chỉ xoá mục.');
1930
+ }
1931
+ });
1932
+ }
1933
+ }
1934
+
1935
+ // ── R22 — mọi artifact được khai phải có ÍT NHẤT MỘT lệnh TẠO ra nó ──────────
1936
+ //
1937
+ // Vì sao: `artifact_writers` trả lời "ai ghi gì", nhưng GHI không đồng nghĩa với TẠO. Một lệnh
1938
+ // khai `mode: merge` là nói "tôi sửa một phần của file đã có" — nó KHÔNG hứa file đó tồn tại.
1939
+ // Nếu MỌI lệnh ghi một artifact đều chỉ `merge`, thì **không lệnh nào sinh ra nó**, và cả chuỗi
1940
+ // trạm phía sau đứng chờ một file không bao giờ xuất hiện. Build vẫn xanh suốt.
1941
+ //
1942
+ // GẶP THẬT, HAI CA (Đợt 2 · b3 · 2026-09-16):
1943
+ // · AUTOMATION_ASSESSMENT.md — /qc-design-script khai ghi CỘT `Script file` của nó (merge),
1944
+ // và /qc-run-manualtest đọc cột `Automatable` từ nó. Không lệnh nào TẠO. Ba lệnh viết ở b2
1945
+ // cùng trỏ vào một file không ai sinh, và không rule nào kêu.
1946
+ // · _seams.tsv — /generate-code tự nói "tạo file + header nếu chưa có", nhưng KHÔNG khai
1947
+ // artifact đó; chỉ /validate-traces khai (merge, "nếu tồn tại"). Sổ này rơi hẳn khỏi registry.
1948
+ //
1949
+ // `creates: true` LÀ ĐƯỜNG KHAI CHÍNH XÁC, KHÔNG PHẢI ĐƯỜNG TRỐN. Nhiều lệnh vừa tạo-khi-vắng
1950
+ // vừa merge-khi-có (file findings của /refine-prd · /review-context · /review-tech-docs). Ép
1951
+ // chúng khai `overwrite` là nói dối — chế độ delta của chúng GIỮ `status` của reviewer. Nên
1952
+ // mode vẫn `merge`, và `creates: true` nói thêm "và tôi sinh nó ra nếu chưa có".
1953
+ {
1954
+ const AW = schema.artifact_writers;
1955
+ if (AW && AW.enrolled) {
1956
+ // Khoá nhận dạng artifact: tên file trong `path`. Cùng tên = cùng artifact, bất kể
1957
+ // placeholder thư mục khác nhau ({qc_artifact_dir} vs {refinement_dir}).
1958
+ const KEY = /([A-Z][A-Z0-9_]{3,}(?:_?<[A-Z]+>)?\.[A-Za-z.]+|[a-z][a-z-]*\.(?:tsv|jsonl|json|yaml|yml))/;
1959
+ const seen = new Map(); // tên file → [{cmd, mode, creates}]
1960
+
1961
+ for (const [cmd, e] of Object.entries(AW.enrolled)) {
1962
+ for (const w of e.writes || []) {
1963
+ if (!w || typeof w !== 'object') continue;
1964
+ const m = KEY.exec(String(w.path || ''));
1965
+ if (!m) continue; // artifact mô tả bằng lời (vd "Page Object") — không khoá được
1966
+ if (!seen.has(m[1])) seen.set(m[1], []);
1967
+ seen.get(m[1]).push({ cmd, mode: w.mode, creates: w.creates === true });
1968
+ }
1969
+ }
1970
+
1971
+ const CREATING = new Set(['overwrite', 'create', 'append']);
1972
+ for (const [file, writers] of seen) {
1973
+ const creators = writers.filter(w => CREATING.has(w.mode) || w.creates);
1974
+ if (creators.length) continue;
1975
+ err('R22', `\`${file}\` không lệnh nào TẠO — mọi lệnh ghi nó đều \`merge\``,
1976
+ `Đang ghi: ${writers.map(w => w.cmd + ':' + w.mode).join(' · ')}\n`
1977
+ + ' `merge` nói "tôi sửa một phần của file ĐÃ CÓ" — nó không hứa file đó tồn tại. Cả\n'
1978
+ + ' chuỗi trạm sau sẽ đứng chờ một file không bao giờ xuất hiện, và build vẫn xanh.\n'
1979
+ + ' Sửa: khai lệnh TẠO nó (`overwrite`/`create`/`append`), HOẶC thêm `creates: true`\n'
1980
+ + ' vào entry của lệnh vừa-tạo-khi-vắng vừa-merge-khi-có.');
1981
+ }
1982
+ }
1983
+ }
1984
+
1385
1985
  // ── report ────────────────────────────────────────────────────────────────────
1386
1986
  const tagCount = schema.tags.length;
1387
1987
  const colCount = schema.tsv_columns.length;
@@ -1405,6 +2005,13 @@ if (!errors.length && !warns.length) {
1405
2005
  // một key `$` kể từ khi thêm `$lint_binding_comment`, nên hằng số cũ sẽ đếm lố.
1406
2006
  const vocabCount = Object.keys(schema.vocabularies).filter(k => !k.startsWith('$')).length;
1407
2007
  console.log(` ✅ ${tagCount} tag + ${colCount} cột TSV + ${auxCount} bảng phụ + ${queueCount} hàng đợi + ${vocabCount} vocabulary — contract khớp mọi lệnh`);
2008
+ {
2009
+ const _CL = (schema.gate || {}).checkpoint_levels || {};
2010
+ const _hard = (_CL.hard || []).filter(e => e.cmd);
2011
+ const _u = _hard.filter(e => e.narrowing === null);
2012
+ const _max = ((_CL.unconditional_hard_budget || {}).max);
2013
+ console.log(` + ${_hard.length} cổng CỨNG, trong đó ${_u.length} nổ VÔ ĐIỀU KIỆN` + (typeof _max === 'number' ? ` (hạn mức ${_max})` : '') + ` — ${_hard.length - _u.length} cổng còn lại chỉ nổ khi đã có thứ để mất`);
2014
+ }
1408
2015
  // ĐẾM rule từ chính lint-trace.js, không hard-code: một con số phát biểu ở hai chỗ rồi
1409
2016
  // lệch nhau là đúng lớp lỗi G23 được dựng lên để chặn. Đếm id `Tn` mà nó thực sự phát ra.
1410
2017
  let lintRules = '?';