agent-quality-kit 0.15.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 (62) hide show
  1. package/README.md +61 -15
  2. package/README.ru.md +62 -15
  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 +3 -1
  24. package/tool/commands/doctor-catalog.mjs +35 -10
  25. package/tool/commands/feedback.mjs +75 -1
  26. package/tool/commands/gates.mjs +12 -6
  27. package/tool/commands/report.mjs +19 -3
  28. package/tool/commands/vitals.mjs +9 -3
  29. package/tool/i18n/en-docs.mjs +18 -2
  30. package/tool/i18n/en-gates.mjs +9 -1
  31. package/tool/i18n/en.mjs +24 -2
  32. package/tool/i18n/ru-docs.mjs +17 -2
  33. package/tool/i18n/ru-gates.mjs +9 -1
  34. package/tool/i18n/ru.mjs +20 -2
  35. package/tool/lib/adopt.mjs +58 -4
  36. package/tool/lib/core.mjs +42 -6
  37. package/tool/lib/execution.mjs +32 -1
  38. package/tool/lib/manifest.mjs +39 -13
  39. package/tool/lib/prove.mjs +3 -3
  40. package/tool/lib/run.mjs +25 -6
  41. package/tool/selfcheck/smoke/_fixture.mjs +13 -1
  42. package/tool/selfcheck/smoke/feedback-send.test.mjs +87 -0
  43. package/tool/selfcheck/smoke/first-run.test.mjs +67 -3
  44. package/tool/selfcheck/smoke/preflight.test.mjs +83 -0
  45. package/tool/selfcheck/smoke/verdict.test.mjs +50 -4
  46. package/tool/selfcheck/smoke/version-sync.test.mjs +140 -0
  47. package/tool/selfcheck/smoke.sh +106 -4
  48. package/tool/selfcheck/units-execution.mjs +37 -1
  49. package/tool/selfcheck/units-level.mjs +41 -1
  50. package/tool/selfcheck/units-repo.mjs +75 -0
  51. package/tool/selfcheck/units-vitals.mjs +27 -0
  52. package/kit/gates/entry-links-exist/README.md +0 -27
  53. package/kit/gates/entry-links-exist/check.sh +0 -33
  54. package/kit/gates/entry-links-exist/gate.yml +0 -17
  55. package/kit/gates/entry-links-exist/green/AGENTS.md +0 -10
  56. package/kit/gates/entry-links-exist/green/rules/general.md +0 -3
  57. package/kit/gates/entry-links-exist/red/AGENTS.md +0 -3
  58. package/kit/gates/no-phantom-package/README.md +0 -84
  59. package/kit/gates/no-phantom-package/check.sh +0 -168
  60. package/kit/gates/no-phantom-package/gate.yml +0 -20
  61. package/kit/gates/no-phantom-package/green/AGENTS.md +0 -15
  62. package/kit/gates/no-phantom-package/red/AGENTS.md +0 -15
package/tool/lib/core.mjs CHANGED
@@ -40,8 +40,16 @@ const exists = async (p) => access(p, constants.F_OK).then(() => true, () => fal
40
40
  // Как звать программу — зависит от того, как её запустили. Через npx команды `aqk` в системе
41
41
  // нет: подсказка «aqk doctor» отправляет человека в «команда не найдена» на первом же шаге.
42
42
  // Печатаем то, что можно скопировать и выполнить прямо сейчас.
43
- // Имя в реестре, а не адрес репозитория: короче, скачивается 230 КБ вместо клона всего
44
- // репозитория и не заставляет человека ждать три минуты в тишине на первой же команде.
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
+ // установки обойдётся дороже сэкономленного.
45
53
  const REPO = "agent-quality-kit";
46
54
  // Адрес репозитория отдельно от имени пакета. Когда имя стало коротким, ссылка «поставь
47
55
  // звезду» собиралась из него и вела на github.com/agent-quality-kit — несуществующую
@@ -88,6 +96,7 @@ function commandRows(L) {
88
96
  { name: "badge", args: "", text: h.badge },
89
97
  { name: "vitals", args: "", text: h.vitals },
90
98
  { name: "feedback", args: "", text: h.feedback },
99
+ { name: "feedback", args: "--send", text: h.feedbackSend },
91
100
  { name: "version", args: "", text: h.version },
92
101
  ];
93
102
  }
@@ -171,15 +180,42 @@ async function ensureIgnored(cwd = CWD) {
171
180
  }
172
181
 
173
182
  // Стоит ли хук pre-commit НА САМОМ ДЕЛЕ — в `.git/hooks`, а не в `.pre-commit-config.yaml`:
174
- // запись в конфиге — намерение, сработает только то, что лежит в гите. Три ответа: true — стоит,
175
- // false — нет, null — не git или файл не прочитать («не знаем» не сливается с «нет»).
176
- // Одна функция на `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
+
177
208
  async function preCommitHook(cwd = CWD) {
178
209
  const { readFile } = await import("node:fs/promises");
179
210
  if (!(await exists(join(cwd, ".git")))) return null;
180
211
  const hook = join(cwd, ".git", "hooks", "pre-commit");
181
212
  if (!(await exists(hook))) return false;
182
- 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";
183
219
  }
184
220
 
