agent-quality-kit 0.8.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 (76) hide show
  1. package/README.md +154 -12
  2. package/README.ru.md +185 -27
  3. package/kit/docs/ai/index.md +1 -0
  4. package/kit/docs/ai/project-baseline.md +14 -0
  5. package/kit/docs/api-e2e.md +214 -0
  6. package/kit/docs/ready-made-rules.md +188 -0
  7. package/kit/gates/README.md +22 -0
  8. package/kit/gates/api-contract-has-arbiter/README.md +63 -0
  9. package/kit/gates/api-contract-has-arbiter/check.sh +117 -0
  10. package/kit/gates/api-contract-has-arbiter/gate.yml +15 -0
  11. package/kit/gates/api-contract-has-arbiter/green/.github/workflows/ci.yml +12 -0
  12. package/kit/gates/api-contract-has-arbiter/green/openapi.yaml +18 -0
  13. package/kit/gates/api-contract-has-arbiter/red/.github/workflows/ci.yml +11 -0
  14. package/kit/gates/api-contract-has-arbiter/red/openapi.yaml +18 -0
  15. package/kit/gates/ci-actually-fails/check.sh +9 -1
  16. package/kit/gates/color-from-token/check.sh +5 -1
  17. package/kit/gates/commit-explains-itself/check.sh +15 -0
  18. package/kit/gates/complexity-limit/red/deep.go +17 -0
  19. package/kit/gates/complexity-limit/red/deep.rs +17 -0
  20. package/kit/gates/gate-not-weakened/red/suppress.go +5 -0
  21. package/kit/gates/gate-not-weakened/red/suppress.rs +3 -0
  22. package/kit/gates/lesson-has-outcome/check.sh +5 -1
  23. package/kit/gates/mcp-server-resolves/README.md +62 -0
  24. package/kit/gates/mcp-server-resolves/check.sh +110 -0
  25. package/kit/gates/mcp-server-resolves/gate.yml +18 -0
  26. package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
  27. package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
  28. package/kit/gates/protection-not-removed/README.md +67 -0
  29. package/kit/gates/protection-not-removed/check.sh +92 -0
  30. package/kit/gates/protection-not-removed/gate.yml +10 -0
  31. package/kit/gates/protection-not-removed/green/.aqk.yml +7 -0
  32. package/kit/gates/protection-not-removed/green/gates-declared.txt +4 -0
  33. package/kit/gates/protection-not-removed/red/.aqk.yml +7 -0
  34. package/kit/gates/protection-not-removed/red/gates-declared.txt +4 -0
  35. package/kit/gates/secrets-not-in-code/red/leak.go +9 -0
  36. package/kit/gates/secrets-not-in-code/red/leak.rs +5 -0
  37. package/kit/gates/todo-without-task/red/later.go +6 -0
  38. package/kit/gates/todo-without-task/red/later.rs +4 -0
  39. package/llms.txt +25 -4
  40. package/package.json +3 -6
  41. package/tool/commands/context.mjs +37 -6
  42. package/tool/commands/doctor.mjs +139 -16
  43. package/tool/commands/probe.mjs +228 -0
  44. package/tool/commands/project.mjs +18 -2
  45. package/tool/commands/prove.mjs +1 -0
  46. package/tool/commands/vitals.mjs +167 -0
  47. package/tool/i18n/en-docs.mjs +48 -0
  48. package/tool/i18n/en-gates.mjs +309 -0
  49. package/tool/i18n/en.mjs +26 -279
  50. package/tool/i18n/index.mjs +36 -3
  51. package/tool/i18n/ru-docs.mjs +48 -0
  52. package/tool/i18n/ru-gates.mjs +311 -0
  53. package/tool/i18n/ru.mjs +26 -278
  54. package/tool/lib/banner.mjs +59 -0
  55. package/tool/lib/brief.mjs +192 -0
  56. package/tool/lib/cadence.mjs +57 -0
  57. package/tool/lib/core.mjs +3 -0
  58. package/tool/lib/history.mjs +82 -0
  59. package/tool/lib/manifest.mjs +146 -15
  60. package/tool/lib/prove.mjs +11 -1
  61. package/tool/lib/repo.mjs +43 -3
  62. package/tool/program.mjs +33 -0
  63. package/tool/selfcheck/smoke/_fixture.mjs +89 -0
  64. package/tool/selfcheck/smoke/api-contract.test.mjs +79 -0
  65. package/tool/selfcheck/smoke/commit-report.test.mjs +47 -0
  66. package/tool/selfcheck/smoke/verdict.test.mjs +40 -0
  67. package/tool/selfcheck/smoke.sh +528 -6
  68. package/tool/selfcheck/units-banner.mjs +65 -0
  69. package/tool/selfcheck/units-brief.mjs +97 -0
  70. package/tool/selfcheck/units-cadence.mjs +69 -0
  71. package/tool/selfcheck/units-context.mjs +3 -1
  72. package/tool/selfcheck/units-level.mjs +147 -1
  73. package/tool/selfcheck/units-probe.mjs +100 -0
  74. package/tool/selfcheck/units-repo.mjs +164 -0
  75. package/tool/selfcheck/units-vitals.mjs +81 -0
  76. package/tool/selfcheck/units.mjs +3 -75
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env sh
2
+ # Набор объявленных гейтов может только РАСТИ. Убрать гейт можно, но не молча.
3
+ #
4
+ # ЗАЧЕМ. Комплект ловит агента, когда тот выключает сигнал: `# noqa`, `pytest || true`,
5
+ # ослабленный тест. А главный рубильник — сам манифест — не сторожил никто. Замер 2026-09-09:
6
+ # четыре гейта и настоящий секрет в коде дают код 1; убираешь ОДНУ строку из `.aqk.yml` —
7
+ # `Порог AQK-1 пройден`, код 0, секрет на месте, файлы гейта на диске. Ни `doctor`, ни `report`,
8
+ # ни `vitals`, ни блок состояния для агента этого не заметили.
9
+ #
10
+ # Это `pytest || true` этажом выше. И бьёт сильнее обычного обхода: `# noqa` виден в коде, а
11
+ # удалённая строка — это ОТСУТСТВИЕ, а отсутствие не рецензируют.
12
+ #
13
+ # ЧТО ЭТО НЕ ЗАПРЕЩАЕТ. Снять гейт по-прежнему можно — но названно. Причина пишется в реестре
14
+ # после `#`, как `deprecated` обязан нести `superseded_by`: решение остаётся за человеком и
15
+ # перестаёт быть невидимым.
16
+ DIR="${1:-.}"
17
+ MAN="$DIR/.aqk.yml"
18
+ [ -f "$MAN" ] || { echo "нет .aqk.yml — проверять нечего"; exit 0; }
19
+
20
+ MANTEXT="$(tr -d '\r' < "$MAN")"
21
+
22
+ # Имена гейтов из блока gates:. Пустая команда защиты не даёт — такие не считаем объявленными.
23
+ DECLARED="$(printf '%s\n' "$MANTEXT" \
24
+ | awk '/^gates:/{g=1;next} /^[A-Za-z]/{g=0} g && /^[[:space:]]+[A-Za-z0-9_-]+:[[:space:]]*[^[:space:]]/{print}' \
25
+ | sed 's/^[[:space:]]*\([A-Za-z0-9_-]*\):.*/\1/' | LC_ALL=C sort -u)"
26
+ [ -z "$DECLARED" ] && { echo "гейтов не объявлено — сторожить нечего"; exit 0; }
27
+
28
+ # Кавычки снимаются, и ПУСТОЕ значение считается отсутствующим. `init` кладёт `ratchets: ""`
29
+ # намеренно — пустое поле честнее заглушки, — и без снятия кавычек путь получался «""/…»:
30
+ # сообщение с таким путём человек не может выполнить. Найдено первым же прогоном в чужом
31
+ # проекте, а не на нашем репозитории, где поле заполнено.
32
+ RDIR="$(printf '%s\n' "$MANTEXT" | sed -n 's/^ratchets:[[:space:]]*//p' | head -1 \
33
+ | sed 's/^"//; s/"$//; s/^'"'"'//; s/'"'"'$//; s/[[:space:]]*$//')"
34
+ [ -z "$RDIR" ] && RDIR="ratchets"
35
+ REG="$DIR/$RDIR/gates-declared.txt"
36
+ REL="$RDIR/gates-declared.txt"
37
+
38
+ if [ ! -f "$REG" ]; then
39
+ # Реестра нет. Два разных случая, и путать их нельзя: снимка ещё не снимали — или его
40
+ # УДАЛИЛИ, что и есть тот самый обход, только другим файлом. Свидетель — git: если у пути
41
+ # есть история, файл существовал.
42
+ if git -C "$DIR" log -1 --format=%H -- "$REL" 2>/dev/null | grep -q .; then
43
+ echo "$REL был в истории и удалён — снимок объявленной защиты уничтожен"
44
+ echo " почини: верни файл (git checkout -- $REL). Удаление снимка снимает и саму проверку —"
45
+ echo " почини: это тот же обход, что удаление гейта, только через соседний файл."
46
+ exit 1
47
+ fi
48
+ mkdir -p "$DIR/$RDIR" || { echo "не создать $RDIR"; exit 2; }
49
+ printf '# Снимок объявленной защиты. Набор может только РАСТИ.\n' > "$REG"
50
+ printf '# Убрал гейт — напиши причину после # в его строке, иначе проверка краснеет.\n' >> "$REG"
51
+ printf '%s\n' "$DECLARED" >> "$REG"
52
+ echo "снят снимок объявленной защиты: $(printf '%s\n' "$DECLARED" | wc -l | tr -d ' ') гейтов → $REL"
53
+ echo " почини: закоммить этот файл — без него проверка не знает, что защита была."
54
+ exit 0
55
+ fi
56
+
57
+ # Имя без причины — строка реестра, где после имени НЕТ решётки. Имя с причиной — снятое
58
+ # осознанно; печатаем его отдельно, но прогон не роняем.
59
+ GONE=""
60
+ NAMED=""
61
+ while IFS= read -r LINE; do
62
+ case "$LINE" in ''|'#'*) continue ;; esac
63
+ NAME=$(printf '%s' "$LINE" | sed 's/[[:space:]]*#.*//; s/[[:space:]]*$//')
64
+ [ -z "$NAME" ] && continue
65
+ printf '%s\n' "$DECLARED" | grep -qx "$NAME" && continue
66
+ case "$LINE" in
67
+ *'#'*) NAMED="$NAMED $NAME" ;;
68
+ *) GONE="$GONE $NAME" ;;
69
+ esac
70
+ done < "$REG"
71
+
72
+ [ -n "$NAMED" ] && echo "совет: снято осознанно и названо в $REL:$NAMED"
73
+
74
+ if [ -n "$GONE" ]; then
75
+ for N in $GONE; do
76
+ echo "$REL: гейт «$N» был объявлен и исчез из .aqk.yml"
77
+ done
78
+ echo " почини: верни строку в gates: — либо, если снял намеренно, напиши причину в $REL"
79
+ echo " почини: после имени через #, например «$N # снят: закрыт гейтом lint»."
80
+ exit 1
81
+ fi
82
+
83
+ # Всё на месте — дописываем новые. Набор растёт сам; укоротить его молча нельзя.
84
+ NEW=""
85
+ for N in $DECLARED; do
86
+ sed 's/[[:space:]]*#.*//; s/[[:space:]]*$//' "$REG" | grep -qx "$N" || NEW="$NEW $N"
87
+ done
88
+ if [ -n "$NEW" ]; then
89
+ for N in $NEW; do printf '%s\n' "$N" >> "$REG"; done
90
+ echo "совет: в снимок дописаны новые гейты:$NEW"
91
+ fi
92
+ exit 0
@@ -0,0 +1,10 @@
1
+ intent: объявленная защита не исчезает молча — набор гейтов в манифесте может только расти
2
+ intent_en: declared protection does not vanish silently — the set of gates in the manifest may only grow
3
+
4
+ trigger:
5
+ has_gates: true
6
+
7
+ recipes:
8
+ any: bash {gate}/check.sh {dir}
9
+
10
+ proof: incidents/README.md — «2026-09-09 сигнализация построена, выключатель оставлен снаружи без пломбы»
@@ -0,0 +1,7 @@
1
+ aqk: 1
2
+ entry:
3
+ - AGENTS.md
4
+ rules: .aqk/rules
5
+ ratchets: .
6
+ gates:
7
+ lint: "true"
@@ -0,0 +1,4 @@
1
+ # Снимок объявленной защиты. Набор может только РАСТИ.
2
+ # Убрал гейт — напиши причину после # в его строке, иначе проверка краснеет.
3
+ lint
4
+ secrets-not-in-code # снят 2026-09-09: закрыт гейтом lint, см. covers в манифесте
@@ -0,0 +1,7 @@
1
+ aqk: 1
2
+ entry:
3
+ - AGENTS.md
4
+ rules: .aqk/rules
5
+ ratchets: .
6
+ gates:
7
+ lint: "true"
@@ -0,0 +1,4 @@
1
+ # Снимок объявленной защиты. Набор может только РАСТИ.
2
+ # Убрал гейт — напиши причину после # в его строке, иначе проверка краснеет.
3
+ lint
4
+ secrets-not-in-code
@@ -0,0 +1,9 @@
1
+ package config
2
+
3
+ // Ключ в коде: он уедет в историю git и останется там навсегда.
4
+ //
5
+ // Строка намеренно КОРОЧЕ настоящего ключа Stripe и повторяет ту, что лежит в settings.py:
6
+ // правдоподобный ключ блокирует защита GitHub от секретов, и образец нельзя отправить в
7
+ // репозиторий вовсе. Красный образец обязан быть узнаваем НАШИМ гейтом и не узнаваем чужими
8
+ // сканерами — проверено 2026-09-09: push отклонён на «Stripe API Key».
9
+ const StripeKey = "sk_live_51HxxQwErTyUiOpAsDfGh"
@@ -0,0 +1,5 @@
1
+ // Ключ в коде: он уедет в историю git и останется там навсегда.
2
+ //
3
+ // Строка намеренно короче настоящего ключа Stripe — см. комментарий в leak.go: правдоподобный
4
+ // ключ блокирует защита GitHub от секретов, и образец не отправить.
5
+ pub const STRIPE_KEY: &str = "sk_live_51HxxQwErTyUiOpAsDfGh";
@@ -0,0 +1,6 @@
1
+ package svc
2
+
3
+ func Send(to string) error {
4
+ // TODO: переписать на очередь
5
+ return deliver(to)
6
+ }
@@ -0,0 +1,4 @@
1
+ pub fn send(to: &str) -> bool {
2
+ // FIXME: переписать на очередь
3
+ deliver(to)
4
+ }
package/llms.txt CHANGED
@@ -25,8 +25,9 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
25
25
  - Show only what a diff introduced, so a legacy repo is usable from day one: `doctor --run --since main`
