agent-quality-kit 0.13.0 → 0.15.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 (57) hide show
  1. package/README.md +46 -8
  2. package/README.ru.md +49 -9
  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/entry-commands-exist/README.md +64 -0
  6. package/kit/gates/entry-commands-exist/check.sh +110 -0
  7. package/kit/gates/entry-commands-exist/gate.yml +19 -0
  8. package/kit/gates/entry-commands-exist/green/AGENTS.md +13 -0
  9. package/kit/gates/entry-commands-exist/green/Makefile +6 -0
  10. package/kit/gates/entry-commands-exist/green/justfile +2 -0
  11. package/kit/gates/entry-commands-exist/green/package.json +10 -0
  12. package/kit/gates/entry-commands-exist/red/AGENTS.md +9 -0
  13. package/kit/gates/entry-commands-exist/red/Makefile +2 -0
  14. package/kit/gates/entry-commands-exist/red/package.json +9 -0
  15. package/llms.txt +5 -1
  16. package/package.json +1 -1
  17. package/tool/commands/context.mjs +59 -44
  18. package/tool/commands/doctor-catalog.mjs +222 -0
  19. package/tool/commands/doctor.mjs +46 -237
  20. package/tool/commands/feedback.mjs +157 -0
  21. package/tool/commands/learn.mjs +119 -19
  22. package/tool/commands/probe.mjs +4 -2
  23. package/tool/commands/project.mjs +11 -13
  24. package/tool/commands/prompt.mjs +70 -0
  25. package/tool/i18n/en-docs.mjs +1 -0
  26. package/tool/i18n/en-gates.mjs +27 -0
  27. package/tool/i18n/en.mjs +70 -3
  28. package/tool/i18n/index.mjs +42 -3
  29. package/tool/i18n/ru-docs.mjs +1 -0
  30. package/tool/i18n/ru-gates.mjs +28 -0
  31. package/tool/i18n/ru.mjs +81 -3
  32. package/tool/lib/annotate.mjs +66 -0
  33. package/tool/lib/ask.mjs +118 -0
  34. package/tool/lib/brief.mjs +17 -38
  35. package/tool/lib/cadence.mjs +30 -1
  36. package/tool/lib/core.mjs +8 -6
  37. package/tool/lib/gate-worker.mjs +4 -1
  38. package/tool/lib/repo.mjs +45 -4
  39. package/tool/lib/run.mjs +140 -9
  40. package/tool/program.mjs +10 -0
  41. package/tool/selfcheck/smoke/_fixture.mjs +8 -3
  42. package/tool/selfcheck/smoke/api-contract.test.mjs +37 -0
  43. package/tool/selfcheck/smoke/corpus.test.mjs +151 -0
  44. package/tool/selfcheck/smoke/fail-closed.test.mjs +96 -1
  45. package/tool/selfcheck/smoke/first-run.test.mjs +39 -0
  46. package/tool/selfcheck/smoke/verdict.test.mjs +41 -2
  47. package/tool/selfcheck/smoke.sh +4 -0
  48. package/tool/selfcheck/units-annotate.mjs +67 -0
  49. package/tool/selfcheck/units-ask.mjs +85 -0
  50. package/tool/selfcheck/units-brief.mjs +3 -13
  51. package/tool/selfcheck/units-cadence.mjs +26 -1
  52. package/tool/selfcheck/units-context.mjs +2 -1
  53. package/tool/selfcheck/units-feedback.mjs +137 -0
  54. package/tool/selfcheck/units-learn.mjs +32 -0
  55. package/tool/selfcheck/units-level.mjs +21 -1
  56. package/tool/selfcheck/units-prompt.mjs +106 -0
  57. package/tool/selfcheck/units-repo.mjs +20 -1
@@ -0,0 +1,9 @@
1
+ # Rules
2
+
3
+ Build and check before every commit:
4
+
5
+ ```sh
6
+ npm run build
7
+ npm run debug # build + install + logs
8
+ make deploy
9
+ ```
@@ -0,0 +1,2 @@
1
+ test:
2
+ node --test
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "sample",
3
+ "scripts": {
4
+ "build": "tsc"
5
+ },
6
+ "dependencies": {
7
+ "debug": "4.3.4"
8
+ }
9
+ }
package/llms.txt CHANGED
@@ -46,6 +46,10 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
46
46
  adds the command map and the rulebook verbatim (~7000 tokens): a deliberate trade, chosen by
