@educa-corp/sdd-framework 0.9.6 → 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 (93) hide show
  1. package/bin/lint-trace.js +4 -4
  2. package/bin/self-check.js +448 -12
  3. package/bin/trace-schema.json +643 -112
  4. package/core/FRAMEWORK_VERSION +1 -1
  5. package/core/commands/amend-prd.md +7 -1
  6. package/core/commands/debug.md +8 -2
  7. package/core/commands/define-product.md +38 -1
  8. package/core/commands/dev-gen-test.md +9 -3
  9. package/core/commands/dev-run-test.md +8 -2
  10. package/core/commands/dev-smoke-test.md +7 -1
  11. package/core/commands/extend-prd.md +7 -1
  12. package/core/commands/fix-bug.md +11 -5
  13. package/core/commands/generate-architecture.md +9 -1
  14. package/core/commands/generate-bdd.md +45 -5
  15. package/core/commands/generate-code.md +43 -4
  16. package/core/commands/generate-design-spec.md +7 -1
  17. package/core/commands/generate-prd.md +9 -1
  18. package/core/commands/generate-spec-manifest.md +7 -1
  19. package/core/commands/generate-tech-docs.md +41 -1
  20. package/core/commands/learn.md +7 -1
  21. package/core/commands/map-testids.md +11 -5
  22. package/core/commands/propose-scenario.md +7 -1
  23. package/core/commands/qc-analyze.md +12 -6
  24. package/core/commands/qc-automation-assess.md +356 -0
  25. package/core/commands/qc-design-script.md +400 -0
  26. package/core/commands/qc-design-test.md +21 -15
  27. package/core/commands/qc-plan.md +9 -3
  28. package/core/commands/qc-report.md +9 -3
  29. package/core/commands/qc-review-script.md +333 -0
  30. package/core/commands/{qc-review.md → qc-review-testcase.md} +84 -53
  31. package/core/commands/qc-run-manualtest.md +401 -0
  32. package/core/commands/qc-run-script.md +414 -0
  33. package/core/commands/refine-prd.md +7 -1
  34. package/core/commands/report-bug.md +9 -3
  35. package/core/commands/review-code.md +9 -3
  36. package/core/commands/review-context.md +11 -3
  37. package/core/commands/review-tech-docs.md +11 -3
  38. package/core/commands/setup-ai-first.md +7 -1
  39. package/core/commands/validate-traces.md +10 -4
  40. package/core/modules/qc-playwright/stack-profile.yaml +1 -1
  41. package/core/rules/workflow.md +2 -2
  42. package/core/skills/qc/_shared/self-review-principles.md +2 -2
  43. package/core/skills/qc/qa-analyst/DOC_GAP.template.md +1 -1
  44. package/core/skills/qc/qa-analyst/spec-issue-reporter.md +1 -1
  45. package/core/skills/qc/qa-automation-assess/matrix.md +120 -0
  46. package/core/skills/qc/qa-designer/e2e/journey.md +1 -1
  47. package/core/skills/qc/qa-designer/exploratory/explore-to-functional.md +1 -1
  48. package/core/skills/qc/qa-designer/functional/api.md +1 -1
  49. package/core/skills/qc/qa-designer/functional/gui-feature.md +1 -1
  50. package/core/skills/qc/qa-designer/functional/gui-screen.md +1 -1
  51. package/core/skills/qc/qa-designer/integration/api.md +1 -1
  52. package/core/skills/qc/qa-designer/integration/db.md +1 -1
  53. package/core/skills/qc/qa-designer/integration/gui.md +1 -1
  54. package/core/skills/qc/qa-designer/integration/kafka.md +1 -1
  55. package/core/skills/qc/qa-designer/non-functional.md +1 -1
  56. package/core/skills/qc/qa-designer/shared/tc-metadata-format.md +11 -6
  57. package/core/skills/qc/qa-reviewer/script/e2e.md +1 -1
  58. package/core/skills/qc/qa-reviewer/script/exploratory.md +1 -1
  59. package/core/skills/qc/qa-reviewer/script/functional.md +1 -1
  60. package/core/skills/qc/qa-reviewer/script/integration.md +1 -1
  61. package/core/skills/qc/qa-reviewer/script/non-functional.md +1 -1
  62. package/core/skills/qc/qa-reviewer/shared/review-file-template.md +3 -3
  63. package/core/skills/qc/qa-reviewer/test-case/e2e.md +1 -1
  64. package/core/skills/qc/qa-reviewer/test-case/functional.md +1 -1
  65. package/core/skills/qc/qa-reviewer/test-case/integration.md +1 -1
  66. package/core/skills/qc/qa-reviewer/test-case/non-functional.md +1 -1
  67. package/core/skills/qc/qa-runner/e2e.md +2 -2
  68. package/core/skills/qc/qa-runner/functional/gui-feature.md +3 -3
  69. package/core/skills/qc/qa-runner/functional/gui-screen.md +3 -3
  70. package/core/skills/qc/qa-runner/integration.md +1 -1
  71. package/core/skills/qc/qa-runner/non-functional.md +1 -1
  72. package/core/steps/context-loader.md +1 -1
  73. package/core/steps/gate.md +7 -1
  74. package/core/steps/qc-scope.md +2 -2
  75. package/core/steps/qc-stamp.md +4 -4
  76. package/core/steps/report-footer.md +10 -9
  77. package/docs/02-concepts/pipeline-steps/07-dev-selftest.md +1 -1
  78. package/docs/02-concepts/pipeline-steps/08-qc-automation.md +9 -8
  79. package/docs/02-concepts/pipeline-steps/10-feedback-loop.md +3 -3
  80. package/docs/02-concepts/traceability.md +1 -1
  81. package/docs/03-guides/developer.md +1 -1
  82. package/docs/03-guides/tester-qa.md +8 -7
  83. package/docs/04-reference/commands.md +4 -2
  84. package/docs/04-reference/trace-schema.md +4 -4
  85. package/docs/explain/17-qc-design-test.md +5 -5
  86. package/docs/explain/18-qc-review.md +42 -20
  87. package/docs/explain/19-qc-run-test.md +9 -6
  88. package/docs/explain/20-qc-report.md +3 -3
  89. package/docs/explain/23-fix-bug.md +2 -2
  90. package/docs/explain/README.md +2 -2
  91. package/docs/plans/qc-surgery/01-checklist.md +70 -17
  92. package/package.json +1 -1
  93. package/core/commands/qc-run-test.md +0 -561
