agent-quality-kit 0.12.0 → 0.14.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 (74) hide show
  1. package/README.md +43 -7
  2. package/README.ru.md +44 -7
  3. package/kit/gates/api-contract-has-arbiter/README.md +16 -1
  4. package/kit/gates/api-contract-has-arbiter/check.sh +66 -23
  5. package/kit/gates/complexity-limit/gate.yml +4 -0
  6. package/kit/gates/dead-code/gate.yml +4 -0
  7. package/kit/gates/entry-commands-exist/README.md +64 -0
  8. package/kit/gates/entry-commands-exist/check.sh +110 -0
  9. package/kit/gates/entry-commands-exist/gate.yml +19 -0
  10. package/kit/gates/entry-commands-exist/green/AGENTS.md +13 -0
  11. package/kit/gates/entry-commands-exist/green/Makefile +6 -0
  12. package/kit/gates/entry-commands-exist/green/justfile +2 -0
  13. package/kit/gates/entry-commands-exist/green/package.json +10 -0
  14. package/kit/gates/entry-commands-exist/red/AGENTS.md +9 -0
  15. package/kit/gates/entry-commands-exist/red/Makefile +2 -0
  16. package/kit/gates/entry-commands-exist/red/package.json +9 -0
  17. package/kit/gates/env-secrets-not-committed/README.md +73 -0
  18. package/kit/gates/env-secrets-not-committed/check.sh +139 -0
  19. package/kit/gates/env-secrets-not-committed/gate.yml +21 -0
  20. package/kit/gates/env-secrets-not-committed/green/.aqk-tracked +10 -0
  21. package/kit/gates/env-secrets-not-committed/green/.env +10 -0
  22. package/kit/gates/env-secrets-not-committed/green/.env.production +5 -0
  23. package/kit/gates/env-secrets-not-committed/green/.env.test +2 -0
  24. package/kit/gates/env-secrets-not-committed/red/.aqk-tracked +5 -0
  25. package/kit/gates/env-secrets-not-committed/red/.env +7 -0
  26. package/kit/gates/no-print-in-prod/gate.yml +4 -0
  27. package/kit/gates/swallowed-error/gate.yml +4 -0
  28. package/kit/gates/todo-without-task/gate.yml +4 -0
  29. package/llms.txt +8 -2
  30. package/package.json +1 -1
  31. package/tool/commands/badge.mjs +1 -1
  32. package/tool/commands/context.mjs +81 -9
  33. package/tool/commands/doctor-catalog.mjs +222 -0
  34. package/tool/commands/doctor.mjs +81 -209
  35. package/tool/commands/learn.mjs +119 -19
  36. package/tool/commands/probe.mjs +50 -68
  37. package/tool/commands/project.mjs +6 -1
  38. package/tool/commands/prompt.mjs +69 -0
  39. package/tool/commands/report.mjs +1 -1
  40. package/tool/commands/vitals.mjs +15 -11
  41. package/tool/i18n/en-docs.mjs +22 -1
  42. package/tool/i18n/en-gates.mjs +6 -1
  43. package/tool/i18n/en.mjs +78 -4
  44. package/tool/i18n/index.mjs +42 -3
  45. package/tool/i18n/ru-docs.mjs +24 -1
  46. package/tool/i18n/ru-gates.mjs +6 -1
  47. package/tool/i18n/ru.mjs +87 -4
  48. package/tool/lib/adopt.mjs +15 -1
  49. package/tool/lib/advice.mjs +115 -0
  50. package/tool/lib/annotate.mjs +66 -0
  51. package/tool/lib/brief.mjs +3 -1
  52. package/tool/lib/cadence.mjs +40 -1
  53. package/tool/lib/core.mjs +40 -1
  54. package/tool/lib/gate-worker.mjs +18 -0
  55. package/tool/lib/history.mjs +34 -5
  56. package/tool/lib/manifest.mjs +65 -21
  57. package/tool/lib/repo.mjs +47 -35
  58. package/tool/lib/run.mjs +98 -9
  59. package/tool/program.mjs +4 -0
  60. package/tool/selfcheck/smoke/_fixture.mjs +8 -3
  61. package/tool/selfcheck/smoke/api-contract.test.mjs +37 -0
  62. package/tool/selfcheck/smoke/corpus.test.mjs +151 -0
  63. package/tool/selfcheck/smoke/first-run.test.mjs +144 -0
  64. package/tool/selfcheck/smoke/verdict.test.mjs +91 -3
  65. package/tool/selfcheck/smoke.sh +10 -2
  66. package/tool/selfcheck/units-annotate.mjs +67 -0
  67. package/tool/selfcheck/units-cadence.mjs +40 -1
  68. package/tool/selfcheck/units-context.mjs +64 -1
  69. package/tool/selfcheck/units-learn.mjs +32 -0
  70. package/tool/selfcheck/units-level.mjs +97 -3
  71. package/tool/selfcheck/units-probe.mjs +2 -1
  72. package/tool/selfcheck/units-prompt.mjs +106 -0
  73. package/tool/selfcheck/units-repo.mjs +44 -1
  74. package/tool/selfcheck/units-verdict.mjs +76 -0
