agent-quality-kit 0.10.1 → 0.12.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 (67) hide show
  1. package/README.md +10 -3
  2. package/README.ru.md +10 -3
  3. package/kit/gates/_native.sh +4 -0
  4. package/kit/gates/_skip.sh +7 -0
  5. package/kit/gates/api-contract-has-arbiter/check.sh +7 -0
  6. package/kit/gates/color-from-token/check.sh +7 -0
  7. package/kit/gates/commit-explains-itself/check.sh +8 -2
  8. package/kit/gates/complexity-limit/check.sh +22 -4
  9. package/kit/gates/complexity-limit/gate.yml +7 -2
  10. package/kit/gates/complexity-limit/red/deep.js +15 -0
  11. package/kit/gates/dead-code/gate.yml +5 -0
  12. package/kit/gates/duplicate-code/check.sh +7 -0
  13. package/kit/gates/duplicate-code/gate.yml +10 -1
  14. package/kit/gates/file-size-limit/check.sh +7 -0
  15. package/kit/gates/file-size-limit/red/big.js +600 -0
  16. package/kit/gates/gate-not-weakened/check.sh +7 -0
  17. package/kit/gates/gate-not-weakened/green/suppress.js +4 -0
  18. package/kit/gates/gate-not-weakened/red/suppress.js +5 -0
  19. package/kit/gates/mcp-server-resolves/check.sh +7 -0
  20. package/kit/gates/no-phantom-package/check.sh +7 -0
  21. package/kit/gates/no-print-in-prod/gate.yml +8 -3
  22. package/kit/gates/personal-config-not-shared/check.sh +7 -0
  23. package/kit/gates/secrets-not-in-code/check.sh +7 -0
  24. package/kit/gates/secrets-not-in-code/gate.yml +20 -0
  25. package/kit/gates/secrets-not-in-code/green/config.js +4 -0
  26. package/kit/gates/secrets-not-in-code/red/leak.js +6 -0
  27. package/kit/gates/swallowed-error/gate.yml +8 -3
  28. package/kit/gates/test-has-assertion/check.sh +7 -0
  29. package/kit/gates/test-has-assertion/green/checkout.test.js +5 -0
  30. package/kit/gates/test-has-assertion/red/checkout.test.js +8 -0
  31. package/kit/gates/test-not-adjusted/check.sh +8 -2
  32. package/kit/gates/todo-without-task/check.sh +7 -0
  33. package/kit/gates/todo-without-task/gate.yml +7 -2
  34. package/kit/gates/todo-without-task/green/app.js +2 -0
  35. package/kit/gates/todo-without-task/red/later.js +4 -0
  36. package/llms.txt +4 -2
  37. package/package.json +1 -1
  38. package/tool/commands/badge.mjs +1 -1
  39. package/tool/commands/context.mjs +3 -1
  40. package/tool/commands/doctor.mjs +94 -152
  41. package/tool/commands/gates.mjs +3 -2
  42. package/tool/commands/probe.mjs +284 -44
  43. package/tool/commands/report.mjs +1 -1
  44. package/tool/commands/vitals.mjs +6 -1
  45. package/tool/i18n/en-docs.mjs +2 -1
  46. package/tool/i18n/en-gates.mjs +23 -5
  47. package/tool/i18n/en.mjs +16 -0
  48. package/tool/i18n/ru-docs.mjs +2 -1
  49. package/tool/i18n/ru-gates.mjs +24 -6
  50. package/tool/i18n/ru.mjs +16 -0
  51. package/tool/lib/adopt.mjs +98 -0
  52. package/tool/lib/cadence.mjs +36 -1
  53. package/tool/lib/execution.mjs +50 -1
  54. package/tool/lib/history.mjs +89 -8
  55. package/tool/lib/prove.mjs +2 -2
  56. package/tool/lib/repo.mjs +47 -0
  57. package/tool/lib/run.mjs +194 -0
  58. package/tool/selfcheck/gates.sh +42 -4
  59. package/tool/selfcheck/smoke/_fixture.mjs +24 -2
  60. package/tool/selfcheck/smoke/fail-closed.test.mjs +112 -0
  61. package/tool/selfcheck/smoke/own-samples.test.mjs +131 -0
  62. package/tool/selfcheck/units-cadence.mjs +37 -1
  63. package/tool/selfcheck/units-execution.mjs +78 -1
  64. package/tool/selfcheck/units-level.mjs +24 -0
  65. package/tool/selfcheck/units-probe.mjs +385 -22
  66. package/tool/selfcheck/units-repo.mjs +123 -1
  67. package/tool/selfcheck/units.mjs +34 -1
@@ -4,7 +4,27 @@ intent_en: keys, passwords and private keys do not end up in the code
4
4
  trigger:
5
5
  always: true
6
6
 
7
+ # Домашние страницы готовых инструментов, которые зовут рецепты ниже. Адреса сверены
8
+ # по реестру github 2026-09-10, а не написаны по памяти: три выдуманных адреса чужих
9
+ # репозиториев — записанная шишка комплекта.
10
+ tool: https://github.com/gitleaks/gitleaks
11
+
7
12
  recipes:
