agent-quality-kit 0.8.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 (38) hide show
  1. package/README.md +92 -9
  2. package/README.ru.md +124 -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/color-from-token/check.sh +5 -1
  6. package/kit/gates/lesson-has-outcome/check.sh +5 -1
  7. package/kit/gates/mcp-server-resolves/README.md +62 -0
  8. package/kit/gates/mcp-server-resolves/check.sh +110 -0
  9. package/kit/gates/mcp-server-resolves/gate.yml +18 -0
  10. package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
  11. package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
  12. package/llms.txt +19 -4
  13. package/package.json +2 -6
  14. package/tool/commands/context.mjs +9 -5
  15. package/tool/commands/doctor.mjs +83 -14
  16. package/tool/commands/project.mjs +18 -2
  17. package/tool/commands/prove.mjs +1 -0
  18. package/tool/commands/vitals.mjs +159 -0
  19. package/tool/i18n/en-docs.mjs +40 -0
  20. package/tool/i18n/en.mjs +18 -0
  21. package/tool/i18n/index.mjs +36 -3
  22. package/tool/i18n/ru-docs.mjs +40 -0
  23. package/tool/i18n/ru.mjs +18 -0
  24. package/tool/lib/banner.mjs +59 -0
  25. package/tool/lib/brief.mjs +192 -0
  26. package/tool/lib/core.mjs +2 -0
  27. package/tool/lib/manifest.mjs +146 -15
  28. package/tool/lib/prove.mjs +11 -1
  29. package/tool/lib/repo.mjs +31 -1
  30. package/tool/program.mjs +26 -0
  31. package/tool/selfcheck/smoke.sh +350 -1
  32. package/tool/selfcheck/units-banner.mjs +65 -0
  33. package/tool/selfcheck/units-brief.mjs +97 -0
  34. package/tool/selfcheck/units-context.mjs +3 -1
  35. package/tool/selfcheck/units-level.mjs +147 -1
  36. package/tool/selfcheck/units-repo.mjs +134 -0
  37. package/tool/selfcheck/units-vitals.mjs +62 -0
  38. package/tool/selfcheck/units.mjs +3 -75