@@ -0,0 +1,222 @@
1
+ // tool/commands/doctor-catalog.mjs — что каталог говорит об ЭТОМ репозитории: какие записи
2
+ // держит машина, что у проекта уже есть, что пропустила проба, с чего начать, что неприменимо.
3
+ //
4
+ // Вынесено из doctor.mjs, когда тот дорос до 496 строк при пределе 500 (наш же
5
+ // `file-size-limit`). Шов настоящий: прогон гейтов и уровень — про то, что ОБЪЯВЛЕНО и как оно
6
+ // отработало; здесь — про каталог против фактов репозитория, и меняется это в другие дни.
7
+
8
+ import { readFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { CWD, SELF, c } from "../lib/core.mjs";
11
+ import { coversOf, coversUnproven } from "../lib/manifest.mjs";
12
+ import { readCatalog, browserServerAdvice } from "../lib/repo.mjs";
13
+ import { startWith, catalogBuckets, blindAdvice } from "../lib/advice.mjs";
14
+ import { proposeGates, readAdoptFiles } from "../lib/adopt.mjs";
15
+ import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
16
+ import { declaredGates } from "../lib/run.mjs";
17
+ import { L } from "../i18n/index.mjs";
18
+
19
+ // Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
20
+ // место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
21
+ async function reportBaseline(man, facts) {
22
+ const { readdir, readFile } = await import("node:fs/promises");
23
+ let files = [];
24
+ try {
25
+ files = (await readdir(CWD, { withFileTypes: true })).map((d) => d.name);
26
+ } catch { /* пустой список честнее выдуманного: ни один пункт не подтвердится */ }
27
+
28
+ // Файлы зависимостей читаются целиком и склеиваются: трекер ошибок объявляют по-разному в
29
+ // каждой экосистеме, а искать его надо одинаково.
30
+ let depsText = "";
31
+ for (const f of DEP_FILES) {
32
+ if (!files.some((n) => n.toLowerCase() === f)) continue;
33
+ try { depsText += (await readFile(join(CWD, f), "utf8")).toLowerCase() + "\n"; } catch { /* нечитаемый файл — просто не признак */ }
34
+ }
35
+
36
+ const rows = assessBaseline({ files, gateKeys: facts.gateKeys, facts, manifest: man || {}, depsText });
37
+ const okCount = rows.filter((r) => r.ok).length;
38
+
39
+ console.log(c.bold(`\n ${L.baseline.heading}\n`));
40
+ console.log(c.dim(` ${L.baseline.intro(rows.length, BASELINE_TOTAL)}`));
41
+ console.log(c.dim(` ${L.baseline.caveat}\n`));
42
+ for (const r of rows) {
43
+ const mark = r.ok ? c.green("✔") : c.yellow("✘");
44
+ const title = L.baseline.titles[r.key] || r.key;
45
+ console.log(` ${mark} ${String(r.n).padStart(2)}. ${title}`);
46
+ console.log(c.dim(` ${r.ok ? L.baseline.by(r.by) : L.baseline.none}`));
47
+ }
48
+ console.log(
49
+ "\n " + (okCount === rows.length ? c.green(`${okCount}/${rows.length}`) : c.yellow(`${okCount}/${rows.length}`)) +
50
+ c.dim(` · ${L.baseline.eyes(BASELINE_TOTAL - rows.length, "kit/docs/ai/project-baseline.md")}\n`)
51
+ );
52
+ }
53
+
54
+ async function reportCatalog(man, facts, probe = null, verbose = true) {
55
+ const catalog = await readCatalog();
56
+ if (!catalog.length) return;
57
+
58
+ // Четвёртая корзина, а не третья: «закрыто другим арбитром» — это НЕ «не поставлено».
59
+ // Пока их считали вместе, вывод каждый прогон называл долгом то, что уже держит biome или
60
+ // ruff. Просьба первого чужого пользователя; она же — наша собственная норма про вывод.
61
+ const { covered, unknownGates } = coversOf(man);
62
+ const { held, todo, skip, byOther } = catalogBuckets(catalog, facts, covered);
63
+
64
+ console.log(c.bold(`\n ${L.doctor.gatesHeading}\n`));
65
+ const marks = ["has_ci", "has_db", "has_docker", "has_tests", "has_deps"]
66
+ .filter((k) => facts[k])
67
+ .map((k) => k.replace("has_", ""));
68
+ console.log(
69
+ c.dim(` ${L.doctor.langs}: ${[...facts.langs].join(", ") || L.doctor.langsUnknown} · ${L.doctor.files}: ${facts.files}` +
70
+ (marks.length ? ` · ${L.doctor.hasThings}: ${marks.join(", ")}` : "") + "\n")
71
+ );
72
+
73
+ if (verbose) for (const rec of held) console.log(` ${c.green("✔")} ${rec.slug.padEnd(22)} ${c.dim(rec.intent || "")}`);
74
+ else if (held.length) console.log(` ${c.green("✔")} ${L.doctor.heldQuiet(held.length, `${SELF} doctor --verbose`)}`);
75
+ // ЧТО У ВАС УЖЕ ЕСТЬ — до итога и до списка крестов. Комплект, поставленный в проект с
76
+ // eslint, mocha и конвейером, показывал двадцать крестов и «держит машина 0»: мы считали
77
+ // только СВОИ записи, а чужие проверки не читали вовсе. С точки зрения владельца это
78
+ // неправда, и первое, что он видел, было обвинением. Предлагаем, а не вписываем: гейт в
79
+ // чужом манифесте без спроса — наше решение в чужом файле.
80
+ if (!declaredGates(man).length) {
81
+ const found = proposeGates(await readAdoptFiles(CWD));
82
+ if (found.length) {
83
+ console.log(`\n ${c.bold(L.doctor.haveAlready(found.length))}`);
84
+ for (const g of found) {
85
+ console.log(` ${c.green("✔")} ${g.name.padEnd(12)} ${c.dim(`${g.cmd} ← ${g.source}`)}`);
86
+ }
87
+ console.log(c.dim(` ${L.doctor.haveAlreadyHow(found.map((g) => `${g.name}: "${g.cmd}"`).join(" "))}`));
88
+ }
89
+ }
90
+
91
+ // ЧТО ВАШИ ПРОВЕРКИ ПРОПУСТИЛИ. Проба знала имена непойманных классов и писала в отметку одно
92
+ // число; человек в `doctor` не видел ничего. Это самое конкретное, что мы знаем о проекте, —
93
+ // не «хорошая практика», а брак, подсаженный в ЕГО файл и ЕГО проверками не замеченный, —
94
+ // поэтому стоит выше списка «с чего начать». Читается из файла: ничего не запускает.
95
+ const blindOnes = (probe?.classes || []).map((b) => [b, catalog.find((r) => r.slug === b.slug)]).filter(([, r]) => r);
96
+ if (blindOnes.length) {
97
+ console.log(`\n ${c.yellow("⚠")} ${c.bold(L.doctor.blindHeading(probe.behind))}`);
98
+ for (const [b, rec] of blindOnes) {
99
+ // Три случая, и сливать их нельзя. Гейт стоял и проба его ГОНЯЛА — «стоит, но здесь не
100
+ // ловит», самое ценное. Гейт объявлен, но проба его не гоняла (поставлен позже или
101
+ // медленный) — «поймает ли, покажет следующая», а не «пойман». Гейта нет — совет.
102
+ const ranIt = probe.ran?.has(rec.slug);
103
+ const now = facts.gateKeys.includes(rec.slug);
104
+ console.log(` ${now && !ranIt ? c.dim("~") : c.red("✘")} ${rec.slug.padEnd(22)} ${c.dim(`${rec.intent || ""} ← ${b.file}`)}`);
105
+ if (ranIt) { console.log(c.dim(` ${L.doctor.blindRan(rec.slug)}`)); continue; }
106
+ if (now) { console.log(c.dim(` ${L.doctor.blindInstalled}`)); continue; }
107
+ const adv = blindAdvice(rec, facts, {});
108
+ if (adv.command) console.log(c.dim(` ${L.doctor.startCmd(adv.command)}`));
109
+ else console.log(c.dim(` ${L.doctor.install(`${SELF} add ${rec.slug}`)}`));
110
+ }
111
+ console.log(c.dim(` ${L.doctor.blindMore(`${SELF} probe`)}`));
112
+ } else if (probe?.state === "never" && declaredGates(man).length) {
113
+ console.log(c.dim(`\n ${L.doctor.probeNever(`${SELF} probe`)}`));
114
+ }
115
+
116
+ // С ЧЕГО НАЧАТЬ. Двадцать одинаковых крестов — это ноль требований: закрывают первое
117
+ // попавшееся или не закрывают ничего. Порядок не по нашему вкусу: сперва то, что родилось из
118
+ // настоящего отказа И закрывается одной готовой командой.
119
+ const first = todo.length > 3 ? startWith(todo, facts, 3) : [];
120
+ if (first.length) {
121
+ console.log(`\n ${c.bold(L.doctor.startWith)}`);
122
+ for (const rec of first) {
123
+ const adv = blindAdvice(rec, facts, {});
124
+ console.log(` ${c.yellow("→")} ${rec.slug.padEnd(22)} ${c.dim(rec.intent || "")}`);
125
+ if (adv.command) console.log(c.dim(` ${L.doctor.startCmd(adv.command)}`));
126
+ if (adv.tool) console.log(c.dim(` ${L.doctor.startTool(adv.tool)}`));
127
+ }
128
+ // Одна проверка руками — это разовый героизм. Сказать про хук здесь, а не в конце: человек
129
+ // читает первые строки и закрывает, а именно сейчас у него в руках список того, что стоит
130
+ // повесить перед пушем.
131
+ console.log(c.dim(`\n ${L.doctor.startHook}`));
132
+ }
133
+
134
+ // ОСТАЛЬНОЕ — ПОСЛЕ ГЛАВНОГО И СЖАТО. Список шёл первым, по две строки на запись (вторая —
135
+ // «поставить: aqk add …»), и на requests главное начиналось со строки 84 из 102: человек
136
+ // читает сверху и закрывает раньше. Разбор соседа 2026-09-11 (research/competitors/agentlint.md):
137
+ // там первыми идут пять главных исправлений. Записи не теряются — теряется повтор подсказки.
138
+ const rest = todo.filter((r) => !first.includes(r));
139
+ if (rest.length) {
140
+ if (first.length) console.log(`\n ${c.bold(L.doctor.todoRest(rest.length))}`);
141
+ else console.log("");
142
+ // ○, а не ✘: запись не установлена — это не падение. Крест в зелёном прогоне глаз читает
143
+ // как провал, и через неделю человек перестаёт смотреть на красное вообще (отзыв с живого
144
+ // проекта 2026-09-11). ✘ остаётся за тем, что упало или пропустило брак.
145
+ for (const rec of rest) console.log(` ${c.dim("○")} ${rec.slug.padEnd(22)} ${rec.intent || ""}`);
146
+ console.log(c.dim(` ${L.doctor.todoRestHow(SELF)}`));
147
+ }
148
+
149
+ // Второстепенное — в конце: что закрыто чужим арбитром, что неприменимо, советы без вердикта.
150
+ if (byOther.length) {
151
+ console.log(c.dim(`\n ${L.doctor.coveredBy(byOther.length)}`));
152
+ for (const [rec, gate] of byOther) console.log(c.dim(` ~ ${rec.slug.padEnd(22)} ${L.doctor.coveredByGate(gate)}`));
153
+ }
154
+ // Гейт, которого нет в gates:, не закрывает ничего — и молчать об этом нельзя: человек
155
+ // считает запись закрытой, а её не держит никто. Называется поимённо, жёлтым.
156
+ if (unknownGates.length) {
157
+ console.log(c.yellow(`\n ${L.doctor.coversUnknown(unknownGates.join(", "))}`));
158
+ }
159
+ // Заявка «эту запись держит наш линтер» сверяется с кодами правил из рецепта записи.
160
+ // Замерено на живом ruff.toml: девятнадцать групп правил, а print() не ловится — и заявка
161
+ // сняла бы запись с долга, не закрыв её ничем.
162
+ // Конфиги — ПО ЛИНТЕРАМ, а не одной склейкой: заявка сверяется правилами того линтера,
163
+ // которым закрыт гейт (отзыв с живого проекта 2026-09-11 — коды ruff искались в biome.json).
164
+ const readAll = async (names) => {
165
+ let t = "";
166
+ for (const f of names) { try { t += await readFile(join(CWD, f), "utf8") + "\n"; } catch { /* нет файла */ } }
167
+ return t;
168
+ };
169
+ let scripts = {}, pkgText = "";
170
+ try { pkgText = await readFile(join(CWD, "package.json"), "utf8"); scripts = JSON.parse(pkgText)?.scripts || {}; } catch { /* нет или не JSON */ }
171
+ const configs = {
172
+ // ruff.toml и .ruff.toml — конфиг ruff целиком, слово «ruff» в них писать незачем (поймал наш же
173
+ // smoke: `extend-select = [..., "T20"]` выбрасывался). pyproject.toml — только если в нём есть
174
+ // раздел ruff: он есть почти у каждого python-проекта и без ruff.
175
+ ruff: (await readAll(["ruff.toml", ".ruff.toml"])) +
176
+ ((await readAll(["pyproject.toml"])).match(/^\[tool\.ruff[\s\S]*/m)?.[0] || ""),
177
+ eslint: (await readAll([".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts"])) +
178
+ (/"eslintConfig"/.test(pkgText) ? pkgText : ""),
179
+ biome: await readAll(["biome.json", "biome.jsonc"]),
180
+ scripts,
181
+ };
182
+ for (const u of coversUnproven(man, catalog, configs)) {
183
+ if (u.kind === "unproven") {
184
+ console.log(c.yellow(`\n ${L.doctor.coversUnproven(u.entry, u.gate, u.codes.join(", "))}`));
185
+ console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
186
+ } else if (u.kind === "impossible") {
187
+ console.log(c.yellow(`\n ${L.doctor.coversImpossible(u.entry, u.gate, u.linter)}`));
188
+ console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
189
+ } else {
190
+ console.log(c.dim(`\n ${L.doctor.coversCantCheck(u.entry, u.gate)}`));
191
+ }
192
+ }
193
+ // Не вердикт, а совет: отсутствие браузерного сервера — незанятая возможность, а не дефект.
194
+ // Поэтому строка тусклая и без значка, и её нет у проекта без интерфейса.
195
+ let mcpText = "";
196
+ for (const f of [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".claude/mcp.json"]) {
197
+ try { mcpText += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
198
+ }
199
+ const browser = browserServerAdvice(facts, mcpText);
200
+ if (browser) {
201
+ console.log(c.dim(`\n ${L.doctor.noBrowserServer}`));
202
+ console.log(c.dim(` ${L.doctor.noBrowserServerHow(browser.servers.join(" · "))}`));
203
+ }
204
+ if (skip.length) {
205
+ if (verbose) {
206
+ console.log(c.dim(`\n ${L.doctor.notApplicable(skip.length)}`));
207
+ for (const [rec, why] of skip) console.log(c.dim(` · ${rec.slug.padEnd(22)} ${why}`));
208
+ } else {
209
+ console.log(c.dim(`\n ${L.doctor.skipQuiet(skip.length, `${SELF} doctor --verbose`)}`));
210
+ }
211
+ }
212
+ console.log(
213
+ `\n ${c.bold(L.doctor.total)} ${L.doctor.totalHeld(held.length)}, ${L.doctor.totalTodo(c.yellow(todo.length))}, ` +
214
+ (byOther.length ? `${L.doctor.totalCovered(byOther.length)}, ` : "") +
215
+ c.dim(L.doctor.totalSkip(skip.length)) + "\n"
216
+ );
217
+ // Числа отдаются наружу, а не пересчитываются второй раз: два счёта одного и того же
218
+ // расходятся ровно так же, как два списка команд.
219
+ return { held: held.length, todo: todo.length, todoRecs: todo };
220
+ }
221
+
222
+ export { reportBaseline, reportCatalog };
@@ -3,200 +3,23 @@
3
3
  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
- import { scopeOutput, splitAdvice, changedFiles } from "../lib/scope.mjs";
7
- import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die } from "../lib/core.mjs";
8
- import { cmdProbe, probeStatus, blindAdvice } from "./probe.mjs";
9
- import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, advisorySet, layoutChecks, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
6
+ import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die, RUNTIME_FILES } from "../lib/core.mjs";
7
+ import { cmdProbe, probeStatus } from "./probe.mjs";
8
+ import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, layoutChecks, unparsedLines } from "../lib/manifest.mjs";
10
9
  import { proveGates } from "../lib/prove.mjs";
