agent-quality-kit 0.7.0 → 0.9.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 (61) hide show
  1. package/README.md +135 -9
  2. package/README.ru.md +167 -23
  3. package/kit/docs/ai/project-baseline.md +14 -0
  4. package/kit/docs/ready-made-rules.md +103 -0
  5. package/kit/gates/_skip.sh +61 -1
  6. package/kit/gates/ci-not-hijackable/README.md +56 -0
  7. package/kit/gates/ci-not-hijackable/check.sh +73 -0
  8. package/kit/gates/ci-not-hijackable/gate.yml +19 -0
  9. package/kit/gates/ci-not-hijackable/green/.github/workflows/triage.yml +19 -0
  10. package/kit/gates/ci-not-hijackable/red/.github/workflows/triage.yml +18 -0
  11. package/kit/gates/color-from-token/check.sh +10 -2
  12. package/kit/gates/color-from-token/green/Button.tsx +2 -0
  13. package/kit/gates/complexity-limit/check.sh +6 -7
  14. package/kit/gates/duplicate-code/check.sh +5 -1
  15. package/kit/gates/entry-links-exist/check.sh +4 -1
  16. package/kit/gates/entry-links-exist/green/AGENTS.md +2 -0
  17. package/kit/gates/file-size-limit/check.sh +1 -1
  18. package/kit/gates/lesson-has-outcome/check.sh +5 -1
  19. package/kit/gates/mcp-server-resolves/README.md +62 -0
  20. package/kit/gates/mcp-server-resolves/check.sh +110 -0
  21. package/kit/gates/mcp-server-resolves/gate.yml +18 -0
  22. package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
  23. package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
  24. package/kit/gates/secrets-not-in-code/check.sh +16 -3
  25. package/kit/gates/secrets-not-in-code/green/testdata/certificate/key.pem +3 -0
  26. package/kit/gates/todo-without-task/check.sh +1 -1
  27. package/kit/gates/todo-without-task/green/app.py +1 -0
  28. package/llms.txt +38 -2
  29. package/package.json +2 -3
  30. package/tool/commands/context.mjs +264 -0
  31. package/tool/commands/doctor.mjs +104 -28
  32. package/tool/commands/learn.mjs +159 -0
  33. package/tool/commands/project.mjs +19 -2
  34. package/tool/commands/prove.mjs +1 -0
  35. package/tool/commands/report.mjs +33 -1
  36. package/tool/commands/vitals.mjs +159 -0
  37. package/tool/i18n/en-docs.mjs +125 -1
  38. package/tool/i18n/en.mjs +39 -36
  39. package/tool/i18n/index.mjs +36 -3
  40. package/tool/i18n/ru-docs.mjs +127 -1
  41. package/tool/i18n/ru.mjs +39 -36
  42. package/tool/lib/banner.mjs +59 -0
  43. package/tool/lib/brief.mjs +192 -0
  44. package/tool/lib/core.mjs +32 -1
  45. package/tool/lib/evidence.mjs +124 -0
  46. package/tool/lib/manifest.mjs +173 -15
  47. package/tool/lib/prove.mjs +24 -2
  48. package/tool/lib/repo.mjs +31 -1
  49. package/tool/lib/scope.mjs +10 -1
  50. package/tool/lib/templates.mjs +1 -0
  51. package/tool/program.mjs +45 -23
  52. package/tool/selfcheck/smoke.sh +592 -3
  53. package/tool/selfcheck/units-banner.mjs +65 -0
  54. package/tool/selfcheck/units-brief.mjs +97 -0
  55. package/tool/selfcheck/units-context.mjs +188 -0
  56. package/tool/selfcheck/units-evidence.mjs +83 -0
  57. package/tool/selfcheck/units-learn.mjs +88 -0
  58. package/tool/selfcheck/units-level.mjs +211 -3
  59. package/tool/selfcheck/units-repo.mjs +134 -0
  60. package/tool/selfcheck/units-vitals.mjs +62 -0
  61. package/tool/selfcheck/units.mjs +4 -75
