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
@@ -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 };
@@ -12,7 +12,10 @@ import { gateCommand } from "./execution.mjs";
12
12
  parentPort.on("message", ({ id, cmd, cwd, timeout }) => {
13
13
  const r = spawnSync(gateCommand(cmd), { shell: true, cwd, encoding: "utf8", timeout });
14
14
  parentPort.postMessage({
15
- id, status: r.status, stdout: r.stdout || "", stderr: r.stderr || "",
15
+ // `signal` передаётся наравне со статусом: по нему исход отличает наш срок (SIGTERM) от
16
+ // чужого убийства. Без него параллельный прогон объяснял бы сбой иначе, чем одиночный, —
17
+ // а одно и то же событие обязано называться одним словом в обоих.
18
+ id, status: r.status, signal: r.signal || null, stdout: r.stdout || "", stderr: r.stderr || "",
16
19
  error: r.error ? { code: r.error.code || String(r.error.message || r.error) } : null,
17
20
  });
18
21
  });
@@ -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
@@ -10,13 +10,14 @@
10
10
  // контексту». Ровно то, о чём предупреждает совет самого гейта.
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { existsSync } from "node:fs";
13
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
13
14
  import { join } from "node:path";
14
15
  import { Worker } from "node:worker_threads";
15
16
  import { scopeOutput, splitAdvice, changedFiles } from "./scope.mjs";
16
- import { CWD, c, die } from "./core.mjs";
17
+ import { CWD, TARGET_DIR, c, die, exists } from "./core.mjs";
17
18
  import { advisorySet } from "./manifest.mjs";
18
19
  import { L } from "../i18n/index.mjs";
19
- import { gateCommand } from "./execution.mjs";
20
+ import { gateCommand, classify, findingCodes, gateTimeout } from "./execution.mjs";
20
21
  import { annotations } from "./annotate.mjs";
21
22
 
22
23
 
@@ -102,11 +103,12 @@ function selectGates(gates, man, { only = [], skip = [] } = {}) {
102
103
  return { run, skipped: gates.map(([n]) => n).filter((n) => !kept.has(n)), unknown: [...new Set(unknown)] };
103
104
  }
104
105
 
105
- const GATE_TIMEOUT = 300000;
106
+ // Сколько ждать — знает `execution.mjs`, там же, где всё остальное про запуск чужой команды.
107
+ // Число жило здесь и ещё в трёх местах; переопределяется переменной AQK_GATE_TIMEOUT.
106
108
 
107
109
  // Один гейт в этом потоке — прежний путь, без `--jobs`.
108
110
  function spawnGate(cmd) {
109
- 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 });
110
112
  }
111
113
 
112
114
  // ПАРАЛЛЕЛЬНО — ПО ФЛАГУ. Отзыв с живого проекта 2026-09-11: 34 независимых гейта шли друг за
@@ -124,7 +126,7 @@ function startPool(cmds, jobs) {
124
126
  const onErr = (e) => { results[id].res({ status: null, stdout: "", stderr: String(e?.message || e), error: { code: "WORKER" } }); };
125
127
  w.once("error", onErr);
126
128
  w.once("message", (m) => { w.off("error", onErr); results[id].res(m); feed(w); });
127
- w.postMessage({ id, cmd: cmds[id], cwd: CWD, timeout: GATE_TIMEOUT });
129
+ w.postMessage({ id, cmd: cmds[id], cwd: CWD, timeout: gateTimeout().ms });
128
130
  };
129
131
  for (let i = 0; i < Math.min(jobs, cmds.length); i++) feed(new Worker(url));
130
132
  return results;
