agent-quality-kit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +88 -18
  2. package/README.ru.md +91 -19
  3. package/kit/docs/ai/index.md +1 -0
  4. package/kit/docs/ai/operational-gates.md +275 -0
  5. package/kit/gates/_target.sh +53 -0
  6. package/kit/gates/ci-actually-fails/check.sh +18 -3
  7. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +10 -0
  8. package/kit/gates/entry-commands-exist/check.sh +88 -12
  9. package/kit/gates/hook-actually-fires/README.md +12 -0
  10. package/kit/gates/hook-actually-fires/check.sh +66 -6
  11. package/kit/gates/hook-actually-fires/gate.yml +2 -2
  12. package/kit/gates/hook-actually-fires/green/.claude/hooks/auto-format.sh +3 -0
  13. package/kit/gates/hook-actually-fires/green/.claude/hooks/block-dangerous.sh +3 -0
  14. package/kit/gates/hook-actually-fires/green/.claude/hooks/done.sh +3 -0
  15. package/kit/gates/hook-actually-fires/green/.claude/hooks/idle.sh +3 -0
  16. package/kit/gates/hook-actually-fires/green/.claude/hooks/prompt.sh +3 -0
  17. package/kit/gates/hook-actually-fires/green/.claude/hooks/session.mjs +1 -0
  18. package/kit/gates/hook-actually-fires/green/.claude/hooks/stop-gate.sh +3 -0
  19. package/kit/gates/hook-actually-fires/green/.claude/settings.json +12 -0
  20. package/kit/gates/test-not-adjusted/README.md +31 -0
  21. package/llms.txt +26 -7
  22. package/package.json +1 -1
  23. package/tool/commands/context.mjs +39 -41
  24. package/tool/commands/doctor-catalog.mjs +35 -10
  25. package/tool/commands/doctor.mjs +18 -26
  26. package/tool/commands/feedback.mjs +231 -0
  27. package/tool/commands/gates.mjs +12 -6
  28. package/tool/commands/project.mjs +11 -13
  29. package/tool/commands/prompt.mjs +2 -1
  30. package/tool/commands/report.mjs +19 -3
  31. package/tool/commands/vitals.mjs +9 -3
  32. package/tool/i18n/en-docs.mjs +19 -2
  33. package/tool/i18n/en-gates.mjs +35 -0
  34. package/tool/i18n/en.mjs +29 -2
  35. package/tool/i18n/ru-docs.mjs +18 -2
  36. package/tool/i18n/ru-gates.mjs +36 -0
  37. package/tool/i18n/ru.mjs +27 -2
  38. package/tool/lib/adopt.mjs +58 -4
  39. package/tool/lib/ask.mjs +118 -0
  40. package/tool/lib/brief.mjs +17 -38
  41. package/tool/lib/core.mjs +49 -12
  42. package/tool/lib/execution.mjs +32 -1
  43. package/tool/lib/gate-worker.mjs +4 -1
  44. package/tool/lib/manifest.mjs +39 -13
  45. package/tool/lib/prove.mjs +3 -3
  46. package/tool/lib/run.mjs +142 -10
  47. package/tool/program.mjs +6 -0
  48. package/tool/selfcheck/smoke/_fixture.mjs +13 -1
  49. package/tool/selfcheck/smoke/fail-closed.test.mjs +96 -1
  50. package/tool/selfcheck/smoke/feedback-send.test.mjs +87 -0
  51. package/tool/selfcheck/smoke/first-run.test.mjs +67 -3
  52. package/tool/selfcheck/smoke/preflight.test.mjs +83 -0
  53. package/tool/selfcheck/smoke/verdict.test.mjs +50 -4
  54. package/tool/selfcheck/smoke/version-sync.test.mjs +140 -0
  55. package/tool/selfcheck/smoke.sh +106 -4
  56. package/tool/selfcheck/units-ask.mjs +85 -0
  57. package/tool/selfcheck/units-brief.mjs +3 -13
  58. package/tool/selfcheck/units-context.mjs +2 -1
  59. package/tool/selfcheck/units-execution.mjs +37 -1
  60. package/tool/selfcheck/units-feedback.mjs +137 -0
  61. package/tool/selfcheck/units-level.mjs +41 -1
  62. package/tool/selfcheck/units-repo.mjs +75 -0
  63. package/tool/selfcheck/units-vitals.mjs +27 -0
  64. package/kit/gates/entry-links-exist/README.md +0 -27
  65. package/kit/gates/entry-links-exist/check.sh +0 -33
  66. package/kit/gates/entry-links-exist/gate.yml +0 -17
  67. package/kit/gates/entry-links-exist/green/AGENTS.md +0 -10
  68. package/kit/gates/entry-links-exist/green/rules/general.md +0 -3
  69. package/kit/gates/entry-links-exist/red/AGENTS.md +0 -3
  70. package/kit/gates/no-phantom-package/README.md +0 -84
  71. package/kit/gates/no-phantom-package/check.sh +0 -168
  72. package/kit/gates/no-phantom-package/gate.yml +0 -20
  73. package/kit/gates/no-phantom-package/green/AGENTS.md +0 -15
  74. package/kit/gates/no-phantom-package/red/AGENTS.md +0 -15