package/bin/lint-trace.js CHANGED
@@ -965,7 +965,7 @@ function lintTestidContract() {
965
965
  //
966
966
  // LẬP LUẬN GIỮ `error` ĐÃ HẾT ĐÚNG. Nó là "QC đoán theo nền ⇒ trượt 100% locator TRONG IM
967
967
  // LẶNG". Vế "im lặng" bị hai consumer vô hiệu hoá từ lâu:
968
- // qc-run-test "Cảnh báo mềm, KHÔNG im lặng hardcode: ⚠️ Tech-doc thiếu @trace.testid_attr…"
968
+ // qc-design-script "Cảnh báo mềm, KHÔNG im lặng hardcode: ⚠️ Tech-doc thiếu @trace.testid_attr…"
969
969
  // generate-code "Cảnh báo mềm, KHÔNG im lặng hardcode, rồi mới fallback"
970
970
  // Giữ `error` là dựng cổng thứ hai cho một chuyện đã có người canh — và cổng đó chặn nhầm
971
971
  // người. Cùng lập luận đã dùng cho T14: "nợ tồn ở project đang chạy sẽ đỏ khắp nơi ở lần đầu
@@ -1272,10 +1272,10 @@ lintCodeVsLedger();
1272
1272
  // Chân thứ tư — bảng ↔ *.Test.md — là chân DUY NHẤT mà giá trị id bị CHÉP CỨNG vào một
1273
1273
  // artifact BỀN rồi nằm lại:
1274
1274
  // generate-code → bake vào code → T17/T18 canh
1275
- // qc-run-test → dựng locator LÚC CHẠY → đọc bảng tươi, tự cứu
1275
+ // qc-run-script → dựng locator LÚC CHẠY → đọc bảng tươi, tự cứu
1276
1276
  // qc-design-test → chép vào .Test.md → chân này
1277
1277
  //
1278
- // VÌ SAO CẦN CẢ LỚP NÀY khi /qc-review và /qc-run-test ĐÃ có guard cho nó (G64):
1278
+ // VÌ SAO CẦN CẢ LỚP NÀY khi /qc-review-testcase và /qc-design-script ĐÃ có guard cho nó (G64):
1279
1279
  // rules/workflow.md — "Canh contract ≠ canh dữ liệu. Cần CẢ HAI". Guard ở trạm 4-5 phủ 100%
1280
1280
  // đường đi thật nhưng do LLM thực thi: nó không chặn được trước khi merge. T19 nông hơn nhưng
1281
1281
  // chạy trong CI. Hai lớp KHÔNG thay nhau — đúng cặp self-check ↔ lint-trace.
@@ -1288,7 +1288,7 @@ function lintTestidVsTestCases() {
1288
1288
  if (!QC_DIRS.length) {
1289
1289
  infos.push(`T19 BỎ QUA — không có --qc nên chưa đối chiếu hợp đồng test-id với test-case. ` +
1290
1290
  `Ca "TC bám id không còn trong §4.5.6" đang KHÔNG được kiểm ở tầng CI ` +
1291
- `(/qc-review và /qc-run-test vẫn bắt được, nhưng chỉ khi có người chạy chúng). ` +
1291
+ `(/qc-review-testcase và /qc-design-script vẫn bắt được, nhưng chỉ khi có người chạy chúng). ` +
1292
1292
  `Truyền --qc {qc_dir} để bật.`);
1293
1293
  return;
1294
1294
  }
package/bin/self-check.js CHANGED
@@ -1096,7 +1096,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1096
1096
  // ── R14 — quyền khẳng định `pass`: chủ cột phải ĐỌC status, và T12 phải tồn tại ─
1097
1097
  //
1098
1098
  // Vì sao cần rule riêng (G55): schema khai cột 21 `status` có `read_by` gồm dev-run-test và
1099
- // 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
1100
1100
  // chúng "trực giao" với nó, tức cố ý KHÔNG dùng. Số lần đọc thật: 0.
1101
1101
  //
1102
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
@@ -1118,10 +1118,15 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1118
1118
  }
1119
1119
 
1120
1120
  for (const g of PAG.guards || []) {
1121
- 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`;
1122
1127
  const abs = path.join(ROOT, rel);
1123
1128
  if (!fs.existsSync(abs)) {
1124
- 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`);
1125
1130
  continue;
1126
1131
  }
