@educa-corp/sdd-framework 0.9.6 → 0.9.8

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 (147) hide show
  1. package/bin/lint-trace.js +4 -4
  2. package/bin/qc-base-map.json +13 -11
  3. package/bin/self-check.js +497 -16
  4. package/bin/trace-schema.json +3226 -2656
  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 +9 -3
  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 +43 -4
  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 +41 -1
  21. package/core/commands/learn.md +7 -1
  22. package/core/commands/map-testids.md +11 -5
  23. package/core/commands/propose-scenario.md +7 -1
  24. package/core/commands/qc-analyze.md +12 -6
  25. package/core/commands/qc-automation-assess.md +356 -0
  26. package/core/commands/qc-design-script.md +430 -0
  27. package/core/commands/qc-design-test.md +98 -20
  28. package/core/commands/qc-plan.md +9 -3
  29. package/core/commands/qc-report.md +92 -77
  30. package/core/commands/qc-review-script.md +342 -0
  31. package/core/commands/{qc-review.md → qc-review-testcase.md} +86 -54
  32. package/core/commands/qc-run-manualtest.md +401 -0
  33. package/core/commands/qc-run-script.md +421 -0
  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 +10 -4
  41. package/core/modules/qc-playwright-ts/module.yaml +13 -0
  42. package/core/modules/qc-playwright-ts/stack-profile.yaml +99 -0
  43. package/core/modules/qc-wdio-appium/module.yaml +20 -0
  44. package/core/modules/qc-wdio-appium/stack-profile.yaml +107 -0
  45. package/core/rules/workflow.md +2 -2
  46. package/core/skills/qc/_shared/self-review-principles.md +2 -2
  47. package/core/skills/qc/qa-analyst/DOC_GAP.template.md +1 -1
  48. package/core/skills/qc/qa-analyst/data-flow.md +1 -1
  49. package/core/skills/qc/qa-analyst/spec-issue-reporter.md +1 -1
  50. package/core/skills/qc/qa-automation-assess/matrix.md +123 -0
  51. package/core/skills/qc/qa-designer/e2e/journey.md +1 -1
  52. package/core/skills/qc/qa-designer/exploratory/explore-to-functional.md +1 -1
  53. package/core/skills/qc/{qa-runner → qa-designer}/exploratory/session.md +8 -2
  54. package/core/skills/qc/qa-designer/functional/api.md +2 -2
  55. package/core/skills/qc/qa-designer/functional/gui-feature.md +1 -1
  56. package/core/skills/qc/qa-designer/functional/gui-screen.md +1 -1
  57. package/core/skills/qc/qa-designer/functional/job.md +128 -0
  58. package/core/skills/qc/qa-designer/integration/api.md +2 -2
  59. package/core/skills/qc/qa-designer/integration/db.md +2 -2
  60. package/core/skills/qc/qa-designer/integration/gui.md +1 -1
  61. package/core/skills/qc/qa-designer/integration/{kafka.md → queue.md} +21 -5
  62. package/core/skills/qc/qa-designer/non-functional.md +1 -1
  63. package/core/skills/qc/qa-designer/shared/skill-decision-tree.md +17 -0
  64. package/core/skills/qc/qa-designer/shared/tc-metadata-format.md +28 -6
  65. package/core/skills/qc/qa-reviewer/script/_shared/review-rules.md +121 -0
  66. package/core/skills/qc/qa-reviewer/script/api/auth.md +49 -0
  67. package/core/skills/qc/qa-reviewer/script/api/endpoint.md +89 -0
  68. package/core/skills/qc/qa-reviewer/script/api/security.md +46 -0
  69. package/core/skills/qc/qa-reviewer/script/exploratory.md +3 -3
  70. package/core/skills/qc/qa-reviewer/script/mobile/e2e.md +41 -0
  71. package/core/skills/qc/qa-reviewer/script/mobile/functional.md +90 -0
  72. package/core/skills/qc/qa-reviewer/script/mobile/integration.md +41 -0
  73. package/core/skills/qc/qa-reviewer/script/mobile/non-functional.md +43 -0
  74. package/core/skills/qc/qa-reviewer/script/web/e2e.md +46 -0
  75. package/core/skills/qc/qa-reviewer/script/web/functional.md +111 -0
  76. package/core/skills/qc/qa-reviewer/script/web/integration.md +46 -0
  77. package/core/skills/qc/qa-reviewer/script/web/non-functional.md +49 -0
  78. package/core/skills/qc/qa-reviewer/shared/read-doc-gap-inputs.md +1 -1
  79. package/core/skills/qc/qa-reviewer/shared/review-file-template.md +29 -10
  80. package/core/skills/qc/qa-reviewer/test-case/e2e.md +2 -2
  81. package/core/skills/qc/qa-reviewer/test-case/exploratory.md +1 -1
  82. package/core/skills/qc/qa-reviewer/test-case/functional.md +2 -2
  83. package/core/skills/qc/qa-reviewer/test-case/integration.md +2 -2
  84. package/core/skills/qc/qa-reviewer/test-case/non-functional.md +2 -2
  85. package/core/skills/qc/qa-script-designer/_shared/api-conventions.md +94 -0
  86. package/core/skills/qc/qa-script-designer/_shared/file-naming-and-folders.md +109 -0
  87. package/core/skills/qc/qa-script-designer/_shared/mobile-conventions.md +196 -0
  88. package/core/skills/qc/qa-script-designer/_shared/web-conventions.md +257 -0
  89. package/core/skills/qc/qa-script-designer/api/auth.md +43 -0
  90. package/core/skills/qc/qa-script-designer/api/endpoint.md +61 -0
  91. package/core/skills/qc/qa-script-designer/api/security.md +41 -0
  92. package/core/skills/qc/qa-script-designer/mobile/e2e.md +35 -0
  93. package/core/skills/qc/qa-script-designer/mobile/functional/feature.md +32 -0
  94. package/core/skills/qc/qa-script-designer/mobile/functional/screen.md +42 -0
  95. package/core/skills/qc/qa-script-designer/mobile/integration.md +39 -0
  96. package/core/skills/qc/qa-script-designer/mobile/non-functional.md +39 -0
  97. package/core/skills/qc/qa-script-designer/web/e2e.md +36 -0
  98. package/core/skills/qc/qa-script-designer/web/functional/api.md +39 -0
  99. package/core/skills/qc/qa-script-designer/web/functional/gui-feature.md +34 -0
  100. package/core/skills/qc/qa-script-designer/web/functional/gui-screen.md +42 -0
  101. package/core/skills/qc/qa-script-designer/web/integration.md +43 -0
  102. package/core/skills/qc/qa-script-designer/web/non-functional.md +42 -0
  103. package/core/skills/qc/qa-script-runner/mobile/run.md +38 -0
  104. package/core/skills/qc/qa-script-runner/report.md +41 -0
  105. package/core/skills/qc/qa-script-runner/web/run.md +48 -0
  106. package/core/steps/context-loader.md +1 -1
  107. package/core/steps/gate.md +7 -1
  108. package/core/steps/qc-scope.md +45 -2
  109. package/core/steps/qc-stamp.md +4 -4
  110. package/core/steps/report-footer.md +10 -9
  111. package/docs/02-concepts/pipeline-steps/07-dev-selftest.md +1 -1
  112. package/docs/02-concepts/pipeline-steps/08-qc-automation.md +13 -12
  113. package/docs/02-concepts/pipeline-steps/10-feedback-loop.md +3 -3
  114. package/docs/02-concepts/traceability.md +1 -1
  115. package/docs/03-guides/developer.md +1 -1
  116. package/docs/03-guides/tester-qa.md +40 -11
  117. package/docs/04-reference/commands.md +4 -2
  118. package/docs/04-reference/modules.md +2 -1
  119. package/docs/04-reference/trace-schema.md +4 -4
  120. package/docs/explain/17-qc-design-test.md +5 -5
  121. package/docs/explain/18-qc-review.md +42 -20
  122. package/docs/explain/19-qc-run-test.md +13 -10
  123. package/docs/explain/20-qc-report.md +3 -3
  124. package/docs/explain/23-fix-bug.md +2 -2
  125. package/docs/explain/README.md +2 -2
  126. package/docs/plans/qc-surgery/01-checklist.md +86 -21
  127. package/docs/plans/qc-surgery/PLAN_v2.md +295 -0
  128. package/docs/plans/qc-surgery/exec-S-ap-stack-typescript.md +420 -0
  129. package/docs/plans/qc-surgery/exec-S0-guard-cam-stack-cu.md +400 -0
  130. package/docs/plans/qc-surgery/exec-S1-hai-module-thay-qc-playwright.md +267 -0
  131. package/docs/plans/qc-surgery/exec-S2-qa-runner-thanh-script-designer-runner.md +340 -0
  132. package/docs/plans/qc-surgery/exec-S3-viet-lai-tieu-chi-review-script.md +322 -0
  133. package/docs/plans/qc-surgery/exec-S5-an-theo-don-dau-vet-stack-cu.md +292 -0
  134. package/package.json +1 -1
  135. package/core/commands/qc-run-test.md +0 -561
  136. package/core/modules/qc-playwright/stack-profile.yaml +0 -66
  137. package/core/skills/qc/qa-reviewer/script/e2e.md +0 -95
  138. package/core/skills/qc/qa-reviewer/script/functional.md +0 -109
  139. package/core/skills/qc/qa-reviewer/script/integration.md +0 -99
  140. package/core/skills/qc/qa-reviewer/script/non-functional.md +0 -134
  141. package/core/skills/qc/qa-runner/e2e.md +0 -49
  142. package/core/skills/qc/qa-runner/functional/api.md +0 -35
  143. package/core/skills/qc/qa-runner/functional/gui-feature.md +0 -57
  144. package/core/skills/qc/qa-runner/functional/gui-screen.md +0 -61
  145. package/core/skills/qc/qa-runner/integration.md +0 -47
  146. package/core/skills/qc/qa-runner/non-functional.md +0 -49
  147. package/core/skills/qc/qa-runner/report/report.md +0 -37
package/bin/self-check.js CHANGED
@@ -103,8 +103,26 @@ function allSourceFiles() {
103
103
  push(path.join(ROOT, 'templates'), () => true);
104
104
  push(path.join(ROOT, 'rules'), f => f.endsWith('.md'));
105
105
  push(path.join(ROOT, 'modules'), f => f.endsWith('.yaml') || f.endsWith('.md'));
106
+ // skills/ vào scan set từ Bước S · S0 (2026-09-17). Trước đó R4/R5/R6 mù với toàn bộ
107
+ // skills/ — và 21 trong 27 file mang stack QC cũ nằm đúng ở đó, nên R5 không canh được
108
+ // việc một stack đã bỏ quay trở lại. Đo trước khi mở: 11 pattern đang có × 64 file
109
+ // skills/**/*.md = 0 hit, tức mở scan set không làm đỏ thứ gì đang xanh.
110
+ push(path.join(ROOT, 'skills'), f => f.endsWith('.md'));
106
111
  return out;
107
112
  }
113
+
114
+ /**
115
+ * Glob tối giản cho `forbidden_patterns[].scope`. Hỗ trợ `*` (không qua `/`) và `**`
116
+ * (qua mọi cấp) — đủ cho ba dạng đang dùng và cố ý KHÔNG hơn: một glob engine đầy đủ ở
117
+ * đây là thứ không ai đọc lại, và scope sai thì rule im lặng chứ không kêu lên.
118
+ */
119
+ function globMatch(glob, rel) {
120
+ const re = glob.split('/').map(seg =>
121
+ seg === '**' ? '.*'
122
+ : seg.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*')
123
+ ).join('/').replace(/\/\.\*$/, '(?:/.*)?');
124
+ return new RegExp(`^${re}$`).test(rel);
125
+ }
108
126
  const sources = allSourceFiles().map(p => ({
109
127
  rel: path.relative(ROOT, p).replace(/\\/g, '/'),
110
128
  text: fs.readFileSync(p, 'utf8'),
@@ -281,16 +299,43 @@ for (const fp of schema.forbidden_patterns) {
281
299
  // `except` = file được phép chứa pattern vì nó đang TRÍCH DẪN pattern trong một cảnh
282
300
  // báo ("đừng glob X"). Mỗi entry phải kèm lý do trong schema — không phải ignore mù.
283
301
  const except = new Set(Object.keys(fp.except || {}));
284
- const hits = sources.filter(s => s.text.includes(fp.pattern) && !except.has(s.rel));
302
+ // `scope` (thêm ở Bước S · S0) = mảng glob giới hạn vùng file rule soi tới. KHÔNG khai
303
+ // scope = toàn scan set, nên mọi entry có từ trước giữ nguyên hành vi. Vì sao cần: `pytest`
304
+ // bị cấm trong phạm vi QC nhưng HỢP LỆ ở lane DEV — modules/context-engineering là module
305
+ // Python thật, dev-gen-test mặc định sinh test Python. Cấm toàn cục là 4 dương tính giả,
306
+ // và dương tính giả đẻ ra except bừa cho tới khi R5 rỗng ruột mà vẫn xanh.
307
+ const inScope = s => !fp.scope || fp.scope.some(g => globMatch(g, s.rel));
308
+ const hits = sources.filter(s => inScope(s) && s.text.includes(fp.pattern) && !except.has(s.rel));
285
309
  if (hits.length) {
286
310
  err('R5', `Pattern bị cấm quay lại: \`${fp.pattern}\` (${fp.gap})`,
287
311
  `${fp.reason}\n Thấy ở: ${hits.map(h => h.rel).join(', ')}`);
288
312
  }
289
- // Cảnh báo nếu một except trở nên vô nghĩa — cảnh báo đã bị xoá thì except cũng nên bỏ
313
+ // Cảnh báo nếu một except trở nên vô nghĩa — cảnh báo đã bị xoá thì except cũng nên bỏ.
314
+ // Nằm ngoài `scope` cũng là vô nghĩa: file đó không bao giờ bị soi, giữ except chỉ làm
315
+ // người đọc tưởng nó đang che một thứ có thật.
290
316
  for (const f of except) {
291
317
  const src = sources.find(s => s.rel === f);
292
- if (!src || !src.text.includes(fp.pattern)) {
293
- warn('R5', `\`${fp.pattern}\`: except \`${f}\` không còn chứa pattern — bỏ except này khỏi schema`);
318
+ if (!src || !src.text.includes(fp.pattern) || !inScope(src)) {
319
+ warn('R5', `\`${fp.pattern}\`: except \`${f}\` không còn chứa pattern (hoặc ngoài scope) — bỏ except này khỏi schema`);
320
+ }
321
+ }
322
+ }
323
+
324
+ // Một `scope` glob khớp 0 file thì rule **trông như** đang canh một vùng, thực ra canh không
325
+ // khí — đúng kiểu rỗng ruột mà cảnh báo `except` chết ở trên sinh ra để chống, chỉ ở đầu kia.
326
+ // Đã suýt xảy ra thật ở S0: scope trỏ `qa-runner/**` (thư mục S2 sắp XOÁ) và không trỏ
327
+ // `qa-script-designer/**` (thư mục S2 sắp TẠO) → sau S2 đèn xanh vì RỖNG, không vì SẠCH.
328
+ // Chỉ WARN, không ERROR: trỏ vào thư mục chưa tạo là hợp lệ trong lúc đang chuyển stack.
329
+ // Gộp theo glob, không theo entry — 6 entry dùng chung một scope thì cảnh báo một lần.
330
+ {
331
+ const seen = new Set();
332
+ for (const fp of schema.forbidden_patterns) {
333
+ for (const g of (fp.scope || [])) {
334
+ if (seen.has(g)) continue;
335
+ seen.add(g);
336
+ if (!sources.some(s => globMatch(g, s.rel))) {
337
+ warn('R5', `scope \`${g}\` khớp 0 file — thư mục chưa tồn tại, hoặc đã bị xoá mà glob còn ở lại`);
338
+ }
294
339
  }
295
340
  }
296
341
  }
@@ -1096,7 +1141,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1096
1141
  // ── R14 — quyền khẳng định `pass`: chủ cột phải ĐỌC status, và T12 phải tồn tại ─
1097
1142
  //
1098
1143
  // 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
1144
+ // 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
1145
  // chúng "trực giao" với nó, tức cố ý KHÔNG dùng. Số lần đọc thật: 0.
1101
1146
  //
1102
1147
  // Đâ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 +1163,15 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1118
1163
  }
1119
1164
 
1120
1165
  for (const g of PAG.guards || []) {
1121
- const rel = `commands/${g.owner}.tmpl`;
1166
+ // `owner` CHUỖI khi cột có một chủ, MẢNG khi có nhiều (F2 · Đợt 2 · b2: cột
1167
+ // `qc_status` có HAI chủ — /qc-run-script và /qc-run-manualtest). Chuẩn hoá về mảng
1168
+ // thay vì khai hai entry guard cho cùng một cột: hai entry làm `blocked_when_status`
1169
+ // 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).
1170
+ for (const owner of (Array.isArray(g.owner) ? g.owner : [g.owner])) {
1171
+ const rel = `commands/${owner}.tmpl`;
1122
1172
  const abs = path.join(ROOT, rel);
1123
1173
  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`);
1174
+ err('R14', `\`positive_assertion_guards\` khai chủ cột \`${g.column}\` là \`${owner}\` — ${rel} không tồn tại`);
1125
1175
  continue;
1126
1176
  }
1127
1177
  const text = fs.readFileSync(abs, 'utf8');
@@ -1143,6 +1193,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1143
1193
  '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
1194
  + ' giữ nguyên giá trị cũ — tức guard không có tác dụng.');
1145
1195
  }
1196
+ }
1146
1197
  // (c) `fail` KHÔNG được nằm trong positive_values. Chặn tin xấu là biến một guard
