agent-quality-kit 0.9.0 → 0.10.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 (57) hide show
  1. package/README.md +69 -10
  2. package/README.ru.md +68 -11
  3. package/kit/docs/ai/index.md +1 -0
  4. package/kit/docs/api-e2e.md +214 -0
  5. package/kit/docs/ready-made-rules.md +85 -0
  6. package/kit/gates/README.md +22 -0
  7. package/kit/gates/api-contract-has-arbiter/README.md +63 -0
  8. package/kit/gates/api-contract-has-arbiter/check.sh +117 -0
  9. package/kit/gates/api-contract-has-arbiter/gate.yml +15 -0
  10. package/kit/gates/api-contract-has-arbiter/green/.github/workflows/ci.yml +12 -0
  11. package/kit/gates/api-contract-has-arbiter/green/openapi.yaml +18 -0
  12. package/kit/gates/api-contract-has-arbiter/red/.github/workflows/ci.yml +11 -0
  13. package/kit/gates/api-contract-has-arbiter/red/openapi.yaml +18 -0
  14. package/kit/gates/ci-actually-fails/check.sh +9 -1
  15. package/kit/gates/commit-explains-itself/check.sh +15 -0
  16. package/kit/gates/complexity-limit/red/deep.go +17 -0
  17. package/kit/gates/complexity-limit/red/deep.rs +17 -0
  18. package/kit/gates/gate-not-weakened/red/suppress.go +5 -0
  19. package/kit/gates/gate-not-weakened/red/suppress.rs +3 -0
  20. package/kit/gates/protection-not-removed/README.md +67 -0
  21. package/kit/gates/protection-not-removed/check.sh +92 -0
  22. package/kit/gates/protection-not-removed/gate.yml +10 -0
  23. package/kit/gates/protection-not-removed/green/.aqk.yml +7 -0
  24. package/kit/gates/protection-not-removed/green/gates-declared.txt +4 -0
  25. package/kit/gates/protection-not-removed/red/.aqk.yml +7 -0
  26. package/kit/gates/protection-not-removed/red/gates-declared.txt +4 -0
  27. package/kit/gates/secrets-not-in-code/red/leak.go +9 -0
  28. package/kit/gates/secrets-not-in-code/red/leak.rs +5 -0
  29. package/kit/gates/todo-without-task/red/later.go +6 -0
  30. package/kit/gates/todo-without-task/red/later.rs +4 -0
  31. package/llms.txt +10 -4
  32. package/package.json +2 -1
  33. package/tool/commands/context.mjs +28 -1
  34. package/tool/commands/doctor.mjs +56 -2
  35. package/tool/commands/probe.mjs +228 -0
  36. package/tool/commands/vitals.mjs +11 -3
  37. package/tool/i18n/en-docs.mjs +8 -0
  38. package/tool/i18n/en-gates.mjs +309 -0
  39. package/tool/i18n/en.mjs +10 -281
  40. package/tool/i18n/ru-docs.mjs +8 -0
  41. package/tool/i18n/ru-gates.mjs +311 -0
  42. package/tool/i18n/ru.mjs +10 -280
  43. package/tool/lib/cadence.mjs +57 -0
  44. package/tool/lib/core.mjs +1 -0
  45. package/tool/lib/history.mjs +82 -0
  46. package/tool/lib/manifest.mjs +1 -1
  47. package/tool/lib/repo.mjs +12 -2
  48. package/tool/program.mjs +7 -0
  49. package/tool/selfcheck/smoke/_fixture.mjs +89 -0
  50. package/tool/selfcheck/smoke/api-contract.test.mjs +79 -0
  51. package/tool/selfcheck/smoke/commit-report.test.mjs +47 -0
  52. package/tool/selfcheck/smoke/verdict.test.mjs +40 -0
  53. package/tool/selfcheck/smoke.sh +179 -6
  54. package/tool/selfcheck/units-cadence.mjs +69 -0
  55. package/tool/selfcheck/units-probe.mjs +100 -0
  56. package/tool/selfcheck/units-repo.mjs +31 -1
  57. package/tool/selfcheck/units-vitals.mjs +19 -0
@@ -0,0 +1,4 @@
1
+ pub fn send(to: &str) -> bool {
2
+ // FIXME: переписать на очередь
3
+ deliver(to)
4
+ }
package/llms.txt CHANGED
@@ -47,6 +47,13 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
47
47
  config is an intention, not a guard), does the agent receive the state, is the version current.
48
48
  Four states, not two: connected · BROKEN · not connected and that is a choice · could not look.
49
49
  Only BROKEN affects the exit code
50
+ - See what your declared checks CANNOT see: `npx agent-quality-kit probe` — takes the files your
51
+ own fix history calls hot, plants a proven red sample from the catalogue into a copy of each,
52
+ and runs YOUR declared gates against it. Coverage is not declared, it is proven by planting.
53
+ Three states that never merge: caught · nothing catches it · nothing to check with. Measured on
54
+ the kit itself: a real file, a swallowed error and a debug print planted, 21 declared gates,
55
+ none went red. The working tree is untouched and the exit code is always 0 — a look, not a
56
+ threshold
50
57
  - See what you told the agent and never wrote down: `npx agent-quality-kit learn` — reads Claude Code
51
58
  transcripts for this project on this machine and prints rule candidates missing from the entry
52
59
  point. Current project only, terminal only, writes nothing, always exits 0
@@ -56,13 +63,12 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
56
63
  package itself; there are no dependencies to pull in.
57
64
  - Without Node at all (a Python, Go or Rust project where nobody installed it):
58
65
  `docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/work" ghcr.io/arsen-ask-lx/aqk doctor`.
59
- Published from v0.9.0 onward, by the same run that publishes the package; before that tag,
60
- build it from the repository: `docker build -t aqk . && docker run --rm -u "$(id -u):$(id -g)"
61
- -v "$PWD:/work" aqk doctor`. Keep the `--user` flag: without it the container runs as root and
66
+ Pushed to the registry by the same run, from the same tag, that publishes the package.
67
+ Keep the `--user` flag: without it the container runs as root and
62
68
  the files `init` writes are owned by root, so you cannot edit your own manifest. Debian-based
63
69
  on purpose: the gates are `sh`, `grep`, `awk`, `find` — under alpine's busybox they behave
64
70
  differently, and an image where the gates behave differently is worse than no image
