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
@@ -66,7 +66,7 @@ const enDocs = {
66
66
  runClean: (when) => `Last run ${when} — nothing red.`,
67
67
  runRed: (when, names) => `Last run ${when} — RED: ${names}.`,
68
68
  andMore: (n) => `and ${n} more`,
69
- skipped: (n) => `Not run: ${n} — the tool is absent on this machine, their state is unknown.`,
69
+ skipped: (n) => `Not run: ${n} — skipped by --only/--skip or the tool is absent on this machine; their state is unknown.`,
70
70
  probeNever: "No coverage probe has run — what is covered by nothing here is UNKNOWN. That is not \"covered\": `aqk probe`.",
71
71
  probeOff: "The coverage probe is switched off in the manifest (`probe: 0`) — nobody counts what is covered by nothing here.",
72
72
  probeUnknown: "The coverage probe could not run — what is covered by nothing here is UNKNOWN. That is not \"covered\".",
@@ -79,6 +79,26 @@ const enDocs = {
79
79
  (behind ? ` (${behind} commits behind)` : "") + ".",
80
80
  ratchets: (list) => `Ratchets: ${list}. The list may only get shorter, never longer.`,
81
81
  where: (entry) => `The rulebook: ${entry}. What proves a diff: \`aqk report --since main\`.`,
82
+ nextTitle: "Next, in order — computed from this repository, not generic advice:",
83
+ nextStep: {
84
+ init: () => "Set up the standard: `aqk init` — without .aqk.yml `aqk add` refuses.",
85
+ adopt: (s) => `Declare the checks this project already has: in .aqk.yml, under gates: ${s.gates.map((g) => `${g.name}: "${g.cmd}"`).join(", ")}. Then \`aqk doctor --run\`.`,
86
+ blind: (s) => `The "${s.slug}" defect the probe planted in ${s.file} was NOT caught by your checks.` +
87
+ (s.command ? ` Catch it now: \`${s.command}\`.` : "") + ` Keep it caught: \`aqk add ${s.slug}\`.`,
88
+ start: (s) => `Install ${s.slug}: \`aqk add ${s.slug}\`` + (s.command ? ` (one line, no kit needed: \`${s.command}\`)` : "") +
89
+ ", then `aqk prove` — the gate must go red on its own red sample.",
90
+ },
91
+ nextMore: (n) => `And ${n} more — the full list: \`aqk doctor\`.`,
92
+ whenTitle: "When to do what:",
93
+ whenCommit: (hook) => hook === true
94
+ ? "Before a commit → the `.git/hooks/pre-commit` hook runs the checks itself; do not bypass it (`--no-verify`)."
95
+ : "Before a commit → `aqk doctor --run --since main`: there is no pre-commit hook, it will not happen by itself.",
96
+ whenRules: [
97
+ "Added or changed a check → `aqk prove`: the gate must go red on its own red sample, otherwise it checks nothing.",
98
+ "Writing a rule into the rulebook → put the arbiter mark next to it, `<!-- aqk: gate-name -->`; no gate — `<!-- aqk: human -->`, which is an admission, not a check.",
99
+ "A check is in your way → do not weaken it (`|| true`, `--exit-zero`, a suppression without a rule code): stop and ask the owner.",
100
+ "You think it is done → `aqk report --since main`: every changed file must be named by a check.",
101
+ ],
82
102
  mapTitle: "WHAT THIS TOOL CAN DO. The full list of commands — not a retelling, the same list\nthe help is built from:",
83
103
  rulesTitle: (e) => `THE RULEBOOK OF THIS PROJECT (${e}) — verbatim, in full. This is not an invitation\nto read it: it is already here.`,
84
104
  hookAlready: (p) => `the hook is already in ${p} — changing nothing.`,
@@ -196,6 +216,7 @@ const enDocs = {
196
216
  },
197
217
  ],
198
218
  report: {
219
+ skippedBySelect: "not run (by --only/--skip), state unknown",
199
220
  title: "aqk doctor --run",
200
221
  version: "version",
201
222
  level: "level",
@@ -158,6 +158,7 @@ export const enGates = {
158
158
  "the guides in .aqk/docs/ are in Russian — a deliberate decision, not a broken install.\n The rules in .aqk/rules/ are in English; the guides are prose an agent may ignore anyway,\n and what a machine holds lives in .aqk.yml and the gates. Translation waits for someone who needs it.",
159
159
  created: (n) => `created (${n}):`,
160
160
  andMore: (n) => `… and ${n} more`,
161
+ ignored: (list) => `the kit's runtime files were added to .gitignore — machine state, it does not belong in git: ${list}`,
161
162
  kept: (n) => `already there, left untouched (${n}):`,
162
163
  overwrite: (cmd) => `overwrite: ${cmd}`,
163
164
  nextTitle: "What to do next, in order:",
@@ -255,16 +256,20 @@ export const enGates = {
255
256
  badEvery: (v) => `the manifest says probe: "${v}", which is not a commit count. The probe does NOT run: silently using the default would mean doing something other than what is written.`,
256
257
  autoFirst: "no coverage probe has ever run here — running it myself. Turn off: AQK_PROBE=0",
257
258
  auto: (n) => `${n} commits since the last probe — running it myself. Turn off: AQK_PROBE=0`,
259
+ autoNotInCi: (cmd) => `in CI the probe does not run by itself — it would be minutes of surprise in a fast check; run it as a separate job: ${cmd} (or AQK_PROBE=1)`,
258
260
  title: "aqk probe — what the declared checks cannot see",
259
261
  method: (files, entries, gates) =>
260
262
  `method: a red sample from a catalogue entry is planted into a COPY of the project, then the ` +
261
263
  `DECLARED gates are run there — the command is used as written, nothing is substituted into it. ` +
262
264
  `Files: ${files}, applicable entries: ${entries}, gates green on a clean checkout: ${gates}. ` +
263
- `The working tree is not touched.`,
265
+ `Project files are not changed; the probe result goes to .aqk/last-probe.md (in .gitignore after init).`,
264
266
  fixes: (n) => `fixes in history: ${n}`,
265
267
  caught: (names) => `caught by: ${names}`,
266
268
  blind: "NOTHING CATCHES IT",
267
269
  unknown: "nothing to check with — the gate did not run (delegated tool missing)",
270
+ nameless: "did not name the planted file",
271
+ planting: "goes red on the green sample too — fails from the planting itself",
272
+ unattributed: (g) => `went red: ${g} — the catch is not proven`,
268
273
  blindWhere: (file, fixes) => `where: ${file} — ${fixes} fix commits in its history`,
269
274
  blindWhat: "what we planted into your file:",
270
275
  blindFix: (cmd) => `catch it right now, no kit needed: ${cmd}`,
package/tool/i18n/en.mjs CHANGED
@@ -44,6 +44,7 @@ export const en = {
44
44
  blob: "assemble the guides into a single GOD_AI.md",
45
45
  learn: "rule candidates from local transcripts: said out loud, never written down",
46
46
  context: "the project state in one block — for an agent's context, not for reading",
47
+ vitals: "is what the kit runs on wired up: gate tools, hooks, version freshness",
47
48
  contextInstall: "the same in full — the map and the rulebook — installed as a hook",
48
49
  report: "the mandatory report form: what is in place, what is not, what was not read; --since <ref> adds what proves the diff",
49
50
  badge: "a level badge for your README — and a check that it does not lie",
@@ -65,6 +66,14 @@ export const en = {
65
66
  gitignore: "repository hygiene",
66
67
  git: "project under version control",
67
68
 
69
+ runtimeTracked: (f, cmd) => `${f} is tracked by git — every run rewrites it, and the tree always shows a modified file. Take it out: ${cmd}`,
70
+ runtimeNotIgnored: (f, cmd) => `${f} is this machine's state, yet git sees it: one \`git add .\` and it is in a commit. Hide it: ${cmd}`,
71
+ layoutAdvice: "missing — advice, it does not fail the run",
72
+ coversImpossible: (entry, gate, linter) => `the claim "${gate} holds ${entry}" is wrong: ${linter} has no rule for this class — there is nothing to hold it with`,
73
+ coversCantCheck: (entry, gate) => `cannot check the claim "${gate} holds ${entry}": the gate's linter is not recognised or the entry has no rules for it — taken on trust`,
74
+ selectUnknown: (names, groups) => `--only/--skip: "${names}" is neither a gate from gates: nor a group from groups:${groups ? ` (groups: ${groups})` : ""}. Running everything instead of skipping would be a lie, so stopping.`,
75
+ selectSkipped: (names) => `not run (by --only/--skip): ${names} — their state is unknown, they are not "green"`,
76
+ jobsBad: (v) => `--jobs expects a whole number from 1: "${v}" will not do. A one-by-one run passed off as parallel would be a lie, so stopping.`,
68
77
  rulesByHuman: (total, machine, human) =>
69
78
  `${human} of ${total} rules in the entry point are guarded by a HUMAN, ${machine} by a machine.`,
70
79
  rulesByHumanWhy: "A rule guarded by a human is guarded by nobody the day the human is busy. That is the hole this kit exists to close — and the reminder belongs to the human, not only to the agent. Counted in the ENTRY POINT only: a promise kept in any other file is checked by nothing at all, and this kit will not tell you it exists.",
@@ -108,7 +117,9 @@ export const en = {
108
117
  blindRan: (g) => `gate ${g} is declared and was run — and still missed the defect in this file`,
109
118
  blindInstalled: "declared, but the probe did not run this gate (added later or too slow) — the next probe will show whether it catches",
110
119
  blindMore: (cmd) => `what was planted and where — ${cmd}`,
111
- probeNever: (cmd) => `Whether your checks catch a real defect has not been tested yet: ${cmd} plants one in a copy of the project and shows (a minute or two, your files are not touched).`,
120
+ probeNever: (cmd) => `Whether your checks catch a real defect has not been tested yet: ${cmd} plants one in a copy of the project and shows (a minute or two, your project files are not changed).`,
121
+ todoRest: (n) => `The other entries that apply here (${n}):`,
122
+ todoRestHow: (self) => `install any: ${self} add <name> · what it catches and why: ${self} why <name>`,
112
123
  startWith: "Start with these three — born from a real failure, and each closes with one ready command:",
113
124
  startCmd: (cmd) => `one line, no kit needed: ${cmd}`,
114
125
  startTool: (url) => `the tool: ${url}`,
@@ -199,6 +210,7 @@ export const en = {
199
210
  has_docker: ["no Dockerfile or compose", "docker is already here"],
200
211
  has_deps: ["no dependency file in sight", "dependencies are declared"],
201
212
  has_tests: ["no tests in sight", "tests exist"],
213
+ has_biome: ["the linter is not Biome", "the project's linter is Biome"],
202
214
  has_env: ["no environment file", "an environment file exists"],
203
215
  has_agent_config: ["the agent was never configured here", "agent settings exist"],
204
216
  has_agent_entry: ["no entry point for an agent here", "an entry point for an agent exists"],
@@ -68,7 +68,7 @@ const ruDocs = {
68
68
  runClean: (when) => `Последний прогон ${when} — красных нет.`,
69
69
  runRed: (when, names) => `Последний прогон ${when} — КРАСНЫЕ: ${names}.`,
70
70
  andMore: (n) => `и ещё ${n}`,
71
- skipped: (n) => `Не запускались: ${n} — инструмента нет на этой машине, их состояние неизвестно.`,
71
+ skipped: (n) => `Не запускались: ${n} — пропущены по --only/--skip или инструмента нет на этой машине; их состояние неизвестно.`,
72
72
  probeNever: "Проба покрытия не делалась — что здесь не прикрыто ничем, НЕИЗВЕСТНО. Это не «прикрыто»: `aqk probe`.",
73
73
  probeOff: "Проба покрытия выключена в манифесте (`probe: 0`) — что здесь не прикрыто ничем, никто не считает.",
74
74
  probeUnknown: "Пробу покрытия провести не удалось — что здесь не прикрыто ничем, НЕИЗВЕСТНО. Это не «прикрыто».",
@@ -81,6 +81,28 @@ const ruDocs = {
81
81
  (behind ? ` (отстала на ${behind} коммитов)` : "") + ".",
82
82
  ratchets: (list) => `Храповики: ${list}. Список может только укорачиваться, увеличивать нельзя.`,
83
83
  where: (entry) => `Свод правил: ${entry}. Чем доказан диф: \`aqk report --since main\`.`,
84
+ nextTitle: "Дальше, по порядку — вычислено из этого репозитория, а не общий совет:",
85
+ // По виду шага — таблица, а не лесенка тернарников: её не держат в голове, и наш же
86
+ // complexity-limit её поймал.
87
+ nextStep: {
88
+ init: () => "Заведи стандарт: `aqk init` — без .aqk.yml `aqk add` откажет.",
89
+ adopt: (s) => `Объяви проверки, которые у проекта уже есть: в .aqk.yml, в gates: ${s.gates.map((g) => `${g.name}: "${g.cmd}"`).join(", ")}. Затем \`aqk doctor --run\`.`,
90
+ blind: (s) => `Брак «${s.slug}», подсаженный пробой в ${s.file}, ваши проверки НЕ поймали.` +
91
+ (s.command ? ` Поймать сейчас: \`${s.command}\`.` : "") + ` Держать всегда: \`aqk add ${s.slug}\`.`,
92
+ start: (s) => `Поставь ${s.slug}: \`aqk add ${s.slug}\`` + (s.command ? ` (одной строкой, без комплекта: \`${s.command}\`)` : "") +
93
+ ", затем `aqk prove` — гейт обязан покраснеть на своём красном образце.",
94
+ },
95
+ nextMore: (n) => `И ещё ${n} — весь список: \`aqk doctor\`.`,
96
+ whenTitle: "Когда что делать:",
97
+ whenCommit: (hook) => hook === true
98
+ ? "Перед коммитом → хук `.git/hooks/pre-commit` прогонит проверки сам; не обходи его (`--no-verify`)."
99
+ : "Перед коммитом → `aqk doctor --run --since main`: хука перед коммитом нет, само это не случится.",
100
+ whenRules: [
101
+ "Добавил или поменял проверку → `aqk prove`: гейт обязан покраснеть на своём красном образце, иначе он не проверяет ничего.",
102
+ "Пишешь правило в свод → рядом метка арбитра `<!-- aqk: имя-гейта -->`; гейта нет — `<!-- aqk: человек -->`, и это признание, а не проверка.",
103
+ "Проверка мешает → не ослабляй её (`|| true`, `--exit-zero`, подавление без кода правила): остановись и спроси владельца.",
104
+ "Считаешь, что готово → `aqk report --since main`: каждый изменённый файл должен быть назван проверкой.",
105
+ ],
84
106
  mapTitle: "ЧТО УМЕЕТ ЭТОТ ИНСТРУМЕНТ. Полный список команд — не пересказ, а тот же список,\nиз которого собрана справка:",
85
107
  rulesTitle: (e) => `СВОД ПРАВИЛ ЭТОГО ПРОЕКТА (${e}) — дословно, целиком. Это не приглашение\nпрочитать: он уже здесь.`,
86
108
  hookAlready: (p) => `хук уже стоит в ${p} — ничего не меняю.`,
@@ -198,6 +220,7 @@ const ruDocs = {
198
220
  },
199
221
  ],
200
222
  report: {
223
+ skippedBySelect: "не запускался (по --only/--skip), состояние неизвестно",
201
224
  title: "aqk doctor --run",
202
225
  version: "версия",
203
226
  level: "уровень",
@@ -160,6 +160,7 @@ export const ruGates = {
160
160
  "методички в .aqk/docs/ остаются на русском — решение, а не недоделка.",
161
161
  created: (n) => `создано (${n}):`,
162
162
  andMore: (n) => `… и ещё ${n}`,
163
+ ignored: (list) => `служебные файлы комплекта дописаны в .gitignore — это состояние машины, в git ему не место: ${list}`,
163
164
  kept: (n) => `уже были на месте, не тронуты (${n}):`,
164
165
  overwrite: (cmd) => `перезаписать: ${cmd}`,
165
166
  nextTitle: "Что дальше — по порядку:",
@@ -257,16 +258,20 @@ export const ruGates = {
257
258
  badEvery: (v) => `в манифесте probe: «${v}» — это не число коммитов. Проба НЕ делается: подставить умолчание значило бы делать не то, что написано.`,
258
259
  autoFirst: "пробы покрытия здесь ещё не делали — делаю её сам. Выключить: AQK_PROBE=0",
259
260
  auto: (n) => `прошло ${n} коммитов с прошлой пробы — делаю её сам. Выключить: AQK_PROBE=0`,
261
+ autoNotInCi: (cmd) => `в конвейере проба сама не запускается — это минуты сюрпризом в быстрой проверке; поставьте её отдельной задачей: ${cmd} (или AQK_PROBE=1)`,
260
262
  title: "aqk probe — чего объявленные проверки не видят",
261
263
  method: (files, entries, gates) =>
262
264
  `способ: красный образец записи каталога подсаживается в КОПИЮ проекта, и там гоняются ` +
263
265
  `ОБЪЯВЛЕННЫЕ гейты — команда берётся как написана, в неё ничего не подставляется. ` +
264
266
  `Файлов: ${files}, применимых записей: ${entries}, гейтов зелёных на чистом дереве: ${gates}. ` +
265
- `Рабочее дерево не трогается.`,
267
+ `Файлы проекта не меняются; итог пробы пишется в .aqk/last-probe.md (он в .gitignore после init).`,
266
268
  fixes: (n) => `починок в истории: ${n}`,
267
269
  caught: (names) => `ловит: ${names}`,
268
270
  blind: "НЕ ЛОВИТ НИКТО",
269
271
  unknown: "проверить нечем — гейт не состоялся (нет делегированной программы)",
272
+ nameless: "не назвал подсаженный файл",
273
+ planting: "краснеет и на зелёном образце — падает от самой подсадки",
274
+ unattributed: (g) => `покраснело: ${g} — поимка не доказана`,
270
275
  blindWhere: (file, fixes) => `где: ${file} — починок в истории: ${fixes}`,
271
276
  blindWhat: "что подсадили в ваш файл:",
272
277
  blindFix: (cmd) => `поймать прямо сейчас, без комплекта: ${cmd}`,
package/tool/i18n/ru.mjs CHANGED
@@ -45,6 +45,7 @@ export const ru = {
45
45
  blob: "собрать методички в один файл GOD_AI.md",
46
46
  learn: "кандидаты в правила из локальной переписки: сказано вслух и не записано",
47
47
  context: "состояние проекта одним блоком — для контекста агента, а не для чтения",
48
+ vitals: "подключено ли то, чем комплект работает: инструменты гейтов, хуки, свежесть версии",
48
49
  contextInstall: "то же самое, но целиком — карта и свод правил — и хуком в контекст",
49
50
  report: "обязательная форма отчёта: что стоит, что нет, что не прочитано; --since <ссылка> — ещё и чем доказан диф",
50
51
  badge: "значок уровня для README — и проверка, что он не врёт",
@@ -66,6 +67,14 @@ export const ru = {
66
67
  gitignore: "гигиена репозитория",
67
68
  git: "проект под контролем версий",
68
69
 
70
+ runtimeTracked: (f, cmd) => `${f} отслеживается git — каждый прогон его переписывает, и в дереве вечно висит изменённый файл. Вынуть: ${cmd}`,
71
+ runtimeNotIgnored: (f, cmd) => `${f} — состояние этой машины, а git его видит: одно \`git add .\`, и он в коммите. Спрятать: ${cmd}`,
72
+ layoutAdvice: "нет — это совет, прогон не роняет",
73
+ coversImpossible: (entry, gate, linter) => `заявка «${gate} держит ${entry}» неверна: у ${linter} нет правила под этот класс — закрывать его нечем`,
74
+ coversCantCheck: (entry, gate) => `заявку «${gate} держит ${entry}» проверить не умею: линтер гейта не распознан или правил записи для него нет — принято на слово`,
75
+ selectUnknown: (names, groups) => `--only/--skip: не знаю «${names}» — это не гейт из gates: и не группа из groups:${groups ? ` (группы: ${groups})` : ""}. Прогон всего подряд вместо пропуска был бы неправдой, поэтому стоп.`,
76
+ selectSkipped: (names) => `не запускались (по --only/--skip): ${names} — их состояние неизвестно, это не «зелёные»`,
77
+ jobsBad: (v) => `--jobs ждёт целое число от 1: «${v}» не подходит. Прогон по одному под видом параллельного был бы неправдой, поэтому стоп.`,
69
78
  rulesByHuman: (total, machine, human) =>
70
79
  `правил в точке входа: ${total}. Сторож — ЧЕЛОВЕК у ${human}, машина у ${machine}.`,
71
80
  rulesByHumanWhy: "Правило со сторожем-человеком не сторожит никто в тот день, когда человек занят. Это та самая дыра, ради которой комплект и написан, — и напоминание про неё нужно человеку, а не только агенту. Считаны правила ТОЧКИ ВХОДА: обещание, живущее в любом другом файле, не проверяет вообще ничто, и комплект даже не скажет вам, что оно есть.",
@@ -109,7 +118,9 @@ export const ru = {
109
118
  blindRan: (g) => `гейт ${g} стоит и прогонялся — а брак в этом файле пропустил`,
110
119
  blindInstalled: "объявлено, но проба этот гейт не гоняла (поставлен позже или медленный) — поймает ли, покажет следующая",
111
120
  blindMore: (cmd) => `что именно подсадили и куда — ${cmd}`,
112
- probeNever: (cmd) => `Ловят ли ваши проверки настоящий брак, ещё не проверялось: ${cmd} подсадит его в копию проекта и покажет (минута-две, рабочие файлы не трогает).`,
121
+ probeNever: (cmd) => `Ловят ли ваши проверки настоящий брак, ещё не проверялось: ${cmd} подсадит его в копию проекта и покажет (минута-две, файлы проекта не меняет).`,
122
+ todoRest: (n) => `Остальные записи, применимые у вас (${n}):`,
123
+ todoRestHow: (self) => `поставить любую: ${self} add <имя> · что она ловит и зачем: ${self} why <имя>`,
113
124
  startWith: "Начните с этих трёх — они родились из настоящего отказа и закрываются одной готовой командой:",
114
125
  startCmd: (cmd) => `одной строкой, без комплекта: ${cmd}`,
115
126
  startTool: (url) => `инструмент: ${url}`,
@@ -205,6 +216,7 @@ export const ru = {
205
216
  has_docker: ["нет Dockerfile или compose", "docker уже есть"],
206
217
  has_deps: ["не видно файла зависимостей", "зависимости объявлены"],
207
218
  has_tests: ["не видно тестов", "тесты есть"],
219
+ has_biome: ["линтер не Biome", "линтер проекта — Biome"],
208
220
  has_env: ["нет файла окружения", "файл окружения есть"],
209
221
  has_agent_config: ["агента здесь не настраивали", "настройки агента есть"],
210
222
  has_agent_entry: ["свода для агента здесь нет", "свод для агента есть"],
@@ -95,4 +95,18 @@ function proposeGates(files = {}) {
95
95
  const ADOPT_FILES = ["package.json", "Makefile", ".pre-commit-config.yaml", "tox.ini", "pyproject.toml"];
96
96
  const ADOPT_SCRIPTS = ["scripts/test", "scripts/check", "scripts/lint"];
97
97
 
98
- export { proposeGates, ADOPT_FILES, ADOPT_SCRIPTS };
98
+ // Чтение с диска отдельно от разбора: разбор проверяется перебором случаев, чтение — прогоном.
99
+ async function readAdoptFiles(cwd) {
100
+ const { readFile, access } = await import("node:fs/promises");
101
+ const { join } = await import("node:path");
102
+ const files = {};
103
+ for (const n of ADOPT_FILES) {
104
+ try { files[n] = await readFile(join(cwd, n), "utf8"); } catch { /* нет — и ладно */ }
105
+ }
106
+ for (const n of ADOPT_SCRIPTS) {
107
+ try { await access(join(cwd, n)); files[n] = ""; } catch { /* нет — и ладно */ }
108
+ }
109
+ return files;
110
+ }
111
+
112
+ export { proposeGates, readAdoptFiles };
@@ -0,0 +1,115 @@
1
+ // tool/lib/advice.mjs — ЧТО СОВЕТОВАТЬ ПРОЕКТУ: какие записи каталога его касаются, с каких
2
+ // начать и какой командой закрыть класс брака прямо сейчас, без комплекта.
3
+ //
4
+ // ОТДЕЛЬНЫМ ФАЙЛОМ, а не в repo.mjs: там осмотр репозитория (факты, триггеры, рецепты), здесь —
5
+ // решение, что из этого сказать человеку и агенту. Одно знание на `doctor`, `probe` и блок для
6
+ // агента: жило в двух файлах и росло в обоих; шов вскрыл наш же file-size-limit — repo.mjs
7
+ // дорос до 494 строк при пределе 500.
8
+ import { triggerVerdict } from "./repo.mjs";
9
+
10
+ // Корзины каталога для ЭТОГО проекта: держит · к установке · закрыто другим арбитром ·
11
+ // неприменимо. Одна раскладка на `doctor` и на блок для агента: жила внутри `doctor`, и блоку
12
+ // пришлось бы завести вторую — а два счёта одного и того же расходятся первыми.
13
+ function catalogBuckets(catalog, facts, covered = new Map()) {
14
+ const held = [], todo = [], skip = [], byOther = [];
15
+ for (const rec of catalog) {
16
+ const v = triggerVerdict(rec, facts);
17
+ if (!v.applies) skip.push([rec, v.why]);
18
+ else if (facts.gateKeys.includes(rec.slug)) held.push(rec);
19
+ else if (covered.has(rec.slug)) byOther.push([rec, covered.get(rec.slug)]);
20
+ else todo.push(rec);
21
+ }
22
+ return { held, todo, skip, byOther };
23
+ }
24
+
25
+ // С ЧЕГО НАЧАТЬ: три записи вместо двадцати равнозначных крестов.
26
+ //
27
+ // Двадцать одинаковых требований — это ноль требований: закрывают первое попавшееся или не
28
+ // закрывают ничего. Порядок НЕ по нашему вкусу; два признака, оба — факты, которые у нас уже
29
+ // есть:
30
+ // · запись родилась из настоящего отказа (`lifecycle: stable` — `proof` ссылается на журнал
31
+ // шишек), то есть она про боль, которая СЛУЧАЛАСЬ, а не про «хорошую практику»;
32
+ // · её можно закрыть одной готовой командой — цена входа минутная.
33
+ // Сначала то, что и больно, и дёшево.
34
+ //
35
+ // При равенстве признаков — по имени: одинаковый ввод обязан давать одинаковый ответ, иначе
36
+ // человек видит разный совет на двух прогонах подряд и перестаёт верить обоим.
37
+ function startWith(entries, facts, n = 3) {
38
+ const langs = facts?.langs ? [...facts.langs] : [];
39
+ const cheap = (e) => {
40
+ const r = e?.recipes && typeof e.recipes === "object" ? e.recipes : {};
41
+ return [...langs, "native"].some((k) => r[k] && !/\{gate\}/.test(r[k])) ? 1 : 0;
42
+ };
43
+ // Зрелость НЕ поле записи, а вычисляемый признак: `proof` ссылается на журнал шишек. Тот же
44
+ // признак, которым каталог отделяет условную запись с первого дня (`entryLifecycle`).
45
+ // Заводить второй счёт нельзя: разъехавшись, они дали бы разные ответы про одну запись.
46
+ const hurt = (e) => (/incidents\//.test(String(e?.proof || "")) ? 1 : 0);
47
+ return [...entries]
48
+ .sort((a, b) =>
49
+ (hurt(b) + cheap(b)) - (hurt(a) + cheap(a)) ||
50
+ cheap(b) - cheap(a) ||
51
+ String(a.slug).localeCompare(String(b.slug)))
52
+ .slice(0, n);
53
+ }
54
+
55
+ // Адрес Biome сверен по реестру github 2026-09-11.
56
+ const BIOME_URL = "https://github.com/biomejs/biome";
57
+
58
+ // Совет по НЕПОКРЫТОМУ классу: команда, которую можно вставить прямо сейчас.
59
+ //
60
+ // Проба находит настоящие дыры и печатала про них «close it: aqk add <имя>» — то есть «поставь
61
+ // нашу штуку». Человек, впервые увидевший комплект, закрывает окно. А готовая однострочная
62
+ // команда под его стек У НАС УЖЕ ЛЕЖИТ в `recipes` записи каталога; мы её не показывали.
63
+ //
64
+ // Замер руками на `requests` (самый скачиваемый python-пакет) 2026-09-10: в
65
+ // `src/requests/utils.py` — 75 коммитов-починок; дописана функция с `except Exception: pass`;
66
+ // их собственные `ruff` и `pytest` дали 0 и на чистой копии, и на подсаженной. Строка, которая
67
+ // поймала бы это, лежала в нашем каталоге всё это время.
68
+ //
69
+ // Переносимый рецепт (`any`) в совет НЕ идёт: он зовёт файл из комплекта, и человеку без
70
+ // комплекта вставить его некуда. Нет родного рецепта под стек — команды нет, и это честнее
71
+ // выдуманной.
72
+ function blindAdvice(entry, facts, hot = {}) {
73
+ const recipes = entry?.recipes && typeof entry.recipes === "object" ? entry.recipes : {};
74
+ // `langs` приходит МНОЖЕСТВОМ, а не массивом — `Array.isArray` тихо давал пустой список, и
75
+ // совет не печатался вовсе. Поймано на живом `requests`: langs = Set(1) { python }.
76
+ const langs = facts?.langs ? [...facts.langs] : [];
77
+ // Тот же порядок, что у `pickRecipe`: свой язык → безъязыковой родной → ничего. Переносимый
78
+ // (`any`) сюда не идёт никогда: он зовёт файл из комплекта, и человеку без комплекта вставить
79
+ // его некуда.
80
+ // Проект на Biome — совет на языке Biome, а не eslint (отзыв с живого проекта 2026-09-11:
81
+ // «начни с трёх» советовал завести eslint проекту на Biome). Одно правило разово через `--only`;
82
+ // `--error-on-warnings` обязателен — правило вне рекомендованных Biome ставит на
83
+ // «предупреждение», и без флага команда выходила с нулём, напечатав находку (Biome 2.5.12).
84
+ const br = typeof entry?.biome_rules === "string" ? entry.biome_rules.trim() : "";
85
+ if (facts?.has_biome && br && br !== "none" && langs.some((l) => l === "javascript" || l === "typescript")) {
86
+ const only = br.split(",").map((x) => x.trim()).filter(Boolean).map((r) => `--only=${r}`).join(" ");
87
+ return { command: `npx @biomejs/biome lint --error-on-warnings ${only} .`, tool: BIOME_URL,
88
+ file: hot.file ?? null, fixes: hot.fixes ?? null, slug: entry?.slug ?? null };
89
+ }
90
+ let cmd = null;
91
+ for (const key of [...langs, "native"]) {
92
+ const r = recipes[key];
93
+ if (!r || /\{gate\}/.test(r)) continue;
94
+ cmd = String(r).replace(/\{dir\}/g, ".")
95
+ // Вычистить то, что относится к НАМ, а не к его проекту. Исключение наших красных
96
+ // образцов нужно УСТАНОВЛЕННОМУ гейту — рядом с ним лежат образцы. Человеку, который
97
+ // команду только копирует, этих каталогов не существует, и флаги про них подрывают
98
+ // доверие: инструмент говорит про чужое хозяйство вместо его кода.
99
+ .replace(/\s--ignore-pattern\s+'[^']*gates\/[^']*'/g, "")
100
+ .replace(/\s--ignore-paths=?\s*'[^']*gates\/[^']*'/g, "")
101
+ .replace(/\s+/g, " ")
102
+ .trim();
103
+ break;
104
+ }
105
+ // Адрес — того инструмента, которым команда начинается: поле `tool` общее на все языки, и
106
+ // python-проекту показывалась ссылка на eslint. Первые три слова, а не одно: `npx knip`,
107
+ // `python -m vulture`. Не совпало — весь список: лишняя ссылка лучше, чем ни одной.
108
+ const urls = entry?.tool ? String(entry.tool).split(/\s+·\s+/) : [];
109
+ const head = cmd ? cmd.split(" ").slice(0, 3) : [];
110
+ const own = urls.find((u) => head.includes(u.replace(/\/+$/, "").split("/").pop()));
111
+ const tool = own ?? (entry?.tool ? String(entry.tool) : null);
112
+ return { command: cmd, tool, file: hot.file ?? null, fixes: hot.fixes ?? null, slug: entry?.slug ?? null };
113
+ }
114
+
115
+ export { catalogBuckets, startWith, blindAdvice };
@@ -112,7 +112,9 @@ function beginBrief() {
112
112
 
113
113
  // Печать краткого итога. Совет — не чаще раза в сутки и с явным способом отказаться: то, что
114
114
  // видишь тридцатый раз, перестаёт читаться и пролистывается вместе с настоящими находками рядом.
115
- // Отметка времени лежит в .aqk/, который в .gitignore: это состояние машины, а не проекта.
115
+ // Отметка времени лежит в .aqk/ и попадает в .gitignore при `init` (RUNTIME_FILES в core.mjs):
116
+ // это состояние машины, а не проекта. Раньше здесь было написано «.aqk/ в .gitignore» — а
117
+ // `init` туда ничего не клал, и на живом проекте служебный файл уехал в коммит.
116
118
  async function finishBrief(buf, state, todoRecs, ok) {
117
119
  if (!buf) return;
118
120
  buf.restore();
@@ -89,4 +89,14 @@ function parseRan(text) {
89
89
  return m ? new Set(m[1].split(/\s+/).filter(Boolean)) : null;
90
90
  }
91
91
 
92
- export { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan };
92
+ // Запускаться ли пробе САМОЙ внутри `doctor --run`. Не в конвейере: там это +2–3 минуты сюрпризом
93
+ // в случайном прогоне (отзыв с живого проекта 2026-09-11), и место пробы — отдельная задача. Не в
94
+ // коротком режиме: там хук на воротах коммита. `AQK_PROBE=0` — выключить, `AQK_PROBE=1` — включить
95
+ // и в конвейере.
96
+ function autoProbeAllowed({ brief = false, env = process.env } = {}) {
97
+ if (env.AQK_PROBE === "0" || brief) return false;
98
+ if (env.AQK_PROBE === "1") return true;
99
+ return !(env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI || env.BUILDKITE || env.JENKINS_URL);
100
+ }
101
+
102
+ export { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan, autoProbeAllowed };
package/tool/lib/core.mjs CHANGED
@@ -142,9 +142,47 @@ async function writeIfAbsent(path, content, { force }) {
142
142
  return true;
143
143
  }
144
144
 
145
+ // СЛУЖЕБНЫЕ ФАЙЛЫ: состояние ЭТОЙ машины, переписываются каждым прогоном — в git им не место.
146
+ // Один список на `init` (кладёт в .gitignore), `doctor` (предупреждает, если git их видит) и
147
+ // читателей. Отзыв с живого проекта 2026-09-11: `.aqk/last-run.md` однажды закоммитили, и каждый
148
+ // `make check` оставлял изменённый файл. Целиком `.aqk/` не игнорируется: методички и правила в
149
+ // нём — содержимое проекта.
150
+ const RUNTIME_FILES = ["last-run.md", "last-probe.md", "advice-shown", "update-checked"];
151
+ const L_IGNORE_NOTE = LANG === "en"
152
+ ? "# aqk: this machine's state — rewritten by every run, it does not belong in git"
153
+ : "# aqk: состояние этой машины — переписывается каждым прогоном, в git ему не место";
154
+
155
+ // Дописать служебные файлы в .gitignore, не трогая чужих строк и не дублируя своих. Возвращает
156
+ // список добавленных строк — `init` называет их вслух: правка чужого файла без слова была бы
157
+ // тем самым «пишу в дерево без спроса».
158
+ async function ensureIgnored(cwd = CWD) {
159
+ const { readFile, writeFile } = await import("node:fs/promises");
160
+ const file = join(cwd, ".gitignore");
161
+ let text = "";
162
+ try { text = await readFile(file, "utf8"); } catch { /* файла нет — создадим */ }
163
+ const have = new Set(text.split(/\r?\n/).map((l) => l.trim()));
164
+ const add = RUNTIME_FILES.map((f) => `${TARGET_DIR}/${f}`).filter((l) => !have.has(l) && !have.has(`/${l}`));
165
+ if (!add.length) return [];
166
+ const head = text && !text.endsWith("\n") ? "\n" : "";
167
+ await writeFile(file, `${text}${head}${text ? "\n" : ""}${L_IGNORE_NOTE}\n${add.join("\n")}\n`, "utf8");
168
+ return add;
169
+ }
170
+
171
+ // Стоит ли хук pre-commit НА САМОМ ДЕЛЕ — в `.git/hooks`, а не в `.pre-commit-config.yaml`:
172
+ // запись в конфиге — намерение, сработает только то, что лежит в гите. Три ответа: true — стоит,
173
+ // false — нет, null — не git или файл не прочитать («не знаем» не сливается с «нет»).
174
+ // Одна функция на `vitals` (подключено ли) и `context` (что сказать агенту перед коммитом).
175
+ async function preCommitHook(cwd = CWD) {
176
+ const { readFile } = await import("node:fs/promises");
177
+ if (!(await exists(join(cwd, ".git")))) return null;
178
+ const hook = join(cwd, ".git", "hooks", "pre-commit");
179
+ if (!(await exists(hook))) return false;
180
+ try { return /pre-commit|aqk/i.test(await readFile(hook, "utf8")); } catch { return null; }
181
+ }
182
+
145
183
  export {
146
184
  copyDir, writeIfAbsent,
147
185
  PKG_ROOT, CWD, DOCS_SRC, RULES_SRC, TARGET_DIR, docPath,
148
186
  MANIFEST, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB,
149
- SELF, REPO_URL, c, exists, die, FEEDBACK_MARK, commandRows,
187
+ SELF, REPO_URL, c, exists, die, FEEDBACK_MARK, commandRows, preCommitHook, RUNTIME_FILES, ensureIgnored,
150
188
  };
@@ -0,0 +1,18 @@
1
+ // tool/lib/gate-worker.mjs — рабочий поток параллельного прогона (`doctor --run --jobs N`).
2
+ //
3
+ // ПОЧЕМУ ПОТОК, А НЕ АСИНХРОННЫЙ ЗАПУСК. Прогон стоит на `spawnSync` с таймаутом: его поведение
4
+ // на зависшем гейте замерено и описано (execution.mjs). Асинхронный `spawn` потребовал бы своего
5
+ // убийства дерева процессов на таймауте — на Windows это отдельная история. Поток выполняет ТОТ ЖЕ
6
+ // `spawnSync` с тем же таймаутом, и семантика прогона не меняется ни в чём, кроме одновременности.
7
+ // Зависимостей это не добавляет: worker_threads встроены в Node.
8
+ import { parentPort } from "node:worker_threads";
9
+ import { spawnSync } from "node:child_process";
10
+ import { gateCommand } from "./execution.mjs";
11
+
12
+ parentPort.on("message", ({ id, cmd, cwd, timeout }) => {
13
+ const r = spawnSync(gateCommand(cmd), { shell: true, cwd, encoding: "utf8", timeout });
14
+ parentPort.postMessage({
15
+ id, status: r.status, stdout: r.stdout || "", stderr: r.stderr || "",
16
+ error: r.error ? { code: r.error.code || String(r.error.message || r.error) } : null,
17
+ });
18
+ });
@@ -117,9 +117,36 @@ function probeSummary({ caught = 0, blind = 0, unknown = 0, unprobed = 0 } = {})
117
117
  // Найдено пробой на самом комплекте 2026-09-11: образец лёг на место общей библиотеки
118
118
  // kit/gates/_skip.sh, двенадцать гейтов вышли с кодом 2 — включая тот, что этот образец в
119
119
  // отдельной папке ловит. Остальные молчали, и проба назвала класс слепым.
120
+ // «ПОКРАСНЕЛ» ≠ «ПОЙМАЛ». Отзыв с живого проекта 2026-09-11: класс «цвет из токена темы» отмечен
121
+ // пойманным линтером, хотя Biome цвета не проверяет, — подсаженный кусок сломал форматирование.
122
+ // Вердикт сравнивал коды возврата и выбрасывал вывод. Теперь гейт засчитывается, только если в
123
+ // его выводе ПОСЛЕ подсадки подсаженный файл назван чаще, чем до неё. Путь ищется в любой
124
+ // форме: `src/x`, `./src/x`, абсолютный из песочницы (подстрока), с обратными слешами Windows.
125
+ // Одно имя файла без каталога не засчитывается: у двух файлов оно бывает одинаковым, и
126
+ // поимка чужого файла выдалась бы за поимку нашего. Безымянное падение — «неизвестно».
127
+ function namesPlant(before, after, rel) {
128
+ const forms = [rel, String(rel).replace(/\//g, "\\")];
129
+ const count = (s) => forms.reduce((n, f) => n + String(s || "").split(f).length - 1, 0);
130
+ return count(after) > count(before);
131
+ }
132
+
133
+ // ПАРА, А НЕ ОДИН ОБРАЗЕЦ. Имени файла мало: Biome, падая на форматировании, тоже называет файл.
134
+ // Поэтому поимка подтверждается зелёным образцом той же записи, положенным в то же место, — тем
135
+ // же приёмом, каким мы требуем от чужого гейта доказательства. Три исхода:
136
+ // caught — на красном покраснел и назвал файл, на зелёном промолчал (или файла не назвал);
137
+ // nameless — покраснел, но подсаженного файла не назвал: упал по своей причине;
138
+ // planting — краснеет и на зелёном, называя тот же файл: падает от самой подсадки.
139
+ // Зелёного образца под это расширение нет — судим по имени файла: пусть слабее, но это не
140
+ // «поймано» на ровном месте, и в README записи такой случай назван.
141
+ function catchVerdict(beforeOut, red, green, rel) {
142
+ if (!namesPlant(beforeOut, red?.out, rel)) return "nameless";
143
+ if (green && green.code === 1 && namesPlant(beforeOut, green.out, rel)) return "planting";
144
+ return "caught";
145
+ }
146
+
120
147
  function probeVerdictPaired(before, after) {
121
148
  const byName = new Map(after.map((r) => [r.name, r]));
122
- let usable = 0, alreadyRed = 0, failed = 0, caught = 0, brokenByPlant = 0;
149
+ let usable = 0, alreadyRed = 0, failed = 0, caught = 0, brokenByPlant = 0, unattributed = 0;
123
150
  for (const b of before) {
124
151
  const a = byName.get(b.name);
125
152
  const broke = (r) => !r || (r.code !== 0 && r.code !== 1);
@@ -130,10 +157,12 @@ function probeVerdictPaired(before, after) {
130
157
  }
131
158
  if (b.code === 1) { alreadyRed++; continue; }
132
159
  usable++;
133
- if (a.code === 1) caught++;
160
+ // `named === false` — покраснел, но подсаженного файла не назвал. `undefined` — вызывающий
161
+ // вывода не собирал (старые вызовы): тогда прежнее правило.
162
+ if (a.code === 1) { if (a.named === false) unattributed++; else caught++; }
134
163
  }
135
- const verdict = caught ? "caught" : !usable || brokenByPlant ? "unknown" : "blind";
136
- return { verdict, usable, alreadyRed, failed, caught, brokenByPlant };
164
+ const verdict = caught ? "caught" : !usable || brokenByPlant || unattributed ? "unknown" : "blind";
165
+ return { verdict, usable, alreadyRed, failed, caught, brokenByPlant, unattributed };
137
166
  }
138
167
 
139
168
  // Счёт непокрытого КЛАССАМИ, а не пробами. Замер на десяти живых репозиториях 2026-09-10:
@@ -160,4 +189,4 @@ function countProbe(records) {
160
189
  };
161
190
  }
162
191
 
163
- export { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe };
192
+ export { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe, namesPlant, catchVerdict };