agent-quality-kit 0.12.0 → 0.13.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 (50) hide show
  1. package/README.md +26 -4
  2. package/README.ru.md +26 -4
  3. package/kit/gates/complexity-limit/gate.yml +4 -0
  4. package/kit/gates/dead-code/gate.yml +4 -0
  5. package/kit/gates/env-secrets-not-committed/README.md +73 -0
  6. package/kit/gates/env-secrets-not-committed/check.sh +139 -0
  7. package/kit/gates/env-secrets-not-committed/gate.yml +21 -0
  8. package/kit/gates/env-secrets-not-committed/green/.aqk-tracked +10 -0
  9. package/kit/gates/env-secrets-not-committed/green/.env +10 -0
  10. package/kit/gates/env-secrets-not-committed/green/.env.production +5 -0
  11. package/kit/gates/env-secrets-not-committed/green/.env.test +2 -0
  12. package/kit/gates/env-secrets-not-committed/red/.aqk-tracked +5 -0
  13. package/kit/gates/env-secrets-not-committed/red/.env +7 -0
  14. package/kit/gates/no-print-in-prod/gate.yml +4 -0
  15. package/kit/gates/swallowed-error/gate.yml +4 -0
  16. package/kit/gates/todo-without-task/gate.yml +4 -0
  17. package/llms.txt +4 -2
  18. package/package.json +1 -1
  19. package/tool/commands/badge.mjs +1 -1
  20. package/tool/commands/context.mjs +56 -3
  21. package/tool/commands/doctor.mjs +136 -81
  22. package/tool/commands/probe.mjs +46 -66
  23. package/tool/commands/project.mjs +6 -1
  24. package/tool/commands/report.mjs +1 -1
  25. package/tool/commands/vitals.mjs +15 -11
  26. package/tool/i18n/en-docs.mjs +22 -1
  27. package/tool/i18n/en-gates.mjs +6 -1
  28. package/tool/i18n/en.mjs +13 -1
  29. package/tool/i18n/ru-docs.mjs +24 -1
  30. package/tool/i18n/ru-gates.mjs +6 -1
  31. package/tool/i18n/ru.mjs +13 -1
  32. package/tool/lib/adopt.mjs +15 -1
  33. package/tool/lib/advice.mjs +115 -0
  34. package/tool/lib/brief.mjs +3 -1
  35. package/tool/lib/cadence.mjs +11 -1
  36. package/tool/lib/core.mjs +39 -1
  37. package/tool/lib/gate-worker.mjs +18 -0
  38. package/tool/lib/history.mjs +34 -5
  39. package/tool/lib/manifest.mjs +65 -21
  40. package/tool/lib/repo.mjs +2 -31
  41. package/tool/lib/run.mjs +78 -7
  42. package/tool/selfcheck/smoke/first-run.test.mjs +105 -0
  43. package/tool/selfcheck/smoke/verdict.test.mjs +51 -2
  44. package/tool/selfcheck/smoke.sh +6 -2
  45. package/tool/selfcheck/units-cadence.mjs +15 -1
  46. package/tool/selfcheck/units-context.mjs +64 -1
  47. package/tool/selfcheck/units-level.mjs +76 -2
  48. package/tool/selfcheck/units-probe.mjs +2 -1
  49. package/tool/selfcheck/units-repo.mjs +25 -1
  50. package/tool/selfcheck/units-verdict.mjs +76 -0
@@ -4,17 +4,19 @@ import { readFile, mkdir, writeFile } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { scopeOutput, splitAdvice, changedFiles } from "../lib/scope.mjs";
7
- import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die } from "../lib/core.mjs";
8
- import { cmdProbe, probeStatus, blindAdvice } from "./probe.mjs";
7
+ import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die, RUNTIME_FILES } from "../lib/core.mjs";
8
+ import { cmdProbe, probeStatus } 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, startWith } from "../lib/repo.mjs";
12
- import { proposeGates, ADOPT_FILES, ADOPT_SCRIPTS } from "../lib/adopt.mjs";
11
+ import { detectFacts, readCatalog, browserServerAdvice } from "../lib/repo.mjs";
12
+ import { startWith, catalogBuckets, blindAdvice } from "../lib/advice.mjs";
13
+ import { proposeGates, readAdoptFiles } from "../lib/adopt.mjs";
13
14
  import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
14
15
  import { L } from "../i18n/index.mjs";
15
16
  import { countArbiters } from "./context.mjs";
16
17
  import { beginBrief, finishBrief } from "../lib/brief.mjs";
17
- import { declaredGates, sinceRef, runGates, progress } from "../lib/run.mjs";
18
+ import { declaredGates, sinceRef, runGates, progress, listArg } from "../lib/run.mjs";
19
+ import { autoProbeAllowed } from "../lib/cadence.mjs";
18
20
 
19
21
  // Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
20
22
  // место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
@@ -59,14 +61,7 @@ async function reportCatalog(man, facts, probe = null) {
59
61
  // Пока их считали вместе, вывод каждый прогон называл долгом то, что уже держит biome или
60
62
  // ruff. Просьба первого чужого пользователя; она же — наша собственная норма про вывод.
61
63
  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
- }
64
+ const { held, todo, skip, byOther } = catalogBuckets(catalog, facts, covered);
70
65
 
