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.
- package/README.md +92 -9
- package/README.ru.md +124 -23
- package/kit/docs/ai/project-baseline.md +14 -0
- package/kit/docs/ready-made-rules.md +103 -0
- package/kit/gates/color-from-token/check.sh +5 -1
- package/kit/gates/lesson-has-outcome/check.sh +5 -1
- package/kit/gates/mcp-server-resolves/README.md +62 -0
- package/kit/gates/mcp-server-resolves/check.sh +110 -0
- package/kit/gates/mcp-server-resolves/gate.yml +18 -0
- package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
- package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
- package/llms.txt +19 -4
- package/package.json +2 -6
- package/tool/commands/context.mjs +9 -5
- package/tool/commands/doctor.mjs +83 -14
- package/tool/commands/project.mjs +18 -2
- package/tool/commands/prove.mjs +1 -0
- package/tool/commands/vitals.mjs +159 -0
- package/tool/i18n/en-docs.mjs +40 -0
- package/tool/i18n/en.mjs +18 -0
- package/tool/i18n/index.mjs +36 -3
- package/tool/i18n/ru-docs.mjs +40 -0
- package/tool/i18n/ru.mjs +18 -0
- package/tool/lib/banner.mjs +59 -0
- package/tool/lib/brief.mjs +192 -0
- package/tool/lib/core.mjs +2 -0
- package/tool/lib/manifest.mjs +146 -15
- package/tool/lib/prove.mjs +11 -1
- package/tool/lib/repo.mjs +31 -1
- package/tool/program.mjs +26 -0
- package/tool/selfcheck/smoke.sh +350 -1
- package/tool/selfcheck/units-banner.mjs +65 -0
- package/tool/selfcheck/units-brief.mjs +97 -0
- package/tool/selfcheck/units-context.mjs +3 -1
- package/tool/selfcheck/units-level.mjs +147 -1
- package/tool/selfcheck/units-repo.mjs +134 -0
- package/tool/selfcheck/units-vitals.mjs +62 -0
- 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,
|
|
29
|
-
no directory
|
|
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
|
-
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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`
|
package/tool/commands/doctor.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
package/tool/commands/prove.mjs
CHANGED
|
@@ -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
|
}
|