@@ -0,0 +1,192 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { CWD, PKG_ROOT, TARGET_DIR, SELF, c } from "./core.mjs";
4
+ import { L } from "../i18n/index.mjs";
5
+ import { canDrawArt } from "./banner.mjs";
6
+ // tool/lib/brief.mjs — короткая строка присутствия для прогона в хуке.
7
+ //
8
+ // ЗАЧЕМ ЭТО СУЩЕСТВУЕТ. Хук pre-commit молчит на успехе: вывод показывается только при провале
9
+ // — это его умолчание, проверено по документации 2026-09-08. То есть комплект, который всё
10
+ // держит, для человека НЕОТЛИЧИМ от невставленного: он поставил, поработал неделю и не увидел
11
+ // ни строки. «Скачал и че дальше» — дословная жалоба владельца.
12
+ //
13
+ // Это ровно тот порок, против которого написан весь комплект, только у нас самих: тишина
14
+ // неотличима от успеха. Поэтому строка печатается ВСЕГДА, в том числе — и особенно — когда
15
+ // всё хорошо. Одна строка: присутствие видно, читать нечего.
16
+ //
17
+ // СОВЕТ ОТДЕЛЬНО И РЕДКО. Вторая строка называет ОДНУ непоставленную запись и способ отказаться.
18
+ // Не список: список читается как «у вас всё плохо» и не помогает выбрать. Не каждый коммит:
19
+ // то, что видишь тридцатый раз, перестаёт читаться — и пролистывается вместе с настоящими
20
+ // находками, стоящими рядом.
21
+
22
+ // Сутки. Не «раз в прогон» и не «раз в неделю»: за сутки человек успевает забыть, но не успевает
23
+ // устать. Число здесь спорное — важно, что ограничитель есть и он машинный.
24
+ const ADVICE_EVERY_MS = 24 * 60 * 60 * 1000;
25
+
26
+ // ЗНАЧОК ПРИСУТСТВИЯ — здесь, а не в каталогах строк. Символ один на оба языка, и держать его
27
+ // в двух местах значит однажды получить разные значки в ru и en: то же правило, по которому у
28
+ // нас один свод правил на две точки входа. Выбран владельцем из пятидесяти семи вариантов.
29
+ // В терминале без UTF-8 он превратится в мусор — там правило то же, что у заставки, и оно
30
+ // одно на двоих: разойдись эти два условия, и значок рисовался бы там, где картинка уже нет.
31
+ const MARK = "❖";
32
+
33
+ function briefLine(state, L, env = process.env) {
34
+ const t = L.brief;
35
+ const mark = canDrawArt(env) ? `${MARK} ` : "";
36
+ const parts = [t.held(state.held), t.todo(state.todo)];
37
+ if (state.level >= 0) parts.push(`AQK-${state.level}`);
38
+ else parts.push(t.levelUnknown);
39
+ const head = `${mark}${t.name} ${parts.join(" · ")}`;
40
+ if (!state.red || !state.red.length) return head;
41
+ return `${head}\n${t.red(state.red.join(", "))}`;
42
+ }
43
+
44
+ // «Не знаем, когда показывали» и «показывали давно» — одно и то же решение: показать.
45
+ // Испорченная отметка попадает сюда же намеренно: молчать из-за нечитаемого файла состояния
46
+ // значит потерять совет навсегда и не сказать почему.
47
+ function adviceDue(lastIso, now = Date.now()) {
48
+ if (!lastIso) return true;
49
+ const t = Date.parse(String(lastIso));
50
+ if (!Number.isFinite(t)) return true;
51
+ return now - t >= ADVICE_EVERY_MS;
52
+ }
53
+
54
+ // Первая из непоставленных, а не «самая важная»: важность мы не считаем, а порядок каталога
55
+ // осмыслен — записи в нём лежат от общего к частному. Выдавать порядок за приоритет нельзя.
56
+ function pickAdvice(todo = []) {
57
+ return todo.length ? todo[0] : null;
58
+ }
59
+
60
+ // УВЕДОМЛЕНИЕ ОБ ОБНОВЛЕНИИ — и почему НЕ автообновление.
61
+ //
62
+ // Выпуски идут часто, а у половины способов установки версия закреплена и сама не двигается:
63
+ // `rev:` у pre-commit, тег у GitHub Action. Человек ставит комплект, получает версию с уже
64
+ // исправленной ошибкой и не узнаёт об этом никогда. У `npx` без версии проблемы нет — он берёт
65
+ // свежее при каждом запуске.
66
+ //
67
+ // АВТООБНОВЛЕНИЯ НЕТ, И ЭТО РЕШЕНИЕ, А НЕ НЕДОДЕЛКА. В тот же день выпущена запись каталога,
68
+ // краснеющая на `@latest`: «версия не закреплена — завтра приедет другая». Инструмент, который
69
+ // молча подменяет себя, стоя на воротах коммита, делал бы ровно то, что мы запрещаем другим.
70
+ // Первый же внешний читатель это заметит, и будет прав.
71
+ //
72
+ // СРАВНЕНИЕ ПО ЧИСЛАМ. Строкой «0.10.0» меньше «0.9.0», и уведомление пропало бы ровно на
73
+ // десятом выпуске — тихо и надолго. Такие поломки не замечают месяцами.
74
+ function cmpVer(a, b) {
75
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
76
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
77
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
78
+ const d = (pa[i] || 0) - (pb[i] || 0);
79
+ if (d) return d;
80
+ }
81
+ return 0;
82
+ }
83
+
84
+ // Совет зависит от способа установки: у pre-commit это `autoupdate`, а не npm. Совет мимо
85
+ // способа человек не выполнит — и перестанет читать следующие.
86
+ function updateNotice(current, latest, env = process.env, L) {
87
+ if (!current || !latest || cmpVer(latest, current) <= 0) return null;
88
+ const how = env.PRE_COMMIT ? L.brief.updateHookHow : L.brief.updateHow;
89
+ return `${L.brief.update(latest, current, how)}\n${L.brief.updateOff("AQK_UPDATE=0")}`;
90
+ }
91
+
92
+ // В конвейере не спрашиваем вовсе: там версия закреплена сознательно, читать уведомление
93
+ // некому, а лишний исходящий запрос из инструмента, который иначе не делает ни одного, —
94
+ // плохой размен. Отказ человека уважается тем же способом.
95
+ function updateWanted(env = process.env) {
96
+ if (String(env.AQK_UPDATE || "") === "0") return false;
97
+ if (env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI) return false;
98
+ return true;
99
+ }
100
+
101
+ // КРАТКИЙ РЕЖИМ для хука. Вывод целиком БУФЕРИЗУЕТСЯ, а печатается одна строка присутствия —
102
+ // и, при провале, весь буфер, чтобы человеку было что чинить. Перехват console.log выглядит
103
+ // грубо, и это осознанный размен: альтернатива — протащить флаг через четыреста строк печати,
104
+ // где каждая строка стала бы условной. Перехват локален, снимается в том же вызове и объяснён
105
+ // здесь; условие в каждой строке объяснить было бы негде.
106
+ function beginBrief() {
107
+ const lines = [];
108
+ const real = console.log;
109
+ console.log = (...a) => lines.push(a.join(" "));
110
+ return { lines, restore: () => { console.log = real; } };
111
+ }
112
+
113
+ // Печать краткого итога. Совет — не чаще раза в сутки и с явным способом отказаться: то, что
114
+ // видишь тридцатый раз, перестаёт читаться и пролистывается вместе с настоящими находками рядом.
115
+ // Отметка времени лежит в .aqk/, который в .gitignore: это состояние машины, а не проекта.
116
+ async function finishBrief(buf, state, todoRecs, ok) {
117
+ if (!buf) return;
118
+ buf.restore();
119
+ console.log(briefLine(state, L));
120
+
121
+ // Уведомление об обновлении — ДО разбора вердикта: оно от него не зависит. Сначала было
122
+ // после, и у любого проекта, где чего-то не хватает, версия не спрашивалась никогда —
123
+ // то есть у всех, кому комплект и нужен. Поймано первым же живым запуском.
124
+ await maybeUpdateNotice();
125
+
126
+ // При провале печатаем ВЕСЬ буфер: человеку нужно чинить, а одной строкой не починишь.
127
+ if (!ok) { console.log(buf.lines.join("\n")); return; }
128
+
129
+ if (process.env.AQK_ADVICE === "0" || !state.todo) return;
130
+ const stampFile = join(CWD, TARGET_DIR, "advice-shown");
131
+ let last = null;
132
+ try { last = (await readFile(stampFile, "utf8")).trim(); } catch { /* не показывали ещё */ }
133
+ if (!adviceDue(last)) return;
134
+ const advice = pickAdvice(todoRecs);
135
+ if (!advice) return;
136
+ console.log(c.dim(L.brief.advise(advice.slug, advice.intent || "")));
137
+ console.log(c.dim(L.brief.adviseOff(`${SELF} why ${advice.slug}`, "AQK_ADVICE=0")));
138
+ try {
139
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
140
+ await writeFile(stampFile, new Date().toISOString(), "utf8");
141
+ } catch { /* не смогли записать отметку — совет повторится, это не беда */ }
142
+ }
143
+
144
+ // Спрашивает реестр npm о своей версии. РАЗ В СУТКИ, НЕ В КОНВЕЙЕРЕ, С ТАЙМАУТОМ, И МОЛЧА
145
+ // ПРИ ЛЮБОЙ ОШИБКЕ. До этой строки комплект не делал ни одного исходящего запроса — так
146
+ // написано в README и SECURITY.md, и там же теперь написано про этот. Сделать тихо то, за что
147
+ // мы ругаем других, нельзя: весь смысл в том, что заявленное совпадает с происходящим.
148
+ //
149
+ // Код возврата не меняется никогда: уведомление, роняющее коммит, выключат в тот же день —
150
+ // и вместе с ним всё остальное, что печатает эта строка.
151
+ async function maybeUpdateNotice() {
152
+ if (!updateWanted()) return;
153
+ const stamp = join(CWD, TARGET_DIR, "update-checked");
154
+ let last = null;
155
+ try { last = (await readFile(stamp, "utf8")).trim(); } catch { /* ещё не спрашивали */ }
156
+ if (!adviceDue(last)) return;
157
+
158
+ let current = "";
159
+ try { current = JSON.parse(await readFile(join(PKG_ROOT, "package.json"), "utf8")).version || ""; } catch { return; }
160
+
161
+ // ОТМЕТКА СТАВИТСЯ ДО ЗАПРОСА, а не после удачного ответа. Сперва было наоборот, и замер
162
+ // показал цену: человек без сети платил бы ожиданием на КАЖДОМ коммите, а не раз в сутки.
163
+ // Из двух ошибок выбрана дешёвая: пропущенное за день уведомление против ежедневного стопора.
164
+ try {
165
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
166
+ await writeFile(stamp, new Date().toISOString(), "utf8");
167
+ } catch { /* не смогли записать — спросим ещё раз, это не беда */ }
168
+
169
+ let latest = "";
170
+ try {
171
+ // Три секунды, а не полторы. Замерено 2026-09-08: тёплый запрос к реестру — 533 мс,
172
+ // а первый, с разрешением имени и рукопожатием, в полторы секунды не уложился. Слишком
173
+ // тугой срок означал бы, что уведомление не приходит никогда и никто не знает почему.
174
+ const r = await fetch("https://registry.npmjs.org/agent-quality-kit/latest", {
175
+ signal: AbortSignal.timeout(3000),
176
+ headers: { accept: "application/vnd.npm.install-v1+json" },
177
+ });
178
+ if (!r.ok) return;
179
+ latest = String((await r.json()).version || "");
180
+ } catch {
181
+ // Сети нет, реестр молчит, таймаут — всё это НЕ повод сказать хоть слово. Инструмент,
182
+ // который жалуется на отсутствие интернета посреди коммита, выключают.
183
+ return;
184
+ }
185
+
186
+ const notice = updateNotice(current, latest, process.env, L);
187
+ if (notice) console.log(c.dim(notice));
188
+ }
189
+
190
+ // Наружу — только то, что зовут снаружи. `cmpVer` и `ADVICE_EVERY_MS` внутренние: экспорт,
191
+ // который никто не импортирует, читается как часть договора и мешает менять внутренности.
192
+ export { briefLine, adviceDue, pickAdvice, updateNotice, updateWanted, beginBrief, finishBrief };
package/tool/lib/core.mjs CHANGED
@@ -83,6 +83,8 @@ function commandRows(L) {
83
83
  { name: "context", args: "", text: h.context },
84
84
  { name: "context", args: "--full --install", text: h.contextInstall },
85
85
  { name: "badge", args: "", text: h.badge },
86
+ { name: "vitals", args: "", text: h.vitals },
87
+ { name: "version", args: "", text: h.version },
86
88
  ];