71
66
  console.log(c.bold(`\n ${L.doctor.gatesHeading}\n`));
72
67
  const marks = ["has_ci", "has_db", "has_docker", "has_tests", "has_deps"]
@@ -78,58 +73,13 @@ async function reportCatalog(man, facts, probe = null) {
78
73
  );
79
74
 
80
75
  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
76
  // ЧТО У ВАС УЖЕ ЕСТЬ — до итога и до списка крестов. Комплект, поставленный в проект с
122
77
  // eslint, mocha и конвейером, показывал двадцать крестов и «держит машина 0»: мы считали
123
78
  // только СВОИ записи, а чужие проверки не читали вовсе. С точки зрения владельца это
124
79
  // неправда, и первое, что он видел, было обвинением. Предлагаем, а не вписываем: гейт в
125
80
  // чужом манифесте без спроса — наше решение в чужом файле.
126
81
  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);
82
+ const found = proposeGates(await readAdoptFiles(CWD));
133
83
  if (found.length) {
134
84
  console.log(`\n ${c.bold(L.doctor.haveAlready(found.length))}`);
135
85
  for (const g of found) {
@@ -167,8 +117,8 @@ async function reportCatalog(man, facts, probe = null) {
167
117
  // С ЧЕГО НАЧАТЬ. Двадцать одинаковых крестов — это ноль требований: закрывают первое
168
118
  // попавшееся или не закрывают ничего. Порядок не по нашему вкусу: сперва то, что родилось из
169
119
  // настоящего отказа И закрывается одной готовой командой.
170
- if (todo.length > 3) {
171
- const first = startWith(todo, facts, 3);
120
+ const first = todo.length > 3 ? startWith(todo, facts, 3) : [];
121
+ if (first.length) {
172
122
  console.log(`\n ${c.bold(L.doctor.startWith)}`);
173
123
  for (const rec of first) {
174
124
  const adv = blindAdvice(rec, facts, {});
@@ -182,6 +132,80 @@ async function reportCatalog(man, facts, probe = null) {
182
132
  console.log(c.dim(`\n ${L.doctor.startHook}`));
183
133
  }
184
134
 
135
+ // ОСТАЛЬНОЕ — ПОСЛЕ ГЛАВНОГО И СЖАТО. Список шёл первым, по две строки на запись (вторая —
136
+ // «поставить: aqk add …»), и на requests главное начиналось со строки 84 из 102: человек
137
+ // читает сверху и закрывает раньше. Разбор соседа 2026-09-11 (research/competitors/agentlint.md):
138
+ // там первыми идут пять главных исправлений. Записи не теряются — теряется повтор подсказки.
139
+ const rest = todo.filter((r) => !first.includes(r));
140
+ if (rest.length) {
141
+ if (first.length) console.log(`\n ${c.bold(L.doctor.todoRest(rest.length))}`);
142
+ else console.log("");
143
+ // ○, а не ✘: запись не установлена — это не падение. Крест в зелёном прогоне глаз читает
144
+ // как провал, и через неделю человек перестаёт смотреть на красное вообще (отзыв с живого
145
+ // проекта 2026-09-11). ✘ остаётся за тем, что упало или пропустило брак.
146
+ for (const rec of rest) console.log(` ${c.dim("○")} ${rec.slug.padEnd(22)} ${rec.intent || ""}`);
147
+ console.log(c.dim(` ${L.doctor.todoRestHow(SELF)}`));
148
+ }
149
+
150
+ // Второстепенное — в конце: что закрыто чужим арбитром, что неприменимо, советы без вердикта.
151
+ if (byOther.length) {
152
+ console.log(c.dim(`\n ${L.doctor.coveredBy(byOther.length)}`));
153
+ for (const [rec, gate] of byOther) console.log(c.dim(` ~ ${rec.slug.padEnd(22)} ${L.doctor.coveredByGate(gate)}`));
154
+ }
155
+ // Гейт, которого нет в gates:, не закрывает ничего — и молчать об этом нельзя: человек
156
+ // считает запись закрытой, а её не держит никто. Называется поимённо, жёлтым.
157
+ if (unknownGates.length) {
158
+ console.log(c.yellow(`\n ${L.doctor.coversUnknown(unknownGates.join(", "))}`));
159
+ }
160
+ // Заявка «эту запись держит наш линтер» сверяется с кодами правил из рецепта записи.
161
+ // Замерено на живом ruff.toml: девятнадцать групп правил, а print() не ловится — и заявка
162
+ // сняла бы запись с долга, не закрыв её ничем.
163
+ // Конфиги — ПО ЛИНТЕРАМ, а не одной склейкой: заявка сверяется правилами того линтера,
164
+ // которым закрыт гейт (отзыв с живого проекта 2026-09-11 — коды ruff искались в biome.json).
165
+ const readAll = async (names) => {
166
+ let t = "";
167
+ for (const f of names) { try { t += await readFile(join(CWD, f), "utf8") + "\n"; } catch { /* нет файла */ } }
168
+ return t;
169
+ };
170
+ let scripts = {}, pkgText = "";
171
+ try { pkgText = await readFile(join(CWD, "package.json"), "utf8"); scripts = JSON.parse(pkgText)?.scripts || {}; } catch { /* нет или не JSON */ }
172
+ const configs = {
173
+ // ruff.toml и .ruff.toml — конфиг ruff целиком, слово «ruff» в них писать незачем (поймал наш же
174
+ // smoke: `extend-select = [..., "T20"]` выбрасывался). pyproject.toml — только если в нём есть
175
+ // раздел ruff: он есть почти у каждого python-проекта и без ruff.
176
+ ruff: (await readAll(["ruff.toml", ".ruff.toml"])) +
177
+ ((await readAll(["pyproject.toml"])).match(/^\[tool\.ruff[\s\S]*/m)?.[0] || ""),
178
+ eslint: (await readAll([".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts"])) +
179
+ (/"eslintConfig"/.test(pkgText) ? pkgText : ""),
180
+ biome: await readAll(["biome.json", "biome.jsonc"]),
181
+ scripts,
182
+ };
183
+ for (const u of coversUnproven(man, catalog, configs)) {
184
+ if (u.kind === "unproven") {
185
+ console.log(c.yellow(`\n ${L.doctor.coversUnproven(u.entry, u.gate, u.codes.join(", "))}`));
186
+ console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
187
+ } else if (u.kind === "impossible") {
188
+ console.log(c.yellow(`\n ${L.doctor.coversImpossible(u.entry, u.gate, u.linter)}`));
189
+ console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
190
+ } else {
191
+ console.log(c.dim(`\n ${L.doctor.coversCantCheck(u.entry, u.gate)}`));
192
+ }
193
+ }
194
+ // Не вердикт, а совет: отсутствие браузерного сервера — незанятая возможность, а не дефект.
195
+ // Поэтому строка тусклая и без значка, и её нет у проекта без интерфейса.
196
+ let mcpText = "";
197
+ for (const f of [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".claude/mcp.json"]) {
198
+ try { mcpText += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
199
+ }
200
+ const browser = browserServerAdvice(facts, mcpText);
201
+ if (browser) {
202
+ console.log(c.dim(`\n ${L.doctor.noBrowserServer}`));
203
+ console.log(c.dim(` ${L.doctor.noBrowserServerHow(browser.servers.join(" · "))}`));
204
+ }
205
+ if (skip.length) {
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
+ }
185
209
  console.log(
186
210
  `\n ${c.bold(L.doctor.total)} ${L.doctor.totalHeld(held.length)}, ${L.doctor.totalTodo(c.yellow(todo.length))}, ` +
187
211
  (byOther.length ? `${L.doctor.totalCovered(byOther.length)}, ` : "") +
@@ -196,7 +220,7 @@ async function reportCatalog(man, facts, probe = null) {
196
220
  // сессии и для самого владельца: список объявленных гейтов молчит о том, сколько из них
197
221
  // действительно стоят и работают именно СЕЙЧАС. Перезаписывается каждым прогоном, не копится:
198
222
  // история — дело git-лога коммитов с этим отчётом, если владелец решит его коммитить.
199
- async function writeRunReport({ version, reached, results }) {
223
+ async function writeRunReport({ version, reached, results, skipped = [] }) {
200
224
  const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
201
225
  const ok = results.filter((r) => r.ok).length;
202
226
  const lines = [
@@ -205,6 +229,9 @@ async function writeRunReport({ version, reached, results }) {
205
229
  `${L.report.level}: AQK-${reached < 0 ? L.doctor.levelNone : reached}`,
206
230
  "",
207
231
  ...results.map((r) => `${r.ok ? "✔" : "✘"} ${r.name} — ${r.secs}s${r.ok ? "" : ` (${r.note || L.doctor.exitCode(r.code)})`}`),
232
+ // Пропущенные по --skip/--only — строкой «~»: блок для агента читает их как «не запускались»,
233
+ // а не как зелёные. Молчание о них прочиталось бы как «проверено».
234
+ ...skipped.map((n) => `~ ${n} — ${L.report.skippedBySelect}`),
208
235
  "",
209
236
  L.report.summary(ok, results.length),
210
237
  ].filter((l) => l !== null);
@@ -214,6 +241,22 @@ async function writeRunReport({ version, reached, results }) {
214
241
  await writeFile(dst, lines.join("\n") + "\n", "utf8");
215
242
  }
216
243
 
244
+ // ПРОБА ЗАПУСКАЕТСЯ САМА, раз в сто коммитов, — кроме конвейера (там это минуты сюрпризом в
245
+ // быстрой проверке, отзыв с живого проекта 2026-09-11). Не влияет на код возврата никогда: это
246
+ // осмотр, а не порог. Отдельной функцией: внутри прогона эта лесенка дала вложенность 6, и наш же
247
+ // complexity-limit её поймал.
248
+ async function autoProbe(brief) {
249
+ let st;
250
+ try { st = await probeStatus(); } catch { return; /* пробы нет — прогон про гейты, а не про неё */ }
251
+ if (st.badEvery !== undefined) { console.log(c.yellow(`\n ${L.probe.badEvery(st.badEvery)}`)); return; }
252
+ if (st.state !== "never" && st.state !== "stale") return;
253
+ if (!autoProbeAllowed({ brief })) { console.log(c.dim(`\n ${L.probe.autoNotInCi(`${SELF} probe`)}`)); return; }
254
+ // Сообщение обязано быть верным в обоих случаях: первая версия печатала «прошло сто коммитов»
255
+ // и там, где пробы не было ВОВСЕ — число бралось из порога, а не из факта.
256
+ console.log(c.dim(`\n ${st.state === "never" ? L.probe.autoFirst : L.probe.auto(st.behind)}`));
257
+ try { await cmdProbe([], { auto: true }); } catch { /* проба не состоялась — прогон это не роняет */ }
258
+ }
259
+
217
260
  async function cmdDoctor() {
218
261
  const brief = process.argv.includes("--brief");
219
262
  const buf = brief ? beginBrief() : null;
@@ -236,10 +279,26 @@ async function cmdDoctor() {
236
279
  const checks = layoutChecks(man, inKit);
237
280
 
238
281
  let missing = 0;
239
- for (const [path, what] of checks) {
282
+ for (const [path, what, required] of checks) {
240
283
  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)}`);
284
+ if (!ok && required) missing++;
285
+ const mark = ok ? c.green("✔") : required ? c.red("✘") : c.dim("○");
286
+ console.log(` ${mark} ${path.padEnd(22)} ${c.dim(what)}${!ok && !required ? c.dim(` · ${L.doctor.layoutAdvice}`) : ""}`);
287
+ }
288
+
289
+ // СЛУЖЕБНЫЙ ФАЙЛ, КОТОРЫЙ ВИДИТ GIT. Отзыв с живого проекта 2026-09-11: `.aqk/last-run.md`
290
+ // однажды закоммитили, и каждый прогон оставлял изменённый файл. `init` теперь кладёт их в
291
+ // .gitignore сам; здесь — для тех, кто поставил раньше. Спрашиваем git, а не диск.
292
+ const git = (...a) => spawnSync("git", a, { cwd: CWD, encoding: "utf8" });
293
+ if (git("rev-parse", "--git-dir").status === 0) {
294
+ const tracked = new Set(String(git("ls-files", "--", TARGET_DIR).stdout || "").split("\n"));
295
+ for (const f of RUNTIME_FILES.map((n) => `${TARGET_DIR}/${n}`)) {
296
+ if (tracked.has(f)) {
297
+ console.log(`\n ${c.yellow("!")} ${L.doctor.runtimeTracked(f, `git rm --cached ${f} && echo ${f} >> .gitignore`)}`);
298
+ } else if (await exists(join(CWD, f)) && git("check-ignore", "-q", f).status !== 0) {
299
+ console.log(c.dim(`\n ${L.doctor.runtimeNotIgnored(f, `echo ${f} >> .gitignore`)}`));
300
+ }
301
+ }
243
302
  }
244
303
 
245
304
  // Команды в точке входа заполнены или остались пустыми заготовками? Файл берётся тот же,
@@ -353,11 +412,19 @@ async function cmdDoctor() {
353
412
  const gates = declaredGates(man);
354
413
  let gateFailed = 0;
355
414
  let failedNames = [];
415
+ let skippedNames = [];
356
416
  if (wantRun) {
357
- const run = runGates(man, { since: sinceRef() });
417
+ // --jobs N: сколько гейтов одновременно. Без флага — по одному, как было: чужие гейты бывают
418
+ // зависимыми (общий dist/), и плавающее красное хуже медленного. Не число — отказ, а не тихий
419
+ // последовательный прогон под видом параллельного.
420
+ const ji = process.argv.indexOf("--jobs");
421
+ const jobs = ji > -1 ? Number(process.argv[ji + 1]) : 1;
422
+ if (!Number.isInteger(jobs) || jobs < 1) die(L.doctor.jobsBad(process.argv[ji + 1] ?? ""));
423
+ const run = await runGates(man, { since: sinceRef(), only: listArg(process.argv, "--only"), skip: listArg(process.argv, "--skip"), jobs });
358
424
  gateFailed = run.failed;
359
425
  failedNames = run.results.filter((r) => !r.ok).map((r) => r.name);
360
- await writeRunReport({ version, reached, results: run.results });
426
+ skippedNames = run.skipped || [];
427
+ await writeRunReport({ version, reached, results: run.results, skipped: run.skipped });
361
428
 
362
429
  // ПРОБА ЗАПУСКАЕТСЯ САМА. Владелец сформулировал так: «команду, о которой надо вспомнить,
363
430
  // агент не вспомнит, а человек о ней не узнает». Это тот же класс, что файл, который можно
@@ -369,20 +436,7 @@ async function cmdDoctor() {
369
436
  // надо. В кратком режиме не запускается: там хук на воротах коммита, и лишние секунды там
370
437
  // стоят дороже. Не влияет на код возврата НИКОГДА — это осмотр, а не порог.
371
438
  // Выключается 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
- }
439
+ if (!brief && process.env.AQK_PROBE !== "0") await autoProbe(brief);
386
440
  } else if (gates.length) {
387
441
  console.log(
388
442
  c.yellow(` ${L.doctor.declaredNotRun(gates.length)}`) +
@@ -417,6 +471,7 @@ async function cmdDoctor() {
417
471
  if (wantRun) {
418
472
  if (ok) {
419
473
  console.log(c.green(` ${L.doctor.runVerdictOk}\n`));
474
+ if (skippedNames.length) console.log(c.yellow(` ${L.doctor.selectSkipped(skippedNames.join(", "))}\n`));
420
475
  } else {
421
476
  const why = [];
422
477
  if (missing) why.push(L.doctor.whyMissing);
@@ -26,8 +26,9 @@ import { readFileSync } from "node:fs";
26
26
  import { tmpdir } from "node:os";
27
27
  import { join, dirname, extname } from "node:path";
28
28
  import { readManifest } from "../lib/manifest.mjs";
29
- import { fixHotspots, probeSummary, probeVerdictPaired, countProbe } from "../lib/history.mjs";
29
+ import { fixHotspots, probeSummary, probeVerdictPaired, countProbe, namesPlant, catchVerdict } from "../lib/history.mjs";
30
30
  import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
31
+ import { blindAdvice } from "../lib/advice.mjs";
31
32
  import { CWD, GATES_SRC, TARGET_DIR, c, SELF, exists } from "../lib/core.mjs";
32
33
  import { probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan } from "../lib/cadence.mjs";
33
34
  import { L } from "../i18n/index.mjs";
@@ -95,7 +96,7 @@ async function writeMark(now, blind, lines) {
95
96
  "",
96
97
  ...lines,
97
98
  "",
98
- "Файл эфемерный: его переписывает каждая проба. В .gitignore его стоит держать самому.",
99
+ "Файл эфемерный: его переписывает каждая проба. `aqk init` кладёт его в .gitignore.",
99
100
  ].join("\n");
100
101
  await writeFile(MARK(), body + "\n", "utf8");
101
102
  }
@@ -156,58 +157,11 @@ function sampleLines(path, max = 4) {
156
157
  .map((l) => l.slice(0, 88));
157
158
  }
158
159
 
159
- // Совет по НЕПОКРЫТОМУ классу: команда, которую можно вставить прямо сейчас.
160
- //
161
- // Проба находит настоящие дыры и печатала про них «close it: aqk add <имя>» — то есть «поставь
162
- // нашу штуку». Человек, впервые увидевший комплект, закрывает окно. А готовая однострочная
163
- // команда под его стек У НАС УЖЕ ЛЕЖИТ в `recipes` записи каталога; мы её не показывали.
164
- //
165
- // Замер руками на `requests` (самый скачиваемый python-пакет) 2026-09-10: в
166
- // `src/requests/utils.py` — 75 коммитов-починок; дописана функция с `except Exception: pass`;
167
- // их собственные `ruff` и `pytest` дали 0 и на чистой копии, и на подсаженной. Строка, которая
168
- // поймала бы это, лежала в нашем каталоге всё это время.
169
- //
170
- // Переносимый рецепт (`any`) в совет НЕ идёт: он зовёт файл из комплекта, и человеку без
171
- // комплекта вставить его некуда. Нет родного рецепта под стек — команды нет, и это честнее
172
- // выдуманной.
173
- function blindAdvice(entry, facts, hot = {}) {
174
- const recipes = entry?.recipes && typeof entry.recipes === "object" ? entry.recipes : {};
175
- // `langs` приходит МНОЖЕСТВОМ, а не массивом — `Array.isArray` тихо давал пустой список, и
176
- // совет не печатался вовсе. Поймано на живом `requests`: langs = Set(1) { python }.
177
- const langs = facts?.langs ? [...facts.langs] : [];
178
- // Тот же порядок, что у `pickRecipe`: свой язык → безъязыковой родной → ничего. Переносимый
179
- // (`any`) сюда не идёт никогда: он зовёт файл из комплекта, и человеку без комплекта вставить
180
- // его некуда.
181
- let cmd = null;
182
- for (const key of [...langs, "native"]) {
183
- const r = recipes[key];
184
- if (!r || /\{gate\}/.test(r)) continue;
185
- cmd = String(r).replace(/\{dir\}/g, ".")
186
- // Вычистить то, что относится к НАМ, а не к его проекту. Исключение наших красных
187
- // образцов нужно УСТАНОВЛЕННОМУ гейту — рядом с ним лежат образцы. Человеку, который
188
- // команду только копирует, этих каталогов не существует, и флаги про них подрывают
189
- // доверие: инструмент говорит про чужое хозяйство вместо его кода.
190
- .replace(/\s--ignore-pattern\s+'[^']*gates\/[^']*'/g, "")
191
- .replace(/\s--ignore-paths=?\s*'[^']*gates\/[^']*'/g, "")
192
- .replace(/\s+/g, " ")
193
- .trim();
194
- break;
195
- }
196
- // Адрес — того инструмента, которым команда начинается: поле `tool` общее на все языки, и
197
- // python-проекту показывалась ссылка на eslint. Первые три слова, а не одно: `npx knip`,
198
- // `python -m vulture`. Не совпало — весь список: лишняя ссылка лучше, чем ни одной.
199
- const urls = entry?.tool ? String(entry.tool).split(/\s+·\s+/) : [];
200
- const head = cmd ? cmd.split(" ").slice(0, 3) : [];
201
- const own = urls.find((u) => head.includes(u.replace(/\/+$/, "").split("/").pop()));
202
- const tool = own ?? (entry?.tool ? String(entry.tool) : null);
203
- return { command: cmd, tool, file: hot.file ?? null, fixes: hot.fixes ?? null, slug: entry?.slug ?? null };
204
- }
205
-
206
160
  // Красный образец записи, подходящий по расширению горячего файла. Расширение обязано
207
161
  // совпадать: питоновский образец в проекте на TypeScript не проверит ничего, а покажет
208
162
  // «не прикрыто» — ложная тревога того же класса, что молчащий гейт, только наоборот.
209
- async function redSampleFor(entry, ext) {
210
- const dir = join(GATES_SRC, entry, "red");
163
+ async function sampleFor(entry, ext, kind = "red") {
164
+ const dir = join(GATES_SRC, entry, kind);
211
165
  if (!(await exists(dir))) return null;
212
166
  let names = [];
213
167
  try { names = await readdir(dir); } catch { return null; }
@@ -226,7 +180,8 @@ async function redSampleFor(entry, ext) {
226
180
  //
227
181
  // Способ взят из мутационного тестирования, где та же задача решена двадцать лет назад: Stryker
228
182
  // копирует проект во временный каталог, СИМЛИНКУЕТ `node_modules` и гоняет там родную команду.
229
- // Копируются только ОТСЛЕЖИВАЕМЫЕ файлы (`git archive HEAD`) — рабочее дерево не трогается, а
183
+ // Копируются отслеживаемые и неигнорируемые файлы (`git ls-files --cached --others
184
+ // --exclude-standard`) — файлы проекта не меняются (итог пишется в .aqk/last-probe.md), а
230
185
  // мусор сборки не тащится; тяжёлые каталоги зависимостей симлинкуются, иначе `npm test` в
231
186
  // песочнице падал бы с «модуль не найден», и это читалось бы как сбой инструмента.
232
187
  const DEP_DIRS = ["node_modules", ".venv", "venv", "vendor", "target", ".tox", ".bundle"];
@@ -284,20 +239,44 @@ async function plant(root, relPath, sample) {
284
239
  // Гейт запускается В ПЕСОЧНИЦЕ и командой КАК ЕСТЬ — ничего в неё не подставляется. Именно это
285
240
  // и делает пробу независимой от формы команды.
286
241
  //
287
- // `stopOnRed` ранний выход: как только гейт покраснел, вердикт «поймано» уже получен, и гонять
288
- // остальные незачем. На сухом прогоне выхода нет: там нужны ВСЕ длительности и все коды.
289
- function runGates(gates, sandbox, { stopOnRed = false } = {}) {
242
+ // Вывод гейта сохраняется: по нему видно, ИЗ-ЗА ЧЕГО он покраснел (см. catchVerdict).
243
+ function runGates(gates, sandbox) {
290
244
  const out = [];
291
245
  for (const [name, cmd] of gates) {
292
246
  const t0 = Date.now();
293
247
  const r = spawnSync(gateCommand(cmd), { shell: true, cwd: sandbox, encoding: "utf8", timeout: 120000 });
294
248
  const code = r.status === null ? 2 : r.status;
295
- out.push({ name, code, ms: Date.now() - t0 });
296
- if (stopOnRed && code === 1) break;
249
+ out.push({ name, code, ms: Date.now() - t0, out: `${r.stdout || ""}${r.stderr || ""}`.slice(0, 200000) });
297
250
  }
298
251
  return out;
299
252
  }
300
253
 
254
+ // Один класс брака на одном файле: гейты по одному, в порядке плана. Покраснел и назвал файл —
255
+ // контроль ЗЕЛЁНЫМ образцом той же записи в том же месте (catchVerdict). Ранний выход — только на
256
+ // ПОДТВЕРЖДЁННОЙ поимке: первым мог покраснеть форматтер, и остановка на нём скрыла бы линтер,
257
+ // который брак действительно поймал.
258
+ async function probeOne({ sandbox, rel, red, green, gates, baseOut }) {
259
+ const after = [];
260
+ let restore = await plant(sandbox, rel, red);
261
+ try {
262
+ for (const g of gates) {
263
+ const [res] = runGates([g], sandbox);
264
+ after.push(res);
265
+ if (res.code !== 1) continue;
266
+ let ctl = null;
267
+ if (green && namesPlant(baseOut(res.name), res.out, rel)) {
268
+ await restore(); restore = await plant(sandbox, rel, green);
269
+ [ctl] = runGates([g], sandbox);
270
+ await restore(); restore = await plant(sandbox, rel, red);
271
+ }
272
+ res.why = catchVerdict(baseOut(res.name), res, ctl, rel);
273
+ res.named = res.why === "caught";
274
+ if (res.named) break;
275
+ }
276
+ } finally { await restore(); }
277
+ return after;
278
+ }
279
+
301
280
  // Каким гейтом пробовать и в каком порядке.
302
281
  //
303
282
  // Цена пробы = (файлы × записи) × сумма длительностей гейтов. На самом комплекте после перехода
@@ -368,7 +347,8 @@ async function cmdProbe(args, { auto = false } = {}) {
368
347
  // Пробуем только запланированными, в порядке плана.
369
348
  const byName = new Map(gates);
370
349
  const probeGates = plan.use.map((g) => [g.name, byName.get(g.name)]);
371
- const baseline = plan.use.map((g) => ({ name: g.name, code: g.code }));
350
+ const baseline = plan.use.map((g) => ({ name: g.name, code: g.code, out: g.out }));
351
+ const baseOut = (name) => baseline.find((b) => b.name === name)?.out || "";
372
352
 
373
353
  console.log(c.dim(` ${P.method(hot.length, entries.length, plan.use.length)}\n`));
374
354
  if (plan.tooSlow.length) {
@@ -385,20 +365,20 @@ async function cmdProbe(args, { auto = false } = {}) {
385
365
  let probed = 0;
386
366
 
387
367
  for (const e of entries) {
388
- const sample = await redSampleFor(e.slug, ext);
368
+ const sample = await sampleFor(e.slug, ext, "red");
389
369
  if (!sample) continue;
390
370
  probed++;
391
- const restore = await plant(sandbox, rel, sample);
392
- let after;
393
- try { after = runGates(probeGates, sandbox, { stopOnRed: true }); } finally { await restore(); }
371
+ const green = await sampleFor(e.slug, ext, "green");
372
+ const after = await probeOne({ sandbox, rel, red: sample, green, gates: probeGates, baseOut });
394
373
  // Ранний выход обрывает список: гейты, до которых не дошли, считаются такими же, как на
395
374
  // сухом прогоне. Иначе их отсутствие прочиталось бы как сбой запуска.
396
375
  const seen = new Set(after.map((a) => a.name));
397
376
  const full = after.concat(baseline.filter((b) => !seen.has(b.name)));
398
377
  const r = probeVerdictPaired(baseline, full);
399
378
  const verdict = r.verdict;
400
- const caught = full.filter((a) => a.code === 1 && baseline.find((b) => b.name === a.name)?.code === 0)
401
- .map((a) => a.name);
379
+ const redNow = full.filter((a) => a.code === 1 && baseline.find((b) => b.name === a.name)?.code === 0);
380
+ const caught = redNow.filter((a) => a.named !== false).map((a) => a.name);
381
+ const nameless = redNow.filter((a) => a.named === false).map((a) => `${a.name} (${a.why === "planting" ? P.planting : P.nameless})`);
402
382
  records.push({ entry: e.slug, file: rel, verdict });
403
383
  if (verdict === "caught") {
404
384
  console.log(` ${c.green("✔")} ${e.intent.padEnd(48)} ${c.dim(P.caught(caught.join(", ")))}`);
@@ -419,7 +399,7 @@ async function cmdProbe(args, { auto = false } = {}) {
419
399
  if (adv.tool) console.log(c.dim(` ${P.blindTool(adv.tool)}`));
420
400
  console.log(c.dim(` ${P.install(`${SELF} add ${e.slug}`)}`));
421
401
  } else {
422
- console.log(` ${c.dim("~")} ${c.dim(e.intent.padEnd(48))} ${c.dim(P.unknown)}`);
402
+ console.log(` ${c.dim("~")} ${c.dim(e.intent.padEnd(48))} ${c.dim(nameless.length ? P.unattributed(nameless.join(", ")) : P.unknown)}`);
423
403
  }
424
404
  }
425
405
  if (!probed) { unprobedN++; console.log(c.dim(` ${P.noSampleFor(ext || "—")}`)); }
@@ -465,4 +445,4 @@ async function probeStatus() {
465
445
  return { ...probeState(mark, commitCount(), every), classes: parseBlind(mark?.text), ran: parseRan(mark?.text) };
466
446
  }
467
447
 
468
- export { cmdProbe, probeStatus, probeableGates, gatesState, extAlternatives, planProbeGates, blindAdvice, isCode };
448
+ export { cmdProbe, probeStatus, probeableGates, gatesState, extAlternatives, planProbeGates, isCode };
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
6
6
  import { join, dirname, relative } from "node:path";
7
7
  import {
8
8
  CWD, PKG_ROOT, DOCS_SRC, RULES_SRC, TARGET_DIR, MANIFEST, SELF, REPO_URL, c, exists, die,
9
- copyDir, writeIfAbsent, FEEDBACK_MARK, docPath } from "../lib/core.mjs";
9
+ copyDir, writeIfAbsent, FEEDBACK_MARK, docPath, ensureIgnored } from "../lib/core.mjs";
10
10
  import { AGENTS_MD, CLAUDE_MD, MANIFEST_YML } from "../lib/templates.mjs";
11
11
  import { banner } from "../lib/banner.mjs";
12
12
  import { readManifest } from "../lib/manifest.mjs";
@@ -41,6 +41,10 @@ async function cmdInit(args) {
41
41
  const claude = join(CWD, "CLAUDE.md");
42
42
  track(await writeIfAbsent(claude, CLAUDE_MD, { force }), claude);
43
43
 
44
+ // Служебные файлы — в .gitignore сразу, до первого прогона: иначе первый же `doctor --run`
45
+ // оставит в дереве файл, который попадёт в коммит (так и случилось на живом проекте).
46
+ const ignored = await ensureIgnored(CWD);
47
+
44
48
  // Заставка в начале init — первая встреча человека с комплектом. Второй раз он увидит её
45
49
  // только если сам спросит `--version`: то, что видишь тридцатый раз, перестаёт читаться.
46
50
  console.log(`\n${banner()}\n`);
@@ -56,6 +60,7 @@ async function cmdInit(args) {
56
60
  if (LANG === "en" && created.some((f) => f.includes(`${TARGET_DIR}/docs/`) || f.includes(`${TARGET_DIR}\\docs\\`))) {
57
61
  console.log(c.dim(`\n ${L.init.docsRu}`));
58
62
  }
63
+ if (ignored.length) console.log(c.dim(`\n ${L.init.ignored(ignored.join(", "))}`));
59
64
  if (skipped.length) {
60
65
  console.log(c.yellow(`\n ${L.init.kept(skipped.length)}`));
61
66
  for (const f of skipped) console.log(` ${f}`);
@@ -86,7 +86,7 @@ async function cmdReport() {
86
86
 
87
87
  // Прогон, а не чтение манифеста: «объявлен» и «работает» — разные утверждения, и весь
88
88
  // смысл этой команды в том, чтобы в отчёт попало второе.
89
- const run = declared.length ? runGates(man) : { results: [], failed: 0 };
89
+ const run = declared.length ? await runGates(man) : { results: [], failed: 0 };
90
90
 
91
91
  const held = [], broken = [], todo = [], skip = [];
92
92
  for (const res of run.results) {
@@ -16,9 +16,10 @@
16
16
  // сознательное решение: pre-commit локально мы не ставим, проверки идут в CI. Команда, которая
17
17
  // кричит «сломано» про выбор, — ровно та, которую выключают в первый день, и вместе с ней
18
18
  // перестают читать настоящие отказы. Кода возврата касается только `✘`.
19
+ import { spawnSync } from "node:child_process";
19
20
  import { readFile } from "node:fs/promises";
20
21
  import { join } from "node:path";
21
- import { CWD, MANIFEST, SELF, c, exists } from "../lib/core.mjs";
22
+ import { CWD, MANIFEST, SELF, c, exists, preCommitHook } from "../lib/core.mjs";
22
23
  import { readManifest, unparsedLines, gateRequires } from "../lib/manifest.mjs";
23
24
  import { whichSync } from "../lib/repo.mjs";
24
25
  import { gitBash } from "../lib/execution.mjs";
@@ -122,14 +123,7 @@ async function cmdVitals() {
122
123
  // Хук pre-commit проверяется в `.git/hooks`, а НЕ в `.pre-commit-config.yaml`. Запись в
123
124
  // конфиге — это намерение; сработает только то, что лежит в самом гите. Ровно та разница,
124
125
  // ради которой весь комплект: объявлено и работает — разные утверждения.
125
- let preCommit = null;
126
- const hook = join(CWD, ".git", "hooks", "pre-commit");
127
- if (await exists(join(CWD, ".git"))) {
128
- preCommit = false;
129
- if (await exists(hook)) {
130
- try { preCommit = /pre-commit|aqk/i.test(await readFile(hook, "utf8")); } catch { preCommit = null; }
131
- }
132
- }
126
+ const preCommit = await preCommitHook(CWD);
133
127
 
134
128
  let sessionHook = null;
135
129
  const settings = join(CWD, ".claude", "settings.json");
@@ -145,15 +139,25 @@ async function cmdVitals() {
145
139
 
146
140
  let version = null;
147
141
  if (updateWanted()) {
142
+ let current = "";
148
143
  try {
149
144
  const { PKG_ROOT } = await import("../lib/core.mjs");
150
- const current = JSON.parse(await readFile(join(PKG_ROOT, "package.json"), "utf8")).version || "";
145
+ current = JSON.parse(await readFile(join(PKG_ROOT, "package.json"), "utf8")).version || "";
151
146
  const r = await fetch("https://registry.npmjs.org/agent-quality-kit/latest", {
152
147
  signal: AbortSignal.timeout(3000),
153
148
  headers: { accept: "application/vnd.npm.install-v1+json" },
154
149
  });
155
150
  version = { current, latest: r.ok ? String((await r.json()).version || "") : "" };
156
- } catch { /* сети нет строку про версию просто не покажем */ }
151
+ } catch { /* прямой запрос не прошёл ниже спросим npm */ }
152
+ // ПРЯМОЙ ЗАПРОС НЕ ПРОШЁЛ — СПРОСИТЬ NPM. Отзыв с живого проекта 2026-09-11: vitals писал «не
153
+ // достучался до реестра», а `npm view` на той же машине работал. `fetch` в Node не знает
154
+ // прокси и зеркала из .npmrc, а npm знает. Только здесь, не в хуке: там секунды дороже.
155
+ if (current && !version?.latest) {
156
+ const r = spawnSync("npm", ["view", "agent-quality-kit", "version"],
157
+ { encoding: "utf8", timeout: 10000, shell: process.platform === "win32" });
158
+ const latest = r.status === 0 ? String(r.stdout || "").trim().split("\n").pop() : "";
159
+ version = { current, latest };
160
+ }
157
161
  }
158
162
 
159
163
  const rows = vitalsRows({ tools: [...seen.values()], unparsed, preCommit, sessionHook, version });