13
+ # Готовый аналог сильнее нашего, и об этом прямо сказано в README записи: `gitleaks` знает
14
+ # сотни форматов токенов и умеет читать историю, а не только рабочее дерево. Наша проверка —
15
+ # запасная, для тех, кто не хочет ставить лишний бинарь.
16
+ #
17
+ # Ключ `native`, а не язык: секреты ищутся в любом файле, и раскладывать одну команду по
18
+ # восьми языкам значило бы завести ровно тот повтор, против которого у нас есть гейт.
19
+ #
20
+ # `dir` — рабочее дерево; чтение истории (`gitleaks git`) сюда не берём: гейт обязан отвечать
21
+ # про ТЕКУЩЕЕ состояние, а прошлое лечится не проверкой, а ротацией ключа.
22
+ #
23
+ # ОСТОРОЖНО С КОДОМ ВОЗВРАТА: у `gitleaks` 1 означает «нашёл ЛИБО сломался» — по документации,
24
+ # проверено 2026-09-10. То есть для него «находка» и «сбой инструмента» неразличимы, и это
25
+ # ровно тот класс, который мы разделяем у себя. Пока принимаем как есть и говорим об этом
26
+ # вслух, а не делаем вид, что кодов три.
27
+ native: gitleaks dir --no-banner {dir}
8
28
  any: bash {gate}/check.sh {dir}
9
29
 
10
30
  proof: kit/docs/ai/project-baseline.md, пункт 5 — «секреты не хранятся в коде вообще: ни в истории, ни в примерах, ни в тестах»
@@ -0,0 +1,4 @@
1
+ // Ключ приходит из окружения. В коде остаётся только его ИМЯ — это не секрет.
2
+ export const STRIPE_KEY = process.env.STRIPE_KEY;
3
+
4
+ if (!STRIPE_KEY) throw new Error("STRIPE_KEY не задан: см. .env.example");
@@ -0,0 +1,6 @@
1
+ // Ключ в коде: он уедет в историю git и останется там навсегда.
2
+ //
3
+ // Строка намеренно КОРОЧЕ настоящего ключа Stripe и повторяет ту, что лежит в leak.go:
4
+ // правдоподобный ключ блокирует защита GitHub от секретов, и образец нельзя отправить в
5
+ // репозиторий вовсе.
6
+ export const STRIPE_KEY = "sk_live_51HxxQwErTyUiOpAsDfGh";
@@ -7,11 +7,16 @@ intent_en: an error is not silently swallowed — it is handled and logged, or r
7
7
  trigger:
8
8
  langs: python, javascript, typescript, go
9
9
 
10
+ # Домашние страницы готовых инструментов, которые зовут рецепты ниже. Адреса сверены
11
+ # по реестру github 2026-09-10, а не написаны по памяти: три выдуманных адреса чужих
12
+ # репозиториев — записанная шишка комплекта.
13
+ tool: https://github.com/astral-sh/ruff · https://github.com/eslint/eslint · https://github.com/kisielk/errcheck
14
+
10
15
  recipes:
11
16
  # BLE — ловля голого исключения, TRY400 — запись без трейса, SIM105 — перехват ради тишины.
12
- python: ruff check --select BLE,TRY400,SIM105 {dir}
13
- typescript: eslint --rule '{"no-empty":["error",{"allowEmptyCatch":false}]}' {dir}
14
- javascript: eslint --rule '{"no-empty":["error",{"allowEmptyCatch":false}]}' {dir}
17
+ python: ruff check -q --output-format=concise --select BLE,TRY400,SIM105 {dir}
18
+ typescript: eslint --no-config-lookup --ignore-pattern 'gates/*/red/**' --ignore-pattern 'gates/*/green/**' --rule '{"no-empty":["error",{"allowEmptyCatch":false}]}' {dir}
19
+ javascript: eslint --no-config-lookup --ignore-pattern 'gates/*/red/**' --ignore-pattern 'gates/*/green/**' --rule '{"no-empty":["error",{"allowEmptyCatch":false}]}' {dir}
15
20
  # errcheck — канонический инструмент Go под этот же предмет: ошибка присвоена и не проверена.
16
21
  go: errcheck -blank {dir}/...
17
22
 
@@ -25,6 +25,13 @@ if [ ! -f "$SKIP_LIB" ]; then
25
25
  exit 2
26
26
  fi
27
27
  . "$SKIP_LIB"
28
+ # Файл на месте — этого мало: подмена содержимого давала код 0. Метка стоит в КОНЦЕ _skip.sh,
29
+ # поэтому проверка ловит и обрыв файла на середине.
30
+ if [ "${AQK_SKIP_READY:-}" != 1 ]; then
31
+ echo "_skip.sh есть, но обход не собрался — проверка НЕ СОСТОЯЛАСЬ, а не прошла"
32
+ echo " почини: замени kit/gates/_skip.sh целым файлом из каталога"
33
+ exit 2
34
+ fi
28
35
 
