agent-quality-kit 0.13.0 → 0.15.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 (57) hide show
  1. package/README.md +46 -8
  2. package/README.ru.md +49 -9
  3. package/kit/gates/api-contract-has-arbiter/README.md +16 -1
  4. package/kit/gates/api-contract-has-arbiter/check.sh +66 -23
  5. package/kit/gates/entry-commands-exist/README.md +64 -0
  6. package/kit/gates/entry-commands-exist/check.sh +110 -0
  7. package/kit/gates/entry-commands-exist/gate.yml +19 -0
  8. package/kit/gates/entry-commands-exist/green/AGENTS.md +13 -0
  9. package/kit/gates/entry-commands-exist/green/Makefile +6 -0
  10. package/kit/gates/entry-commands-exist/green/justfile +2 -0
  11. package/kit/gates/entry-commands-exist/green/package.json +10 -0
  12. package/kit/gates/entry-commands-exist/red/AGENTS.md +9 -0
  13. package/kit/gates/entry-commands-exist/red/Makefile +2 -0
  14. package/kit/gates/entry-commands-exist/red/package.json +9 -0
  15. package/llms.txt +5 -1
  16. package/package.json +1 -1
  17. package/tool/commands/context.mjs +59 -44
  18. package/tool/commands/doctor-catalog.mjs +222 -0
  19. package/tool/commands/doctor.mjs +46 -237
  20. package/tool/commands/feedback.mjs +157 -0
  21. package/tool/commands/learn.mjs +119 -19
  22. package/tool/commands/probe.mjs +4 -2
  23. package/tool/commands/project.mjs +11 -13
  24. package/tool/commands/prompt.mjs +70 -0
  25. package/tool/i18n/en-docs.mjs +1 -0
  26. package/tool/i18n/en-gates.mjs +27 -0
  27. package/tool/i18n/en.mjs +70 -3
  28. package/tool/i18n/index.mjs +42 -3
  29. package/tool/i18n/ru-docs.mjs +1 -0
  30. package/tool/i18n/ru-gates.mjs +28 -0
  31. package/tool/i18n/ru.mjs +81 -3
  32. package/tool/lib/annotate.mjs +66 -0
  33. package/tool/lib/ask.mjs +118 -0
  34. package/tool/lib/brief.mjs +17 -38
  35. package/tool/lib/cadence.mjs +30 -1
  36. package/tool/lib/core.mjs +8 -6
  37. package/tool/lib/gate-worker.mjs +4 -1
  38. package/tool/lib/repo.mjs +45 -4
  39. package/tool/lib/run.mjs +140 -9
  40. package/tool/program.mjs +10 -0
  41. package/tool/selfcheck/smoke/_fixture.mjs +8 -3
  42. package/tool/selfcheck/smoke/api-contract.test.mjs +37 -0
  43. package/tool/selfcheck/smoke/corpus.test.mjs +151 -0
  44. package/tool/selfcheck/smoke/fail-closed.test.mjs +96 -1
  45. package/tool/selfcheck/smoke/first-run.test.mjs +39 -0
  46. package/tool/selfcheck/smoke/verdict.test.mjs +41 -2
  47. package/tool/selfcheck/smoke.sh +4 -0
  48. package/tool/selfcheck/units-annotate.mjs +67 -0
  49. package/tool/selfcheck/units-ask.mjs +85 -0
  50. package/tool/selfcheck/units-brief.mjs +3 -13
  51. package/tool/selfcheck/units-cadence.mjs +26 -1
  52. package/tool/selfcheck/units-context.mjs +2 -1
  53. package/tool/selfcheck/units-feedback.mjs +137 -0
  54. package/tool/selfcheck/units-learn.mjs +32 -0
  55. package/tool/selfcheck/units-level.mjs +21 -1
  56. package/tool/selfcheck/units-prompt.mjs +106 -0
  57. package/tool/selfcheck/units-repo.mjs +20 -1
package/tool/lib/repo.mjs CHANGED
@@ -2,9 +2,8 @@
2
2
  // триггера, выбор рецепта, сверка по намерению.
3
3
 
4
4
  import { readdir, readFile } from "node:fs/promises";
