agent-quality-kit 0.12.0 → 0.14.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 +43 -7
  2. package/README.ru.md +44 -7
  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/complexity-limit/gate.yml +4 -0
  6. package/kit/gates/dead-code/gate.yml +4 -0
  7. package/kit/gates/entry-commands-exist/README.md +64 -0
  8. package/kit/gates/entry-commands-exist/check.sh +110 -0
  9. package/kit/gates/entry-commands-exist/gate.yml +19 -0
  10. package/kit/gates/entry-commands-exist/green/AGENTS.md +13 -0
  11. package/kit/gates/entry-commands-exist/green/Makefile +6 -0
  12. package/kit/gates/entry-commands-exist/green/justfile +2 -0
  13. package/kit/gates/entry-commands-exist/green/package.json +10 -0
  14. package/kit/gates/entry-commands-exist/red/AGENTS.md +9 -0
  15. package/kit/gates/entry-commands-exist/red/Makefile +2 -0
  16. package/kit/gates/entry-commands-exist/red/package.json +9 -0
  17. package/kit/gates/env-secrets-not-committed/README.md +73 -0
  18. package/kit/gates/env-secrets-not-committed/check.sh +139 -0
  19. package/kit/gates/env-secrets-not-committed/gate.yml +21 -0
  20. package/kit/gates/env-secrets-not-committed/green/.aqk-tracked +10 -0
  21. package/kit/gates/env-secrets-not-committed/green/.env +10 -0
  22. package/kit/gates/env-secrets-not-committed/green/.env.production +5 -0
  23. package/kit/gates/env-secrets-not-committed/green/.env.test +2 -0
  24. package/kit/gates/env-secrets-not-committed/red/.aqk-tracked +5 -0
  25. package/kit/gates/env-secrets-not-committed/red/.env +7 -0
  26. package/kit/gates/no-print-in-prod/gate.yml +4 -0
  27. package/kit/gates/swallowed-error/gate.yml +4 -0
  28. package/kit/gates/todo-without-task/gate.yml +4 -0
  29. package/llms.txt +8 -2
  30. package/package.json +1 -1
  31. package/tool/commands/badge.mjs +1 -1
  32. package/tool/commands/context.mjs +81 -9
  33. package/tool/commands/doctor-catalog.mjs +222 -0
  34. package/tool/commands/doctor.mjs +81 -209
  35. package/tool/commands/learn.mjs +119 -19
  36. package/tool/commands/probe.mjs +50 -68
  37. package/tool/commands/project.mjs +6 -1
  38. package/tool/commands/prompt.mjs +69 -0
  39. package/tool/commands/report.mjs +1 -1
  40. package/tool/commands/vitals.mjs +15 -11
  41. package/tool/i18n/en-docs.mjs +22 -1
  42. package/tool/i18n/en-gates.mjs +6 -1
  43. package/tool/i18n/en.mjs +78 -4
  44. package/tool/i18n/index.mjs +42 -3
  45. package/tool/i18n/ru-docs.mjs +24 -1
  46. package/tool/i18n/ru-gates.mjs +6 -1
  47. package/tool/i18n/ru.mjs +87 -4
  48. package/tool/lib/adopt.mjs +15 -1
  49. package/tool/lib/advice.mjs +115 -0
  50. package/tool/lib/annotate.mjs +66 -0
  51. package/tool/lib/brief.mjs +3 -1
  52. package/tool/lib/cadence.mjs +40 -1
  53. package/tool/lib/core.mjs +40 -1
  54. package/tool/lib/gate-worker.mjs +18 -0
  55. package/tool/lib/history.mjs +34 -5
  56. package/tool/lib/manifest.mjs +65 -21
  57. package/tool/lib/repo.mjs +47 -35
  58. package/tool/lib/run.mjs +98 -9
  59. package/tool/program.mjs +4 -0
  60. package/tool/selfcheck/smoke/_fixture.mjs +8 -3
  61. package/tool/selfcheck/smoke/api-contract.test.mjs +37 -0
  62. package/tool/selfcheck/smoke/corpus.test.mjs +151 -0
  63. package/tool/selfcheck/smoke/first-run.test.mjs +144 -0
  64. package/tool/selfcheck/smoke/verdict.test.mjs +91 -3
  65. package/tool/selfcheck/smoke.sh +10 -2
  66. package/tool/selfcheck/units-annotate.mjs +67 -0
  67. package/tool/selfcheck/units-cadence.mjs +40 -1
  68. package/tool/selfcheck/units-context.mjs +64 -1
  69. package/tool/selfcheck/units-learn.mjs +32 -0
  70. package/tool/selfcheck/units-level.mjs +97 -3
  71. package/tool/selfcheck/units-probe.mjs +2 -1
  72. package/tool/selfcheck/units-prompt.mjs +106 -0
  73. package/tool/selfcheck/units-repo.mjs +44 -1
  74. package/tool/selfcheck/units-verdict.mjs +76 -0