29
36
  # Только файлы тестов. Слово «assert» в обычном коде — не тест, а проверка входа.
30
37
  FILES=$(find "$DIR" $(skip_find) -type f \( \
@@ -0,0 +1,5 @@
1
+ import { checkout } from "./checkout.js";
2
+
3
+ it("считает корзину", async () => {
4
+ expect(await checkout({ items: [1, 2] })).toEqual({ total: 3 });
5
+ });
@@ -0,0 +1,8 @@
1
+ import { checkout } from "./checkout.js";
2
+
3
+ it("считает корзину", async () => {
4
+ });
5
+
6
+ it("не пускает пустую корзину", () => {
7
+ expect(true).toBe(true);
8
+ });
@@ -56,8 +56,14 @@ if [ -d "$DIR/before" ] && [ -d "$DIR/after" ]; then
56
56
  REPO="$T"; RANGE="HEAD~1..HEAD"
57
57
  else
58
58
  if ! (cd "$DIR" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1); then
59
- echo "не git-репозиторий проверять нечего"
60
- exit 0
59
+ # НЕ «проверять нечего», а «проверить нечем»: без истории свидетель слеп, и код 0 здесь
60
+ # неотличим от «проверено и чисто». Договор комплекта: 0 — чисто, 1 — находка, остальное —
61
+ # не смогли. Тот же довод уже записан ниже про мелкий клон и в protection-not-removed:
62
+ # когда свидетель слеп, зеленеть нельзя.
63
+ echo "не git-репозиторий — историю смотреть нечем, проверка НЕ СОСТОЯЛАСЬ"
64
+ echo " почини: запусти проверку в репозитории, либо убери запись из манифеста —"
65
+ echo " почини: гейт, который не может посмотреть, не защищает ничего."
66
+ exit 2
61
67
  fi
62
68
  # Диапазон: по умолчанию последний коммит. Проект может назвать свой — так же, как это
63
69
  # делает `doctor --since`.
@@ -15,6 +15,13 @@ if [ ! -f "$SKIP_LIB" ]; then
15
15
  exit 2
16
16
  fi
17
17
  . "$SKIP_LIB"
18
+ # Файл на месте — этого мало: подмена содержимого давала код 0. Метка стоит в КОНЦЕ _skip.sh,
19
+ # поэтому проверка ловит и обрыв файла на середине.
20
+ if [ "${AQK_SKIP_READY:-}" != 1 ]; then
21
+ echo "_skip.sh есть, но обход не собрался — проверка НЕ СОСТОЯЛАСЬ, а не прошла"
22
+ echo " почини: замени kit/gates/_skip.sh целым файлом из каталога"
23
+ exit 2
24
+ fi
18
25
 
19
26
  # shellcheck disable=SC2086
20
27
  # Маркер обязан стоять В КОММЕНТАРИИ. Иначе гейт краснеет на имени переменной с таким же
@@ -4,10 +4,15 @@ intent_en: "fix later" markers are absent from finished code — a filed task re
4
4
  trigger:
5
5
  always: true
6
6
 
7
+ # Домашние страницы готовых инструментов, которые зовут рецепты ниже. Адреса сверены
8
+ # по реестру github 2026-09-10, а не написаны по памяти: три выдуманных адреса чужих
9
+ # репозиториев — записанная шишка комплекта.
10
+ tool: https://github.com/astral-sh/ruff · https://github.com/eslint/eslint
11
+
7
12
  recipes:
8
13
  any: bash {gate}/check.sh {dir}
9
14
  # Готовое правило точнее самописного и не требует поддержки.
10
- python: ruff check --select FIX,TD {dir}
11
- typescript: eslint --rule '{"no-warning-comments":["error",{"terms":["todo","fixme","hack","xxx"]}]}' {dir}
15
+ python: ruff check -q --output-format=concise --select FIX,TD {dir}
16
+ typescript: eslint --no-config-lookup --ignore-pattern 'gates/*/red/**' --ignore-pattern 'gates/*/green/**' --rule '{"no-warning-comments":["error",{"terms":["todo","fixme","hack","xxx"]}]}' {dir}
12
17
 
13
18
  proof: incidents/README.md — «2026-08-25 разбор 1069 коммитов»: 416 коммитов из 1069 оказались стабилизацией уже выкаченного
@@ -0,0 +1,2 @@
1
+ // XXX_LIMIT — имя переменной, а не маркер долга: слово внутри идентификатора долгом не делает.
2
+ export const XXX_LIMIT = 10;
@@ -0,0 +1,4 @@
1
+ export function send(to) {
2
+ // TODO: переписать на очередь
3
+ return deliver(to);
4
+ }
package/llms.txt CHANGED
@@ -3,7 +3,9 @@
3
3
  > A standard and a CLI that check whether a repository is ready to have its code written by AI
4
4
  > coding agents. Every rule the project promises to follow becomes a command with an exit code,
5
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.
6
+ > AQK-3, computed by a run — never by a questionnaire and never by a model's opinion. One step
7
+ > further than readiness scores: `probe` plants a known defect into a copy of the project and
8
+ > checks whether the DECLARED guards go red. "Tests exist" and "tests catch" are different claims.
7
9
 
8
10
  Vendor-neutral: works with any coding agent (Claude Code, Codex, Cursor, Gemini CLI, GitHub
9
11
  Copilot, Windsurf, Aider, OpenCode) and with no AI at all. It calls no vendor API and needs no
@@ -68,7 +70,7 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
68
70
  the files `init` writes are owned by root, so you cannot edit your own manifest. Debian-based
69
71
  on purpose: the gates are `sh`, `grep`, `awk`, `find` — under alpine's busybox they behave
70
72
  differently, and an image where the gates behave differently is worse than no image
71
- - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.10.1` with `min: 1`
73
+ - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.12.0` with `min: 1`
72
74
  (https://github.com/marketplace/actions/agent-quality-kit-aqk)
73
75
 
74
76
  ## What makes it different
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-quality-kit",
3
- "version": "0.10.1",
3
+ "version": "0.12.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": {
@@ -10,7 +10,7 @@ import { join } from "node:path";
10
10
  import { CWD, SELF, REPO_URL, c, exists, die } from "../lib/core.mjs";
11
11
  import { readManifest, assessLevel } from "../lib/manifest.mjs";
12
12
  import { proveGates } from "../lib/prove.mjs";
13
- import { runGates, declaredGates } from "./doctor.mjs";
13
+ import { runGates, declaredGates } from "../lib/run.mjs";
14
14
  import { L } from "../i18n/index.mjs";
15
15
 
16
16
  // Один разбор на запись и на чтение: значок, который мы печатаем, обязан читаться нами же.
@@ -80,7 +80,9 @@ function contextBlock(state, T = L.context) {
80
80
  if (pr.state === "never") out.push(T.probeNever);
81
81
  else if (pr.state === "off") out.push(T.probeOff);
82
82
  else if (pr.state === "unknown") out.push(T.probeUnknown);
83
- else if (pr.blind > 0) out.push(T.probeBlind(pr.blind, pr.state === "stale" ? pr.behind : 0));
83
+ // Имена агенту они нужнее числа: «один класс» не говорит, какой файл трогать осторожно.
84
+ else if (pr.blind > 0) out.push(T.probeBlind(pr.blind, pr.state === "stale" ? pr.behind : 0,
85
+ (pr.classes || []).map((b) => `${b.slug} (${b.file})`).join(", ")));
84
86
  else out.push(T.probeClean(pr.state === "stale" ? pr.behind : 0));
85
87
  }
86
88
 
@@ -5,13 +5,16 @@ import { join, resolve } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { scopeOutput, splitAdvice, changedFiles } from "../lib/scope.mjs";
7
7
  import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die } from "../lib/core.mjs";
8
- import { cmdProbe, probeStatus } from "./probe.mjs";
8
+ import { cmdProbe, probeStatus, blindAdvice } from "./probe.mjs";
9
9
  import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, advisorySet, layoutChecks, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
10
10
  import { proveGates } from "../lib/prove.mjs";
11
- import { detectFacts, readCatalog, triggerVerdict, browserServerAdvice } from "../lib/repo.mjs";
11
+ import { detectFacts, readCatalog, triggerVerdict, browserServerAdvice, startWith } from "../lib/repo.mjs";
12
+ import { proposeGates, ADOPT_FILES, ADOPT_SCRIPTS } from "../lib/adopt.mjs";
12
13
  import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
13
14
  import { L } from "../i18n/index.mjs";
15
+ import { countArbiters } from "./context.mjs";
14
16
  import { beginBrief, finishBrief } from "../lib/brief.mjs";
17
+ import { declaredGates, sinceRef, runGates, progress } from "../lib/run.mjs";
15
18
 
16
19
  // Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
17
20
  // место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
@@ -48,7 +51,7 @@ async function reportBaseline(man, facts) {
48
51
  );
49
52
  }
50
53
 
51
- async function reportCatalog(man, facts) {
54
+ async function reportCatalog(man, facts, probe = null) {
52
55
  const catalog = await readCatalog();
53
56
  if (!catalog.length) return;
54
57
 
@@ -115,6 +118,70 @@ async function reportCatalog(man, facts) {
115
118
  console.log(c.dim(`\n ${L.doctor.notApplicable(skip.length)}`));
116
119
  for (const [rec, why] of skip) console.log(c.dim(` · ${rec.slug.padEnd(22)} ${why}`));
117
120
  }
121
+ // ЧТО У ВАС УЖЕ ЕСТЬ — до итога и до списка крестов. Комплект, поставленный в проект с
122
+ // eslint, mocha и конвейером, показывал двадцать крестов и «держит машина 0»: мы считали
123
+ // только СВОИ записи, а чужие проверки не читали вовсе. С точки зрения владельца это
124
+ // неправда, и первое, что он видел, было обвинением. Предлагаем, а не вписываем: гейт в
125
+ // чужом манифесте без спроса — наше решение в чужом файле.
126
+ if (!declaredGates(man).length) {
127
+ const files = {};
128
+ for (const n of ADOPT_FILES) {
129
+ try { files[n] = await readFile(join(CWD, n), "utf8"); } catch { /* нет — и ладно */ }
130
+ }
131
+ for (const n of ADOPT_SCRIPTS) if (await exists(join(CWD, n))) files[n] = "";
132
+ const found = proposeGates(files);
133
+ if (found.length) {
134
+ console.log(`\n ${c.bold(L.doctor.haveAlready(found.length))}`);
135
+ for (const g of found) {
136
+ console.log(` ${c.green("✔")} ${g.name.padEnd(12)} ${c.dim(`${g.cmd} ← ${g.source}`)}`);
137
+ }
138
+ console.log(c.dim(` ${L.doctor.haveAlreadyHow(found.map((g) => `${g.name}: "${g.cmd}"`).join(" "))}`));
139
+ }
140
+ }
141
+
142
+ // ЧТО ВАШИ ПРОВЕРКИ ПРОПУСТИЛИ. Проба знала имена непойманных классов и писала в отметку одно
143
+ // число; человек в `doctor` не видел ничего. Это самое конкретное, что мы знаем о проекте, —
144
+ // не «хорошая практика», а брак, подсаженный в ЕГО файл и ЕГО проверками не замеченный, —
145
+ // поэтому стоит выше списка «с чего начать». Читается из файла: ничего не запускает.
146
+ const blindOnes = (probe?.classes || []).map((b) => [b, catalog.find((r) => r.slug === b.slug)]).filter(([, r]) => r);
147
+ if (blindOnes.length) {
148
+ console.log(`\n ${c.yellow("⚠")} ${c.bold(L.doctor.blindHeading(probe.behind))}`);
149
+ for (const [b, rec] of blindOnes) {
150
+ // Три случая, и сливать их нельзя. Гейт стоял и проба его ГОНЯЛА — «стоит, но здесь не
151
+ // ловит», самое ценное. Гейт объявлен, но проба его не гоняла (поставлен позже или
152
+ // медленный) — «поймает ли, покажет следующая», а не «пойман». Гейта нет — совет.
153
+ const ranIt = probe.ran?.has(rec.slug);
154
+ const now = facts.gateKeys.includes(rec.slug);
155
+ console.log(` ${now && !ranIt ? c.dim("~") : c.red("✘")} ${rec.slug.padEnd(22)} ${c.dim(`${rec.intent || ""} ← ${b.file}`)}`);
156
+ if (ranIt) { console.log(c.dim(` ${L.doctor.blindRan(rec.slug)}`)); continue; }
157
+ if (now) { console.log(c.dim(` ${L.doctor.blindInstalled}`)); continue; }
158
+ const adv = blindAdvice(rec, facts, {});
159
+ if (adv.command) console.log(c.dim(` ${L.doctor.startCmd(adv.command)}`));
160
+ else console.log(c.dim(` ${L.doctor.install(`${SELF} add ${rec.slug}`)}`));
161
+ }
162
+ console.log(c.dim(` ${L.doctor.blindMore(`${SELF} probe`)}`));
163
+ } else if (probe?.state === "never" && declaredGates(man).length) {
164
+ console.log(c.dim(`\n ${L.doctor.probeNever(`${SELF} probe`)}`));
165
+ }
166
+
167
+ // С ЧЕГО НАЧАТЬ. Двадцать одинаковых крестов — это ноль требований: закрывают первое
168
+ // попавшееся или не закрывают ничего. Порядок не по нашему вкусу: сперва то, что родилось из
169
+ // настоящего отказа И закрывается одной готовой командой.
170
+ if (todo.length > 3) {
171
+ const first = startWith(todo, facts, 3);
172
+ console.log(`\n ${c.bold(L.doctor.startWith)}`);
173
+ for (const rec of first) {
174
+ const adv = blindAdvice(rec, facts, {});
175
+ console.log(` ${c.yellow("→")} ${rec.slug.padEnd(22)} ${c.dim(rec.intent || "")}`);
176
+ if (adv.command) console.log(c.dim(` ${L.doctor.startCmd(adv.command)}`));
177
+ if (adv.tool) console.log(c.dim(` ${L.doctor.startTool(adv.tool)}`));
178
+ }
179
+ // Одна проверка руками — это разовый героизм. Сказать про хук здесь, а не в конце: человек
180
+ // читает первые строки и закрывает, а именно сейчас у него в руках список того, что стоит
181
+ // повесить перед пушем.
182
+ console.log(c.dim(`\n ${L.doctor.startHook}`));
183
+ }
184
+
118
185
  console.log(
119
186
  `\n ${c.bold(L.doctor.total)} ${L.doctor.totalHeld(held.length)}, ${L.doctor.totalTodo(c.yellow(todo.length))}, ` +
120
187
  (byOther.length ? `${L.doctor.totalCovered(byOther.length)}, ` : "") +
@@ -125,153 +192,6 @@ async function reportCatalog(man, facts) {
125
192
  return { held: held.length, todo: todo.length, todoRecs: todo };
126
193
  }
127
194
 
128
- // «Гейт объявлен» и «гейт работает» — разные утверждения. Первое читается из манифеста,
129
- // второе узнаётся только запуском. Пока doctor верил манифесту на слово, уровень означал
130
- // добросовестность автора, а не факт — ровно то, от чего мы защищаемся.
131
- //
132
- // Запуск чужих команд — по явной просьбе (--run), а не втихую: гейт бывает долгим и с
133
- // побочными действиями. Без флага doctor честно говорит, что не проверял.
134
-
135
- function declaredGates(man) {
136
- const g = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
137
- return Object.entries(g)
138
- .map(([name, cmd]) => [name, String(cmd || "").trim()])
139
- .filter(([, cmd]) => cmd);
140
- }
141
-
142
- // Ссылка, относительно которой сужается вывод: `--since main`, `--since HEAD~5`.
143
- // Без значения флаг бессмыслен — молча взять умолчание нельзя: «сужено не тем» неотличимо
144
- // от «не сужено».
145
- function sinceRef(argv = process.argv) {
146
- const i = argv.indexOf("--since");
147
- if (i === -1) return null;
148
- const v = argv[i + 1];
149
- return v && !v.startsWith("-") ? v : null;
150
- }
151
-
152
- function runGates(man, opts = {}) {
153
- const gates = declaredGates(man);
154
- if (!gates.length) return { failed: 0, ran: 0, results: [] };
155
- const advisory = advisorySet(man);
156
-
157
- // Сужение по дифу — договор с человеком, и он должен видеть, ЧТО именно сужено. Пустой диф
158
- // называется вслух: иначе «все гейты зелёные» означало бы «сравнили не с тем» и читалось бы
159
- // как успех. Это тот же класс, что и весь стандарт, только внутри нашего флага.
160
- const scoped = opts.since ? changedFiles(opts.since, CWD) : null;
161
- if (opts.since && scoped === null) die(L.doctor.sinceBadRef(opts.since));
162
- if (scoped) console.log(c.dim(`\n ${L.doctor.sinceHeading(opts.since, scoped.size)}`));
163
-
164
- console.log(c.bold(`\n ${L.doctor.runHeading}\n`));
165
- let failed = 0;
166
- const results = [];
167
-
168
- for (const [name, cmd] of gates) {
169
- const t0 = Date.now();
170
- const r = spawnSync(cmd, { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
171
- const secs = (Math.max(0, Date.now() - t0) / 1000).toFixed(1);
172
- // Вывод гейта запоминается целиком (с потолком, чтобы болтливый инструмент не съел память):
173
- // по нему считается покрытие дифа — какой файл вообще был назван хоть одной проверкой.
174
- // Без этого «готово = доказано» остаётся правилом, за которым следит только человек.
175
- const outAll = `${r.stdout || ""}${r.stderr || ""}`.slice(0, 200000);
176
-
177
- if (r.error && r.error.code === "ETIMEDOUT") {
178
- console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.timeout)}`);
179
- failed++;
180
- results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout, out: outAll });
181
- continue;
182
- }
183
- const code = r.status;
184
- if (code === 0) {
185
- // Совещательный называется и когда он зелёный. Иначе гейт, который уронить прогон НЕ
186
- // МОЖЕТ, по выводу неотличим от того, который может, — и список `advisory:` в манифесте
187
- // виден только в тот день, когда он покраснел. Измерено 2026-09-09: зелёный
188
- // совещательный печатался обычной галочкой, а README обещал, что список назван каждый
189
- // прогон. Тот же класс, что молчащий гейт, только про сам прибор.
190
- const quiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
191
- console.log(` ${c.green("✔")} ${name.padEnd(14)}${quiet} ${c.dim(`${secs}s · ${cmd}`)}`);
192
- // Зелёный гейт иногда всё-таки говорит человеку что-то важное: храповик, дошедший до цели,
193
- // просит убрать обёртку. Вывод успешного гейта не показывался вовсе, и это сообщение
194
- // уходило в никуда — тот же класс, что обрезанный совет у красного, только тише.
195
- // Показываем ровно строки с меткой совета: остальной вывод успешной проверки — шум.
196
- const okAdvice = splitAdvice(`${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean)).advice;
197
- for (const line of okAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
198
- results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), out: outAll });
199
- } else {
200
- const raw = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
201
- // Совет отделяется ДО сужения. Иначе он сам попадает под фильтр по путям: сообщение
202
- // храповика про вышедший срок называет путь к реестру, реестра в дифе нет, и гейт,
203
- // обязанный краснеть по сроку, печатался зелёным с пометкой «находки вне дифа».
204
- // Ровно то, что стандарт запрещает: срок без последствия. Найдено ревью 2026-09-06.
205
- const parted = splitAdvice(raw);
206
- let out = parted.findings;
207
- const alwaysAdvice = parted.advice;
208
-
209
- // Сужение до дифа. Три исхода, и все три называются вслух.
210
- if (scoped) {
211
- const s = scopeOutput(out, scoped);
212
- // Гейт, у которого находок нет вовсе, а есть только совет, сузить нечем: его вердикт
213
- // не про файлы. Признать такой успешным — вернуть ту же тишину другим путём.
214
- if (!s.scopable || out.length === 0) {
215
- // Гейт печатает вердикт без путей — сузить нечем. Признать его успешным значило бы
216
- // выдать провал за тишину; остаётся красным, и причина названа.
217
- // Совещательный не роняет прогон НИКОГДА — в том числе здесь. Раньше failed++ стоял
218
- // безусловно, и гейт, объявленный совещательным, валил сборку с `--since` только
219
- // потому, что в его выводе нет путей. Измерено 2026-09-09.
220
- const nsAdv = advisory.has(name);
221
- const nsMark = nsAdv ? c.yellow("!") : c.red("✘");
222
- const nsVerdict = nsAdv ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
223
- console.log(` ${nsMark} ${name.padEnd(14)} ${nsVerdict} ${c.dim(`· ${L.doctor.notScopable}`)}`);
224
- if (!nsAdv) failed++;
225
- results.push({ name, cmd, ok: false, secs, code, advisory: nsAdv, note: L.doctor.notScopable, out: outAll });
226
- continue;
227
- }
228
- if (s.findings === 0) {
229
- // Долг есть, но не в том, что внёс диф. Зелёный — но с числом спрятанного: молчаливое
230
- // «всё хорошо» здесь было бы неправдой.
231
- const sQuiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
232
- console.log(` ${c.green("✔")} ${name.padEnd(14)}${sQuiet} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
233
- results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), scopedAway: out.length, out: outAll });
234
- continue;
235
- }
236
- out = s.kept;
237
- }
238
-
239
- failed++;
240
- // Находки обрезаются, совет — никогда. Все записи каталога печатают «почини: …» последней
241
- // строкой, и при обрезке до трёх строк человек не видел именно её: находка без действия
242
- // закрывает окно, а не дефект.
243
- // Совещательный гейт показывает находки и не роняет прогон. Знак другой, чтобы «показано»
244
- // и «провалено» не читались одинаково; в сводке ниже он назван поимённо.
245
- const isAdvisory = advisory.has(name);
246
- if (isAdvisory) failed--;
247
- const mark = isAdvisory ? c.yellow("!") : c.red("✘");
248
- const verdict = isAdvisory ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
249
- console.log(` ${mark} ${name.padEnd(14)} ${verdict} ${c.dim(`· ${secs}s · ${cmd}`)}`);
250
- // ГОЛОВА И ХВОСТ, А НЕ ТОЛЬКО ГОЛОВА. Гейт, который сам является прогоном (наш `smoke`),
251
- // печатает сотни строк, и вердикт у него в конце — при обрезке до первых трёх человек
252
- // видел «программа разбирается» и ни слова о том, что упало. Час поисков в конвейере
253
- // 2026-09-09 стоил ровно этого. Голова нужна тоже: у сканирующих записей находки идут
254
- // с первой строки.
255
- const HEAD = 3, TAIL = 2;
256
- for (const line of out.slice(0, HEAD)) console.log(c.dim(` ${line.slice(0, 100)}`));
257
- if (out.length > HEAD + TAIL) {
258
- console.log(c.dim(` ${L.doctor.moreLines(out.length - HEAD - TAIL)}`));
259
- for (const line of out.slice(-TAIL)) console.log(c.dim(` ${line.slice(0, 100)}`));
260
- } else {
261
- for (const line of out.slice(HEAD)) console.log(c.dim(` ${line.slice(0, 100)}`));
262
- }
263
- // Совет тоже не бесконечен: гейт, зовущий помощник шесть раз, печатает его шесть раз.
264
- for (const line of alwaysAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
265
- results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll });
266
- }
267
- }
268
- // Совещательные, которые покраснели, называются вслух ВСЕГДА. Молчание о них — ровно та
269
- // тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
270
- const advisoryFailed = results.filter((x) => x.advisory && !x.ok).map((x) => x.name);
271
- if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
272
- return { failed, ran: gates.length, results, advisoryFailed };
273
- }
274
-
275
195
  // Короткий отчёт «что из этого реально брали» — не для человека, а для агента в следующей
276
196
  // сессии и для самого владельца: список объявленных гейтов молчит о том, сколько из них
277
197
  // действительно стоят и работают именно СЕЙЧАС. Перезаписывается каждым прогоном, не копится:
@@ -328,6 +248,21 @@ async function cmdDoctor() {
328
248
  const agents = join(CWD, entryFile);
329
249
  if (await exists(agents)) {
330
250
  const text = await readFile(agents, "utf8");
251
+
252
+ // Сколько обещаний НЕ сторожит машина. Считалось и печаталось это давно — но только в
253
+ // блоке `context`, который читает АГЕНТ. Человеку, который и назначен сторожем, `doctor`
254
+ // не говорил ни слова: единственный, кто обязан помнить о непроверяемом обещании, был
255
+ // единственным, кому о нём не сообщали.
256
+ //
257
+ // Найдено не нами: отчёт живого проекта 2026-09-10 — «всё, что касается масштаба, помечено
258
+ // aqk: человек. AQK отработал честно: потребовал назвать сторожа, мы назвали — и сторож не
259
+ // проверил». В нашем собственном своде так помечены 12 правил из 14.
260
+ const arb = countArbiters(text, ["человек", "human", "nobody"]);
261
+ if (arb.total && arb.human) {
262
+ console.log(`\n ${c.yellow("!")} ${L.doctor.rulesByHuman(arb.total, arb.machine, arb.human)}`);
263
+ console.log(c.dim(` ${L.doctor.rulesByHumanWhy}`));
264
+ }
265
+
331
266
  const emptyCommands = (text.match(/^- [^:]+: ``$/gm) || []).length;
332
267
  if (emptyCommands) {
333
268
  console.log(
@@ -357,7 +292,11 @@ async function cmdDoctor() {
357
292
  // Доказательство считается только при прогоне: узнать, ловит ли гейт брак, нельзя иначе как
358
293
  // запустив его по образцу. Без прогона ступени со второй помечаются «не доказано» — это
359
294
  // честнее, чем показывать их выполненными по наличию папок.
295
+ // Доказательство — секунды тишины до первой строки уровня; строка «идёт» их называет.
296
+ const bar = progress();
297
+ if (process.argv.includes("--run")) bar.show(c.dim(` ⋯ ${L.doctor.proving}`));
360
298
  const proof = process.argv.includes("--run") ? await proveGates(man) : null;
299
+ bar.clear();
361
300
  const { reached, steps } = await assessLevel(man, proof);
362
301
 
363
302
  console.log(c.bold(`\n ${L.doctor.levelHeading}\n`));
@@ -404,7 +343,10 @@ async function cmdDoctor() {
404
343
  await reportBaseline(man, facts);
405
344
  process.exit(0);
406
345
  }
407
- const cat = (await reportCatalog(man, facts)) || { held: 0, todo: 0, todoRecs: [] };
346
+ // Состояние пробы из файла отметки, миллисекунды. Нет его блок про пробу просто молчит.
347
+ let probe = null;
348
+ try { probe = await probeStatus(); } catch { /* пробы нет — и ладно */ }
349
+ const cat = (await reportCatalog(man, facts, probe)) || { held: 0, todo: 0, todoRecs: [] };
408
350
 
409
351
  // «Объявлен» ≠ «работает». Без --run говорим это вслух, а не молчим.
410
352
  const wantRun = process.argv.includes("--run");
@@ -489,4 +431,4 @@ async function cmdDoctor() {
489
431
 
490
432
  // Наружу — только команда. Остальное здесь же и используется: экспорт, который никто не
491
433
  // импортирует, читается как «это часть договора» и мешает менять внутренности.
492
- export { cmdDoctor, runGates, declaredGates, sinceRef };
434
+ export { cmdDoctor };
@@ -15,6 +15,7 @@ import {
15
15
  } from "../lib/repo.mjs";
16
16
  import { GATE_YML_TEMPLATE, CHECK_SH_TEMPLATE, README_TEMPLATE } from "../lib/templates.mjs";
17
17
  import { L } from "../i18n/index.mjs";
18
+ import { gateCommand } from "../lib/execution.mjs";
18
19
 
19
20
  // Ставит гейт из каталога в проект. Проверка КОПИРУЕТСЯ в репозиторий, а не остаётся
20
21
  // ссылкой в пакет: при установке через npx пакет временный, и завтра команда в манифесте
@@ -230,7 +231,7 @@ async function cmdRatchet(args) {
230
231
 
231
232
  // Снимок текущих нарушений — это и есть долг. Ключ без номера строки: правка соседней
232
233
  // строки не должна читаться как новое нарушение.
233
- const r = spawnSync(inner, { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
234
+ const r = spawnSync(gateCommand(inner), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
234
235
  if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {
235
236
  die(L.ratchet.notRunnable(slug, inner));
236
237
  }
@@ -438,7 +439,7 @@ async function cmdWhy(args) {
438
439
 
439
440
  // --- 3. объявлен: спрашиваем у него самого ---------------------------------
440
441
  console.log(c.dim(` ${L.why.declaredAs(cmd)}`));
441
- const r = spawnSync(String(cmd), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
442
+ const r = spawnSync(gateCommand(String(cmd)), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
442
443
  const ci = await runsInCi(slug, String(cmd));
443
444
 
444
445
  if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {