agent-quality-kit 0.4.1 → 0.5.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 (41) hide show
  1. package/README.md +101 -12
  2. package/README.ru.md +101 -11
  3. package/kit/gates/README.md +15 -0
  4. package/kit/gates/_native.sh +18 -2
  5. package/kit/gates/_skip.sh +18 -0
  6. package/kit/gates/color-from-token/README.md +52 -0
  7. package/kit/gates/color-from-token/check.sh +69 -0
  8. package/kit/gates/color-from-token/gate.yml +15 -0
  9. package/kit/gates/color-from-token/green/Button.tsx +4 -0
  10. package/kit/gates/color-from-token/green/Panel.vue +4 -0
  11. package/kit/gates/color-from-token/green/card.css +5 -0
  12. package/kit/gates/color-from-token/green/notes.md +2 -0
  13. package/kit/gates/color-from-token/green/tokens.css +7 -0
  14. package/kit/gates/color-from-token/red/Button.tsx +4 -0
  15. package/kit/gates/color-from-token/red/Panel.vue +4 -0
  16. package/kit/gates/color-from-token/red/card.css +5 -0
  17. package/kit/gates/commit-explains-itself/check.sh +25 -2
  18. package/kit/gates/duplicate-code/check.sh +13 -4
  19. package/kit/gates/duplicate-code/gate.yml +11 -2
  20. package/kit/gates/gate-has-samples/check.sh +9 -3
  21. package/kit/gates/gates-are-runnable/check.sh +7 -1
  22. package/kit/gates/gates-run-in-ci/check.sh +7 -1
  23. package/kit/gates/lesson-has-outcome/check.sh +6 -1
  24. package/kit/ratchet/ratchet.sh +9 -2
  25. package/llms.txt +57 -0
  26. package/package.json +8 -3
  27. package/tool/commands/doctor.mjs +62 -7
  28. package/tool/commands/gates.mjs +9 -2
  29. package/tool/commands/project.mjs +7 -4
  30. package/tool/commands/report.mjs +2 -2
  31. package/tool/i18n/en.mjs +38 -0
  32. package/tool/i18n/ru.mjs +43 -0
  33. package/tool/lib/baseline.mjs +87 -0
  34. package/tool/lib/core.mjs +10 -2
  35. package/tool/lib/manifest.mjs +33 -2
  36. package/tool/lib/repo.mjs +7 -0
  37. package/tool/selfcheck/gates.sh +9 -2
  38. package/tool/selfcheck/mutation.sh +95 -0
  39. package/tool/selfcheck/smoke.sh +216 -8
  40. package/tool/selfcheck/syntax.sh +9 -1
  41. package/tool/selfcheck/units.mjs +76 -1
@@ -19,7 +19,8 @@ find "$DIR" $(skip_find "$DIR") $TESTS -type f \
19
19
  ! -name 'test_*' ! -name '*_test.*' ! -name '*.test.*' ! -name '*.spec.*' \
20
20
  -print 2>/dev/null | only_code | own_samples_filter "$DIR" \
21
21
  | while IFS= read -r F; do is_generated "$F" || printf '%s\n' "$F"; done \
22
- | xargs -r awk -v WIN="$WIN" '
22
+ | LC_ALL=C sort \
23
+ | xargs -r env LC_ALL=C awk -v WIN="$WIN" '
23
24
  FNR == 1 { n = 0; delete buf }
24
25
  {
25
26
  line = $0
@@ -37,11 +38,19 @@ find "$DIR" $(skip_find "$DIR") $TESTS -type f \
37
38
  }
38
39
  }
39
40
  ' 2>/dev/null | sort -u \
40
- | awk -F' и |: ' '
41
+ | LC_ALL=C awk -F' и |: ' '
41
42
  # Один повторённый кусок даёт столько сообщений, на сколько окон он делится: восемь
42
43
  # строк — восемь почти одинаковых строк отчёта. Схлопываем в одну на пару файлов.