1127
1132
  const text = fs.readFileSync(abs, 'utf8');
@@ -1143,6 +1148,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1143
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'
1144
1149
  + ' giữ nguyên giá trị cũ — tức guard không có tác dụng.');
1145
1150
  }
1151
+ }
1146
1152
  // (c) `fail` KHÔNG được nằm trong positive_values. Chặn tin xấu là biến một guard
1147
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.
1148
1154
  if ((g.positive_values || []).includes('fail')) {
@@ -1187,7 +1193,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1187
1193
  //
1188
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
1189
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à
1190
- // 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
1191
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
1192
1198
  // xác nhận contract bằng đúng câu văn đang phủ nhận contract.
1193
1199
  //
@@ -1493,7 +1499,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1493
1499
  }
1494
1500
  }
1495
1501
 
1496
- // ── R18 — lệnh ghi artifact phải KHAI, và "có người nhập tay" thì phải `hard` ──
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` ──
1497
1503
  //
1498
1504
  // Vì sao: `gate.checkpoint_levels` là ALLOWLIST với mặc định `normal` (mức LỎNG NHẤT), và
1499
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
@@ -1501,26 +1507,107 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1501
1507
  //
1502
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ỉ
1503
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.
1504
1537
  {
1505
1538
  const AW = schema.artifact_writers;
1506
1539
  const CL = (schema.gate || {}).checkpoint_levels;
1507
1540
  if (AW && CL) {
1508
- const hardSet = new Set((CL.hard || []).map(e => e.cmd));
1541
+ const hardSet = new Set((CL.hard || []).filter(e => e.cmd).map(e => e.cmd));
1509
1542
  const enrolled = AW.enrolled || {};
1510
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);
1511
1553
 
1512
1554
  for (const cmd of Object.keys(enrolled)) {
1513
1555
  const e = enrolled[cmd];
1514
1556
  const rel = `commands/${cmd}.tmpl`;
1557
+ const abs = path.join(ROOT, rel);
1515
1558
 
1516
- if (!fs.existsSync(path.join(ROOT, rel))) {
1559
+ if (!fs.existsSync(abs)) {
1517
1560
  err('R18', `\`artifact_writers.enrolled\` khai \`${cmd}\` — ${rel} không tồn tại`,
1518
1561
  'Lệnh bị đổi tên/bỏ? Khai một lệnh không có nghĩa là registry đang phủ nó.');
1519
1562
  continue;
1520
1563
  }
1564
+ const tmpl = fs.readFileSync(abs, 'utf8');
1565
+
1521
1566
  if (!Array.isArray(e.writes) || !e.writes.length) {
1522
1567
  err('R18', `\`artifact_writers.enrolled.${cmd}\` thiếu \`writes\``,
1523
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
+ });
1524
1611
  }
1525
1612
  if (typeof e.has_human_content !== 'boolean') {
1526
1613
  err('R18', `\`artifact_writers.enrolled.${cmd}\` thiếu \`has_human_content\` (boolean)`,
@@ -1533,17 +1620,70 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1533
1620
  'Bắt buộc CẢ KHI `has_human_content: false`. Ghi đè an toàn mà không ai khai là an\n'
1534
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).');
1535
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 ──
1536
1641
  if (e.has_human_content === true && !hardSet.has(cmd)) {
1537
- err('R18', `\`${cmd}\` ghi artifact phần người nhập tay nhưng KHÔNG \`checkpoint_levels.hard\``,
1538
- `Khai: "${String(e.why || '').slice(0, 110)}…"\n`
1539
- + ` Ghi đè thứ người nhập tay chỉmức \`normal\` thì \`--yes\` xoá nó TRONG IM LẶNG.\n`
1540
- + ` Sửa: thêm \`${cmd}\` vào gate.checkpoint_levels.hard + viết §luật chạy lại trong lệnh.`);
1642
+ // `has_human_content` 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` một tiền đề SAI. Gặp thật/fix-bug (N3): 4 artifact, chỉ `{lessons_path}`
1645
+ // phần người nhập tay, 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).');
1541
1680
  }
1542
1681
  }
1543
1682
 
1544
1683
  // Chiều ngược: lệnh đã ở `hard` VÌ LÝ DO GHI ĐÈ phải có mặt trong registry.
1545
1684
  for (const h of CL.hard || []) {
1546
- if (!/ghi đè/i.test(String(h.why || ''))) continue; // `hard` vì lý do khác (vd --resume)
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)
1547
1687
  if (!enrolled[h.cmd] && !pending.includes(h.cmd)) {
1548
1688
  err('R18', `\`${h.cmd}\` ở \`checkpoint_levels.hard\` vì ghi đè, nhưng không có trong \`artifact_writers\``,
1549
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'
@@ -1553,6 +1693,295 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1553
1693
  }
1554
1694
  }
1555
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
+
1556
1985
  // ── report ────────────────────────────────────────────────────────────────────
1557
1986
  const tagCount = schema.tags.length;
1558
1987
  const colCount = schema.tsv_columns.length;
@@ -1576,6 +2005,13 @@ if (!errors.length && !warns.length) {
1576
2005
  // một key `$` kể từ khi thêm `$lint_binding_comment`, nên hằng số cũ sẽ đếm lố.
1577
2006
  const vocabCount = Object.keys(schema.vocabularies).filter(k => !k.startsWith('$')).length;
1578
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
+ }
1579
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
1580
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.
1581
2017
  let lintRules = '?';