@@ -139,6 +141,12 @@ async function runGates(man, opts = {}) {
139
141
  const advisory = advisorySet(man);
140
142
  const bar = progress();
141
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
+
142
150
  // Сужение по дифу — договор с человеком, и он должен видеть, ЧТО именно сужено. Пустой диф
143
151
  // называется вслух: иначе «все гейты зелёные» означало бы «сравнили не с тем» и читалось бы
144
152
  // как успех. Это тот же класс, что и весь стандарт, только внутри нашего флага.
@@ -168,10 +176,46 @@ async function runGates(man, opts = {}) {
168
176
  // Без этого «готово = доказано» остаётся правилом, за которым следит только человек.
169
177
  const outAll = `${r.stdout || ""}${r.stderr || ""}`.slice(0, 200000);
170
178
 
171
- if (r.error && r.error.code === "ETIMEDOUT") {
172
- console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.timeout)}`);
173
- failed++;
174
- results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout, out: outAll });
179
+ // ИСХОД ЗАПУСКА ДО РАЗБОРА ВЫВОДА И ДО СУЖЕНИЯ. Состояний три, а не два: clean · finding
180
+ // · infra_error. Знание о кодах живёт рядом с инструментом (`execution.mjs`): у vulture
181
+ // находка это 3, у pylint — битовая маска, а у незнакомой программы находка только 1.
182
+ //
183
+ // ЗАЧЕМ ЗДЕСЬ. Прежде прогон ловил один лишь ETIMEDOUT, а любой другой ненулевой код шёл в
184
+ // разбор находок — и `--since` фильтровал его ПО ПУТЯМ. Гейт, который НЕ СМОГ отработать,
185
+ // называл путь вне дифа и печатался зелёным: проверка сломалась, прогон сказал «чисто».
186
+ // Найдено внешним разбором 2026-09-13 (аудит Runcap), воспроизведено проверкой
187
+ // `fail-closed`. Сужать дифом можно только НАХОДКУ: у сбоя нет места в коде, которое он
188
+ // называет, — есть только сам сбой.
189
+ const verdict = classify(r, findingCodes(String(cmd).trim().split(/\s+/)[0]));
190
+ if (verdict.state === "infra_error") {
191
+ const why =
192
+ verdict.reason === "timeout" ? L.doctor.timeout
193
+ : verdict.reason === "spawn_error" ? L.doctor.whySpawn(r.error?.code || "")
194
+ : verdict.reason === "signal" ? L.doctor.whySignal(r.signal)
195
+ : L.doctor.whyExit(verdict.code);
196
+ // Совещательный не роняет прогон НИКОГДА — в том числе своим сбоем: список `advisory:`
197
+ // означает «эта проверка не имеет права останавливать работу», и причина остановки тут
198
+ // ни при чём. Но НАЗВАН он обязан быть: «не смогли» и «чисто» неразличимы только там,
199
+ // где о них молчат. Раньше таймаут ронял прогон и у совещательного — тот же класс, что
200
+ // измеренный 2026-09-09 случай с гейтом без путей в выводе.
201
+ const adv = advisory.has(name);
202
+ const note = L.doctor.cannotCheck(why);
203
+ const paint = adv ? c.yellow : c.red;
204
+ console.log(` ${adv ? c.yellow("!") : c.red("✘")} ${name.padEnd(14)} ${paint(note)} ${c.dim(`· ${secs}s · ${cmd}`)}`);
205
+ // Вывод сбоя показывается тоже. «Не смогли проверить: код 127» без строки
206
+ // «command not found: ruff» не говорит, ЧТО чинить, — а чинить тут надо инструмент,
207
+ // и первые строки обычно и есть его жалоба.
208
+ for (const line of outAll.trim().split("\n").filter(Boolean).slice(0, 3)) {
209
+ console.log(c.dim(` ${line.slice(0, 100)}`));
210
+ }
211
+ if (!adv) failed++;
212
+ // В pull request это ОБЩАЯ пометка гейта, а не пометка у строки файла: `shown` — то, что
213
+ // прогон показал, и у сбоя это причина, а не путь. Иначе «не смогли проверить» повисло бы
214
+ // на первом файле, который гейт успел назвать перед падением, — то есть на невиновном.
215
+ // Сырой `out` не трогаем: по нему считается покрытие дифа.
216
+ // `cannot` — не украшение: по нему отчёт прогона отличает сбой от находки одним знаком,
217
+ // а блок для агента и просьба об отзыве читают это из файла, ничего не запуская.
218
+ results.push({ name, cmd, ok: false, cannot: true, secs, code: verdict.code, advisory: adv, note, out: outAll, shown: note });
175
219
  continue;
176
220
  }
177
221
  const code = r.status;
@@ -280,4 +324,92 @@ async function runGates(man, opts = {}) {
280
324
  return { failed, ran: gates.length, results, advisoryFailed, skipped: sel.skipped };
281
325
  }
282
326
 
283
- export { declaredGates, sinceRef, runGates, progress, selectGates, listArg };
327
+
328
+ // ─────────────────────────────────────────────────────────────────────────────
329
+ // ОТЧЁТ ПРОГОНА: пишется здесь же, где прогон, и читается здесь же. Раньше `doctor` его ПИСАЛ,
330
+ // а `context` РАЗБИРАЛ — два файла, которые друг о друге не знают, держали один формат. Третий
331
+ // знак («?» — не смогли проверить) пришлось заводить в обоих, и это ровно тот случай, когда
332
+ // одно знание живёт в двух местах: правишь одно, второе молча расходится.
333
+ //
334
+ // Читателей у отчёта трое — блок для агента, задание и просьба об отзыве, — и ни один не
335
+ // запускает гейты заново: хук обязан укладываться в секунду, а прогон идёт минуту.
336
+ // Короткий отчёт «что из этого реально брали» — не для человека, а для агента в следующей
337
+ // сессии и для самого владельца: список объявленных гейтов молчит о том, сколько из них
338
+ // действительно стоят и работают именно СЕЙЧАС. Перезаписывается каждым прогоном, не копится:
339
+ // история — дело git-лога коммитов с этим отчётом, если владелец решит его коммитить.
340
+ async function writeRunReport({ version, reached, results, skipped = [] }) {
341
+ const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
342
+ const ok = results.filter((r) => r.ok).length;
343
+ const lines = [
344
+ `# ${L.report.title} — ${stamp}`,
345
+ version ? `${L.report.version}: ${version}` : null,
346
+ `${L.report.level}: AQK-${reached < 0 ? L.doctor.levelNone : reached}`,
347
+ "",
348
+ // ТРИ ЗНАКА, А НЕ ДВА: ✔ прошло · ✘ находка · ? НЕ СМОГЛИ ПРОВЕРИТЬ. Отчёт читает не только
349
+ // человек: из него блок для агента и просьба об отзыве узнают, что случилось, ничего не
350
+ // запуская. Слив «не смогли» с находкой означал бы, что агент чинит код там, где сломан
351
+ // инструмент, — и никогда не узнает, что инструмент сломан.
352
+ ...results.map((r) => `${r.ok ? "✔" : r.cannot ? "?" : "✘"} ${r.name} — ${r.secs}s${r.ok ? "" : ` (${r.note || L.doctor.exitCode(r.code)})`}`),
353
+ // Пропущенные по --skip/--only — строкой «~»: блок для агента читает их как «не запускались»,
354
+ // а не как зелёные. Молчание о них прочиталось бы как «проверено».
355
+ ...skipped.map((n) => `~ ${n} — ${L.report.skippedBySelect}`),
356
+ "",
357
+ L.report.summary(ok, results.length),
358
+ ].filter((l) => l !== null);
359
+
360
+ // ЗАПИСЬ ОТЧЁТА НЕ РОНЯЕТ ПРОГОН. Тот же класс, что у `report` (разбор 2026-09-16): в рабочей
361
+ // области, где `.aqk/` не создать — read-only контейнер, чужой конвейер, каталог под
362
+ // ревью, — прогон падал уже ПОСЛЕ того, как все гейты отработали, и человек не получал ни
363
+ // вердикта, ни кода возврата. Побочное действие отменяло то, ради чего команду звали.
364
+ //
365
+ // Но молчать нельзя: отчёт читают `context` и `prompt`, и его отсутствие они прочтут как
366
+ // «прогона не было». Разница между «не было» и «был, записать не смогли» — ровно та, которую
367
+ // весь комплект и защищает, поэтому она называется вслух.
368
+ const dst = join(CWD, TARGET_DIR, "last-run.md");
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
+ }
375
+ }
376
+
377
+ // Разбор отчёта прошлого прогона. Формат кладёт сам `doctor` в .aqk/last-run.md; читаем его,
378
+ // а не запускаем гейты заново: хук обязан укладываться в секунду-две, а прогон у нас идёт минуту.
379
+ function parseLastRun(text) {
380
+ if (!text) return null;
381
+ const when = (text.match(/^# aqk doctor --run — (.+)$/m) || [])[1] || "";
382
+ const red = [];
383
+ for (const m of text.matchAll(/^✘ ([^\s—]+)/gm)) red.push(m[1]);
384
+ // «Не смогли проверить» — свой знак и свой список. Гейт, который не сумел отработать, не
385
+ // находка о коде: агент, прочитавший его как находку, пойдёт чинить исправный файл, а
386
+ // сломанный инструмент останется сломанным. Слить их в один список было бы той же тишиной,
387
+ // только наоборот.
388
+ const cannot = [];
389
+ for (const m of text.matchAll(/^\? ([^\s—]+)/gm)) cannot.push(m[1]);
390
+ const skipped = (text.match(/^~ /gm) || []).length;
391
+ return { when: when.trim(), red, cannot, skipped, stale: false };
392
+ }
393
+
394
+ // Прогон старше последнего коммита описывает не тот код, что лежит перед агентом. Молча выдать
395
+ // его за свежий — соврать: именно так «зелёный месяц назад» превращается в «зелёный сейчас».
396
+ function runIsStale(when) {
397
+ if (!when) return false;
398
+ const r = spawnSync("git", ["log", "-1", "--format=%cI"], { cwd: CWD, encoding: "utf8" });
399
+ if (r.status !== 0 || !r.stdout) return false;
400
+ const commit = Date.parse(r.stdout.trim());
401
+ const run = Date.parse(when.replace(" ", "T"));
402
+ return Number.isFinite(commit) && Number.isFinite(run) && run < commit;
403
+ }
404
+
405
+ // Прошлый прогон — из отчёта, который кладёт `doctor --run`. Отдельной функцией: его читают и
406
+ // `context`, и `prompt`, и два разбора одного файла разошлись бы.
407
+ async function readRun() {
408
+ const lastRun = join(CWD, TARGET_DIR, "last-run.md");
409
+ if (!(await exists(lastRun))) return null;
410
+ const run = parseLastRun(await readFile(lastRun, "utf8"));
411
+ if (run) run.stale = runIsStale(run.when);
412
+ return run;
413
+ }
414
+
415
+ export { declaredGates, sinceRef, runGates, progress, selectGates, listArg, writeRunReport, parseLastRun, readRun };
package/tool/program.mjs CHANGED
@@ -30,6 +30,7 @@ import { cmdProbe } from "./commands/probe.mjs";
30
30
  import { cmdContext } from "./commands/context.mjs";
31
31
  import { cmdPrompt } from "./commands/prompt.mjs";
32
32
  import { cmdVitals } from "./commands/vitals.mjs";
33
+ import { cmdFeedback } from "./commands/feedback.mjs";
33
34
 
34
35
  // Разбор аргументов выполняется только при запуске файла как программы. При импорте —
35
36
  // а так его читают модульные проверки tool/selfcheck/units.mjs — CLI запускаться не должен.
@@ -127,6 +128,11 @@ if (IS_MAIN) {
127
128
  case "badge":
128
129
  await cmdBadge(rest);
129
130
  break;
131
+ // Собирает отчёт о работе комплекта и даёт готовую ссылку. Ничего не отправляет: исходящий
132
+ // запрос у комплекта ровно один — про свежесть версии.
133
+ case "feedback":
134
+ await cmdFeedback();
135
+ break;
130
136
  default: {
131
137
  // Ширина колонки считается, а не подбирается пробелами: строки в двух языках разной
132
138
  // длины, и вручную выровненная справка на втором языке разъезжается.
@@ -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 };
@@ -20,7 +20,7 @@ import assert from "node:assert/strict";
20
20
  import { cpSync, writeFileSync, mkdirSync } from "node:fs";
21
21
  import { join, dirname } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
- import { project, run, aqk } from "./_fixture.mjs";
23
+ import { project, run, aqk, aqkEnv } from "./_fixture.mjs";
24
24
 
25
25
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
26
26
  const posix = (p) => String(p).replace(/\\/g, "/");
@@ -110,3 +110,98 @@ test("doctor называет человеку, сколько правил не
110
110
  assert.ok(/человек/i.test(r.out),
111
111
  `doctor не сказал про сторожа-человека ни слова, хотя таких правил здесь два.\n${r.out}`);
112
112
  });
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // СБОЙ САМОЙ ПРОВЕРКИ НЕ СУЖАЕТСЯ ДИФОМ. Написано ДО кода 2026-09-14.
116
+ //
117
+ // ЗАЧЕМ. Внешний разбор (аудит Runcap, 2026-09-13) искал у нас тот же класс отказа, что нашёл
118
+ // у соседа, и нашёл: `--since` фильтрует вывод ПО ПУТЯМ, и гейт, который НЕ СМОГ отработать,
119
+ // печатался зелёным, если названный им путь в диф не попал. То есть
120
+ //
121
+ // проверка сломалась → прогон говорит «чисто»
122
+ //
123
+ // — ровно та тишина, против которой построен весь стандарт, только внутри нашего же флага.
124
+ // Договор уже записан в `execution.mjs` и уже соблюдается в `prove`: 0 чисто, 1 находка (у
125
+ // vulture 3, у pylint битовой маской), ВСЁ ОСТАЛЬНОЕ — «не смогли». Сужать дифом можно только
126
+ // НАХОДКУ: у сбоя нет места в коде, которое он называет, — есть только сам сбой.
127
+ // Цвет снимается: в терминале между знаком и именем гейта стоит управляющая
128
+ // последовательность, и проверка, написанная «по глазам», не совпала бы ни разу.
129
+ const plain = (s) => String(s).replace(/\u001b\[[0-9;]*m/g, "");
130
+
131
+ function scopeProject(t, { code, path }) {
132
+ const p = project(t, {
133
+ "AGENTS.md": "# проект\n",
134
+ ".aqk.yml": 'aqk: 1\nentry:\n - AGENTS.md\ngates:\n broken: "bash broken.sh"\n',
135
+ // Команда — ФАЙЛОМ, а не строкой `echo …; exit 2`: на Windows строку исполняет cmd.exe,
136
+ // где `;` не разделитель, и гейт выходит нулём. Поймано конвейером 2026-09-11.
137
+ "broken.sh": `echo "${path}: infrastructure failure"\nexit ${code}\n`,
138
+ });
139
+ run(p, "git", ["add", "-A"]);
140
+ run(p, "git", ["commit", "-qm", "база"]);
141
+ // Изменение ПОСЛЕ коммита: ровно один файл в дифе, и путь из вывода гейта в него не входит.
142
+ mkdirSync(join(p.dir, "src"), { recursive: true });
143
+ writeFileSync(join(p.dir, "src", "a.mjs"), "export const a = 1;\n", "utf8");
144
+ return p;
145
+ }
146
+
147
+ test("гейт, выпавший с непонятным кодом, остаётся красным и при --since", (t) => {
148
+ const p = scopeProject(t, { code: 2, path: "outside/old.mjs:1" });
149
+ const r = aqk(p, "doctor", "--run", "--since", "HEAD");
150
+ assert.match(plain(r.out), /✘\s+broken/,
151
+ "гейт вышел с кодом 2 — «не смогли проверить» — и показан зелёным, потому что названный " +
152
+ `им путь не попал в диф. Вывод:\n${r.out}`);
153
+ });
154
+
155
+ test("контроль: НАХОДКА вне дифа по-прежнему сужается", (t) => {
156
+ const p = scopeProject(t, { code: 1, path: "outside/old.mjs:1" });
157
+ const r = aqk(p, "doctor", "--run", "--since", "HEAD");
158
+ assert.match(plain(r.out), /✔\s+broken/,
159
+ `находка вне дифа обязана сужаться — иначе --since не делает ничего. Вывод:\n${r.out}`);
160
+ });
161
+
162
+ test("контроль: находка ВНУТРИ дифа остаётся красной", (t) => {
163
+ const p = scopeProject(t, { code: 1, path: "src/a.mjs:1" });
164
+ const r = aqk(p, "doctor", "--run", "--since", "HEAD");
165
+ assert.match(plain(r.out), /✘\s+broken/, `находка в изменённом файле пропала. Вывод:\n${r.out}`);
166
+ });
167
+
168
+ test("контроль: нулевой код остаётся «чисто»", (t) => {
169
+ const p = scopeProject(t, { code: 0, path: "outside/old.mjs:1" });
170
+ const r = aqk(p, "doctor", "--run", "--since", "HEAD", "--verbose");
171
+ assert.match(plain(r.out), /✔\s+broken/, `нулевой код перестал означать «чисто». Вывод:\n${r.out}`);
172
+ });
173
+
174
+ test("параллельный прогон говорит о сбое то же самое, что одиночный", (t) => {
175
+ const p = scopeProject(t, { code: 2, path: "outside/old.mjs:1" });
176
+ const one = plain(aqk(p, "doctor", "--run", "--since", "HEAD").out);
177
+ const many = plain(aqk(p, "doctor", "--run", "--since", "HEAD", "--jobs", "2").out);
178
+ const line = (out) => (out.split("\n").find((l) => /broken/.test(l) && /[✘✔!]/.test(l)) || "").trim();
179
+ assert.match(line(many), /✘/, `при --jobs сбой не покраснел. Вывод:\n${many}`);
180
+ assert.equal(line(many).replace(/\d+\.\ds/, ""), line(one).replace(/\d+\.\ds/, ""),
181
+ `одно и то же событие названо по-разному в одиночном и параллельном прогоне:\n${line(one)}\n${line(many)}`);
182
+ });
183
+
184
+ // Совещательный — это «показать, но не останавливать». Причина остановки тут ни при чём: сбой
185
+ // такой проверки обязан быть НАЗВАН и обязан не ронять прогон. Раньше на таймауте ронял.
186
+ test("сбой СОВЕЩАТЕЛЬНОГО гейта назван, но прогон не уронен", (t) => {
187
+ const p = scopeProject(t, { code: 2, path: "outside/old.mjs:1" });
188
+ writeFileSync(join(p.dir, ".aqk.yml"),
189
+ 'aqk: 1\nentry:\n - AGENTS.md\ngates:\n broken: "bash broken.sh"\nadvisory:\n - broken\n', "utf8");
190
+ const r = aqk(p, "doctor", "--run", "--since", "HEAD");
191
+ const out = plain(r.out);
192
+ assert.match(out, /!\s+broken/, `совещательный сбой не назван вовсе. Вывод:\n${out}`);
193
+ assert.match(out, /не смогли проверить/, `сбой назван находкой, а не «не смогли». Вывод:\n${out}`);
194
+ assert.doesNotMatch(out, /красных гейтов/,
195
+ `совещательный уронил прогон — список advisory перестал что-либо значить. Вывод:\n${out}`);
196
+ });
197
+
198
+ // Пометка в pull request у сбоя — ОБЩАЯ, без file=: путь из вывода упавшего гейта назвал не
199
+ // виноватого, а того, до кого гейт успел дойти.
200
+ test("в GitHub Actions сбой помечается гейтом, а не строкой чужого файла", (t) => {
201
+ const p = scopeProject(t, { code: 2, path: "outside/old.mjs:1" });
202
+ const r = aqkEnv(p, { GITHUB_ACTIONS: "true" }, "doctor", "--run", "--since", "HEAD");
203
+ const ann = plain(r.out).split("\n").filter((l) => l.startsWith("::error"));
204
+ assert.equal(ann.length, 1, `пометок про сбой не одна:\n${ann.join("\n")}`);
205
+ assert.doesNotMatch(ann[0], /file=/, `пометка повешена на файл, которого сбой не касается: ${ann[0]}`);
206
+ assert.match(ann[0], /title=aqk%3A broken/, `пометка не называет гейт: ${ann[0]}`);
207
+ });
@@ -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
+ });