43
- { split($1, a, ":"); split($2, b, ":"); pair = a[1] " и " b[1]
44
- if (!(pair in seen)) { seen[pair] = $1 " и " $2 }
44
+ #
45
+ # Пара упорядочивается лексикографически, а НЕ в порядке обхода. Порядок, в котором
46
+ # find отдаёт файлы, свой на каждой системе (здесь — по хешу имени), и реестр, снятый
47
+ # на одной машине, краснел в конвейере целиком: те же дубли читались как новые, а
48
+ # храповик объявлял их исправленными и вычёркивал. Одной сортировки списка файлов мало:
49
+ # когда кусок лежит в трёх файлах, «первым» становится тот, кого раньше отдал обход.
50
+ { split($1, a, ":"); split($2, b, ":")
51
+ if (a[1] <= b[1]) { pair = a[1] " и " b[1]; loc = $1 " и " $2 }
52
+ else { pair = b[1] " и " a[1]; loc = $2 " и " $1 }
53
+ if (!(pair in seen)) { seen[pair] = loc }
45
54
  cnt[pair]++ }
46
55
  END { for (p in seen) print seen[p] ": одинаковый кусок" (cnt[p] > 1 ? " (окон: " cnt[p] ")" : "") }
47
56
  ' | sort > /tmp/.dup.$$
@@ -7,7 +7,16 @@ trigger:
7
7
  recipes:
8
8
  any: bash {gate}/check.sh {dir}
9
9
  # jscpd умеет и Python, и TypeScript одним прогоном и считает похожесть, а не совпадение.
10
- javascript: npx --yes jscpd@5 --min-lines 8 --threshold 1 {dir}
11
- typescript: npx --yes jscpd@5 --min-lines 8 --threshold 1 {dir}
10
+ #
11
+ # --format обязателен: без него jscpd считает клонами разметку, JSON и текст. Замер на живом
12
+ # проекте (Django + React, 2750 файлов кода): 1846 клонов, из них 435 — markdown, json и
13
+ # text, то есть 23.6% находок не про код вовсе. Переносимая проверка смотрит только в
14
+ # расширения кода (CODE_EXT в _skip.sh), и родная обязана мерить то же самое: иначе один
15
+ # гейт означает разное на разных машинах.
16
+ #
17
+ # «c#» в списке нет намеренно: разбор манифеста режет строку по «#», и рецепт обрывался бы
18
+ # на «c». Обёртку _native.sh не пишем — её добавляет сама программа при установке гейта.
19
+ javascript: npx --yes jscpd@5 --min-lines 8 --threshold 1 --format "javascript,jsx,typescript,tsx,vue,python,go,ruby,java,php,rust,kotlin,swift,scala" {dir}
20
+ typescript: npx --yes jscpd@5 --min-lines 8 --threshold 1 --format "javascript,jsx,typescript,tsx,vue,python,go,ruby,java,php,rust,kotlin,swift,scala" {dir}
12
21
 
13
22
  proof: incidents/README.md — «2026-08-25 разбор 1069 коммитов»: число моделей захардкожено в четырёх местах четырьмя разными значениями
@@ -15,9 +15,15 @@
15
15
  DIR="${1:-.}"
16
16
  MAN="$DIR/.aqk.yml"
17
17
  [ -f "$MAN" ] || { echo "нет .aqk.yml — проверять нечего"; exit 0; }
18
+ # Перевод строк из windows-чекаута снимается ДО разбора. Жадный захват в sed («[^"#]*») и
19
+ # якорь конца строки («s/"$//») проглатывают \r: путь получался с кавычкой на хвосте, а
20
+ # красный образец переставал краснеть. Найдено мутационной проверкой
21
+ # (tool/selfcheck/mutation.sh), а не на Windows-машине — её у нас до сих пор нет.
22
+ MANTEXT="$(tr -d '\r' < "$MAN")"
18
23
 
19
- SAMPLES=$(sed -n 's/^samples:[[:space:]]*"\{0,1\}\([^"#]*\)"\{0,1\}[[:space:]]*$/\1/p' "$MAN" | head -1)
20
- KEYS=$(awk '/^gates:/{g=1;next} /^[A-Za-z]/{g=0} g && /^[[:space:]]+[A-Za-z0-9_-]+:/{sub(/:.*/,"");gsub(/[[:space:]]/,"");print}' "$MAN")
24
+
25
+ SAMPLES=$(printf '%s\n' "$MANTEXT" | sed -n 's/^samples:[[:space:]]*"\{0,1\}\([^"#]*\)"\{0,1\}[[:space:]]*$/\1/p' | head -1)
26
+ KEYS=$(printf '%s\n' "$MANTEXT" | awk '/^gates:/{g=1;next} /^[A-Za-z]/{g=0} g && /^[[:space:]]+[A-Za-z0-9_-]+:/{sub(/:.*/,"");gsub(/[[:space:]]/,"");print}')
21
27
 
22
28
  [ -z "$KEYS" ] && { echo "гейтов не объявлено — проверять нечего"; exit 0; }
23
29
 
@@ -32,7 +38,7 @@ fi
32
38
  BAD=0
33
39
  for K in $KEYS; do
34
40
  # Команда записи целиком — по ней видно, сканер это или собственный прогон проекта.
35
- CMD=$(awk -v k="$K" '/^gates:/{g=1;next} /^[A-Za-z]/{g=0} g && $0 ~ "^[[:space:]]+" k ":" {sub(/^[[:space:]]*[A-Za-z0-9_-]+:[[:space:]]*/,"");gsub(/^"|"$/,"");print;exit}' "$MAN")
41
+ CMD=$(printf '%s\n' "$MANTEXT" | awk -v k="$K" '/^gates:/{g=1;next} /^[A-Za-z]/{g=0} g && $0 ~ "^[[:space:]]+" k ":" {sub(/^[[:space:]]*[A-Za-z0-9_-]+:[[:space:]]*/,"");gsub(/^"|"$/,"");print;exit}')
36
42
  case "$CMD" in
37
43
  *"$SAMPLES/"*) ;; # сканер из каталога — образцы обязательны
38
44
  *) continue ;; # свой прогон — арбитр внутри него
@@ -5,9 +5,15 @@
5
5
  DIR="${1:-.}"
6
6
  MAN="$DIR/.aqk.yml"
7
7
  [ -f "$MAN" ] || { echo "нет .aqk.yml — проверять нечего"; exit 0; }