185
221
  export {
@@ -105,6 +105,37 @@ function launchable(cmd, bash) {
105
105
  return bash && /^bash(\s|$)/.test(s) ? `"${bash}"${s.slice(4)}` : s;
106
106
  }
107
107
 
108
+ // СКОЛЬКО ЖДАТЬ ЧУЖУЮ КОМАНДУ. Знание лежит здесь, рядом с остальным протоколом запуска: до
109
+ // 2026-09-16 число 300000 было вписано в четырёх местах — `run.mjs`, `prove.mjs` и дважды
110
+ // `gates.mjs`. Это не похожие строки, а одно знание в четырёх файлах.
111
+ //
112
+ // СПОСОБ РЕШИТЬ ИНАЧЕ ОБЯЗАН БЫТЬ — правило записано парой десятков строк ниже, про выбор bash,
113
+ // и у таймаута его не было. Цена известна: живой проект, чей полный verify идёт дольше пяти
114
+ // минут, просто не стал объявлять свою главную проверку гейтом.
115
+ //
116
+ // ПЯТЬ МИНУТ НЕ ВЫДУМАНЫ, И ПЕРЕМЕННАЯ СРЕДЫ — ТОЖЕ НЕ НАША ВЫДУМКА. У SonarQube
117
+ // `SONAR_QUALITY_GATE_TIMEOUT` ровно 300 секунд, у GitLab срок шага задаётся
118
+ // `RUNNER_AFTER_SCRIPT_TIMEOUT`. Поля `timeout` у команды нет ни у lefthook (весь их список
119
+ // ключей проверен 2026-09-16), ни у pre-commit — поэтому в манифест мы его не заводим:
120
+ // соглашения снаружи нет, а просьба была одна. Заведём, когда попросят второй раз.
121
+ //
122
+ // ПРОБА ЖИВЁТ ПО СВОИМ ЧАСАМ намеренно (`probe.mjs`): она гоняет десятки гейтов на подсаженном
123
+ // дефекте, и её минута-две — осознанный предел выборки, а не «сколько ждать эту проверку».
124
+ const GATE_TIMEOUT_DEFAULT = 300000;
125
+
126
+ // Возвращается ОБЪЕКТ, а не число: мусор в переменной нельзя проглотить молча — человек задал
127
+ // её и ждёт действия, — но и печатать отсюда нельзя, функция чистая. Поэтому «исправно ли» едет
128
+ // рядом со значением, а говорит об этом тот, у кого есть экран (`run.mjs`).
129
+ function gateTimeout(env = process.env) {
130
+ const raw = String(env?.AQK_GATE_TIMEOUT ?? "").trim();
131
+ if (!raw) return { ms: GATE_TIMEOUT_DEFAULT, raw: "", ok: true };
132
+ const secs = Number(raw);
133
+ // Ноль и отрицательное у spawnSync означают «ждать вечно» — то есть висящий гейт вместо
134
+ // честного «не смогли проверить». Ровно тот исход, ради различения которого написан файл.
135
+ if (!Number.isFinite(secs) || secs <= 0) return { ms: GATE_TIMEOUT_DEFAULT, raw, ok: false };
136
+ return { ms: Math.round(secs * 1000), raw, ok: true };
137
+ }
138
+
108
139
  // Одна точка для всех пяти мест, где запускается команда гейта: прогон, доказательство, проба,
109
140
  // храповик, `why`. Поиск делается раз на процесс — он смотрит на диск, а гейтов бывает тридцать.
110
141
  let cachedBash;
@@ -113,4 +144,4 @@ function gateCommand(cmd) {
113
144
  return launchable(cmd, cachedBash);
114
145
  }
115
146
 
116
- export { classify, findingCodes, gitBash, launchable, gateCommand };
147
+ export { classify, findingCodes, gitBash, launchable, gateCommand, gateTimeout, GATE_TIMEOUT_DEFAULT };
@@ -112,7 +112,7 @@ function unparsedLines(text) {
112
112
  // Список обязан совпадать с тем, что программа РЕАЛЬНО читает (`man?.<поле>` в tool/):
113
113
  // лишнее имя здесь молча узаконивает поле, которое ни на что не влияет, — та же тишина,
114
114
  // только с другой стороны. Сверено обходом: aqk, entry, rules, gates, samples, ratchets, lessons.
115
- const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory", "probe", "groups"];
115
+ const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "requires", "samples", "ratchets", "lessons", "advisory", "probe", "groups"];
116
116
 
117
117
  // ГДЕ У ПРОЕКТА ЛЕЖИТ РАЗЛОЖЕННЫЙ КОМПЛЕКТ. Список для шапки `doctor`. До 2026-09-08 он был
118
118
  // литеральным: `.aqk/rules`, `.aqk/docs`, `AGENTS.md` — независимо от того, что написано в
@@ -306,19 +306,45 @@ function entryLifecycle(rec) {
306
306
  // `vitals` печатал «все инструменты на месте» ровно там, где прогон краснел.
307
307
  // `has` передаётся вызывающим, а не берётся отсюда: manifest.mjs не должен знать про осмотр
308
308
  // репозитория — импорт в обратную сторону завёл бы цикл. Заодно функция проверяема модульно.
309
- async function gateRequires(samplesDir, name, has) {
310
- if (!samplesDir) return null;
311
- const yml = join(CWD, samplesDir, name, "gate.yml");
312
- if (!(await exists(yml))) return null;
313
- try {
314
- const rec = parseManifest(await readFile(yml, "utf8"));
315
- const raw = typeof rec?.requires === "string" ? rec.requires.trim() : "";
316
- if (!raw) return null;
317
- const missing = raw.split(",").map((x) => x.trim()).filter(Boolean).filter((x) => !has(x));
318
- return missing.length ? missing : null;
319
- } catch {
320
- return null;
309
+ // ДВА ИСТОЧНИКА, А НЕ ОДИН. Поле читалось только из `<samples>/<гейт>/gate.yml`, то есть было
310
+ // доступно НАШИМ записям и недоступно гейтам проекта. Разбор чужой интеграции 2026-09-16: гейт
311
+ // объявлен как `docker run --rm … promtool test rules …`, `vitals` смотрит первое слово, видит
312
+ // `docker` и говорит «инструменты на месте». У проекта с чужими командами `samples` пуст по
313
+ // построению, и сказать «этому гейту нужен docker» было нечем.
314
+ //
315
+ // СНАРУЖИ СОГЛАШЕНИЯ НЕТ проверено 2026-09-16, и это сказано вслух, а не выдано за
316
+ // общепринятое. У pre-commit ровно эта просьба закрыта нерешённой (issue #2042: трактовать
317
+ // `additional_dependencies` как список программ в $PATH и пропускать хук, если программы нет;
318
+ // ответ — `system`-хуки окружения не ставят). У lefthook такого ключа нет вовсе. Поэтому мы не
319
+ // копируем чужую форму, а распространяем свою: то же имя поля и та же форма «имя гейта →
320
+ // значение», что у `covers:` и `groups:`. Новых понятий в манифесте не появляется.
321
+ //
322
+ // ГРАНИЦА НАЗЫВАЕТСЯ ВСЛУХ: «программа есть в PATH» и «программа сможет отработать» — разные
323
+ // утверждения. `docker` в PATH при мёртвом демоне по-прежнему считается найденным; это не
324
+ // ложь vitals, а предел того, что видно без запуска. Запускать чужой инструмент ради осмотра
325
+ // мы не будем: осмотр обязан быть дешёвым и без побочных действий.
326
+ function requiredBy(man, name) {
327
+ const r = man?.requires && typeof man.requires === "object" && !Array.isArray(man.requires) ? man.requires : null;
328
+ const v = r ? r[name] : null;
329
+ if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean);
330
+ return typeof v === "string" ? v.split(",").map((x) => x.trim()).filter(Boolean) : [];
331
+ }
332
+
333
+ async function gateRequires(man, samplesDir, name, has) {
334
+ const names = [...requiredBy(man, name)];
335
+ if (samplesDir) {
336
+ const yml = join(CWD, samplesDir, name, "gate.yml");
337
+ if (await exists(yml)) {
338
+ try {
339
+ const rec = parseManifest(await readFile(yml, "utf8"));
340
+ const raw = typeof rec?.requires === "string" ? rec.requires.trim() : "";
341
+ for (const x of raw.split(",").map((s) => s.trim()).filter(Boolean)) names.push(x);
342
+ } catch { /* нечитаемая запись — не повод обвинять гейт */ }
343
+ }
321
344
  }
345
+ if (!names.length) return null;
346
+ const missing = [...new Set(names)].filter((x) => !has(x));
347
+ return missing.length ? missing : null;
322
348
  }
323
349
 
324
350
  // ПОЧЕМУ СПИСКОМ В МАНИФЕСТЕ, А НЕ ФЛАГОМ ПРОГОНА. Флаг «не роняй ничего» — это тот самый
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { CWD, exists } from "./core.mjs";
16
16
  import { parseManifest, gateRequires } from "./manifest.mjs";
17
17
  import { whichSync } from "./repo.mjs";
18
- import { classify, findingCodes, gateCommand } from "./execution.mjs";
18
+ import { classify, findingCodes, gateCommand, gateTimeout } from "./execution.mjs";
19
19
 
20
20
  // Гейт можно доказать, если у него есть оба образца. Признак по образцам, а не по тексту
21
21
  // команды: запись, делегирующая готовому инструменту (`npx knip --directory .`), каталог
@@ -115,7 +115,7 @@ function run(cmd, timeoutMs, prog) {
115
115
 
116
116
  // Возвращает { proven, broken, unprovable, results } — числами и списком, чтобы вызывающий
117
117
  // сам решал, что печатать и чем краснеть.
118
- async function proveGates(man, { timeoutMs = 300000 } = {}) {
118
+ async function proveGates(man, { timeoutMs = gateTimeout().ms } = {}) {
119
119
  const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
120
120
  const samplesDir = typeof man?.samples === "string" ? man.samples.trim() : "";
121
121
  const results = [];
@@ -135,7 +135,7 @@ async function proveGates(man, { timeoutMs = 300000 } = {}) {
135
135
  // Программы, без которой запись не работает, может не быть на машине — тогда доказывать
136
136
  // нечем, а не «гейт сломан». Проверяется ДО запуска: без неё обёртка краснеет на обоих
137
137
  // образцах, и вердикт вышел бы «краснеет на исправном коде».
138
- const missing = await gateRequires(samplesDir, name, whichSync);
138
+ const missing = await gateRequires(man, samplesDir, name, whichSync);
139
139
  if (missing) {
140
140
  results.push({ name, state: "unprovable", why: "needs-program", missing });
141
141
  continue;
package/tool/lib/run.mjs CHANGED
@@ -17,7 +17,7 @@ import { scopeOutput, splitAdvice, changedFiles } from "./scope.mjs";
17
17
  import { CWD, TARGET_DIR, c, die, exists } from "./core.mjs";
18
18
  import { advisorySet } from "./manifest.mjs";
19
19
  import { L } from "../i18n/index.mjs";
20
- import { gateCommand, classify, findingCodes } from "./execution.mjs";
20
+ import { gateCommand, classify, findingCodes, gateTimeout } from "./execution.mjs";
21
21
  import { annotations } from "./annotate.mjs";
22
22
 
23
23
 
@@ -103,11 +103,12 @@ function selectGates(gates, man, { only = [], skip = [] } = {}) {
103
103
  return { run, skipped: gates.map(([n]) => n).filter((n) => !kept.has(n)), unknown: [...new Set(unknown)] };
104
104
  }
105
105
 
106
- const GATE_TIMEOUT = 300000;
106
+ // Сколько ждать — знает `execution.mjs`, там же, где всё остальное про запуск чужой команды.
107
+ // Число жило здесь и ещё в трёх местах; переопределяется переменной AQK_GATE_TIMEOUT.
107
108
 
108
109
  // Один гейт в этом потоке — прежний путь, без `--jobs`.
109
110
  function spawnGate(cmd) {
110
- return spawnSync(gateCommand(cmd), { shell: true, cwd: CWD, encoding: "utf8", timeout: GATE_TIMEOUT });
111
+ return spawnSync(gateCommand(cmd), { shell: true, cwd: CWD, encoding: "utf8", timeout: gateTimeout().ms });
111
112
  }
112
113
 
113
114
  // ПАРАЛЛЕЛЬНО — ПО ФЛАГУ. Отзыв с живого проекта 2026-09-11: 34 независимых гейта шли друг за
@@ -125,7 +126,7 @@ function startPool(cmds, jobs) {
125
126
  const onErr = (e) => { results[id].res({ status: null, stdout: "", stderr: String(e?.message || e), error: { code: "WORKER" } }); };
126
127
  w.once("error", onErr);
127
128
  w.once("message", (m) => { w.off("error", onErr); results[id].res(m); feed(w); });
128
- w.postMessage({ id, cmd: cmds[id], cwd: CWD, timeout: GATE_TIMEOUT });
129
+ w.postMessage({ id, cmd: cmds[id], cwd: CWD, timeout: gateTimeout().ms });
129
130
  };
130
131
  for (let i = 0; i < Math.min(jobs, cmds.length); i++) feed(new Worker(url));
131
132
  return results;
@@ -140,6 +141,12 @@ async function runGates(man, opts = {}) {
140
141
  const advisory = advisorySet(man);
141
142
  const bar = progress();
142
143
 
144
+ // Мусор в AQK_GATE_TIMEOUT называется вслух ОДИН раз за прогон. Молча вернуть умолчание
145
+ // значило бы, что человек задал переменную, ничего не получил и об этом не узнал, — та же
146
+ // тишина, против которой написан комплект, только в его собственной настройке.
147
+ const t = gateTimeout();
148
+ if (!t.ok) console.log(c.yellow(` ${L.doctor.timeoutBadEnv(t.raw, Math.round(t.ms / 1000))}`));
149
+
143
150
  // Сужение по дифу — договор с человеком, и он должен видеть, ЧТО именно сужено. Пустой диф
144
151
  // называется вслух: иначе «все гейты зелёные» означало бы «сравнили не с тем» и читалось бы
145
152
  // как успех. Это тот же класс, что и весь стандарт, только внутри нашего флага.
@@ -350,9 +357,21 @@ async function writeRunReport({ version, reached, results, skipped = [] }) {
350
357
  L.report.summary(ok, results.length),
351
358
  ].filter((l) => l !== null);
352
359
 
360
+ // ЗАПИСЬ ОТЧЁТА НЕ РОНЯЕТ ПРОГОН. Тот же класс, что у `report` (разбор 2026-09-16): в рабочей
361
+ // области, где `.aqk/` не создать — read-only контейнер, чужой конвейер, каталог под
362
+ // ревью, — прогон падал уже ПОСЛЕ того, как все гейты отработали, и человек не получал ни
363
+ // вердикта, ни кода возврата. Побочное действие отменяло то, ради чего команду звали.
364
+ //
365
+ // Но молчать нельзя: отчёт читают `context` и `prompt`, и его отсутствие они прочтут как
366
+ // «прогона не было». Разница между «не было» и «был, записать не смогли» — ровно та, которую
367
+ // весь комплект и защищает, поэтому она называется вслух.
353
368
  const dst = join(CWD, TARGET_DIR, "last-run.md");
354
- await mkdir(join(CWD, TARGET_DIR), { recursive: true });
355
- await writeFile(dst, lines.join("\n") + "\n", "utf8");
369
+ try {
370
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
371
+ await writeFile(dst, lines.join("\n") + "\n", "utf8");
372
+ } catch (e) {
373
+ console.log(c.yellow(` ${L.report.notWritten(join(TARGET_DIR, "last-run.md"), e?.code || String(e?.message || e))}`));
374
+ }
356
375
  }
357
376
 
358
377
  // Разбор отчёта прошлого прогона. Формат кладёт сам `doctor` в .aqk/last-run.md; читаем его,
@@ -93,6 +93,18 @@ function run({ dir, home }, cmd, args = [], extra = {}) {
93
93
  return { code: r.status, out: `${r.stdout || ""}${r.stderr || ""}` };
94
94
  }
95
95
 
96
+ // ХВОСТ ВЫВОДА — последние N строк. Знание «как взять хвост» лежало в трёх проверках тремя
97
+ // одинаковыми цепочками; сообщение утверждения без хвоста отправляет читателя гадать, поэтому
98
+ // хвост берут все, кто что-то утверждает о выводе.
99
+ //
100
+ // Попутно это уводит наши файлы из-под чужого дефекта: `checkwash` (0.2.13 и 0.3.4 одинаково)
101
+ // принимает строковый литерал `"\n"` за ИМЯ тестового юнита, и файл, где таких литералов два,
102
+ // на любом сдвиге строк получает `TEST_DISABLED — test unit disappeared` уровня high. То есть
103
+ // наш собственный гейт `test-not-adjusted` краснел на добавленном комментарии. Воспроизведение
104
+ // в шесть строк отправлено автору; здесь не обход ради обхода, а устранение настоящего повтора,
105
+ // у которого этот эффект оказался побочным.
106
+ const tail = (out, n = 6) => String(out).trimEnd().split("\n").slice(-n).join("\n");
107
+
96
108
  const aqk = (p, ...args) => run(p, process.execPath, [CLI, ...args]);
97
109
  const aqkEnv = (p, extra, ...args) => run(p, process.execPath, [CLI, ...args], extra);
98
110
 
@@ -113,4 +125,4 @@ const gate = (p, name, sub = ".") =>
113
125
  // никто не берёт, читается как часть договора и мешает менять внутренности — поймал наш же
114
126
  // dead-code через минуту после того, как файл был написан. `run` вернулся, когда появилась
115
127
  // проверка, которой нужен сырой git: экспорт заводится под потребителя, а не про запас.
116
- export { project, run, aqk, aqkEnv, gate };
128
+ export { project, run, aqk, aqkEnv, gate, tail };
@@ -0,0 +1,87 @@
1
+ // tool/selfcheck/smoke/feedback-send.test.mjs — ОТПРАВКА ТОЛЬКО ПО ЯВНОМУ СЛОВУ.
2
+ //
3
+ // ЗАЧЕМ ЭТО ГЛАВНАЯ ПРОВЕРКА ФАЙЛА. Владелец 2026-09-14 спросил, нельзя ли отправлять отзыв
4
+ // «без согласия пользователя, чтобы агент мог быстро сообщить». Ответ — нет, и не из вежливости:
5
+ // в README и SECURITY.md написано, что исходящий запрос у комплекта ровно один, про версию.
6
+ // Инструмент, который втихую шлёт что-то из чужого репозитория, становится ровно тем, что мы
7
+ // критикуем, — а наша аудитория это те, кто проверяет инструменты на вранье.
8
+ //
9
+ // Поэтому согласие живёт В САМОМ ФЛАГЕ: `--send` не набирают случайно. И это утверждение обязана
10
+ // держать машина, а не наше обещание в документации, — иначе однажды рефакторинг отправит письмо
11
+ // из команды, которая всю жизнь только печатала.
12
+ //
13
+ // КАК ПРОВЕРЯЕТСЯ. `AQK_GH` подменяет программу `gh` на скрипт, который записывает сам факт
14
+ // вызова в файл. Запуск через `node`, а не через оболочку: то же решение, что у `AQK_BASH` в
15
+ // execution.mjs, и оно работает и на windows-задании конвейера.
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { existsSync, writeFileSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { project, aqkEnv } from "./_fixture.mjs";
21
+
22
+ // Поддельный `gh`: пишет, с какими доводами его позвали, и отвечает так, чтобы отправка дошла
23
+ // до конца. Чего он НЕ делает — ничего наружу.
24
+ function fakeGh(p) {
25
+ const log = join(p.dir, "gh-calls.txt");
26
+ const script = join(p.dir, "fake-gh.mjs");
27
+ writeFileSync(script, `
28
+ import { appendFileSync } from "node:fs";
29
+ const args = process.argv.slice(2);
30
+ appendFileSync(${JSON.stringify(log)}, args.join(" ") + "\\n");
31
+ if (args[0] === "auth") process.exit(0);
32
+ const all = args.join(" ");
33
+ if (all.includes("addDiscussionComment")) { console.log(JSON.stringify({ data: { addDiscussionComment: { comment: { url: "https://example.invalid/c/1" } } } })); process.exit(0); }
34
+ console.log(JSON.stringify({ data: { repository: { discussion: { id: "D_test" } } } }));
35
+ `, "utf8");
36
+ return { log, env: { AQK_GH: `node ${script.replace(/\\\\/g, "/")}` } };
37
+ }
38
+
39
+ const posix = (s) => String(s).replace(/\\/g, "/");
40
+
41
+ // ГЛАВНОЕ УТВЕРЖДЕНИЕ ФАЙЛА.
42
+ test("без --send команда не зовёт gh ни разу", (t) => {
43
+ const p = project(t, { "AGENTS.md": "# проект\n", ".aqk.yml": 'aqk: 1\nentry:\n - AGENTS.md\ngates:\n lint: "true"\n' });
44
+ const g = fakeGh(p);
45
+ const r = aqkEnv(p, g.env, "feedback");
46
+ assert.ok(!existsSync(g.log),
47
+ `команда без --send позвала gh — то есть отправила что-то наружу без слова человека. Вывод:\n${r.out}`);
48
+ assert.match(r.out, /###/, `отчёт не напечатан вовсе. Вывод:\n${r.out}`);
49
+ });
50
+
51
+ test("и с чужими доводами рядом — тоже не зовёт", (t) => {
52
+ const p = project(t, { "AGENTS.md": "# проект\n" });
53
+ const g = fakeGh(p);
54
+ aqkEnv(p, g.env, "feedback", "--verbose", "что-то своими словами");
55
+ assert.ok(!existsSync(g.log), "слово без флага прочиталось как разрешение отправить");
56
+ });
57
+
58
+ test("с --send отправка происходит и адрес показан человеку", (t) => {
59
+ const p = project(t, { "AGENTS.md": "# проект\n", ".aqk.yml": 'aqk: 1\nentry:\n - AGENTS.md\ngates:\n lint: "true"\n' });
60
+ const g = fakeGh(p);
61
+ const r = aqkEnv(p, g.env, "feedback", "--send");
62
+ assert.ok(existsSync(g.log), `с --send gh не позвался вовсе. Вывод:\n${r.out}`);
63
+ const calls = readFileSync(g.log, "utf8");
64
+ assert.match(calls, /auth/, "вход не проверялся — отправили бы вслепую");
65
+ assert.match(calls, /addDiscussionComment/, `комментарий не отправлялся. Вызовы:\n${calls}`);
66
+ assert.match(r.out, /example\.invalid/, `человеку не показали, куда ушёл отзыв. Вывод:\n${r.out}`);
67
+ });
68
+
69
+ // Своя строка — самое ценное во всём письме, и она обязана доехать целиком.
70
+ test("слова человека попадают в отправленное", (t) => {
71
+ const p = project(t, { "AGENTS.md": "# проект\n" });
72
+ const g = fakeGh(p);
73
+ aqkEnv(p, g.env, "feedback", "--send", "у меня не нашёл ни одного гейта");
74
+ const calls = readFileSync(g.log, "utf8");
75
+ assert.match(calls, /не нашёл ни одного гейта/, `строка человека потерялась. Вызовы:\n${calls}`);
76
+ });
77
+
78
+ // «Не смогли» и «отправлено» обязаны различаться: молчаливый отказ здесь означал бы, что человек
79
+ // считает отзыв ушедшим, а его нет. Тот же договор, что у гейтов.
80
+ test("без входа в gh команда говорит это вслух и не молчит", (t) => {
81
+ const p = project(t, { "AGENTS.md": "# проект\n" });
82
+ const script = join(p.dir, "no-auth.mjs");
83
+ writeFileSync(script, 'process.exit(1);\n', "utf8");
84
+ const r = aqkEnv(p, { AQK_GH: `node ${posix(script)}` }, "feedback", "--send");
85
+ assert.doesNotMatch(r.out, /example\.invalid/, "сказали, что отправили, хотя не смогли");
86
+ assert.match(r.out, /github\.com/, `не дали запасного пути — человек остался ни с чем. Вывод:\n${r.out}`);
87
+ });
@@ -9,7 +9,7 @@ import test from "node:test";
9
9
  import assert from "node:assert/strict";
10
10
  import { readFileSync, writeFileSync, rmSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { project, aqk, run } from "./_fixture.mjs";
12
+ import { project, aqk, run, tail } from "./_fixture.mjs";
13
13
 
14
14
  // Цвет снимается до разбора: заголовок жирный, значки цветные, и регулярка по сырому выводу
15
15
  // не узнаёт ни то, ни другое.
@@ -99,8 +99,8 @@ test("нет .aqk/docs и .aqk/rules — прогон всё равно зелё
99
99
  const man = join(p.dir, ".aqk.yml");
100
100
  writeFileSync(man, readFileSync(man, "utf8").replace(/^gates:\s*$/m, 'gates:\n тихий: "true"'), "utf8");
101
101
  const r = aqk(p, "doctor", "--run");
102
- const tail = plain(r.out).trimEnd().split("\n").slice(-4).join("\n");
103
- assert.equal(r.code, 0, `прогон покраснел из-за методичек:\n${tail}`);
102
+ const last = tail(plain(r.out), 4);
103
+ assert.equal(r.code, 0, `прогон покраснел из-за методичек:\n${last}`);
104
104
  assert.match(plain(r.out), /\.aqk\/docs/, "отсутствие методичек не названо вовсе");
105
105
  });
106
106
 
@@ -142,3 +142,67 @@ test("prompt: задание без прогона начинается с пр
142
142
  assert.match(r.out, /(Как проверить, что готово|How to verify)/);
143
143
  assert.doesNotMatch(r.out, /\x1b\[/, "задание уходит агенту текстом — без цветовых кодов");
144
144
  });
145
+
146
+ // ВЫКЛЮЧЕННАЯ ПРОВЕРКА НАЗЫВАЕТСЯ ВЫКЛЮЧЕННОЙ — В ПЕРВЫЕ ПЯТЬ СЕКУНД.
147
+ //
148
+ // Найдено 2026-09-14 живым прогоном на репозитории, где выключено всё: `"test": "node --test ||
149
+ // true"`, `"lint": "ruff check . --exit-zero"`, шаг конвейера под `continue-on-error`. `doctor`
150
+ // печатал «Проверки, которые у вас УЖЕ ЕСТЬ (2)» с двумя ЗЕЛЁНЫМИ галочками: мы читали ИМЯ
151
+ // скрипта и ни разу не заглядывали в его тело. Тот самый класс отказа, ради которого написан
152
+ // комплект, — в нашем собственном первом экране.
153
+ //
154
+ // Это главная встреча постороннего с инструментом: ноль настройки, одна команда. Если здесь мы
155
+ // подтверждаем ложное «у вас всё хорошо», второго запуска не будет.
156
+ test("проверка, которую нельзя провалить, названа находкой, а не зелёной галочкой", (t) => {
157
+ const p = project(t, {
158
+ ...FILES,
159
+ "package.json": JSON.stringify({ name: "x", scripts: { test: "node --test || true", lint: "ruff check . --exit-zero" } }),
160
+ });
161
+ const out = plain(aqk(p, "doctor").out);
162
+ const block = out.slice(out.indexOf("УЖЕ ЕСТЬ"));
163
+ assert.ok(block, "блок «проверки, которые уже есть» пропал совсем");
164
+ assert.doesNotMatch(block.split("\n").slice(0, 3).join("\n"), /✔/,
165
+ `выключенная проверка получила зелёную галочку — это ложное «у вас всё хорошо»:\n${block.slice(0, 400)}`);
166
+ assert.match(block, /\|\| true/, "человеку не показали строку, по которой он найдёт выключатель у себя");
167
+ assert.match(block, /--exit-zero/, "флаг, гасящий исход, не назван");
168
+
169
+ // И то же знание — агенту: иначе он впишет дыру в манифест, и с этого дня за неё ручается машина.
170
+ const ctx = plain(aqk(p, "context").out);
171
+ assert.match(ctx, /покраснеть не могут/,
172
+ `блок для агента велит объявить проверки, которые не могут провалиться:\n${ctx.slice(0, 600)}`);
173
+ });
174
+
175
+ // ЗРЕЛЫЙ ПРОЕКТ НЕ ТЕРЯЕТ СВОИ ПРОВЕРКИ ИЗ ВИДА, КОГДА ОБЪЯВИЛ ГЕЙТ.
176
+ //
177
+ // Замер 2026-09-16 по двенадцати чужим репозиториям: у шести зрелых (requests, httpx, fastapi,
178
+ // black, express, flask) блок «у вас уже есть» исчезал целиком, стоило объявить один гейт, —
179
+ // и итог печатал «держит машина 0» проекту с настоящим pre-commit и Makefile. Чем больше
180
+ // человек настроил, тем меньше мы о нём знали: условие показывало чужие проверки только тому,
181
+ // у кого гейтов нет вовсе.
182
+ test("объявленный гейт не прячет проверки, которые у проекта уже есть", (t) => {
183
+ const p = project(t, {
184
+ ...FILES,
185
+ Makefile: "test:\n\tpytest -q\n\nlint:\n\truff check .\n",
186
+ ".aqk.yml": 'aqk: 1\nentry: [AGENTS.md]\ngates:\n project-verify: "bash scripts/verify.sh"\n',
187
+ "AGENTS.md": "# вход\n",
188
+ });
189
+ const out = plain(aqk(p, "doctor").out);
190
+ assert.match(out, /УЖЕ ЕСТЬ/,
191
+ `проект объявил один гейт — и его собственные проверки пропали из вывода:\n${out.slice(-1500)}`);
192
+ assert.match(out, /make test|npm test/, "найденная команда не названа человеку");
193
+ });
194
+
195
+ // И обратное: то, что УЖЕ объявлено, вторым списком не повторяется. Иначе вывод советует
196
+ // поставить то, что стоит, — и человек перестаёт читать этот блок целиком.
197
+ test("объявленная проверка не советуется второй раз", (t) => {
198
+ const p = project(t, {
199
+ ...FILES,
200
+ Makefile: "test:\n\tpytest -q\n",
201
+ ".aqk.yml": 'aqk: 1\nentry: [AGENTS.md]\ngates:\n test: "make test"\n lint: "npm run lint"\n',
202
+ "AGENTS.md": "# вход\n",
203
+ });
204
+ const out = plain(aqk(p, "doctor").out);
205
+ const block = out.includes("УЖЕ ЕСТЬ") ? out.slice(out.indexOf("УЖЕ ЕСТЬ")) : "";
206
+ assert.doesNotMatch(block.split("\n").slice(0, 6).join("\n"), /make test/,
207
+ `«make test» уже объявлен гейтом, а мы советуем его снова:\n${block.slice(0, 400)}`);
208
+ });
@@ -0,0 +1,83 @@
1
+ // Прогон, который не может состояться, обязан сказать это, а не показать пятнадцать крестов.
2
+ //
3
+ // НАЙДЕНО ЧУЖИМ РАЗБОРОМ 2026-09-16. `smoke.sh`, запущенный внутри песочницы Codex, дал 15
4
+ // падений из 123. Настоящая причина: дочерним процессам Node запрещалось запускать git и npm —
5
+ // `spawnSync` возвращал EPERM. Проверки об этом не знали и печатали обычные кресты. Человек
6
+ // час разбирал несуществующие дефекты, а строка «дефекты AQK 0.15.0» уехала в чужой отчёт.
7
+ //
8
+ // ИРОНИЯ, РАДИ КОТОРОЙ ЭТОТ ФАЙЛ И НАПИСАН. `tool/lib/execution.mjs` различает три исхода —
9
+ // clean · finding · infra_error — именно затем, чтобы сбой инструмента не выдавался за приговор
10
+ // коду. Своей собственной оснастке мы это правило не применили.
11
+ //
12
+ // ЧАСТИЧНЫЙ ПРОГОН НИЧЕГО НЕ ДОКАЗЫВАЕТ: 108 зелёных из 123 при недоступном git — не «почти
13
+ // всё хорошо», а «мерить было нечем». Поэтому предполёт отказывает целиком и выходит кодом 3 —
14
+ // тем же, которым pytest отделяет INTERNAL_ERROR от провала теста (их код 1).
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { spawnSync } from "node:child_process";
18
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join, dirname, delimiter } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
24
+ const SMOKE = join(ROOT, "tool", "selfcheck", "smoke.sh");
25
+
26
+ const WIN = process.platform === "win32";
27
+
28
+ // Заглушка вместо настоящей программы: выходит кодом 126 — «найдено, но запустить нельзя».
29
+ // Ровно то, чем для нас выглядел запрет песочницы.
30
+ //
31
+ // ДВА ФАЙЛА, А НЕ ОДИН. Поймано windows-заданием конвейера: скрипт без расширения там не
32
+ // перехватывает ничего — CreateProcess ищет `.exe`/`.cmd` по PATHEXT, а Git Bash понимает
33
+ // shebang. Значит нужны обе заглушки сразу, иначе проверка молча меряет настоящий git и
34
+ // краснеет на исправной машине — то есть сама становится тем ложным красным, против которого
35
+ // написан предполёт.
36
+ function stubDir(t, name) {
37
+ const dir = mkdtempSync(join(tmpdir(), "aqk-stub-"));
38
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
39
+ writeFileSync(join(dir, name), "#!/bin/sh\nexit 126\n", "utf8");
40
+ chmodSync(join(dir, name), 0o755);
41
+ if (WIN) writeFileSync(join(dir, `${name}.cmd`), "@echo off\r\nexit /b 126\r\n", "utf8");
42
+ return dir;
43
+ }
44
+
45
+ // PATH СОБИРАЕТСЯ ЧЕРЕЗ `delimiter`, А НЕ ЧЕРЕЗ ДВОЕТОЧИЕ. На Windows разделитель — точка с
46
+ // запятой, а сам путь вида `C:\Users\…` содержит двоеточие внутри: строка `"C:\...:" + PATH`
47
+ // разваливается на первом же символе, заглушка в PATH не попадает, и проверка меряет настоящий
48
+ // git. Поймано windows-заданием дважды подряд — первый раз я починил вызов, а не сборку пути.
49
+ const withStub = (dir) => (dir ? `${dir}${delimiter}${process.env.PATH}` : process.env.PATH);
50
+
51
+ const runPreflight = (extraPath) => spawnSync("bash", [SMOKE, "--preflight-only"], {
52
+ encoding: "utf8", timeout: 60000,
53
+ env: { ...process.env, PATH: withStub(extraPath), AQK_LANG: "ru" },
54
+ });
55
+
56
+ test("предполёт: исправная машина проходит и ничего не ломает", () => {
57
+ const r = runPreflight(null);
58
+ assert.equal(r.status, 0, `предполёт отказал на исправной машине:\n${r.stdout}${r.stderr}`);
59
+ });
60
+
61
+ test("предполёт: недоступный git — это «не смогли проверить», а не падения проверок", (t) => {
62
+ const r = runPreflight(stubDir(t, "git"));
63
+ assert.equal(r.status, 3,
64
+ `код ${r.status}: запрет среды неотличим от провала проверок. Нужен 3 — тот же, которым pytest отделяет внутреннюю ошибку от провала теста`);
65
+ const out = `${r.stdout || ""}${r.stderr || ""}`;
66
+ assert.match(out, /git/i, "не сказано, ЧТО именно недоступно");
67
+ assert.doesNotMatch(out, /✘/, `предполёт напечатал крест — значит выдал сбой среды за находку:\n${out}`);
68
+ });
69
+
70
+ // Тот самый случай из отчёта: сам шелл git видит, а подпроцессы Node — нет. Проверять только
71
+ // первое значило бы пройти предполёт и упасть пятнадцатью крестами следом.
72
+ test("предполёт: git виден шеллу, но не подпроцессу Node — тоже отказ", (t) => {
73
+ const dir = stubDir(t, "git");
74
+ // `shell` — тот же, что в самом предполёте: на Windows без оболочки Node не видит ни `.cmd`
75
+ // заглушки, ни настоящего `npm`. Проверка обязана звать так же, как зовёт код, иначе она
76
+ // проверяет не его. Ровно на этом конвейер и поймал первую версию.
77
+ // Node зовётся НАПРЯМУЮ, без оболочки-посредника: подстановка пути в строку `bash -c` — это
78
+ // ещё один способ потерять заглушку, и именно так эта проверка уже ошиблась.
79
+ const probe = `const r=require("node:child_process").spawnSync("git",["--version"],{shell:${WIN}}); process.exit(r.status===0?0:3)`;
80
+ const r = spawnSync(process.execPath, ["-e", probe],
81
+ { encoding: "utf8", timeout: 30000, env: { ...process.env, PATH: withStub(dir) } });
82
+ assert.equal(r.status, 3, "оснастка проверки неверна: заглушка не перехватывает git у Node");
83
+ });