agent-quality-kit 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +61 -2
  2. package/README.ru.md +61 -2
  3. package/kit/docs/ai/agent-harness-playbook.md +1 -1
  4. package/kit/docs/ready-made-rules.md +29 -4
  5. package/kit/gates/README.md +40 -0
  6. package/kit/gates/_skip.sh +61 -1
  7. package/kit/gates/ci-actually-fails/README.md +12 -0
  8. package/kit/gates/ci-actually-fails/check.sh +26 -3
  9. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +16 -0
  10. package/kit/gates/ci-actually-fails/red/.github/workflows/soft.yml +15 -0
  11. package/kit/gates/ci-not-hijackable/README.md +56 -0
  12. package/kit/gates/ci-not-hijackable/check.sh +73 -0
  13. package/kit/gates/ci-not-hijackable/gate.yml +19 -0
  14. package/kit/gates/ci-not-hijackable/green/.github/workflows/triage.yml +19 -0
  15. package/kit/gates/ci-not-hijackable/red/.github/workflows/triage.yml +18 -0
  16. package/kit/gates/color-from-token/check.sh +19 -3
  17. package/kit/gates/color-from-token/green/Button.tsx +2 -0
  18. package/kit/gates/commit-explains-itself/README.md +13 -3
  19. package/kit/gates/commit-explains-itself/check.sh +8 -4
  20. package/kit/gates/complexity-limit/README.md +5 -0
  21. package/kit/gates/complexity-limit/check.sh +27 -9
  22. package/kit/gates/complexity-limit/green/test_fixtures.py +14 -0
  23. package/kit/gates/deps-are-pinned/README.md +14 -1
  24. package/kit/gates/deps-are-pinned/check.sh +6 -1
  25. package/kit/gates/deps-are-pinned/green/pyproject-with-requirements/pyproject.toml +12 -0
  26. package/kit/gates/deps-are-pinned/green/pyproject-with-requirements/requirements.txt +3 -0
  27. package/kit/gates/deps-are-pinned/red/pyproject-loose/pyproject.toml +12 -0
  28. package/kit/gates/deps-are-pinned/red/pyproject-loose/requirements.txt +3 -0
  29. package/kit/gates/duplicate-code/README.md +11 -2
  30. package/kit/gates/duplicate-code/check.sh +36 -5
  31. package/kit/gates/duplicate-code/gate.yml +8 -0
  32. package/kit/gates/duplicate-code/green/imports_a.go +20 -0
  33. package/kit/gates/duplicate-code/green/imports_b.go +19 -0
  34. package/kit/gates/entry-links-exist/README.md +5 -0
  35. package/kit/gates/entry-links-exist/check.sh +10 -1
  36. package/kit/gates/entry-links-exist/green/AGENTS.md +5 -0
  37. package/kit/gates/file-size-limit/README.md +9 -2
  38. package/kit/gates/file-size-limit/check.sh +14 -2
  39. package/kit/gates/gate-not-weakened/check.sh +13 -1
  40. package/kit/gates/hook-actually-fires/README.md +74 -0
  41. package/kit/gates/hook-actually-fires/check.sh +183 -0
  42. package/kit/gates/hook-actually-fires/gate.yml +15 -0
  43. package/kit/gates/hook-actually-fires/green/.claude/hooks/hooks.json +3 -0
  44. package/kit/gates/hook-actually-fires/green/.claude/settings.json +74 -0
  45. package/kit/gates/hook-actually-fires/green/.claude/settings.local.json +74 -0
  46. package/kit/gates/hook-actually-fires/red/.claude/hooks/hooks.json +4 -0
  47. package/kit/gates/hook-actually-fires/red/.claude/settings.json +53 -0
  48. package/kit/gates/no-phantom-package/README.md +84 -0
  49. package/kit/gates/no-phantom-package/check.sh +161 -0
  50. package/kit/gates/no-phantom-package/gate.yml +20 -0
  51. package/kit/gates/no-phantom-package/green/AGENTS.md +15 -0
  52. package/kit/gates/no-phantom-package/red/AGENTS.md +15 -0
  53. package/kit/gates/no-print-in-prod/README.md +33 -39
  54. package/kit/gates/no-print-in-prod/gate.yml +14 -6
  55. package/kit/gates/personal-config-not-shared/README.md +66 -0
  56. package/kit/gates/personal-config-not-shared/check.sh +103 -0
  57. package/kit/gates/personal-config-not-shared/gate.yml +16 -0
  58. package/kit/gates/personal-config-not-shared/green/.aqk-tracked +9 -0
  59. package/kit/gates/personal-config-not-shared/red/.aqk-tracked +6 -0
  60. package/kit/gates/secrets-not-in-code/check.sh +29 -4
  61. package/kit/gates/secrets-not-in-code/green/testdata/certificate/key.pem +3 -0
  62. package/kit/gates/swallowed-error/README.md +36 -18
  63. package/kit/gates/swallowed-error/gate.yml +13 -3
  64. package/kit/gates/test-has-assertion/check.sh +13 -1
  65. package/kit/gates/test-not-adjusted/README.md +79 -0
  66. package/kit/gates/test-not-adjusted/check.sh +136 -0
  67. package/kit/gates/test-not-adjusted/gate.yml +19 -0
  68. package/kit/gates/test-not-adjusted/green/after/calc.py +6 -0
  69. package/kit/gates/test-not-adjusted/green/after/tests/test_calc.py +9 -0
  70. package/kit/gates/test-not-adjusted/green/before/calc.py +2 -0
  71. package/kit/gates/test-not-adjusted/green/before/tests/test_calc.py +5 -0
  72. package/kit/gates/test-not-adjusted/red/after/calc.py +2 -0
  73. package/kit/gates/test-not-adjusted/red/after/tests/test_calc.py +5 -0
  74. package/kit/gates/test-not-adjusted/red/before/calc.py +2 -0
  75. package/kit/gates/test-not-adjusted/red/before/tests/test_calc.py +7 -0
  76. package/kit/gates/todo-without-task/README.md +6 -0
  77. package/kit/gates/todo-without-task/check.sh +14 -2
  78. package/kit/gates/todo-without-task/green/app.py +1 -0
  79. package/kit/ratchet/ratchet.sh +70 -2
  80. package/kit/rules/general.md +9 -0
  81. package/kit/rules-en/general.md +82 -0
  82. package/kit/rules-en/security.md +33 -0
  83. package/kit/rules-en/testing.md +48 -0
  84. package/llms.txt +22 -1
  85. package/package.json +6 -2
  86. package/tool/commands/badge.mjs +7 -1
  87. package/tool/commands/context.mjs +260 -0
  88. package/tool/commands/doctor.mjs +72 -25
  89. package/tool/commands/gates.mjs +10 -4
  90. package/tool/commands/learn.mjs +159 -0
  91. package/tool/commands/project.mjs +9 -1
  92. package/tool/commands/prove.mjs +67 -0
  93. package/tool/commands/report.mjs +37 -2
  94. package/tool/i18n/en-docs.mjs +154 -0
  95. package/tool/i18n/en.mjs +70 -90
  96. package/tool/i18n/ru-docs.mjs +156 -0
  97. package/tool/i18n/ru.mjs +69 -90
  98. package/tool/i18n/templates-en.mjs +1 -1
  99. package/tool/i18n/templates-ru.mjs +1 -1
  100. package/tool/lib/core.mjs +37 -2
  101. package/tool/lib/evidence.mjs +124 -0
  102. package/tool/lib/manifest.mjs +63 -5
  103. package/tool/lib/prove.mjs +172 -0
  104. package/tool/lib/repo.mjs +31 -2
  105. package/tool/lib/scope.mjs +46 -2
  106. package/tool/lib/templates.mjs +3 -0
  107. package/tool/program.mjs +23 -22
  108. package/tool/selfcheck/gates.sh +66 -0
  109. package/tool/selfcheck/mutation.sh +21 -1
  110. package/tool/selfcheck/smoke.sh +519 -39
  111. package/tool/selfcheck/units-context.mjs +186 -0
  112. package/tool/selfcheck/units-evidence.mjs +83 -0
  113. package/tool/selfcheck/units-learn.mjs +88 -0
  114. package/tool/selfcheck/units-level.mjs +122 -0
  115. package/tool/selfcheck/units.mjs +114 -2
  116. package/kit/gates/no-print-in-prod/check.sh +0 -38
  117. package/kit/gates/no-print-in-prod/green/docs.ts +0 -15
  118. package/kit/gates/no-print-in-prod/green/main.go +0 -8
  119. package/kit/gates/no-print-in-prod/green/main.rs +0 -4
  120. package/kit/gates/no-print-in-prod/red/main.go +0 -8
  121. package/kit/gates/no-print-in-prod/red/main.rs +0 -4
  122. package/kit/gates/swallowed-error/check.sh +0 -54
  123. package/kit/gates/swallowed-error/green/run.js +0 -8
  124. package/kit/gates/swallowed-error/red/run.js +0 -3
  125. /package/kit/gates/commit-explains-itself/green/{COMMIT_MSG → .aqk-commit-msg} +0 -0
  126. /package/kit/gates/commit-explains-itself/red/{COMMIT_MSG → .aqk-commit-msg} +0 -0
@@ -3,9 +3,10 @@
3
3
  import { readFile, mkdir, writeFile } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
- import { scopeOutput, changedFiles } from "../lib/scope.mjs";
6
+ import { scopeOutput, splitAdvice, changedFiles } from "../lib/scope.mjs";
7
7
  import { CWD, PKG_ROOT, TARGET_DIR, SELF, c, exists, die } from "../lib/core.mjs";
8
- import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS } from "../lib/manifest.mjs";
8
+ import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, advisorySet, layoutChecks } from "../lib/manifest.mjs";
9
+ import { proveGates } from "../lib/prove.mjs";
9
10
  import { detectFacts, readCatalog, triggerVerdict, recipeFor } from "../lib/repo.mjs";
10
11
  import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
11
12
  import { L } from "../i18n/index.mjs";
@@ -108,6 +109,7 @@ function sinceRef(argv = process.argv) {
108
109
  function runGates(man, opts = {}) {
109
110
  const gates = declaredGates(man);
110
111
  if (!gates.length) return { failed: 0, ran: 0, results: [] };
112
+ const advisory = advisorySet(man);
111
113
 
112
114
  // Сужение по дифу — договор с человеком, и он должен видеть, ЧТО именно сужено. Пустой диф
113
115
  // называется вслух: иначе «все гейты зелёные» означало бы «сравнили не с тем» и читалось бы
@@ -124,49 +126,83 @@ function runGates(man, opts = {}) {
124
126
  const t0 = Date.now();
125
127
  const r = spawnSync(cmd, { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
126
128
  const secs = (Math.max(0, Date.now() - t0) / 1000).toFixed(1);
129
+ // Вывод гейта запоминается целиком (с потолком, чтобы болтливый инструмент не съел память):
130
+ // по нему считается покрытие дифа — какой файл вообще был назван хоть одной проверкой.
131
+ // Без этого «готово = доказано» остаётся правилом, за которым следит только человек.
132
+ const outAll = `${r.stdout || ""}${r.stderr || ""}`.slice(0, 200000);
127
133
 
128
134
  if (r.error && r.error.code === "ETIMEDOUT") {
129
135
  console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.timeout)}`);
130
136
  failed++;
131
- results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout });
137
+ results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout, out: outAll });
132
138
  continue;
133
139
  }
134
140
  const code = r.status;
135
141
  if (code === 0) {
136
142
  console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${cmd}`)}`);
137
- results.push({ name, cmd, ok: true, secs });
143
+ // Зелёный гейт иногда всё-таки говорит человеку что-то важное: храповик, дошедший до цели,
144
+ // просит убрать обёртку. Вывод успешного гейта не показывался вовсе, и это сообщение
145
+ // уходило в никуда — тот же класс, что обрезанный совет у красного, только тише.
146
+ // Показываем ровно строки с меткой совета: остальной вывод успешной проверки — шум.
147
+ const okAdvice = splitAdvice(`${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean)).advice;
148
+ for (const line of okAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
149
+ results.push({ name, cmd, ok: true, secs, out: outAll });
138
150
  } else {
139
- let out = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
151
+ const raw = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
152
+ // Совет отделяется ДО сужения. Иначе он сам попадает под фильтр по путям: сообщение
153
+ // храповика про вышедший срок называет путь к реестру, реестра в дифе нет, и гейт,
154
+ // обязанный краснеть по сроку, печатался зелёным с пометкой «находки вне дифа».
155
+ // Ровно то, что стандарт запрещает: срок без последствия. Найдено ревью 2026-09-06.
156
+ const parted = splitAdvice(raw);
157
+ let out = parted.findings;
158
+ const alwaysAdvice = parted.advice;
140
159
 
141
160
  // Сужение до дифа. Три исхода, и все три называются вслух.
142
161
  if (scoped) {
143
162
  const s = scopeOutput(out, scoped);
144
- if (!s.scopable) {
163
+ // Гейт, у которого находок нет вовсе, а есть только совет, сузить нечем: его вердикт
164
+ // не про файлы. Признать такой успешным — вернуть ту же тишину другим путём.
165
+ if (!s.scopable || out.length === 0) {
145
166
  // Гейт печатает вердикт без путей — сузить нечем. Признать его успешным значило бы
146
167
  // выдать провал за тишину; остаётся красным, и причина названа.
147
168
  console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.exitCode(code))} ${c.dim(`· ${L.doctor.notScopable}`)}`);
148
169
  failed++;
149
- results.push({ name, cmd, ok: false, secs, code, note: L.doctor.notScopable });
170
+ results.push({ name, cmd, ok: false, secs, code, note: L.doctor.notScopable, out: outAll });
150
171
  continue;
151
172
  }
152
173
  if (s.findings === 0) {
153
174
  // Долг есть, но не в том, что внёс диф. Зелёный — но с числом спрятанного: молчаливое
154
175
  // «всё хорошо» здесь было бы неправдой.
155
176
  console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
156
- results.push({ name, cmd, ok: true, secs, scopedAway: out.length });
177
+ results.push({ name, cmd, ok: true, secs, scopedAway: out.length, out: outAll });
157
178
  continue;
158
179
  }
159
180
  out = s.kept;
160
181
  }
161
182
 
162
183
  failed++;
163
- console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.exitCode(code))} ${c.dim(`· ${secs}s · ${cmd}`)}`);
184
+ // Находки обрезаются, совет никогда. Все записи каталога печатают «почини: …» последней
185
+ // строкой, и при обрезке до трёх строк человек не видел именно её: находка без действия
186
+ // закрывает окно, а не дефект.
187
+ // Совещательный гейт показывает находки и не роняет прогон. Знак другой, чтобы «показано»
188
+ // и «провалено» не читались одинаково; в сводке ниже он назван поимённо.
189
+ const isAdvisory = advisory.has(name);
190
+ if (isAdvisory) failed--;
191
+ const mark = isAdvisory ? c.yellow("!") : c.red("✘");
192
+ const verdict = isAdvisory ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
193
+ console.log(` ${mark} ${name.padEnd(14)} ${verdict} ${c.dim(`· ${secs}s · ${cmd}`)}`);
164
194
  for (const line of out.slice(0, 3)) console.log(c.dim(` ${line.slice(0, 100)}`));
165
195
  if (out.length > 3) console.log(c.dim(` ${L.doctor.moreLines(out.length - 3)}`));
166
- results.push({ name, cmd, ok: false, secs, code });
196
+ // Совет тоже не бесконечен: гейт, зовущий помощник шесть раз, печатает его шесть раз.
197
+ for (const line of alwaysAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
198
+ results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll });
167
199
  }
168
200
  }
169
- return { failed, ran: gates.length, results };
201
+ // Совещательные, которые покраснели, называются вслух ВСЕГДА. Молчание о них — ровно та
202
+ // тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
203
+ const advisoryFailed = results.filter((x) => x.advisory).map((x) => x.name);
204
+ if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
205
+ return { failed, ran: gates.length, results, advisoryFailed };
170
206
  }
171
207
 
172
208
  // Короткий отчёт «что из этого реально брали» — не для человека, а для агента в следующей
@@ -205,13 +241,10 @@ async function cmdDoctor() {
205
241
  // а копия завтра разошлась бы с ними. Без этого различия `doctor` краснел на собственном
206
242
  // репозитории и требовал разложить комплект в комплект.
207
243
  const inKit = resolve(CWD) === resolve(PKG_ROOT);
208
- const checks = [
209
- inKit ? ["kit/docs", L.doctor.docsKit] : [".aqk/docs", L.doctor.docs],
210
- inKit ? ["kit/rules", L.doctor.rulesKit] : [".aqk/rules", L.doctor.rules],
211
- ["AGENTS.md", L.doctor.agents],
212
- [".gitignore", L.doctor.gitignore],
213
- [".git", L.doctor.git],
214
- ];
244
+ const man = await readManifest();
245
+ // Что именно проверять решает манифест: где у ЭТОГО проекта правила, методички и точка
246
+ // входа. Литеральный список стоял здесь до 2026-09-08 и печатал кресты за сделанное.
247
+ const checks = layoutChecks(man, inKit);
215
248
 
216
249
  let missing = 0;
217
250
  for (const [path, what] of checks) {
@@ -220,8 +253,10 @@ async function cmdDoctor() {
220
253
  console.log(` ${ok ? c.green("✔") : c.red("✘")} ${path.padEnd(22)} ${c.dim(what)}`);
221
254
  }
222
255
 
223
- // Команды в AGENTS.md заполнены или остались пустыми заготовками?
224
- const agents = join(CWD, "AGENTS.md");
256
+ // Команды в точке входа заполнены или остались пустыми заготовками? Файл берётся тот же,
257
+ // что проверен выше, — иначе проект на `CLAUDE.md` этой проверки не получал вовсе.
258
+ const entryFile = (Array.isArray(man?.entry) ? man.entry : []).find((e) => typeof e === "string" && e.trim())?.trim() || "AGENTS.md";
259
+ const agents = join(CWD, entryFile);
225
260
  if (await exists(agents)) {
226
261
  const text = await readFile(agents, "utf8");
227
262
  const emptyCommands = (text.match(/^- [^:]+: ``$/gm) || []).length;
@@ -233,8 +268,6 @@ async function cmdDoctor() {
233
268
  }
234
269
  }
235
270
 
236
- const man = await readManifest();
237
-
238
271
  // Опечатка в имени поля означала «поля нет»: вердикт выдавался неверный, а причина молчала.
239
272
  // Называем поле и говорим, какие бывают — иначе человек ищет ошибку в проекте, а она в файле.
240
273
  const unknown = unknownKeys(man);
@@ -243,12 +276,20 @@ async function cmdDoctor() {
243
276
  console.log(c.dim(` ${L.doctor.manifestKnown(KNOWN_KEYS)}\n`));
244
277
  }
245
278
 
246
- const { reached, steps } = await assessLevel(man);
279
+ // Доказательство считается только при прогоне: узнать, ловит ли гейт брак, нельзя иначе как
280
+ // запустив его по образцу. Без прогона ступени со второй помечаются «не доказано» — это
281
+ // честнее, чем показывать их выполненными по наличию папок.
282
+ const proof = process.argv.includes("--run") ? await proveGates(man) : null;
283
+ const { reached, steps } = await assessLevel(man, proof);
247
284
 
248
285
  console.log(c.bold(`\n ${L.doctor.levelHeading}\n`));
249
286
  for (const s of steps) {
250
287
  const mark = s.ok ? c.green("✔") : reached + 1 === s.level ? c.yellow("→") : c.dim("·");
251
- console.log(` ${mark} AQK-${s.level} ${s.title}`);
288
+ const note = !s.ok && s.needsProof ? c.dim(` · ${L.doctor.levelUnproven(`${SELF} prove`)}`) : "";
289
+ console.log(` ${mark} AQK-${s.level} ${s.title}${note}`);
290
+ }
291
+ if (proof && proof.broken) {
292
+ console.log(c.red(`\n ${L.doctor.gatesDoNotCatch(proof.broken, `${SELF} prove`)}`));
252
293
  }
253
294
 
254
295
  const next = steps.find((s) => !s.ok);
@@ -276,6 +317,12 @@ async function cmdDoctor() {
276
317
 
277
318
  const facts = await detectFacts(man);
278
319
  if (process.argv.includes("--baseline")) {
320
+ // `--baseline` — осмотр, а не прогон: он выходит с нулём всегда. Совмещённый с `--run` или
321
+ // `--min` он давал конвейер, который НЕ МОЖЕТ покраснеть: порог назван, гейты не запущены,
322
+ // код нулевой. Человек, собравший такую строку, считает, что порог держится. Отказываемся
323
+ // вслух — молчаливое зелёное здесь дороже сломанной команды. Найдено ревью 2026-09-08.
324
+ const clash = ["--run", "--min"].filter((f) => process.argv.includes(f));
325
+ if (clash.length) die(L.doctor.baselineClash(clash.join(", ")));
279
326
  await reportBaseline(man, facts);
280
327
  process.exit(0);
281
328
  }
@@ -319,4 +366,4 @@ async function cmdDoctor() {
319
366
 
320
367
  // Наружу — только команда. Остальное здесь же и используется: экспорт, который никто не
321
368
  // импортирует, читается как «это часть договора» и мешает менять внутренности.
322
- export { cmdDoctor, runGates, declaredGates };
369
+ export { cmdDoctor, runGates, declaredGates, sinceRef };
@@ -47,7 +47,8 @@ async function installGate(slug, man, facts) {
47
47
  }
48
48
 
49
49
  // Команда под стек проекта, с путями внутри репозитория, а не внутри пакета.
50
- const picked = String(pickRecipe(rec, facts) || "");
50
+ const missing = [];
51
+ const picked = String(pickRecipe(rec, facts, missing) || "");
51
52
  let cmd = picked
52
53
  .replace(/\{gate\}/g, `${PROJECT_GATES}/${slug}`)
53
54
  .replace(/\{dir\}/g, ".");
@@ -57,7 +58,7 @@ async function installGate(slug, man, facts) {
57
58
  // ни `ruff`, ни `vulture`, и установка обрывалась на записи `dead-code`, которой нужен
58
59
  // настоящий инструмент. Отсутствие сигнала неотличимо от успеха — здесь оно было внутри
59
60
  // самой установки.
60
- if (!cmd) return { rec, cmd: null, copied, declared: false, why: null, noRecipe: true };
61
+ if (!cmd) return { rec, cmd: null, copied, declared: false, why: null, noRecipe: true, missing };
61
62
 
62
63
  // Родной инструмент не знает про наши образцы и выдаёт их как находки — в любом проекте,
63
64
  // куда поставили гейты. Заворачиваем его в общий фильтр. Переносимая проверка фильтрует
@@ -94,9 +95,14 @@ async function cmdAdd(args) {
94
95
  console.log(c.dim(` ${L.add.installAnyway}\n`));
95
96
  }
96
97
 
97
- const { cmd, copied, declared, why, noRecipe, retired } = await installGate(slug, man, facts);
98
+ const { cmd, copied, declared, why, noRecipe, retired, missing } = await installGate(slug, man, facts);
98
99
  if (retired !== undefined) die(L.lifecycle.installDeprecated(slug, retired ? `${SELF} add ${retired}` : "—"));
99
- if (noRecipe) die(L.add.noRecipe(slug, [...facts.langs].join("/") || L.add.thisStack));
100
+ // «Рецепта нет» и «рецепт есть, а инструмента нет» — разные причины и разные починки.
101
+ // Диагноз, противоречащий gate.yml, стоит доверия всему выводу. Найдено код-ревью 2026-09-07.
102
+ if (noRecipe) {
103
+ if (missing && missing.length) die(L.add.toolMissing(slug, missing));
104
+ die(L.add.noRecipe(slug, [...facts.langs].join("/") || L.add.thisStack, missing));
105
+ }
100
106
 
101
107
  console.log(c.bold(`\naqk add ${slug}\n`));
102
108
  console.log(` ${c.green("✔")} ${PROJECT_GATES}/${slug}/ ${c.dim(L.add.copied(copied.length))}`);
@@ -0,0 +1,159 @@
1
+ // tool/commands/learn.mjs — кандидаты в правила из локальных логов сессий.
2
+ //
3
+ // ЗАЧЕМ. Тезис комплекта: обещание обязано стать командой. Но сначала обещание обязано быть
4
+ // ЗАПИСАНО, а половина того, что человек требует от агента, живёт только в переписке. Здесь
5
+ // комплект смотрит туда, где эти требования лежат, и показывает те, которых нет в точке входа.
6
+ //
7
+ // ЧТО ИЗМЕРЕНО ДО КОДА (2026-09-08, 67 сессий на машине владельца):
8
+ // · 25 353 записи `user` — из них человеком напечатано 1912. Остальное результаты
9
+ // инструментов. Отличает их поле `promptSource: "typed"`, и оно точнее любой эвристики:
10
+ // первая версия отбирала по длине и языку и выдавала «agent quality kit» 44 раза — то есть
11
+ // вставленные пути, а не правила;
12
+ // · из 1619 уникальных напечатанных реплик маркеры наставления дают 79, это 4%. Среди них
13
+ // настоящие правила («файл не трогай», «делай прогон с базой обязательно», «никаких
14
+ // обходных временных путей») и разговорная шелуха примерно поровну.
15
+ //
16
+ // ЧЕГО ЗДЕСЬ НАМЕРЕННО НЕТ. Поиска ПОВТОРОВ — приёма, на котором построен session-analyzer у
17
+ // agent-lint. Замер его не подтвердил: на 67 сессиях владелец не повторяет правило дословно, он
18
+ // говорит его один раз и каждый раз иначе. Те «повторы», что нашлись, оказались задвоением
19
+ // одной реплики в самом логе.
20
+ //
21
+ // ПРИВАТНОСТЬ. Команда читает переписку. Поэтому: только логи ТЕКУЩЕГО проекта (или явно
22
+ // названного), только в терминал, ни строки на диск, код возврата всегда 0. Отчёт, который
23
+ // можно закоммитить, из переписки не собирается — это решение, а не недоделка.
24
+
25
+ import { readdir, readFile } from "node:fs/promises";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+ import { CWD, c, exists } from "../lib/core.mjs";
29
+ import { readManifest } from "../lib/manifest.mjs";
30
+ import { L } from "../i18n/index.mjs";
31
+
32
+ // Каталог логов зовётся по рабочему пути, где всё, кроме букв и цифр, заменено на дефис.
33
+ // Правило снято с живой машины, а не угадано: /home/ser/projects/audit_project лежит в
34
+ // -home-ser-projects-audit-project, то есть подчёркивание тоже становится дефисом.
35
+ function logSlug(cwd) {
36
+ return String(cwd).toLowerCase().replace(/[^a-z0-9]+/g, "-");
37
+ }
38
+
39
+ // Маркеры наставления на двух языках. Список короткий намеренно: каждый лишний маркер добавляет
40
+ // шума больше, чем находок, а разбирать этот список человеку.
41
+ const MARKERS = new RegExp(
42
+ "(всегда|никогда|не надо|не нужно|обязательно|запомни|больше не|каждый раз|нельзя|" +
43
+ "только после|перед тем|сначала|не забывай|не трогай|как договорились|" +
44
+ "always|never|don'?t|do not|make sure|remember to|must not)",
45
+ "i",
46
+ );
47
+
48
+ // Признаки вставки, а не реплики: длина, код в тройных кавычках, много переносов, пути, ссылки.
49
+ // Каждый добавлен по итогу прогона, а не на всякий случай.
50
+ function looksLikeRule(text) {
51
+ const t = String(text || "").trim();
52
+ if (!t || t.length > 400) return false;
53
+ if (t.includes("```")) return false;
54
+ if ((t.match(/\n/g) || []).length > 6) return false;
55
+ if (/https?:\/\//.test(t)) return false;
56
+ if ((t.match(/\S+\/\S+/g) || []).length >= 3) return false;
57
+ return MARKERS.test(t);
58
+ }
59
+
60
+ // Слова, по которым сверяем сказанное с записанным. Короткие отброшены: на них совпадёт что
61
+ // угодно, и любое правило показалось бы уже записанным — то есть команда молчала бы всегда.
62
+ function keyWords(text) {
63
+ return [...new Set(String(text).toLowerCase().match(/[а-яёa-z]{4,}/g) || [])];
64
+ }
65
+
66
+ // Сверяем по ОСНОВЕ, а не по слову целиком. Русский язык склоняет: в реплике «локальный
67
+ // костыль», в своде «до местного костыля» — по целому слову это промах, и правило, записанное
68
+ // час назад, показалось бы незаписанным. Проверено на живом логе: без основы первым же пунктом
69
+ // вышло правило, внесённое в AGENTS.md в тот же день.
70
+ function stem(w) {
71
+ return w.length > 5 ? w.slice(0, 5) : w;
72
+ }
73
+
74
+ // Сказано вслух и НЕ записано. Порог половинный: правило, у которого хотя бы половина значимых
75
+ // слов уже стоит в точке входа, считаем записанным — иначе команда повторяла бы владельцу его
76
+ // же свод. Порог назван здесь, а не спрятан: он произвольный, и это видно.
77
+ function saidNotWritten(text, entryText) {
78
+ const words = keyWords(text);
79
+ if (!words.length) return false;
80
+ const hay = String(entryText || "").toLowerCase();
81
+ const hit = words.filter((w) => hay.includes(stem(w))).length;
82
+ return hit / words.length < 0.5;
83
+ }
84
+
85
+ // Напечатанные человеком реплики одной сессии. Всё прочее — результаты инструментов, служебные
86
+ // вставки и подсказки — отбрасывается по полю promptSource.
87
+ function typedFrom(jsonl) {
88
+ const out = [];
89
+ for (const line of jsonl.split("\n")) {
90
+ if (!line.trim()) continue;
91
+ let d;
92
+ try { d = JSON.parse(line); } catch { continue; }
93
+ if (d?.type !== "user" || d?.promptSource !== "typed") continue;
94
+ const cont = d?.message?.content;
95
+ const text = typeof cont === "string"
96
+ ? cont
97
+ : Array.isArray(cont)
98
+ ? cont.filter((b) => b?.type === "text").map((b) => b.text || "").join(" ")
99
+ : "";
100
+ const t = String(text).replace(/\s+/g, " ").trim();
101
+ if (t) out.push({ text: t, when: String(d.timestamp || "").slice(0, 10) });
102
+ }
103
+ return out;
104
+ }
105
+
106
+ async function readEntry(man) {
107
+ const names = Array.isArray(man?.entry) && man.entry.length ? man.entry : ["AGENTS.md", "CLAUDE.md"];
108
+ let all = "";
109
+ for (const n of names) {
110
+ try { all += `\n${await readFile(join(CWD, String(n)), "utf8")}`; } catch { /* нет файла — не беда */ }
111
+ }
112
+ return all;
113
+ }
114
+
115
+ async function cmdLearn(argv = process.argv) {
116
+ const limitAt = argv.indexOf("--limit");
117
+ const limit = limitAt !== -1 && /^\d+$/.test(argv[limitAt + 1] || "") ? Number(argv[limitAt + 1]) : 20;
118
+ const root = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "projects", logSlug(CWD));
119
+
120
+ console.log(c.bold(`\n ${L.learn.title}\n`));
121
+ if (!(await exists(root))) {
122
+ console.log(` ${L.learn.noLogs(root)}\n`);
123
+ return;
124
+ }
125
+
126
+ let files = [];
127
+ try { files = (await readdir(root)).filter((f) => f.endsWith(".jsonl")); } catch { files = []; }
128
+ const seen = new Set();
129
+ const said = [];
130
+ let typedTotal = 0;
131
+ for (const f of files) {
132
+ let raw = "";
133
+ try { raw = await readFile(join(root, f), "utf8"); } catch { continue; }
134
+ for (const m of typedFrom(raw)) {
135
+ typedTotal++;
136
+ const key = m.text.toLowerCase().slice(0, 200);
137
+ if (seen.has(key)) continue;
138
+ seen.add(key);
139
+ if (looksLikeRule(m.text)) said.push(m);
140
+ }
141
+ }
142
+
143
+ const entry = await readEntry(await readManifest());
144
+ const fresh = said.filter((m) => saidNotWritten(m.text, entry));
145
+ fresh.sort((a, b) => String(b.when).localeCompare(String(a.when)));
146
+
147
+ console.log(` ${c.dim(L.learn.counted(files.length, typedTotal, said.length, fresh.length))}\n`);
148
+ if (!fresh.length) {
149
+ console.log(` ${L.learn.nothing}\n`);
150
+ return;
151
+ }
152
+ for (const m of fresh.slice(0, limit)) {
153
+ console.log(` ${c.dim(m.when)} ${m.text.slice(0, 150)}`);
154
+ }
155
+ if (fresh.length > limit) console.log(c.dim(`\n ${L.learn.andMore(fresh.length - limit)}`));
156
+ console.log(`\n ${c.yellow(L.learn.warn)}\n`);
157
+ }
158
+
159
+ export { cmdLearn, logSlug, looksLikeRule, saidNotWritten, typedFrom };
@@ -11,7 +11,7 @@ import { AGENTS_MD, CLAUDE_MD, MANIFEST_YML } from "../lib/templates.mjs";
11
11
  import { readManifest } from "../lib/manifest.mjs";
12
12
  import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
13
13
  import { installGate } from "./gates.mjs";
14
- import { L } from "../i18n/index.mjs";
14
+ import { L, LANG } from "../i18n/index.mjs";
15
15
 
16
16
  async function cmdInit(args) {
17
17
  const force = args.includes("--force");
@@ -46,6 +46,13 @@ async function cmdInit(args) {
46
46
  for (const f of created.slice(0, 8)) console.log(` ${f}`);
47
47
  if (created.length > 8) console.log(c.dim(` ${L.init.andMore(created.length - 8)}`));
48
48
  }
49
+ // Методички остаются на русском — решение владельца, принятое 2026-09-07, а не недоделка.
50
+ // Сказать об этом обязательно: человек, открывший `.aqk/docs/` и увидевший чужой язык, иначе
51
+ // решит, что установка сломалась. Правила переведены, методички нет; молчать об этом значит
52
+ // выдать решение за оплошность.
53
+ if (LANG === "en" && created.some((f) => f.includes(`${TARGET_DIR}/docs/`) || f.includes(`${TARGET_DIR}\\docs\\`))) {
54
+ console.log(c.dim(`\n ${L.init.docsRu}`));
55
+ }
49
56
  if (skipped.length) {
50
57
  console.log(c.yellow(`\n ${L.init.kept(skipped.length)}`));
51
58
  for (const f of skipped) console.log(` ${f}`);
@@ -69,6 +76,7 @@ ${c.bold(L.init.nextTitle)}
69
76
  4. ${L.init.n4a} ${c.bold(L.init.n4b)}${L.init.n4c}
70
77
  ${L.init.n4d}
71
78
 
79
+ ${c.dim(L.init.hookHint(`${SELF} context --install`))}
72
80
  ${c.dim(L.init.burned(`${SELF} note "…"`))}
73
81
  `);
74
82
  await maybeAskFeedback();
@@ -0,0 +1,67 @@
1
+ // tool/commands/prove.mjs — `aqk prove`: доказать, что гейты проекта ловят брак.
2
+ //
3
+ // Отдельная команда, а не флаг: вопрос «работают ли мои проверки» задают сам по себе, и ответ
4
+ // на него нужен раньше, чем прогон по коду. Прогон говорит «сегодня чисто»; доказательство —
5
+ // «а если бы было грязно, я бы это увидел».
6
+ import { readManifest } from "../lib/manifest.mjs";
7
+ import { proveGates } from "../lib/prove.mjs";
8
+ import { c, SELF } from "../lib/core.mjs";
9
+ import { L } from "../i18n/index.mjs";
10
+
11
+ function line(r) {
12
+ const P = L.prove;
13
+ const pad = r.name.padEnd(22);
14
+ if (r.state === "proven") return ` ${c.green("✔")} ${pad} ${c.dim(P.okRed)}`;
15
+ if (r.state === "unprovable") {
16
+ const why =
17
+ r.why === "no-samples" ? P.noSamples
18
+ : r.why === "other-recipe" ? P.otherRecipe(r.forRecipe.lang)
19
+ : r.why === "no-target" ? P.noTarget
20
+ : P.empty;
21
+ return ` ${c.dim("~")} ${c.dim(pad)} ${c.dim(why)}`;
22
+ }
23
+ const why = r.why === "red-passed" ? P.redPassed : r.why === "green-failed" ? P.greenFailed : P.empty;
24
+ return ` ${c.red("✘")} ${pad} ${c.red(why)}`;
25
+ }
26
+
27
+ async function cmdProve() {
28
+ const man = await readManifest();
29
+ const P = L.prove;
30
+ console.log(c.bold(`\n${P.title}\n`));
31
+
32
+ // «Манифеста нет» и «гейтов не объявлено» — разные причины и разные починки. Остальные
33
+ // команды это различают; здесь не различалось. Найдено код-ревью 2026-09-07.
34
+ if (!man) {
35
+ console.log(` ${c.red(L.ratchet.noManifest(`${SELF} init`))}\n`);
36
+ process.exit(1);
37
+ }
38
+ const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
39
+ if (!Object.keys(gates).length) {
40
+ console.log(` ${P.noGates}\n`);
41
+ process.exit(1);
42
+ }
43
+ if (!String(man?.samples || "").trim()) {
44
+ console.log(` ${c.red(P.noSamplesDir)}\n`);
45
+ process.exit(1);
46
+ }
47
+
48
+ const res = await proveGates(man);
49
+ // Сначала сломанные: красное называется первым, иначе его не читают.
50
+ for (const r of res.results.filter((x) => x.state === "broken")) console.log(line(r));
51
+ for (const r of res.results.filter((x) => x.state === "proven")) console.log(line(r));
52
+ for (const r of res.results.filter((x) => x.state === "unprovable")) console.log(line(r));
53
+
54
+ const parts = [c.green(P.proven(res.proven))];
55
+ if (res.broken) parts.push(c.red(P.broken(res.broken)));
56
+ if (res.unprovable) parts.push(c.dim(P.unprovable(res.unprovable)));
57
+ console.log(`\n ${parts.join(" ")}`);
58
+
59
+ if (!res.ok && res.proven === 0 && res.broken === 0) {
60
+ console.log(`\n ${c.yellow(P.nothingProven)}`);
61
+ console.log(` ${c.dim(P.fix(`${SELF} add ${L.help.name}`))}`);
62
+ }
63
+ console.log("");
64
+ process.exit(res.ok ? 0 : 1);
65
+ }
66
+
67
+ export { cmdProve };
@@ -15,10 +15,13 @@
15
15
 
16
16
  import { mkdir, writeFile, readdir, readFile } from "node:fs/promises";
17
17
  import { join, relative } from "node:path";
18
+ import { statSync } from "node:fs";
18
19
  import { CWD, TARGET_DIR, SELF, c, exists, docPath } from "../lib/core.mjs";
19
20
  import { readManifest, assessLevel } from "../lib/manifest.mjs";
21
+ import { proveGates } from "../lib/prove.mjs";
20
22
  import { detectFacts, readCatalog, triggerVerdict, whichSync } from "../lib/repo.mjs";
21
- import { runGates, declaredGates } from "./doctor.mjs";
23
+ import { changedCode, coverage, evidenceHash, readForHash } from "../lib/evidence.mjs";
24
+ import { runGates, declaredGates, sinceRef } from "./doctor.mjs";
22
25
  import { L } from "../i18n/index.mjs";
23
26
 
24
27
  // Каким рецептом стоит гейт: родным инструментом или переносимой проверкой. Именно это
@@ -73,7 +76,9 @@ async function cmdReport() {
73
76
  process.exit(1);
74
77
  }
75
78
 
76
- const { reached } = await assessLevel(man);
79
+ // Значок самое громкое утверждение комплекта, и доказывать его обязательно. Без этого
80
+ // проект с гейтом «true» получал AQK-3 и зелёную картинку в README: проверено прогоном.
81
+ const { reached } = await assessLevel(man, await proveGates(man));
77
82
  const facts = await detectFacts(man);
78
83
  const catalog = await readCatalog();
79
84
  const bySlug = Object.fromEntries(catalog.map((r) => [r.slug, r]));
@@ -172,6 +177,36 @@ async function cmdReport() {
172
177
  say(`> ${L.report2.ignoreWarn}`);
173
178
  }
174
179
 
180
+ // --- чем доказан этот диф -------------------------------------------------
181
+ // Раздел появляется только с `--since`: без базы сравнения говорить о покрытии нечего, а
182
+ // молчаливо взять умолчание нельзя — «сравнили не с тем» неотличимо от «всё покрыто».
183
+ const since = sinceRef();
184
+ if (since) {
185
+ const changed = changedCode(since, CWD);
186
+ say("");
187
+ say(`## ${L.report2.evidenceTitle}`);
188
+ say("");
189
+ if (changed === null) {
190
+ say(`- ⚠️ ${L.report2.evidenceBadRef(since)}`);
191
+ } else if (!changed.length) {
192
+ say(`- ${L.report2.evidenceNoFiles(since)}`);
193
+ } else {
194
+ // Каталог ли это — спрашиваем у диска: цель гейта «tool» и файл «tool.js» иначе
195
+ // неразличимы, и второй попал бы в «просмотрен» ни за что.
196
+ const isDir = (rel) => { try { return statSync(join(CWD, rel)).isDirectory(); } catch { return false; } };
197
+ const cov = coverage(changed, run.results, isDir);
198
+ const hash = evidenceHash(since, run.results, readForHash(changed, CWD));
199
+ for (const [f, by] of cov.covered) say(`- ✅ ${f} — ${L.report2.evidenceNamed(by.join(", "))}`);
200
+ for (const [f, by] of cov.silent) say(`- ◻️ ${f} — ${L.report2.evidenceSilent(by.length)}`);
201
+ for (const f of cov.uncovered) say(`- ❌ ${f} — ${L.report2.evidenceUncovered}`);
202
+ say("");
203
+ say(`- ${L.report2.evidenceBase}: \`${since}\``);
204
+ say(`- ${L.report2.evidenceHash}: \`${hash}\``);
205
+ say("");
206
+ say(`> ${L.report2.evidenceWarn}`);
207
+ }
208
+ }
209
+
175
210
  say("");
176
211
  say(`## ${L.report2.whyTitle}`);
177
212
  say("");