1147
1198
  // 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
1199
  if ((g.positive_values || []).includes('fail')) {
@@ -1187,7 +1238,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1187
1238
  //
1188
1239
  // 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
1240
  // 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
1241
+ // qc-run-script; R3 xác nhận ✅ vì cả hai file CÓ nhắc `status` — trong đúng câu khai rằng
1191
1242
  // 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
1243
  // xác nhận contract bằng đúng câu văn đang phủ nhận contract.
1193
1244
  //
@@ -1493,7 +1544,7 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1493
1544
  }
1494
1545
  }
1495
1546
 
1496
- // ── R18 — lệnh ghi artifact phải KHAI, và "có người nhập tay" thì phải `hard` ──
1547
+ // ── 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
1548
  //
1498
1549
  // Vì sao: `gate.checkpoint_levels` là ALLOWLIST với mặc định `normal` (mức LỎNG NHẤT), và
1499
1550
  // 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 +1552,107 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1501
1552
  //
1502
1553
  // 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
1554
  // thấy được cái ĐÃ KHAI, mà cái CHƯA KHAI mới là chỗ lỗi sống.
1555
+ //
1556
+ // HAI LUẬT ÉP `hard`, KHÔNG PHẢI MỘT:
1557
+ // (A) `has_human_content: true` — cấp LỆNH
1558
+ // (B) artifact `mode !== 'append'` + `regenerable: false` + `when: 'always'` — cấp ARTIFACT
1559
+ //
1560
+ // Luật (B) sinh ra vì (A) không phủ được SỔ TÍCH LUỸ: `trace-history.jsonl` KHÔNG ai gõ tay
1561
+ // vào — nên (A) trả lời "false", ĐÚNG CHỮ — trong khi chính /validate-traces gọi nó là
1562
+ // AUTHORITATIVE, không regenerate được. Đè lên là mất vĩnh viễn.
1563
+ //
1564
+ // VÌ SAO `mode !== 'append'` CHỨ KHÔNG PHẢI `mode === 'overwrite'`:
1565
+ // `merge` KHÔNG an toàn. /amend-prd là merge, và nó tự khai là "thao tác ghi DUY NHẤT trong
1566
+ // framework được phép làm output KHÔNG phải superset của bản cũ". Chỉ `append` an toàn vô
1567
+ // đ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.
1568
+ //
1569
+ // VÌ SAO (B) THÊM ĐIỀU KIỆN `when === 'always'` (D4):
1570
+ // 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
1571
+ // đều có ghi (/review-context · /review-tech-docs · /validate-traces · /review-code · /debug).
1572
+ // 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 đó
1573
+ // đã 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:
1574
+ // /validate-traces chạy hàng ngày ở chế độ đọc, và dựng cổng chặn lên nó vì nhánh
1575
+ // `--reconcile-code` sẽ dạy người ta thêm `--yes` vào script — rồi `--yes` đó nằm lại vĩnh
1576
+ // viễn, kể cả cho ngày họ chạy `--reconcile-code`.
1577
+ //
1578
+ // ĐỔ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
1579
+ // lệnh lười khai. `when` là tên cờ ⇒ tmpl PHẢI nhắc đúng cờ đó; `when: 'confirm'` ⇒ tmpl
1580
+ // 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
1581
+ // trong khi không ai áp — đúng bài học R17.
1504
1582
  {
1505
1583
  const AW = schema.artifact_writers;
1506
1584
  const CL = (schema.gate || {}).checkpoint_levels;
1507
1585
  if (AW && CL) {
1508
- const hardSet = new Set((CL.hard || []).map(e => e.cmd));
1586
+ const hardSet = new Set((CL.hard || []).filter(e => e.cmd).map(e => e.cmd));
1509
1587
  const enrolled = AW.enrolled || {};
1510
1588
  const pending = (AW.pending_enrollment || {}).cmds || [];
1589
+ // Từ vựng đọc TỪ SCHEMA, không hard-code — thêm giá trị mới mà quên dạy checker là đúng
1590
+ // kiểu lỗi R18 sinh ra để bắt.
1591
+ const MODES = Object.keys(AW.write_modes || {}).filter(k => k !== '$comment');
1592
+ // Mode được miễn luật (B) — ĐỌC TỪ SCHEMA, không hard-code (xem artifact_writers.safe_modes).
1593
+ const SAFE = new Set(((AW.safe_modes || {}).modes) || ['append']);
1594
+ const ALWAYS = 'always';
1595
+ const CONFIRM = 'confirm';
1596
+ const isFlag = w => /^--[a-z0-9][a-z0-9-]*$/.test(w);
1597
+ const validWhen = w => w === ALWAYS || w === CONFIRM || isFlag(w);
1511
1598
 
1512
1599
  for (const cmd of Object.keys(enrolled)) {
1513
1600
  const e = enrolled[cmd];
1514
1601
  const rel = `commands/${cmd}.tmpl`;
1602
+ const abs = path.join(ROOT, rel);
1515
1603
 
1516
- if (!fs.existsSync(path.join(ROOT, rel))) {
1604
+ if (!fs.existsSync(abs)) {
1517
1605
  err('R18', `\`artifact_writers.enrolled\` khai \`${cmd}\` — ${rel} không tồn tại`,
1518
1606
  'Lệnh bị đổi tên/bỏ? Khai một lệnh không có nghĩa là registry đang phủ nó.');
1519
1607
  continue;
1520
1608
  }
1609
+ const tmpl = fs.readFileSync(abs, 'utf8');
1610
+
1521
1611
  if (!Array.isArray(e.writes) || !e.writes.length) {
1522
1612
  err('R18', `\`artifact_writers.enrolled.${cmd}\` thiếu \`writes\``,
1523
1613
  '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".');
1614
+ } else {
1615
+ e.writes.forEach((w, i) => {
1616
+ const at = `artifact_writers.enrolled.${cmd}.writes[${i}]`;
1617
+ if (typeof w !== 'object' || w === null || Array.isArray(w)) {
1618
+ err('R18', `\`${at}\` phải là object, không phải chuỗi`,
1619
+ 'Khuôn cũ (list chuỗi) không chứa được \`mode\`/\`regenerable\`/\`when\`. Một lệnh đụng\n'
1620
+ + ' 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.');
1621
+ return;
1622
+ }
1623
+ if (!w.path) {
1624
+ err('R18', `\`${at}\` thiếu \`path\``,
1625
+ 'Không có tên thì không đối chiếu ngược được với tmpl.');
1626
+ }
1627
+ if (!MODES.includes(w.mode)) {
1628
+ err('R18', `\`${at}\` có \`mode\` không hợp lệ: ${JSON.stringify(w.mode)}`,
1629
+ `Phải là một trong: ${MODES.join(' · ')} — định nghĩa ở \`artifact_writers.write_modes\`.`);
1630
+ }
1631
+ if (typeof w.regenerable !== 'boolean') {
1632
+ err('R18', `\`${at}\` thiếu \`regenerable\` (boolean)`,
1633
+ 'Câu hỏi mà \`has_human_content\` KHÔNG hỏi được: "đè mất thì dựng lại được không?".\n'
1634
+ + ' Sổ tích luỹ không ai gõ tay vào, nên \`has_human_content\` trả lời false — đúng\n'
1635
+ + ' chữ, và để lọt đúng thứ mà mất là mất vĩnh viễn.');
1636
+ }
1637
+ if (!validWhen(w.when)) {
1638
+ err('R18', `\`${at}\` thiếu/sai \`when\`: ${JSON.stringify(w.when)}`,
1639
+ 'Phải là \`always\`, \`confirm\`, hoặc một tên cờ dạng \`--ten-co\`. Hành vi ghi thuộc\n'
1640
+ + ' về CHẾ ĐỘ, không thuộc về lệnh — năm lệnh đã khai "read-only" trong khi cả năm\n'
1641
+ + ' đều có ghi, chính vì nhãn đặt theo chế độ mặc định rồi không ai sửa.');
1642
+ return;
1643
+ }
1644
+ // Điều kiện phải KIỂM CHỨNG ĐƯỢC, nếu không `when` thành cửa thoát.
1645
+ if (isFlag(w.when) && !tmpl.includes(w.when)) {
1646
+ err('R18', `\`${at}\` khai \`when: "${w.when}"\` nhưng ${rel} KHÔNG nhắc cờ đó`,
1647
+ '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'
1648
+ + ' áp — đúng bài học R17. Và nó miễn luôn artifact này khỏi luật (B).');
1649
+ }
1650
+ if (w.when === CONFIRM && !/\(Y\/N\)/.test(tmpl)) {
1651
+ err('R18', `\`${at}\` khai \`when: "confirm"\` nhưng ${rel} không có câu hỏi \`(Y/N)\``,
1652
+ '`confirm` nghĩa là NGƯỜI đã bấm Y — đó là cái thay cho CHECKPOINT. Không tìm thấy\n'
1653
+ + ' 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).');
1654
+ }
1655
+ });
1524
1656
  }
1525
1657
  if (typeof e.has_human_content !== 'boolean') {
1526
1658
  err('R18', `\`artifact_writers.enrolled.${cmd}\` thiếu \`has_human_content\` (boolean)`,
@@ -1533,17 +1665,70 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1533
1665
  'Bắt buộc CẢ KHI `has_human_content: false`. Ghi đè an toàn mà không ai khai là an\n'
1534
1666
  + ' 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
1667
  }
1668
+
1669
+ // ── Luật (B) — cấp ARTIFACT, chỉ cho thao tác ghi VÔ ĐIỀU KIỆN ──
1670
+ const doomed = (Array.isArray(e.writes) ? e.writes : [])
1671
+ .filter(w => w && typeof w === 'object'
1672
+ && !SAFE.has(w.mode) && w.regenerable === false && w.when === ALWAYS);
1673
+ if (doomed.length && !hardSet.has(cmd)) {
1674
+ const w = doomed[0];
1675
+ 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\``,
1676
+ `Artifact: ${w.path}\n`
1677
+ + ` Khai: "${String(w.note || e.why || '').slice(0, 110)}…"\n`
1678
+ + ' Không ai gõ tay vào nó thì `has_human_content` vẫn là false — đúng chữ. Nhưng\n'
1679
+ + ` \`${w.mode}\` lên thứ không dựng lại được là mất VĨNH VIỄN, và \`--yes\` ở mức\n`
1680
+ + ' `normal` làm việc đó TRONG IM LẶNG.\n'
1681
+ + ` Sửa: thêm \`${cmd}\` vào gate.checkpoint_levels.hard · HOẶC \`mode: "append"\` nếu\n`
1682
+ + ' 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.');
1683
+ }
1684
+
1685
+ // ── Luật (A) — cấp LỆNH ──
1536
1686
  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.`);
1687
+ // `has_human_content` cờ CẤP LỆNH. Khi một lệnh ghi nhiều artifact mà chỉ MỘT trong
1688
+ // 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
1689
+ // `hard` một tiền đề SAI. Gặp thật/fix-bug (N3): 4 artifact, chỉ `{lessons_path}`
1690
+ // phần người nhập tay, nằm sau `(Y/N)`.
1691
+ // Nên artifact ĐƯỢC PHÉP tự khai `human_content: true`; khi có khai, luật (A) chỉ xét
1692
+ // đúng những artifact đó. Không khai thì giữ hành vi cũ (xét mọi artifact) — an toàn hơn.
1693
+ // Miễn cho mode AN TOÀN, y như luật (B). Gặp thật ở /qc-run-manualtest (Đợt 2 · b2):
1694
+ // MANUAL_EVIDENCE.md CÓ phần người nhập tay và ghi ở MỌI lần chạy — nhưng `append`,
1695
+ // 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
1696
+ // mất gì, tức đúng loại ma sát vô ích mà `unconditional_hard_budget` sinh ra để chống.
1697
+ const flagged = (e.writes || []).filter(w => w && w.human_content === true);
1698
+ const scope = (flagged.length ? flagged : (e.writes || [])).filter(w => w && !SAFE.has(w.mode));
1699
+ const conditional = scope.every(w => w && w.when && w.when !== ALWAYS);
1700
+ if (!conditional) {
1701
+ err('R18', `\`${cmd}\` ghi artifact CÓ phần người nhập tay nhưng KHÔNG ở \`checkpoint_levels.hard\``,
1702
+ `Khai: "${String(e.why || '').slice(0, 110)}…"\n`
1703
+ + ` Ghi đè thứ người nhập tay mà chỉ ở mức \`normal\` thì \`--yes\` xoá nó TRONG IM LẶNG.\n`
1704
+ + ` Sửa: thêm \`${cmd}\` vào gate.checkpoint_levels.hard + viết §luật chạy lại trong lệnh.`);
1705
+ }
1706
+ }
1707
+ }
1708
+
1709
+ // `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
1710
+ // 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.
1711
+ const NW = AW.non_writers || {};
1712
+ for (const c of NW.cmds || []) {
1713
+ if (!fs.existsSync(path.join(ROOT, `commands/${c}.tmpl`))) {
1714
+ err('R18', `\`non_writers\` khai \`${c}\` — commands/${c}.tmpl không tồn tại`,
1715
+ '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ế.');
1716
+ }
1717
+ if (enrolled[c]) {
1718
+ err('R18', `\`${c}\` ở CẢ \`non_writers\` LẪN \`enrolled\``,
1719
+ '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.');
1720
+ }
1721
+ if (!(NW.why || {})[c]) {
1722
+ err('R18', `\`non_writers\` khai \`${c}\` nhưng thiếu lý do trong \`why\``,
1723
+ '"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'
1724
+ + ' cùng lập luận đã bắt `why` phải khai cả khi `has_human_content: false` (G79).');
1541
1725
  }
1542
1726
  }