@@ -0,0 +1,124 @@
1
+ // tool/lib/evidence.mjs — привязка доказательства к дифу.
2
+ //
3
+ // ЗАЧЕМ. «Готово = доказано» — центральное правило свода, и оно единственное из четырнадцати,
4
+ // за которым не следила машина: в `AGENTS.md` его сторожем честно записан человек. Цена этого
5
+ // измерена 2026-09-08 прогоном чужого инструмента по восьми нашим коммитам: `publish.yml`
6
+ // менялся и не был назван ни одной командой проверки — и именно он оказался сломан. Коммит при
7
+ // этом говорил «прогон: units 51, smoke 81». Утверждение было правдой и не относилось к делу.
8
+ //
9
+ // Механизм взят у [donecheck](https://github.com/AtharvaMaik/donecheck) (MIT, ноль
10
+ // зависимостей). Своего разбора команд не пишем — берём идею, а не код: donecheck САМ запускает
11
+ // команды проверки, а `doctor --run` их уже запустил, и обёртка означала бы двойной прогон
12
+ // всего набора гейтов. Здесь данные уже есть.
13
+ //
14
+ // ОДНО ОТЛИЧИЕ, И ОНО НАМЕРЕННОЕ. donecheck считает файл покрытым и по голому имени. В нашем
15
+ // каталоге имя `check.sh` носят два десятка разных файлов: мягкое сравнение объявило бы
16
+ // покрытым каждый из них, стоит любому гейту напечатать это слово. Ошибка в сторону «покрыто»
17
+ // — это тишина, а тишина здесь и есть предмет спора. Сравниваем строго, по полному пути.
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { changedFiles, pathsIn, normPath } from "./scope.mjs";
23
+
24
+ // Расширения, для которых «никто не проверил» — утверждение о деле. Документ не проверяется
25
+ // прогоном по своей природе, и требовать этого значило бы красить каждую правку README.
26
+ const CODE_EXT = new Set([
27
+ "c", "cjs", "cpp", "cs", "css", "go", "h", "java", "js", "json", "jsx", "kt", "mjs", "mts",
28
+ "php", "pl", "py", "rb", "rs", "scala", "sh", "sql", "swift", "toml", "ts", "tsx", "vue",
29
+ "yaml", "yml",
30
+ ]);
31
+
32
+ // Образцы гейтов исключены по той же причине, по какой их исключает каждая сканирующая
33
+ // проверка каталога: они существуют, чтобы быть неправильными, и командой проверки покрыты
34
+ // быть не могут. Без этого замер дал восемь ложных находок из шестнадцати.
35
+ function isSample(p) {
36
+ return /(^|\/)gates\/[^/]+\/(red|green)(\/|$)/.test(p);
37
+ }
38
+
39
+ function ext(p) {
40
+ const m = /\.([A-Za-z0-9]+)$/.exec(p);
41
+ return m ? m[1].toLowerCase() : "";
42
+ }
43
+
44
+ // Файлы кода, которые внёс диф. null — если ссылка не разобрана: «сравнили не с тем» обязано
45
+ // отличаться от «изменений нет».
46
+ function changedCode(ref, cwd) {
47
+ const all = changedFiles(ref, cwd);
48
+ if (all === null) return null;
49
+ return [...all].map(normPath).filter((p) => CODE_EXT.has(ext(p)) && !isSample(p)).sort();
50
+ }
51
+
52
+ // Куда гейт был НАПРАВЛЕН: цели из его команды. Проверка, обошедшая каталог и не нашедшая
53
+ // ничего, файла не назовёт — и «просмотрен и чист» стало бы неотличимо от «никто не смотрел».
54
+ // Различить это по выводу нельзя, а по команде можно: она говорит, куда гейт направляли.
55
+ function targetsOf(cmd, isDir) {
56
+ const out = [];
57
+ for (const tok of String(cmd || "").split(/\s+/)) {
58
+ if (tok.startsWith("-")) continue;
59
+ const t = normPath(tok);
60
+ if (tok === "." || tok === "./") { out.push(""); continue; }
61
+ if (t && !t.includes("*") && isDir(t)) out.push(t.replace(/\/$/, ""));
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // Три состояния, а не два, и это главное в этой функции.
67
+ //
68
+ // named — гейт напечатал путь файла: он его точно видел и что-то о нём сказал;
69
+ // silent — гейт был направлен в каталог с этим файлом, но ничего не напечатал. Просмотрен и
70
+ // чист либо не просмотрен вовсе — по выводу это неразличимо, и выдавать одно за
71
+ // другое нельзя ни в ту, ни в другую сторону;
72
+ // none — ни одна команда даже не была направлена туда, где файл лежит.
73
+ //
74
+ // Двух состояний хватило ровно до первого прогона: `tool/commands/doctor.mjs` попал в «никем
75
+ // не проверен», хотя его обходят пять проверок — они просто промолчали, потому что нашли
76
+ // чисто. Замеряно 2026-09-08.
77
+ function coverage(files, results, isDir = () => false) {
78
+ const seen = results.map((r) => ({
79
+ name: r.name,
80
+ paths: pathsIn(`${r.cmd || ""}\n${r.out || ""}`),
81
+ targets: targetsOf(r.cmd, isDir),
82
+ }));
83
+ const covered = new Map();
84
+ const silent = new Map();
85
+ const uncovered = [];
86
+ for (const raw of files) {
87
+ const f = normPath(raw);
88
+ const by = seen.filter((n) => n.paths.has(f)).map((n) => n.name);
89
+ if (by.length) { covered.set(raw, by); continue; }
90
+ const aimed = seen
91
+ .filter((n) => n.targets.some((t) => t === "" || f === t || f.startsWith(`${t}/`)))
92
+ .map((n) => n.name);
93
+ if (aimed.length) silent.set(raw, aimed);
94
+ else uncovered.push(raw);
95
+ }
96
+ return { covered, silent, uncovered };
97
+ }
98
+
99
+ // Отпечаток того, о чём отчитывается расписка: базовый коммит, набор команд, содержимое файлов.
100
+ // Изменилось что угодно из этого — расписка устарела, и «прогнал, потом поправил ещё три файла»
101
+ // перестаёт быть неотличимым от «прогнал».
102
+ //
103
+ // files — пары [путь, содержимое]. Содержимое передаётся, а не читается здесь: вызывающий уже
104
+ // держит файлы в руках, а функция без ввода-вывода проверяется модульно.
105
+ function evidenceHash(base, commands, files) {
106
+ const h = createHash("sha256");
107
+ h.update(`base:${base || ""}\0`);
108
+ for (const c of commands) h.update(`cmd:${c?.cmd ?? c}\0`);
109
+ for (const [p, body] of [...files].sort((a, b) => String(a[0]).localeCompare(String(b[0])))) {
110
+ h.update(`path:${normPath(p)}\0`);
111
+ h.update(body === null || body === undefined ? "<missing>" : String(body));
112
+ h.update("\0");
113
+ }
114
+ return h.digest("hex");
115
+ }
116
+
117
+ // Читает содержимое для отпечатка. Отсутствующий файл — тоже факт: удаление меняет расписку.
118
+ function readForHash(files, cwd) {
119
+ return files.map((p) => {
120
+ try { return [p, readFileSync(join(cwd, p), "utf8")]; } catch { return [p, null]; }
121
+ });
122
+ }
123
+
124
+ export { changedCode, coverage, evidenceHash, readForHash };
@@ -25,6 +25,14 @@ function stripComment(raw) {
25
25
  return raw[i] === "#" ? raw.slice(0, i) : raw.slice(0, i + 1);
26
26
  }
27
27
 
28
+ // Список в одну строку: `[AGENTS.md, docs/START.md]`. Вынесен отдельно, потому что нужен на
29
+ // двух уровнях, а два одинаковых куска разбора расходятся ровно так же, как два свода правил.
30
+ function inlineList(v) {
31
+ const s = String(v).trim();
32
+ if (!s.startsWith("[") || !s.endsWith("]")) return null;
33
+ return s.slice(1, -1).split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
34
+ }
35
+
28
36
  function parseManifest(text) {
29
37
  const out = {};
30
38
  let section = null;
@@ -48,26 +56,51 @@ function parseManifest(text) {
48
56
 
49
57
  if (indented && section) {
50
58
  if (typeof out[section] !== "object" || Array.isArray(out[section])) out[section] = {};
51
- out[section][key] = clean;
59
+ // Список в одну строку разбирается и на вложенном уровне: `covers:` ниже ` lint: [a, b]`.
60
+ // Раньше вложенное значение всегда оставалось строкой, и `[a, b]` превращалось в текст
61
+ // «[a, b]» — молча, как это умеет только разбор без схемы. Наверху такой список уже
62
+ // разбирался; расхождение между уровнями и есть источник тихой неправды.
63
+ out[section][key] = inlineList(clean) || clean;
52
64
  continue;
53
65
  }
54
66
  section = key;
55
67
  // Список в одну строку: entry: [AGENTS.md, docs/START.md]. Люди пишут именно так —
56
68
  // и раньше манифест молча читался как пустой, а проект получал вердикт «нет AQK-0».
57
69
  // Неверный вердикт хуже отсутствия вердикта: ему верят.
58
- if (clean.startsWith("[") && clean.endsWith("]")) {
59
- out[key] = clean
60
- .slice(1, -1)
61
- .split(",")
62
- .map((v) => v.trim().replace(/^["']|["']$/g, ""))
63
- .filter(Boolean);
64
- continue;
65
- }
70
+ const list = inlineList(clean);
71
+ if (list) { out[key] = list; continue; }
66
72
  out[key] = clean === "" ? {} : clean;
67
73
  }
68
74
  return out;
69
75
  }
70
76
 
77
+ // СТРОКА, КОТОРУЮ РАЗБОР НЕ ПОНЯЛ, НЕ ИСЧЕЗАЕТ МОЛЧА.
78
+ //
79
+ // Найдено 2026-09-09 случайно: подсаживали падающий гейт с именем «плохой», чтобы посмотреть на
80
+ // вывод, — и прогон вышел с НУЛЁМ. Гейт не упал: его не существовало. Имена разбираются только
81
+ // латиницей, а строка, не подошедшая под это, выбрасывалась без единого слова.
82
+ //
83
+ // Это наш класс в чистом виде: человек объявил проверку, видит её в файле, а её нет. Хуже
84
+ // опечатки в имени поля — ту мы называем с 2026-09-06, а эту не называли вовсе.
85
+ //
86
+ // ЧИНИТСЯ ГОЛОСОМ, А НЕ АЛФАВИТОМ. Расширить набор букв — залатать один случай; строк, которые
87
+ // разбор не понимает, бывает больше (табуляция вместо пробелов, двоеточие в значении без
88
+ // кавычек). Называется любая: разбор ограниченного подмножества YAML честен ровно до тех пор,
89
+ // пока говорит, чего не взял.
90
+ function unparsedLines(text) {
91
+ const out = [];
92
+ let n = 0;
93
+ for (const raw of String(text).split("\n")) {
94
+ n += 1;
95
+ const line = stripComment(raw).replace(/\s+$/, "");
96
+ if (!line.trim()) continue;
97
+ if (line.trim().startsWith("- ")) continue;
98
+ if (/^\s*[A-Za-z0-9_-]+:\s*(.*)$/.test(line)) continue;
99
+ out.push({ line: n, text: raw.trim() });
100
+ }
101
+ return out;
102
+ }
103
+
71
104
  // Поля, которые манифест знает. Список здесь, а не в схеме-файле: зависимостей у программы
72
105
  // нет, а схема на восемь ключей, которую надо валидировать библиотекой, стоит дороже, чем
73
106
  // защищает.
@@ -79,7 +112,107 @@ function parseManifest(text) {
79
112
  // Список обязан совпадать с тем, что программа РЕАЛЬНО читает (`man?.<поле>` в tool/):
80
113
  // лишнее имя здесь молча узаконивает поле, которое ни на что не влияет, — та же тишина,
81
114
  // только с другой стороны. Сверено обходом: aqk, entry, rules, gates, samples, ratchets, lessons.
82
- const KNOWN_KEYS = ["aqk", "entry", "rules", "gates", "samples", "ratchets", "lessons", "advisory"];
115
+ const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory"];
116
+
117
+ // ГДЕ У ПРОЕКТА ЛЕЖИТ РАЗЛОЖЕННЫЙ КОМПЛЕКТ. Список для шапки `doctor`. До 2026-09-08 он был
118
+ // литеральным: `.aqk/rules`, `.aqk/docs`, `AGENTS.md` — независимо от того, что написано в
119
+ // манифесте. Второй пользователь прислал разбор: у него `rules: .temper/rules`, правила на
120
+ // месте, гейт entry-links-exist их видит, СТУПЕНЬ считается по манифесту и берётся — а шапка
121
+ // рисует два креста и советует сделать сделанное. Вывод расходился с собственным вердиктом
122
+ // программы; это хуже, чем просто неверный вывод, потому что оба напечатаны рядом.
123
+ // Поля `docs:` не существовало вовсе: методички было некуда перенести, и крест за них снять
124
+ // было нельзя ничем. Умолчания остаются для тех, кто полей не завёл, — это большинство.
125
+ function layoutChecks(man, inKit) {
126
+ const field = (name, dflt) => {
127
+ const v = man && typeof man === "object" && !Array.isArray(man) ? man[name] : null;
128
+ return typeof v === "string" && v.trim() ? v.trim() : dflt;
129
+ };
130
+ // Точка входа — тоже поле манифеста, и по той же причине: проект на `CLAUDE.md` получал крест
131
+ // за `AGENTS.md`, которого у него намеренно нет. Класс дефекта один, чинится он один раз.
132
+ const entries = (Array.isArray(man?.entry) ? man.entry : [])
133
+ .filter((e) => typeof e === "string" && e.trim())
134
+ .map((e) => e.trim());
135
+ 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],
141
+ ];
142
+ }
143
+
144
+ // ЗАПИСЬ ЗАКРЫТА ДРУГИМ АРБИТРОМ. `covers: { lint: [no-print-in-prod, swallowed-error] }`
145
+ // читается как «гейт lint держит эти записи каталога». Просьба первого чужого пользователя,
146
+ // названная им первой: без этого `doctor` каждый прогон печатал «применимо, но не поставлено»
147
+ // про то, что у него закрыто biome. Неправда в собственном выводе дороже всех остальных: весь
148
+ // стандарт стоит на том, что вывод не врёт.
149
+ //
150
+ // НО ЭТО НЕ ПРИЗНАНИЕ НА СЛОВО. Закрывать может только гейт, ОБЪЯВЛЕННЫЙ в `gates:` непустой
151
+ // командой. Иначе поле превращается в способ объявить защиту, которой нет, — ровно тот отказ,
152
+ // против которого написан комплект. Необъявленные называются поимённо, а не отбрасываются молча.
153
+ function coversOf(man) {
154
+ const covered = new Map();
155
+ const unknownGates = [];
156
+ const raw = man?.covers;
157
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { covered, unknownGates };
158
+
159
+ const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
160
+ const declared = new Set(Object.entries(gates).filter(([, cmd]) => String(cmd || "").trim()).map(([k]) => k));
161
+
162
+ for (const [gate, value] of Object.entries(raw)) {
163
+ const entries = Array.isArray(value)
164
+ ? value
165
+ : String(value || "").split(",").map((x) => x.trim()).filter(Boolean);
166
+ if (!declared.has(gate)) { if (entries.length) unknownGates.push(gate); continue; }
167
+ for (const e of entries) if (!covered.has(e)) covered.set(e, gate);
168
+ }
169
+ return { covered, unknownGates };
170
+ }
171
+
172
+ // ЗАЯВКА `covers` СВЕРЯЕТСЯ, А НЕ ПРИНИМАЕТСЯ НА СЛОВО — насколько это вообще возможно.
173
+ //
174
+ // Поле `covers` заведено 2026-09-08 утром, и тогда же в коммите было записано честное: «снимает
175
+ // запись с долга по СЛОВУ человека; проверить, что чужой гейт ловит то же самое, машина не
176
+ // может». К вечеру выяснилось, что это не теория. Запуск на настоящем `ruff.toml` из живого
177
+ // проекта: девятнадцать групп правил в `extend-select`, а `print()` не ловится — группы `T20`
178
+ // среди них нет. Заявка «no-print-in-prod держит наш lint» была бы ложной, а запись ушла бы из
179
+ // долга. То есть поле, снимающее неправду из вывода, само стало бы способом её произвести.
180
+ //
181
+ // ЧТО СВЕРЯЕТСЯ. У записи каталога в рецепте стоят коды правил: `ruff check --select T20 {dir}`.
182
+ // Если ни команда закрывающего гейта, ни конфиг линтера этих кодов не называют — заявка не
183
+ // подтверждена. Записи без кодов в рецепте (переносимые проверки) не сверяются вовсе: там
184
+ // сверять нечего, и выдумывать вердикт нельзя.
185
+ //
186
+ // ПОЧЕМУ «НЕ ПОДТВЕРЖДЕНО», А НЕ «ЛОЖЬ». Правило могло прийти из плагина, пресета или общего
187
+ // конфига этажом выше. Объявлять такое ошибкой значит краснеть на нормальном укладе — а такой
188
+ // вывод перестают читать целиком, вместе с настоящими находками.
189
+ const RULE_CODES = /--select[= ]([A-Za-z0-9,]+)/;
190
+
191
+ function coversUnproven(man, catalog = [], linterConfigText = "") {
192
+ const { covered } = coversOf(man);
193
+ if (!covered.size) return [];
194
+ const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
195
+ const cfg = String(linterConfigText || "");
196
+ const out = [];
197
+
198
+ for (const [entry, gate] of covered) {
199
+ 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] });
213
+ }
214
+ return out;
215
+ }
83
216
 
84
217
  function unknownKeys(man) {
85
218
  if (!man || typeof man !== "object" || Array.isArray(man)) return [];
@@ -119,16 +252,41 @@ function entryLifecycle(rec) {
119
252
  return { state, why, supersededBy: supersededBy || null, problem };
120
253
  }
121
254
 
122
- // СОВЕЩАТЕЛЬНЫЕ ГЕЙТЫ. Правило вводят в проект, где старый код ему не соответствует. Храповик
123
- // отвечает на это одним способом: старое становится долгом, новое блокируется. Второй способ —
124
- // показывать, не роняя, пока команда договаривается о правиле. Без него у человека остаётся
125
- // выбор из двух крайностей: включить и сломать сборку либо не включать вовсе.
255
+ // Программа, без которой запись каталога не работает вовсе: поле `requires` в её `gate.yml`.
256
+ // Возвращает список НЕДОСТАЮЩИХ программ или null.
126
257
  //
258
+ // ЗАЧЕМ ОБЩИМ. Переносимый рецепт бывает обёрткой вокруг готового инструмента: первое слово
259
+ // команды тогда `bash`, и по нему не видно, чего не хватает. Этот вопрос задают трое —
260
+ // приёмка каталога, доказательство гейтов и осмотр обвязки, — и каждый отвечал на него
261
+ // по-своему или не отвечал вовсе. 2026-09-09: `prove` объявлял такой гейт сломанным, а
262
+ // `vitals` печатал «все инструменты на месте» ровно там, где прогон краснел.
263
+ // `has` передаётся вызывающим, а не берётся отсюда: manifest.mjs не должен знать про осмотр
264
+ // репозитория — импорт в обратную сторону завёл бы цикл. Заодно функция проверяема модульно.
265
+ async function gateRequires(samplesDir, name, has) {
266
+ if (!samplesDir) return null;
267
+ const yml = join(CWD, samplesDir, name, "gate.yml");
268
+ if (!(await exists(yml))) return null;
269
+ try {
270
+ const rec = parseManifest(await readFile(yml, "utf8"));
271
+ const raw = typeof rec?.requires === "string" ? rec.requires.trim() : "";
272
+ if (!raw) return null;
273
+ const missing = raw.split(",").map((x) => x.trim()).filter(Boolean).filter((x) => !has(x));
274
+ return missing.length ? missing : null;
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
127
280
  // ПОЧЕМУ СПИСКОМ В МАНИФЕСТЕ, А НЕ ФЛАГОМ ПРОГОНА. Флаг «не роняй ничего» — это тот самый
128
281
  // `continue-on-error`, против которого написана наша запись ci-actually-fails: он понижает всё
129
282
  // разом, не виден в дифе и не назван в сводке. Список виден в манифесте, называется поимённо и
130
283
  // печатается КАЖДЫЙ прогон: совещательный гейт, о котором забыли, — это выключенная проверка,
131
284
  // и молчать о нём нельзя.
285
+ // СОВЕЩАТЕЛЬНЫЕ ГЕЙТЫ. Правило вводят в проект, где старый код ему не соответствует. Храповик
286
+ // отвечает на это одним способом: старое становится долгом, новое блокируется. Второй способ —
287
+ // показывать, не роняя, пока команда договаривается о правиле. Без него у человека остаётся
288
+ // выбор из двух крайностей: включить и сломать сборку либо не включать вовсе.
289
+ //
132
290
  function advisorySet(man) {
133
291
  const v = man?.advisory;
134
292
  if (Array.isArray(v)) return new Set(v.map((x) => String(x).trim()).filter(Boolean));
@@ -215,5 +373,5 @@ function manifestWithGate(text, slug, cmd) {
215
373
 
216
374
  export {
217
375
  parseManifest, readManifest, assessLevel, manifestWithGate, unknownKeys, KNOWN_KEYS,
218
- entryLifecycle, advisorySet,
376
+ entryLifecycle, advisorySet, gateRequires, layoutChecks, coversOf, coversUnproven, unparsedLines,
219
377
  };
@@ -13,7 +13,8 @@ import { spawnSync } from "node:child_process";
13
13
  import { readFile } from "node:fs/promises";
14
14
  import { join } from "node:path";
15
15
  import { CWD, exists } from "./core.mjs";
16
- import { parseManifest } from "./manifest.mjs";
16
+ import { parseManifest, gateRequires } from "./manifest.mjs";
17
+ import { whichSync } from "./repo.mjs";
17
18
 
18
19
  // Гейт можно доказать, если у него есть оба образца. Признак по образцам, а не по тексту
19
20
  // команды: запись, делегирующая готовому инструменту (`npx knip --directory .`), каталог
@@ -63,7 +64,19 @@ function targetIsLast(parts) {
63
64
  return last === "." || last === "./";
64
65
  }
65
66
 
66
- function commandFor(cmd, dir) {
67
+ // Путь уходит в СТРОКУ КОМАНДЫ, а её исполняет `sh` — не Node. Для `sh` обратный слэш это
68
+ // экранирование, а не разделитель: `gates\\x\\red` превращается в `gatesxred`, каталога с таким
69
+ // именем нет, обход молчит, гейт выходит с нулём — и доказательство объявляет ИСПРАВНЫЙ гейт
70
+ // сломанным. Найдено вторым пользователем 2026-09-08 на Windows: `path.join` там даёт `\\`,
71
+ // и падали ровно те записи, что обходят дерево через `find`; на `grep -r` выживали, потому что
72
+ // Windows разбирает слэши сам. Худший из возможных отказов: комплект против «зелёного, потому
73
+ // что ничего не проверялось» сам выдал зелёное за красное и отобрал у проекта ступень.
74
+ // Нормализация стоит ЗДЕСЬ, а не у сборщика пути: это единственная дверь из мира путей Node
75
+ // в мир оболочки, и закрывать её надо один раз, кто бы путь ни собрал.
76
+ const forShell = (p) => String(p).replace(/\\/g, "/");
77
+
78
+ function commandFor(cmd, dirRaw) {
79
+ const dir = forShell(dirRaw);
67
80
  const { parts, nativeAt } = unwrap(cmd);
68
81
  const out = parts.slice();
69
82
  out[out.length - 1] = dir;
@@ -116,6 +129,15 @@ async function proveGates(man, { timeoutMs = 300000 } = {}) {
116
129
  continue;
117
130
  }
118
131
 
132
+ // Программы, без которой запись не работает, может не быть на машине — тогда доказывать
133
+ // нечем, а не «гейт сломан». Проверяется ДО запуска: без неё обёртка краснеет на обоих
134
+ // образцах, и вердикт вышел бы «краснеет на исправном коде».
135
+ const missing = await gateRequires(samplesDir, name, whichSync);
136
+ if (missing) {
137
+ results.push({ name, state: "unprovable", why: "needs-program", missing });
138
+ continue;
139
+ }
140
+
119
141
  // Образцы бывают написаны под КОНКРЕТНЫЙ рецепт: запись без переносимой проверки называет
120
142
  // его полем `samples_for`. Если в проекте стоит рецепт под другой язык, гонять по этим
121
143
  // образцам нечего — они на чужом языке. Найдено первым же прогоном на своём репозитории:
package/tool/lib/repo.mjs CHANGED
@@ -47,6 +47,10 @@ const MARKS = [
47
47
  // чем `has_agent_config`: настройки заводят не все, а свод — почти каждый, кто работает с
48
48
  // агентом. Записи про личные файлы касаются именно вторых.
49
49
  ["has_agent_entry", ["CLAUDE.md", "AGENTS.md", ".claude", ".cursor/rules", ".github/copilot-instructions.md"]],
50
+ // Агенту подключены внешние инструменты через MCP. Отдельный признак, а не часть
51
+ // `has_agent_config`: настройки заводят почти все, а MCP-серверы — те, кто дал агенту
52
+ // браузер, базу или трекер. Записи про мёртвый сервер касаются только вторых.
53
+ ["has_mcp", [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".claude/mcp.json"]],
50
54
  ];
51
55
 
52
56
  async function detectFacts(man) {
@@ -253,6 +257,32 @@ function pickRecipe(rec, facts, missing) {
253
257
  return recipes.any || null;
254
258
  }
255
259
 
260
+ // БРАУЗЕР У АГЕНТА. Проект с интерфейсом, у которого агенту нечем посмотреть на собственное
261
+ // изменение, — это §16 методички: «прогон на живой системе перед сдачей, как пользователь, через
262
+ // настоящий вход». Без браузера агент судит о своей работе по тому, что компилируется, а это
263
+ // разные утверждения; мок-тесты сюда же.
264
+ //
265
+ // СОВЕТ, А НЕ ЗАПИСЬ КАТАЛОГА, и разница принципиальная. Запись каталога — машинная проверка с
266
+ // вердиктом; здесь вердикта нет: отсутствие браузерного сервера не дефект, а незанятая
267
+ // возможность. Красить это в красное значит требовать поставить инструмент — мы так не делаем
268
+ // ни с чем другим.
269
+ //
270
+ // НАЗЫВАЮТСЯ ДВА, а не один. `chrome-devtools-mcp` от Google — трассировка производительности,
271
+ // Lighthouse, сеть, консоль; `@playwright/mcp` от Microsoft — прогон сценариев в трёх движках.
272
+ // Назвать один значит выдать выбор за факт: они решают разные задачи, и выбирает человек.
273
+ // Проверено по реестру npm 2026-09-08: 1.9.0 и 0.0.80, оба живые, оба от первых лиц.
274
+ //
275
+ // НЕ ВСЕМ. Проекту без интерфейса браузер не нужен, а совет, показанный не тому, стоит доверия
276
+ // всем остальным советам — та же норма, что у записей каталога.
277
+ const BROWSER_SERVERS = ["chrome-devtools-mcp", "@playwright/mcp", "playwright-mcp", "puppeteer-mcp", "puppeteer"];
278
+
279
+ function browserServerAdvice(facts, mcpText = "") {
280
+ if (!facts?.has_ui) return null;
281
+ const text = String(mcpText || "");
282
+ if (BROWSER_SERVERS.some((n) => text.includes(n))) return null;
283
+ return { servers: ["chrome-devtools-mcp", "@playwright/mcp"] };
284
+ }
285
+
256
286
  function recipeFor(rec, facts) {
257
287
  const cmd = pickRecipe(rec, facts);
258
288
  if (!cmd) return L.recipe.none;
@@ -325,6 +355,6 @@ async function matchCatalog(query) {
325
355
  // который никто не берёт, читается как часть договора и мешает менять внутренности.
326
356
  export {
327
357
  whichSync,
328
- EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor,
358
+ EXT_LANG, detectFacts, readCatalog, triggerVerdict, pickRecipe, recipeFor, browserServerAdvice, MARKS,
329
359
  stems, overlap, matchCatalog,
330
360
  };
@@ -33,6 +33,15 @@ const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
33
33
  // принималась бы за находку и отбрасывалась.
34
34
  const CANDIDATE = /[\w.@+-]+(?:\/[\w.@+-]+)*\.[A-Za-z][A-Za-z0-9]{0,9}/g;
35
35
 
36
+ // Все пути, названные в тексте. Тот же разбор, что при сужении: цвет снимается, вид пути
37
+ // нормализуется. Вынесено отдельно, потому что покрытие спрашивает у вывода обратное:
38
+ // не «попадает ли находка в диф», а «назвал ли гейт этот файл».
39
+ function pathsIn(text) {
40
+ const out = new Set();
41
+ for (const m of String(text).replace(ANSI, "").matchAll(CANDIDATE)) out.add(normPath(m[0]));
42
+ return out;
43
+ }
44
+
36
45
  function inScope(candidate, files) {
37
46
  const c = normPath(candidate);
38
47
  if (files.has(c)) return true;
@@ -128,4 +137,4 @@ function changedFiles(ref, cwd) {
128
137
  );
129
138
  }
130
139
 
131
- export { scopeOutput, splitAdvice, changedFiles };
140
+ export { scopeOutput, splitAdvice, changedFiles, pathsIn, normPath };
@@ -28,6 +28,7 @@ const MANIFEST_YML = [
28
28
  "",
29
29
  d.rules,
30
30
  "rules: .aqk/rules",
31
+ "docs: .aqk/docs",
31
32
  "gates:",
32
33
  ...d.gates,
33
34
  "",
package/tool/program.mjs CHANGED
@@ -16,14 +16,18 @@
16
16
 
17
17
  import { realpathSync } from "node:fs";
18
18
  import { fileURLToPath } from "node:url";
19
- import { c, SELF } from "./lib/core.mjs";
19
+ import { c, SELF, commandRows } from "./lib/core.mjs";
20
+ import { banner } from "./lib/banner.mjs";
20
21
  import { L } from "./i18n/index.mjs";
21
22
  import { cmdInit, cmdNote, cmdBlob, cmdStart } from "./commands/project.mjs";
22
23
  import { cmdDoctor } from "./commands/doctor.mjs";
23
24
  import { cmdAdd, cmdNew, cmdRatchet, cmdFind, cmdWhy } from "./commands/gates.mjs";
24
25
  import { cmdReport } from "./commands/report.mjs";
26
+ import { cmdLearn } from "./commands/learn.mjs";
25
27
  import { cmdBadge } from "./commands/badge.mjs";
26
28
  import { cmdProve } from "./commands/prove.mjs";
29
+ import { cmdContext } from "./commands/context.mjs";
30
+ import { cmdVitals } from "./commands/vitals.mjs";
27
31
 
28
32
  // Разбор аргументов выполняется только при запуске файла как программы. При импорте —
29
33
  // а так его читают модульные проверки tool/selfcheck/units.mjs — CLI запускаться не должен.
@@ -67,46 +71,64 @@ if (IS_MAIN) {
67
71
  case "blob":
68
72
  await cmdBlob();
69
73
  break;
74
+ // Читает локальную переписку — поэтому только в терминал и всегда с кодом 0. Подробности
75
+ // и замер, на котором стоит отбор, — в шапке tool/commands/learn.mjs.
76
+ case "learn":
77
+ await cmdLearn();
78
+ break;
79
+
70
80
  case "report":
71
81
  await cmdReport();
72
82
  break;
73
83
  case "prove":
74
84
  await cmdProve();
75
85
  break;
86
+ // Печатает состояние репозитория для КОНТЕКСТА агента, а не для человека. Зовётся хуком
87
+ // SessionStart, поэтому ничего не запускает и всегда выходит с нулём: хук, роняющий запуск
88
+ // агента из-за неготового проекта, отключат в тот же день, и не станет ни хука, ни блока.
89
+ // Заставка по --version: одно из двух мест, где человек встречается с комплектом впервые.
90
+ case "--version":
91
+ case "-v":
92
+ case "version": {
93
+ const { readFile } = await import("node:fs/promises");
94
+ const { join, dirname } = await import("node:path");
95
+ const { fileURLToPath } = await import("node:url");
96
+ let v = "";
97
+ try {
98
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
99
+ v = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version || "";
100
+ } catch { /* пакет без package.json — версия просто не покажется */ }
101
+ console.log(`\n${banner()}\n`);
102
+ if (v) console.log(c.dim(` версия ${v}\n`));
103
+ break;
104
+ }
105
+
106
+ // Смотрит не на репозиторий, а на саму обвязку: стоят ли инструменты гейтов, прописан ли
107
+ // хук в .git/hooks, получает ли агент состояние. Без неё это выясняется красным гейтом
108
+ // посреди коммита — в момент, когда человек занят другим и просто выключит проверку.
109
+ case "vitals":
110
+ await cmdVitals();
111
+ break;
112
+
113
+ case "context":
114
+ await cmdContext(rest);
115
+ break;
76
116
  case "badge":
77
117
  await cmdBadge(rest);
78
118
  break;
79
119
  default: {
80
120
  // Ширина колонки считается, а не подбирается пробелами: строки в двух языках разной
81
121
  // длины, и вручную выровненная справка на втором языке разъезжается.
82
- const h = L.help;
83
- const rows = [
84
- [`${SELF} init`, h.init],
85
- [`${SELF} init --force`, h.initForce],
86
- [`${SELF} start`, h.start],
87
- [`${SELF} doctor`, h.doctor],
88
- [`${SELF} doctor --run`, h.doctorRun],
89
- [`${SELF} doctor --run --since main`, h.doctorSince],
90
- [`${SELF} prove`, h.prove],
91
- [`${SELF} add ${h.name}`, h.add],
92
- [`${SELF} find "…"`, h.find],
93
- [`${SELF} why "…"`, h.why],
94
- [`${SELF} ratchet ${h.name}`, h.ratchet],
95
- [`${SELF} new ${h.name}`, h.new],
96
- [`${SELF} note "…"`, h.note],
97
- [`${SELF} blob`, h.blob],
98
- [`${SELF} report`, h.report],
99
- [`${SELF} badge`, h.badge],
100
- ];
122
+ const rows = commandRows(L).map((r) => [`${SELF} ${r.name}${r.args ? " " + r.args : ""}`, r.text]);
101
123
  const width = Math.max(...rows.map(([cmdText]) => cmdText.length));
102
124
  const lines = rows.map(([cmdText, text]) => ` ${c.bold(cmdText.padEnd(width))} ${text}`);
103
125
  console.log(`
104
- ${c.bold("aqk")} — ${h.tagline}
126
+ ${c.bold("aqk")} — ${L.help.tagline}
105
127
 
106
128
  ${lines.join("\n")}
107
129
 
108
- ${c.dim(h.noInstall)}
109
- ${c.dim(h.language)}
130
+ ${c.dim(L.help.noInstall)}
131
+ ${c.dim(L.help.language)}
110
132
  `);
111
133
  process.exit(cmd ? 1 : 0);
112
134
  }