47
47
  the owner after the objection about long inputs, on the grounds that an agent reads files
48
48
  poorly and the tokens are the price of it not guessing
49
+ - Hand the agent one task instead of retelling the diagnosis: `npx agent-quality-kit prompt` —
50
+ ground rules on top, at most five items by weight (red gates, defects the probe planted and no
51
+ check caught, the project's own checks to declare, rules Claude Code cannot see, catalog
52
+ entries to propose), and for every item the command that proves it done. Nothing is run
49
53
  - Check that the kit's own wiring is actually connected: `npx agent-quality-kit vitals` — are the
50
54
  tools the declared gates need installed, is the hook present in `.git/hooks` (a line in the
51
55
  config is an intention, not a guard), does the agent receive the state, is the version current.
@@ -72,7 +76,7 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
72
76
  the files `init` writes are owned by root, so you cannot edit your own manifest. Debian-based
73
77
  on purpose: the gates are `sh`, `grep`, `awk`, `find` — under alpine's busybox they behave
74
78
  differently, and an image where the gates behave differently is worse than no image
75
- - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.13.0` with `min: 1`
79
+ - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.15.0` with `min: 1`
76
80
  (https://github.com/marketplace/actions/agent-quality-kit-aqk)
77
81
 
78
82
  ## What makes it different
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-quality-kit",
3
- "version": "0.13.0",
3
+ "version": "0.15.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": {
@@ -21,14 +21,14 @@
21
21
  // а агент примет его за утверждение. Поэтому каждое незнание называется словом: прогона не было —
22
22
  // так и написано, прогон устарел — тоже, инструмента нет — тоже.
23
23
  import { readFile, writeFile, mkdir } from "node:fs/promises";
24
- import { spawnSync } from "node:child_process";
25
24
  import { join } from "node:path";
26
25
  import { CWD, TARGET_DIR, SELF, c, exists, commandRows, preCommitHook } from "../lib/core.mjs";
26
+ import { maybeAsk } from "./feedback.mjs";
27
27
  import { readManifest, assessLevel, coversOf } from "../lib/manifest.mjs";
28
28
  import { detectFacts, readCatalog } from "../lib/repo.mjs";
29
29
  import { catalogBuckets, startWith, blindAdvice } from "../lib/advice.mjs";
30
30
  import { proposeGates, readAdoptFiles } from "../lib/adopt.mjs";
31
- import { declaredGates } from "../lib/run.mjs";
31
+ import { declaredGates, readRun } from "../lib/run.mjs";
32
32
  import { probeStatus } from "./probe.mjs";
33
33
  import { L } from "../i18n/index.mjs";
34
34
 
@@ -73,11 +73,17 @@ function contextBlock(state, T = L.context) {
73
73
  out.push(T.runNone);
74
74
  } else {
75
75
  const red = state.run.red || [];
76
- const shown = red.slice(0, MAX_RED);
77
- const names = red.length > MAX_RED
78
- ? `${shown.join(", ")} — ${T.andMore(red.length - MAX_RED)}`
79
- : shown.join(", ");
80
- out.push(red.length ? T.runRed(state.run.when, names) : T.runClean(state.run.when));
76
+ const cannot = state.run.cannot || [];
77
+ const short = (list) => (list.length > MAX_RED
78
+ ? `${list.slice(0, MAX_RED).join(", ")} — ${T.andMore(list.length - MAX_RED)}`
79
+ : list.join(", "));
80
+ if (red.length) out.push(T.runRed(state.run.when, short(red)));
81
+ // «НЕ СМОГЛИ ПРОВЕРИТЬ» — ОТДЕЛЬНОЙ СТРОКОЙ, И ЧИСТО ТОЛЬКО КОГДА ОБА СПИСКА ПУСТЫ.
82
+ // Гейт, который не сумел отработать, не находка о коде: агент, прочитавший его как находку,
83
+ // пойдёт чинить исправный файл. А если бы он не попал НИКУДА, прогон, где всё сломалось,
84
+ // читался бы как «чисто» — та же тишина, только внутри блока, который читает машина.
85
+ if (cannot.length) out.push(T.runCannot(short(cannot)));
86
+ if (!red.length && !cannot.length) out.push(T.runClean(state.run.when));
81
87
  if (state.run.stale) out.push(T.runStale(state.run.when));
82
88
  if (state.run.skipped) out.push(T.skipped(state.run.skipped));
83
89
  }
@@ -148,20 +154,13 @@ function contextBlock(state, T = L.context) {
148
154
  // чем промолчать: он пойдёт его читать и получит пустоту вместо правил. Замерено на шести
149
155
  // чужих проектах: на flask блок писал «Свод правил: AGENTS.md», которого там нет.
150
156
  if (state.entryExists !== false) out.push("", T.where(state.entry || "AGENTS.md"));
157
+ // ПРОСЬБА ОБ ОТЗЫВЕ — последней строкой и только при содержании (feedback.mjs). Последней
158
+ // потому, что это единственная строка блока, которая не про состояние проекта: ставить её
159
+ // выше значило бы отодвинуть работой то, ради чего блок и читают.
160
+ if (state.ask) out.push("", state.ask);
151
161
  return out;
152
162
  }
153
163
 
154
- // Разбор отчёта прошлого прогона. Формат кладёт сам `doctor` в .aqk/last-run.md; читаем его,
155
- // а не запускаем гейты заново: хук обязан укладываться в секунду-две, а прогон у нас идёт минуту.
156
- function parseLastRun(text) {
157
- if (!text) return null;
158
- const when = (text.match(/^# aqk doctor --run — (.+)$/m) || [])[1] || "";
159
- const red = [];
160
- for (const m of text.matchAll(/^✘ ([^\s—]+)/gm)) red.push(m[1]);
161
- const skipped = (text.match(/^~ /gm) || []).length;
162
- return { when: when.trim(), red, skipped, stale: false };
163
- }
164
-
165
164
  // Правила и их арбитры: отметка `<!-- aqk: имя -->` рядом с правилом. `человек` — честное
166
165
  // признание, что машина этого не держит; так его и считаем, отдельно от машинных.
167
166
  function countArbiters(text, humanWords) {
@@ -173,16 +172,6 @@ function countArbiters(text, humanWords) {
173
172
  return { total: marks.length, machine: marks.length - human, human };
174
173
  }
175
174
 
176
- // Прогон старше последнего коммита описывает не тот код, что лежит перед агентом. Молча выдать
177
- // его за свежий — соврать: именно так «зелёный месяц назад» превращается в «зелёный сейчас».
178
- function runIsStale(when) {
179
- if (!when) return false;
180
- const r = spawnSync("git", ["log", "-1", "--format=%cI"], { cwd: CWD, encoding: "utf8" });
181
- if (r.status !== 0 || !r.stdout) return false;
182
- const commit = Date.parse(r.stdout.trim());
183
- const run = Date.parse(when.replace(" ", "T"));
184
- return Number.isFinite(commit) && Number.isFinite(run) && run < commit;
185
- }
186
175
 
187
176
 
188
177
  // УСТАНОВКА ХУКА — отдельной командой, а не частью `init`, и это решение, а не лень. Комплект
@@ -260,6 +249,27 @@ async function installHook(full = false) {
260
249
  console.log(c.dim(` ${T.hookWhat}`));
261
250
  }
262
251
 
252
+ // Что советовать — теми же функциями, что у `doctor`: корзины каталога, «начните с трёх», совет
253
+ // под язык, чужие проверки проекта. Одно место на `context` и `prompt`: второй расчёт того же
254
+ // самого разошёлся бы с первым. Класс из пробы, чей гейт уже стоит, в совет не идёт — ставить
255
+ // его второй раз бессмысленно.
256
+ async function readAdvice(man, probe) {
257
+ const facts = await detectFacts(man);
258
+ const catalog = await readCatalog();
259
+ const { todo } = catalogBuckets(catalog, facts, coversOf(man).covered);
260
+ const adopt = declaredGates(man).length ? [] : proposeGates(await readAdoptFiles(CWD));
261
+ const blind = (probe?.classes || [])
262
+ .filter((b) => !facts.gateKeys.includes(b.slug))
263
+ .map((b) => ({ ...b, command: blindAdvice(catalog.find((r) => r.slug === b.slug), facts, {}).command }));
264
+ const start = startWith(todo, facts, 3)
265
+ .map((rec) => ({ slug: rec.slug, intent: rec.intent || "", command: blindAdvice(rec, facts, {}).command }));
266
+ // Гейт стоит, проба его ГОНЯЛА — и брак он пропустил. Самое ценное, что проба знает: не
267
+ // «поставь», а «твоя проверка здесь слепа». Гейт, поставленный после пробы, сюда не идёт —
268
+ // поймает ли, покажет следующая.
269
+ const missed = (probe?.classes || []).filter((b) => facts.gateKeys.includes(b.slug) && probe?.ran?.has(b.slug));
270
+ return { adopt, blind, start, missed };
271
+ }
272
+
263
273
  async function cmdContext(args = []) {
264
274
  const full = args.includes("--full");
265
275
  if (args.includes("--install")) return installHook(full);
@@ -286,12 +296,7 @@ async function cmdContext(args = []) {
286
296
  rules = countArbiters(await readFile(join(CWD, entry), "utf8"), ["человек", "human", "nobody"]);
287
297
  }
288
298
 
289
- let run = null;
290
- const lastRun = join(CWD, TARGET_DIR, "last-run.md");
291
- if (await exists(lastRun)) {
292
- run = parseLastRun(await readFile(lastRun, "utf8"));
293
- if (run) run.stale = runIsStale(run.when);
294
- }
299
+ const run = await readRun();
295
300
 
296
301
  // Проба: сколько классов не ловит никто и насколько отметка отстала. Читается из файла,
297
302
  // ничего не запускает — блок обязан укладываться в секунду.
@@ -334,21 +339,31 @@ async function cmdContext(args = []) {
334
339
  // язык, чужие проверки проекта. Второй расчёт того же самого разошёлся бы с первым.
335
340
  let next = null;
336
341
  try {
337
- const facts = await detectFacts(man);
338
- const catalog = await readCatalog();
339
- const { todo } = catalogBuckets(catalog, facts, coversOf(man).covered);
340
- const adopt = declaredGates(man).length ? [] : proposeGates(await readAdoptFiles(CWD));
341
- const blind = (probe?.classes || [])
342
- .filter((b) => !facts.gateKeys.includes(b.slug))
343
- .map((b) => ({ ...b, command: blindAdvice(catalog.find((r) => r.slug === b.slug), facts, {}).command }));
344
- const start = startWith(todo, facts, 3).map((rec) => ({ slug: rec.slug, command: blindAdvice(rec, facts, {}).command }));
342
+ const { adopt, blind, start } = await readAdvice(man, probe);
345
343
  next = nextSteps({ init: !man, adopt, blind, start });
346
344
  } catch { /* не посчитали — блок скажет остальное; выдумывать шаги нельзя */ }
347
345
 
346
+ // ЕДИНСТВЕННАЯ ПЛАТА ЗА КОМПЛЕКТ — один ответ автору, и просит о нём агент: он читает этот
347
+ // блок каждую сессию и передаёт человеку то, что в нём написано. Замер 2026-09-14: тысяча
348
+ // скачиваний в неделю и ноль отзывов за всё время — просьба печаталась только при `init`, то
349
+ // есть до того, как комплект сделал хоть что-то.
350
+ //
351
+ // ТРИ УСЛОВИЯ, И ВСЕ ТРИ ОБЯЗАТЕЛЬНЫ: не выключено человеком, не просили на этом проекте
352
+ // раньше, и ЕСТЬ О ЧЁМ рассказать. Без третьего это «оставьте отзыв» — шум, а шум выключают
353
+ // вместе с хуком, в котором он приехал.
354
+ //
355
+ // ЕДИНСТВЕННАЯ ЗАПИСЬ НА ДИСК В ЭТОЙ КОМАНДЕ, кроме `--install`. Без отметки просьба
356
+ // повторялась бы каждую сессию: красный гейт живёт в проекте днями, а блок читается заново
357
+ // при каждом запуске агента и после каждого сжатия контекста.
358
+ const ask = await maybeAsk({
359
+ cannot: run?.cannot || [], red: run?.red || [],
360
+ blind: (probe?.classes || []).map((b) => b.slug),
361
+ }, portableSelf(SELF), { agent: true });
362
+
348
363
  console.log(contextBlock({
349
364
  entry, entryExists: rules !== null, level, rules, run, ratchets, probe, full: fullPart,
350
- next, when: { hook: await preCommitHook(CWD) },
365
+ next, when: { hook: await preCommitHook(CWD) }, ask,
351
366
  }).join("\n"));
352
367
  }
353
368
 
354
- export { cmdContext, contextBlock, nextSteps, parseLastRun, countArbiters, withHook, hasOurHook, portableSelf };
369
+ export { cmdContext, contextBlock, nextSteps, countArbiters, withHook, hasOurHook, portableSelf, readAdvice };
@@ -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 };