65
- - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.9.0` with `min: 1`
71
+ - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.10.0` with `min: 1`
66
72
  (https://github.com/marketplace/actions/agent-quality-kit-aqk)
67
73
 
68
74
  ## What makes it different
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-quality-kit",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Turns the rules an agent is supposed to follow into commands with exit codes, and reports which of them actually run. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,7 @@
44
44
  "knip": {
45
45
  "entry": [
46
46
  "tool/selfcheck/units*.mjs",
47
+ "tool/selfcheck/smoke/*.test.mjs",
47
48
  "tool/selfcheck/lifecycle.mjs"
48
49
  ],
49
50
  "project": [
@@ -25,6 +25,7 @@ import { spawnSync } from "node:child_process";
25
25
  import { join } from "node:path";
26
26
  import { CWD, TARGET_DIR, SELF, c, exists, commandRows } from "../lib/core.mjs";
27
27
  import { readManifest, assessLevel } from "../lib/manifest.mjs";
28
+ import { probeStatus } from "./probe.mjs";
28
29
  import { L } from "../i18n/index.mjs";
29
30
 
30
31
  // Больше пяти имён подряд агент всё равно не удержит, а блок ради них раздувается. Остаток
@@ -63,6 +64,18 @@ function contextBlock(state, T = L.context) {
63
64
  const rat = (state.ratchets || []).slice(0, MAX_RATCHETS);
64
65
  if (rat.length) out.push(T.ratchets(rat.map((r) => `${r.name} (${r.count})`).join(", ")));
65
66
 
67
+ // ЧТО НЕ ПРИКРЫТО НИЧЕМ — сюда попадает потому, что иначе об этом не узнает никто. Команду
68
+ // `probe` надо вспомнить, а агент не вспомнит: это тот же класс, что файл, который можно не
69
+ // прочитать. Блок читается по построению, поэтому знание живёт здесь, а не в команде.
70
+ // Состояние «не делалась» печатается как НЕИЗВЕСТНО, а не опускается: молчание тут
71
+ // прочиталось бы как «всё прикрыто», а прикрыто ли — мы не знаем.
72
+ const pr = state.probe;
73
+ if (pr) {
74
+ if (pr.state === "never") out.push(T.probeNever);
75
+ else if (pr.blind > 0) out.push(T.probeBlind(pr.blind, pr.state === "stale" ? pr.behind : 0));
76
+ else out.push(T.probeClean(pr.state === "stale" ? pr.behind : 0));
77
+ }
78
+
66
79
  // ПОЛНЫЙ БЛОК — решение владельца от 2026-09-08, принятое ПОСЛЕ возражения и вопреки ему.
67
80
  // Возражение было такое: вход, растущий в длину, роняет качество у всех проверенных моделей,
68
81
  // и свод, влитый целиком, даёт правило, которое в контексте есть и не выполняется. Ответ
@@ -233,6 +246,20 @@ async function cmdContext(args = []) {
233
246
  if (run) run.stale = runIsStale(run.when);
234
247
  }
235
248
 
249
+ // Проба: сколько классов не ловит никто и насколько отметка отстала. Читается из файла,
250
+ // ничего не запускает — блок обязан укладываться в секунду.
251
+ let probe = null;
252
+ try {
253
+ const st = await probeStatus();
254
+ let blind = null;
255
+ const mark = join(CWD, TARGET_DIR, "last-probe.md");
256
+ if (await exists(mark)) {
257
+ const m = /^blind:\s*(\d+)/m.exec(await readFile(mark, "utf8"));
258
+ if (m) blind = Number(m[1]);
259
+ }
260
+ probe = { ...st, blind };
261
+ } catch { /* пробы нет — блок просто не покажет строку про неё */ }
262
+
236
263
  const ratchets = [];
237
264
  const dir = typeof man?.ratchets === "string" ? man.ratchets.trim() : "";
238
265
  if (dir && (await exists(join(CWD, dir)))) {
@@ -257,7 +284,7 @@ async function cmdContext(args = []) {
257
284
  }
258
285
 
259
286
  console.log(contextBlock({
260
- entry, entryExists: rules !== null, level, rules, run, ratchets, full: fullPart,
287
+ entry, entryExists: rules !== null, level, rules, run, ratchets, probe, full: fullPart,
261
288
  }).join("\n"));
262
289
  }
263
290
 
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { scopeOutput, splitAdvice, changedFiles } from "../lib/scope.mjs";
7
7
  import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die } from "../lib/core.mjs";
8
+ import { cmdProbe, probeStatus } from "./probe.mjs";
8
9
  import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, advisorySet, layoutChecks, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
9
10
  import { proveGates } from "../lib/prove.mjs";
10
11
  import { detectFacts, readCatalog, triggerVerdict, browserServerAdvice } from "../lib/repo.mjs";
@@ -246,8 +247,19 @@ function runGates(man, opts = {}) {
246
247
  const mark = isAdvisory ? c.yellow("!") : c.red("✘");
247
248
  const verdict = isAdvisory ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
248
249
  console.log(` ${mark} ${name.padEnd(14)} ${verdict} ${c.dim(`· ${secs}s · ${cmd}`)}`);
249
- for (const line of out.slice(0, 3)) console.log(c.dim(` ${line.slice(0, 100)}`));
250
- if (out.length > 3) console.log(c.dim(` ${L.doctor.moreLines(out.length - 3)}`));
250
+ // ГОЛОВА И ХВОСТ, А НЕ ТОЛЬКО ГОЛОВА. Гейт, который сам является прогоном (наш `smoke`),
251
+ // печатает сотни строк, и вердикт у него в конце — при обрезке до первых трёх человек
252
+ // видел «программа разбирается» и ни слова о том, что упало. Час поисков в конвейере
253
+ // 2026-09-09 стоил ровно этого. Голова нужна тоже: у сканирующих записей находки идут
254
+ // с первой строки.
255
+ const HEAD = 3, TAIL = 2;
256
+ for (const line of out.slice(0, HEAD)) console.log(c.dim(` ${line.slice(0, 100)}`));
257
+ if (out.length > HEAD + TAIL) {
258
+ console.log(c.dim(` ${L.doctor.moreLines(out.length - HEAD - TAIL)}`));
259
+ for (const line of out.slice(-TAIL)) console.log(c.dim(` ${line.slice(0, 100)}`));
260
+ } else {
261
+ for (const line of out.slice(HEAD)) console.log(c.dim(` ${line.slice(0, 100)}`));
262
+ }
251
263
  // Совет тоже не бесконечен: гейт, зовущий помощник шесть раз, печатает его шесть раз.
252
264
  for (const line of alwaysAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
253
265
  results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll });
@@ -404,6 +416,31 @@ async function cmdDoctor() {
404
416
  gateFailed = run.failed;
405
417
  failedNames = run.results.filter((r) => !r.ok).map((r) => r.name);
406
418
  await writeRunReport({ version, reached, results: run.results });
419
+
420
+ // ПРОБА ЗАПУСКАЕТСЯ САМА. Владелец сформулировал так: «команду, о которой надо вспомнить,
421
+ // агент не вспомнит, а человек о ней не узнает». Это тот же класс, что файл, который можно
422
+ // не прочитать, — и весь комплект написан против него. `probe` отвечает на важнейший
423
+ // вопрос («что здесь не прикрыто ничем») и, оставаясь ручной, не задаётся никем.
424
+ //
425
+ // Поэтому не напоминание, а действие: раз в сто коммитов прогон делает пробу сам. Единица
426
+ // — коммиты, а не сутки: месяц без работы перепроверять незачем, сто коммитов за день —
427
+ // надо. В кратком режиме не запускается: там хук на воротах коммита, и лишние секунды там
428
+ // стоят дороже. Не влияет на код возврата НИКОГДА — это осмотр, а не порог.
429
+ // Выключается AQK_PROBE=0 — у всего, что случается само, обязан быть выключатель.
430
+ if (!brief && process.env.AQK_PROBE !== "0") {
431
+ try {
432
+ const st = await probeStatus();
433
+ if (st.badEvery !== undefined) {
434
+ console.log(c.yellow(`\n ${L.probe.badEvery(st.badEvery)}`));
435
+ } else if (st.state === "never" || st.state === "stale") {
436
+ // Сообщение обязано быть верным в обоих случаях. Первая версия печатала «прошло сто
437
+ // коммитов» и там, где пробы не было ВОВСЕ: число бралось из порога, а не из факта.
438
+ // Мелочь, но того же класса, что и всё остальное здесь: вывод, который не врёт.
439
+ console.log(c.dim(`\n ${st.state === "never" ? L.probe.autoFirst : L.probe.auto(st.behind)}`));
440
+ await cmdProbe([], { auto: true });
441
+ }
442
+ } catch { /* проба не состоялась — прогон это не роняет: он про гейты, а не про неё */ }
443
+ }
407
444
  } else if (gates.length) {
408
445
  console.log(
409
446
  c.yellow(` ${L.doctor.declaredNotRun(gates.length)}`) +
@@ -429,6 +466,23 @@ async function cmdDoctor() {
429
466
  process.exit(pass ? 0 : 1);
430
467
  }
431
468
  const ok = !(missing || reached < 0 || gateFailed);
469
+ // ВЕРДИКТ НАЗЫВАЕТСЯ СЛОВАМИ, а не только кодом возврата. С `--min` он печатался всегда, без
470
+ // него — никогда: прогон выходил с единицей, а внизу человек видел список зелёных гейтов и
471
+ // шёл искать причину. Обратная сторона нашего же принципа: молчание неотличимо не только от
472
+ // успеха, но и от отказа. Найдено аудитом фич 2026-09-09.
473
+ //
474
+ // Печатается и на зелёном тоже: «ничего не сказал» и «всё проверено» обязаны различаться.
475
+ if (wantRun) {
476
+ if (ok) {
477
+ console.log(c.green(` ${L.doctor.runVerdictOk}\n`));
478
+ } else {
479
+ const why = [];
480
+ if (missing) why.push(L.doctor.whyMissing);
481
+ if (reached < 0) why.push(L.doctor.whyLevel);
482
+ if (gateFailed) why.push(L.doctor.whyGates(gateFailed, failedNames.join(", ")));
483
+ console.log(c.red(` ${L.doctor.runVerdictFail(why.join(", "))}\n`));
484
+ }
485
+ }
432
486
  await finishBrief(buf, { held: cat.held, todo: cat.todo, level: reached, red: [] }, cat.todoRecs, ok);
433
487
  process.exit(ok ? 0 : 1);
434
488
  }
@@ -0,0 +1,228 @@
1
+ // tool/commands/probe.mjs — `aqk probe`: чего объявленные проверки НЕ видят.
2
+ //
3
+ // ЗАЧЕМ ЭТО ОТДЕЛЬНАЯ КОМАНДА. `doctor` отвечает «держит машина 21». Двадцать один из чего?
4
+ // Знаменателя нет: 21 — это то, что мы успели написать в каталог, а не то, что важно в этом
5
+ // проекте. `prove` доказывает, что гейт ловит брак НА СВОЁМ образце. Ни один из них не
6
+ // отвечает на вопрос владельца: «что у меня не прикрыто вообще».
7
+ //
8
+ // Замер, с которого команда началась, — на самом комплекте, 2026-09-09. Взят настоящий файл
9
+ // проекта, в копию подсажены проглоченная ошибка и отладочная печать, прогнаны ВСЕ 21
10
+ // сканирующих гейта из манифеста. Покраснело: ноль. У проекта с AQK-3 есть брак, невидимый
11
+ // всем его проверкам, — и узнать об этом было нечем.
12
+ //
13
+ // КАК УСТРОЕНО. Два источника, и оба — факты, а не наш вкус:
14
+ // 1. история репозитория: где брак ВОЗВРАЩАЕТСЯ (коммиты-починки, `history.mjs`);
15
+ // 2. красные образцы каталога: каждый доказан прогоном, каждый — настоящий брак.
16
+ // Образец кладётся во временный каталог по пути горячего файла, и по нему прогоняются
17
+ // ОБЪЯВЛЕННЫЕ гейты проекта. Никто не покраснел — класс не прикрыт, и это доказано, а не
18
+ // выведено из списка.
19
+ //
20
+ // ЧЕГО КОМАНДА НЕ ДЕЛАЕТ. Не трогает рабочее дерево: проба живёт в каталоге mkdtemp и
21
+ // удаляется. Не меняет манифест. Не роняет прогон: код возврата всегда 0 — это осмотр, а
22
+ // не порог. Порог — у `doctor --run --min`.
23
+ import { spawnSync } from "node:child_process";
24
+ import { mkdtemp, mkdir, copyFile, rm, readdir, writeFile, readFile } from "node:fs/promises";
25
+ import { tmpdir } from "node:os";
26
+ import { join, dirname, extname } from "node:path";
27
+ import { readManifest } from "../lib/manifest.mjs";
28
+ import { commandFor } from "../lib/prove.mjs";
29
+ import { fixHotspots, probeVerdict } from "../lib/history.mjs";
30
+ import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
31
+ import { CWD, GATES_SRC, TARGET_DIR, c, SELF, exists } from "../lib/core.mjs";
32
+ import { probeState, probeEvery, PROBE_EVERY } from "../lib/cadence.mjs";
33
+ import { L } from "../i18n/index.mjs";
34
+
35
+ // Тот же набор расширений, что у привязки доказательства к дифу. Список один на программу:
36
+ // второй через месяц разошёлся бы с первым.
37
+ const CODE_EXT = new Set([
38
+ "c", "cjs", "cpp", "cs", "css", "go", "h", "java", "js", "json", "jsx", "kt", "mjs", "mts",
39
+ "php", "pl", "py", "rb", "rs", "scala", "sh", "sql", "swift", "ts", "tsx", "vue",
40
+ ]);
41
+
42
+ const isCode = (p) =>
43
+ CODE_EXT.has(extname(p).slice(1).toLowerCase()) &&
44
+ !/(^|\/)gates\/[^/]+\/(red|green)(\/|$)/.test(p);
45
+
46
+ // Мелкий клон истории не содержит. `fetch-depth: 2` в конвейере — обычная настройка, и на нём
47
+ // рейтинг починок пуст ВСЕГДА. Сказать там «коммитов-починок не найдено» значит выдать
48
+ // отсутствие данных за факт о репозитории: та же подмена, что «зелено, потому что не
49
+ // проверялось». Найдено собственным конвейером 2026-09-09.
50
+ function isShallow() {
51
+ const r = spawnSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: CWD, encoding: "utf8" });
52
+ return r.status === 0 && String(r.stdout || "").trim() === "true";
53
+ }
54
+
55
+ // История берётся одним вызовом: тема коммита и его файлы. Слияния исключены — в них файлы
56
+ // второй ветки, а починку делали не в них.
57
+ function gitLog(limit) {
58
+ const r = spawnSync(
59
+ "git",
60
+ ["log", "--no-merges", `--max-count=${limit}`, "--format=%s", "--name-only"],
61
+ { cwd: CWD, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
62
+ );
63
+ return r.status === 0 ? r.stdout || "" : null;
64
+ }
65
+
66
+ // Сколько коммитов в репозитории сейчас. Единица каденции — коммиты, а не сутки: месяц без
67
+ // работы перепроверять незачем, а сто коммитов за день — надо.
68
+ function commitCount() {
69
+ const r = spawnSync("git", ["rev-list", "--count", "HEAD"], { cwd: CWD, encoding: "utf8" });
70
+ if (r.status !== 0) return null;
71
+ const n = Number(String(r.stdout || "").trim());
72
+ return Number.isFinite(n) ? n : null;
73
+ }
74
+
75
+ const MARK = () => join(CWD, TARGET_DIR, "last-probe.md");
76
+
77
+ // Отметка о прошлой пробе. Формат человеческий намеренно: файл читают глазами и агентом,
78
+ // а не только программой. Разбирается одна строка — та, что несёт число коммитов.
79
+ async function readMark() {
80
+ try {
81
+ const text = await readFile(MARK(), "utf8");
82
+ const m = /^at:\s*(\d+)/m.exec(text);
83
+ return m ? { at: Number(m[1]), text } : {};
84
+ } catch { return null; }
85
+ }
86
+
87
+ async function writeMark(now, blind, lines) {
88
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
89
+ const body = [
90
+ "# Проба покрытия — что объявленные проверки НЕ видят",
91
+ "",
92
+ `at: ${now === null ? "?" : now}`,
93
+ `blind: ${blind}`,
94
+ "",
95
+ ...lines,
96
+ "",
97
+ "Файл эфемерный: его переписывает каждая проба. В .gitignore его стоит держать самому.",
98
+ ].join("\n");
99
+ await writeFile(MARK(), body + "\n", "utf8");
100
+ }
101
+
102
+ // Гейты, которым можно подставить каталог. Команда записи каталога кончается каталогом
103
+ // проверки; написанная руками — чем угодно, и подставлять там некуда. Ровно то же правило,
104
+ // по которому `prove` объявляет запись недоказуемой, а не сломанной.
105
+ function scanningGates(man) {
106
+ const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
107
+ return Object.entries(gates)
108
+ .map(([name, raw]) => [name, String(raw || "").trim()])
109
+ .filter(([, cmd]) => cmd && /(\.|\.\/)$/.test(cmd));
110
+ }
111
+
112
+ // Красный образец записи, подходящий по расширению горячего файла. Расширение обязано
113
+ // совпадать: питоновский образец в проекте на TypeScript не проверит ничего, а покажет
114
+ // «не прикрыто» — ложная тревога того же класса, что молчащий гейт, только наоборот.
115
+ async function redSampleFor(entry, ext) {
116
+ const dir = join(GATES_SRC, entry, "red");
117
+ if (!(await exists(dir))) return null;
118
+ let names = [];
119
+ try { names = await readdir(dir); } catch { return null; }
120
+ const hit = names.find((n) => extname(n).toLowerCase() === ext);
121
+ return hit ? join(dir, hit) : null;
122
+ }
123
+
124
+ // Проба: временный каталог, в нём образец по пути горячего файла. Путь сохраняется целиком —
125
+ // правила, привязанные к путям (`.aqkignore`, исключения гейтов), обязаны действовать так же,
126
+ // как в настоящем репозитории. Без этого проба отвечала бы про несуществующее место.
127
+ async function buildProbe(relPath, sample) {
128
+ const root = await mkdtemp(join(tmpdir(), "aqk-probe-"));
129
+ const dest = join(root, relPath);
130
+ await mkdir(dirname(dest), { recursive: true });
131
+ await copyFile(sample, dest);
132
+ // Переносится ТОЛЬКО .aqkignore: правила, привязанные к путям, обязаны действовать так же,
133
+ // как в настоящем репозитории. Манифест НЕ переносится намеренно — иначе записи, читающие
134
+ // `.aqk.yml` (`gates-are-runnable`, `gate-has-samples`, `protection-not-removed`), краснеют
135
+ // на том, что в пробе нет объявленных ими файлов, и проба объявляет класс прикрытым, хотя
136
+ // на подсаженный брак не отреагировал никто. Ошибка в сторону «прикрыто» — это тишина,
137
+ // а тишина здесь и есть предмет спора. Поймано первым же прогоном на своём репозитории.
138
+ if (await exists(join(CWD, ".aqkignore"))) {
139
+ await copyFile(join(CWD, ".aqkignore"), join(root, ".aqkignore"));
140
+ }
141
+ return root;
142
+ }
143
+
144
+ function runGates(gates, dir) {
145
+ const out = [];
146
+ for (const [name, cmd] of gates) {
147
+ const r = spawnSync(commandFor(cmd, dir), { shell: true, cwd: CWD, encoding: "utf8", timeout: 120000 });
148
+ out.push({ name, code: r.status === null ? 2 : r.status });
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // `auto` — проба запущена САМА, по каденции, из `doctor --run`. Тогда она короче и говорит
154
+ // вслух, почему случилась: команда, возникшая без спроса, обязана объяснить себя, иначе её
155
+ // читают как сбой.
156
+ async function cmdProbe(args, { auto = false } = {}) {
157
+ const P = L.probe;
158
+ const topArg = Number(args[args.indexOf("--top") + 1]);
159
+ const TOP = args.includes("--top") && Number.isFinite(topArg) && topArg > 0 ? topArg : (auto ? 3 : 5);
160
+
161
+ console.log(c.bold(`\n${P.title}\n`));
162
+
163
+ const man = await readManifest();
164
+ const gates = scanningGates(man);
165
+ if (!gates.length) { console.log(c.yellow(` ${P.noGates(`${SELF} add <имя>`)}\n`)); return; }
166
+
167
+ const raw = gitLog(2000);
168
+ if (raw === null) { console.log(c.yellow(` ${P.noGit}\n`)); return; }
169
+ const hot = fixHotspots(raw, { isCode }).slice(0, TOP);
170
+ if (!hot.length) { console.log(c.yellow(` ${isShallow() ? P.shallow : P.noFixes}\n`)); return; }
171
+
172
+ // Записи каталога, применимые к ЭТОМУ репозиторию. Показывать пробы записей, которые
173
+ // проекту не подходят, значит советовать закрыть дыру, которой нет.
174
+ const facts = await detectFacts();
175
+ const catalog = await readCatalog();
176
+ const entries = catalog.filter((e) => triggerVerdict(e, facts).applies);
177
+
178
+ console.log(c.dim(` ${P.method(hot.length, entries.length)}\n`));
179
+
180
+ let blind = 0;
181
+ for (const { path: rel, fixes } of hot) {
182
+ console.log(` ${c.bold(rel)} ${c.dim(P.fixes(fixes))}`);
183
+ const ext = extname(rel).toLowerCase();
184
+ let probed = 0;
185
+
186
+ for (const e of entries) {
187
+ const sample = await redSampleFor(e.slug, ext);
188
+ if (!sample) continue;
189
+ probed++;
190
+ const dir = await buildProbe(rel, sample);
191
+ let results;
192
+ try { results = runGates(gates, dir); } finally { await rm(dir, { recursive: true, force: true }); }
193
+ const verdict = probeVerdict(results);
194
+ const caught = results.filter((r) => r.code === 1).map((r) => r.name);
195
+ if (verdict === "caught") {
196
+ console.log(` ${c.green("✔")} ${e.intent.padEnd(48)} ${c.dim(P.caught(caught.join(", ")))}`);
197
+ } else if (verdict === "blind") {
198
+ blind++;
199
+ console.log(` ${c.red("✘")} ${e.intent.padEnd(48)} ${c.red(P.blind)}`);
200
+ console.log(c.dim(` ${P.install(`${SELF} add ${e.slug}`)}`));
201
+ } else {
202
+ console.log(` ${c.dim("~")} ${c.dim(e.intent.padEnd(48))} ${c.dim(P.unknown)}`);
203
+ }
204
+ }
205
+ if (!probed) console.log(c.dim(` ${P.noSampleFor(ext || "—")}`));
206
+ }
207
+
208
+ console.log(blind ? c.yellow(`\n ${P.summaryBlind(blind)}\n`) : c.green(`\n ${P.summaryClean}\n`));
209
+
210
+ // Отметка нужна не для отчёта, а для КАДЕНЦИИ: по ней следующий прогон поймёт, что пора.
211
+ // Без неё команда снова становится тем, о чём надо вспомнить.
212
+ await writeMark(commitCount(), blind, hot.map(({ path: p2, fixes }) => `- ${p2} (${P.fixes(fixes)})`));
213
+ }
214
+
215
+ // Состояние пробы для тех, кто только ПОКАЗЫВАЕТ его: прогон и блок для агента.
216
+ //
217
+ // Порог берётся из манифеста (`probe: 250`), умолчание — PROBE_EVERY. Непонятое значение не
218
+ // подменяется умолчанием молча: в манифесте было бы написано одно, а происходило бы другое.
219
+ // Возвращается пометка `badEvery`, и вызывающий говорит о ней вслух.
220
+ async function probeStatus() {
221
+ const man = await readManifest();
222
+ const every = probeEvery(man);
223
+ if (every === null) return { state: "unknown", behind: null, badEvery: String(man?.probe) };
224
+ if (every === 0) return { state: "off", behind: null };
225
+ return probeState(await readMark(), commitCount(), every);
226
+ }
227
+
228
+ export { cmdProbe, probeStatus, scanningGates, isCode };
@@ -55,13 +55,21 @@ function vitalsRows(f) {
55
55
  },
56
56
  ];
57
57
  // Устаревшая версия — не отказ: человек мог закрепить её сознательно, и ронять за это нельзя.
58
+ //
59
+ // СОСТОЯНИЙ ТРИ, А НЕ ДВА. Реестр может ответить ошибкой — не упасть, а вернуть не-200; тогда
60
+ // `latest` пустой, и прежняя ветка печатала «свежая». Посмотреть не смогли, а сказали «всё
61
+ // хорошо»: тот самый грех, против которого написан весь комплект, у него самого. Найдено
62
+ // аудитом фич 2026-09-09.
58
63
  if (f.version) {
64
+ const latest = String(f.version.latest || "");
59
65
  rows.push({
60
66
  key: "version",
61
67
  ok: null,
62
- detail: f.version.latest && f.version.latest !== f.version.current
63
- ? t.versionOld(f.version.latest, f.version.current)
64
- : t.versionOk(f.version.current),
68
+ detail: !latest
69
+ ? t.versionUnknown(f.version.current)
70
+ : latest !== f.version.current
71
+ ? t.versionOld(latest, f.version.current)
72
+ : t.versionOk(f.version.current),
65
73
  });
66
74
  }
67
75
  return rows;
@@ -29,6 +29,7 @@ const enDocs = {
29
29
  sessionOk: "the SessionStart hook hands the state to the agent",
30
30
  sessionNo: (cmd) => `the agent gets no state: ${cmd}`,
31
31
  versionOk: (v) => `${v}, current`,
32
+ versionUnknown: (v) => `${v}, could not reach the registry — freshness unknown`,
32
33
  versionOld: (l, cur) => `${l} is out, you have ${cur}`,
33
34
  },
34
35
  // Блок краткого вывода переехал сюда 2026-09-09: терминальный каталог снова перерос
@@ -66,6 +67,13 @@ const enDocs = {
66
67
  runRed: (when, names) => `Last run ${when} — RED: ${names}.`,
67
68
  andMore: (n) => `and ${n} more`,
68
69
  skipped: (n) => `Not run: ${n} — the tool is absent on this machine, their state is unknown.`,
70
+ probeNever: "No coverage probe has run — what is covered by nothing here is UNKNOWN. That is not \"covered\": `aqk probe`.",
71
+ probeBlind: (n, behind) =>
72
+ `Covered by nothing: ${n} defect classes in the places people most often come back to fix` +
73
+ (behind ? ` (the probe is ${behind} commits behind)` : "") + ". Details: `aqk probe`.",
74
+ probeClean: (behind) =>
75
+ "Coverage probe: in the places probed, every applicable class is caught by something" +
76
+ (behind ? ` (${behind} commits behind)` : "") + ".",
69
77
  ratchets: (list) => `Ratchets: ${list}. The list may only get shorter, never longer.`,
70
78
  where: (entry) => `The rulebook: ${entry}. What proves a diff: \`aqk report --since main\`.`,
71
79
  mapTitle: "WHAT THIS TOOL CAN DO. The full list of commands — not a retelling, the same list\nthe help is built from:",