agent-quality-kit 0.8.0 → 0.9.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 (38) hide show
  1. package/README.md +92 -9
  2. package/README.ru.md +124 -23
  3. package/kit/docs/ai/project-baseline.md +14 -0
  4. package/kit/docs/ready-made-rules.md +103 -0
  5. package/kit/gates/color-from-token/check.sh +5 -1
  6. package/kit/gates/lesson-has-outcome/check.sh +5 -1
  7. package/kit/gates/mcp-server-resolves/README.md +62 -0
  8. package/kit/gates/mcp-server-resolves/check.sh +110 -0
  9. package/kit/gates/mcp-server-resolves/gate.yml +18 -0
  10. package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
  11. package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
  12. package/llms.txt +19 -4
  13. package/package.json +2 -6
  14. package/tool/commands/context.mjs +9 -5
  15. package/tool/commands/doctor.mjs +83 -14
  16. package/tool/commands/project.mjs +18 -2
  17. package/tool/commands/prove.mjs +1 -0
  18. package/tool/commands/vitals.mjs +159 -0
  19. package/tool/i18n/en-docs.mjs +40 -0
  20. package/tool/i18n/en.mjs +18 -0
  21. package/tool/i18n/index.mjs +36 -3
  22. package/tool/i18n/ru-docs.mjs +40 -0
  23. package/tool/i18n/ru.mjs +18 -0
  24. package/tool/lib/banner.mjs +59 -0
  25. package/tool/lib/brief.mjs +192 -0
  26. package/tool/lib/core.mjs +2 -0
  27. package/tool/lib/manifest.mjs +146 -15
  28. package/tool/lib/prove.mjs +11 -1
  29. package/tool/lib/repo.mjs +31 -1
  30. package/tool/program.mjs +26 -0
  31. package/tool/selfcheck/smoke.sh +350 -1
  32. package/tool/selfcheck/units-banner.mjs +65 -0
  33. package/tool/selfcheck/units-brief.mjs +97 -0
  34. package/tool/selfcheck/units-context.mjs +3 -1
  35. package/tool/selfcheck/units-level.mjs +147 -1
  36. package/tool/selfcheck/units-repo.mjs +134 -0
  37. package/tool/selfcheck/units-vitals.mjs +62 -0
  38. package/tool/selfcheck/units.mjs +3 -75