8
+ # Перевод строк из windows-чекаута снимается ДО разбора. Жадный захват в sed («[^"#]*») и
9
+ # якорь конца строки («s/"$//») проглатывают \r: путь получался с кавычкой на хвосте, а
10
+ # красный образец переставал краснеть. Найдено мутационной проверкой
11
+ # (tool/selfcheck/mutation.sh), а не на Windows-машине — её у нас до сих пор нет.
12
+ MANTEXT="$(tr -d '\r' < "$MAN")"
13
+
8
14
 
9
15
  # строки вида ` имя: "команда"` внутри блока gates:
10
- LINES=$(awk '/^gates:/{g=1;next} /^[A-Za-z]/{g=0} g && /^[[:space:]]+[A-Za-z0-9_-]+:/{print}' "$MAN")
16
+ LINES=$(printf '%s\n' "$MANTEXT" | awk '/^gates:/{g=1;next} /^[A-Za-z]/{g=0} g && /^[[:space:]]+[A-Za-z0-9_-]+:/{print}')
11
17
  [ -z "$LINES" ] && { echo "гейтов не объявлено"; exit 0; }
12
18
 
13
19
  BAD=0
@@ -4,6 +4,12 @@
4
4
  DIR="${1:-.}"
5
5
  MAN="$DIR/.aqk.yml"
6
6
  [ -f "$MAN" ] || { echo "нет .aqk.yml — проверять нечего"; exit 0; }
7
+ # Перевод строк из windows-чекаута снимается ДО разбора. Жадный захват в sed («[^"#]*») и
8
+ # якорь конца строки («s/"$//») проглатывают \r: путь получался с кавычкой на хвосте, а
9
+ # красный образец переставал краснеть. Найдено мутационной проверкой
10
+ # (tool/selfcheck/mutation.sh), а не на Windows-машине — её у нас до сих пор нет.
11
+ MANTEXT="$(tr -d '\r' < "$MAN")"
12
+
7
13
 
8
14
  CI=$(find "$DIR/.github/workflows" "$DIR/.gitlab-ci.yml" "$DIR/.circleci" "$DIR/Jenkinsfile" \
9
15
  -type f 2>/dev/null)
@@ -18,7 +24,7 @@ if grep -qE 'doctor[[:space:]]+--run|--run[[:space:]]+.*doctor' $CI 2>/dev/null;
18
24
  exit 0
19
25
  fi
20
26
 
21
- NAMES=$(awk '/^gates:/{g=1;next} /^[A-Za-z]/{g=0} g && /^[[:space:]]+[A-Za-z0-9_-]+:/{print}' "$MAN")
27
+ NAMES=$(printf '%s\n' "$MANTEXT" | awk '/^gates:/{g=1;next} /^[A-Za-z]/{g=0} g && /^[[:space:]]+[A-Za-z0-9_-]+:/{print}')
22
28
  [ -z "$NAMES" ] && { echo "гейтов не объявлено"; exit 0; }
23
29
 
24
30
  BAD=0
@@ -9,7 +9,12 @@ DIR="${1:-.}"
9
9
  MAN="$DIR/.aqk.yml"
10
10
 
11
11
  # Где журнал — говорит манифест. Своего мнения у проверки быть не должно: путь у каждого свой.
12
- LESSONS=$(sed -n 's/^lessons:[[:space:]]*"\{0,1\}\([^"#]*\)"\{0,1\}[[:space:]]*$/\1/p' "$MAN" 2>/dev/null | head -1)
12
+ # Перевод строк из windows-чекаута снимается ДО разбора. Жадный захват в sed («[^"#]) и
13
+ # якорь конца строки («s/"$//») проглатывают \r: путь получался с кавычкой на хвосте, а
14
+ # красный образец переставал краснеть. Найдено мутационной проверкой
15
+ # (tool/selfcheck/mutation.sh), а не на Windows-машине — её у нас до сих пор нет.
16
+ MANTEXT="$(tr -d '\r' < "$MAN" 2>/dev/null)"
17
+ LESSONS=$(printf '%s\n' "$MANTEXT" | sed -n 's/^lessons:[[:space:]]*"\{0,1\}\([^"#]*\)"\{0,1\}[[:space:]]*$/\1/p' 2>/dev/null | head -1)
13
18
  case "$LESSONS" in
14
19
  "" ) echo "в .aqk.yml не указано поле lessons — журнала нет"; exit 0 ;;
15
20
  http*://* ) echo "журнал вынесен наружу ($LESSONS) — здесь не проверить"; exit 0 ;;
@@ -17,7 +17,12 @@ REG="${1:-}"; shift || true
17
17
 
18
18
  # Ключ нарушения обязан переживать правку соседних строк, иначе сдвиг на строку читается
19
19
  # как новое нарушение. Поэтому номер строки из ключа убирается.
20
- keys() { grep -E '^[^[:space:]].*:' | sed -E 's/:[0-9]+:/:/' | sort -u; }
20
+ #
21
+ # Два шаблона, а не один: у гейта duplicate-code строка несёт ДВА места — «a:171 и b:188: …»,
22
+ # и за номером первого идёт не двоеточие, а пробел. Шаблон с двоеточием убирал номер только
23
+ # у второго файла пары, и сдвиг кода в первом читался как новое нарушение — ровно то, от чего
24
+ # храповик защищает.
25
+ keys() { grep -E '^[^[:space:]].*:' | sed -E 's/:[0-9]+:/:/g; s/:[0-9]+( |$)/:\1/g' | LC_ALL=C sort -u; }
21
26
 
