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
@@ -0,0 +1,85 @@
1
+ // tool/selfcheck/units-ask.mjs — КОГДА КОМПЛЕКТ ОБРАЩАЕТСЯ К ЧЕЛОВЕКУ: ограничитель обращений.
2
+ //
3
+ // ЗАЧЕМ ЭТОТ ФАЙЛ И ЭТОТ МОДУЛЬ. Знание «мы это человеку уже показывали» жило в комплекте
4
+ // ДВАЖДЫ и по-разному: `~/.config/aqk/feedback-shown` — файл-флаг, раз на машину, заведён в
5
+ // core.mjs и прочитан в project.mjs; `.aqk/advice-shown` и `.aqk/update-checked` — отметка
6
+ // временем плюс `adviceDue()` на сутки, заведённые в brief.mjs. Одно решение — «не долби» — в
7
+ // двух местах с двумя форматами хранения. Третье обращение (просьба об отзыве по делу) завело
8
+ // бы третий формат, и дальше они расходятся молча: именно так и умирает ограничитель.
9
+ //
10
+ // Поэтому здесь один модуль на все обращения и таблица видов. Вид объявляет ДВЕ вещи, и обе
11
+ // нельзя угадать по имени: где живёт отметка (проект или дом) и как часто можно повторять.
12
+ //
13
+ // node --test tool/selfcheck/units-ask.mjs
14
+ import test from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { askDue, askFile, ASKS, ASK_FILES } from "../lib/ask.mjs";
17
+ import { RUNTIME_FILES } from "../lib/core.mjs";
18
+
19
+ const NOW = Date.parse("2026-09-14T20:00:00Z");
20
+
21
+ // --- «раз в сутки»: совет и проверка версии ----------------------------------
22
+ // Тот же договор, что был у adviceDue, и он обязан остаться прежним: совет на каждом коммите
23
+ // превращается в шум, а шум пролистывают вместе с настоящими находками.
24
+ test("суточное обращение: не показывали — пора", () => {
25
+ assert.equal(askDue("advice", null, NOW), true);
26
+ });
27
+
28
+ test("суточное обращение: час назад — рано, сутки назад — пора", () => {
29
+ assert.equal(askDue("advice", "2026-09-14T19:00:00Z", NOW), false);
30
+ assert.equal(askDue("advice", "2026-09-13T19:00:00Z", NOW), true);
31
+ });
32
+
33
+ // Испорченная отметка — это «не знаем, когда показывали». Молчать из-за нечитаемого файла
34
+ // состояния значит потерять обращение навсегда и не сказать почему.
35
+ test("испорченная отметка читается как «не знаем» и обращение показывается", () => {
36
+ assert.equal(askDue("advice", "не дата", NOW), true);
37
+ });
38
+
39
+ // --- «один раз»: просьба об отзыве -------------------------------------------
40
+ // Разовое обращение отличается от суточного не числом, а СМЫСЛОМ: его нельзя повторить никогда,
41
+ // сколько бы времени ни прошло. Считать его «раз в очень много часов» — значит однажды получить
42
+ // повтор у человека, который поставил комплект год назад.
43
+ test("разовое обращение: без отметки — пора, с любой отметкой — никогда", () => {
44
+ assert.equal(askDue("install", null, NOW), true);
45
+ assert.equal(askDue("install", "shown\n", NOW), false);
46
+ assert.equal(askDue("install", "2020-01-01T00:00:00Z", NOW), false);
47
+ assert.equal(askDue("install", "мусор", NOW), false);
48
+ });
49
+
50
+ // --- опечатка в виде обращения -----------------------------------------------
51
+ // Неизвестный вид обязан падать, а не превращаться в «показывать всегда» или «не показывать
52
+ // никогда». Оба умолчания — молчаливый выбор за человека, и оба неверны в половине случаев.
53
+ test("неизвестный вид обращения — отказ, а не тихое умолчание", () => {
54
+ assert.throws(() => askDue("опечатка", null, NOW), /опечатка/);
55
+ assert.throws(() => askFile("опечатка", { project: "/p", home: "/h" }), /опечатка/);
56
+ });
57
+
58
+ // --- где лежит отметка --------------------------------------------------------
59
+ // Разовая на машину отметка НЕ ИМЕЕТ ПРАВА лежать в проекте: внутри `.aqk/` она либо уедет в
60
+ // чужой git как наш мусор, либо пропадёт при `init --force` — и то и другое врёт о том, видел
61
+ // человек обращение или нет. Довод записан в core.mjs с 2026-09; проверки у него не было.
62
+ test("разовая на машину отметка лежит вне проекта", () => {
63
+ const p = askFile("install", { project: "/p", home: "/h" });
64
+ assert.ok(!p.startsWith("/p"), `отметка машины уехала в проект: ${p}`);
65
+ assert.ok(p.startsWith("/h"), `отметка машины легла мимо дома: ${p}`);
66
+ });
67
+
68
+ test("отметки про этот проект лежат в проекте", () => {
69
+ for (const kind of Object.keys(ASKS).filter((k) => ASKS[k].where === "project")) {
70
+ const p = askFile(kind, { project: "/p", home: "/h" });
71
+ assert.ok(p.startsWith("/p"), `${kind}: отметка проекта легла мимо проекта: ${p}`);
72
+ }
73
+ });
74
+
75
+ // --- СВЯЗЬ ДВУХ СПИСКОВ, КОТОРУЮ ИНАЧЕ НЕ СТОРОЖИТ НИКТО ----------------------
76
+ // Отметка, лежащая в `.aqk/` и не названная в RUNTIME_FILES, не попадёт в .gitignore — и уедет
77
+ // в чужой коммит. Это уже случалось с `.aqk/last-run.md` на живом проекте 2026-09-11, и оттуда
78
+ // же взялся сам список. Список и таблица видов — два разных файла, и связь между ними держится
79
+ // только этой проверкой.
80
+ test("каждая отметка проекта названа в RUNTIME_FILES — иначе уедет в чужой git", () => {
81
+ for (const f of ASK_FILES) {
82
+ assert.ok(RUNTIME_FILES.includes(f),
83
+ `«${f}» не назван в RUNTIME_FILES: init не положит его в .gitignore, и отметка уедет в коммит`);
84
+ }
85
+ });
@@ -8,7 +8,7 @@
8
8
  // node --test tool/selfcheck/units-brief.mjs