5
- import { existsSync, statSync } from "node:fs";
5
+ import { existsSync, statSync, lstatSync, realpathSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
- import { spawnSync } from "node:child_process";
8
7
  import { CWD, GATES_SRC, c, exists } from "./core.mjs";
9
8
  import { parseManifest } from "./manifest.mjs";
10
9
  import { L, LANG } from "../i18n/index.mjs";
@@ -34,6 +33,18 @@ function isApiSpec(name) {
34
33
  return /^(openapi|swagger|asyncapi)[^/]*\.(ya?ml|json)$/i.test(name);
35
34
  }
36
35
 
36
+ // Тот же договор без файла: схемы в коде, типы общие у сервера и клиента. Список — тот же, что
37
+ // в `api-contract-has-arbiter/check.sh`; разойдутся — запись покажут не тому проекту, и это
38
+ // сторожит проверка прогона. Один `zod` договором не считается: им разбирают и формы.
39
+ const CODE_CONTRACT = /"(@trpc\/server|@ts-rest\/core|@hono\/zod-openapi|@fastify\/type-provider-[a-z0-9-]+|fastify-type-provider-zod)"\s*:/;
40
+ async function isCodeContract(path) {
41
+ try {
42
+ return CODE_CONTRACT.test(await readFile(path, "utf8"));
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
37
48
  const SKIP_DIRS = new Set([".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".aqk"]);
38
49
 
39
50
  // Факты о репозитории. Только то, что видно машине: спрашивать человека анкетой
@@ -102,7 +113,7 @@ async function detectFacts(man) {
102
113
  if (/\.(test|spec)\.[a-z]+$/i.test(it.name) || /^test_.*\.py$/i.test(it.name) || /_test\.go$/i.test(it.name)) hasTests = true;
103
114
  if (it.name.endsWith(".sql")) hasDb = true;
104
115
  if (/\.(css|scss|sass|less|styl|vue|svelte|astro)$/i.test(it.name)) hasUi = true;
105
- if (isApiSpec(it.name)) hasApiSpec = true;
116
+ if (isApiSpec(it.name) || (!hasApiSpec && it.name === "package.json" && (await isCodeContract(full)))) hasApiSpec = true;
106
117
  const dot = it.name.lastIndexOf(".");
107
118
  if (dot > 0) {
108
119
  const lang = EXT_LANG[it.name.slice(dot)];
@@ -312,6 +323,36 @@ function browserServerAdvice(facts, mcpText = "") {
312
323
  return { servers: ["chrome-devtools-mcp", "@playwright/mcp"] };
313
324
  }
314
325
 
326
+ // Свод в AGENTS.md и Claude Code. Документация Claude Code (code.claude.com/docs/en/memory,
327
+ // раздел «AGENTS.md», сверено 2026-09-11): «Claude Code reads CLAUDE.md, not AGENTS.md» —
328
+ // рекомендовано CLAUDE.md с `@AGENTS.md` либо символическая ссылка. Проект, где Claude Code
329
+ // настроен, а правила лежат только в AGENTS.md, пишет их агенту, который их не читает.
330
+ // Молчим, где Claude Code нет вовсе: Codex и Cursor читают AGENTS.md сами. Упоминание словами
331
+ // («see AGENTS.md») — не подключение: файл в контекст не попадёт, агент дочитает или нет по
332
+ // настроению. `@` в обратных кавычках документация прямо называет «не импорт».
333
+ // Исходы: null — всё видно или нечего видеть; "missing" — CLAUDE.md нет; "noImport" — есть, но
334
+ // AGENTS.md не подключает.
335
+ function claudeSeesRules({ agents, claude, claudeLink, dotClaude }) {
336
+ if (!agents || claudeLink) return null;
337
+ if (claude === null) return dotClaude ? "missing" : null;
338
+ const prose = String(claude).replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
339
+ return /(^|\s)@(\.{1,2}\/)*AGENTS\.md\b/.test(prose) ? null : "noImport";
340
+ }
341
+
342
+ // То же, прочитанное с диска: одно место на `doctor` и `prompt`. Оба места CLAUDE.md —
343
+ // документация называет и ./CLAUDE.md, и ./.claude/CLAUDE.md; подключение в любом засчитывается.
344
+ async function claudeShimFor(cwd = CWD) {
345
+ const readOr = async (rel) => { try { return await readFile(join(cwd, rel), "utf8"); } catch { return null; } };
346
+ let claudeLink = false;
347
+ try { claudeLink = lstatSync(join(cwd, "CLAUDE.md")).isSymbolicLink() && /AGENTS\.md$/i.test(realpathSync(join(cwd, "CLAUDE.md"))); } catch { /* файла нет */ }
348
+ return claudeSeesRules({
349
+ agents: await exists(join(cwd, "AGENTS.md")),
350
+ claude: [await readOr("CLAUDE.md"), await readOr(".claude/CLAUDE.md")].filter((t) => t !== null).join("\n") || null,
351
+ claudeLink,
352
+ dotClaude: await exists(join(cwd, ".claude")),
353
+ });
354
+ }
355
+
315
356
  function recipeFor(rec, facts) {
316
357
  const cmd = pickRecipe(rec, facts);
317
358
  if (!cmd) return L.recipe.none;
@@ -385,4 +426,4 @@ async function matchCatalog(query) {
385
426
  export {
386
427
  whichSync,
387
428
  EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor, browserServerAdvice, MARKS,
388
- stems, overlap, matchCatalog, isApiSpec };
429
+ stems, overlap, matchCatalog, isApiSpec, claudeSeesRules, claudeShimFor };
package/tool/lib/run.mjs CHANGED
@@ -9,12 +9,16 @@
9
9
  // файл был полон, и любая следующая правка ложилась туда просто потому, что «так ближе по
10
10
  // контексту». Ровно то, о чём предупреждает совет самого гейта.
11
11
  import { spawnSync } from "node:child_process";
12
+ import { existsSync } from "node:fs";
13
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
14
+ import { join } from "node:path";
12
15
  import { Worker } from "node:worker_threads";
13
16
  import { scopeOutput, splitAdvice, changedFiles } from "./scope.mjs";
14
- import { CWD, c, die } from "./core.mjs";
17
+ import { CWD, TARGET_DIR, c, die, exists } from "./core.mjs";
15
18
  import { advisorySet } from "./manifest.mjs";
16
19
  import { L } from "../i18n/index.mjs";
17
- import { gateCommand } from "./execution.mjs";
20
+ import { gateCommand, classify, findingCodes } from "./execution.mjs";
21
+ import { annotations } from "./annotate.mjs";
18
22
 
19
23
 
20
24
  // «Гейт объявлен» и «гейт работает» — разные утверждения. Первое читается из манифеста,
@@ -147,6 +151,7 @@ async function runGates(man, opts = {}) {
147
151
  if (sel.skipped.length) console.log(c.yellow(` ${L.doctor.selectSkipped(sel.skipped.join(", "))}\n`));
148
152
  let failed = 0;
149
153
  const results = [];
154
+ const quietOk = [];
150
155
 
151
156
  const jobs = Math.max(1, Number(opts.jobs) || 1);
152
157
  const pooled = jobs > 1 ? startPool(gates.map(([, cmd]) => cmd), jobs) : null;
@@ -164,10 +169,46 @@ async function runGates(man, opts = {}) {
164
169
  // Без этого «готово = доказано» остаётся правилом, за которым следит только человек.
165
170
  const outAll = `${r.stdout || ""}${r.stderr || ""}`.slice(0, 200000);
166
171
 
167
- if (r.error && r.error.code === "ETIMEDOUT") {
168
- console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.timeout)}`);
169
- failed++;
170
- results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout, out: outAll });
172
+ // ИСХОД ЗАПУСКА ДО РАЗБОРА ВЫВОДА И ДО СУЖЕНИЯ. Состояний три, а не два: clean · finding
173
+ // · infra_error. Знание о кодах живёт рядом с инструментом (`execution.mjs`): у vulture
174
+ // находка это 3, у pylint — битовая маска, а у незнакомой программы находка только 1.
175
+ //
176
+ // ЗАЧЕМ ЗДЕСЬ. Прежде прогон ловил один лишь ETIMEDOUT, а любой другой ненулевой код шёл в
177
+ // разбор находок — и `--since` фильтровал его ПО ПУТЯМ. Гейт, который НЕ СМОГ отработать,
178
+ // называл путь вне дифа и печатался зелёным: проверка сломалась, прогон сказал «чисто».
179
+ // Найдено внешним разбором 2026-09-13 (аудит Runcap), воспроизведено проверкой
180
+ // `fail-closed`. Сужать дифом можно только НАХОДКУ: у сбоя нет места в коде, которое он
181
+ // называет, — есть только сам сбой.
182
+ const verdict = classify(r, findingCodes(String(cmd).trim().split(/\s+/)[0]));
183
+ if (verdict.state === "infra_error") {
184
+ const why =
185
+ verdict.reason === "timeout" ? L.doctor.timeout
186
+ : verdict.reason === "spawn_error" ? L.doctor.whySpawn(r.error?.code || "")
187
+ : verdict.reason === "signal" ? L.doctor.whySignal(r.signal)
188
+ : L.doctor.whyExit(verdict.code);
189
+ // Совещательный не роняет прогон НИКОГДА — в том числе своим сбоем: список `advisory:`
190
+ // означает «эта проверка не имеет права останавливать работу», и причина остановки тут
191
+ // ни при чём. Но НАЗВАН он обязан быть: «не смогли» и «чисто» неразличимы только там,
192
+ // где о них молчат. Раньше таймаут ронял прогон и у совещательного — тот же класс, что
193
+ // измеренный 2026-09-09 случай с гейтом без путей в выводе.
194
+ const adv = advisory.has(name);
195
+ const note = L.doctor.cannotCheck(why);
196
+ const paint = adv ? c.yellow : c.red;
197
+ console.log(` ${adv ? c.yellow("!") : c.red("✘")} ${name.padEnd(14)} ${paint(note)} ${c.dim(`· ${secs}s · ${cmd}`)}`);
198
+ // Вывод сбоя показывается тоже. «Не смогли проверить: код 127» без строки
199
+ // «command not found: ruff» не говорит, ЧТО чинить, — а чинить тут надо инструмент,
200
+ // и первые строки обычно и есть его жалоба.
201
+ for (const line of outAll.trim().split("\n").filter(Boolean).slice(0, 3)) {
202
+ console.log(c.dim(` ${line.slice(0, 100)}`));
203
+ }
204
+ if (!adv) failed++;
205
+ // В pull request это ОБЩАЯ пометка гейта, а не пометка у строки файла: `shown` — то, что
206
+ // прогон показал, и у сбоя это причина, а не путь. Иначе «не смогли проверить» повисло бы
207
+ // на первом файле, который гейт успел назвать перед падением, — то есть на невиновном.
208
+ // Сырой `out` не трогаем: по нему считается покрытие дифа.
209
+ // `cannot` — не украшение: по нему отчёт прогона отличает сбой от находки одним знаком,
210
+ // а блок для агента и просьба об отзыве читают это из файла, ничего не запуская.
211
+ results.push({ name, cmd, ok: false, cannot: true, secs, code: verdict.code, advisory: adv, note, out: outAll, shown: note });
171
212
  continue;
172
213
  }
173
214
  const code = r.status;
@@ -178,12 +219,16 @@ async function runGates(man, opts = {}) {
178
219
  // совещательный печатался обычной галочкой, а README обещал, что список назван каждый
179
220
  // прогон. Тот же класс, что молчащий гейт, только про сам прибор.
180
221
  const quiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
181
- console.log(` ${c.green("✔")} ${name.padEnd(14)}${quiet} ${c.dim(`${secs}s · ${cmd}`)}`);
182
222
  // Зелёный гейт иногда всё-таки говорит человеку что-то важное: храповик, дошедший до цели,
183
223
  // просит убрать обёртку. Вывод успешного гейта не показывался вовсе, и это сообщение
184
224
  // уходило в никуда — тот же класс, что обрезанный совет у красного, только тише.
185
225
  // Показываем ровно строки с меткой совета: остальной вывод успешной проверки — шум.
186
226
  const okAdvice = splitAdvice(`${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean)).advice;
227
+ // Без `--verbose` зелёный гейт своей строки не получает — если ему нечего сказать. Совещательный
228
+ // и гейт с советом печатаются всегда: первый обязан быть назван каждый прогон (см. выше),
229
+ // второй несёт строку, ради которой человек и смотрит.
230
+ if (opts.verbose || quiet || okAdvice.length) console.log(` ${c.green("✔")} ${name.padEnd(14)}${quiet} ${c.dim(`${secs}s · ${cmd}`)}`);
231
+ else quietOk.push(name);
187
232
  for (const line of okAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
188
233
  results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), out: outAll });
189
234
  } else {
@@ -252,14 +297,100 @@ async function runGates(man, opts = {}) {
252
297
  }
253
298
  // Совет тоже не бесконечен: гейт, зовущий помощник шесть раз, печатает его шесть раз.
254
299
  for (const line of alwaysAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
255
- results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll });
300
+ // `shown` то, что прогон ПОКАЗАЛ: после сужения по дифу и с советом. Пометки в pull request
301
+ // берутся отсюда, а не из сырого вывода: иначе при --since они вешались бы на файлы вне
302
+ // дифа — поймано конвейером на первом же прогоне (smoke: «--since сузил не то»).
303
+ results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll, shown: [...out, ...alwaysAdvice].join("\n") });
256
304
  }
257
305
  }
258
306
  // Совещательные, которые покраснели, называются вслух ВСЕГДА. Молчание о них — ровно та
259
307
  // тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
308
+ if (quietOk.length) console.log(` ${c.green("✔")} ${L.doctor.passedQuiet(quietOk.length)}`);
260
309
  const advisoryFailed = results.filter((x) => x.advisory && !x.ok).map((x) => x.name);
261
310
  if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
311
+ // В GitHub Actions — те же находки пометками у строк файла в pull request. Вердикт не меняется.
312
+ if (process.env.GITHUB_ACTIONS === "true") {
313
+ const ann = annotations(results, { exists: (f) => existsSync(join(CWD, f)) });
314
+ for (const line of ann.lines) console.log(line);
315
+ if (ann.dropped) console.log(c.dim(` ${L.doctor.annotDropped(ann.lines.length, ann.dropped)}`));
316
+ }
262
317
  return { failed, ran: gates.length, results, advisoryFailed, skipped: sel.skipped };
263
318
  }