@@ -0,0 +1,62 @@
1
+ # mcp-server-resolves
2
+
3
+ **Что ловит.** Объявленный MCP-сервер, которого на деле нет: команда указывает на несуществующий
4
+ файл, либо версия не закреплена и завтра приедет другая.
5
+
6
+ **Почему это отдельная запись.** Сервер, который не поднялся, агенту **никак не виден**: у него
7
+ просто нет этих инструментов, и он молча работает без них. Ни ошибки, ни строки в журнале.
8
+ Это тот же класс, что хук с опечаткой в имени события: настройка выглядит как возможность и
9
+ возможностью не является.
10
+
11
+ ## Шишка, из которой она выросла
12
+
13
+ 2026-09-08. В проект записали настройку `chrome-devtools-mcp` — выглядела безупречно:
14
+
15
+ ```json
16
+ "chrome-devtools": { "command": "npx", "args": ["-y", "chrome-devtools-mcp@1.9.0"] }
17
+ ```
18
+
19
+ Сервер был мёртв. Puppeteer подхватывал Windows-овый Chrome из `/mnt/c/` и падал с
20
+ `Target closed`. Узнали только потому, что запустили руками и прочитали журнал — обычно так не
21
+ делают: записали конфиг, увидели «сохранено», пошли дальше.
22
+
23
+ ## Замер
24
+
25
+ Двадцать настоящих настроек `.mcp.json`, взятых поиском по GitHub 2026-09-08.
26
+
27
+ | | |
28
+ |---|---|
29
+ | конфигов проверено | 20 |
30
+ | покраснело | 9 |
31
+ | находок | 14 |
32
+ | ложных срабатываний | **0** |
33
+
34
+ Все находки — чужие серверы, которые тянутся незакреплёнными при каждом запуске:
35
+ `@modelcontextprotocol/server-github`, `firecrawl-mcp`, `mcp-server-postgres`, `@upstash/context7-mcp`.
36
+ Серверу с таким доступом «сегодня одна версия, завтра другая» — открытая дверь в цепочке поставок.
37
+
38
+ Замер тут же нашёл и два ложных срабатывания, оба починены до объявления записи рабочей:
39
+
40
+ 1. `npx tsx rag/src/index.ts` — сервер **свой**, лежит в репозитории и уже под версией. Требовать
41
+ закрепить `tsx` значит краснеть на нормальном укладе, а такой гейт выключают в первый день.
42
+ 2. Починка первого съела настоящую находку: `@meridian/docs-mcp` — имя пакета со слэшем, а слэш
43
+ считался признаком локального файла. Видно было только построчным сравнением до и после.
44
+
45
+ ## Готовый аналог
46
+
47
+ Готового аналога нет. Проверено 2026-09-08 поиском: линтеры настроек агента существуют
48
+ (`claudelint`, `agent-lint`), но смотрят на права и хуки, а не на MCP. Ближайшее по смыслу —
49
+ наш собственный `deps-are-pinned`: там то же правило про версии, но для зависимостей сборки,
50
+ и файла `.mcp.json` он не видит.
51
+
52
+ ## Чего НЕ ловит
53
+
54
+ - **Не запускает серверы.** Мёртвый по любой другой причине сервер — как в шишке выше, где путь
55
+ и версия были верны, а падал сам браузер, — эта проверка не увидит. Запуск чужих команд на
56
+ каждый коммит означает скачивание пакетов и минуты ожидания; такую проверку выключают сразу.
57
+ Живое рукопожатие — то, что делают руками при подключении сервера, и это записано в методичке,
58
+ а не в гейте.
59
+ - **Не судит об относительных путях.** `"command": "./bin/server"` не проверяется: рабочий
60
+ каталог сервера задаётся полем `cwd`, и проверить существование без запуска нельзя.
61
+ - **Не знает, что пакет существует в реестре.** Это делает `no-phantom-package`, но только для
62
+ того, что упомянуто в документации.
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env sh
2
+ # Объявленный MCP-сервер, которого на деле нет: команда указывает на файл, которого не
3
+ # существует, либо версия не закреплена и завтра приедет другая.
4
+ #
5
+ # ЗАЧЕМ. Сервер, который не поднялся, агенту НИКАК не виден: у него просто нет этих
6
+ # инструментов, и он молча работает без них. Ни ошибки, ни строки в журнале — та же тишина,
7
+ # что у хука с опечаткой в имени события. Настройка выглядит как возможность и возможностью
8
+ # не является.
9
+ #
10
+ # ПРОВЕРЕНО НА СЕБЕ 2026-09-08. Записали в проект настройку chrome-devtools-mcp, выглядела
11
+ # безупречно. Сервер был мёртв: puppeteer подхватывал Windows-овый Chrome из /mnt/c/ и падал
12
+ # с «Target closed». Узнали только потому, что запустили руками и прочитали журнал; обычно
13
+ # так не делают — записали конфиг, увидели «сохранено», пошли дальше.
14
+ #
15
+ # ЧТО ЭТА ПРОВЕРКА НЕ ДЕЛАЕТ. Она НЕ запускает серверы. Запуск — это исполнение чужих команд
16
+ # на каждый коммит, скачивание пакетов и минуты ожидания; такую проверку выключают в первый
17
+ # день. Здесь ловится только то, что видно без запуска, и этого достаточно для двух самых
18
+ # частых смертей: файла нет и версия плавает.
19
+ DIR="${1:-.}"
20
+ SKIP_LIB="$(dirname "$0")/../_skip.sh"
21
+ if [ ! -f "$SKIP_LIB" ]; then
22
+ echo "рядом с проверкой нет _skip.sh — обход не собран, проверка не состоялась"
23
+ echo " почини: скопируй гейт вместе с файлом kit/gates/_skip.sh, он общий на весь каталог"
24
+ exit 2
25
+ fi
26
+ . "$SKIP_LIB"
27
+
28
+ OUT=""
29
+ for F in "$DIR/.mcp.json" "$DIR/.cursor/mcp.json" "$DIR/.vscode/mcp.json" "$DIR/.claude/mcp.json"; do
30
+ [ -f "$F" ] || continue
31
+ RES=$(awk -v file="$F" -v dir="$DIR" '
32
+ # Номера строк с командой копятся отдельно от текста: разбор идёт в END по всему файлу
33
+ # одной строкой (иначе минифицированный JSON не разобрать), а находку надо привязать к
34
+ # строке — без неё `--since` не сверит путь с дифом и гейт зазеленеет на чужом дифе.
35
+ /"command"[ \t]*:/ { cline[++ci] = NR }
36
+ { buf = buf $0 " " }
37
+ END {
38
+ n = split(buf, part, /"command"[ \t]*:/)
39
+ for (i = 2; i <= n; i++) {
40
+ chunk = part[i]
41
+ if (!match(chunk, /"[^"]*"/)) continue
42
+ cmd = substr(chunk, RSTART + 1, RLENGTH - 2)
43
+ line = cline[i - 1] ? cline[i - 1] : 1
44
+
45
+ # 1. Абсолютный путь, которого нет. Самая частая смерть у пакетов, чья версия вшита
46
+ # в имя файла: обновились — старый файл удалён, сервер умер, никто не заметил.
47
+ if (substr(cmd, 1, 1) == "/") {
48
+ if (system("test -e \"" cmd "\"") != 0)
49
+ print file ":" line ": команда сервера не существует: " cmd
50
+ continue
51
+ }
52
+
53
+ # 2. Скачивающий запуск без точной версии. `@latest` и голое имя означают «сегодня
54
+ # одно, завтра другое», а для сервера, которому доверен браузер и файлы, это дверь
55
+ # в цепочке поставок. Правило то же, что у нас для зависимостей.
56
+ if (cmd == "npx" || cmd == "uvx" || cmd == "pipx" || cmd == "bunx" || cmd == "pnpm") {
57
+ args = ""
58
+ if (match(chunk, /"args"[ \t]*:[ \t]*\[[^]]*\]/)) args = substr(chunk, RSTART, RLENGTH)
59
+ if (args == "") continue
60
+ if (args ~ /@latest/) {
61
+ print file ":" line ": версия сервера не закреплена (@latest): " cmd
62
+ continue
63
+ }
64
+ # Имя пакета — первый довод, не начинающийся с дефиса. Точная версия пишется как
65
+ # name@1.2.3 (npm) или name==1.2.3 (python); без неё запуск невоспроизводим.
66
+ m = args
67
+ gsub(/"args"[ \t]*:[ \t]*\[/, "", m); gsub(/\]/, "", m)
68
+ k = split(m, tok, ",")
69
+
70
+ # СЕРВЕР ИЗ СВОЕГО РЕПОЗИТОРИЯ — не находка. `npx tsx rag/src/index.ts` запускает
71
+ # СВОЙ файл, а npx здесь только запускалка: код сервера лежит в репозитории и уже
72
+ # под версией — той же, что и весь проект. Требовать закрепить `tsx` значит краснеть
73
+ # на нормальном укладе, а такой гейт выключают в первый день. Найдено замером по
74
+ # двадцати чужим настройкам с GitHub 2026-09-08: две из десяти находок были ровно
75
+ # этим. Тот же класс, что четыре ложных срабатывания на чужом коде до этого.
76
+ local_entry = 0
77
+ for (j = 1; j <= k; j++) {
78
+ t = tok[j]; gsub(/^[ \t"]+|[ \t"]+$/, "", t)
79
+ # Признак локального входа — расширение файла или явно относительный путь.
80
+ # Просто «есть слэш» не годится: `@scope/name` — это имя пакета в npm, и первая
81
+ # версия этой проверки съела настоящую находку `npx @meridian/docs-mcp`. Поймано
82
+ # тем же замером по двадцати чужим настройкам: находок стало восемь вместо девяти,
83
+ # и пропажу было видно только построчным сравнением до и после.
84
+ if (t ~ /\.(ts|js|mjs|cjs|py|rb|sh)$/ || t ~ /^\// || t ~ /^\.\// || t ~ /^\.\.\//) { local_entry = 1; break }
85
+ }
86
+ if (local_entry) continue
87
+
88
+ for (j = 1; j <= k; j++) {
89
+ t = tok[j]; gsub(/^[ \t"]+|[ \t"]+$/, "", t)
90
+ if (t == "" || substr(t, 1, 1) == "-") continue
91
+ if (t ~ /@[0-9]/ || t ~ /==[0-9]/) break
92
+ print file ":" line ": версия сервера не закреплена: " cmd " " t
93
+ break
94
+ }
95
+ }
96
+ }
97
+ }
98
+ ' "$F")
99
+ [ -n "$RES" ] && OUT="${OUT}${RES}
100
+ "
101
+ done
102
+
103
+ OUT=$(printf '%s' "$OUT" | own_samples_filter "$DIR" | sed '/^$/d')
104
+ if [ -n "$OUT" ]; then
105
+ printf '%s\n' "$OUT"
106
+ echo " почини: закрепи точную версию (пакет@1.2.3) и убедись, что файл команды существует."
107
+ echo " мёртвый сервер агенту не виден: он просто работает без этих инструментов и молчит."
108
+ exit 1
109
+ fi
110
+ exit 0
@@ -0,0 +1,18 @@
1
+ intent: объявленный MCP-сервер существует и закреплён по версии — иначе агент молча работает без него
2
+ intent_en: a declared MCP server exists and is version-pinned — otherwise the agent silently works without it
3
+
4
+ # Только там, где агенту подключали внешние инструменты. В проекте без `.mcp.json` проверять
5
+ # нечего, а запись, показанная не тому, стоит доверия всему каталогу.
6
+ trigger:
7
+ has_mcp: true
8
+
9
+ recipes:
10
+ any: bash {gate}/check.sh {dir}
11
+
12
+ samples_for: any
13
+
14
+ proof: incidents/README.md, 2026-09-08 «конфиг был написан верно, а сервер мёртв» — в проект
15
+ записали настройку `chrome-devtools-mcp`, выглядела безупречно; сервер падал с «Target closed»,
16
+ потому что puppeteer подхватывал Windows-овый Chrome из `/mnt/c/`. Узнали только запуском
17
+ руками и чтением журнала: агенту мёртвый сервер не виден вовсе — у него просто нет этих
18
+ инструментов, и он молча работает без них
@@ -0,0 +1,20 @@
1
+ {
2
+ "mcpServers": {
3
+ "browser": {
4
+ "command": "npx",
5
+ "args": ["-y", "chrome-devtools-mcp@1.9.0"]
6
+ },
7
+ "search": {
8
+ "command": "uvx",
9
+ "args": ["some-search-mcp==0.4.1"]
10
+ },
11
+ "local": {
12
+ "command": "node",
13
+ "args": ["scripts/mcp-server.mjs"]
14
+ },
15
+ "own-code": {
16
+ "command": "npx",
17
+ "args": ["tsx", "rag/src/index.ts"]
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "mcpServers": {
3
+ "browser": {
4
+ "command": "npx",
5
+ "args": ["-y", "chrome-devtools-mcp@latest"]
6
+ },
7
+ "memory": {
8
+ "command": "/opt/tools/memory-mcp-v0.10.8/bin/memory",
9
+ "args": []
10
+ },
11
+ "search": {
12
+ "command": "npx",
13
+ "args": ["-y", "some-search-mcp"]
14
+ }
15
+ }
16
+ }
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,11 @@ 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
44
50
  - See what you told the agent and never wrote down: `npx agent-quality-kit learn` — reads Claude Code
45
51
  transcripts for this project on this machine and prints rule candidates missing from the entry
46
52
  point. Current project only, terminal only, writes nothing, always exits 0
@@ -48,7 +54,15 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
48
54
  - As a pre-commit hook: `repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit` with
49
55
  `id: aqk` (blocking), `aqk-doctor` (read-only) or `aqk-baseline`. pre-commit installs the
50
56
  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`
57
+ - Without Node at all (a Python, Go or Rust project where nobody installed it):
58
+ `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
62
+ the files `init` writes are owned by root, so you cannot edit your own manifest. Debian-based
63
+ on purpose: the gates are `sh`, `grep`, `awk`, `find` — under alpine's busybox they behave
64
+ 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`
52
66
  (https://github.com/marketplace/actions/agent-quality-kit-aqk)
53
67
 
54
68
  ## What makes it different
@@ -64,7 +78,8 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
64
78
  ## Files it reads and writes
65
79
 
66
80
  - `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
81
+ - `.aqk.yml` — the manifest: entry, rules, docs, lang, gates as commands, covers (what a
82
+ declared gate already holds, so it is not reported as debt), samples, ratchets, lessons
68
83
  - `.aqkignore` — paths the scanning checks must not read (brought-in code, vendored, generated)
69
84
 
70
85
  ## 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.9.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,7 @@
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",
51
47
  "tool/selfcheck/lifecycle.mjs"
52
48
  ],
53
49
  "project": [
@@ -130,11 +130,15 @@ function runIsStale(when) {
130
130
  // `SessionStart` — принадлежность одного Claude Code, и класть его всем подряд значило бы
131
131
  // объявить нейтральность и нарушить её в первой же команде.
132
132
  //
133
- // БЕЗ MATCHER НАМЕРЕННО. Справочник на сайте перечисляет у SessionStart значения matcher
134
- // (startup, resume, clear, compact), а таблица событий, ВШИТАЯ в установленную версию 2.1.263,
135
- // показывает в колонке matcher прочерк. Одно из двух неверно, и выяснить это гаданием нельзя.
136
- // Хук без matcher верен при любом из двух чтений: где matcher поддержан — сработает на всех
137
- // источниках, где не поддержан на всех тоже. Проверено чтением бинаря, не памятью.
133
+ // БЕЗ MATCHER НАМЕРЕННО, и теперь по установленной причине, а не из осторожности.
134
+ // 2026-09-08 расхождение разрешено: в бинаре установленной версии 2.1.263 лежит буквальное
135
+ // перечисление источников "startup","resume","clear","compact","fork" и вызов
136
+ // {kind:"session-start", source:"startup"}. То есть у SessionStart источники ЕСТЬ, и `compact`
137
+ // среди них: хук срабатывает и после сжатия контекста. Это важнее, чем кажется: сжатие —
138
+ // ровно тот момент, когда состояние вылетает из окна, и без повторного срабатывания вся
139
+ // затея работала бы до первого /compact.
140
+ // Matcher не ставим потому, что нужны ВСЕ источники: и старт, и продолжение, и очистка, и
141
+ // сжатие. Отсутствие matcher означает «на любой источник» — это и требуется.
138
142
  const HOOK_FILE = [".claude", "settings.json"];
139
143
 
140
144
  // Команда, которая пойдёт В ОБЩИЙ файл настроек, а значит и в чужие руки через git. `SELF`
@@ -4,12 +4,13 @@ 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 { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, advisorySet, layoutChecks, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
9
9
  import { proveGates } from "../lib/prove.mjs";
10
- import { detectFacts, readCatalog, triggerVerdict, recipeFor } from "../lib/repo.mjs";
10
+ import { detectFacts, readCatalog, triggerVerdict, browserServerAdvice } from "../lib/repo.mjs";
11
11
  import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
12
12
  import { L } from "../i18n/index.mjs";
13
+ import { beginBrief, finishBrief } from "../lib/brief.mjs";
13
14
 
14
15
  // Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
15
16
  // место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
@@ -50,11 +51,16 @@ async function reportCatalog(man, facts) {
50
51
  const catalog = await readCatalog();
51
52
  if (!catalog.length) return;
52
53
 
53
- const held = [], todo = [], skip = [];
54
+ // Четвёртая корзина, а не третья: «закрыто другим арбитром» — это НЕ «не поставлено».
55
+ // Пока их считали вместе, вывод каждый прогон называл долгом то, что уже держит biome или
56
+ // ruff. Просьба первого чужого пользователя; она же — наша собственная норма про вывод.
57
+ const { covered, unknownGates } = coversOf(man);
58
+ const held = [], todo = [], skip = [], byOther = [];
54
59
  for (const rec of catalog) {
55
60
  const v = triggerVerdict(rec, facts);
56
61
  if (!v.applies) skip.push([rec, v.why]);
57
62
  else if (facts.gateKeys.includes(rec.slug)) held.push(rec);
63
+ else if (covered.has(rec.slug)) byOther.push([rec, covered.get(rec.slug)]);
58
64
  else todo.push(rec);
59
65
  }
60
66
 
@@ -72,14 +78,50 @@ async function reportCatalog(man, facts) {
72
78
  console.log(` ${c.yellow("✘")} ${rec.slug.padEnd(22)} ${rec.intent || ""}`);
73
79
  console.log(c.dim(` ${L.doctor.install(`${SELF} add ${rec.slug}`)}`));
74
80
  }
81
+ if (byOther.length) {
82
+ console.log(c.dim(`\n ${L.doctor.coveredBy(byOther.length)}`));
83
+ for (const [rec, gate] of byOther) console.log(c.dim(` ~ ${rec.slug.padEnd(22)} ${L.doctor.coveredByGate(gate)}`));
84
+ }
85
+ // Гейт, которого нет в gates:, не закрывает ничего — и молчать об этом нельзя: человек
86
+ // считает запись закрытой, а её не держит никто. Называется поимённо, жёлтым.
87
+ if (unknownGates.length) {
88
+ console.log(c.yellow(`\n ${L.doctor.coversUnknown(unknownGates.join(", "))}`));
89
+ }
90
+ // Заявка «эту запись держит наш линтер» сверяется с кодами правил из рецепта записи.
91
+ // Замерено на живом ruff.toml: девятнадцать групп правил, а print() не ловится — и заявка
92
+ // сняла бы запись с долга, не закрыв её ничем.
93
+ let linterCfg = "";
94
+ for (const f of ["ruff.toml", ".ruff.toml", "pyproject.toml", ".eslintrc.json", "eslint.config.js", "eslint.config.mjs", "biome.json"]) {
95
+ try { linterCfg += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
96
+ }
97
+ const unproven = coversUnproven(man, catalog, linterCfg);
98
+ for (const u of unproven) {
99
+ console.log(c.yellow(`\n ${L.doctor.coversUnproven(u.entry, u.gate, u.codes.join(", "))}`));
100
+ console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
101
+ }
102
+ // Не вердикт, а совет: отсутствие браузерного сервера — незанятая возможность, а не дефект.
103
+ // Поэтому строка тусклая и без значка, и её нет у проекта без интерфейса.
104
+ let mcpText = "";
105
+ for (const f of [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".claude/mcp.json"]) {
106
+ try { mcpText += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
107
+ }
108
+ const browser = browserServerAdvice(facts, mcpText);
109
+ if (browser) {
110
+ console.log(c.dim(`\n ${L.doctor.noBrowserServer}`));
111
+ console.log(c.dim(` ${L.doctor.noBrowserServerHow(browser.servers.join(" · "))}`));
112
+ }
75
113
  if (skip.length) {
76
114
  console.log(c.dim(`\n ${L.doctor.notApplicable(skip.length)}`));
77
115
  for (const [rec, why] of skip) console.log(c.dim(` · ${rec.slug.padEnd(22)} ${why}`));
78
116
  }
79
117
  console.log(
80
118
  `\n ${c.bold(L.doctor.total)} ${L.doctor.totalHeld(held.length)}, ${L.doctor.totalTodo(c.yellow(todo.length))}, ` +
119
+ (byOther.length ? `${L.doctor.totalCovered(byOther.length)}, ` : "") +
81
120
  c.dim(L.doctor.totalSkip(skip.length)) + "\n"
82
121
  );
122
+ // Числа отдаются наружу, а не пересчитываются второй раз: два счёта одного и того же
123
+ // расходятся ровно так же, как два списка команд.
124
+ return { held: held.length, todo: todo.length, todoRecs: todo };
83
125
  }
84
126
 
85
127
  // «Гейт объявлен» и «гейт работает» — разные утверждения. Первое читается из манифеста,
@@ -139,14 +181,20 @@ function runGates(man, opts = {}) {
139
181
  }
140
182
  const code = r.status;
141
183
  if (code === 0) {
142
- console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${cmd}`)}`);
184
+ // Совещательный называется и когда он зелёный. Иначе гейт, который уронить прогон НЕ
185
+ // МОЖЕТ, по выводу неотличим от того, который может, — и список `advisory:` в манифесте
186
+ // виден только в тот день, когда он покраснел. Измерено 2026-09-09: зелёный
187
+ // совещательный печатался обычной галочкой, а README обещал, что список назван каждый
188
+ // прогон. Тот же класс, что молчащий гейт, только про сам прибор.
189
+ const quiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
190
+ console.log(` ${c.green("✔")} ${name.padEnd(14)}${quiet} ${c.dim(`${secs}s · ${cmd}`)}`);
143
191
  // Зелёный гейт иногда всё-таки говорит человеку что-то важное: храповик, дошедший до цели,
144
192
  // просит убрать обёртку. Вывод успешного гейта не показывался вовсе, и это сообщение
145
193
  // уходило в никуда — тот же класс, что обрезанный совет у красного, только тише.
146
194
  // Показываем ровно строки с меткой совета: остальной вывод успешной проверки — шум.
147
195
  const okAdvice = splitAdvice(`${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean)).advice;
148
196
  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 });
197
+ results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), out: outAll });
150
198
  } else {
151
199
  const raw = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
152
200
  // Совет отделяется ДО сужения. Иначе он сам попадает под фильтр по путям: сообщение
@@ -165,16 +213,23 @@ function runGates(man, opts = {}) {
165
213
  if (!s.scopable || out.length === 0) {
166
214
  // Гейт печатает вердикт без путей — сузить нечем. Признать его успешным значило бы
167
215
  // выдать провал за тишину; остаётся красным, и причина названа.
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 });
216
+ // Совещательный не роняет прогон НИКОГДА — в том числе здесь. Раньше failed++ стоял
217
+ // безусловно, и гейт, объявленный совещательным, валил сборку с `--since` только
218
+ // потому, что в его выводе нет путей. Измерено 2026-09-09.
219
+ const nsAdv = advisory.has(name);
220
+ const nsMark = nsAdv ? c.yellow("!") : c.red("✘");
221
+ const nsVerdict = nsAdv ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
222
+ console.log(` ${nsMark} ${name.padEnd(14)} ${nsVerdict} ${c.dim(`· ${L.doctor.notScopable}`)}`);
223
+ if (!nsAdv) failed++;
224
+ results.push({ name, cmd, ok: false, secs, code, advisory: nsAdv, note: L.doctor.notScopable, out: outAll });
171
225
  continue;
172
226
  }
173
227
  if (s.findings === 0) {
174
228
  // Долг есть, но не в том, что внёс диф. Зелёный — но с числом спрятанного: молчаливое
175
229
  // «всё хорошо» здесь было бы неправдой.
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 });
230
+ const sQuiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
231
+ console.log(` ${c.green("✔")} ${name.padEnd(14)}${sQuiet} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
232
+ results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), scopedAway: out.length, out: outAll });
178
233
  continue;
179
234
  }
180
235
  out = s.kept;
@@ -200,7 +255,7 @@ function runGates(man, opts = {}) {
200
255
  }
201
256
  // Совещательные, которые покраснели, называются вслух ВСЕГДА. Молчание о них — ровно та
202
257
  // тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
203
- const advisoryFailed = results.filter((x) => x.advisory).map((x) => x.name);
258
+ const advisoryFailed = results.filter((x) => x.advisory && !x.ok).map((x) => x.name);
204
259
  if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
205
260
  return { failed, ran: gates.length, results, advisoryFailed };
206
261
  }
@@ -228,6 +283,8 @@ async function writeRunReport({ version, reached, results }) {
228
283
  }
229
284
 
230
285
  async function cmdDoctor() {
286
+ const brief = process.argv.includes("--brief");
287
+ const buf = brief ? beginBrief() : null;
231
288
  // Версия в шапке — единственное, что привязывает баг-репорт к коммиту, если ставили не из
232
289
  // релиза: без неё "у меня не работает" ничем не отличается от любой другой версии за год.
233
290
  let version = "";
@@ -270,6 +327,15 @@ async function cmdDoctor() {
270
327
 
271
328
  // Опечатка в имени поля означала «поля нет»: вердикт выдавался неверный, а причина молчала.
272
329
  // Называем поле и говорим, какие бывают — иначе человек ищет ошибку в проекте, а она в файле.
330
+ // Строка, которую разбор не понял, называется ПЕРВОЙ и жёлтым: человек видит проверку в
331
+ // файле, а её не существует. До 2026-09-09 такая строка исчезала без слова — найдено
332
+ // случайно, гейтом с кириллическим именем, который «прошёл», не запустившись.
333
+ try {
334
+ const bad = unparsedLines(await readFile(join(CWD, MANIFEST), "utf8"));
335
+ for (const b of bad) console.log(c.yellow(`\n ${L.doctor.manifestUnparsed(b.line, b.text)}`));
336
+ if (bad.length) console.log(c.dim(` ${L.doctor.manifestUnparsedWhy}`));
337
+ } catch { /* манифеста нет — про строки в нём говорить нечего */ }
338
+
273
339
  const unknown = unknownKeys(man);
274
340
  if (unknown.length) {
275
341
  console.log(c.yellow(`\n ${L.doctor.manifestUnknown(unknown)}`));
@@ -326,7 +392,7 @@ async function cmdDoctor() {
326
392
  await reportBaseline(man, facts);
327
393
  process.exit(0);
328
394
  }
329
- await reportCatalog(man, facts);
395
+ const cat = (await reportCatalog(man, facts)) || { held: 0, todo: 0, todoRecs: [] };
330
396
 
331
397
  // «Объявлен» ≠ «работает». Без --run говорим это вслух, а не молчим.
332
398
  const wantRun = process.argv.includes("--run");
@@ -359,9 +425,12 @@ async function cmdDoctor() {
359
425
  else if (!levelOk) line = c.red(` ${L.doctor.thresholdFail(min, now)}\n`);
360
426
  else line = c.red(` ${L.doctor.thresholdGateFail(min, now, failedNames)}\n`);
361
427
  console.log(line);
428
+ await finishBrief(buf, { held: cat.held, todo: cat.todo, level: reached, red: failedNames ? String(failedNames).split(", ").filter(Boolean) : [] }, cat.todoRecs, pass);
362
429
  process.exit(pass ? 0 : 1);
363
430
  }
364
- process.exit(missing || reached < 0 || gateFailed ? 1 : 0);
431
+ const ok = !(missing || reached < 0 || gateFailed);
432
+ await finishBrief(buf, { held: cat.held, todo: cat.todo, level: reached, red: [] }, cat.todoRecs, ok);
433
+ process.exit(ok ? 0 : 1);
365
434
  }
366
435
 
367
436
  // Наружу — только команда. Остальное здесь же и используется: экспорт, который никто не
@@ -8,6 +8,7 @@ import {
8
8
  CWD, PKG_ROOT, DOCS_SRC, RULES_SRC, TARGET_DIR, MANIFEST, SELF, REPO_URL, c, exists, die,
9
9
  copyDir, writeIfAbsent, FEEDBACK_MARK, docPath } from "../lib/core.mjs";
10
10
  import { AGENTS_MD, CLAUDE_MD, MANIFEST_YML } from "../lib/templates.mjs";
11
+ import { banner } from "../lib/banner.mjs";
11
12
  import { readManifest } from "../lib/manifest.mjs";
12
13
  import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
13
14
  import { installGate } from "./gates.mjs";
@@ -40,7 +41,9 @@ async function cmdInit(args) {
40
41
  const claude = join(CWD, "CLAUDE.md");
41
42
  track(await writeIfAbsent(claude, CLAUDE_MD, { force }), claude);
42
43
 
43
- console.log(c.bold("\naqk init\n"));
44
+ // Заставка в начале init — первая встреча человека с комплектом. Второй раз он увидит её
45
+ // только если сам спросит `--version`: то, что видишь тридцатый раз, перестаёт читаться.
46
+ console.log(`\n${banner()}\n`);
44
47
  if (created.length) {
45
48
  console.log(c.green(` ${L.init.created(created.length)}`));
46
49
  for (const f of created.slice(0, 8)) console.log(` ${f}`);
@@ -96,7 +99,20 @@ ${c.bold(L.feedback.title)}
96
99
  ${url}/issues/new
97
100
  ${c.dim(` ${L.feedback.once}`)}
98
101
  `);
99
- await writeIfAbsent(FEEDBACK_MARK, "shown\n", { force: false });
102
+ // Пометка «уже показывали» удобство, а не работа команды. Домашнего каталога может не быть
103
+ // записываемым вовсе: в контейнере, запущенном `--user 1001:127`, у этого uid нет записи в
104
+ // /etc/passwd, `homedir()` даёт «/», и запись падает с EACCES на `/.config`. До 2026-09-09
105
+ // это роняло ВЕСЬ `init` — то есть любого, кто набрал команду из нашей же документации по
106
+ // docker. Локально не воспроизводилось случайно: uid разработчика 1000 совпадает с
107
+ // пользователем `node` в образе, у которого дом есть. Нашёл конвейер, где uid 1001.
108
+ //
109
+ // Молча глотать нельзя — это то, что красит наш же swallowed-error. Поэтому вслух: не
110
+ // запомнили, покажем снова. Установка при этом доходит до конца.
111
+ try {
112
+ await writeIfAbsent(FEEDBACK_MARK, "shown\n", { force: false });
113
+ } catch {
114
+ console.log(c.dim(` ${L.feedback.notRemembered}`));
115
+ }
100
116
  }
101
117
 
102
118
  function findJournal() {
@@ -17,6 +17,7 @@ function line(r) {
17
17
  r.why === "no-samples" ? P.noSamples
18
18
  : r.why === "other-recipe" ? P.otherRecipe(r.forRecipe.lang)
19
19
  : r.why === "no-target" ? P.noTarget
20
+ : r.why === "needs-program" ? P.needsProgram(r.missing.join(", "))
20
21
  : P.empty;
21
22
  return ` ${c.dim("~")} ${c.dim(pad)} ${c.dim(why)}`;
22
23
  }