9
9
  import test from "node:test";
10
10
  import assert from "node:assert/strict";
11
- import { briefLine, adviceDue, pickAdvice, updateNotice, updateWanted } from "../lib/brief.mjs";
11
+ import { briefLine, pickAdvice, updateNotice, updateWanted } from "../lib/brief.mjs";
12
12
  import { CATALOGS } from "../i18n/index.mjs";
13
13
 
14
14
  const T = CATALOGS.ru;
@@ -35,18 +35,8 @@ test("невычисленный уровень не выдумывается",
35
35
  });
36
36
 
37
37
  // --- ограничитель совета -----------------------------------------------------
38
- // Совет на КАЖДОМ коммите превращается в шум, а шум пролистывают вместе с настоящими
39
- // находками. Раз в сутки это заметно и не мешает.
40
- test("совет не повторяется чаще раза в сутки", () => {
41
- const now = Date.parse("2026-09-08T20:00:00Z");
42
- assert.equal(adviceDue(null, now), true, "первый раз показывается");
43
- assert.equal(adviceDue("2026-09-08T19:00:00Z", now), false, "час назад — рано");
44
- assert.equal(adviceDue("2026-09-07T19:00:00Z", now), true, "сутки прошли");
45
- });
46
-
47
- test("испорченная отметка времени не мешает показать совет", () => {
48
- assert.equal(adviceDue("не дата", Date.parse("2026-09-08T20:00:00Z")), true);
49
- });
38
+ // Сам ограничитель переехал в `ask.mjs` он общий на все обращения комплекта к человеку, и
39
+ // проверки на него лежат в units-ask.mjs. Здесь остаётся то, что про краткий режим.
50
40
 
51
41
  // --- выбор совета ------------------------------------------------------------
52
42
  // Один совет за раз, а не список: список читается как «у вас всё плохо» и не помогает выбрать.
@@ -9,7 +9,8 @@
9
9
  // node --test tool/selfcheck/units-context.mjs
10
10
  import test from "node:test";
11
11
  import assert from "node:assert/strict";
12
- import { contextBlock, countArbiters, parseLastRun, withHook, hasOurHook, portableSelf, nextSteps } from "../commands/context.mjs";
12
+ import { contextBlock, countArbiters, withHook, hasOurHook, portableSelf, nextSteps } from "../commands/context.mjs";
13
+ import { parseLastRun } from "../lib/run.mjs";
13
14
  import { CATALOGS } from "../i18n/index.mjs";
14
15
  import { commandRows } from "../lib/core.mjs";
15
16
  import { readFile } from "node:fs/promises";
@@ -17,7 +17,7 @@
17
17
  // рядом с инструментом — нормализующим адаптером, а протокол остаётся простым.
18
18
  import test from "node:test";
19
19
  import assert from "node:assert/strict";
20
- import { classify, findingCodes, gitBash, launchable, gateCommand } from "../lib/execution.mjs";
20
+ import { classify, findingCodes, gitBash, launchable, gateCommand, gateTimeout, GATE_TIMEOUT_DEFAULT } from "../lib/execution.mjs";
21
21
 
22
22
  // Вход — то, что отдаёт spawnSync: { status, signal, error }.
23
23
  const R = (over = {}) => ({ status: 0, signal: null, error: undefined, ...over });
@@ -163,3 +163,39 @@ test("Windows: команда гейта со словом bash отвечает
163
163
  assert.equal(r.status, 0, r.stderr);
164
164
  assert.match(r.stdout, /MINGW|MSYS/, `ответил не Git Bash: ${r.stdout}`);
165
165
  });
166
+
167
+ // СКОЛЬКО ЖДАТЬ ЧУЖУЮ КОМАНДУ — ОДНО ЗНАНИЕ, А НЕ ПЯТЬ КОПИЙ.
168
+ //
169
+ // До 2026-09-16 число 300000 было вписано в четырёх местах: run.mjs, prove.mjs и дважды в
170
+ // gates.mjs. Это не похожие строки, а одно знание в четырёх файлах, и правится оно по одному.
171
+ //
172
+ // СПОСОБ РЕШИТЬ ИНАЧЕ ОБЯЗАН БЫТЬ. Он записан в соседнем файле про выбор bash: «у всего, что мы
173
+ // решаем сами, обязан быть способ решить иначе». У таймаута его не было, и живой проект с
174
+ // verify-прогоном длиннее пяти минут просто выкинул свою главную проверку из манифеста.
175
+ //
176
+ // ПЯТЬ МИНУТ ПО УМОЛЧАНИЮ НЕ ВЫДУМАНЫ: у SonarQube SONAR_QUALITY_GATE_TIMEOUT ровно 300 секунд.
177
+ // Переопределение переменной среды — тоже их способ, и GitLab делает так же
178
+ // (RUNNER_AFTER_SCRIPT_TIMEOUT). Поля «timeout» у команды нет ни у lefthook, ни у pre-commit,
179
+ // поэтому в манифест мы его не заводим: соглашения нет, а просьба была одна.
180
+ test("без переменной среды — прежние пять минут", () => {
181
+ assert.equal(gateTimeout({}).ms, GATE_TIMEOUT_DEFAULT);
182
+ assert.equal(GATE_TIMEOUT_DEFAULT, 300000);
183
+ assert.equal(gateTimeout({ AQK_GATE_TIMEOUT: "" }).ms, GATE_TIMEOUT_DEFAULT);
184
+ });
185
+
186
+ test("переменная задаёт срок в секундах", () => {
187
+ assert.equal(gateTimeout({ AQK_GATE_TIMEOUT: "900" }).ms, 900000);
188
+ assert.equal(gateTimeout({ AQK_GATE_TIMEOUT: " 60 " }).ms, 60000);
189
+ });
190
+
191
+ // МУСОР НЕ ПРЕВРАЩАЕТСЯ В «БЕЗ ПРЕДЕЛА». Ноль или буквы в NaN дали бы spawnSync поведение
192
+ // «ждать вечно» — то есть висящий гейт вместо честного «не смогли проверить». И молчать об
193
+ // этом нельзя: человек задал переменную и ждёт от неё действия.
194
+ test("мусор в переменной — прежний срок и слово об этом, а не вечное ожидание", () => {
195
+ for (const bad of ["abc", "0", "-5", "NaN", "1e999"]) {
196
+ const t = gateTimeout({ AQK_GATE_TIMEOUT: bad });
197
+ assert.equal(t.ms, GATE_TIMEOUT_DEFAULT, `«${bad}» изменило срок`);
198
+ assert.equal(t.ok, false, `«${bad}» принято за исправное значение`);
199
+ assert.equal(t.raw, bad);
200
+ }
201
+ });
@@ -0,0 +1,137 @@
1
+ // tool/selfcheck/units-feedback.mjs — О ЧЁМ комплект просит человека и что кладёт в отчёт.
2
+ //
3
+ // ЗАЧЕМ ЭТО ВООБЩЕ. Замер 2026-09-14: 1342 скачивания в неделю в npm, из них ни одного
4
+ // пользователя — версии качаются равномерно, включая прожившую двадцать пять минут, то есть это
5
+ // зеркала и сканеры. На GitHub за две недели семь уникальных посетителей, две звезды, ноль чужих
6
+ // комментариев за всё время. Обратной связи нет не потому, что люди молчат, — её не у кого
7
+ // просить, и просить мы не умеем: единственная просьба печаталась при `init`, то есть ДО того,
8
+ // как комплект сделал хоть что-то полезное.
9
+ //
10
+ // ПОЭТОМУ ЗДЕСЬ ДВЕ ЧИСТЫЕ ФУНКЦИИ И ДВА ПРАВИЛА.
11
+ // 1. Просим, ТОЛЬКО когда есть что рассказать. «Оставьте отзыв» без содержания — шум, а шум
12
+ // выключают вместе с хуком, в котором он приехал. Самое ценное — «AQK не смог»: жалоба
13
+ // даётся людям легче похвалы, и она же говорит нам, где инструмент врёт.
14
+ // 2. Отчёт не несёт ни путей, ни кода. Человек отправляет его сам, и он обязан видеть глазами
15
+ // всё, что отправляет, — иначе первый же внимательный читатель назовёт это телеметрией.
16
+ //
17
+ // node --test tool/selfcheck/units-feedback.mjs
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { feedbackAsk, reportText, issueUrl, askLine, feedbackWanted } from "../commands/feedback.mjs";
21
+
22
+ // --- о чём просим ------------------------------------------------------------
23
+
24
+ // Рассказывать нечего — не просим. Это главное правило: просьба без содержания превращает
25
+ // ограничитель в шум, а следующую просьбу — в то, что пролистывают не читая.
26
+ test("сказать нечего — просьбы нет", () => {
27
+ assert.equal(feedbackAsk({ cannot: [], blind: [], red: [] }), null);
28
+ assert.equal(feedbackAsk({}), null);
29
+ });
30
+
31
+ // «Не смогли проверить» — самое ценное, что у нас бывает: это отказ прибора, а не находка о
32
+ // коде. Такое человек рассказывает охотнее, чем похвалу, и чинить надо именно это.
33
+ test("«не смогли проверить» важнее всего остального", () => {
34
+ const a = feedbackAsk({ cannot: ["smoke"], blind: ["swallowed-error"], red: ["lint"] });
35
+ assert.equal(a.reason, "cannot");
36
+ assert.deepEqual(a.names, ["smoke"]);
37
+ });
38
+
39
+ test("слепой класс важнее просто красного гейта", () => {
40
+ const a = feedbackAsk({ cannot: [], blind: ["swallowed-error"], red: ["lint"] });
41
+ assert.equal(a.reason, "blind");
42
+ assert.deepEqual(a.names, ["swallowed-error"]);
43
+ });
44
+
45
+ // Красный гейт — это момент, когда комплект принёс пользу: он поймал то, ради чего стоит.
46
+ test("пойманное красным — тоже повод, но последний", () => {
47
+ const a = feedbackAsk({ cannot: [], blind: [], red: ["lint", "smoke"] });
48
+ assert.equal(a.reason, "red");
49
+ assert.deepEqual(a.names, ["lint", "smoke"]);
50
+ });
51
+
52
+ // --- что в отчёте ------------------------------------------------------------
53
+
54
+ // ПУТЕЙ НЕ БЫВАЕТ. Проба знает файл, в который подсаживала брак («blind-class: slug path»), и
55
+ // соблазн положить его в отчёт велик — он же объясняет находку. Нельзя: путь внутри чужого
56
+ // репозитория рассказывает о чужом проекте больше, чем его владелец собирался рассказать.
57
+ test("отчёт называет класс, но не файл, в котором его нашли", () => {
58
+ const text = reportText({
59
+ version: "0.15.0", node: "v22.0.0", platform: "linux", level: 3,
60
+ langs: ["javascript"], gates: 31, red: ["lint"], cannot: [],
61
+ blind: [{ slug: "swallowed-error", file: "src/secret/internal.js" }],
62
+ }).join("\n");
63
+ assert.match(text, /swallowed-error/, "класс не назван — отчёт бесполезен");
64
+ assert.doesNotMatch(text, /src\/secret\/internal\.js/, "в отчёт попал путь из чужого репозитория");
65
+ assert.doesNotMatch(text, /internal/, "в отчёт попал кусок пути из чужого репозитория");
66
+ });
67
+
68
+ // МЕТКА ИСТОЧНИКА. Отзыв в GitHub ничем не связан с просьбой, и понять, работает ли канал,
69
+ // иначе нельзя. Метка видна человеку в том же тексте, который он отправляет: это не слежка,
70
+ // а подпись. Слежки у комплекта нет и не будет — исходящий запрос ровно один, про версию.
71
+ test("отчёт подписан командой и версией — иначе не узнать, работает ли канал", () => {
72
+ const text = reportText({ version: "0.15.0", node: "v22.0.0", platform: "linux" }).join("\n");
73
+ assert.match(text, /aqk feedback/, "нет метки источника");
74
+ assert.match(text, /0\.15\.0/, "нет версии");
75
+ });
76
+
77
+ // Незнание называется словом, а не пропускается: пустая строка в отчёте читается как «всё
78
+ // хорошо» — тот же порок, против которого написан весь комплект, только в нашем же письме.
79
+ test("чего не знаем — сказано словом, а не пустым местом", () => {
80
+ const text = reportText({ version: "0.15.0", node: "v22.0.0", platform: "linux", level: null }).join("\n");
81
+ assert.doesNotMatch(text, /уровень:\s*$/m, "уровень пропущен молча");
82
+ });
83
+
84
+ // --- ссылка ------------------------------------------------------------------
85
+
86
+ // Предзаполнение задачи параметрами `title` и `body` описано в документации GitHub (Creating an
87
+ // issue from a URL query, сверено 2026-09-14). Кодирование обязательно: в теле переносы строк,
88
+ // решётки и пробелы, и незакодированная ссылка обрывается на первом же из них.
89
+ test("ссылка предзаполнена и закодирована", () => {
90
+ const u = issueUrl("https://github.com/o/r", "Заголовок про AQK", "строка\nвторая #2");
91
+ assert.ok(u.startsWith("https://github.com/o/r/issues/new?"), `не та ссылка: ${u}`);
92
+ assert.match(u, /title=/);
93
+ assert.match(u, /body=/);
94
+ assert.doesNotMatch(u, /\n/, "перенос строки не закодирован — ссылка оборвётся");
95
+ assert.doesNotMatch(u, /#2/, "решётка не закодирована — всё после неё отвалится как якорь");
96
+ });
97
+
98
+ // --- как это звучит ----------------------------------------------------------
99
+ // Строку строит feedback.mjs, а печатают её `context` (агенту) и `doctor` (человеку): один
100
+ // текст на два места. Собери его в каждом месте отдельно — и через месяц они разойдутся, как
101
+ // разошлись бы `context` и `prompt` без общего `readAdvice`.
102
+ test("строка называет причину, имена и команду", () => {
103
+ const s = askLine({ reason: "cannot", names: ["smoke"] }, "npx agent-quality-kit");
104
+ assert.match(s, /smoke/, "не названо, что именно не смогли");
105
+ assert.match(s, /npx agent-quality-kit feedback/, "нет команды — просьбу нечем выполнить");
106
+ });
107
+
108
+ // АГЕНТУ — ОТДЕЛЬНАЯ ОГОВОРКА. Мы кладём строку в контекст ЧУЖОГО агента, и он исполнит то, что
109
+ // там написано. Без «не настаивай» это превращается в рекламу в чужом окне — и хук, которым она
110
+ // приехала, снесут в первый же день вместе со всей затеей.
111
+ test("агенту сказано не настаивать, человеку — как выключить", () => {
112
+ const forAgent = askLine({ reason: "red", names: ["lint"] }, "aqk", { agent: true });
113
+ const forHuman = askLine({ reason: "red", names: ["lint"] }, "aqk", { agent: false });
114
+ assert.notEqual(forAgent, forHuman, "агенту и человеку сказано одно и то же");
115
+ assert.match(forHuman, /AQK_FEEDBACK=0/, "человеку не сказано, как это выключить");
116
+ });
117
+
118
+ test("просить нечего — строки нет, а не пустая", () => {
119
+ assert.equal(askLine(null, "aqk"), null);
120
+ });
121
+
122
+ // У всего, что случается само, обязан быть выключатель — то же правило, что у совета
123
+ // (AQK_ADVICE=0), у пробы (AQK_PROBE=0) и у проверки версии (AQK_UPDATE=0).
124
+ test("выключатель уважается", () => {
125
+ assert.equal(feedbackWanted({ AQK_FEEDBACK: "0" }), false);
126
+ assert.equal(feedbackWanted({}), true);
127
+ });
128
+
129
+ // ПРОБА НЕ ДЕЛАЛАСЬ — «НЕИЗВЕСТНО», А НЕ «НЕТ». Поймано на первом же живом запуске команды:
130
+ // в письме стояло «классы, которые здесь не ловит никто: нет», хотя проба не запускалась ни
131
+ // разу. То есть наш собственный отчёт об инструменте против тишины сам выдавал незнание за
132
+ // чистоту — и автор, читающий такое письмо, сделал бы неверный вывод о чужом проекте.
133
+ test("проба не делалась — в отчёте «неизвестно», а не «нет»", () => {
134
+ const text = reportText({ version: "0.15.0", blind: null }).join("\n");
135
+ assert.doesNotMatch(text, /не ловит никто: нет/, "незнание выдано за чистоту");
136
+ assert.match(text, /не ловит никто: неизвестно/, "незнание не названо словом");
137
+ });
@@ -10,7 +10,7 @@
10
10
  import test from "node:test";
11
11
  import assert from "node:assert/strict";
12
12
  import { commandFor, verdict } from "../lib/prove.mjs";
13
- import { assessLevel, layoutChecks, unknownKeys, KNOWN_KEYS, parseManifest, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
13
+ import { assessLevel, layoutChecks, unknownKeys, KNOWN_KEYS, parseManifest, coversOf, coversUnproven, unparsedLines, gateRequires } from "../lib/manifest.mjs";
14
14
  import { pickLang, langFromText, langFromDocs } from "../i18n/index.mjs";
15
15
  import { progress, selectGates } from "../lib/run.mjs";
16
16
 
@@ -415,3 +415,43 @@ test("выбор гейтов: без флагов гоняется всё, ка
415
415
  assert.equal(s.run.length, 2);
416
416
  assert.deepEqual(s.skipped, []);
417
417
  });
418
+
419
+ // ЧЕМ ГЕЙТ РАБОТАЕТ — МОЖЕТ СКАЗАТЬ И САМ ПРОЕКТ, НЕ ТОЛЬКО ЗАПИСЬ КАТАЛОГА.
420
+ //
421
+ // Разбор чужой интеграции 2026-09-16: гейт объявлен как
422
+ // `docker run --rm … promtool test rules …`. `vitals` смотрит ПЕРВОЕ СЛОВО, находит `docker` и
423
+ // говорит «инструменты на месте». Поле `requires` для такого случая у нас уже есть — но
424
+ // читалось оно только из `<samples>/<гейт>/gate.yml`, то есть было доступно нашим записям и
425
+ // недоступно гейтам проекта. У проекта с чужими командами `samples` пуст по построению, и
426
+ // сказать «этому гейту нужен docker» было нечем.
427
+ //
428
+ // СНАРУЖИ СОГЛАШЕНИЯ НЕТ — проверено 2026-09-16. У pre-commit ровно эта просьба (issue #2042:
429
+ // трактовать `additional_dependencies` как список программ в $PATH и пропускать хук, если
430
+ // программы нет) закрыта нерешённой: `system`-хуки окружения не ставят. У lefthook такого поля
431
+ // нет вовсе. Поэтому мы не копируем чужую форму, а распространяем СВОЮ, уже существующую, —
432
+ // и это сказано вслух, а не выдано за общепринятое.
433
+ test("requires в манифесте проекта называет программу гейта", async () => {
434
+ const man = parseManifest('gates:\n rules: "docker run x"\nrequires:\n rules: docker\n');
435
+ assert.deepEqual(await gateRequires(man, "", "rules", () => false), ["docker"]);
436
+ assert.equal(await gateRequires(man, "", "rules", () => true), null, "программа на месте — жаловаться не на что");
437
+ });
438
+
439
+ test("requires принимает и список, и перечисление через запятую", async () => {
440
+ const list = parseManifest('gates:\n g: "x"\nrequires:\n g: [docker, jq]\n');
441
+ assert.deepEqual(await gateRequires(list, "", "g", () => false), ["docker", "jq"]);
442
+ const csv = parseManifest('gates:\n g: "x"\nrequires:\n g: docker, jq\n');
443
+ assert.deepEqual(await gateRequires(csv, "", "g", () => false), ["docker", "jq"]);
444
+ });
445
+
446
+ // Гейт, про который в манифесте ничего не сказано, остаётся как был: молчание — не требование.
447
+ test("без requires поведение прежнее", async () => {
448
+ const man = parseManifest('gates:\n g: "x"\n');
449
+ assert.equal(await gateRequires(man, "", "g", () => false), null);
450
+ assert.equal(await gateRequires(null, "", "g", () => false), null);
451
+ });
452
+
453
+ // Иначе `doctor` напечатает «неизвестное поле» на том, что сам же и читает.
454
+ test("requires — известное поле манифеста", () => {
455
+ assert.ok(KNOWN_KEYS.includes("requires"));
456
+ assert.deepEqual(unknownKeys({ requires: { g: "docker" } }), []);
457
+ });
@@ -327,3 +327,78 @@ test("свод в AGENTS.md виден Claude Code только через CLAUD
327
327
  assert.equal(claudeSeesRules({ ...base, claude: "x", claudeLink: true }), null, "ссылка на AGENTS.md — тот же файл");
328
328
  assert.equal(claudeSeesRules({ ...base, agents: false, dotClaude: true }), null, "AGENTS.md нет — подключать нечего");
329
329
  });
330
+
331
+ // --- проверка, которая у проекта есть и не может провалиться -----------------------
332
+ //
333
+ // САМЫЙ ЦЕННЫЙ ОТВЕТ В ПЕРВЫЕ ПЯТЬ СЕКУНД, и до 2026-09-14 мы его не давали. `doctor` читал
334
+ // ИМЯ скрипта («test», «lint») и печатал каноничную команду `npm test` с зелёной галочкой,
335
+ // ни разу не заглянув в ТЕЛО скрипта. А выключатель стоит именно там: `node --test || true`.
336
+ // То есть на репозитории, где выключено всё, мы говорили «у вас уже есть 2 проверки» —
337
+ // ровно та ошибка, ради которой написан весь комплект, в нашем собственном первом экране.
338
+ //
339
+ // ГРАНИЦА НАМЕРЕННО УЗКАЯ, и она взята у гейта `ci-actually-fails`: `|| true` в СЕРЕДИНЕ
340
+ // команды — это идемпотентность вспомогательного шага (`mkdir -p … || true`), а не выключенная
341
+ // проверка. Красным делается только гашение, под которое попадает ВЕСЬ исход: в конце тела
342
+ // либо флаг, у которого другого назначения нет.
343
+ test("выключатель в теле скрипта виден, а не прячется за каноничной командой", () => {
344
+ const pkg = (scripts) => ({ "package.json": JSON.stringify({ scripts }) });
345
+ const one = (scripts) => proposeGates(pkg(scripts))[0];
346
+
347
+ assert.equal(one({ test: "node --test" }).weak, undefined, "рабочая проверка не должна обвиняться");
348
+ assert.equal(one({ test: "node --test || true" }).weak?.kind, "off", "«|| true» в конце гасит весь исход");
349
+ assert.equal(one({ test: "node --test || :" }).weak?.kind, "off");
350
+ assert.equal(one({ test: "node --test || exit 0" }).weak?.kind, "off");
351
+ assert.equal(one({ lint: "ruff check . --exit-zero" }).weak?.kind, "zero", "флаг, у которого нет другого назначения");
352
+ assert.equal(one({ test: 'echo "no tests yet"' }).weak?.kind, "stub", "заглушка проходит всегда");
353
+ assert.equal(one({ test: 'echo "Error: no test specified" && exit 1' }).weak, undefined,
354
+ "заготовка npm провалиться МОЖЕТ — обвинять её нельзя");
355
+
356
+ // Ложные, на которых узость границы и проверяется.
357
+ assert.equal(one({ test: "mkdir -p tmp || true && node --test" }).weak, undefined,
358
+ "гашение вспомогательного шага в середине — идемпотентность, а не выключенная проверка");
359
+ assert.equal(one({ test: "node --test # было || true" }).weak, undefined, "упоминание в комментарии — не выключатель");
360
+
361
+ // Своя строка доезжает до человека целиком: без неё он не поверит и не найдёт, что чинить.
362
+ assert.match(one({ test: "node --test || true" }).weak.text, /\|\| true/);
363
+ });
364
+
365
+ // ОБЪЯВЛЕННЫЙ ГЕЙТ НЕ ДЕЛАЕТ ЧУЖИЕ ПРОВЕРКИ НЕВИДИМЫМИ.
366
+ //
367
+ // ЗАМЕР 2026-09-16 по двенадцати чужим репозиториям. У шести зрелых (requests, httpx, fastapi,
368
+ // black, express, flask) `doctor` без манифеста читал в их файлах от одной до трёх настоящих
369
+ // проверок. Стоило объявить ОДИН гейт — блок «у вас уже есть» исчезал у всех шести, и итог
370
+ // говорил «держит машина 0». То есть чем больше проект настроил, тем меньше мы о нём знали.
371
+ //
372
+ // Это ВТОРАЯ встреча с тем же классом: первый чужой пользователь 2026-09-08 назвал «применимо,
373
+ // но не поставлено: 5» неправдой, и ответом стало поле `covers`. Оно заполняется руками — и в
374
+ // зрелом проекте с двумя десятками записей его не заполнил никто. Лекарство, которое просит
375
+ // человека делать то, что умеет машина, не работает.
376
+ //
377
+ // Сравнение — ПО КОМАНДЕ, а не по имени: имена гейтов у проекта свои (`project-verify`), и по
378
+ // ним совпадения не будет никогда.
379
+ test("чужие проверки видны и тогда, когда гейты уже объявлены", () => {
380
+ const files = { Makefile: "test:\n\tpytest -q\n\nlint:\n\truff check .\n" };
381
+ const got = proposeGates(files, ["bash scripts/verify.sh"]);
382
+ assert.deepEqual(got.map((g) => g.name).sort(), ["lint", "test"],
383
+ "объявленный гейт с ДРУГОЙ командой скрыл чужие проверки");
384
+ });
385
+
386
+ test("уже объявленная проверка второй раз не предлагается", () => {
387
+ const files = { Makefile: "test:\n\tpytest -q\n\nlint:\n\truff check .\n" };
388
+ const got = proposeGates(files, ["make test"]);
389
+ assert.deepEqual(got.map((g) => g.name), ["lint"], "`make test` объявлен, а мы советуем его снова");
390
+ });
391
+
392
+ // Обёртка снимается: человек пишет `bash scripts/check`, мы нашли `scripts/check` — это одно и
393
+ // то же, и предлагать его второй раз значит советовать то, что уже стоит.
394
+ test("обёртка bash/sh не мешает узнать объявленную команду", () => {
395
+ const got = proposeGates({ "scripts/check": "" }, ["bash scripts/check"]);
396
+ assert.deepEqual(got, [], "`bash scripts/check` и `scripts/check` не узнаны как одна команда");
397
+ });
398
+
399
+ // Пустой список объявленных — прежнее поведение до последней запятой: ничего не фильтруем.
400
+ test("без объявленных гейтов список тот же, что и раньше", () => {
401
+ const files = { Makefile: "test:\n\tpytest -q\n" };
402
+ assert.deepEqual(proposeGates(files).map((g) => g.name), ["test"]);
403
+ assert.deepEqual(proposeGates(files, []).map((g) => g.name), ["test"]);
404
+ });
@@ -79,3 +79,30 @@ test("старая версия сообщается, но не роняет", (
79
79
  assert.match(v.detail, /0\.9\.0/);
80
80
  assert.equal(vitalsVerdict(rows), 0);
81
81
  });
