agent-quality-kit 0.2.2

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 (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -0
  3. package/kit/docs/ai/agent-harness-playbook.md +596 -0
  4. package/kit/docs/ai/ai-native-development.md +371 -0
  5. package/kit/docs/ai/ai-sdlc.md +221 -0
  6. package/kit/docs/ai/anthropic-ai-native-sdlc-2026-08.md +294 -0
  7. package/kit/docs/ai/app-owner-strategy.md +921 -0
  8. package/kit/docs/ai/deep-research-2026-07.md +161 -0
  9. package/kit/docs/ai/harness-best-practices.md +385 -0
  10. package/kit/docs/ai/index.md +64 -0
  11. package/kit/docs/ai/project-baseline.md +261 -0
  12. package/kit/docs/ai/quality-gates-checklist.md +322 -0
  13. package/kit/docs/ai/sources-building-with-agents.md +111 -0
  14. package/kit/docs/ai/stream-2026-08-ai-coding-panel.md +304 -0
  15. package/kit/docs/ready-made-rules.md +170 -0
  16. package/kit/gates/README.md +231 -0
  17. package/kit/gates/_skip.sh +75 -0
  18. package/kit/gates/commit-explains-itself/README.md +45 -0
  19. package/kit/gates/commit-explains-itself/check.sh +63 -0
  20. package/kit/gates/commit-explains-itself/gate.yml +10 -0
  21. package/kit/gates/commit-explains-itself/green/COMMIT_MSG +6 -0
  22. package/kit/gates/commit-explains-itself/red/COMMIT_MSG +3 -0
  23. package/kit/gates/complexity-limit/README.md +37 -0
  24. package/kit/gates/complexity-limit/check.sh +44 -0
  25. package/kit/gates/complexity-limit/gate.yml +13 -0
  26. package/kit/gates/complexity-limit/green/flat.py +10 -0
  27. package/kit/gates/complexity-limit/red/deep.py +9 -0
  28. package/kit/gates/dead-code/README.md +30 -0
  29. package/kit/gates/dead-code/gate.yml +23 -0
  30. package/kit/gates/dead-code/green/mod.py +9 -0
  31. package/kit/gates/dead-code/red/mod.py +9 -0
  32. package/kit/gates/deps-are-pinned/README.md +29 -0
  33. package/kit/gates/deps-are-pinned/check.sh +49 -0
  34. package/kit/gates/deps-are-pinned/gate.yml +9 -0
  35. package/kit/gates/deps-are-pinned/green/nodep-go/go.mod +3 -0
  36. package/kit/gates/deps-are-pinned/green/package-lock.json +3 -0
  37. package/kit/gates/deps-are-pinned/green/package.json +4 -0
  38. package/kit/gates/deps-are-pinned/green/requirements.txt +2 -0
  39. package/kit/gates/deps-are-pinned/red/package.json +4 -0
  40. package/kit/gates/deps-are-pinned/red/requirements.txt +2 -0
  41. package/kit/gates/deps-are-pinned/red/withdep-go/go.mod +5 -0
  42. package/kit/gates/duplicate-code/README.md +40 -0
  43. package/kit/gates/duplicate-code/check.sh +58 -0
  44. package/kit/gates/duplicate-code/gate.yml +12 -0
  45. package/kit/gates/duplicate-code/green/common.py +9 -0
  46. package/kit/gates/duplicate-code/green/use.py +9 -0
  47. package/kit/gates/duplicate-code/red/a.py +12 -0
  48. package/kit/gates/duplicate-code/red/b.py +12 -0
  49. package/kit/gates/entry-links-exist/README.md +22 -0
  50. package/kit/gates/entry-links-exist/check.sh +24 -0
  51. package/kit/gates/entry-links-exist/gate.yml +16 -0
  52. package/kit/gates/entry-links-exist/green/AGENTS.md +5 -0
  53. package/kit/gates/entry-links-exist/green/rules/general.md +3 -0
  54. package/kit/gates/entry-links-exist/red/AGENTS.md +3 -0
  55. package/kit/gates/file-size-limit/README.md +22 -0
  56. package/kit/gates/file-size-limit/check.sh +34 -0
  57. package/kit/gates/file-size-limit/gate.yml +9 -0
  58. package/kit/gates/file-size-limit/green/a.py +251 -0
  59. package/kit/gates/file-size-limit/green/b.py +251 -0
  60. package/kit/gates/file-size-limit/red/big.py +601 -0
  61. package/kit/gates/gate-has-samples/README.md +29 -0
  62. package/kit/gates/gate-has-samples/check.sh +48 -0
  63. package/kit/gates/gate-has-samples/gate.yml +9 -0
  64. package/kit/gates/gate-has-samples/green/.aqk.yml +10 -0
  65. package/kit/gates/gate-has-samples/green/gates/no-print-in-prod/check.sh +2 -0
  66. package/kit/gates/gate-has-samples/green/gates/no-print-in-prod/green/good.py +2 -0
  67. package/kit/gates/gate-has-samples/green/gates/no-print-in-prod/red/bad.py +1 -0
  68. package/kit/gates/gate-has-samples/red/.aqk.yml +10 -0
  69. package/kit/gates/gate-has-samples/red/gates/no-print-in-prod/check.sh +2 -0
  70. package/kit/gates/gates-are-runnable/README.md +23 -0
  71. package/kit/gates/gates-are-runnable/check.sh +35 -0
  72. package/kit/gates/gates-are-runnable/gate.yml +9 -0
  73. package/kit/gates/gates-are-runnable/green/.aqk.yml +9 -0
  74. package/kit/gates/gates-are-runnable/green/checks/lint.sh +2 -0
  75. package/kit/gates/gates-are-runnable/red/.aqk.yml +6 -0
  76. package/kit/gates/gates-run-in-ci/README.md +29 -0
  77. package/kit/gates/gates-run-in-ci/check.sh +42 -0
  78. package/kit/gates/gates-run-in-ci/gate.yml +12 -0
  79. package/kit/gates/gates-run-in-ci/green/.aqk.yml +6 -0
  80. package/kit/gates/gates-run-in-ci/green/.github/workflows/ci.yml +7 -0
  81. package/kit/gates/gates-run-in-ci/green/checks/lint.sh +2 -0
  82. package/kit/gates/gates-run-in-ci/red/.aqk.yml +6 -0
  83. package/kit/gates/gates-run-in-ci/red/.github/workflows/ci.yml +7 -0
  84. package/kit/gates/gates-run-in-ci/red/checks/lint.sh +2 -0
  85. package/kit/gates/lesson-has-outcome/README.md +37 -0
  86. package/kit/gates/lesson-has-outcome/check.sh +50 -0
  87. package/kit/gates/lesson-has-outcome/gate.yml +11 -0
  88. package/kit/gates/lesson-has-outcome/green/.aqk.yml +2 -0
  89. package/kit/gates/lesson-has-outcome/green/incidents/README.md +32 -0
  90. package/kit/gates/lesson-has-outcome/red/.aqk.yml +2 -0
  91. package/kit/gates/lesson-has-outcome/red/incidents/README.md +13 -0
  92. package/kit/gates/no-print-in-prod/README.md +44 -0
  93. package/kit/gates/no-print-in-prod/check.sh +36 -0
  94. package/kit/gates/no-print-in-prod/gate.yml +15 -0
  95. package/kit/gates/no-print-in-prod/green/docs.ts +15 -0
  96. package/kit/gates/no-print-in-prod/green/legacy.py +9 -0
  97. package/kit/gates/no-print-in-prod/green/main.go +8 -0
  98. package/kit/gates/no-print-in-prod/green/main.rs +4 -0
  99. package/kit/gates/no-print-in-prod/green/service.py +8 -0
  100. package/kit/gates/no-print-in-prod/red/main.go +8 -0
  101. package/kit/gates/no-print-in-prod/red/main.rs +4 -0
  102. package/kit/gates/no-print-in-prod/red/service.py +3 -0
  103. package/kit/gates/secrets-not-in-code/README.md +29 -0
  104. package/kit/gates/secrets-not-in-code/check.sh +18 -0
  105. package/kit/gates/secrets-not-in-code/gate.yml +9 -0
  106. package/kit/gates/secrets-not-in-code/green/settings.py +4 -0
  107. package/kit/gates/secrets-not-in-code/red/settings.py +2 -0
  108. package/kit/gates/swallowed-error/README.md +26 -0
  109. package/kit/gates/swallowed-error/check.sh +54 -0
  110. package/kit/gates/swallowed-error/gate.yml +12 -0
  111. package/kit/gates/swallowed-error/green/loader.py +11 -0
  112. package/kit/gates/swallowed-error/green/run.js +8 -0
  113. package/kit/gates/swallowed-error/red/loader.py +5 -0
  114. package/kit/gates/swallowed-error/red/run.js +3 -0
  115. package/kit/gates/todo-without-task/README.md +26 -0
  116. package/kit/gates/todo-without-task/check.sh +19 -0
  117. package/kit/gates/todo-without-task/gate.yml +12 -0
  118. package/kit/gates/todo-without-task/green/order.py +9 -0
  119. package/kit/gates/todo-without-task/red/order.py +8 -0
  120. package/kit/ratchet/ratchet.sh +62 -0
  121. package/kit/rules/general.md +55 -0
  122. package/kit/rules/security.md +33 -0
  123. package/kit/rules/testing.md +46 -0
  124. package/package.json +41 -0
  125. package/tool/commands/doctor.mjs +230 -0
  126. package/tool/commands/gates.mjs +445 -0
  127. package/tool/commands/project.mjs +316 -0
  128. package/tool/lib/core.mjs +98 -0
  129. package/tool/lib/manifest.mjs +140 -0
  130. package/tool/lib/repo.mjs +270 -0
  131. package/tool/lib/templates.mjs +187 -0
  132. package/tool/program.mjs +81 -0
  133. package/tool/selfcheck/conditional.sh +24 -0
  134. package/tool/selfcheck/gates.sh +127 -0
  135. package/tool/selfcheck/smoke.sh +539 -0
  136. package/tool/selfcheck/syntax.sh +23 -0
  137. package/tool/selfcheck/units.mjs +105 -0
@@ -0,0 +1,445 @@
1
+ // tool/commands/gates.mjs — работа с гейтами: поставить из каталога, завести свой, накинуть
2
+ // храповик, свериться по намерению.
3
+
4
+ import { mkdir, copyFile, writeFile, readFile, readdir } from "node:fs/promises";
5
+ import { join, dirname, relative, resolve } from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import {
8
+ CWD, PKG_ROOT, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB, MANIFEST, SELF, c, exists, die,
9
+ copyDir,
10
+ } from "../lib/core.mjs";
11
+ import { parseManifest, readManifest, manifestWithGate } from "../lib/manifest.mjs";
12
+ import {
13
+ detectFacts, readCatalog, pickRecipe, triggerVerdict, stems, overlap, matchCatalog,
14
+ } from "../lib/repo.mjs";
15
+ import { GATE_YML_TEMPLATE, CHECK_SH_TEMPLATE, README_TEMPLATE } from "../lib/templates.mjs";
16
+
17
+ // Ставит гейт из каталога в проект. Проверка КОПИРУЕТСЯ в репозиторий, а не остаётся
18
+ // ссылкой в пакет: при установке через npx пакет временный, и завтра команда в манифесте
19
+ // указывала бы в никуда — тот самый класс «гейт объявлен, но не запускается».
20
+
21
+ // Установка одной записи в проект: копия проверки, общий список исключений, строка в манифест.
22
+ // Отдельно от печати — той же работой пользуется `start`, ставящий сторожей дня 0 пачкой.
23
+ // Скопированная в третий раз, эта работа однажды разъехалась бы: копия гейта без _skip.sh
24
+ // читает окружение и выдаёт тысячу чужих нарушений.
25
+ async function installGate(slug, man, facts) {
26
+ const src = join(GATES_SRC, slug);
27
+ if (!(await exists(src))) die(`Нет такого гейта: ${slug}\nСписок применимых — ${SELF} doctor`);
28
+
29
+ const rec = { slug, ...parseManifest(await readFile(join(src, "gate.yml"), "utf8")) };
30
+ const dst = join(CWD, PROJECT_GATES, slug);
31
+ await mkdir(dst, { recursive: true });
32
+ const copied = await copyDir(src, dst, { force: false });
33
+
34
+ // Общий список исключений едет вместе с проверкой: без него она читает окружение и
35
+ // зависимости, и человек получает тысячу чужих нарушений вместо сотни своих.
36
+ const skipSrc = join(GATES_SRC, "_skip.sh");
37
+ if (await exists(skipSrc)) await copyFile(skipSrc, join(CWD, PROJECT_GATES, "_skip.sh"));
38
+
39
+ // Команда под стек проекта, с путями внутри репозитория, а не внутри пакета.
40
+ const cmd = String(pickRecipe(rec, facts) || "")
41
+ .replace(/\{gate\}/g, `${PROJECT_GATES}/${slug}`)
42
+ .replace(/\{dir\}/g, ".");
43
+ if (!cmd) die(`У записи ${slug} нет команды ни под ${[...facts.langs].join("/") || "этот стек"}, ни общей.`);
44
+
45
+ const manPath = join(CWD, MANIFEST);
46
+ const { text, why } = manifestWithGate(await readFile(manPath, "utf8"), slug, cmd);
47
+ if (text) await writeFile(manPath, text, "utf8");
48
+ return { rec, cmd, copied, declared: Boolean(text), why };
49
+ }
50
+
51
+ async function cmdAdd(args) {
52
+ const slug = args.find((a) => !a.startsWith("-"));
53
+ if (!slug) die(`Укажи имя гейта: ${SELF} add <имя>. Список — ${SELF} doctor`);
54
+
55
+ const man = await readManifest();
56
+ if (!man) die(`Нет .aqk.yml — сначала ${SELF} init`);
57
+ const facts = await detectFacts(man);
58
+
59
+ const src = join(GATES_SRC, slug);
60
+ if (!(await exists(src))) die(`Нет такого гейта: ${slug}\nСписок применимых — ${SELF} doctor`);
61
+ const probe = { slug, ...parseManifest(await readFile(join(src, "gate.yml"), "utf8")) };
62
+ const verdict = triggerVerdict(probe, facts);
63
+ if (!verdict.applies) {
64
+ console.log(c.yellow(`\n Этот гейт к репозиторию не применим: ${verdict.why}`));
65
+ console.log(c.dim(" Ставлю всё равно — решение твоё, но сторожить ему нечего.\n"));
66
+ }
67
+
68
+ const { cmd, copied, declared, why } = await installGate(slug, man, facts);
69
+
70
+ console.log(c.bold(`\naqk add ${slug}\n`));
71
+ console.log(` ${c.green("✔")} ${PROJECT_GATES}/${slug}/ ${c.dim(`${copied.length} файлов: проверка и образцы`)}`);
72
+ if (declared) {
73
+ console.log(` ${c.green("✔")} .aqk.yml ${c.dim(`гейт объявлен: ${cmd}`)}`);
74
+ } else {
75
+ console.log(` ${c.yellow("!")} .aqk.yml ${c.dim(`не тронут (${why}). Впиши сам: ${slug}: "${cmd}"`)}`);
76
+ }
77
+
78
+ console.log(`
79
+ ${c.bold("Дальше:")}
80
+
81
+ 1. Проверь, что он краснеет и молчит там, где должен:
82
+ ${c.bold(`${cmd.replace(/ \.$/, ` ${PROJECT_GATES}/${slug}/red`)}`)} ${c.dim("→ ожидается отказ")}
83
+ ${c.bold(`${cmd.replace(/ \.$/, ` ${PROJECT_GATES}/${slug}/green`)}`)} ${c.dim("→ ожидается тишина")}
84
+ 2. Впиши команду в хук коммита и в конвейер. ${c.dim("Гейт, который никто не запускает, — не гейт.")}
85
+ 3. Прогон всех объявленных: ${c.bold(`${SELF} doctor --run`)}
86
+ `);
87
+ }
88
+
89
+ // Заготовка записи каталога. Поля намеренно оставлены незаполненными и в таком виде
90
+ // НЕ ПРОХОДЯТ проверку: пустая заготовка, принятая как запись, — это тот же мёртвый гейт.
91
+ // Сначала сверка по намерению: чаще всего нужного гейта не хватает не в каталоге, а в проекте.
92
+
93
+ async function cmdNew(args) {
94
+ const slug = args.find((a) => !a.startsWith("-"));
95
+ if (!slug) die(`Укажи имя: ${SELF} new no-print-in-prod`);
96
+ if (!/^[a-z][a-z0-9-]{2,}$/.test(slug)) {
97
+ die(`Имя «${slug}» не годится: латиница через дефис, например secrets-not-in-code.\nИмя читают в чужих проектах — оно часть словаря.`);
98
+ }
99
+
100
+ // Сначала сверка: новая запись нужна реже, чем кажется. Порог берётся по совпадению с
101
+ // намерением, а не с пояснением: пояснение у всех записей похоже.
102
+ const words = slug.replace(/-/g, " ") + " " + args.filter((a) => !a.startsWith("-")).slice(1).join(" ");
103
+ for (const { rec, hits, headScore } of await matchCatalog(words)) {
104
+ if (hits >= 2 && headScore >= 0.5 && !args.includes("--force")) {
105
+ console.log(c.yellow(`\n Похоже, такое уже есть: ${c.bold(rec.slug)}`));
106
+ console.log(` ${rec.intent || ""}\n`);
107
+ console.log(c.dim(" Рецепт под другой стек — это строка в recipes существующей записи."));
108
+ console.log(c.dim(` Всё равно завести новую: ${SELF} new ${slug} --force\n`));
109
+ process.exit(1);
110
+ }
111
+ }
112
+
113
+ // Где заводить заготовку. Проверка «существует ли каталог комплекта» была неверной: он
114
+ // существует всегда — это каталог самого пакета. Из чужого проекта заготовка уезжала внутрь
115
+ // пакета, а через npx пакет лежит во временной папке и исчезает вместе с ней: работа сделана,
116
+ // результата нет. Признак один — работаем ли мы над самим комплектом.
117
+ const inKit = resolve(CWD) === resolve(PKG_ROOT);
118
+ const dst = inKit ? join(GATES_SRC, slug) : join(CWD, PROJECT_GATES, slug);
119
+ if (await exists(dst)) die(`${relative(CWD, dst)} уже существует.`);
120
+
121
+ await mkdir(join(dst, "red"), { recursive: true });
122
+ await mkdir(join(dst, "green"), { recursive: true });
123
+ await writeFile(join(dst, "gate.yml"), GATE_YML_TEMPLATE(slug), "utf8");
124
+ await writeFile(join(dst, "check.sh"), CHECK_SH_TEMPLATE, "utf8");
125
+ await writeFile(join(dst, "README.md"), README_TEMPLATE(slug), "utf8");
126
+ await writeFile(join(dst, "red", ".keep"), "", "utf8");
127
+ await writeFile(join(dst, "green", ".keep"), "", "utf8");
128
+
129
+ // check.sh шаблона зовёт skip_grep/own_samples_filter из _skip.sh — тем же путём, каким его
130
+ // зовут установленные записи каталога. В самом комплекте оригинал уже лежит на месте (../),
131
+ // в чужом проекте его никто не клал, пока не было ни одной установленной записи через `add`.
132
+ if (!inKit) {
133
+ const skipSrc = join(GATES_SRC, "_skip.sh");
134
+ if (await exists(skipSrc)) await copyFile(skipSrc, join(CWD, PROJECT_GATES, "_skip.sh"));
135
+ }
136
+
137
+ console.log(c.bold(`\naqk new ${slug}\n`));
138
+ console.log(` ${c.green("✔")} ${relative(CWD, dst)}/ ${c.dim("gate.yml · check.sh · red/ · green/ · README.md")}`);
139
+ console.log(`
140
+ ${c.bold("Дальше — по порядку:")}
141
+
142
+ 1. ${c.bold("Проверь, нет ли готового правила")} в ruff, eslint, semgrep.
143
+ ${c.dim("Готовое точнее, подробнее и его поддерживают без тебя. Своя проверка — запасная.")}
144
+ 2. ${c.bold("Положи образцы.")} В ${c.bold("red/")} — код, на котором проверка обязана сработать.
145
+ В ${c.bold("green/")} — тот же код, но правильный.
146
+ ${c.dim("Зелёный важнее: он ловит проверку, которая краснеет на исправном коде.")}
147
+ 3. ${c.bold("Напиши проверку")} в check.sh. В тексте отказа — что именно сделать.
148
+ 4. ${c.bold("Заполни gate.yml:")} намерение, триггер, доказательство отказом.
149
+ 5. ${c.bold("Прогони:")} bash tool/selfcheck/gates.sh
150
+ ${c.dim("Арбитр обязан покраснеть на red/ и промолчать на green/. Не прошло — не запись.")}
151
+ `);
152
+ }
153
+
154
+ // Ставит храповик поверх уже объявленного гейта: снимает список текущих нарушений в реестр
155
+ // и заворачивает команду в обёртку, которая пускает старое и не пускает новое.
156
+ //
157
+ // Без этого правило нельзя ввести в живой проект: гейт покраснеет на всём старом коде,
158
+ // его выключат, и правило не будет действовать вовсе.
159
+
160
+ async function cmdRatchet(args) {
161
+ const slug = args.find((a) => !a.startsWith("-"));
162
+ if (!slug) die(`Укажи гейт: ${SELF} ratchet <имя>. Он должен быть уже объявлен в .aqk.yml`);
163
+
164
+ const manPath = join(CWD, MANIFEST);
165
+ if (!(await exists(manPath))) die(`Нет .aqk.yml — сначала ${SELF} init`);
166
+ let text = await readFile(manPath, "utf8");
167
+
168
+ const line = text.split("\n").find((l) => new RegExp(`^\\s+${slug}:`).test(l));
169
+ if (!line) die(`Гейт «${slug}» не объявлен в .aqk.yml. Сначала: ${SELF} add ${slug}`);
170
+
171
+ const cmd = line.replace(/^\s*[^:]+:\s*/, "").replace(/^"|"$/g, "");
172
+ const inKit = resolve(CWD) === resolve(PKG_ROOT);
173
+ const lib = inKit ? "kit/ratchet/ratchet.sh" : RATCHET_LIB;
174
+
175
+ // «Обёртка объявлена» и «долг снят» — разные состояния. Если реестра на диске нет, гейт
176
+ // краснеет на всём подряд, а команда отказывалась помочь словами «храповик уже стоит».
177
+ // Тогда снимаем снимок заново по внутренней команде, а строку манифеста не трогаем.
178
+ const reg = join(CWD, RATCHET_DIR, `${slug}.txt`);
179
+ const wrapped0 = cmd.includes("ratchet.sh");
180
+ if (wrapped0 && (await exists(reg))) die(`На гейте «${slug}» храповик уже стоит.`);
181
+ const prefix = `bash ${lib} ${RATCHET_DIR}/${slug}.txt `;
182
+ const inner = wrapped0 && cmd.startsWith(prefix) ? cmd.slice(prefix.length) : cmd;
183
+
184
+ // Обёртка копируется в репозиторий: ссылка на пакет завтра указывала бы в никуда. Исключение —
185
+ // сам комплект: здесь оригинал уже лежит рядом, и копия завтра разошлась бы с ним. Ровно то
186
+ // правило, по которому здесь не копируются и гейты.
187
+ if (!inKit) {
188
+ await mkdir(dirname(join(CWD, lib)), { recursive: true });
189
+ await copyFile(join(PKG_ROOT, "kit", "ratchet", "ratchet.sh"), join(CWD, lib));
190
+ }
191
+
192
+ // Снимок текущих нарушений — это и есть долг. Ключ без номера строки: правка соседней
193
+ // строки не должна читаться как новое нарушение.
194
+ const r = spawnSync(inner, { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
195
+ if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {
196
+ die(
197
+ `Гейт «${slug}» не запускается: ${inner}\n` +
198
+ `Снимать долг с несуществующего сторожа нельзя — в реестр попадут его же сообщения\n` +
199
+ `об ошибке, и он станет разрешением. Сначала почини команду.`
200
+ );
201
+ }
202
+ const keys = [...new Set(
203
+ `${r.stdout || ""}${r.stderr || ""}`
204
+ .split("\n")
205
+ .filter((l) => l && !/^\s/.test(l) && l.includes(":"))
206
+ .map((l) => l.replace(/:\d+:/, ":"))
207
+ )].sort();
208
+
209
+ await mkdir(join(CWD, RATCHET_DIR), { recursive: true });
210
+ const stamp = new Date().toISOString().slice(0, 10);
211
+ await writeFile(
212
+ reg,
213
+ `# Реестр долга: ${slug}\n` +
214
+ `# Снят ${stamp}. Список разрешается ТОЛЬКО укорачивать.\n` +
215
+ `# Новое нарушение красит гейт; исправленное вычёркивается автоматически.\n` +
216
+ keys.join("\n") + (keys.length ? "\n" : ""),
217
+ "utf8"
218
+ );
219
+
220
+ if (!wrapped0) {
221
+ text = text.replace(line, ` ${slug}: "${prefix}${cmd}"`);
222
+ text = text.replace(/^ratchets:\s*""\s*$/m, `ratchets: ${RATCHET_DIR}`);
223
+ await writeFile(manPath, text, "utf8");
224
+ }
225
+
226
+ console.log(c.bold(`\naqk ratchet ${slug}\n`));
227
+ console.log(` ${c.green("✔")} ${RATCHET_DIR}/${slug}.txt ${c.dim(`${keys.length} нарушений записано долгом`)}`);
228
+ if (!inKit) console.log(` ${c.green("✔")} ${lib} ${c.dim("обёртка скопирована в проект")}`);
229
+ console.log(` ${c.green("✔")} .aqk.yml ${c.dim("команда завёрнута в храповик")}`);
230
+ console.log(`
231
+ ${c.bold("Что это меняет:")}
232
+
233
+ Правило действует ${c.bold("со дня установки")}. Старый код трогать не надо, но новое
234
+ нарушение того же класса гейт не пропустит.
235
+
236
+ ${c.dim("Проверка, что это храповик, а не советчик: «может ли новый код добавить нарушение")}
237
+ ${c.dim("и пройти?» Может — значит гейта нет.")}
238
+
239
+ Прогнать: ${c.bold(`${SELF} doctor --run`)}
240
+ `);
241
+ }
242
+
243
+ // «Есть ли у вас уже такое?» — вопрос, без которого обмен знанием превращается в свалку.
244
+ // Сверка идёт ПО НАМЕРЕНИЮ, а не по тексту команды: «печать не доезжает до прода» — одно
245
+ // намерение, а ruff, eslint и свой поиск — три исполнителя. Принёс рецепт под новый язык —
246
+ // это строка в существующей записи, а не новая запись.
247
+ //
248
+ // Сравниваем огрублённо: русский язык склоняется, и «печать / печати / печатью» обязаны
249
+ // совпасть. Берём начало слова — грубо, зато без словарей и без единой зависимости.
250
+
251
+ async function cmdFind(args) {
252
+ const query = args.filter((a) => !a.startsWith("-")).join(" ").trim();
253
+ if (!query) die(`Опиши намерение словами: ${SELF} find "отладочная печать не доезжает до прода"`);
254
+
255
+ const q = stems(query);
256
+ const scored = (await matchCatalog(query)).map((m) => [m.score, m.rec]);
257
+
258
+ // шишки в журнале: записана, но гейта из неё может не быть
259
+ const journal = [];
260
+ const jPath = join(PKG_ROOT, "incidents", "README.md");
261
+ if (await exists(jPath)) {
262
+ const text = await readFile(jPath, "utf8");
263
+ for (const m of text.matchAll(/^## (20\d\d-\d\d-\d\d)\s+—\s+(.+)$/gm)) {
264
+ const score = overlap(q, stems(m[2]));
265
+ if (score >= 0.34) journal.push([score, m[1], m[2].trim()]);
266
+ }
267
+ journal.sort((a, b) => b[0] - a[0]);
268
+ }
269
+
270
+ console.log(c.bold(`\naqk find «${query}»\n`));
271
+
272
+ const same = scored.filter(([sc]) => sc >= 0.6);
273
+ const near = scored.filter(([sc]) => sc >= 0.3 && sc < 0.6);
274
+
275
+ if (same.length) {
276
+ console.log(c.green(" Такое уже есть — новую запись заводить не надо:\n"));
277
+ for (const [sc, rec] of same.slice(0, 3)) {
278
+ console.log(` ${c.bold(rec.slug)} ${c.dim(`совпадение ${Math.round(sc * 100)}%`)}`);
279
+ console.log(` ${rec.intent || ""}`);
280
+ const langs = Object.keys(rec.recipes || {}).filter((k) => k !== "any");
281
+ console.log(c.dim(` рецепты: ${langs.length ? langs.join(", ") + ", " : ""}общий`));
282
+ }
283
+ console.log(c.dim("\n Если у тебя рецепт под другой стек — это строка в recipes существующей"));
284
+ console.log(c.dim(" записи, а не новый гейт. Намерение одно, исполнителей может быть много.\n"));
285
+ } else if (near.length) {
286
+ console.log(c.yellow(" Точного совпадения нет, но рядом лежит:\n"));
287
+ for (const [sc, rec] of near.slice(0, 4)) {
288
+ console.log(` ${c.bold(rec.slug)} ${c.dim(`${Math.round(sc * 100)}%`)} ${rec.intent || ""}`);
289
+ }
290
+ console.log(c.dim("\n Прочитай их README. Если намерение то же — дополняй, а не заводи новое.\n"));
291
+ } else {
292
+ console.log(c.yellow(" Такого намерения в каталоге нет.\n"));
293
+ }
294
+
295
+ if (journal.length) {
296
+ console.log(c.bold(" В журнале есть шишка на эту тему:\n"));
297
+ for (const [, date, title] of journal.slice(0, 3)) console.log(` ${c.dim(date)} ${title}`);
298
+ console.log(c.dim("\n Шишка записана — значит доказательство для новой записи уже есть.\n"));
299
+ }
300
+
301
+ if (!same.length) {
302
+ console.log(`${c.bold("Как добавить свой гейт:")}
303
+
304
+ 1. ${c.bold("Назови отказ.")} Какой конкретный брак он поймал в живом проекте, чего это стоило.
305
+ ${c.dim("«Это хорошая практика» не принимается: так каталог набирает сотни пунктов и умирает.")}
306
+ 2. ${c.bold("Заведи папку")} kit/gates/<имя>/ — gate.yml, red/, green/, README.md.
307
+ ${c.dim("Норма записи со всеми полями — kit/gates/README.md")}
308
+ 3. ${c.bold("Проверь машиной:")} bash tool/selfcheck/gates.sh
309
+ ${c.dim("Арбитр обязан покраснеть на red/ и промолчать на green/. Не прошло — не запись.")}
310
+ 4. ${c.bold("Пришли изменением")} в репозиторий комплекта.
311
+ `);
312
+ }
313
+ }
314
+
315
+
316
+ // --- why: почему это не поймали ----------------------------------------------
317
+ // Сценарий «поймал ошибку». Ответ ровно один из трёх, и выбирает его не человек по памяти,
318
+ // а прогон: сторожа не было · сторож есть, но не сработал · сторож есть и ловит, значит его
319
+ // обошли. Разница между вторым и третьим решает, что чинить: саму проверку или её место в
320
+ // конвейере. Без прогона эти два случая неразличимы, и чинят обычно не тот.
321
+
322
+ // Гоняет ли конвейер именно этот гейт. Прогон всего разом (`doctor --run`) считается: тогда
323
+ // добавление гейта в манифест само добавляет его в конвейер.
324
+ async function runsInCi(slug, cmd) {
325
+ const files = [];
326
+ const walk = async (d) => {
327
+ if (!(await exists(d))) return;
328
+ for (const it of await readdir(d, { withFileTypes: true })) {
329
+ const full = join(d, it.name);
330
+ if (it.isDirectory()) await walk(full);
331
+ else files.push(full);
332
+ }
333
+ };
334
+ await walk(join(CWD, ".github", "workflows"));
335
+ for (const f of [".gitlab-ci.yml", "Jenkinsfile", "azure-pipelines.yml"]) {
336
+ if (await exists(join(CWD, f))) files.push(join(CWD, f));
337
+ }
338
+ if (!files.length) return { ci: false, runs: false };
339
+
340
+ const script = (cmd.match(/[\w./-]+\.(?:sh|mjs|js|py)/) || [])[0];
341
+ for (const f of files) {
342
+ const text = await readFile(f, "utf8");
343
+ if (/doctor\s+--run|--run\s+.*doctor/.test(text)) return { ci: true, runs: true, how: "разом: doctor --run" };
344
+ if (text.includes(slug) || (script && text.includes(script))) return { ci: true, runs: true, how: "отдельным шагом" };
345
+ }
346
+ return { ci: true, runs: false };
347
+ }
348
+
349
+ async function cmdWhy(args) {
350
+ const query = args.filter((a) => !a.startsWith("-")).join(" ").trim();
351
+ if (!query) die(`Опиши, что пропустили: ${SELF} why "файл вырос до девяти тысяч строк"`);
352
+
353
+ const man = await readManifest();
354
+ const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
355
+ const matches = await matchCatalog(query);
356
+
357
+ // Имя записи, названное прямо, отменяет любую догадку. Слова — удобство, имя — точность.
358
+ const byName = matches.find((m) => m.rec.slug === query.trim());
359
+ const best = byName || matches[0];
360
+
361
+ console.log(c.bold(`\naqk why «${query}»\n`));
362
+
363
+ // Сверка огрублённая: «вырос» и «вырастает» — разные корни, их не сведёт никакой стеммер.
364
+ // Поэтому при неуверенном совпадении команда НЕ выбирает за человека: неверно названный
365
+ // случай отправляет чинить не то, а это дороже, чем лишний вопрос.
366
+ const near = matches.filter((x) => x.rawScore >= 0.18).sort((a, b) => b.rawScore - a.rawScore);
367
+ if (!byName && (!best || best.score < 0.5) && near.length) {
368
+ console.log(c.yellow(" Уверенного совпадения нет. Похоже на эти записи:\n"));
369
+ for (const m of near.slice(0, 3)) {
370
+ console.log(` ${c.bold(m.rec.slug)} ${c.dim(`${Math.round(m.rawScore * 100)}%`)} ${m.rec.intent || ""}`);
371
+ }
372
+ console.log(c.dim(`\n Назови запись именем: ${SELF} why <имя>`));
373
+ console.log(c.dim(` Ни одна не подходит — значит сторожа не было: ${SELF} new <имя>\n`));
374
+ return;
375
+ }
376
+
377
+ const decide = () => {
378
+ console.log(`${c.bold("Дальше решаешь ты:")} это твоя частность или общий случай?`);
379
+ console.log(c.dim(" Общий — идёт в каталог и достаётся всем. Частный — остаётся у тебя."));
380
+ console.log(c.dim(` Урок в общий журнал в любом случае: ${SELF} note "что случилось"\n`));
381
+ };
382
+
383
+ // --- 1. сторожа не было ----------------------------------------------------
384
+ if (!best || best.score < 0.25) {
385
+ console.log(c.yellow(" Сторожа не было.") + c.dim(" В каталоге нет записи с таким намерением.\n"));
386
+ console.log(` ${c.bold("Почини так:")} заведи запись — ${c.bold(`${SELF} new <имя>`)}`);
387
+ console.log(c.dim(" Красный образец бери прямо из этой поломки: она уже случилась, выдумывать нечего.\n"));
388
+ decide();
389
+ return;
390
+ }
391
+
392
+ const slug = best.rec.slug;
393
+ console.log(` Ближайшая запись каталога: ${c.bold(slug)} ${c.dim(`совпадение ${Math.round(best.score * 100)}%`)}`);
394
+ console.log(` ${c.dim(best.rec.intent || "")}\n`);
395
+
396
+ // --- 2. запись есть, но в проекте не объявлена -----------------------------
397
+ const cmd = gates[slug];
398
+ if (!cmd || !String(cmd).trim()) {
399
+ console.log(c.yellow(" Сторож есть в каталоге, но в этом проекте не поставлен.\n"));
400
+ console.log(` ${c.bold("Почини так:")} ${c.bold(`${SELF} add ${slug}`)}`);
401
+ console.log(c.dim(" Он покраснеет на старом коде — это нормально: старое закрывается храповиком,"));
402
+ console.log(c.dim(` новое ловится со дня установки. ${SELF} ratchet ${slug}\n`));
403
+ decide();
404
+ return;
405
+ }
406
+
407
+ // --- 3. объявлен: спрашиваем у него самого ---------------------------------
408
+ console.log(c.dim(` Объявлен: ${cmd}`));
409
+ const r = spawnSync(String(cmd), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
410
+ const ci = await runsInCi(slug, String(cmd));
411
+
412
+ if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {
413
+ console.log(c.yellow("\n Сторож объявлен, но не запускается.") + c.dim(" Худший случай: тишина читается как успех.\n"));
414
+ console.log(` ${c.bold("Почини так:")} путь или программа из команды не существуют — проверь их.`);
415
+ console.log(c.dim(" Отсутствие сигнала неотличимо от успеха, поэтому это не «мелочь в конфиге».\n"));
416
+ decide();
417
+ return;
418
+ }
419
+
420
+ if (r.status !== 0) {
421
+ console.log(c.yellow("\n Сторож есть и эту поломку ловит — значит его обошли.\n"));
422
+ if (!ci.ci) {
423
+ console.log(` ${c.bold("Почини так:")} конвейера нет. Проверка, которую гоняет только человек,`);
424
+ console.log(c.dim(" работает ровно до первого «забыл».\n"));
425
+ } else if (!ci.runs) {
426
+ console.log(` ${c.bold("Почини так:")} конвейер есть, но этот гейт в нём не запускается.`);
427
+ console.log(c.dim(` Дешевле всего одним шагом: ${SELF} doctor --run — он гоняет всё объявленное.\n`));
428
+ } else {
429
+ console.log(` ${c.bold("Почини так:")} конвейер его гоняет (${ci.how}) — значит красный прогон`);
430
+ console.log(c.dim(" кто-то пропустил или обошёл. Перенеси правило из текста в механику:"));
431
+ console.log(c.dim(" блокирующий шаг, а не необязательный; запрет слияния при красном.\n"));
432
+ }
433
+ decide();
434
+ return;
435
+ }
436
+
437
+ console.log(c.yellow("\n Сторож есть, стоит и запускается — но этой поломки не видит.\n"));
438
+ console.log(` ${c.bold("Почини так:")} положи в ${c.bold(`${slug}/red/`)} кусок кода из этой поломки`);
439
+ console.log(c.dim(" и доведи проверку до красного на нём. Порядок обратный привычному: сначала"));
440
+ console.log(c.dim(" образец, потом правка — иначе непонятно, что именно починено.\n"));
441
+ console.log(c.dim(` Проверить после правки: bash tool/selfcheck/gates.sh\n`));
442
+ decide();
443
+ }
444
+
445
+ export { cmdAdd, cmdNew, cmdRatchet, cmdFind, cmdWhy, installGate };