87
89
  }
88
90
 
@@ -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,7 @@ 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", "docs", "gates", "samples", "ratchets", "lessons", "advisory"];
115
+ const KNOWN_KEYS = ["aqk", "entry", "rules", "docs", "lang", "gates", "covers", "samples", "ratchets", "lessons", "advisory"];
83
116
 
84
117
  // ГДЕ У ПРОЕКТА ЛЕЖИТ РАЗЛОЖЕННЫЙ КОМПЛЕКТ. Список для шапки `doctor`. До 2026-09-08 он был
85
118
  // литеральным: `.aqk/rules`, `.aqk/docs`, `AGENTS.md` — независимо от того, что написано в
@@ -108,6 +141,79 @@ function layoutChecks(man, inKit) {
108
141
  ];
109
142
  }
110
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
+ }
216
+
111
217
  function unknownKeys(man) {
112
218
  if (!man || typeof man !== "object" || Array.isArray(man)) return [];
113
219
  return Object.keys(man).filter((k) => !KNOWN_KEYS.includes(k));
@@ -146,16 +252,41 @@ function entryLifecycle(rec) {
146
252
  return { state, why, supersededBy: supersededBy || null, problem };
147
253
  }
148
254
 
149
- // СОВЕЩАТЕЛЬНЫЕ ГЕЙТЫ. Правило вводят в проект, где старый код ему не соответствует. Храповик
150
- // отвечает на это одним способом: старое становится долгом, новое блокируется. Второй способ —
151
- // показывать, не роняя, пока команда договаривается о правиле. Без него у человека остаётся
152
- // выбор из двух крайностей: включить и сломать сборку либо не включать вовсе.
255
+ // Программа, без которой запись каталога не работает вовсе: поле `requires` в её `gate.yml`.
256
+ // Возвращает список НЕДОСТАЮЩИХ программ или null.
153
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
+
154
280
  // ПОЧЕМУ СПИСКОМ В МАНИФЕСТЕ, А НЕ ФЛАГОМ ПРОГОНА. Флаг «не роняй ничего» — это тот самый
155
281
  // `continue-on-error`, против которого написана наша запись ci-actually-fails: он понижает всё
156
282
  // разом, не виден в дифе и не назван в сводке. Список виден в манифесте, называется поимённо и
157
283
  // печатается КАЖДЫЙ прогон: совещательный гейт, о котором забыли, — это выключенная проверка,
158
284
  // и молчать о нём нельзя.
285
+ // СОВЕЩАТЕЛЬНЫЕ ГЕЙТЫ. Правило вводят в проект, где старый код ему не соответствует. Храповик
286
+ // отвечает на это одним способом: старое становится долгом, новое блокируется. Второй способ —
287
+ // показывать, не роняя, пока команда договаривается о правиле. Без него у человека остаётся
288
+ // выбор из двух крайностей: включить и сломать сборку либо не включать вовсе.
289
+ //
159
290
  function advisorySet(man) {
160
291
  const v = man?.advisory;
161
292
  if (Array.isArray(v)) return new Set(v.map((x) => String(x).trim()).filter(Boolean));
@@ -242,5 +373,5 @@ function manifestWithGate(text, slug, cmd) {
242
373
 
243
374
  export {
244
375
  parseManifest, readManifest, assessLevel, manifestWithGate, unknownKeys, KNOWN_KEYS,
245
- entryLifecycle, advisorySet, layoutChecks,
376
+ entryLifecycle, advisorySet, gateRequires, layoutChecks, coversOf, coversUnproven, unparsedLines,
246
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 .`), каталог
@@ -128,6 +129,15 @@ async function proveGates(man, { timeoutMs = 300000 } = {}) {
128
129
  continue;
129
130
  }
130
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
+
131
141
  // Образцы бывают написаны под КОНКРЕТНЫЙ рецепт: запись без переносимой проверки называет
132
142
  // его полем `samples_for`. Если в проекте стоит рецепт под другой язык, гонять по этим
133
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
  };
package/tool/program.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  import { realpathSync } from "node:fs";
18
18
  import { fileURLToPath } from "node:url";
19
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";
@@ -26,6 +27,7 @@ import { cmdLearn } from "./commands/learn.mjs";
26
27
  import { cmdBadge } from "./commands/badge.mjs";
27
28
  import { cmdProve } from "./commands/prove.mjs";
28
29
  import { cmdContext } from "./commands/context.mjs";
30
+ import { cmdVitals } from "./commands/vitals.mjs";
29
31
 
30
32
  // Разбор аргументов выполняется только при запуске файла как программы. При импорте —
31
33
  // а так его читают модульные проверки tool/selfcheck/units.mjs — CLI запускаться не должен.
@@ -84,6 +86,30 @@ if (IS_MAIN) {
84
86
  // Печатает состояние репозитория для КОНТЕКСТА агента, а не для человека. Зовётся хуком
85
87
  // SessionStart, поэтому ничего не запускает и всегда выходит с нулём: хук, роняющий запуск
86
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
+
87
113
  case "context":
88
114
  await cmdContext(rest);
89
115
  break;