82
+
83
+ // ЧУЖОЙ ХУК — НЕ НАША ОБВЯЗКА. ЧЕТВЁРТОЕ СОСТОЯНИЕ, И ОНО ЗАРАБОТАНО.
84
+ //
85
+ // Разбор интеграции в живом проекте 2026-09-16: vitals печатал «хук pre-commit прописан в
86
+ // .git/hooks», а AQK внутри этого хука не вызывался вовсе — там стоял диспетчер фреймворка
87
+ // pre-commit. Человек прочитал строку как «обвязка на месте» и ушёл; на деле ни один коммит в
88
+ // том репозитории AQK не запускал. Это ровно наш собственный класс «объявлено ≠ работает»,
89
+ // только у нас самих, — и цена ему та же, что мы называем чужим инструментам.
90
+ //
91
+ // Как это делают снаружи: pre-commit узнаёт свой хук функцией `is_our_script()` — ищет в теле
92
+ // файла собственный маркер (CURRENT_HASH и пять PRIOR_HASHES), а не имя. Имя чужого
93
+ // инструмента в теле хука не значит ничего: у них же есть режим миграции, в котором рядом
94
+ // живут оба хука сразу.
95
+ test("хук стоит, но AQK в нём не вызывается — это не «подключено»", () => {
96
+ const rows = vitalsRows({ ...ok, preCommit: "other" });
97
+ const hook = rows.find((r) => r.key === "preCommit");
98
+ assert.notEqual(hook.ok, true, "чужой хук засчитан как подключённый AQK");
99
+ assert.match(hook.detail, /AQK|aqk/, `в строке не сказано, чего именно нет: ${hook.detail}`);
100
+
101
+ // И это НЕ то же самое, что «хука нет вовсе»: в одном случае ставить нечего, в другом —
102
+ // дописать строку в уже стоящий хук. Совет разный, значит и строки разные.
103
+ const none = vitalsRows({ ...ok, preCommit: false }).find((r) => r.key === "preCommit");
104
+ assert.notEqual(hook.detail, none.detail, "«хук чужой» и «хука нет» неразличимы по выводу");
105
+
106
+ // Чужой хук — не поломка: человек мог сознательно гонять AQK в конвейере.
107
+ assert.equal(vitalsVerdict(rows), 0, "команда кричит «сломано» про чужой выбор");
108
+ });
@@ -1,27 +0,0 @@
1
- # Ссылки точки входа ведут на существующие файлы
2
-
3
- **Намерение.** Свод правил, который ссылается на несуществующий файл, утверждает то, чего нет.
4
-
5
- **Какой отказ это поймало.** В `audit_project` чек-лист гейтов четыре недели числил работающими
6
- изоляцию исполнителя и генерацию контрактов. Обе были отключены владельцем за три недели до
7
- этого — инструмент ломал файлы. Планирование опиралось на защиту, которой не существовало.
8
- Запись в журнале: `incidents/README.md`, 2026-08-24.
9
-
10
- **Почему машина, а не внимательность.** Расхождение появляется не в момент написания документа, а
11
- через недели, когда файл удалили в другой задаче. Человек в этот момент смотрит не сюда.
12
-
13
- **Готовый аналог есть и он сильнее.** `lychee` и `markdown-link-check` проверяют ещё и внешние
14
- адреса, и якоря внутри страницы. Рецепта под них в записи нет по устройству формата: рецепты
15
- выбираются **по языку проекта**, а эти инструменты к языку не привязаны. Если такой инструмент у
16
- вас стоит — он лучше нашего.
17
-
18
- **Адрес страницы сайта не считается битой ссылкой.** Путь, кончающийся косой чертой —
19
- `[руководство](tutorial/#install)`, — это адрес на опубликованном сайте, а не файл в
20
- репозитории. Так ссылаются mkdocs, docusaurus и jekyll. Найдено замером по `fastapi`: гейт
21
- объявлял битой рабочую ссылку из их README.
22
-
23
- **Чего НЕ ловит.** Только файлы `*.md` в самом каталоге, без обхода вложенных: намерение записи —
24
- точка входа, а не вся документация. Не проверяет внешние адреса (сеть) и якоря внутри файла.
25
-
26
- **Образцы.** `red/` — свод ссылается на `rules/nope.md`, которого нет: гейт обязан краснеть.
27
- `green/` — ссылается на существующий `rules/general.md`: гейт обязан молчать.
@@ -1,33 +0,0 @@
1
- #!/usr/bin/env sh
2
- # Каждая ссылка на локальный файл из markdown-файлов каталога обязана вести на
3
- # существующий файл. Ссылка в никуда — это документ, утверждающий защиту,
4
- # которой нет: планирование опирается на неё и ломается молча.
5
- DIR="${1:-.}"
6
- MISS=0
7
- for MD in "$DIR"/*.md; do
8
- [ -f "$MD" ] || continue
9
- # вытащить цели ссылок вида [текст](путь)
10
- # Строки со вставками в обратных кавычках чистятся ДО разбора: «`[name](url)`» — это пример
11
- # оформления ссылки, а не ссылка. Замер 2026-09-08: в uv/STYLE.md именно так и было, и гейт
12
- # требовал создать файл с именем «url».
13
- TARGETS=$(sed 's/`[^`]*`//g' "$MD" | sed -n 's/.*](\([^)]*\)).*/\1/p')
14
- for T in $TARGETS; do
15
- # автоссылки бывают обёрнуты как <(https://...)> — искать http где угодно внутри,
16
- # не только в начале строки.
17
- case "$T" in *http://*|*https://*|\#*|mailto:*) continue ;; esac
18
- T=${T%%#*}
19
- [ -z "$T" ] && continue
20
- # Путь, кончающийся косой чертой, — адрес страницы опубликованного сайта, а не файл на
21
- # диске. Так ссылаются mkdocs, docusaurus и jekyll: `[руководство](tutorial/#install)`
22
- # работает у читателя и не существует в репозитории. Найдено замером по fastapi — гейт
23
- # объявлял битой рабочую ссылку из их README. Проверять такие адреса умеют lychee и
24
- # markdown-link-check: они ходят в сеть, а мы смотрим только на диск.
25
- case "$T" in */) continue ;; esac
26
- if [ ! -e "$DIR/$T" ]; then
27
- echo "$MD: ссылка в никуда — $T"
28
- echo " почини: создай файл или убери ссылку. Документ, обещающий несуществующее, хуже отсутствующего."
29
- MISS=1
30
- fi
31
- done
32
- done
33
- exit $MISS
@@ -1,17 +0,0 @@
1
- # Запись каталога AQK. Норма и все поля — kit/gates/README.md.
2
- # Читается программой; всё, что нельзя выполнить, живёт в README.md рядом.
3
-
4
- intent: файлы, на которые ссылается точка входа, существуют на диске
5
- intent_en: files the entry point links to actually exist on disk
6
-
7
- # Когда запись показывается человеку. Отсутствие триггера сделало бы её шумом
8
- # для тех, кого она не касается.
9
- trigger:
10
- always: true
11
-
12
- # Команда-арбитр под каждый стек. {dir} — каталог, который проверяют.
13
- # `any` — команда без зависимостей, работает везде, где есть sh и grep.
14
- recipes:
15
- any: bash {gate}/check.sh {dir}
16
-
17
- proof: incidents/README.md — «2026-08-24 документ четыре недели утверждал защиту, которой не было»
@@ -1,10 +0,0 @@
1
- # Свод правил
2
-
3
- Стандарты: [общие правила](rules/general.md).
4
- Внешняя ссылка: [semver](https://semver.org).
5
- Автоссылка в скобках, как в CHANGELOG.md gin: [#1](<(https://example.com/pull/1)>).
6
- Ссылка на страницу сайта документации: [руководство](tutorial/#install) — путь опубликованного
7
- сайта, а не файл на диске. Найдено замером по fastapi: `[installation guide](tutorial/#install-fastapi)`
8
- в README читалась как битая, хотя на сайте работает. Так ссылаются mkdocs, docusaurus и jekyll.
9
-
10
- Пример оформления ссылки: `[name](url)` — это пример, а не ссылка.
@@ -1,3 +0,0 @@
1
- # Общие правила
2
-
3
- Пусто, но файл существует.
@@ -1,3 +0,0 @@
1
- # Свод правил
2
-
3
- Стандарты: [общие правила](rules/nope.md).