11
- import { detectFacts, readCatalog, triggerVerdict, browserServerAdvice, startWith } from "../lib/repo.mjs";
12
- import { proposeGates, ADOPT_FILES, ADOPT_SCRIPTS } from "../lib/adopt.mjs";
13
- import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
10
+ import { detectFacts, claudeShimFor } from "../lib/repo.mjs";
11
+ import { reportBaseline, reportCatalog } from "./doctor-catalog.mjs";
14
12
  import { L } from "../i18n/index.mjs";
15
13
  import { countArbiters } from "./context.mjs";
16
14
  import { beginBrief, finishBrief } from "../lib/brief.mjs";
17
- import { declaredGates, sinceRef, runGates, progress } from "../lib/run.mjs";
18
-
19
- // Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
20
- // место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
21
- async function reportBaseline(man, facts) {
22
- const { readdir, readFile } = await import("node:fs/promises");
23
- let files = [];
24
- try {
25
- files = (await readdir(CWD, { withFileTypes: true })).map((d) => d.name);
26
- } catch { /* пустой список честнее выдуманного: ни один пункт не подтвердится */ }
27
-
28
- // Файлы зависимостей читаются целиком и склеиваются: трекер ошибок объявляют по-разному в
29
- // каждой экосистеме, а искать его надо одинаково.
30
- let depsText = "";
31
- for (const f of DEP_FILES) {
32
- if (!files.some((n) => n.toLowerCase() === f)) continue;
33
- try { depsText += (await readFile(join(CWD, f), "utf8")).toLowerCase() + "\n"; } catch { /* нечитаемый файл — просто не признак */ }
34
- }
35
-
36
- const rows = assessBaseline({ files, gateKeys: facts.gateKeys, facts, manifest: man || {}, depsText });
37
- const okCount = rows.filter((r) => r.ok).length;
38
-
39
- console.log(c.bold(`\n ${L.baseline.heading}\n`));
40
- console.log(c.dim(` ${L.baseline.intro(rows.length, BASELINE_TOTAL)}`));
41
- console.log(c.dim(` ${L.baseline.caveat}\n`));
42
- for (const r of rows) {
43
- const mark = r.ok ? c.green("✔") : c.yellow("✘");
44
- const title = L.baseline.titles[r.key] || r.key;
45
- console.log(` ${mark} ${String(r.n).padStart(2)}. ${title}`);
46
- console.log(c.dim(` ${r.ok ? L.baseline.by(r.by) : L.baseline.none}`));
47
- }
48
- console.log(
49
- "\n " + (okCount === rows.length ? c.green(`${okCount}/${rows.length}`) : c.yellow(`${okCount}/${rows.length}`)) +
50
- c.dim(` · ${L.baseline.eyes(BASELINE_TOTAL - rows.length, "kit/docs/ai/project-baseline.md")}\n`)
51
- );
52
- }
53
-
54
- async function reportCatalog(man, facts, probe = null) {
55
- const catalog = await readCatalog();
56
- if (!catalog.length) return;
57
-
58
- // Четвёртая корзина, а не третья: «закрыто другим арбитром» — это НЕ «не поставлено».
59
- // Пока их считали вместе, вывод каждый прогон называл долгом то, что уже держит biome или
60
- // ruff. Просьба первого чужого пользователя; она же — наша собственная норма про вывод.
61
- const { covered, unknownGates } = coversOf(man);
62
- const held = [], todo = [], skip = [], byOther = [];
63
- for (const rec of catalog) {
64
- const v = triggerVerdict(rec, facts);
65
- if (!v.applies) skip.push([rec, v.why]);
66
- else if (facts.gateKeys.includes(rec.slug)) held.push(rec);
67
- else if (covered.has(rec.slug)) byOther.push([rec, covered.get(rec.slug)]);
68
- else todo.push(rec);
69
- }
70
-
71
- console.log(c.bold(`\n ${L.doctor.gatesHeading}\n`));
72
- const marks = ["has_ci", "has_db", "has_docker", "has_tests", "has_deps"]
73
- .filter((k) => facts[k])
74
- .map((k) => k.replace("has_", ""));
75
- console.log(
76
- c.dim(` ${L.doctor.langs}: ${[...facts.langs].join(", ") || L.doctor.langsUnknown} · ${L.doctor.files}: ${facts.files}` +
77
- (marks.length ? ` · ${L.doctor.hasThings}: ${marks.join(", ")}` : "") + "\n")
78
- );
79
-
80
- for (const rec of held) console.log(` ${c.green("✔")} ${rec.slug.padEnd(22)} ${c.dim(rec.intent || "")}`);
81
- for (const rec of todo) {
82
- console.log(` ${c.yellow("✘")} ${rec.slug.padEnd(22)} ${rec.intent || ""}`);
83
- console.log(c.dim(` ${L.doctor.install(`${SELF} add ${rec.slug}`)}`));
84
- }
85
- if (byOther.length) {
86
- console.log(c.dim(`\n ${L.doctor.coveredBy(byOther.length)}`));
87
- for (const [rec, gate] of byOther) console.log(c.dim(` ~ ${rec.slug.padEnd(22)} ${L.doctor.coveredByGate(gate)}`));
88
- }
89
- // Гейт, которого нет в gates:, не закрывает ничего — и молчать об этом нельзя: человек
90
- // считает запись закрытой, а её не держит никто. Называется поимённо, жёлтым.
91
- if (unknownGates.length) {
92
- console.log(c.yellow(`\n ${L.doctor.coversUnknown(unknownGates.join(", "))}`));
93
- }
94
- // Заявка «эту запись держит наш линтер» сверяется с кодами правил из рецепта записи.
95
- // Замерено на живом ruff.toml: девятнадцать групп правил, а print() не ловится — и заявка
96
- // сняла бы запись с долга, не закрыв её ничем.
97
- let linterCfg = "";
98
- for (const f of ["ruff.toml", ".ruff.toml", "pyproject.toml", ".eslintrc.json", "eslint.config.js", "eslint.config.mjs", "biome.json"]) {
99
- try { linterCfg += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
100
- }
101
- const unproven = coversUnproven(man, catalog, linterCfg);
102
- for (const u of unproven) {
103
- console.log(c.yellow(`\n ${L.doctor.coversUnproven(u.entry, u.gate, u.codes.join(", "))}`));
104
- console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
105
- }
106
- // Не вердикт, а совет: отсутствие браузерного сервера — незанятая возможность, а не дефект.
107
- // Поэтому строка тусклая и без значка, и её нет у проекта без интерфейса.
108
- let mcpText = "";
109
- for (const f of [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".claude/mcp.json"]) {
110
- try { mcpText += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
111
- }
112
- const browser = browserServerAdvice(facts, mcpText);
113
- if (browser) {
114
- console.log(c.dim(`\n ${L.doctor.noBrowserServer}`));
115
- console.log(c.dim(` ${L.doctor.noBrowserServerHow(browser.servers.join(" · "))}`));
116
- }
117
- if (skip.length) {
118
- console.log(c.dim(`\n ${L.doctor.notApplicable(skip.length)}`));
119
- for (const [rec, why] of skip) console.log(c.dim(` · ${rec.slug.padEnd(22)} ${why}`));
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
-
185
- console.log(
186
- `\n ${c.bold(L.doctor.total)} ${L.doctor.totalHeld(held.length)}, ${L.doctor.totalTodo(c.yellow(todo.length))}, ` +
187
- (byOther.length ? `${L.doctor.totalCovered(byOther.length)}, ` : "") +
188
- c.dim(L.doctor.totalSkip(skip.length)) + "\n"
189
- );
190
- // Числа отдаются наружу, а не пересчитываются второй раз: два счёта одного и того же
191
- // расходятся ровно так же, как два списка команд.
192
- return { held: held.length, todo: todo.length, todoRecs: todo };
193
- }
15
+ import { declaredGates, sinceRef, runGates, progress, listArg } from "../lib/run.mjs";
16
+ import { autoProbeAllowed, levelLimits } from "../lib/cadence.mjs";
194
17
 
195
18
  // Короткий отчёт «что из этого реально брали» — не для человека, а для агента в следующей
196
19
  // сессии и для самого владельца: список объявленных гейтов молчит о том, сколько из них
197
20
  // действительно стоят и работают именно СЕЙЧАС. Перезаписывается каждым прогоном, не копится:
198
21
  // история — дело git-лога коммитов с этим отчётом, если владелец решит его коммитить.
199
- async function writeRunReport({ version, reached, results }) {
22
+ async function writeRunReport({ version, reached, results, skipped = [] }) {
200
23
  const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
201
24
  const ok = results.filter((r) => r.ok).length;
202
25
  const lines = [
@@ -205,6 +28,9 @@ async function writeRunReport({ version, reached, results }) {
205
28
  `${L.report.level}: AQK-${reached < 0 ? L.doctor.levelNone : reached}`,
206
29
  "",
207
30
  ...results.map((r) => `${r.ok ? "✔" : "✘"} ${r.name} — ${r.secs}s${r.ok ? "" : ` (${r.note || L.doctor.exitCode(r.code)})`}`),
31
+ // Пропущенные по --skip/--only — строкой «~»: блок для агента читает их как «не запускались»,
32
+ // а не как зелёные. Молчание о них прочиталось бы как «проверено».
33
+ ...skipped.map((n) => `~ ${n} — ${L.report.skippedBySelect}`),
208
34
  "",
209
35
  L.report.summary(ok, results.length),
210
36
  ].filter((l) => l !== null);
@@ -214,8 +40,30 @@ async function writeRunReport({ version, reached, results }) {
214
40
  await writeFile(dst, lines.join("\n") + "\n", "utf8");
215
41
  }
216
42
 
43
+ // ПРОБА ЗАПУСКАЕТСЯ САМА, раз в сто коммитов, — кроме конвейера (там это минуты сюрпризом в
44
+ // быстрой проверке, отзыв с живого проекта 2026-09-11). Не влияет на код возврата никогда: это
45
+ // осмотр, а не порог. Отдельной функцией: внутри прогона эта лесенка дала вложенность 6, и наш же
46
+ // complexity-limit её поймал.
47
+ async function autoProbe(brief) {
48
+ let st;
49
+ try { st = await probeStatus(); } catch { return; /* пробы нет — прогон про гейты, а не про неё */ }
50
+ if (st.badEvery !== undefined) { console.log(c.yellow(`\n ${L.probe.badEvery(st.badEvery)}`)); return; }
51
+ if (st.state !== "never" && st.state !== "stale") return;
52
+ if (!autoProbeAllowed({ brief })) { console.log(c.dim(`\n ${L.probe.autoNotInCi(`${SELF} probe`)}`)); return; }
53
+ // Сообщение обязано быть верным в обоих случаях: первая версия печатала «прошло сто коммитов»
54
+ // и там, где пробы не было ВОВСЕ — число бралось из порога, а не из факта.
55
+ console.log(c.dim(`\n ${st.state === "never" ? L.probe.autoFirst : L.probe.auto(st.behind)}`));
56
+ try { await cmdProbe([], { auto: true }); } catch { /* проба не состоялась — прогон это не роняет */ }
57
+ }
58
+
217
59
  async function cmdDoctor() {
218
60
  const brief = process.argv.includes("--brief");
61
+ // Коротко по умолчанию, поимённо по `--verbose`. Отзыв с живого проекта 2026-09-11: вывод на
62
+ // сто строк, из них семьдесят — зелёные галочки, и красное теряется между ними. Сворачивается
63
+ // только то, что ничего не требует: пройденное, неприменимое, пояснения. Упавшее, совет и
64
+ // «что поставить» печатаются всегда.
65
+ // AQK_VERBOSE=1 — то же для конвейера, где лог читают потом и целиком.
66
+ const verbose = process.argv.includes("--verbose") || process.env.AQK_VERBOSE === "1";
219
67
  const buf = brief ? beginBrief() : null;
220
68
  // Версия в шапке — единственное, что привязывает баг-репорт к коммиту, если ставили не из
221
69
  // релиза: без неё "у меня не работает" ничем не отличается от любой другой версии за год.
@@ -236,12 +84,33 @@ async function cmdDoctor() {
236
84
  const checks = layoutChecks(man, inKit);
237
85
 
238
86
  let missing = 0;
239
- for (const [path, what] of checks) {
87
+ for (const [path, what, required] of checks) {
240
88
  const ok = await exists(join(CWD, path));
241
- if (!ok) missing++;
242
- console.log(` ${ok ? c.green("✔") : c.red("✘")} ${path.padEnd(22)} ${c.dim(what)}`);
89
+ if (!ok && required) missing++;
90
+ const mark = ok ? c.green("✔") : required ? c.red("✘") : c.dim("○");
91
+ console.log(` ${mark} ${path.padEnd(22)} ${c.dim(what)}${!ok && !required ? c.dim(` · ${L.doctor.layoutAdvice}`) : ""}`);
92
+ }
93
+
94
+ // СЛУЖЕБНЫЙ ФАЙЛ, КОТОРЫЙ ВИДИТ GIT. Отзыв с живого проекта 2026-09-11: `.aqk/last-run.md`
95
+ // однажды закоммитили, и каждый прогон оставлял изменённый файл. `init` теперь кладёт их в
96
+ // .gitignore сам; здесь — для тех, кто поставил раньше. Спрашиваем git, а не диск.
97
+ const git = (...a) => spawnSync("git", a, { cwd: CWD, encoding: "utf8" });
98
+ if (git("rev-parse", "--git-dir").status === 0) {
99
+ const tracked = new Set(String(git("ls-files", "--", TARGET_DIR).stdout || "").split("\n"));
100
+ for (const f of RUNTIME_FILES.map((n) => `${TARGET_DIR}/${n}`)) {
101
+ if (tracked.has(f)) {
102
+ console.log(`\n ${c.yellow("!")} ${L.doctor.runtimeTracked(f, `git rm --cached ${f} && echo ${f} >> .gitignore`)}`);
103
+ } else if (await exists(join(CWD, f)) && git("check-ignore", "-q", f).status !== 0) {
104
+ console.log(c.dim(`\n ${L.doctor.runtimeNotIgnored(f, `echo ${f} >> .gitignore`)}`));
105
+ }
106
+ }
243
107
  }
244
108
 
109
+ // СВОД, КОТОРОГО НЕ ВИДИТ CLAUDE CODE. Он читает CLAUDE.md, а не AGENTS.md (документация,
110
+ // сверено 2026-09-11); подробности и исходы — claudeSeesRules.
111
+ const shim = await claudeShimFor(CWD);
112
+ if (shim) console.log(`\n ${c.yellow("!")} ${L.doctor.claudeShim[shim]}`);
113
+
245
114
  // Команды в точке входа заполнены или остались пустыми заготовками? Файл берётся тот же,
246
115
  // что проверен выше, — иначе проект на `CLAUDE.md` этой проверки не получал вовсе.
247
116
  const entryFile = (Array.isArray(man?.entry) ? man.entry : []).find((e) => typeof e === "string" && e.trim())?.trim() || "AGENTS.md";
@@ -260,7 +129,7 @@ async function cmdDoctor() {
260
129
  const arb = countArbiters(text, ["человек", "human", "nobody"]);
261
130
  if (arb.total && arb.human) {
262
131
  console.log(`\n ${c.yellow("!")} ${L.doctor.rulesByHuman(arb.total, arb.machine, arb.human)}`);
263
- console.log(c.dim(` ${L.doctor.rulesByHumanWhy}`));
132
+ if (verbose) console.log(c.dim(` ${L.doctor.rulesByHumanWhy}`));
264
133
  }
265
134
 
266
135
  const emptyCommands = (text.match(/^- [^:]+: ``$/gm) || []).length;
@@ -331,6 +200,16 @@ async function cmdDoctor() {
331
200
  } else {
332
201
  console.log(c.green(` ${L.doctor.allDone}\n`));
333
202
  }
203
+ // Состояние пробы — из файла отметки, миллисекунды. Нет его — блок про пробу просто молчит.
204
+ let probe = null;
205
+ try { probe = await probeStatus(); } catch { /* пробы нет — и ладно */ }
206
+ // Чего уровень НЕ доказывает — сразу под ним, пока глаз на нём (см. levelLimits).
207
+ if (reached >= 1) {
208
+ const lim = levelLimits(probe);
209
+ console.log(c.dim(` ${L.doctor.limitsTitle}`));
210
+ console.log(` ${L.doctor.limitsProbe[lim.kind](lim, `${SELF} probe`)}`);
211
+ console.log(` ${L.doctor.limitsCi}\n`);
212
+ }
334
213
 
335
214
  const facts = await detectFacts(man);
336
215
  if (process.argv.includes("--baseline")) {
@@ -343,21 +222,26 @@ async function cmdDoctor() {
343
222
  await reportBaseline(man, facts);
344
223
  process.exit(0);
345
224
  }
346
- // Состояние пробы из файла отметки, миллисекунды. Нет его блок про пробу просто молчит.
347
- let probe = null;
348
- try { probe = await probeStatus(); } catch { /* пробы нет — и ладно */ }
349
- const cat = (await reportCatalog(man, facts, probe)) || { held: 0, todo: 0, todoRecs: [] };
225
+ const cat = (await reportCatalog(man, facts, probe, verbose)) || { held: 0, todo: 0, todoRecs: [] };
350
226
 
351
227
  // «Объявлен» ≠ «работает». Без --run говорим это вслух, а не молчим.
352
228
  const wantRun = process.argv.includes("--run");
353
229
  const gates = declaredGates(man);
354
230
  let gateFailed = 0;
355
231
  let failedNames = [];
232
+ let skippedNames = [];
356
233
  if (wantRun) {
357
- const run = runGates(man, { since: sinceRef() });
234
+ // --jobs N: сколько гейтов одновременно. Без флага — по одному, как было: чужие гейты бывают
235
+ // зависимыми (общий dist/), и плавающее красное хуже медленного. Не число — отказ, а не тихий
236
+ // последовательный прогон под видом параллельного.
237
+ const ji = process.argv.indexOf("--jobs");
238
+ const jobs = ji > -1 ? Number(process.argv[ji + 1]) : 1;
239
+ if (!Number.isInteger(jobs) || jobs < 1) die(L.doctor.jobsBad(process.argv[ji + 1] ?? ""));
240
+ const run = await runGates(man, { since: sinceRef(), only: listArg(process.argv, "--only"), skip: listArg(process.argv, "--skip"), jobs, verbose });
358
241
  gateFailed = run.failed;
359
242
  failedNames = run.results.filter((r) => !r.ok).map((r) => r.name);
360
- await writeRunReport({ version, reached, results: run.results });
243
+ skippedNames = run.skipped || [];
244
+ await writeRunReport({ version, reached, results: run.results, skipped: run.skipped });
361
245
 
362
246
  // ПРОБА ЗАПУСКАЕТСЯ САМА. Владелец сформулировал так: «команду, о которой надо вспомнить,
363
247
  // агент не вспомнит, а человек о ней не узнает». Это тот же класс, что файл, который можно
@@ -369,20 +253,7 @@ async function cmdDoctor() {
369
253
  // надо. В кратком режиме не запускается: там хук на воротах коммита, и лишние секунды там
370
254
  // стоят дороже. Не влияет на код возврата НИКОГДА — это осмотр, а не порог.
371
255
  // Выключается AQK_PROBE=0 — у всего, что случается само, обязан быть выключатель.
372
- if (!brief && process.env.AQK_PROBE !== "0") {
373
- try {
374
- const st = await probeStatus();
375
- if (st.badEvery !== undefined) {
376
- console.log(c.yellow(`\n ${L.probe.badEvery(st.badEvery)}`));
377
- } else if (st.state === "never" || st.state === "stale") {
378
- // Сообщение обязано быть верным в обоих случаях. Первая версия печатала «прошло сто
379
- // коммитов» и там, где пробы не было ВОВСЕ: число бралось из порога, а не из факта.
380
- // Мелочь, но того же класса, что и всё остальное здесь: вывод, который не врёт.
381
- console.log(c.dim(`\n ${st.state === "never" ? L.probe.autoFirst : L.probe.auto(st.behind)}`));
382
- await cmdProbe([], { auto: true });
383
- }
384
- } catch { /* проба не состоялась — прогон это не роняет: он про гейты, а не про неё */ }
385
- }
256
+ if (!brief && process.env.AQK_PROBE !== "0") await autoProbe(brief);
386
257
  } else if (gates.length) {
387
258
  console.log(
388
259
  c.yellow(` ${L.doctor.declaredNotRun(gates.length)}`) +
@@ -417,6 +288,7 @@ async function cmdDoctor() {
417
288
  if (wantRun) {
418
289
  if (ok) {
419
290
  console.log(c.green(` ${L.doctor.runVerdictOk}\n`));
291
+ if (skippedNames.length) console.log(c.yellow(` ${L.doctor.selectSkipped(skippedNames.join(", "))}\n`));
420
292
  } else {
421
293
  const why = [];
422
294
  if (missing) why.push(L.doctor.whyMissing);