26
26
  - Prove the gates actually catch defects: `npx agent-quality-kit prove` — every *provable* gate is
27
27
  run against its own red and green sample and must go red on the first and stay quiet on the
28
- second. A gate with no samples, with samples written for another recipe, or whose command takes
29
- no directory is reported as unprovable and named; the verdict is "nothing proven is broken, and
28
+ second. A gate with no samples, with samples written for another recipe, whose command takes
29
+ no directory, or whose `requires:` program is not installed is reported as unprovable and
30
+ named — never as broken; the verdict is "nothing proven is broken, and
30
31
  at least one gate is proven". Level AQK-2 and the badge depend on this, not on the presence of
31
32
  files
32
33
  - See what proves a diff, file by file: `npx agent-quality-kit report --since main` — each changed
@@ -41,6 +42,18 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
41
42
  adds the command map and the rulebook verbatim (~7000 tokens): a deliberate trade, chosen by
42
43
  the owner after the objection about long inputs, on the grounds that an agent reads files
43
44
  poorly and the tokens are the price of it not guessing
45
+ - Check that the kit's own wiring is actually connected: `npx agent-quality-kit vitals` — are the
46
+ tools the declared gates need installed, is the hook present in `.git/hooks` (a line in the
47
+ config is an intention, not a guard), does the agent receive the state, is the version current.
48
+ Four states, not two: connected · BROKEN · not connected and that is a choice · could not look.
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
44
57
  - See what you told the agent and never wrote down: `npx agent-quality-kit learn` — reads Claude Code
45
58
  transcripts for this project on this machine and prints rule candidates missing from the entry
46
59
  point. Current project only, terminal only, writes nothing, always exits 0
@@ -48,7 +61,14 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
48
61
  - As a pre-commit hook: `repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit` with
49
62
  `id: aqk` (blocking), `aqk-doctor` (read-only) or `aqk-baseline`. pre-commit installs the
50
63
  package itself; there are no dependencies to pull in.
51
- - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.8.0` with `min: 1`
64
+ - Without Node at all (a Python, Go or Rust project where nobody installed it):
65
+ `docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/work" ghcr.io/arsen-ask-lx/aqk doctor`.
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
68
+ the files `init` writes are owned by root, so you cannot edit your own manifest. Debian-based
69
+ on purpose: the gates are `sh`, `grep`, `awk`, `find` — under alpine's busybox they behave
70
+ differently, and an image where the gates behave differently is worse than no image
71
+ - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.10.0` with `min: 1`
52
72
  (https://github.com/marketplace/actions/agent-quality-kit-aqk)
53
73
 
54
74
  ## What makes it different
@@ -64,7 +84,8 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
64
84
  ## Files it reads and writes
65
85
 
66
86
  - `AGENTS.md` — what the agent reads first (the entry point; `CLAUDE.md` and others work too)
67
- - `.aqk.yml` — the manifest: entry, rules, gates as commands, samples, ratchets, lessons
87
+ - `.aqk.yml` — the manifest: entry, rules, docs, lang, gates as commands, covers (what a
88
+ declared gate already holds, so it is not reported as debt), samples, ratchets, lessons
68
89
  - `.aqkignore` — paths the scanning checks must not read (brought-in code, vendored, generated)
69
90
 
70
91
  ## Documentation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-quality-kit",
3
- "version": "0.8.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": {
@@ -43,11 +43,8 @@
43
43
  ],
44
44
  "knip": {
45
45
  "entry": [
46
- "tool/selfcheck/units.mjs",
47
- "tool/selfcheck/units-level.mjs",
48
- "tool/selfcheck/units-evidence.mjs",
49
- "tool/selfcheck/units-learn.mjs",
50
- "tool/selfcheck/units-context.mjs",
46
+ "tool/selfcheck/units*.mjs",
47
+ "tool/selfcheck/smoke/*.test.mjs",
51
48
  "tool/selfcheck/lifecycle.mjs"
52
49
  ],
53
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
  // и свод, влитый целиком, даёт правило, которое в контексте есть и не выполняется. Ответ
@@ -130,11 +143,15 @@ function runIsStale(when) {
130
143
  // `SessionStart` — принадлежность одного Claude Code, и класть его всем подряд значило бы
131
144
  // объявить нейтральность и нарушить её в первой же команде.
132
145
  //
133
- // БЕЗ MATCHER НАМЕРЕННО. Справочник на сайте перечисляет у SessionStart значения matcher
134
- // (startup, resume, clear, compact), а таблица событий, ВШИТАЯ в установленную версию 2.1.263,
135
- // показывает в колонке matcher прочерк. Одно из двух неверно, и выяснить это гаданием нельзя.
136
- // Хук без matcher верен при любом из двух чтений: где matcher поддержан — сработает на всех
137
- // источниках, где не поддержан на всех тоже. Проверено чтением бинаря, не памятью.
146
+ // БЕЗ MATCHER НАМЕРЕННО, и теперь по установленной причине, а не из осторожности.
147
+ // 2026-09-08 расхождение разрешено: в бинаре установленной версии 2.1.263 лежит буквальное
148
+ // перечисление источников "startup","resume","clear","compact","fork" и вызов
149
+ // {kind:"session-start", source:"startup"}. То есть у SessionStart источники ЕСТЬ, и `compact`
150
+ // среди них: хук срабатывает и после сжатия контекста. Это важнее, чем кажется: сжатие —
151
+ // ровно тот момент, когда состояние вылетает из окна, и без повторного срабатывания вся
152
+ // затея работала бы до первого /compact.
153
+ // Matcher не ставим потому, что нужны ВСЕ источники: и старт, и продолжение, и очистка, и
154
+ // сжатие. Отсутствие matcher означает «на любой источник» — это и требуется.
138
155
  const HOOK_FILE = [".claude", "settings.json"];
139
156
 
140
157
  // Команда, которая пойдёт В ОБЩИЙ файл настроек, а значит и в чужие руки через git. `SELF`
@@ -229,6 +246,20 @@ async function cmdContext(args = []) {
229
246
  if (run) run.stale = runIsStale(run.when);
230
247
  }
231
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
+
232
263
  const ratchets = [];
233
264
  const dir = typeof man?.ratchets === "string" ? man.ratchets.trim() : "";
234
265
  if (dir && (await exists(join(CWD, dir)))) {
@@ -253,7 +284,7 @@ async function cmdContext(args = []) {
253
284
  }
254
285
 
255
286
  console.log(contextBlock({
256
- entry, entryExists: rules !== null, level, rules, run, ratchets, full: fullPart,
287
+ entry, entryExists: rules !== null, level, rules, run, ratchets, probe, full: fullPart,
257
288
  }).join("\n"));
258
289
  }
259
290
 
@@ -4,12 +4,14 @@ 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
6
  import { scopeOutput, splitAdvice, changedFiles } from "../lib/scope.mjs";
7
- import { CWD, PKG_ROOT, TARGET_DIR, SELF, c, exists, die } from "../lib/core.mjs";
8
- import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, advisorySet, layoutChecks } from "../lib/manifest.mjs";
7
+ import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die } from "../lib/core.mjs";
8
+ import { cmdProbe, probeStatus } from "./probe.mjs";
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
- import { detectFacts, readCatalog, triggerVerdict, recipeFor } from "../lib/repo.mjs";
11
+ import { detectFacts, readCatalog, triggerVerdict, browserServerAdvice } from "../lib/repo.mjs";
11
12
  import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
12
13
  import { L } from "../i18n/index.mjs";
14
+ import { beginBrief, finishBrief } from "../lib/brief.mjs";
13
15
 
14
16
  // Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
15
17
  // место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
@@ -50,11 +52,16 @@ async function reportCatalog(man, facts) {
50
52
  const catalog = await readCatalog();
51
53
  if (!catalog.length) return;
52
54
 
53
- const held = [], todo = [], skip = [];
55
+ // Четвёртая корзина, а не третья: «закрыто другим арбитром» — это НЕ «не поставлено».
56
+ // Пока их считали вместе, вывод каждый прогон называл долгом то, что уже держит biome или
57
+ // ruff. Просьба первого чужого пользователя; она же — наша собственная норма про вывод.
58
+ const { covered, unknownGates } = coversOf(man);
59
+ const held = [], todo = [], skip = [], byOther = [];
54
60
  for (const rec of catalog) {
55
61
  const v = triggerVerdict(rec, facts);
56
62
  if (!v.applies) skip.push([rec, v.why]);
57
63
  else if (facts.gateKeys.includes(rec.slug)) held.push(rec);
64
+ else if (covered.has(rec.slug)) byOther.push([rec, covered.get(rec.slug)]);
58
65
  else todo.push(rec);
59
66
  }
60
67
 
@@ -72,14 +79,50 @@ async function reportCatalog(man, facts) {
72
79
  console.log(` ${c.yellow("✘")} ${rec.slug.padEnd(22)} ${rec.intent || ""}`);
73
80
  console.log(c.dim(` ${L.doctor.install(`${SELF} add ${rec.slug}`)}`));
74
81
  }
82
+ if (byOther.length) {
83
+ console.log(c.dim(`\n ${L.doctor.coveredBy(byOther.length)}`));
84
+ for (const [rec, gate] of byOther) console.log(c.dim(` ~ ${rec.slug.padEnd(22)} ${L.doctor.coveredByGate(gate)}`));
85
+ }
86
+ // Гейт, которого нет в gates:, не закрывает ничего — и молчать об этом нельзя: человек
87
+ // считает запись закрытой, а её не держит никто. Называется поимённо, жёлтым.
88
+ if (unknownGates.length) {
89
+ console.log(c.yellow(`\n ${L.doctor.coversUnknown(unknownGates.join(", "))}`));
90
+ }
91
+ // Заявка «эту запись держит наш линтер» сверяется с кодами правил из рецепта записи.
92
+ // Замерено на живом ruff.toml: девятнадцать групп правил, а print() не ловится — и заявка
93
+ // сняла бы запись с долга, не закрыв её ничем.
94
+ let linterCfg = "";
95
+ for (const f of ["ruff.toml", ".ruff.toml", "pyproject.toml", ".eslintrc.json", "eslint.config.js", "eslint.config.mjs", "biome.json"]) {
96
+ try { linterCfg += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
97
+ }
98
+ const unproven = coversUnproven(man, catalog, linterCfg);
99
+ for (const u of unproven) {
100
+ console.log(c.yellow(`\n ${L.doctor.coversUnproven(u.entry, u.gate, u.codes.join(", "))}`));
101
+ console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
102
+ }
103
+ // Не вердикт, а совет: отсутствие браузерного сервера — незанятая возможность, а не дефект.
104
+ // Поэтому строка тусклая и без значка, и её нет у проекта без интерфейса.
105
+ let mcpText = "";
106
+ for (const f of [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".claude/mcp.json"]) {
107
+ try { mcpText += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
108
+ }
109
+ const browser = browserServerAdvice(facts, mcpText);
110
+ if (browser) {
111
+ console.log(c.dim(`\n ${L.doctor.noBrowserServer}`));
112
+ console.log(c.dim(` ${L.doctor.noBrowserServerHow(browser.servers.join(" · "))}`));
113
+ }
75
114
  if (skip.length) {
76
115
  console.log(c.dim(`\n ${L.doctor.notApplicable(skip.length)}`));
77
116
  for (const [rec, why] of skip) console.log(c.dim(` · ${rec.slug.padEnd(22)} ${why}`));
78
117
  }
79
118
  console.log(
80
119
  `\n ${c.bold(L.doctor.total)} ${L.doctor.totalHeld(held.length)}, ${L.doctor.totalTodo(c.yellow(todo.length))}, ` +
120
+ (byOther.length ? `${L.doctor.totalCovered(byOther.length)}, ` : "") +
81
121
  c.dim(L.doctor.totalSkip(skip.length)) + "\n"
82
122
  );
123
+ // Числа отдаются наружу, а не пересчитываются второй раз: два счёта одного и того же
124
+ // расходятся ровно так же, как два списка команд.
125
+ return { held: held.length, todo: todo.length, todoRecs: todo };
83
126
  }
84
127
 
85
128
  // «Гейт объявлен» и «гейт работает» — разные утверждения. Первое читается из манифеста,
@@ -139,14 +182,20 @@ function runGates(man, opts = {}) {
139
182
  }
140
183
  const code = r.status;
141
184
  if (code === 0) {
142
- console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${cmd}`)}`);
185
+ // Совещательный называется и когда он зелёный. Иначе гейт, который уронить прогон НЕ
186
+ // МОЖЕТ, по выводу неотличим от того, который может, — и список `advisory:` в манифесте
187
+ // виден только в тот день, когда он покраснел. Измерено 2026-09-09: зелёный
188
+ // совещательный печатался обычной галочкой, а README обещал, что список назван каждый
189
+ // прогон. Тот же класс, что молчащий гейт, только про сам прибор.
190
+ const quiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
191
+ console.log(` ${c.green("✔")} ${name.padEnd(14)}${quiet} ${c.dim(`${secs}s · ${cmd}`)}`);
143
192
  // Зелёный гейт иногда всё-таки говорит человеку что-то важное: храповик, дошедший до цели,
144
193
  // просит убрать обёртку. Вывод успешного гейта не показывался вовсе, и это сообщение
145
194
  // уходило в никуда — тот же класс, что обрезанный совет у красного, только тише.
146
195
  // Показываем ровно строки с меткой совета: остальной вывод успешной проверки — шум.
147
196
  const okAdvice = splitAdvice(`${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean)).advice;
148
197
  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 });
198
+ results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), out: outAll });
150
199
  } else {
151
200
  const raw = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
152
201
  // Совет отделяется ДО сужения. Иначе он сам попадает под фильтр по путям: сообщение
@@ -165,16 +214,23 @@ function runGates(man, opts = {}) {
165
214
  if (!s.scopable || out.length === 0) {
166
215
  // Гейт печатает вердикт без путей — сузить нечем. Признать его успешным значило бы
167
216
  // выдать провал за тишину; остаётся красным, и причина названа.
168
- console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.exitCode(code))} ${c.dim(`· ${L.doctor.notScopable}`)}`);
169
- failed++;
170
- results.push({ name, cmd, ok: false, secs, code, note: L.doctor.notScopable, out: outAll });
217
+ // Совещательный не роняет прогон НИКОГДА — в том числе здесь. Раньше failed++ стоял
218
+ // безусловно, и гейт, объявленный совещательным, валил сборку с `--since` только
219
+ // потому, что в его выводе нет путей. Измерено 2026-09-09.
220
+ const nsAdv = advisory.has(name);
221
+ const nsMark = nsAdv ? c.yellow("!") : c.red("✘");
222
+ const nsVerdict = nsAdv ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
223
+ console.log(` ${nsMark} ${name.padEnd(14)} ${nsVerdict} ${c.dim(`· ${L.doctor.notScopable}`)}`);
224
+ if (!nsAdv) failed++;
225
+ results.push({ name, cmd, ok: false, secs, code, advisory: nsAdv, note: L.doctor.notScopable, out: outAll });
171
226
  continue;
172
227
  }
173
228
  if (s.findings === 0) {
174
229
  // Долг есть, но не в том, что внёс диф. Зелёный — но с числом спрятанного: молчаливое
175
230
  // «всё хорошо» здесь было бы неправдой.
176
- console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
177
- results.push({ name, cmd, ok: true, secs, scopedAway: out.length, out: outAll });
231
+ const sQuiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
232
+ console.log(` ${c.green("✔")} ${name.padEnd(14)}${sQuiet} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
233
+ results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), scopedAway: out.length, out: outAll });
178
234
  continue;
179
235
  }
180
236
  out = s.kept;
@@ -191,8 +247,19 @@ function runGates(man, opts = {}) {
191
247
  const mark = isAdvisory ? c.yellow("!") : c.red("✘");
192
248
  const verdict = isAdvisory ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
193
249
  console.log(` ${mark} ${name.padEnd(14)} ${verdict} ${c.dim(`· ${secs}s · ${cmd}`)}`);
194
- for (const line of out.slice(0, 3)) console.log(c.dim(` ${line.slice(0, 100)}`));
195
- 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
+ }
196
263
  // Совет тоже не бесконечен: гейт, зовущий помощник шесть раз, печатает его шесть раз.
197
264
  for (const line of alwaysAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
198
265
  results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll });
@@ -200,7 +267,7 @@ function runGates(man, opts = {}) {
200
267
  }
201
268
  // Совещательные, которые покраснели, называются вслух ВСЕГДА. Молчание о них — ровно та
202
269
  // тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
203
- const advisoryFailed = results.filter((x) => x.advisory).map((x) => x.name);
270
+ const advisoryFailed = results.filter((x) => x.advisory && !x.ok).map((x) => x.name);
204
271
  if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
205
272
  return { failed, ran: gates.length, results, advisoryFailed };
206
273
  }
@@ -228,6 +295,8 @@ async function writeRunReport({ version, reached, results }) {
228
295
  }