22
27
  OUT="$("$@" 2>&1)"
23
28
  NOW="$(printf '%s\n' "$OUT" | keys)"
@@ -26,7 +31,9 @@ if [ ! -f "$REG" ]; then
26
31
  echo "нет реестра $REG — сначала: aqk ratchet <гейт>"
27
32
  exit 2
28
33
  fi
29
- WAS="$(grep -vE '^\s*(#|$)' "$REG" | sort -u)"
34
+ # Та же сортировка, что в keys(): comm сравнивает построчно и требует, чтобы оба входа были
35
+ # упорядочены одинаково. Разная локаль у двух сторон — это тихо разъехавшееся сравнение.
36
+ WAS="$(grep -vE '^\s*(#|$)' "$REG" | LC_ALL=C sort -u)"
30
37
 
31
38
  NEW="$(comm -23 <(printf '%s\n' "$NOW") <(printf '%s\n' "$WAS"))"
32
39
  GONE="$(comm -13 <(printf '%s\n' "$NOW") <(printf '%s\n' "$WAS"))"
package/llms.txt ADDED
@@ -0,0 +1,57 @@
1
+ # AQK — Agent Quality Kit
2
+
3
+ > A standard and a CLI that check whether a repository is ready to have its code written by AI
4
+ > coding agents. Every rule the project promises to follow becomes a command with an exit code,
5
+ > so a machine holds the promise instead of somebody's attention. Reports a level from AQK-0 to
6
+ > AQK-3, computed by a run — never by a questionnaire and never by a model's opinion.
7
+
8
+ Vendor-neutral: works with any coding agent (Claude Code, Codex, Cursor, Gemini CLI, GitHub
9
+ Copilot, Windsurf, Aider, OpenCode) and with no AI at all. It calls no vendor API and needs no
10
+ key. Stack-neutral: portable checks are plain `sh`; where the project already has a native tool
11
+ (ruff, eslint, knip, jscpd) the check uses it instead and says so when it falls back.
12
+
13
+ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
14
+
15
+ ## Use it
16
+
17
+ - Check an existing repository: `npx agent-quality-kit doctor` (read-only: writes nothing, sends
18
+ nothing)
19
+ - Start a new one: `npx agent-quality-kit start`
20
+ - Install one guard from the catalogue: `npx agent-quality-kit add <name>`
21
+ - Check the minimum a project needs before agents can be handed the work:
22
+ `npx agent-quality-kit doctor --baseline` (14 of 50 points confirmed by a run, ecosystem-neutral;
23
+ the other 36 are named as a number, not hidden)
24
+ - Fail a pipeline below a level or on a failed gate: `npx agent-quality-kit doctor --run --min 1`
25
+ - Exit codes: 0 pass, 1 below the level or a gate failed
26
+ - As a pre-commit hook: `repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit` with
27
+ `id: aqk` (blocking), `aqk-doctor` (read-only) or `aqk-baseline`. pre-commit installs the
28
+ package itself; there are no dependencies to pull in.
29
+ - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.4.2` with `min: 1`
30
+ (https://github.com/marketplace/actions/agent-quality-kit-aqk)
31
+
32
+ ## What makes it different
33
+
34
+ - The level is computed by running what the repository declares — not from a self-assessment
35
+ questionnaire, not from the presence of a config file, not from a language model's judgement.
36
+ - Every guard must ship a red and a green sample, and a machine checks that the guard goes red on
37
+ the red one and stays quiet on the green one. A check that cannot fail is indistinguishable
38
+ from a check that works; this is the only place that requirement is enforced.
39
+ - An entry enters the catalogue only when it names a real failure it caught, recorded in the
40
+ bruise journal.
41
+
42
+ ## Files it reads and writes
43
+
44
+ - `AGENTS.md` — what the agent reads first (the entry point; `CLAUDE.md` and others work too)
45
+ - `.aqk.yml` — the manifest: entry, rules, gates as commands, samples, ratchets, lessons
46
+ - `.aqkignore` — paths the scanning checks must not read (brought-in code, vendored, generated)
47
+
48
+ ## Documentation
49
+
50
+ - [README](README.md): what it is, how to install, the catalogue, the badge, CI
51
+ - [SPEC.md](SPEC.md): the standard itself — levels, manifest, how an entry is accepted
52
+ - [Project baseline](kit/docs/ai/project-baseline.md): what a project needs before agents can be
53
+ handed the work, in plain words, independent of language and tooling
54
+ - [The gate catalogue](kit/gates/README.md): what a gate is and the bar an entry must clear
55
+ - [The bruise journal](incidents/README.md): every defect found, and what was made of it
56
+ - [CONTRIBUTING.md](CONTRIBUTING.md): how to bring a gate
57
+ - [SECURITY.md](SECURITY.md): reporting a vulnerability, and the trust model of the manifest
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-quality-kit",
3
- "version": "0.4.1",
4
- "description": "AQK Agent Quality Kit: переносимый комплект, приводящий проект в состояние, пригодное для работы агентов. Правила, механические упоры, накопленные уроки. Одна команда, любой инструмент.",
3
+ "version": "0.5.0",
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": {
7
7
  "aqk": "tool/program.mjs"
@@ -11,6 +11,7 @@
11
11
  "kit",
12
12
  "README.md",
13
13
  "README.ru.md",
14
+ "llms.txt",
14
15
  "LICENSE"
15
16
  ],
16
17
  "engines": {
@@ -34,7 +35,11 @@
34
35
  "codex",
35
36
  "cursor",
36
37
  "harness",
37
- "repository-standard"
38
+ "repository-standard",
39
+ "agents-md",
40
+ "agents",
41
+ "github-action",
42
+ "npx"
38
43
  ],
39
44
  "knip": {
40
45
  "entry": [
@@ -4,10 +4,46 @@ 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 { CWD, PKG_ROOT, TARGET_DIR, SELF, c, exists } from "../lib/core.mjs";
7
- import { readManifest, assessLevel } from "../lib/manifest.mjs";
7
+ import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS } from "../lib/manifest.mjs";
8
8
  import { detectFacts, readCatalog, triggerVerdict, recipeFor } from "../lib/repo.mjs";
9
+ import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
9
10
  import { L } from "../i18n/index.mjs";
10
11
 
12
+ // Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
13
+ // место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
14
+ async function reportBaseline(man, facts) {
15
+ const { readdir, readFile } = await import("node:fs/promises");
16
+ let files = [];
17
+ try {
18
+ files = (await readdir(CWD, { withFileTypes: true })).map((d) => d.name);
19
+ } catch { /* пустой список честнее выдуманного: ни один пункт не подтвердится */ }
20
+
21
+ // Файлы зависимостей читаются целиком и склеиваются: трекер ошибок объявляют по-разному в
22
+ // каждой экосистеме, а искать его надо одинаково.
23
+ let depsText = "";
24
+ for (const f of DEP_FILES) {
25
+ if (!files.some((n) => n.toLowerCase() === f)) continue;
26
+ try { depsText += (await readFile(join(CWD, f), "utf8")).toLowerCase() + "\n"; } catch { /* нечитаемый файл — просто не признак */ }
27
+ }
28
+
29
+ const rows = assessBaseline({ files, gateKeys: facts.gateKeys, facts, manifest: man || {}, depsText });
30
+ const okCount = rows.filter((r) => r.ok).length;
31
+
32
+ console.log(c.bold(`\n ${L.baseline.heading}\n`));
33
+ console.log(c.dim(` ${L.baseline.intro(rows.length, BASELINE_TOTAL)}`));
34
+ console.log(c.dim(` ${L.baseline.caveat}\n`));
35
+ for (const r of rows) {
36
+ const mark = r.ok ? c.green("✔") : c.yellow("✘");
37
+ const title = L.baseline.titles[r.key] || r.key;
38
+ console.log(` ${mark} ${String(r.n).padStart(2)}. ${title}`);
39
+ console.log(c.dim(` ${r.ok ? L.baseline.by(r.by) : L.baseline.none}`));
40
+ }
41
+ console.log(
42
+ "\n " + (okCount === rows.length ? c.green(`${okCount}/${rows.length}`) : c.yellow(`${okCount}/${rows.length}`)) +
43
+ c.dim(` · ${L.baseline.eyes(BASELINE_TOTAL - rows.length, "kit/docs/ai/project-baseline.md")}\n`)
44
+ );
45
+ }
46
+
11
47
  async function reportCatalog(man, facts) {
12
48
  const catalog = await readCatalog();
13
49
  if (!catalog.length) return;
@@ -158,6 +194,15 @@ async function cmdDoctor() {
158
194
  }
159
195
 
160
196
  const man = await readManifest();
197
+
198
+ // Опечатка в имени поля означала «поля нет»: вердикт выдавался неверный, а причина молчала.
199
+ // Называем поле и говорим, какие бывают — иначе человек ищет ошибку в проекте, а она в файле.
200
+ const unknown = unknownKeys(man);
201
+ if (unknown.length) {
202
+ console.log(c.yellow(`\n ${L.doctor.manifestUnknown(unknown)}`));
203
+ console.log(c.dim(` ${L.doctor.manifestKnown(KNOWN_KEYS)}\n`));
204
+ }
205
+
161
206
  const { reached, steps } = await assessLevel(man);
162
207
 
163
208
  console.log(c.bold(`\n ${L.doctor.levelHeading}\n`));
@@ -190,15 +235,21 @@ async function cmdDoctor() {
190
235
  }
191
236
 
192
237
  const facts = await detectFacts(man);
238
+ if (process.argv.includes("--baseline")) {
239
+ await reportBaseline(man, facts);
240
+ process.exit(0);
241
+ }
193
242
  await reportCatalog(man, facts);
194
243
 
195
244
  // «Объявлен» ≠ «работает». Без --run говорим это вслух, а не молчим.
196
245
  const wantRun = process.argv.includes("--run");
197
246
  const gates = declaredGates(man);
198
247
  let gateFailed = 0;
248
+ let failedNames = [];
199
249
  if (wantRun) {
200
250
  const run = runGates(man);
201
251
  gateFailed = run.failed;
252
+ failedNames = run.results.filter((r) => !r.ok).map((r) => r.name);
202
253
  await writeRunReport({ version, reached, results: run.results });
203
254
  } else if (gates.length) {
204
255
  console.log(
@@ -211,12 +262,16 @@ async function cmdDoctor() {
211
262
  const minIdx = process.argv.indexOf("--min");
212
263
  const min = minIdx > -1 ? Number(process.argv[minIdx + 1]) : null;
213
264
  if (min !== null) {
214
- const pass = reached >= min && gateFailed === 0;
215
- console.log(
216
- pass
217
- ? c.green(` ${L.doctor.thresholdPass(min)}\n`)
218
- : c.red(` ${L.doctor.thresholdFail(min, reached < 0 ? L.doctor.levelNone : reached)}\n`)
219
- );
265
+ const levelOk = reached >= min;
266
+ const pass = levelOk && gateFailed === 0;
267
+ // Две разные развилки, и сообщение обязано их различать. «Порог не пройден: сейчас AQK-1»
268
+ // при пороге AQK-1 противоречит само себе и отправляет чинить манифест, когда падал гейт.
269
+ const now = reached < 0 ? L.doctor.levelNone : reached;
270
+ let line;
271
+ if (pass) line = c.green(` ${L.doctor.thresholdPass(min)}\n`);
272
+ else if (!levelOk) line = c.red(` ${L.doctor.thresholdFail(min, now)}\n`);
273
+ else line = c.red(` ${L.doctor.thresholdGateFail(min, now, failedNames)}\n`);
274
+ console.log(line);
220
275
  process.exit(pass ? 0 : 1);
221
276
  }
222
277
  process.exit(missing || reached < 0 || gateFailed ? 1 : 0);
@@ -44,7 +44,13 @@ async function installGate(slug, man, facts) {
44
44
  let cmd = picked
45
45
  .replace(/\{gate\}/g, `${PROJECT_GATES}/${slug}`)
46
46
  .replace(/\{dir\}/g, ".");
47
- if (!cmd) die(L.add.noRecipe(slug, [...facts.langs].join("/") || L.add.thisStack));
47
+ // Отказ, а не смерть. `add` ставит одну запись — там смерть уместна и остаётся в вызывающем.
48
+ // `start` ставит пачку, и падение на одной записи оставляло проект с тремя сторожами вместо
49
+ // двенадцати, без единого слова про остальные девять. Найдено прогоном на Windows: там нет
50
+ // ни `ruff`, ни `vulture`, и установка обрывалась на записи `dead-code`, которой нужен
51
+ // настоящий инструмент. Отсутствие сигнала неотличимо от успеха — здесь оно было внутри
52
+ // самой установки.
53
+ if (!cmd) return { rec, cmd: null, copied, declared: false, why: null, noRecipe: true };
48
54
 
49
55
  // Родной инструмент не знает про наши образцы и выдаёт их как находки — в любом проекте,
50
56
  // куда поставили гейты. Заворачиваем его в общий фильтр. Переносимая проверка фильтрует
@@ -81,7 +87,8 @@ async function cmdAdd(args) {
81
87
  console.log(c.dim(` ${L.add.installAnyway}\n`));
82
88
  }
83
89
 
84
- const { cmd, copied, declared, why } = await installGate(slug, man, facts);
90
+ const { cmd, copied, declared, why, noRecipe } = await installGate(slug, man, facts);
91
+ if (noRecipe) die(L.add.noRecipe(slug, [...facts.langs].join("/") || L.add.thisStack));
85
92
 
86
93
  console.log(c.bold(`\naqk add ${slug}\n`));
87
94
  console.log(` ${c.green("✔")} ${PROJECT_GATES}/${slug}/ ${c.dim(L.add.copied(copied.length))}`);
@@ -6,8 +6,7 @@ import { spawnSync } from "node:child_process";
6
6
  import { join, dirname, relative } from "node:path";
7
7
  import {
8
8
  CWD, PKG_ROOT, DOCS_SRC, RULES_SRC, TARGET_DIR, MANIFEST, SELF, REPO_URL, c, exists, die,
9
- copyDir, writeIfAbsent, FEEDBACK_MARK,
10
- } from "../lib/core.mjs";
9
+ copyDir, writeIfAbsent, FEEDBACK_MARK, docPath } from "../lib/core.mjs";
11
10
  import { AGENTS_MD, CLAUDE_MD, MANIFEST_YML } from "../lib/templates.mjs";
12
11
  import { readManifest } from "../lib/manifest.mjs";
13
12
  import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
@@ -199,7 +198,7 @@ async function cmdBlob() {
199
198
  await walk(dir);
200
199
 
201
200
  for (const full of found) {
202
- out += `\n\n${"=".repeat(78)}\n<!-- ${L.blob.source(relative(PKG_ROOT, full))} -->\n${"=".repeat(78)}\n\n`;
201
+ out += `\n\n${"=".repeat(78)}\n<!-- ${L.blob.source(docPath(PKG_ROOT, full))} -->\n${"=".repeat(78)}\n\n`;
203
202
  // Ссылки на соседние файлы в склейке ведут в никуда: соседей рядом больше нет,
204
203
  // все они внутри этого же текста. Оставляем подпись, снимаем разметку.
205
204
  const body = (await readFile(full, "utf8")).replace(
@@ -268,7 +267,11 @@ async function cmdStart(args) {
268
267
  if (declared.has(rec.slug)) continue;
269
268
  const v = triggerVerdict(rec, facts0);
270
269
  if (!v.applies) { skipped.push([rec.slug, v.why]); continue; }
271
- const { cmd } = await installGate(rec.slug, man, facts0);
270
+ const { cmd, noRecipe } = await installGate(rec.slug, man, facts0);
271
+ // Записи, которой нужен инструмент, а его на машине нет, здесь не место — но и вся
272
+ // установка из-за неё останавливаться не должна. Причина называется вслух и попадает
273
+ // в тот же список пропущенного, что и записи, не подошедшие по триггеру.
274
+ if (noRecipe) { skipped.push([rec.slug, L.start.noRecipeHere]); declared.add(rec.slug); continue; }
272
275
  put.push([rec.slug, cmd, rec.intent || ""]);
273
276
  declared.add(rec.slug);
274
277
  added++;
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { mkdir, writeFile, readdir, readFile } from "node:fs/promises";
17
17
  import { join, relative } from "node:path";
18
- import { CWD, TARGET_DIR, SELF, c, exists } from "../lib/core.mjs";
18
+ import { CWD, TARGET_DIR, SELF, c, exists, docPath } from "../lib/core.mjs";
19
19
  import { readManifest, assessLevel } from "../lib/manifest.mjs";
20
20
  import { detectFacts, readCatalog, triggerVerdict, whichSync } from "../lib/repo.mjs";
21
21
  import { runGates, declaredGates } from "./doctor.mjs";
@@ -59,7 +59,7 @@ async function findDoc(name) {
59
59
  for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
60
60
  const full = join(dir, e.name);
61
61
  if (e.isDirectory()) { const hit = await walk(full); if (hit) return hit; }
62
- else if (e.name === name) return relative(CWD, full);
62
+ else if (e.name === name) return docPath(CWD, full);
63
63
  }
64
64
  return null;
65
65
  };
package/tool/i18n/en.mjs CHANGED
@@ -73,8 +73,44 @@ export const en = {
73
73
  declaredNotRun: (n) => `${n} gates declared, but never run.`,
74
74
  declaredNotRunWhy: (cmd) => ` "declared" and "works" are different claims: ${cmd}`,
75
75
 
76
+ manifestUnknown: (keys) =>
77
+ `The manifest has fields the standard does not know: ${keys.join(", ")}. Looks like a typo — ` +
78
+ `such a field is silently read as absent, and the verdict comes out wrong.`,
79
+ manifestKnown: (keys) => `Manifest fields: ${keys.join(", ")}`,
76
80
  thresholdPass: (min) => `Threshold AQK-${min} passed.`,
77
81
  thresholdFail: (min, now) => `Threshold AQK-${min} NOT passed: currently AQK-${now}.`,
82
+ thresholdGateFail: (min, now, names) =>
83
+ `Threshold AQK-${min} passed (currently AQK-${now}), but a gate failed: ${names.join(", ")}.`,
84
+ },
85
+
86
+ baseline: {
87
+ heading: "The minimum a project needs",
88
+ intro: (checked, total) =>
89
+ `a machine confirms ${checked} of ${total} points; the rest are for your eyes, in the guide`,
90
+ eyes: (n, path) => `${n} points a machine cannot check — they live in ${path}`,
91
+ caveat: "presence is what gets checked, not whether it works: \"a linter is configured\" and \"a linter catches things\" are different claims",
92
+ by: (b) =>
93
+ "proven by: " +
94
+ ({ gate: `gate ${b.value}`, fact: `repository scan: ${b.value}`,
95
+ manifest: `field ${b.value} in the manifest`, dep: `dependency ${b.value}`,
96
+ file: b.value }[b.kind] || b.value),
97
+ none: "no conventional marker — check by eye, it may be done another way",
98
+ titles: {
99
+ oneCommand: "one command brings the whole project up",
100
+ lockfile: "exact versions pinned in a lockfile",
101
+ sameEnv: "the environment is the same for everyone and in CI",
102
+ formatter: "formatting is uniform and applied automatically",
103
+ linter: "a linter is configured",
104
+ types: "type checking exists",
105
+ secretScan: "secret scanning",
106
+ fileSize: "a file size limit",
107
+ ownInvariants: "the project's own invariants",
108
+ tests: "arbiters of correctness: tests exist",
109
+ pipeline: "a pipeline exists",
110
+ errorTracker: "errors are collected separately from logs",
111
+ machineReadable: "the project is machine-readable",
112
+ rulesInRepo: "rules live in the repository and are versioned",
113
+ },
78
114
  },
79
115
 
80
116
  trigger: {
@@ -91,6 +127,7 @@ export const en = {
91
127
  has_deps: ["no dependency file in sight", "dependencies are declared"],
92
128
  has_tests: ["no tests in sight", "tests exist"],
93
129
  has_env: ["no environment file", "an environment file exists"],
130
+ has_ui: ["no stylesheets or UI components in sight", "a UI exists: stylesheets or components"],
94
131
  },
95
132
  },
96
133
 
@@ -299,6 +336,7 @@ export const en = {
299
336
  },
300
337
 
301
338
  start: {
339
+ noRecipeHere: "needs a tool that is not on this machine",
302
340
  initFailed: (cmd) => `Could not lay out the kit. Start with ${cmd}`,
303
341
  tooManyFiles: (n) => `This repository already has ${n} code files — that is a different scenario.`,
304
342
  useDoctor: (cmd) => `${cmd} will inspect what is here and split the entries into three lists:`,
package/tool/i18n/ru.mjs CHANGED
@@ -74,8 +74,49 @@ export const ru = {
74
74
  declaredNotRun: (n) => `${n} гейтов объявлено, но не запускалось.`,
75
75
  declaredNotRunWhy: (cmd) => ` «Объявлен» и «работает» — разные утверждения: ${cmd}`,
76
76
 
77
+ manifestUnknown: (keys) =>
78
+ `В манифесте поля, которых стандарт не знает: ${keys.join(", ")}. Похоже на опечатку — ` +
79
+ `такое поле молча читается как отсутствующее, и вердикт выходит неверным.`,
80
+ manifestKnown: (keys) => `Поля манифеста: ${keys.join(", ")}`,
77
81
  thresholdPass: (min) => `Порог AQK-${min} пройден.`,
78
82
  thresholdFail: (min, now) => `Порог AQK-${min} НЕ пройден: сейчас AQK-${now}.`,
83
+ // Ступень взята, но прогон красный — это другое утверждение, и виновника называем сразу:
84
+ // иначе его ищут глазами выше по логу конвейера.
85
+ thresholdGateFail: (min, now, names) =>
86
+ `Порог AQK-${min} пройден (сейчас AQK-${now}), но упал гейт: ${names.join(", ")}.`,
87
+ },
88
+
89
+ baseline: {
90
+ heading: "Обязательный минимум проекта",
91
+ intro: (checked, total) =>
92
+ `машина подтверждает ${checked} пунктов из ${total}; остальные — глазами, по методичке`,
93
+ eyes: (n, path) => `${n} пунктов машина проверить не может — они в ${path}`,
94
+ caveat: "проверяется НАЛИЧИЕ признака, а не то, что он работает: «линтер настроен» и «линтер ловит» — разные утверждения",
95
+ by: (b) =>
96
+ "подтверждено: " +
97
+ ({ gate: `гейт ${b.value}`, fact: `осмотр репозитория: ${b.value}`,
98
+ manifest: `поле ${b.value} в манифесте`, dep: `зависимость ${b.value}`,
99
+ file: b.value }[b.kind] || b.value),
100
+ // «Не найдено» и «нет» — разные утверждения. Признак ищется по общепринятым именам; проект
101
+ // может делать то же самое своим способом, и тогда пункт остаётся человеку, а не считается
102
+ // проваленным. Иначе прибор врёт на первом же нестандартном проекте — на нас самих и врал.
103
+ none: "общепринятого признака нет — проверь глазами, возможно сделано иначе",
104
+ titles: {
105
+ oneCommand: "одна команда поднимает проект целиком",
106
+ lockfile: "точные версии записаны в файл-замок",
107
+ sameEnv: "среда одинакова у всех и в конвейере",
108
+ formatter: "форматирование единое и применяется автоматически",
109
+ linter: "линтер настроен",
110
+ types: "проверка типов есть",
111
+ secretScan: "поиск секретов",
112
+ fileSize: "ограничение размера файла",
113
+ ownInvariants: "свои инварианты проекта",
114
+ tests: "арбитры правильности: тесты есть",
115
+ pipeline: "конвейер есть",
116
+ errorTracker: "ошибки собираются отдельно от логов",
117
+ machineReadable: "проект читается машиной",
118
+ rulesInRepo: "правила живут в репозитории и версионируются",
119
+ },
79
120
  },
80
121
 
81
122
  trigger: {
@@ -92,6 +133,7 @@ export const ru = {
92
133
  has_deps: ["не видно файла зависимостей", "зависимости объявлены"],
93
134
  has_tests: ["не видно тестов", "тесты есть"],
94
135
  has_env: ["нет файла окружения", "файл окружения есть"],
136
+ has_ui: ["не видно стилей и компонентов интерфейса", "интерфейс есть: стили или компоненты"],
95
137
  },
96
138
  },
97
139
 
@@ -300,6 +342,7 @@ export const ru = {
300
342
  },
301
343
 
302
344
  start: {
345
+ noRecipeHere: "нужен инструмент, которого нет на этой машине",
303
346
  initFailed: (cmd) => `Не получилось разложить комплект. Начни с ${cmd}`,
304
347
  tooManyFiles: (n) => `В репозитории уже ${n} файлов кода — это другой сценарий.`,
305
348
  useDoctor: (cmd) => `${cmd} осмотрит, что есть, и разделит записи на три списка:`,