1543
1727
 
1544
1728
  // Chiều ngược: lệnh đã ở `hard` VÌ LÝ DO GHI ĐÈ phải có mặt trong registry.
1545
1729
  for (const h of CL.hard || []) {
1546
- if (!/ghi đè/i.test(String(h.why || ''))) continue; // `hard` vì lý do khác (vd --resume)
1730
+ if (!h.cmd) continue; // mục $comment
1731
+ if (!/ghi đè/i.test(String(h.why || ''))) continue; // `hard` vì lý do khác (vd --resume)
1547
1732
  if (!enrolled[h.cmd] && !pending.includes(h.cmd)) {
1548
1733
  err('R18', `\`${h.cmd}\` ở \`checkpoint_levels.hard\` vì ghi đè, nhưng không có trong \`artifact_writers\``,
1549
1734
  '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 +1738,295 @@ for (const [vocab, def] of Object.entries(schema.vocabularies)) {
1553
1738
  }
1554
1739
  }
1555
1740
 
1741
+ // ── R19 — bảng mức gate CHÉP TAY trong steps/ và rules/ phải khớp schema ─────
1742
+ //
1743
+ // 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
1744
+ // một chuỗi "nguồn" mà KHÔNG AI THỰC THI:
1745
+ // rules/workflow.md — tự nhận "đây chỉ là bản tóm tắt, gate là nguồn"
1746
+ // steps/gate.md — tự nhận "Nguồn máy đọc: bin/trace-schema.json"
1747
+ // bin/trace-schema.json — nguồn thật
1748
+ // 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
1749
+ // 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
1750
+ // đó đang canh.
1751
+ //
1752
+ // Đã lệch thật (2026-09-16): /review-context và /review-tech-docs chuyển `none` → `hard`,
1753
+ // schema + commands/*.tmpl + README đều sửa, build XANH — mà hai bảng kia vẫn liệt chúng ở
1754
+ // "Không chặn". R11 canh `commands/*.tmpl` ↔ schema nên người sửa tin là "đã có máy canh
1755
+ // 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.
1756
+ //
1757
+ // 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)
1758
+ // 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
1759
+ // ta chép lại ở chỗ khác — xa hơn, khó canh hơn.
1760
+ //
1761
+ // KHÔNG quét `skills/`: ở đó "read-only" nói về ARTIFACT ĐANG XEM (vd /review-code không
1762
+ // 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ữ.
1763
+ {
1764
+ const CL = (schema.gate || {}).checkpoint_levels;
1765
+ if (CL) {
1766
+ const hardSet = new Set((CL.hard || []).filter(e => e.cmd).map(e => e.cmd));
1767
+ const noneSet = new Set((CL.none || []).filter(e => e.cmd).map(e => e.cmd));
1768
+ const realLevel = c => hardSet.has(c) ? 'hard' : (noneSet.has(c) ? 'none' : 'normal');
1769
+
1770
+ // Từ-mức ↔ khoá schema. `normal` là MẶC ĐỊNH (không liệt kê trong schema — xem R11).
1771
+ const ROW = [
1772
+ [/^\|\s*\*\*Không chặn\*\*/i, 'none'],
1773
+ [/^\|\s*\*\*Chặn thường\*\*/i, 'normal'],
1774
+ [/^\|\s*\*\*Chặn CỨNG\*\*/i, 'hard'],
1775
+ ];
1776
+
1777
+ for (const dir of ['steps', 'rules']) {
1778
+ const abs = path.join(ROOT, dir);
1779
+ if (!fs.existsSync(abs)) continue;
1780
+ for (const f of fs.readdirSync(abs).filter(x => x.endsWith('.md'))) {
1781
+ const rel = `${dir}/${f}`;
1782
+ const lines = fs.readFileSync(path.join(abs, f), 'utf8').split(/\r?\n/);
1783
+ lines.forEach((line, i) => {
1784
+ const hit = ROW.find(([re]) => re.test(line));
1785
+ if (!hit) return; // không phải hàng bảng mức
1786
+ const declared = hit[1];
1787
+ // Lookbehind chặn báo oan: "sinh/sửa artifact" từng bị cắt ra thành lệnh `/s`.
1788
+ const names = [...line.matchAll(/(?<!\p{L})\/([a-z][a-z0-9-]{2,})(?![\p{L}\d-])/gu)].map(m => m[1]);
1789
+ for (const c of new Set(names)) {
1790
+ if (!fs.existsSync(path.join(ROOT, 'commands', `${c}.tmpl`))) {
1791
+ err('R19', `${rel}:${i + 1} nêu \`/${c}\` — commands/${c}.tmpl không tồn tại`,
1792
+ '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'
1793
+ + ' phân biệt được đâu là tên cũ chưa dọn, đâu là tên đang sai.');
1794
+ continue;
1795
+ }
1796
+ const real = realLevel(c);
1797
+ if (real !== declared) {
1798
+ err('R19', `${rel}:${i + 1} xếp \`/${c}\` vào mức \`${declared}\`, schema nói \`${real}\``,
1799
+ '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'
1800
+ + ' 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'
1801
+ + ` Sửa hàng bảng cho khớp \`gate.checkpoint_levels\`, đừng sửa schema cho khớp bảng.`);
1802
+ }
1803
+ }
1804
+ });
1805
+ }
1806
+ }
1807
+
1808
+ // 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ủ
1809
+ // 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í
1810
+ // 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.
1811
+ const shown = new Set();
1812
+ for (const dir of ['steps', 'rules']) {
1813
+ const abs = path.join(ROOT, dir);
1814
+ if (!fs.existsSync(abs)) continue;
1815
+ for (const f of fs.readdirSync(abs).filter(x => x.endsWith('.md'))) {
1816
+ const txt = fs.readFileSync(path.join(abs, f), 'utf8');
1817
+ for (const line of txt.split(/\r?\n/)) {
1818
+ if (!ROW.some(([re]) => re.test(line))) continue;
1819
+ for (const m of line.matchAll(/(?<!\p{L})\/([a-z][a-z0-9-]{2,})(?![\p{L}\d-])/gu)) shown.add(m[1]);
1820
+ }
1821
+ }
1822
+ }
1823
+ const missing = [...noneSet].filter(c => !shown.has(c));
1824
+ if (missing.length) {
1825
+ warn('R19', `mức \`none\` có lệnh không bảng nào nêu làm ví dụ: ${missing.join(', ')}`,
1826
+ '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'
1827
+ + ' nhầm nhất. Một ví dụ trong bảng rẻ hơn một lần copy sai.');
1828
+ }
1829
+ }
1830
+ }
1831
+
1832
+ // ── 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 ──
1833
+ //
1834
+ // 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
1835
+ // 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ế
1836
+ // 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
1837
+ // 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 đó:
1838
+ // "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".
1839
+ //
1840
+ // ĐO 2026-09-16 — 12 lệnh ở `hard`, nhưng chỉ 3 trong đó nổ ở MỌI lần chạy:
1841
+ // 7 lệnh khai "chỉ áp khi file đã tồn tại" → lần chạy đầu đi thẳng
1842
+ // 2 lệnh CÓ điều kiện y hệt nhưng KHÔNG khai (generate-prd · generate-architecture)
1843
+ // 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)
1844
+ // 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.
1845
+ //
1846
+ // 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
1847
+ // nhau — đúng lập luận đã bắt `why` phải khai cả khi `false` (G79).
1848
+ //
1849
+ // Đ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ẽ
1850
+ // chặn mọi lần — hình dạng G41, hai nguồn nói khác nhau.
1851
+ {
1852
+ const CL = (schema.gate || {}).checkpoint_levels;
1853
+ if (CL) {
1854
+ const B = CL.unconditional_hard_budget;
1855
+ // Khuôn câu thu hẹp trong tmpl. Chấp nhận cả `*Mức cứng chỉ áp …*` lẫn
1856
+ // `*Mức cứng **chỉ áp** …*` — hai biến thể đang cùng tồn tại.
1857
+ const NARROW_LINE = /Mức cứng\s*(?:\*\*)?\s*chỉ áp/i;
1858
+ const unconditional = [];
1859
+
1860
+ for (const e of CL.hard || []) {
1861
+ if (!e.cmd) continue;
1862
+ const rel = `commands/${e.cmd}.tmpl`;
1863
+
1864
+ if (!('narrowing' in e)) {
1865
+ err('R20', `\`checkpoint_levels.hard\` mục \`${e.cmd}\` thiếu \`narrowing\``,
1866
+ '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'
1867
+ + ' 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'
1868
+ + ' và người sau sẽ copy chỗ sót đi (G79).');
1869
+ continue;
1870
+ }
1871
+
1872
+ if (e.narrowing === null) {
1873
+ unconditional.push(e.cmd);
1874
+ if (!e.narrowing_why) {
1875
+ err('R20', `\`${e.cmd}\` khai \`narrowing: null\` nhưng thiếu \`narrowing_why\``,
1876
+ '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'
1877
+ + ' 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".');
1878
+ }
1879
+ continue;
1880
+ }
1881
+
1882
+ if (typeof e.narrowing !== 'string' || !e.narrowing.trim()) {
1883
+ err('R20', `\`${e.cmd}\`.\`narrowing\` phải là chuỗi điều kiện hoặc \`null\``,
1884
+ `Thấy: ${JSON.stringify(e.narrowing)}`);
1885
+ continue;
1886
+ }
1887
+
1888
+ const abs = path.join(ROOT, rel);
1889
+ if (fs.existsSync(abs) && !NARROW_LINE.test(fs.readFileSync(abs, 'utf8'))) {
1890
+ err('R20', `\`${e.cmd}\` khai \`narrowing\` trong schema nhưng ${rel} KHÔNG có câu thu hẹp`,
1891
+ `Khai: "${e.narrowing.slice(0, 95)}…"\n`
1892
+ + ' Agent đọc TMPL, không đọc schema. Thiếu câu đó thì nó chặn MỌI lần chạy —\n'
1893
+ + ' đúng thứ điều kiện này sinh ra để tránh. Thêm một dòng ngay dưới nhãn\n'
1894
+ + ' Checkpoint theo khuôn: *Mức cứng chỉ áp khi …*');
1895
+ }
1896
+ }
1897
+
1898
+ // ── Hạn mức — bánh cóc cho số cổng nổ vô điều kiện ──
1899
+ if (!B || typeof B.max !== 'number') {
1900
+ err('R20', '`checkpoint_levels.unconditional_hard_budget.max` chưa khai',
1901
+ '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'
1902
+ + ' 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'
1903
+ + ' hệ quả cộng dồn.');
1904
+ } else if (unconditional.length > B.max) {
1905
+ err('R20', `${unconditional.length} cổng cứng nổ VÔ ĐIỀU KIỆN, vượt hạn mức ${B.max}`,
1906
+ `Đang vượt: ${unconditional.join(', ')}\n`
1907
+ + ' `--yes` KHÔNG bỏ qua được `hard`. Nên khi `hard` quá phiền, người ta không thêm\n'
1908
+ + ' `--yes` — họ GỠ LỆNH KHỎI `hard`, và mất luôn cổng đáng giữ.\n'
1909
+ + ' Sửa: thêm `narrowing` cho một lệnh, HOẶC nâng `max` — nâng là một QUYẾT ĐỊNH,\n'
1910
+ + ' phải kèm lý do trong `why` của khối ngân sách.');
1911
+ } else if (Array.isArray(B.current)) {
1912
+ const a = [...unconditional].sort().join(',');
1913
+ const b = [...B.current].sort().join(',');
1914
+ if (a !== b) {
1915
+ err('R20', '`unconditional_hard_budget.current` lệch với thực tế',
1916
+ `khai: ${b || '(rỗng)'}\n thật: ${a || '(rỗng)'}\n`
1917
+ + ' 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ó.');
1918
+ }
1919
+ }
1920
+
1921
+ if (B && typeof B.max === 'number' && !B.why) {
1922
+ err('R20', '`unconditional_hard_budget` thiếu `why`',
1923
+ 'Một con số không nói được vì sao sẽ bị người sau nâng lên cho tiện.');
1924
+ }
1925
+ }
1926
+ }
1927
+
1928
+ // ── R21 — mọi `checked_by[].cmd` phải trỏ vào một lệnh CÓ THẬT ───────────────
1929
+ //
1930
+ // 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`.
1931
+ // 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ả
1932
+ // tệ hơn một tham chiếu treo thường: contract vẫn khai điều kiện đó "đã được canh", trong khi
1933
+ // 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ó ở đó.
1934
+ //
1935
+ // GẶP THẬT (Đợt 2 · b1 · 2026-09-16): tách /qc-review → /qc-review-testcase + /qc-review-script.
1936
+ // `testid_fourth_leg.checked_by` — CHÂN THỨ TƯ của hợp đồng test-id (G64/G76) — trỏ vào tên cũ.
1937
+ // 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
1938
+ // kế hoạch, chân thứ tư mất người canh và build vẫn XANH.
1939
+ //
1940
+ // Đâ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,
1941
+ // trong khi không ai áp") — R21 là cùng luật đó, áp cho `checked_by` thay vì `declared_by`.
1942
+ //
1943
+ // 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`);
1944
+ // 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.
1945
+ {
1946
+ const seen = [];
1947
+ (function walk(node, at) {
1948
+ if (!node || typeof node !== 'object') return;
1949
+ for (const k of Object.keys(node)) {
1950
+ const v = node[k];
1951
+ if (k === 'checked_by' && Array.isArray(v)) seen.push([at + '.' + k, v]);
1952
+ if (v && typeof v === 'object') walk(v, at + '.' + k);
1953
+ }
1954
+ })(schema, '');
1955
+
1956
+ if (!seen.length) {
1957
+ warn('R21', 'không tìm thấy khối `checked_by` nào',
1958
+ 'Rule này sinh ra để canh chúng. Không còn khối nào thì hoặc contract đã đổi hình,\n'
1959
+ + ' hoặc rule đang quét sai chỗ — cả hai đều đáng nhìn lại.');
1960
+ }
1961
+
1962
+ for (const [at, list] of seen) {
1963
+ list.forEach((e, i) => {
1964
+ if (!e || !e.cmd) {
1965
+ err('R21', `\`${at}[${i}]\` thiếu \`cmd\``,
1966
+ 'Một mục "ai canh" không nói được AI canh thì nó không khai gì cả.');
1967
+ return;
1968
+ }
1969
+ const rel = `commands/${e.cmd}.tmpl`;
1970
+ if (!fs.existsSync(path.join(ROOT, rel))) {
1971
+ err('R21', `\`${at}\` khai \`${e.cmd}\` — ${rel} không tồn tại`,
1972
+ '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'
1973
+ + ' 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'
1974
+ + ' Sửa: trỏ sang lệnh THỪA HƯỞNG vai đó, đừng chỉ xoá mục.');
1975
+ }
1976
+ });
1977
+ }
1978
+ }
1979
+
1980
+ // ── R22 — mọi artifact được khai phải có ÍT NHẤT MỘT lệnh TẠO ra nó ──────────
1981
+ //
1982
+ // 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
1983
+ // 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.
1984
+ // 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
1985
+ // trạm phía sau đứng chờ một file không bao giờ xuất hiện. Build vẫn xanh suốt.
1986
+ //
1987
+ // GẶP THẬT, HAI CA (Đợt 2 · b3 · 2026-09-16):
1988
+ // · AUTOMATION_ASSESSMENT.md — /qc-design-script khai ghi CỘT `Script file` của nó (merge),
1989
+ // và /qc-run-manualtest đọc cột `Automatable` từ nó. Không lệnh nào TẠO. Ba lệnh viết ở b2
1990
+ // cùng trỏ vào một file không ai sinh, và không rule nào kêu.
1991
+ // · _seams.tsv — /generate-code tự nói "tạo file + header nếu chưa có", nhưng KHÔNG khai
1992
+ // artifact đó; chỉ /validate-traces khai (merge, "nếu tồn tại"). Sổ này rơi hẳn khỏi registry.
1993
+ //
1994
+ // `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
1995
+ // vừa merge-khi-có (file findings của /refine-prd · /review-context · /review-tech-docs). Ép
1996
+ // chúng khai `overwrite` là nói dối — chế độ delta của chúng GIỮ `status` của reviewer. Nên
1997
+ // mode vẫn `merge`, và `creates: true` nói thêm "và tôi sinh nó ra nếu chưa có".
1998
+ {
1999
+ const AW = schema.artifact_writers;
2000
+ if (AW && AW.enrolled) {
2001
+ // Khoá nhận dạng artifact: tên file trong `path`. Cùng tên = cùng artifact, bất kể
2002
+ // placeholder thư mục khác nhau ({qc_artifact_dir} vs {refinement_dir}).
2003
+ const KEY = /([A-Z][A-Z0-9_]{3,}(?:_?<[A-Z]+>)?\.[A-Za-z.]+|[a-z][a-z-]*\.(?:tsv|jsonl|json|yaml|yml))/;
2004
+ const seen = new Map(); // tên file → [{cmd, mode, creates}]
2005
+
2006
+ for (const [cmd, e] of Object.entries(AW.enrolled)) {
2007
+ for (const w of e.writes || []) {
2008
+ if (!w || typeof w !== 'object') continue;
2009
+ const m = KEY.exec(String(w.path || ''));
2010
+ if (!m) continue; // artifact mô tả bằng lời (vd "Page Object") — không khoá được
2011
+ if (!seen.has(m[1])) seen.set(m[1], []);
2012
+ seen.get(m[1]).push({ cmd, mode: w.mode, creates: w.creates === true });
2013
+ }
2014
+ }
2015
+
2016
+ const CREATING = new Set(['overwrite', 'create', 'append']);
2017
+ for (const [file, writers] of seen) {
2018
+ const creators = writers.filter(w => CREATING.has(w.mode) || w.creates);
2019
+ if (creators.length) continue;
2020
+ err('R22', `\`${file}\` không lệnh nào TẠO — mọi lệnh ghi nó đều \`merge\``,
2021
+ `Đang ghi: ${writers.map(w => w.cmd + ':' + w.mode).join(' · ')}\n`
2022
+ + ' `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'
2023
+ + ' 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'
2024
+ + ' Sửa: khai lệnh TẠO nó (`overwrite`/`create`/`append`), HOẶC thêm `creates: true`\n'
2025
+ + ' vào entry của lệnh vừa-tạo-khi-vắng vừa-merge-khi-có.');
2026
+ }
2027
+ }
2028
+ }
2029
+
1556
2030
  // ── report ────────────────────────────────────────────────────────────────────
1557
2031
  const tagCount = schema.tags.length;
1558
2032
  const colCount = schema.tsv_columns.length;
@@ -1576,6 +2050,13 @@ if (!errors.length && !warns.length) {
1576
2050
  // một key `$` kể từ khi thêm `$lint_binding_comment`, nên hằng số cũ sẽ đếm lố.
1577
2051
  const vocabCount = Object.keys(schema.vocabularies).filter(k => !k.startsWith('$')).length;
1578
2052
  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`);
2053
+ {
2054
+ const _CL = (schema.gate || {}).checkpoint_levels || {};
2055
+ const _hard = (_CL.hard || []).filter(e => e.cmd);
2056
+ const _u = _hard.filter(e => e.narrowing === null);
2057
+ const _max = ((_CL.unconditional_hard_budget || {}).max);
2058
+ 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`);
2059
+ }
1579
2060
  // ĐẾ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
2061
  // 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
2062
  let lintRules = '?';