@@ -0,0 +1,66 @@
1
+ // tool/lib/annotate.mjs — находки упавших гейтов пометками GitHub Actions.
2
+ //
3
+ // ЗАЧЕМ. Находка видна только в логе конвейера, куда почти никто не заглядывает. Строка
4
+ // `::error file=…,line=…,title=…::…` в выводе шага становится красной пометкой у строки файла
5
+ // прямо в pull request. Идея — из разбора AgentLint (research/competitors/agentlint-0xmariowu.md);
6
+ // формат — документация GitHub «Workflow commands»; экранирование — официальный @actions/core
7
+ // (packages/core/src/command.ts, escapeData и escapeProperty), а не их код.
8
+ //
9
+ // ЧТО НЕ ДЕЛАЕТСЯ НИКОГДА: пометка не меняет вердикт. Это печать того же, что уже решил прогон.
10
+ import { splitAdvice, normPath } from "./scope.mjs";
11
+
12
+ const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
13
+
14
+ // Потолок. В документации GitHub лимита не нашли; в обсуждении сообщества 2020 года
15
+ // (github.com/orgs/community/discussions/26680): «10 warning annotations and 10 error annotations
16
+ // per step». Прогон `doctor` — один шаг, поэтому потолок на весь прогон, а остаток — числом в лог.
17
+ const LIMIT = 10;
18
+
19
+ // Путь с расширением или с косой чертой — как в scope.mjs. Две формы строки находки: «файл:строка:
20
+ // текст» (grep -n, линтеры, иногда ещё «:колонка») и «файл: текст» (наши записи каталога).
21
+ const PATH = "(?:\\.\\/)?((?:[\\w.@+-]+\\/)*[\\w.@+-]+\\.[A-Za-z][A-Za-z0-9]{0,9})";
22
+ const WITH_LINE = new RegExp(`^${PATH}:(\\d+)(?::\\d+)?:\\s*([\\s\\S]*)$`);
23
+ const NO_LINE = new RegExp(`^${PATH}:\\s+([\\s\\S]+)$`);
24
+
25
+ function locate(raw) {
26
+ const plain = String(raw).replace(ANSI, "").trim();
27
+ if (/^(почини|fix)\s*:/i.test(plain)) return null;
28
+ let m = WITH_LINE.exec(plain);
29
+ if (m) return { file: normPath(m[1]), line: Number(m[2]), message: m[3] || plain };
30
+ m = NO_LINE.exec(plain);
31
+ if (m) return { file: normPath(m[1]), line: null, message: m[2] };
32
+ return null;
33
+ }
34
+
35
+ const escData = (s) => String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
36
+ const escProp = (s) => escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
37
+
38
+ // results — те же записи, что возвращает runGates: { name, ok, advisory, out, shown }. Берётся
39
+ // `shown` — показанное прогоном после сужения по дифу; сырой `out` — только если его нет. exists(путь) —
40
+ // есть ли файл в репозитории: пометка на несуществующий файл GitHub вешает на `.github`, и
41
+ // человек ищет то, чего нет. Такая находка идёт общей пометкой гейта без файла.
42
+ function annotations(results, { exists = () => true, limit = LIMIT } = {}) {
43
+ const lines = [];
44
+ const used = { error: 0, warning: 0 };
45
+ let dropped = 0;
46
+ const emit = (level, title, message, loc = null) => {
47
+ if (used[level] >= limit) { dropped++; return; }
48
+ used[level]++;
49
+ const props = loc ? [`file=${escProp(loc.file)}`, ...(loc.line ? [`line=${loc.line}`] : [])] : [];
50
+ props.push(`title=${escProp(title)}`);
51
+ lines.push(`::${level} ${props.join(",")}::${escData(message)}`);
52
+ };
53
+ for (const r of results || []) {
54
+ if (r.ok) continue;
55
+ const level = r.advisory ? "warning" : "error";
56
+ const title = `aqk: ${r.name}`;
57
+ const { findings, advice } = splitAdvice(String(r.shown ?? r.out ?? "").split("\n").filter((l) => l.trim()));
58
+ const fix = advice.length ? ` — ${String(advice[0]).replace(ANSI, "").trim()}` : "";
59
+ const located = findings.map(locate).filter((f) => f && exists(f.file));
60
+ for (const f of located) emit(level, title, `${f.message}${fix}`, f);
61
+ if (!located.length) emit(level, title, `${String(findings[0] || r.note || "").replace(ANSI, "").trim()}${fix}`);
62
+ }
63
+ return { lines, dropped };
64
+ }
65
+
66
+ export { locate, annotations };
@@ -112,7 +112,9 @@ function beginBrief() {
112
112
 
113
113
  // Печать краткого итога. Совет — не чаще раза в сутки и с явным способом отказаться: то, что
114
114
  // видишь тридцатый раз, перестаёт читаться и пролистывается вместе с настоящими находками рядом.
115
- // Отметка времени лежит в .aqk/, который в .gitignore: это состояние машины, а не проекта.
115
+ // Отметка времени лежит в .aqk/ и попадает в .gitignore при `init` (RUNTIME_FILES в core.mjs):
116
+ // это состояние машины, а не проекта. Раньше здесь было написано «.aqk/ в .gitignore» — а
117
+ // `init` туда ничего не клал, и на живом проекте служебный файл уехал в коммит.
116
118
  async function finishBrief(buf, state, todoRecs, ok) {
117
119
  if (!buf) return;
118
120
  buf.restore();
@@ -89,4 +89,43 @@ function parseRan(text) {
89
89
  return m ? new Set(m[1].split(/\s+/).filter(Boolean)) : null;
90
90
  }
91
91
 
92
- export { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan };
92
+ // Запускаться ли пробе САМОЙ внутри `doctor --run`. Не в конвейере: там это +2–3 минуты сюрпризом
93
+ // в случайном прогоне (отзыв с живого проекта 2026-09-11), и место пробы — отдельная задача. Не в
94
+ // коротком режиме: там хук на воротах коммита. `AQK_PROBE=0` — выключить, `AQK_PROBE=1` — включить
95
+ // и в конвейере.
96
+ function autoProbeAllowed({ brief = false, env = process.env } = {}) {
97
+ if (env.AQK_PROBE === "0" || brief) return false;
98
+ if (env.AQK_PROBE === "1") return true;
99
+ return !(env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI || env.BUILDKITE || env.JENKINS_URL);
100
+ }
101
+
102
+ // Сколько классов проба поймала и у скольких поимка не доказана. Старая отметка чисел не несёт —
103
+ // null: «не знаем», а не «ноль пойманных».
104
+ function parseCounts(text) {
105
+ const caught = /^caught:[ \t]*(\d+)/m.exec(String(text || ""));
106
+ const unknown = /^unknown:[ \t]*(\d+)/m.exec(String(text || ""));
107
+ return caught && unknown ? { caught: Number(caught[1]), unknown: Number(unknown[1]) } : null;
108
+ }
109
+
110
+ // Чего уровень НЕ доказывает — строкой под ним. Отзыв с живого проекта 2026-09-11: «AQK-3, All
111
+ // levels reached» при конвейере, который ни разу не запускался. Ступени меряют оснащённость:
112
+ // гейты показаны на образцах КАТАЛОГА. Ловят ли они брак в файлах ПРОЕКТА, знает только проба —
113
+ // и строка говорит ровно то, что знает она. Непойманное идёт первым, даже если пойманного
114
+ // больше: «четыре из пяти» глаз читает как «всё хорошо».
115
+ //
116
+ // Планку ступеней не подняли намеренно: отметка пробы лежит в .gitignore, в конвейере её нет, и
117
+ // «AQK-2 только после пробы» уронило бы уровень и `badge --check` у всех разом.
118
+ function levelLimits(st) {
119
+ if (st?.state === "off") return { kind: "off" };
120
+ const behind = Number.isFinite(st?.behind) ? st.behind : null;
121
+ if (st?.classes?.length) return { kind: "blind", names: st.classes.map((x) => x.slug), behind };
122
+ if (st?.counts) {
123
+ const { caught, unknown } = st.counts;
124
+ if (unknown > 0) return { kind: "partial", caught, unknown, behind };
125
+ return caught > 0 ? { kind: "caught", caught, behind } : { kind: "nothing", behind };
126
+ }
127
+ if (st?.ran) return { kind: "old", behind };
128
+ return { kind: "never" };
129
+ }
130
+
131
+ export { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan, autoProbeAllowed, parseCounts, levelLimits };
package/tool/lib/core.mjs CHANGED
@@ -83,6 +83,7 @@ function commandRows(L) {
83
83
  { name: "learn", args: "", text: h.learn },
84
84
  { name: "context", args: "", text: h.context },
85
85
  { name: "context", args: "--full --install", text: h.contextInstall },
86
+ { name: "prompt", args: "", text: h.prompt },
86
87
  { name: "badge", args: "", text: h.badge },
87
88
  { name: "vitals", args: "", text: h.vitals },
88
89
  { name: "version", args: "", text: h.version },
@@ -142,9 +143,47 @@ async function writeIfAbsent(path, content, { force }) {
142
143
  return true;
143
144
  }
144
145
 
146
+ // СЛУЖЕБНЫЕ ФАЙЛЫ: состояние ЭТОЙ машины, переписываются каждым прогоном — в git им не место.
147
+ // Один список на `init` (кладёт в .gitignore), `doctor` (предупреждает, если git их видит) и
148
+ // читателей. Отзыв с живого проекта 2026-09-11: `.aqk/last-run.md` однажды закоммитили, и каждый
149
+ // `make check` оставлял изменённый файл. Целиком `.aqk/` не игнорируется: методички и правила в
150
+ // нём — содержимое проекта.
151
+ const RUNTIME_FILES = ["last-run.md", "last-probe.md", "advice-shown", "update-checked"];
152
+ const L_IGNORE_NOTE = LANG === "en"
153
+ ? "# aqk: this machine's state — rewritten by every run, it does not belong in git"
154
+ : "# aqk: состояние этой машины — переписывается каждым прогоном, в git ему не место";
155
+
156
+ // Дописать служебные файлы в .gitignore, не трогая чужих строк и не дублируя своих. Возвращает
157
+ // список добавленных строк — `init` называет их вслух: правка чужого файла без слова была бы
158
+ // тем самым «пишу в дерево без спроса».
159
+ async function ensureIgnored(cwd = CWD) {
160
+ const { readFile, writeFile } = await import("node:fs/promises");
161
+ const file = join(cwd, ".gitignore");
162
+ let text = "";
163
+ try { text = await readFile(file, "utf8"); } catch { /* файла нет — создадим */ }
164
+ const have = new Set(text.split(/\r?\n/).map((l) => l.trim()));
165
+ const add = RUNTIME_FILES.map((f) => `${TARGET_DIR}/${f}`).filter((l) => !have.has(l) && !have.has(`/${l}`));
166
+ if (!add.length) return [];
167
+ const head = text && !text.endsWith("\n") ? "\n" : "";
168
+ await writeFile(file, `${text}${head}${text ? "\n" : ""}${L_IGNORE_NOTE}\n${add.join("\n")}\n`, "utf8");
169
+ return add;
170
+ }
171
+
172
+ // Стоит ли хук pre-commit НА САМОМ ДЕЛЕ — в `.git/hooks`, а не в `.pre-commit-config.yaml`:
173
+ // запись в конфиге — намерение, сработает только то, что лежит в гите. Три ответа: true — стоит,
174
+ // false — нет, null — не git или файл не прочитать («не знаем» не сливается с «нет»).
175
+ // Одна функция на `vitals` (подключено ли) и `context` (что сказать агенту перед коммитом).
176
+ async function preCommitHook(cwd = CWD) {
177
+ const { readFile } = await import("node:fs/promises");
178
+ if (!(await exists(join(cwd, ".git")))) return null;
179
+ const hook = join(cwd, ".git", "hooks", "pre-commit");
180
+ if (!(await exists(hook))) return false;
181
+ try { return /pre-commit|aqk/i.test(await readFile(hook, "utf8")); } catch { return null; }
182
+ }
183
+
145
184
  export {
146
185
  copyDir, writeIfAbsent,
147
186
  PKG_ROOT, CWD, DOCS_SRC, RULES_SRC, TARGET_DIR, docPath,
148
187
  MANIFEST, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB,
149
- SELF, REPO_URL, c, exists, die, FEEDBACK_MARK, commandRows,
188
+ SELF, REPO_URL, c, exists, die, FEEDBACK_MARK, commandRows, preCommitHook, RUNTIME_FILES, ensureIgnored,
150
189
  };
@@ -0,0 +1,18 @@
1
+ // tool/lib/gate-worker.mjs — рабочий поток параллельного прогона (`doctor --run --jobs N`).
2
+ //
3
+ // ПОЧЕМУ ПОТОК, А НЕ АСИНХРОННЫЙ ЗАПУСК. Прогон стоит на `spawnSync` с таймаутом: его поведение
4
+ // на зависшем гейте замерено и описано (execution.mjs). Асинхронный `spawn` потребовал бы своего
5
+ // убийства дерева процессов на таймауте — на Windows это отдельная история. Поток выполняет ТОТ ЖЕ
6
+ // `spawnSync` с тем же таймаутом, и семантика прогона не меняется ни в чём, кроме одновременности.
7
+ // Зависимостей это не добавляет: worker_threads встроены в Node.
8
+ import { parentPort } from "node:worker_threads";
9
+ import { spawnSync } from "node:child_process";
10
+ import { gateCommand } from "./execution.mjs";
11
+
12
+ parentPort.on("message", ({ id, cmd, cwd, timeout }) => {
13
+ const r = spawnSync(gateCommand(cmd), { shell: true, cwd, encoding: "utf8", timeout });
14
+ parentPort.postMessage({
15
+ id, status: r.status, stdout: r.stdout || "", stderr: r.stderr || "",
16
+ error: r.error ? { code: r.error.code || String(r.error.message || r.error) } : null,
17
+ });
18
+ });
@@ -117,9 +117,36 @@ function probeSummary({ caught = 0, blind = 0, unknown = 0, unprobed = 0 } = {})
117
117
  // Найдено пробой на самом комплекте 2026-09-11: образец лёг на место общей библиотеки
118
118
  // kit/gates/_skip.sh, двенадцать гейтов вышли с кодом 2 — включая тот, что этот образец в
119
119
  // отдельной папке ловит. Остальные молчали, и проба назвала класс слепым.
120
+ // «ПОКРАСНЕЛ» ≠ «ПОЙМАЛ». Отзыв с живого проекта 2026-09-11: класс «цвет из токена темы» отмечен
121
+ // пойманным линтером, хотя Biome цвета не проверяет, — подсаженный кусок сломал форматирование.
122
+ // Вердикт сравнивал коды возврата и выбрасывал вывод. Теперь гейт засчитывается, только если в
123
+ // его выводе ПОСЛЕ подсадки подсаженный файл назван чаще, чем до неё. Путь ищется в любой
124
+ // форме: `src/x`, `./src/x`, абсолютный из песочницы (подстрока), с обратными слешами Windows.
125
+ // Одно имя файла без каталога не засчитывается: у двух файлов оно бывает одинаковым, и
126
+ // поимка чужого файла выдалась бы за поимку нашего. Безымянное падение — «неизвестно».
127
+ function namesPlant(before, after, rel) {
128
+ const forms = [rel, String(rel).replace(/\//g, "\\")];
129
+ const count = (s) => forms.reduce((n, f) => n + String(s || "").split(f).length - 1, 0);
130
+ return count(after) > count(before);
131
+ }
132
+
133
+ // ПАРА, А НЕ ОДИН ОБРАЗЕЦ. Имени файла мало: Biome, падая на форматировании, тоже называет файл.
134
+ // Поэтому поимка подтверждается зелёным образцом той же записи, положенным в то же место, — тем
135
+ // же приёмом, каким мы требуем от чужого гейта доказательства. Три исхода:
136
+ // caught — на красном покраснел и назвал файл, на зелёном промолчал (или файла не назвал);
137
+ // nameless — покраснел, но подсаженного файла не назвал: упал по своей причине;
138
+ // planting — краснеет и на зелёном, называя тот же файл: падает от самой подсадки.
139
+ // Зелёного образца под это расширение нет — судим по имени файла: пусть слабее, но это не
140
+ // «поймано» на ровном месте, и в README записи такой случай назван.
141
+ function catchVerdict(beforeOut, red, green, rel) {
142
+ if (!namesPlant(beforeOut, red?.out, rel)) return "nameless";
143
+ if (green && green.code === 1 && namesPlant(beforeOut, green.out, rel)) return "planting";
144
+ return "caught";
145
+ }
146
+
120
147
  function probeVerdictPaired(before, after) {
121
148
  const byName = new Map(after.map((r) => [r.name, r]));
122
- let usable = 0, alreadyRed = 0, failed = 0, caught = 0, brokenByPlant = 0;
149
+ let usable = 0, alreadyRed = 0, failed = 0, caught = 0, brokenByPlant = 0, unattributed = 0;
123
150
  for (const b of before) {
124
151
  const a = byName.get(b.name);
125
152
  const broke = (r) => !r || (r.code !== 0 && r.code !== 1);
@@ -130,10 +157,12 @@ function probeVerdictPaired(before, after) {
130
157
  }
131
158
  if (b.code === 1) { alreadyRed++; continue; }
132
159
  usable++;
133
- if (a.code === 1) caught++;
160
+ // `named === false` — покраснел, но подсаженного файла не назвал. `undefined` — вызывающий
161
+ // вывода не собирал (старые вызовы): тогда прежнее правило.
162
+ if (a.code === 1) { if (a.named === false) unattributed++; else caught++; }
134
163
  }
135
- const verdict = caught ? "caught" : !usable || brokenByPlant ? "unknown" : "blind";
136
- return { verdict, usable, alreadyRed, failed, caught, brokenByPlant };
164
+ const verdict = caught ? "caught" : !usable || brokenByPlant || unattributed ? "unknown" : "blind";
165
+ return { verdict, usable, alreadyRed, failed, caught, brokenByPlant, unattributed };
137
166
  }
138
167
 
139
168
  // Счёт непокрытого КЛАССАМИ, а не пробами. Замер на десяти живых репозиториях 2026-09-10:
@@ -160,4 +189,4 @@ function countProbe(records) {
160
189
  };
161
190
  }
162
191
 
163
- export { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe };
192
+ export { isFix, fixHotspots, probeSummary, probeVerdictPaired, countProbe, namesPlant, catchVerdict };
@@ -112,7 +112,7 @@ function unparsedLines(text) {
112
112
  // Список обязан совпадать с тем, что программа РЕАЛЬНО читает (`man?.<поле>` в tool/):
113
113
  // лишнее имя здесь молча узаконивает поле, которое ни на что не влияет, — та же тишина,
114
114
  // только с другой стороны. Сверено обходом: aqk, entry, rules, gates, samples, ratchets, lessons.
115
- const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory", "probe"];
115
+ const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory", "probe", "groups"];
116
116
 
117
117
  // ГДЕ У ПРОЕКТА ЛЕЖИТ РАЗЛОЖЕННЫЙ КОМПЛЕКТ. Список для шапки `doctor`. До 2026-09-08 он был
118
118
  // литеральным: `.aqk/rules`, `.aqk/docs`, `AGENTS.md` — независимо от того, что написано в
@@ -132,12 +132,16 @@ function layoutChecks(man, inKit) {
132
132
  const entries = (Array.isArray(man?.entry) ? man.entry : [])
133
133
  .filter((e) => typeof e === "string" && e.trim())
134
134
  .map((e) => e.trim());
135
+ // Третий элемент — обязателен ли пункт. Методички и стандарты `init` кладёт как пособие: проект
136
+ // вправе держать их где-то ещё или не держать вовсе, и их отсутствие — совет, а не приговор
137
+ // прогону (отзыв с живого проекта 2026-09-11: прогон краснел только из-за `.aqk/docs`). Точка
138
+ // входа, .gitignore и .git — обязательны: на них стоит своя проверка вердикта.
135
139
  return [
136
- [field("docs", inKit ? "kit/docs" : ".aqk/docs"), inKit ? L.doctor.docsKit : L.doctor.docs],
137
- [field("rules", inKit ? "kit/rules" : ".aqk/rules"), inKit ? L.doctor.rulesKit : L.doctor.rules],
138
- ...(entries.length ? entries : ["AGENTS.md"]).map((e) => [e, L.doctor.agents]),
139
- [".gitignore", L.doctor.gitignore],
140
- [".git", L.doctor.git],
140
+ [field("docs", inKit ? "kit/docs" : ".aqk/docs"), inKit ? L.doctor.docsKit : L.doctor.docs, false],
141
+ [field("rules", inKit ? "kit/rules" : ".aqk/rules"), inKit ? L.doctor.rulesKit : L.doctor.rules, false],
142
+ ...(entries.length ? entries : ["AGENTS.md"]).map((e) => [e, L.doctor.agents, true]),
143
+ [".gitignore", L.doctor.gitignore, true],
144
+ [".git", L.doctor.git, true],
141
145
  ];
142
146
  }
143
147
 
@@ -188,28 +192,68 @@ function coversOf(man) {
188
192
  // вывод перестают читать целиком, вместе с настоящими находками.
189
193
  const RULE_CODES = /--select[= ]([A-Za-z0-9,]+)/;
190
194
 
191
- function coversUnproven(man, catalog = [], linterConfigText = "") {
195
+ // КАКИМ ЛИНТЕРОМ ЗАКРЫТ ГЕЙТ. Отзыв с живого проекта 2026-09-11 (TypeScript на Biome): заявка
196
+ // «lint держит no-print-in-prod» всегда была «не подтверждена» — сверка искала коды ruff в
197
+ // конфиге Biome, где их не бывает никогда. Поле, снимающее шум, само его производило.
198
+ // Порядок: команда гейта; npm-скрипт, который она зовёт; единственный конфиг линтера в проекте.
199
+ function linterOf(cmd, scripts = {}, present = []) {
200
+ let c = String(cmd || "");
201
+ const m = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:-]+)/.exec(c);
202
+ if (m && typeof scripts[m[1]] === "string") c += ` ${scripts[m[1]]}`;
203
+ if (/\bbiome\b/.test(c)) return "biome";
204
+ if (/\beslint\b/.test(c)) return "eslint";
205
+ if (/\bruff\b/.test(c)) return "ruff";
206
+ return present.length === 1 ? present[0] : null;
207
+ }
208
+
209
+ // Правила записи на языке ЭТОГО линтера. ruff — коды из `--select` рецептов; eslint — имена из
210
+ // `--rule '{…}'`; Biome рецептов не имеет, у записи для него поле `biome_rules` (сверено по схеме
211
+ // конфигурации Biome 2.5.12). `none` — у линтера такого правила нет вовсе. `null` — не знаем.
212
+ function rulesFor(rec, linter) {
213
+ const recipes = Object.values(rec?.recipes && typeof rec.recipes === "object" ? rec.recipes : {}).map(String);
214
+ if (linter === "ruff") {
215
+ const codes = new Set();
216
+ for (const cmd of recipes) { const m = RULE_CODES.exec(cmd); if (m) for (const x of m[1].split(",")) if (x.trim()) codes.add(x.trim()); }
217
+ return codes.size ? [...codes] : null;
218
+ }
219
+ if (linter === "eslint") {
220
+ const names = new Set();
221
+ for (const cmd of recipes) for (const m of cmd.matchAll(/"([@a-z0-9/_-]+)"\s*:\s*\[?\s*"(?:error|warn)"/g)) names.add(m[1]);
222
+ return names.size ? [...names] : null;
223
+ }
224
+ if (linter === "biome") {
225
+ const v = typeof rec?.biome_rules === "string" ? rec.biome_rules.trim() : "";
226
+ if (!v) return null;
227
+ // Поле хранит `группа/правило` — так его ждёт `biome lint --only`; в biome.json правило
228
+ // лежит внутри объекта группы, поэтому ищется по имени без группы.
229
+ return v === "none" ? [] : v.split(",").map((x) => x.trim().split("/").pop()).filter(Boolean);
230
+ }
231
+ return null;
232
+ }
233
+
234
+ // `configs` — тексты конфигов по линтерам: { ruff, eslint, biome, scripts }. Строка (прежний вид
235
+ // вызова) — один общий текст для всех. Исходы: unproven (линтер известен, его правил нет ни в
236
+ // команде, ни в конфиге) · impossible (у линтера такого правила нет) · unknown (линтер не
237
+ // распознан или правил записи для него мы не знаем — «не умею проверить», не обвинение).
238
+ function coversUnproven(man, catalog = [], configs = "") {
192
239
  const { covered } = coversOf(man);
193
240
  if (!covered.size) return [];
194
241
  const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
195
- const cfg = String(linterConfigText || "");
242
+ const cfg = typeof configs === "string" ? { ruff: configs, eslint: configs, biome: configs } : (configs || {});
243
+ const present = ["ruff", "eslint", "biome"].filter((k) => cfg[k] && String(cfg[k]).trim() && typeof configs !== "string");
196
244
  const out = [];
197
245
 
198
246
  for (const [entry, gate] of covered) {
199
247
  const rec = catalog.find((r) => r.slug === entry);
200
- const recipes = rec?.recipes && typeof rec.recipes === "object" ? rec.recipes : {};
201
- // Коды берутся из любого рецепта записи: язык проекта здесь не важен, важно, что запись
202
- // ВООБЩЕ выражается кодами правил. Если ни один рецепт их не называет — сверять нечего.
203
- const codes = new Set();
204
- for (const cmd of Object.values(recipes)) {
205
- const m = RULE_CODES.exec(String(cmd || ""));
206
- if (m) for (const c of m[1].split(",")) if (c.trim()) codes.add(c.trim());
207
- }
208
- if (!codes.size) continue;
209
-
210
- const haystack = `${String(gates[gate] || "")}\n${cfg}`;
211
- const missing = [...codes].filter((c) => !haystack.includes(c));
212
- if (missing.length === codes.size) out.push({ entry, gate, codes: [...codes] });
248
+ // Запись, которая не выражается правилами НИ ОДНОГО линтера (переносимые проверки), не
249
+ // сверяется вовсе: сверять нечего, и выдумывать вердикт нельзя.
250
+ if (!["ruff", "eslint", "biome"].some((k) => rulesFor(rec, k) !== null)) continue;
251
+ const linter = linterOf(gates[gate], cfg.scripts || {}, present);
252
+ const rules = linter ? rulesFor(rec, linter) : null;
253
+ if (rules === null) { out.push({ entry, gate, codes: [], linter, kind: "unknown" }); continue; }
254
+ if (!rules.length) { out.push({ entry, gate, codes: [], linter, kind: "impossible" }); continue; }
255
+ const haystack = `${String(gates[gate] || "")}\n${String(cfg[linter] || "")}`;
256
+ if (rules.every((r) => !haystack.includes(r))) out.push({ entry, gate, codes: rules, linter, kind: "unproven" });
213
257
  }
214
258
  return out;
215
259
  }
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
  // Факты о репозитории. Только то, что видно машине: спрашивать человека анкетой
@@ -46,6 +57,8 @@ const MARKS = [
46
57
  ["has_docker", ["Dockerfile", "compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml"]],
47
58
  ["has_deps", ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "Gemfile", "pom.xml", "composer.json"]],
48
59
  ["has_env", [".env", ".env.example", ".env.sample"]],
60
+ // Линтер проекта — Biome: совет даётся на его языке, а не на языке eslint.
61
+ ["has_biome", ["biome.json", "biome.jsonc"]],
49
62
  // Обвес самого агента: настройки, хуки, права. Отдельный признак нужен, потому что записи
50
63
  // про него не касаются проектов, где агента не настраивали вовсе, — а таких большинство.
51
64
  // Файл `.claude/settings.json` есть и у того, кто настроил один только список разрешений;
@@ -100,7 +113,7 @@ async function detectFacts(man) {
100
113
  if (/\.(test|spec)\.[a-z]+$/i.test(it.name) || /^test_.*\.py$/i.test(it.name) || /_test\.go$/i.test(it.name)) hasTests = true;
101
114
  if (it.name.endsWith(".sql")) hasDb = true;
102
115
  if (/\.(css|scss|sass|less|styl|vue|svelte|astro)$/i.test(it.name)) hasUi = true;
103
- if (isApiSpec(it.name)) hasApiSpec = true;
116
+ if (isApiSpec(it.name) || (!hasApiSpec && it.name === "package.json" && (await isCodeContract(full)))) hasApiSpec = true;
104
117
  const dot = it.name.lastIndexOf(".");
105
118
  if (dot > 0) {
106
119
  const lang = EXT_LANG[it.name.slice(dot)];
@@ -310,6 +323,36 @@ function browserServerAdvice(facts, mcpText = "") {
310
323
  return { servers: ["chrome-devtools-mcp", "@playwright/mcp"] };
311
324
  }
312
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
+
313
356
  function recipeFor(rec, facts) {
314
357
  const cmd = pickRecipe(rec, facts);
315
358
  if (!cmd) return L.recipe.none;
@@ -378,40 +421,9 @@ async function matchCatalog(query) {
378
421
  return out;
379
422
  }
380
423
 
381
- // С ЧЕГО НАЧАТЬ: три записи вместо двадцати равнозначных крестов.
382
- //
383
- // Двадцать одинаковых требований — это ноль требований: закрывают первое попавшееся или не
384
- // закрывают ничего. Порядок НЕ по нашему вкусу; два признака, оба — факты, которые у нас уже
385
- // есть:
386
- // · запись родилась из настоящего отказа (`lifecycle: stable` — `proof` ссылается на журнал
387
- // шишек), то есть она про боль, которая СЛУЧАЛАСЬ, а не про «хорошую практику»;
388
- // · её можно закрыть одной готовой командой — цена входа минутная.
389
- // Сначала то, что и больно, и дёшево.
390
- //
391
- // При равенстве признаков — по имени: одинаковый ввод обязан давать одинаковый ответ, иначе
392
- // человек видит разный совет на двух прогонах подряд и перестаёт верить обоим.
393
- function startWith(entries, facts, n = 3) {
394
- const langs = facts?.langs ? [...facts.langs] : [];
395
- const cheap = (e) => {
396
- const r = e?.recipes && typeof e.recipes === "object" ? e.recipes : {};
397
- return [...langs, "native"].some((k) => r[k] && !/\{gate\}/.test(r[k])) ? 1 : 0;
398
- };
399
- // Зрелость НЕ поле записи, а вычисляемый признак: `proof` ссылается на журнал шишек. Тот же
400
- // признак, которым каталог отделяет условную запись с первого дня (`entryLifecycle`).
401
- // Заводить второй счёт нельзя: разъехавшись, они дали бы разные ответы про одну запись.
402
- const hurt = (e) => (/incidents\//.test(String(e?.proof || "")) ? 1 : 0);
403
- return [...entries]
404
- .sort((a, b) =>
405
- (hurt(b) + cheap(b)) - (hurt(a) + cheap(a)) ||
406
- cheap(b) - cheap(a) ||
407
- String(a.slug).localeCompare(String(b.slug)))
408
- .slice(0, n);
409
- }
410
-
411
424
  // Наружу — то, что действительно импортируют другие файлы и модульные проверки. Экспорт,
412
425
  // который никто не берёт, читается как часть договора и мешает менять внутренности.
413
426
  export {
414
427
  whichSync,
415
428
  EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor, browserServerAdvice, MARKS,
416
- startWith,
417
- stems, overlap, matchCatalog, isApiSpec };
429
+ stems, overlap, matchCatalog, isApiSpec, claudeSeesRules, claudeShimFor };