@@ -26,6 +26,10 @@ const ruDocs = {
26
26
  unknownHook: "посмотреть не смогли — неизвестно, а не «нет»",
27
27
  preCommitOk: "прописан в .git/hooks",
28
28
  preCommitNo: "в .git/hooks его нет: запись в конфиге — намерение, а не защита",
29
+ // ХУК ЕСТЬ, НО НЕ НАШ. Ставить нечего — надо дописать: совет другой, значит и строка
30
+ // другая. До 2026-09-16 этот случай печатался как «прописан», и репозиторий, где AQK не
31
+ // вызывался ни разу, выглядел подключённым.
32
+ preCommitOther: "хук в .git/hooks есть, но AQK в нём не вызывается — проверьте .pre-commit-config.yaml либо само тело хука",
29
33
  sessionOk: "хук SessionStart отдаёт состояние агенту",
30
34
  sessionNo: (cmd) => `агент не получает состояние: ${cmd}`,
31
35
  versionOk: (v) => `${v}, свежая`,
@@ -65,6 +69,7 @@ const ruDocs = {
65
69
  "Прогон не делался — какие проверки красные, НЕИЗВЕСТНО. Это не «чисто»: `aqk doctor --run`.",
66
70
  runStale: (when) =>
67
71
  `Последний прогон ${when} СТАРЕЕ последнего коммита — он описывает не тот код, что здесь.`,
72
+ runCannot: (names) => `НЕ СМОГЛИ ПРОВЕРИТЬ (сбой самих проверок, а не находки о коде): ${names}`,
68
73
  runClean: (when) => `Последний прогон ${when} — красных нет.`,
69
74
  runRed: (when, names) => `Последний прогон ${when} — КРАСНЫЕ: ${names}.`,
70
75
  andMore: (n) => `и ещё ${n}`,
@@ -86,7 +91,14 @@ const ruDocs = {
86
91
  // complexity-limit её поймал.
87
92
  nextStep: {
88
93
  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\`.`,
94
+ adopt: (s) => {
95
+ const ok = s.gates.filter((g) => !g.weak);
96
+ const weak = s.gates.filter((g) => g.weak);
97
+ const out = [];
98
+ if (ok.length) out.push(`Объяви проверки, которые у проекта уже есть: в .aqk.yml, в gates: ${ok.map((g) => `${g.name}: "${g.cmd}"`).join(", ")}. Затем \`aqk doctor --run\`.`);
99
+ if (weak.length) out.push(`А эти в нынешнем виде покраснеть не могут — ${weak.map((g) => `${g.cmd} (${g.weak.text})`).join(", ")} — и объявлять их пока нельзя: скажи владельцу и почини команду.`);
100
+ return out.join(" ");
101
+ },
90
102
  blind: (s) => `Брак «${s.slug}», подсаженный пробой в ${s.file}, ваши проверки НЕ поймали.` +
91
103
  (s.command ? ` Поймать сейчас: \`${s.command}\`.` : "") + ` Держать всегда: \`aqk add ${s.slug}\`.`,
92
104
  start: (s) => `Поставь ${s.slug}: \`aqk add ${s.slug}\`` + (s.command ? ` (одной строкой, без комплекта: \`${s.command}\`)` : "") +
@@ -96,7 +108,9 @@ const ruDocs = {
96
108
  whenTitle: "Когда что делать:",
97
109
  whenCommit: (hook) => hook === true
98
110
  ? "Перед коммитом → хук `.git/hooks/pre-commit` прогонит проверки сам; не обходи его (`--no-verify`)."
99
- : "Перед коммитом → `aqk doctor --run --since main`: хука перед коммитом нет, само это не случится.",
111
+ : hook === "other"
112
+ ? "Перед коммитом → `aqk doctor --run --since main`: хук в `.git/hooks` стоит, но AQK из него не вызывается — сам собой прогон не случится."
113
+ : "Перед коммитом → `aqk doctor --run --since main`: хука перед коммитом нет, само это не случится.",
100
114
  whenRules: [
101
115
  "Добавил или поменял проверку → `aqk prove`: гейт обязан покраснеть на своём красном образце, иначе он не проверяет ничего.",
102
116
  "Пишешь правило в свод → рядом метка арбитра `<!-- aqk: имя-гейта -->`; гейта нет — `<!-- aqk: человек -->`, и это признание, а не проверка.",
@@ -158,6 +172,7 @@ const ruDocs = {
158
172
  whyTitle: "Зачем это нужно — коротко",
159
173
  whyNothing: "нечего добавлять: применимое уже стоит",
160
174
  saved: (path) => `Сохранено: ${path}`,
175
+ notSaved: (f, why) => `сохранить в ${f} не смогли (${why}) — отчёт выше, на диске его нет. Команда только рассказывает о состоянии, поэтому неудачная запись её не роняет.`,
161
176
  docs: {
162
177
  baseline: "обязательный минимум проекта, без привязки к языку",
163
178
  readyMade: "карта готовых правил: сперва ищи готовое, потом пиши своё",
@@ -221,6 +236,7 @@ const ruDocs = {
221
236
  ],
222
237
  report: {
223
238
  skippedBySelect: "не запускался (по --only/--skip), состояние неизвестно",
239
+ notWritten: (f, why) => `отчёт прогона в ${f} записать не смогли (${why}) — гейты отработали, но следующая сессия и хук прочтут это как «прогона не было».`,
224
240
  title: "aqk doctor --run",
225
241
  version: "версия",
226
242
  level: "уровень",
@@ -187,6 +187,42 @@ export const ruGates = {
187
187
  issue: "Нашёл баг или не подошло — заведи Issue, самая полезная обратная связь: и то и другое.",
188
188
  notRemembered: "запомнить не удалось (дом недоступен для записи) — сообщение появится снова",
189
189
  once: "Это разовое сообщение: больше не покажется на этой машине.",
190
+
191
+ // Просьба ПО ДЕЛУ: у неё всегда есть содержание, иначе её нет вовсе.
192
+ ask: {
193
+ cannot: (names, cmd) =>
194
+ `AQK не смог проверить: ${names}. Это самое ценное, что можно рассказать автору — ${cmd}`,
195
+ blind: (names, cmd) =>
196
+ `Проба нашла классы брака, которые здесь не ловит никто: ${names}. Совпало с вашим опытом? — ${cmd}`,
197
+ red: (names, cmd) =>
198
+ `Комплект поймал: ${names}. Одна строка автору — помогло или нет: ${cmd}`,
199
+ once: "Спрашиваем один раз на проект; выключить: AQK_FEEDBACK=0",
200
+ agent: "Скажи это человеку одной фразой и не настаивай, если он не ответил. Захочет отправить — добавь `--send`, сам флаг не добавляй.",
201
+ },
202
+ sending: "отправляю от вашего имени — вашей же учётной записью gh…",
203
+ sent: (url) => `отправлено: ${url}\n Это и есть вся плата за комплект. Спасибо.`,
204
+ sendFailed: {
205
+ "no-auth": "отправить не смогли: gh не установлен или вход не выполнен (`gh auth login`). Ниже — ссылка, она работает всегда.",
206
+ "no-thread": "отправить не смогли: обсуждение не найдено. Ниже — ссылка.",
207
+ failed: "отправить не смогли. Ниже — ссылка, она работает всегда.",
208
+ },
209
+ sendHow: (cmd) => `Отправить одной командой, своей учётной записью GitHub: ${cmd}`,
210
+ how: "Отправить — одно нажатие (откроется готовая задача, текст можно править):",
211
+ issueTitle: "Отзыв о работе комплекта",
212
+ nothingSent: "Ничего не отправлено: комплект не делает исходящих запросов, кроме проверки версии.",
213
+ orPaste: (cmd) => `Не хочется GitHub — перешлите текст выше как есть: он весь собран ${cmd}`,
214
+ report: {
215
+ title: "### Отзыв о комплекте",
216
+ unknown: "неизвестно",
217
+ none: "нет",
218
+ env: (v, node, os) => `версия: ${v} · node: ${node} · система: ${os}`,
219
+ level: (x) => `уровень: ${x}`,
220
+ stack: (x) => `стек: ${x}`,
221
+ gates: (n, red, cannot) => `гейтов объявлено: ${n} · красных: ${red} · не смогли проверить: ${cannot}`,
222
+ blind: (x) => `классы, которые здесь не ловит никто: ${x}`,
223
+ say: "Что сказать своими словами (одна строка — самое полезное во всём письме):",
224
+ mark: (v) => `<!-- собрано «aqk feedback» ${v}: без путей, без кода, без имени репозитория -->`,
225
+ },
190
226
  },
191
227
 
192
228
  note: {
package/tool/i18n/ru.mjs CHANGED
@@ -53,6 +53,8 @@ export const ru = {
53
53
  blob: "собрать методички в один файл GOD_AI.md",
54
54
  learn: "кандидаты в правила из локальной переписки: сказано вслух и не записано",
55
55
  context: "состояние проекта одним блоком — для контекста агента, а не для чтения",
56
+ feedback: "отчёт о работе комплекта и готовая ссылка — единственная плата за него",
57
+ feedbackSend: "отправить отзыв одной командой — вашей учётной записью gh; без флага не уходит ничего",
56
58
  vitals: "подключено ли то, чем комплект работает: инструменты гейтов, хуки, свежесть версии",
57
59
  prompt: "одно задание для агента: что починить, по порядку, и чем доказать, что готово",
58
60
  contextInstall: "то же самое, но целиком — карта и свод правил — и хуком в контекст",
@@ -158,7 +160,16 @@ export const ru = {
158
160
  startHook: "Прогнать руками — разовый героизм. Чтобы это случалось перед каждым пушем: pre-commit (репозиторий https://github.com/arsen-ask-lx/Agent_Quality_Kit, хуки aqk / aqk-doctor) либо обычный .git/hooks/pre-push.",
159
161
  haveAlready: (n) => `Проверки, которые у вас УЖЕ ЕСТЬ (${n}) — прочитаны в ваших файлах, не выдуманы:`,
160
162
  haveAlreadyHow: (line) => `объявите их — и держать будет машина, а не ваше внимание. В .aqk.yml, в gates: ${line}`,
161
- total: "Итого:",
163
+ weakOff: (text) => `не может провалиться: исход погашен прямо в скрипте — «${text}»`,
164
+ weakZero: () => "не может провалиться: --exit-zero велит инструменту выйти нулём, что бы он ни нашёл",
165
+ weakStub: (text) => `ничего не доказывает: всё тело скрипта — печать — «${text}»`,
166
+ weakHow: (n) => `из них ${n} покраснеть не могут. Объявить проверку, которая не может ` +
167
+ "провалиться, — значит сделать молчание машинно-читаемым. Сначала почините команду.",
168
+ // «Итого» — ПО КАТАЛОГУ AQK, и это сказано словом. Считаются наши записи: гейт проекта,
169
+ // названный по-своему (`project-verify`), ни одну из них по имени не закрывает, и строка
170
+ // «держит машина 0» у проекта с тридцатью собственными проверками читалась как приговор
171
+ // ему, а не как счёт наших записей. Замер 2026-09-16 по двенадцати репозиториям.
172
+ total: "Итого по каталогу AQK:",
162
173
  totalHeld: (n) => `держит машина ${n}`,
163
174
  totalTodo: (n) => `применимо но не поставлено ${n}`,
164
175
  totalSkip: (n) => `скрыто ${n}`,
@@ -173,7 +184,14 @@ export const ru = {
173
184
  `совещательные и красные: ${names.join(", ")}. Это выключенные проверки: ` +
174
185
  `либо почини и убери из advisory, либо признай, что правила нет.`,
175
186
  runHeading: "Прогон объявленных гейтов",
187
+ timeoutBadEnv: (raw, secs) => `AQK_GATE_TIMEOUT=«${raw}» — это не число секунд больше нуля; жду как прежде, ${secs}с. Ноль и мусор у spawnSync означают «ждать вечно», а висящий гейт неотличим от работающего.`,
176
188
  timeout: "не уложился в 5 минут",
189
+ // «Не смогли проверить» — третье состояние, и оно обязано звучать иначе, чем находка:
190
+ // «код 2» человек читает как приговор коду, а это приговор запуску.
191
+ cannotCheck: (why) => `не смогли проверить: ${why}`,
192
+ whySpawn: (code) => `запустить не удалось${code ? ` (${code})` : ""}`,
193
+ whySignal: (sig) => `убит сигналом ${sig || "?"}`,
194
+ whyExit: (code) => `код ${code} — у этой команды это сбой, а не находка`,
177
195
  running: (i, n) => `[${i}/${n}] идёт…`,
178
196
  proving: "проверяю, что гейты ловят брак на своих образцах…",
179
197
  exitCode: (code) => `код ${code}`,
@@ -303,7 +321,14 @@ export const ru = {
303
321
  missed: ({ slug, file }, s) => `Гейт \`${slug}\` стоит, но пропустил брак, который проба подсадила в \`${file}\`. Разберись почему — частая причина: гейт не смотрит этот тип файлов или каталог; подробности даст \`${s} probe\`. Готово — \`${s} probe\` больше не называет этот класс.`,
304
322
  red: (name, s) => `Гейт \`${name}\` красный. Запусти \`${s} doctor --run --only ${name}\`, прочитай находки и почини код. Готово — эта команда зелёная.`,
305
323
  blind: ({ slug, file, command }, s) => `Проба подсадила брак класса \`${slug}\` в \`${file}\`, и проверки проекта его не заметили. Поставь проверку: \`${s} add ${slug}\`${command ? ` (то же одной строкой, без комплекта: \`${command}\`)` : ""}. Готово — \`${s} doctor --run --only ${slug}\` проходит, а \`${s} probe\` больше не называет этот класс.`,
306
- adopt: (gates, s) => `У проекта уже есть свои проверки: ${gates.map((g) => `\`${g.cmd}\` (${g.source})`).join(", ")}. Впиши их в gates: манифеста .aqk.yml — ${gates.map((g) => `\`${g.name}: "${g.cmd}"\``).join(", ")}. Готово — \`${s} doctor --run\` их запускает.`,
324
+ adopt: (gates, s) => {
325
+ const weak = gates.filter((g) => g.weak);
326
+ const ok = gates.filter((g) => !g.weak);
327
+ const out = [];
328
+ if (ok.length) out.push(`У проекта уже есть свои проверки: ${ok.map((g) => `\`${g.cmd}\` (${g.source})`).join(", ")}. Впиши их в gates: манифеста .aqk.yml — ${ok.map((g) => `\`${g.name}: "${g.cmd}"\``).join(", ")}. Готово — \`${s} doctor --run\` их запускает.`);
329
+ if (weak.length) out.push(`А эти в нынешнем виде покраснеть НЕ МОГУТ: ${weak.map((g) => `\`${g.cmd}\` — ${g.weak.text}`).join(", ")}. Не вписывай их, пока не починена команда: объявленная проверка, которая не может провалиться, превращает дыру в зелёную галочку, и с этого дня за неё ручается машина. Это решение владельца, а не твоё, — скажи ему, что нашёл.`);
330
+ return out.join(" ");
331
+ },
307
332
  shim: {
308
333
  missing: (s) => `Claude Code здесь настроен, а правила лежат в AGENTS.md — он читает только CLAUDE.md. Создай CLAUDE.md с одной строкой \`@AGENTS.md\`. Готово — \`${s} doctor\` об этом больше не предупреждает.`,
309
334
  noImport: (s) => `CLAUDE.md не подключает AGENTS.md — Claude Code видит только CLAUDE.md. Добавь в CLAUDE.md строку \`@AGENTS.md\` (упоминание словами файл не загружает). Готово — \`${s} doctor\` об этом больше не предупреждает.`,
@@ -20,24 +20,77 @@
20
20
  // ни другой. Берём только то, что отвечает «прошло или нет».
21
21
  const CHECK_NAMES = new Set(["test", "tests", "lint", "typecheck", "type-check", "types", "check"]);
22
22
 
23
+ // ПРОВЕРКА, КОТОРАЯ ЕСТЬ И НЕ МОЖЕТ ПРОВАЛИТЬСЯ. До 2026-09-14 мы читали ИМЯ скрипта и печатали
24
+ // каноничную команду `npm test` с зелёной галочкой, ни разу не заглянув в его ТЕЛО. А выключатель
25
+ // стоит именно там: `node --test || true`. На репозитории, где выключено всё, первый экран
26
+ // говорил «у вас уже есть 2 проверки» — ровно та ошибка, ради которой написан весь комплект.
27
+ //
28
+ // ГРАНИЦА ВЗЯТА У ГЕЙТА `ci-actually-fails`, дословно его довод: `|| true` в СЕРЕДИНЕ команды —
29
+ // это идемпотентность вспомогательного шага (`mkdir -p … || true`), а не выключенная проверка.
30
+ // Поэтому красным делается только то, под что попадает ВЕСЬ исход: гашение в конце тела либо
31
+ // флаг, у которого другого назначения нет.
32
+ const OFF_TAIL = /(\|\|\s*(?:true|:|exit\s+0)|;\s*(?:true|exit\s+0))\s*$/;
33
+ const ZERO_FLAG = /(?:^|\s)--exit-zero(?:\s|$)/;
34
+
35
+ function cannotFail(body) {
36
+ if (typeof body !== "string") return undefined;
37
+ // Комментарий — не команда. `\s#` (а не просто `#`): в `echo "#1"` решётка стоит внутри строки.
38
+ const code = body.replace(/\s#[^"']*$/, "").trim();
39
+ if (!code) return undefined;
40
+ const off = OFF_TAIL.exec(code);
41
+ if (off) return { kind: "off", text: off[1].trim() };
42
+ if (ZERO_FLAG.test(code)) return { kind: "zero", text: "--exit-zero" };
43
+ // Заглушка: всё тело — печать и ничего больше. Заготовка npm
44
+ // (`echo "Error: no test specified" && exit 1`) провалиться МОЖЕТ — её обвинять нельзя,
45
+ // и её отсекает связка `&&`.
46
+ if (/^echo\b/.test(code) && !/[&|;]/.test(code)) return { kind: "stub", text: code.slice(0, 60) };
47
+ return undefined;
48
+ }
49
+
23
50
  // Окружения tox, которые судят, а не гоняют тесты под матрицей версий. Голый `tox` не
24
51
  // предлагаем: он проходит все интерпретаторы из envlist, и у человека без пяти питонов это
25
52
  // красный прогон на пустом месте. Имена сняты с живых tox-файлов (rich, click, flask).
26
53
  const TOX_CHECKS = new Set(["lint", "style", "typing", "types", "type", "mypy", "check", "typecheck"]);
27
54
 
55
+ // ОДНА И ТА ЖЕ КОМАНДА, ЗАПИСАННАЯ ПО-РАЗНОМУ. Сравнивать надо КОМАНДЫ, а не имена: гейт в
56
+ // чужом манифесте зовётся как хочет его владелец (`project-verify`), и по именам совпадения не
57
+ // будет никогда. Обёртка снимается: `bash scripts/check` и `scripts/check` — это одно, и
58
+ // советовать второе тому, у кого объявлено первое, значит советовать уже сделанное.
59
+ //
60
+ // Вхождение подстрокой здесь НЕ годится, хотя и соблазняет: `true` содержится в половине
61
+ // команд, и любой гейт-заглушка съел бы весь список. Равенство после нормализации ошибается
62
+ // в одну сторону — покажет лишнее, — и это дешевле молчания.
63
+ const WRAPPER = /^(?:bash|sh)\s+/;
64
+
65
+ function sameCommand(a, b) {
66
+ const norm = (s) => String(s || "").trim().replace(/\s+/g, " ");
67
+ const bare = (s) => norm(s).replace(WRAPPER, "");
68
+ return (norm(a) && norm(a) === norm(b)) || (bare(a) && bare(a) === bare(b));
69
+ }
70
+
28
71
  // Источники в порядке доверия: при совпадении имени берётся первый. Замер 2026-09-11 на
29
72
  // восьми python-проектах: package.json нет ни у кого, Makefile у трёх, `.pre-commit-config.yaml`
30
73
  // у семи, tox у пяти, scripts/ у httpx. Шаги конвейера НЕ читаются: там `${{ matrix.x }}`,
31
74
  // `PYTHONPATH=…` и обёртки `uv run --locked --group …` — строка, которая работает только в
32
75
  // том конвейере, предложенная как локальный гейт, покраснела бы у человека в первую же минуту.
33
- function proposeGates(files = {}) {
76
+ //
77
+ // `declared` — команды, УЖЕ объявленные в манифесте. Приходят списком строк, а не манифестом:
78
+ // иначе чтение чужих конфигов начало бы зависеть от нашего формата, и файл, заведённый ради
79
+ // разбора ЧУЖИХ источников, получил бы вторую причину меняться.
80
+ function proposeGates(files = {}, declared = []) {
34
81
  const out = [];
35
82
  const seen = new Set();
36
- const push = (name, cmd, source) => {
83
+ const already = (cmd) => (declared || []).some((d) => sameCommand(d, cmd));
84
+ // ТЕЛО передаётся отдельно от команды: человеку показывается каноничная `npm test`, а судим
85
+ // мы по тому, что за ней стоит. Где тела у нас нет (Makefile, tox, scripts/) — там и суждения
86
+ // нет: молчание тут честнее догадки.
87
+ const push = (name, cmd, source, body) => {
37
88
  const key = name === "tests" ? "test" : name.replace(/^type-?check$|^types$/, "typecheck");
38
89
  if (seen.has(key)) return;
39
90
  seen.add(key);
40
- out.push({ name: key, cmd, source });
91
+ if (already(cmd)) return;
92
+ const weak = cannotFail(body);
93
+ out.push(weak ? { name: key, cmd, source, weak } : { name: key, cmd, source });
41
94
  };
42
95
 
43
96
  const pkg = files["package.json"];
@@ -48,7 +101,8 @@ function proposeGates(files = {}) {
48
101
  for (const name of Object.keys(scripts)) {
49
102
  if (!CHECK_NAMES.has(name)) continue;
50
103
  // `npm test` — каноничное написание для теста, остальное через `run`.
51
- push(name, name === "test" || name === "tests" ? "npm test" : `npm run ${name}`, "package.json");
104
+ push(name, name === "test" || name === "tests" ? "npm test" : `npm run ${name}`,
105
+ "package.json", scripts[name]);
52
106
  }
53
107
  }
54
108
  }
@@ -0,0 +1,118 @@
1
+ // tool/lib/ask.mjs — КОГДА КОМПЛЕКТ ОБРАЩАЕТСЯ К ЧЕЛОВЕКУ, и как он помнит, что уже обращался.
2
+ //
3
+ // ЗАЧЕМ ОТДЕЛЬНЫЙ МОДУЛЬ. Знание «мы это показывали» жило в комплекте дважды и по-разному:
4
+ // · `~/.config/aqk/feedback-shown` — файл-флаг, раз на машину: заведён в `core.mjs`,
5
+ // прочитан в `project.mjs`;
6
+ // · `.aqk/advice-shown`, `.aqk/update-checked` — отметка временем плюс `adviceDue()` на
7
+ // сутки: заведены и прочитаны в `brief.mjs`.
8
+ // Одно решение — «не долби человека» — в двух местах и в двух форматах. Третье обращение
9
+ // завело бы третий формат, и дальше они расходятся молча.
10
+ //
11
+ // ЧТО ЗДЕСЬ ЕСТЬ И ЧЕГО НЕТ. Здесь только ОГРАНИЧИТЕЛЬ: можно ли сейчас обратиться и чем это
12
+ // запомнить. Чего здесь нет — текста обращения и решения, есть ли о чём говорить: текст живёт
13
+ // в каталогах строк, решение — у того, кто знает состояние проекта. Иначе модуль про «когда»
14
+ // начал бы меняться вместе с каждой правкой формулировки.
15
+ //
16
+ // ЛИСТ ДЕРЕВА: импортируются только встроенные модули Node. Каталоги передаются вызывающим, а
17
+ // не берутся из `core.mjs`, — иначе получилось бы кольцо: `core.mjs` берёт отсюда список
18
+ // служебных файлов для `.gitignore`.
19
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+
22
+ const DAY = 24 * 60 * 60 * 1000;
23
+
24
+ // ТАБЛИЦА ВИДОВ. Вид объявляет две вещи, и обе нельзя угадать по имени:
25
+ // where — «project» (отметка про ЭТОТ репозиторий, лежит в его служебном каталоге) или
26
+ // «home» (отметка про ЭТУ МАШИНУ, лежит в доме пользователя);
27
+ // every — через сколько можно повторить; `null` означает «никогда», а не «очень нескоро».
28
+ //
29
+ // Имена файлов — прежние, до единой буквы: человек, у которого отметка уже лежит, не должен
30
+ // получить обращение заново только потому, что мы переставили код.
31
+ const ASKS = {
32
+ // Совет про непоставленную запись каталога — в короткой строке хука.
33
+ advice: { where: "project", file: "advice-shown", every: DAY },
34
+ // Проверка свежести версии — единственный исходящий запрос комплекта.
35
+ update: { where: "project", file: "update-checked", every: DAY },
36
+ // Просьба об отзыве после установки — раз на машину: второй `init` в другом репозитории на
37
+ // том же компьютере её не повторяет.
38
+ install: { where: "home", file: "feedback-shown", every: null },
39
+ // Просьба об отзыве ПО ДЕЛУ — раз на проект. Объявлена здесь до первого использования
40
+ // намеренно: строка в `.gitignore` обязана появиться РАНЬШЕ, чем файл будет записан, иначе
41
+ // отметка уедет в чужой коммит у всех, кто поставил комплект между двумя выпусками.
42
+ value: { where: "project", file: "feedback-asked", every: null },
43
+ };
44
+
45
+ // Опечатка в виде обращения обязана падать. Оба молчаливых умолчания неверны в половине
46
+ // случаев: «показывать всегда» превращает ограничитель в шум, «не показывать» — выключает
47
+ // обращение навсегда, и никто об этом не узнает.
48
+ function askKind(kind) {
49
+ const a = ASKS[kind];
50
+ if (!a) throw new Error(`неизвестный вид обращения: ${kind}. Известны: ${Object.keys(ASKS).join(", ")}`);
51
+ return a;
52
+ }
53
+
54
+ // Служебные файлы ЭТОГО проекта — для `.gitignore`. Домашние сюда не идут: их git не видит.
55
+ const ASK_FILES = Object.values(ASKS).filter((a) => a.where === "project").map((a) => a.file);
56
+
57
+ // `project` — служебный каталог репозитория (тот самый `.aqk`), `home` — дом пользователя.
58
+ // Каталог передаётся целиком, а не собирается здесь: имя `.aqk` знает `core.mjs`, и второй
59
+ // его экземпляр однажды разошёлся бы с первым.
60
+ function askFile(kind, { project, home } = {}) {
61
+ const a = askKind(kind);
62
+ return a.where === "home" ? join(String(home ?? ""), ".config", "aqk", a.file) : join(String(project ?? ""), a.file);
63
+ }
64
+
65
+ // Пора ли обращаться. `stamp` — содержимое отметки или null, если её нет.
66
+ //
67
+ // Разовое обращение: отметка есть — значит было, и повторить нельзя никогда, что бы в ней ни
68
+ // лежало. Старый формат хранил слово «shown», а не дату: читать его как испорченную дату и
69
+ // показывать заново значило бы повторить просьбу у всех, кто поставил комплект раньше.
70
+ //
71
+ // Суточное: «не знаем, когда показывали» и «показывали давно» — одно и то же решение, показать.
72
+ // Молчать из-за нечитаемого файла состояния значит потерять обращение навсегда и не сказать
73
+ // почему.
74
+ function askDue(kind, stamp, now = Date.now()) {
75
+ const a = askKind(kind);
76
+ if (stamp === null || stamp === undefined || String(stamp).trim() === "") return true;
77
+ if (a.every === null) return false;
78
+ const t = Date.parse(String(stamp).trim());
79
+ if (!Number.isFinite(t)) return true;
80
+ return now - t >= a.every;
81
+ }
82
+
83
+ // Отметка с диска: строка или null. Файла нет, каталог не читается, прав не хватило — всё это
84
+ // «не показывали»: потерять обращение из-за нечитаемой отметки дешевле, чем молчать.
85
+ async function readStamp(kind, dirs) {
86
+ try {
87
+ return (await readFile(askFile(kind, dirs), "utf8")).trim();
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // Запомнить обращение. Возвращает true, если записали, — СЛОВО О НЕУДАЧЕ ГОВОРИТ ВЫЗЫВАЮЩИЙ:
94
+ // у совета повтор это мелочь, а у разовой просьбы — та же просьба завтра, и человеку надо
95
+ // сказать, почему она вернулась. Дом бывает недоступен для записи: в контейнере, запущенном
96
+ // `--user 1001:127`, у этого uid нет записи в /etc/passwd, `homedir()` даёт «/», и запись
97
+ // падает на `/.config`. До 2026-09-09 это роняло весь `init` — то есть любого, кто набрал
98
+ // команду из нашей же документации по docker.
99
+ async function markAsked(kind, dirs, now = new Date()) {
100
+ const path = askFile(kind, dirs);
101
+ try {
102
+ await mkdir(join(path, ".."), { recursive: true });
103
+ await writeFile(path, `${now.toISOString()}\n`, "utf8");
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ // Пора ли обращаться, с чтением отметки. Две трети вызывающих хотят именно этого; чистый
111
+ // `askDue` остаётся для перебора случаев, которых на диске не бывает.
112
+ async function askAllowed(kind, dirs, now = Date.now()) {
113
+ return askDue(kind, await readStamp(kind, dirs), now);
114
+ }
115
+
116
+ // Наружу — то, что зовут снаружи. `readStamp` внутренний: снаружи спрашивают «пора ли»,
117
+ // а не «что лежит в файле», и лишний экспорт читается как часть договора.
118
+ export { ASKS, ASK_FILES, askDue, askFile, markAsked, askAllowed };
@@ -1,6 +1,7 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { CWD, PKG_ROOT, TARGET_DIR, SELF, c } from "./core.mjs";
3
+ import { PKG_ROOT, SELF, c, stateDirs } from "./core.mjs";
4
+ import { askAllowed, markAsked } from "./ask.mjs";
4
5
  import { L } from "../i18n/index.mjs";
5
6
  import { canDrawArt } from "./banner.mjs";
6
7
  // tool/lib/brief.mjs — короткая строка присутствия для прогона в хуке.
@@ -19,10 +20,6 @@ import { canDrawArt } from "./banner.mjs";
19
20
  // то, что видишь тридцатый раз, перестаёт читаться — и пролистывается вместе с настоящими
20
21
  // находками, стоящими рядом.
21
22
 
22
- // Сутки. Не «раз в прогон» и не «раз в неделю»: за сутки человек успевает забыть, но не успевает
23
- // устать. Число здесь спорное — важно, что ограничитель есть и он машинный.
24
- const ADVICE_EVERY_MS = 24 * 60 * 60 * 1000;
25
-
26
23
  // ЗНАЧОК ПРИСУТСТВИЯ — здесь, а не в каталогах строк. Символ один на оба языка, и держать его
27
24
  // в двух местах значит однажды получить разные значки в ru и en: то же правило, по которому у
28
25
  // нас один свод правил на две точки входа. Выбран владельцем из пятидесяти семи вариантов.
@@ -41,16 +38,6 @@ function briefLine(state, L, env = process.env) {
41
38
  return `${head}\n${t.red(state.red.join(", "))}`;
42
39
  }
43
40
 
44
- // «Не знаем, когда показывали» и «показывали давно» — одно и то же решение: показать.
45
- // Испорченная отметка попадает сюда же намеренно: молчать из-за нечитаемого файла состояния
46
- // значит потерять совет навсегда и не сказать почему.
47
- function adviceDue(lastIso, now = Date.now()) {
48
- if (!lastIso) return true;
49
- const t = Date.parse(String(lastIso));
50
- if (!Number.isFinite(t)) return true;
51
- return now - t >= ADVICE_EVERY_MS;
52
- }
53
-
54
41
  // Первая из непоставленных, а не «самая важная»: важность мы не считаем, а порядок каталога
55
42
  // осмыслен — записи в нём лежат от общего к частному. Выдавать порядок за приоритет нельзя.
56
43
  function pickAdvice(todo = []) {
@@ -112,9 +99,7 @@ function beginBrief() {
112
99
 
113
100
  // Печать краткого итога. Совет — не чаще раза в сутки и с явным способом отказаться: то, что
114
101
  // видишь тридцатый раз, перестаёт читаться и пролистывается вместе с настоящими находками рядом.
115
- // Отметка времени лежит в .aqk/ и попадает в .gitignore при `init` (RUNTIME_FILES в core.mjs):
116
- // это состояние машины, а не проекта. Раньше здесь было написано «.aqk/ в .gitignore» — а
117
- // `init` туда ничего не клал, и на живом проекте служебный файл уехал в коммит.
102
+ // Сам ограничитель и отметка — в `ask.mjs`, общие на все обращения комплекта к человеку.
118
103
  async function finishBrief(buf, state, todoRecs, ok) {
119
104
  if (!buf) return;
120
105
  buf.restore();
@@ -129,18 +114,16 @@ async function finishBrief(buf, state, todoRecs, ok) {
129
114
  if (!ok) { console.log(buf.lines.join("\n")); return; }
130
115
 
131
116
  if (process.env.AQK_ADVICE === "0" || !state.todo) return;
132
- const stampFile = join(CWD, TARGET_DIR, "advice-shown");
133
- let last = null;
134
- try { last = (await readFile(stampFile, "utf8")).trim(); } catch { /* не показывали ещё */ }
135
- if (!adviceDue(last)) return;
117
+ // Ограничитель общий на все обращения комплекта к человеку (ask.mjs): совет, проверка
118
+ // версии и просьба об отзыве считают «уже показывали» одним кодом и одним форматом.
119
+ const dirs = stateDirs();
120
+ if (!(await askAllowed("advice", dirs))) return;
136
121
  const advice = pickAdvice(todoRecs);
137
122
  if (!advice) return;
138
123
  console.log(c.dim(L.brief.advise(advice.slug, advice.intent || "")));
139
124
  console.log(c.dim(L.brief.adviseOff(`${SELF} why ${advice.slug}`, "AQK_ADVICE=0")));
140
- try {
141
- await mkdir(join(CWD, TARGET_DIR), { recursive: true });
142
- await writeFile(stampFile, new Date().toISOString(), "utf8");
143
- } catch { /* не смогли записать отметку — совет повторится, это не беда */ }
125
+ // Не записалось — совет повторится завтра, и это не беда: молчать об этом человеку незачем.
126
+ await markAsked("advice", dirs);
144
127
  }
145
128
 
146
129
  // Спрашивает реестр npm о своей версии. РАЗ В СУТКИ, НЕ В КОНВЕЙЕРЕ, С ТАЙМАУТОМ, И МОЛЧА
@@ -152,10 +135,8 @@ async function finishBrief(buf, state, todoRecs, ok) {
152
135
  // и вместе с ним всё остальное, что печатает эта строка.
153
136
  async function maybeUpdateNotice() {
154
137
  if (!updateWanted()) return;
155
- const stamp = join(CWD, TARGET_DIR, "update-checked");
156
- let last = null;
157
- try { last = (await readFile(stamp, "utf8")).trim(); } catch { /* ещё не спрашивали */ }
158
- if (!adviceDue(last)) return;
138
+ const dirs = stateDirs();
139
+ if (!(await askAllowed("update", dirs))) return;
159
140
 
160
141
  let current = "";
161
142
  try { current = JSON.parse(await readFile(join(PKG_ROOT, "package.json"), "utf8")).version || ""; } catch { return; }
@@ -163,10 +144,7 @@ async function maybeUpdateNotice() {
163
144
  // ОТМЕТКА СТАВИТСЯ ДО ЗАПРОСА, а не после удачного ответа. Сперва было наоборот, и замер
164
145
  // показал цену: человек без сети платил бы ожиданием на КАЖДОМ коммите, а не раз в сутки.
165
146
  // Из двух ошибок выбрана дешёвая: пропущенное за день уведомление против ежедневного стопора.
166
- try {
167
- await mkdir(join(CWD, TARGET_DIR), { recursive: true });
168
- await writeFile(stamp, new Date().toISOString(), "utf8");
169
- } catch { /* не смогли записать — спросим ещё раз, это не беда */ }
147
+ await markAsked("update", dirs);
170
148
 
171
149
  let latest = "";
172
150
  try {
@@ -189,6 +167,7 @@ async function maybeUpdateNotice() {
189
167
  if (notice) console.log(c.dim(notice));
190
168
  }
191
169
 
192
- // Наружу — только то, что зовут снаружи. `cmpVer` и `ADVICE_EVERY_MS` внутренние: экспорт,
193
- // который никто не импортирует, читается как часть договора и мешает менять внутренности.
194
- export { briefLine, adviceDue, pickAdvice, updateNotice, updateWanted, beginBrief, finishBrief };
170
+ // Наружу — только то, что зовут снаружи. `cmpVer` внутренний: экспорт, который никто не
171
+ // импортирует, читается как часть договора и мешает менять внутренности. Ограничитель обращений
172
+ // уехал целиком в `ask.mjs` вместе с проверками, которые его сторожили.
173
+ export { briefLine, pickAdvice, updateNotice, updateWanted, beginBrief, finishBrief };
package/tool/lib/core.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  // размер файла. Зависимостей по-прежнему нет ни одной: только встроенные модули Node.
6
6
 
7
7
  import { LANG } from "../i18n/index.mjs";
8
+ import { ASK_FILES } from "./ask.mjs";
8
9
  import { access, readdir, mkdir, copyFile, writeFile } from "node:fs/promises";
9
10
  import { constants } from "node:fs";
10
11
  import { fileURLToPath } from "node:url";
@@ -39,8 +40,16 @@ const exists = async (p) => access(p, constants.F_OK).then(() => true, () => fal
39
40
  // Как звать программу — зависит от того, как её запустили. Через npx команды `aqk` в системе
40
41
  // нет: подсказка «aqk doctor» отправляет человека в «команда не найдена» на первом же шаге.
41
42
  // Печатаем то, что можно скопировать и выполнить прямо сейчас.
42
- // Имя в реестре, а не адрес репозитория: короче, скачивается 230 КБ вместо клона всего
43
- // репозитория и не заставляет человека ждать три минуты в тишине на первой же команде.
43
+ // Имя в реестре, а не адрес репозитория: короче, тянет пакет вместо клона всего репозитория и
44
+ // не заставляет человека ждать три минуты в тишине на первой же команде.
45
+ //
46
+ // ЧИСЛА ЗДЕСЬ БОЛЬШЕ НЕТ, И ЭТО РЕШЕНИЕ. Стояло «230 КБ»; замер 2026-09-10 дал 574 КБ, замер
47
+ // 2026-09-14 — 692 КБ. Число, которое никто не сторожит, врёт тем сильнее, чем дольше живёт, —
48
+ // наше же правило про утверждение без арбитра, нарушенное в собственном комментарии. Текущий
49
+ // размер считается одной командой: `npm pack --dry-run`. Из 2,2 МБ распакованного 584 КБ — это
50
+ // `tool/selfcheck`, то есть наши собственные тесты, уезжающие каждому пользователю; вырезать их
51
+ // из `files` можно только после проверки, что собранный пакет работает, — иначе поломка
52
+ // установки обойдётся дороже сэкономленного.
44
53
  const REPO = "agent-quality-kit";
45
54
  // Адрес репозитория отдельно от имени пакета. Когда имя стало коротким, ссылка «поставь
46
55
  // звезду» собиралась из него и вела на github.com/agent-quality-kit — несуществующую
@@ -86,6 +95,8 @@ function commandRows(L) {
86
95
  { name: "prompt", args: "", text: h.prompt },
87
96
  { name: "badge", args: "", text: h.badge },
88
97
  { name: "vitals", args: "", text: h.vitals },
98
+ { name: "feedback", args: "", text: h.feedback },
99
+ { name: "feedback", args: "--send", text: h.feedbackSend },
89
100
  { name: "version", args: "", text: h.version },
90
101
  ];
91
102
  }
@@ -105,10 +116,9 @@ const RATCHET_DIR = "ratchets";
105
116
  // обёртка плюс реестр, и разносить их по разным каталогам значит прятать половину механизма.
106
117
  const RATCHET_LIB = `${RATCHET_DIR}/_ratchet.sh`;
107
118
 
108
- // Отметка «просьбу про звезду уже показали»вне репозитория, в доме пользователя. Внутри
109
- // .aqk/ она либо закоммитится в чужой проект как наш мусор, либо пропадёт при init --force:
110
- // то и другое врёт о том, видел человек просьбу или нет.
111
- const FEEDBACK_MARK = join(homedir(), ".config", "aqk", "feedback-shown");
119
+ // Служебный каталог этого проекта и дом пользователя два места, где комплект держит
120
+ // состояние. Кто и как часто туда пишет, решает `ask.mjs`; здесь только адреса.
121
+ const stateDirs = () => ({ project: join(CWD, TARGET_DIR), home: homedir() });
112
122
 
113
123
  // Путь, попадающий в ДОКУМЕНТ, всегда пишется через «/». `relative()` отдаёт разделитель
114
124
  // платформы, и на Windows склейка методичек и отчёт получались с «kit\\docs» вместо «kit/docs»:
@@ -148,7 +158,7 @@ async function writeIfAbsent(path, content, { force }) {
148
158
  // читателей. Отзыв с живого проекта 2026-09-11: `.aqk/last-run.md` однажды закоммитили, и каждый
149
159
  // `make check` оставлял изменённый файл. Целиком `.aqk/` не игнорируется: методички и правила в
150
160
  // нём — содержимое проекта.
151
- const RUNTIME_FILES = ["last-run.md", "last-probe.md", "advice-shown", "update-checked"];
161
+ const RUNTIME_FILES = ["last-run.md", "last-probe.md", ...ASK_FILES];
152
162
  const L_IGNORE_NOTE = LANG === "en"
153
163
  ? "# aqk: this machine's state — rewritten by every run, it does not belong in git"
154
164
  : "# aqk: состояние этой машины — переписывается каждым прогоном, в git ему не место";
@@ -170,20 +180,47 @@ async function ensureIgnored(cwd = CWD) {
170
180
  }
171
181
 
172
182
  // Стоит ли хук pre-commit НА САМОМ ДЕЛЕ — в `.git/hooks`, а не в `.pre-commit-config.yaml`:
173
- // запись в конфиге — намерение, сработает только то, что лежит в гите. Три ответа: true — стоит,
174
- // false — нет, null — не git или файл не прочитать («не знаем» не сливается с «нет»).
175
- // Одна функция на `vitals` (подключено ли) и `context` (что сказать агенту перед коммитом).
183
+ // запись в конфиге — намерение, сработает только то, что лежит в гите.
184
+ //
185
+ // ЧЕТЫРЕ ОТВЕТА, А НЕ ТРИ. Раньше тело хука проверялось регуляркой `/pre-commit|aqk/i`, то есть
186
+ // ПО ИМЕНИ: у любого проекта с фреймворком pre-commit слово «pre-commit» в хуке есть всегда, и
187
+ // `vitals` печатал «прописан в .git/hooks» репозиторию, где AQK не вызывался ни разу. Разбор
188
+ // живой интеграции 2026-09-16: человек прочитал это как «обвязка на месте» и ушёл. Наш
189
+ // собственный класс «объявлено ≠ работает», у нас самих.
190
+ //
191
+ // null — не git либо файл не прочитать («не знаем» не сливается с «нет»);
192
+ // false — хука нет;
193
+ // "other" — хук есть, но AQK в нём не участвует: ставить не надо, надо дописать;
194
+ // true — AQK участвует.
195
+ //
196
+ // КАК ЭТО ДЕЛАЮТ СНАРУЖИ. pre-commit узнаёт свой хук функцией `is_our_script()` — ищет в теле
197
+ // собственный маркер (CURRENT_HASH плюс пять PRIOR_HASHES), а не имя. У них же есть режим
198
+ // миграции: при установке поверх чужого хука запускаются оба, старый уезжает в `.legacy`. То
199
+ // есть «в теле есть слово pre-commit» не доказывает даже, что хук ихний.
200
+ //
201
+ // ДВА ПУТИ ПОДКЛЮЧЕНИЯ, И СЧИТАТЬ НАДО ОБА. Прямой вызов в теле хука — и наш хук из
202
+ // `.pre-commit-hooks.yaml`, поставленный через фреймворк: там в `.git/hooks/pre-commit` лежит
203
+ // диспетчер, а что он запустит, написано в `.pre-commit-config.yaml` проекта. Смотреть только в
204
+ // тело значило бы соврать в обратную сторону — сказать «AQK не подключён» тому, кто подключил.
205
+ const AQK_IN_HOOK = /\baqk\b|agent[-_]quality[-_]kit/i;
206
+ const AQK_IN_CONFIG = /agent[-_]quality[-_]kit|Agent_Quality_Kit|(?:^|\s)-\s*id:\s*["']?aqk\b/im;
207
+
176
208
  async function preCommitHook(cwd = CWD) {
177
209
  const { readFile } = await import("node:fs/promises");
178
210
  if (!(await exists(join(cwd, ".git")))) return null;
179
211
  const hook = join(cwd, ".git", "hooks", "pre-commit");
180
212
  if (!(await exists(hook))) return false;
181
- try { return /pre-commit|aqk/i.test(await readFile(hook, "utf8")); } catch { return null; }
213
+ let body = "";
214
+ try { body = await readFile(hook, "utf8"); } catch { return null; }
215
+ if (AQK_IN_HOOK.test(body)) return true;
216
+ let config = "";
217
+ try { config = await readFile(join(cwd, ".pre-commit-config.yaml"), "utf8"); } catch { /* нет конфига — значит подключения через фреймворк нет */ }
218
+ return AQK_IN_CONFIG.test(config) ? true : "other";
182
219
  }
183
220
 
184
221
  export {
185
222
  copyDir, writeIfAbsent,
186
223
  PKG_ROOT, CWD, DOCS_SRC, RULES_SRC, TARGET_DIR, docPath,
187
224
  MANIFEST, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB,
188
- SELF, REPO_URL, c, exists, die, FEEDBACK_MARK, commandRows, preCommitHook, RUNTIME_FILES, ensureIgnored,
225
+ SELF, REPO_URL, c, exists, die, stateDirs, commandRows, preCommitHook, RUNTIME_FILES, ensureIgnored,
189
226
  };