264
319
 
265
- export { declaredGates, sinceRef, runGates, progress, selectGates, listArg };
320
+
321
+ // ─────────────────────────────────────────────────────────────────────────────
322
+ // ОТЧЁТ ПРОГОНА: пишется здесь же, где прогон, и читается здесь же. Раньше `doctor` его ПИСАЛ,
323
+ // а `context` РАЗБИРАЛ — два файла, которые друг о друге не знают, держали один формат. Третий
324
+ // знак («?» — не смогли проверить) пришлось заводить в обоих, и это ровно тот случай, когда
325
+ // одно знание живёт в двух местах: правишь одно, второе молча расходится.
326
+ //
327
+ // Читателей у отчёта трое — блок для агента, задание и просьба об отзыве, — и ни один не
328
+ // запускает гейты заново: хук обязан укладываться в секунду, а прогон идёт минуту.
329
+ // Короткий отчёт «что из этого реально брали» — не для человека, а для агента в следующей
330
+ // сессии и для самого владельца: список объявленных гейтов молчит о том, сколько из них
331
+ // действительно стоят и работают именно СЕЙЧАС. Перезаписывается каждым прогоном, не копится:
332
+ // история — дело git-лога коммитов с этим отчётом, если владелец решит его коммитить.
333
+ async function writeRunReport({ version, reached, results, skipped = [] }) {
334
+ const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
335
+ const ok = results.filter((r) => r.ok).length;
336
+ const lines = [
337
+ `# ${L.report.title} — ${stamp}`,
338
+ version ? `${L.report.version}: ${version}` : null,
339
+ `${L.report.level}: AQK-${reached < 0 ? L.doctor.levelNone : reached}`,
340
+ "",
341
+ // ТРИ ЗНАКА, А НЕ ДВА: ✔ прошло · ✘ находка · ? НЕ СМОГЛИ ПРОВЕРИТЬ. Отчёт читает не только
342
+ // человек: из него блок для агента и просьба об отзыве узнают, что случилось, ничего не
343
+ // запуская. Слив «не смогли» с находкой означал бы, что агент чинит код там, где сломан
344
+ // инструмент, — и никогда не узнает, что инструмент сломан.
345
+ ...results.map((r) => `${r.ok ? "✔" : r.cannot ? "?" : "✘"} ${r.name} — ${r.secs}s${r.ok ? "" : ` (${r.note || L.doctor.exitCode(r.code)})`}`),
346
+ // Пропущенные по --skip/--only — строкой «~»: блок для агента читает их как «не запускались»,
347
+ // а не как зелёные. Молчание о них прочиталось бы как «проверено».
348
+ ...skipped.map((n) => `~ ${n} — ${L.report.skippedBySelect}`),
349
+ "",
350
+ L.report.summary(ok, results.length),
351
+ ].filter((l) => l !== null);
352
+
353
+ const dst = join(CWD, TARGET_DIR, "last-run.md");
354
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
355
+ await writeFile(dst, lines.join("\n") + "\n", "utf8");
356
+ }
357
+
358
+ // Разбор отчёта прошлого прогона. Формат кладёт сам `doctor` в .aqk/last-run.md; читаем его,
359
+ // а не запускаем гейты заново: хук обязан укладываться в секунду-две, а прогон у нас идёт минуту.
360
+ function parseLastRun(text) {
361
+ if (!text) return null;
362
+ const when = (text.match(/^# aqk doctor --run — (.+)$/m) || [])[1] || "";
363
+ const red = [];
364
+ for (const m of text.matchAll(/^✘ ([^\s—]+)/gm)) red.push(m[1]);
365
+ // «Не смогли проверить» — свой знак и свой список. Гейт, который не сумел отработать, не
366
+ // находка о коде: агент, прочитавший его как находку, пойдёт чинить исправный файл, а
367
+ // сломанный инструмент останется сломанным. Слить их в один список было бы той же тишиной,
368
+ // только наоборот.
369
+ const cannot = [];
370
+ for (const m of text.matchAll(/^\? ([^\s—]+)/gm)) cannot.push(m[1]);
371
+ const skipped = (text.match(/^~ /gm) || []).length;
372
+ return { when: when.trim(), red, cannot, skipped, stale: false };
373
+ }
374
+
375
+ // Прогон старше последнего коммита описывает не тот код, что лежит перед агентом. Молча выдать
376
+ // его за свежий — соврать: именно так «зелёный месяц назад» превращается в «зелёный сейчас».
377
+ function runIsStale(when) {
378
+ if (!when) return false;
379
+ const r = spawnSync("git", ["log", "-1", "--format=%cI"], { cwd: CWD, encoding: "utf8" });
380
+ if (r.status !== 0 || !r.stdout) return false;
381
+ const commit = Date.parse(r.stdout.trim());
382
+ const run = Date.parse(when.replace(" ", "T"));
383
+ return Number.isFinite(commit) && Number.isFinite(run) && run < commit;
384
+ }
385
+
386
+ // Прошлый прогон — из отчёта, который кладёт `doctor --run`. Отдельной функцией: его читают и
387
+ // `context`, и `prompt`, и два разбора одного файла разошлись бы.
388
+ async function readRun() {
389
+ const lastRun = join(CWD, TARGET_DIR, "last-run.md");
390
+ if (!(await exists(lastRun))) return null;
391
+ const run = parseLastRun(await readFile(lastRun, "utf8"));
392
+ if (run) run.stale = runIsStale(run.when);
393
+ return run;
394
+ }
395
+
396
+ export { declaredGates, sinceRef, runGates, progress, selectGates, listArg, writeRunReport, parseLastRun, readRun };
package/tool/program.mjs CHANGED
@@ -28,7 +28,9 @@ import { cmdBadge } from "./commands/badge.mjs";
28
28
  import { cmdProve } from "./commands/prove.mjs";
29
29
  import { cmdProbe } from "./commands/probe.mjs";
30
30
  import { cmdContext } from "./commands/context.mjs";
31
+ import { cmdPrompt } from "./commands/prompt.mjs";
31
32
  import { cmdVitals } from "./commands/vitals.mjs";
33
+ import { cmdFeedback } from "./commands/feedback.mjs";
32
34
 
33
35
  // Разбор аргументов выполняется только при запуске файла как программы. При импорте —
34
36
  // а так его читают модульные проверки tool/selfcheck/units.mjs — CLI запускаться не должен.
@@ -120,9 +122,17 @@ if (IS_MAIN) {
120
122
  case "context":
121
123
  await cmdContext(rest);
122
124
  break;
125
+ case "prompt":
126
+ await cmdPrompt();
127
+ break;
123
128
  case "badge":
124
129
  await cmdBadge(rest);
125
130
  break;
131
+ // Собирает отчёт о работе комплекта и даёт готовую ссылку. Ничего не отправляет: исходящий
132
+ // запрос у комплекта ровно один — про свежесть версии.
133
+ case "feedback":
134
+ await cmdFeedback();
135
+ break;
126
136
  default: {
127
137
  // Ширина колонки считается, а не подбирается пробелами: строки в двух языках разной
128
138
  // длины, и вручную выровненная справка на втором языке разъезжается.
@@ -55,6 +55,9 @@ function env(home, dir) {
55
55
  // Сеть в тестах — отдельный класс флейков, и у нас она включалась ТОЛЬКО вне конвейера:
56
56
  // локально проверки были сетевыми, в конвейере нет. Две разные среды по построению.
57
57
  AQK_UPDATE: "0",
58
+ // Вывод — как у пользователя, короткий. `smoke.sh` включает AQK_VERBOSE=1 для СВОИХ
59
+ // проверок, написанных до свёртки, и без сброса здесь проверки свёртки его унаследовали бы.
60
+ AQK_VERBOSE: "",
58
61
  };
59
62
  }
60
63
 
@@ -82,14 +85,16 @@ function project(t, files = {}, { git = true } = {}) {
82
85
  // Запуск чего угодно в каталоге проекта. Возвращается И код, И вывод: проверка, смотрящая
83
86
  // только на код, не умеет объяснить провал, а смотрящая только на вывод — не умеет заметить,
84
87
  // что команда не роняет прогон.
85
- function run({ dir, home }, cmd, args = []) {
88
+ // `extra` переменные поверх песочницы, когда проверка про само окружение (GITHUB_ACTIONS).
89
+ function run({ dir, home }, cmd, args = [], extra = {}) {
86
90
  const r = spawnSync(cmd, args, {
87
- cwd: dir, encoding: "utf8", timeout: TIMEOUT_MS, env: env(home, dir),
91
+ cwd: dir, encoding: "utf8", timeout: TIMEOUT_MS, env: { ...env(home, dir), ...extra },
88
92
  });
89
93
  return { code: r.status, out: `${r.stdout || ""}${r.stderr || ""}` };
90
94
  }
91
95
 
92
96
  const aqk = (p, ...args) => run(p, process.execPath, [CLI, ...args]);
97
+ const aqkEnv = (p, extra, ...args) => run(p, process.execPath, [CLI, ...args], extra);
93
98
 
94
99
  // ПОЧЕМУ ГЕЙТ ЗОВЁТСЯ ТОЧКОЙ, А НЕ ПУТЁМ, И ПОЧЕМУ ПУТЬ ЧЕРЕЗ КОСУЮ.
95
100
  // Node на Windows отдаёт `C:\Users\…\Temp\aqk-smoke-x`, а Git Bash живёт в POSIX-мире и
@@ -108,4 +113,4 @@ const gate = (p, name, sub = ".") =>
108
113
  // никто не берёт, читается как часть договора и мешает менять внутренности — поймал наш же
109
114
  // dead-code через минуту после того, как файл был написан. `run` вернулся, когда появилась
110
115
  // проверка, которой нужен сырой git: экспорт заводится под потребителя, а не про запас.
111
- export { project, run, aqk, gate };
116
+ export { project, run, aqk, aqkEnv, gate };
@@ -77,3 +77,40 @@ test("держателем не считается определение сос
77
77
  // Второй дефект того же прогона: `find` без завершающего -print печатал обойдённые каталоги.
78
78
  assert.doesNotMatch(r.out, /\.git|\.aqk/, `в списке спецификаций каталоги:\n${r.out}`);
79
79
  });
80
+
81
+ // ДОГОВОР В КОДЕ. Отзыв с живого проекта 2026-09-11: схемы zod запросов и ответов в общем
82
+ // пакете, сервер на `@fastify/type-provider-zod` — а кит писал «спецификации API не видно»,
83
+ // потому что узнавал договор только по имени файла `openapi*`. У такого договора арбитр —
84
+ // проверка типов: разошлись сервер и клиент — `tsc` краснеет. Не запускает её никто — договор
85
+ // не держит никто, ровно как файл OpenAPI без schemathesis.
86
+ test("договор в коде без проверки типов — находка, с tsc — чисто, zod один — не договор", (t) => {
87
+ const provider = { "backend/package.json": '{ "dependencies": { "@fastify/type-provider-zod": "1.0.0", "zod": "4.5.4" } }\n' };
88
+ const bare = gate(project(t, provider), "api-contract-has-arbiter");
89
+ assert.equal(bare.code, 1, `договор без арбитра прошёл зелёным:\n${bare.out}`);
90
+ assert.match(bare.out, /backend\/package\.json/, `находка не называет, где договор:\n${bare.out}`);
91
+
92
+ const held = gate(project(t, {
93
+ ...provider,
94
+ "package.json": '{ "scripts": { "typecheck": "tsc --build" } }\n',
95
+ }), "api-contract-has-arbiter");
96
+ assert.equal(held.code, 0, `tsc в scripts не признан арбитром:\n${held.out}`);
97
+
98
+ for (const dep of ["@trpc/server", "@ts-rest/core", "@hono/zod-openapi", "fastify-type-provider-zod"]) {
99
+ const r = gate(project(t, { "package.json": `{ "dependencies": { "${dep}": "1.0.0" } }\n` }), "api-contract-has-arbiter");
100
+ assert.equal(r.code, 1, `${dep} не опознан как договор:\n${r.out}`);
101
+ }
102
+
103
+ // zod сам по себе — разбор входа, а не договор с чужим кодом: так его зовут и формы, и конфиги.
104
+ const zod = gate(project(t, { "package.json": '{ "dependencies": { "zod": "4.5.4" } }\n' }), "api-contract-has-arbiter");
105
+ assert.equal(zod.code, 0, `один zod объявлен договором:\n${zod.out}`);
106
+ });
107
+
108
+ test("doctor видит договор в коде: запись применима, а не «спецификации не видно»", (t) => {
109
+ const p = project(t, { "package.json": '{ "dependencies": { "@trpc/server": "11.18.0" } }\n' });
110
+ aqk(p, "init");
111
+ // --verbose: без него неприменимые свёрнуты в счёт, причин в выводе нет вовсе — и проверка
112
+ // прошла бы вхолостую при любом опознании.
113
+ const r = aqk(p, "doctor", "--verbose");
114
+ assert.match(r.out, /api-contract-has-arbiter/, `запись не названа вовсе:\n${r.out}`);
115
+ assert.doesNotMatch(r.out, /не видно (спецификации|договора) API|no API (specification|contract) in sight/, `договор в коде не опознан:\n${r.out}`);
116
+ });
@@ -0,0 +1,151 @@
1
+ // Набор настоящих случаев: то, что мы нашли на живых проектах, в журнале и в разборах соседей,
2
+ // — минимальными воспроизведениями с ожидаемым ответом комплекта.
3
+ //
4
+ // ЗАЧЕМ. Образцы red/green проверяют запись В ОДИНОЧКУ, на примере, написанном под неё. Ломается
5
+ // же комплект на целых проектах, где записи стоят рядом: «определение соседней записи —
6
+ // находка» (журнал, 2026-09-09) образцы не поймали ни разу. Разбор AgentLint 2026-09-11 показал
7
+ // другую половину: их «точность 99%» мерила согласие сканера с собственным пересказом правил.
8
+ // Здесь разметка — по СВОЙСТВУ проекта: у каждого случая назван источник, где это свойство
9
+ // видели на деле. Файлы случаев живут внутри проверки, а не в дереве: иначе наши же гейты
10
+ // находили бы в них «находки» у нас в репозитории.
11
+ //
12
+ // Каждый случай — минимальное воспроизведение, а не копия: чужой код переносить нельзя по
13
+ // лицензии, и копия в тысячи строк проверяла бы то же самое, только медленнее.
14
+ import test from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { project, aqk, aqkEnv, gate } from "./_fixture.mjs";
20
+
21
+ const KIT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "kit", "gates");
22
+
23
+ const TWIN = (fake) => ({
24
+ "CLAUDE.md": "# Shop API\n\nSmall HTTP API.\n\n## Commands\n- Test: `npm test`\n",
25
+ "package.json": JSON.stringify({ name: "shop", type: "module", scripts: { test: fake ? "node --test || true" : "node --test" } }),
26
+ "src/cart.js": "export function total(items) {\n return items.reduce((s, i) => s + i.price, 0);\n}\n",
27
+ "test/cart.test.js": fake
28
+ ? "import test from 'node:test';\nimport { total } from '../src/cart.js';\ntest('total', () => { total([{ price: 2 }]); });\n"
29
+ : "import test from 'node:test';\nimport assert from 'node:assert';\nimport { total } from '../src/cart.js';\ntest('total', () => { assert.equal(total([{ price: 2 }]), 2); });\n",
30
+ ".github/workflows/ci.yml": "name: ci\non: [push]\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n" +
31
+ " - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262\n - run: npm test\n" +
32
+ (fake ? " continue-on-error: true\n" : ""),
33
+ });
34
+
35
+ const CASES = [
36
+ {
37
+ name: "близнец с настоящими воротами",
38
+ source: "research/competitors/agentlint-0xmariowu.md, «Близнецы»: AgentLint дал 71 обоим",
39
+ files: TWIN(false),
40
+ gates: { "ci-actually-fails": 0, "test-has-assertion": 0 },
41
+ },
42
+ {
43
+ name: "близнец с поддельными воротами",
44
+ source: "там же; test-has-assertion молчит намеренно — «вызов без утверждения» у нас не находка (.aqk.yml)",
45
+ files: TWIN(true),
46
+ gates: { "ci-actually-fails": 1, "test-has-assertion": 0 },
47
+ },
48
+ {
49
+ name: "ворота в check.yml под continue-on-error, «exit 1» в комментарии",
50
+ source: "agentlint-0xmariowu.md, H7: их проверка назвала такие ворота блокирующими",
51
+ files: {
52
+ ".github/workflows/check.yml": "name: check\non: [push]\npermissions:\n contents: read\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n" +
53
+ " - run: npm test\n continue-on-error: true\n# on failure the step does exit 1\n",
54
+ "package.json": JSON.stringify({ name: "x", scripts: { test: "node --test" } }),
55
+ },
56
+ gates: { "ci-actually-fails": 1 },
57
+ },
58
+ {
59
+ name: "свод велит удалённый скрипт, а debug остался в зависимостях",
60
+ source: "incidents/README.md 2026-09-11; JonnyKreng/pebble-navi 757c563",
61
+ files: {
62
+ "AGENTS.md": "# Rules\n\n```sh\nnpm run debug # build + install + logs\n```\n",
63
+ "package.json": JSON.stringify({ name: "x", scripts: { build: "tsc" }, dependencies: { debug: "4.3.4" } }, null, 2),
64
+ },
65
+ gates: { "entry-commands-exist": 1 },
66
+ },
67
+ {
68
+ name: "цели make в коде свода, «make sure» в прозе",
69
+ source: "живой проект на TypeScript и Biome: шестнадцать целей make в AGENTS.md, все на месте",
70
+ files: {
71
+ "AGENTS.md": "# Rules\n\nMake sure the tree is clean.\n\n- проверка: `make check`\n- типы: `make typecheck`\n",
72
+ "Makefile": ".PHONY: check typecheck\ncheck:\n\tnpm run lint\ntypecheck:\n\tnpx tsc --noEmit\n",
73
+ "package.json": JSON.stringify({ name: "x", scripts: { lint: "biome lint ." } }),
74
+ },
75
+ gates: { "entry-commands-exist": 0 },
76
+ },
77
+ {
78
+ name: "договор в коде (провайдер типов Fastify) без проверки типов",
79
+ source: "отзыв с живого проекта 2026-09-11: «спецификации API не видно» при zod-схемах в общем пакете",
80
+ files: { "backend/package.json": JSON.stringify({ dependencies: { "@fastify/type-provider-zod": "1.0.0", zod: "4.5.4" } }) },
81
+ gates: { "api-contract-has-arbiter": 1 },
82
+ },
83
+ {
84
+ name: "тот же договор, tsc --build в scripts",
85
+ source: "там же: на живом проекте запись применима и зелёная",
86
+ files: {
87
+ "backend/package.json": JSON.stringify({ dependencies: { "@fastify/type-provider-zod": "1.0.0" } }),
88
+ "package.json": JSON.stringify({ scripts: { typecheck: "tsc --build" } }),
89
+ },
90
+ gates: { "api-contract-has-arbiter": 0 },
91
+ },
92
+ {
93
+ name: "CLAUDE.md с windows-переносами подключает AGENTS.md",
94
+ source: "живой проект на Windows; AgentLint (F7) объявил подключение битым",
95
+ files: {
96
+ "CLAUDE.md": "# CLAUDE.md\r\n\r\nПравила — в AGENTS.md.\r\n\r\n@AGENTS.md\r\n",
97
+ "AGENTS.md": "# Правила\n\n- тесты перед коммитом\n",
98
+ ".claude/settings.json": "{}\n",
99
+ },
100
+ doctorNot: /(не подключает AGENTS|does not import AGENTS|Claude Code здесь настроен|Claude Code is set up)/,
101
+ },
102
+ {
103
+ name: "русский свод, машина без LANG",
104
+ source: "отзыв с живого проекта 2026-09-11: Windows без LANG — весь вывод английский",
105
+ files: {
106
+ "AGENTS.md": "# Правила проекта\n\n" + "Перед коммитом запусти проверку и убедись, что она проходит. Изменение договора проходит через ревью и фиксируется в журнале решений. ".repeat(3) + "\n",
107
+ },
108
+ env: { AQK_LANG: "", LANG: "", LC_ALL: "", LC_MESSAGES: "" },
109
+ doctorHas: /Уровень/,
110
+ },
111
+ {
112
+ name: "определения записей, разложенные в проект, — не находки",
113
+ source: "incidents/README.md 2026-09-09 «аудит фич нашёл то, чего не нашли образцы»; AgentLint S6/S7 краснеют на таком же",
114
+ files: { "src/app.py": "def f():\n return 1\n" },
115
+ before: [["init"], ["add", "secrets-not-in-code"], ["add", "personal-config-not-shared"], ["add", "env-secrets-not-committed"]],
116
+ gates: { "secrets-not-in-code": 0, "personal-config-not-shared": 0, "env-secrets-not-committed": 0 },
117
+ },
118
+ ];
119
+
120
+ // Записи, которые работают на одном `sh` и дают один и тот же ответ на любой машине: без
121
+ // `requires:` (нет чужого инструмента — ответ зависел бы от установленного) и без сети.
122
+ const NETWORK = new Set(["no-phantom-package"]);
123
+ const PORTABLE = readdirSync(KIT, { withFileTypes: true })
124
+ .filter((d) => d.isDirectory() && existsSync(join(KIT, d.name, "gate.yml")))
125
+ .map((d) => d.name)
126
+ .filter((slug) => {
127
+ const yml = readFileSync(join(KIT, slug, "gate.yml"), "utf8");
128
+ return !NETWORK.has(slug) && /any:\s*bash \{gate\}\/check\.sh \{dir\}/.test(yml) && !/^requires:/m.test(yml);
129
+ });
130
+
131
+ for (const c of CASES) {
132
+ test(`случай: ${c.name}`, (t) => {
133
+ const p = project(t, c.files);
134
+ for (const args of c.before || []) aqk(p, ...args);
135
+ for (const [slug, want] of Object.entries(c.gates || {})) {
136
+ const r = gate(p, slug);
137
+ assert.equal(r.code, want, `${slug}: ждали ${want}, получили ${r.code}. Источник: ${c.source}\n${r.out}`);
138
+ }
139
+ if (c.doctorNot || c.doctorHas) {
140
+ if (!c.before) aqk(p, "init");
141
+ const r = c.env ? aqkEnv(p, c.env, "doctor") : aqk(p, "doctor");
142
+ if (c.doctorNot) assert.doesNotMatch(r.out, c.doctorNot, `источник: ${c.source}\n${r.out}`);
143
+ if (c.doctorHas) assert.match(r.out, c.doctorHas, `источник: ${c.source}\n${r.out}`);
144
+ }
145
+ // Перекрёстная проверка: на обычном проекте ни одна переносимая запись не отвечает «не смогли
146
+ // проверить». Код 2 здесь — сбой записи на чужой раскладке, а не свойство проекта.
147
+ const broken = PORTABLE.map((slug) => [slug, gate(p, slug)]).filter(([, r]) => r.code !== 0 && r.code !== 1);
148
+ assert.deepEqual(broken.map(([s, r]) => `${s}: код ${r.code}`), [],
149
+ `записи не смогли проверить обычный проект:\n${broken.map(([s, r]) => `${s}:\n${r.out}`).join("\n")}`);
150
+ });
151
+ }