229
296
 
230
297
  async function cmdDoctor() {
298
+ const brief = process.argv.includes("--brief");
299
+ const buf = brief ? beginBrief() : null;
231
300
  // Версия в шапке — единственное, что привязывает баг-репорт к коммиту, если ставили не из
232
301
  // релиза: без неё "у меня не работает" ничем не отличается от любой другой версии за год.
233
302
  let version = "";
@@ -270,6 +339,15 @@ async function cmdDoctor() {
270
339
 
271
340
  // Опечатка в имени поля означала «поля нет»: вердикт выдавался неверный, а причина молчала.
272
341
  // Называем поле и говорим, какие бывают — иначе человек ищет ошибку в проекте, а она в файле.
342
+ // Строка, которую разбор не понял, называется ПЕРВОЙ и жёлтым: человек видит проверку в
343
+ // файле, а её не существует. До 2026-09-09 такая строка исчезала без слова — найдено
344
+ // случайно, гейтом с кириллическим именем, который «прошёл», не запустившись.
345
+ try {
346
+ const bad = unparsedLines(await readFile(join(CWD, MANIFEST), "utf8"));
347
+ for (const b of bad) console.log(c.yellow(`\n ${L.doctor.manifestUnparsed(b.line, b.text)}`));
348
+ if (bad.length) console.log(c.dim(` ${L.doctor.manifestUnparsedWhy}`));
349
+ } catch { /* манифеста нет — про строки в нём говорить нечего */ }
350
+
273
351
  const unknown = unknownKeys(man);
274
352
  if (unknown.length) {
275
353
  console.log(c.yellow(`\n ${L.doctor.manifestUnknown(unknown)}`));
@@ -326,7 +404,7 @@ async function cmdDoctor() {
326
404
  await reportBaseline(man, facts);
327
405
  process.exit(0);
328
406
  }
329
- await reportCatalog(man, facts);
407
+ const cat = (await reportCatalog(man, facts)) || { held: 0, todo: 0, todoRecs: [] };
330
408
 
331
409
  // «Объявлен» ≠ «работает». Без --run говорим это вслух, а не молчим.
332
410
  const wantRun = process.argv.includes("--run");
@@ -338,6 +416,31 @@ async function cmdDoctor() {
338
416
  gateFailed = run.failed;
339
417
  failedNames = run.results.filter((r) => !r.ok).map((r) => r.name);
340
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
+ }
341
444
  } else if (gates.length) {
342
445
  console.log(
343
446
  c.yellow(` ${L.doctor.declaredNotRun(gates.length)}`) +
@@ -359,9 +462,29 @@ async function cmdDoctor() {
359
462
  else if (!levelOk) line = c.red(` ${L.doctor.thresholdFail(min, now)}\n`);
360
463
  else line = c.red(` ${L.doctor.thresholdGateFail(min, now, failedNames)}\n`);
361
464
  console.log(line);
465
+ await finishBrief(buf, { held: cat.held, todo: cat.todo, level: reached, red: failedNames ? String(failedNames).split(", ").filter(Boolean) : [] }, cat.todoRecs, pass);
362
466
  process.exit(pass ? 0 : 1);
363
467
  }
364
- process.exit(missing || reached < 0 || gateFailed ? 1 : 0);
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
+ }
486
+ await finishBrief(buf, { held: cat.held, todo: cat.todo, level: reached, red: [] }, cat.todoRecs, ok);
487
+ process.exit(ok ? 0 : 1);
365
488
  }
366
489
 
367
490
  // Наружу — только команда. Остальное здесь же и используется: экспорт, который никто не