agent-quality-kit 0.8.0 → 0.10.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 (76) hide show
  1. package/README.md +154 -12
  2. package/README.ru.md +185 -27
  3. package/kit/docs/ai/index.md +1 -0
  4. package/kit/docs/ai/project-baseline.md +14 -0
  5. package/kit/docs/api-e2e.md +214 -0
  6. package/kit/docs/ready-made-rules.md +188 -0
  7. package/kit/gates/README.md +22 -0
  8. package/kit/gates/api-contract-has-arbiter/README.md +63 -0
  9. package/kit/gates/api-contract-has-arbiter/check.sh +117 -0
  10. package/kit/gates/api-contract-has-arbiter/gate.yml +15 -0
  11. package/kit/gates/api-contract-has-arbiter/green/.github/workflows/ci.yml +12 -0
  12. package/kit/gates/api-contract-has-arbiter/green/openapi.yaml +18 -0
  13. package/kit/gates/api-contract-has-arbiter/red/.github/workflows/ci.yml +11 -0
  14. package/kit/gates/api-contract-has-arbiter/red/openapi.yaml +18 -0
  15. package/kit/gates/ci-actually-fails/check.sh +9 -1
  16. package/kit/gates/color-from-token/check.sh +5 -1
  17. package/kit/gates/commit-explains-itself/check.sh +15 -0
  18. package/kit/gates/complexity-limit/red/deep.go +17 -0
  19. package/kit/gates/complexity-limit/red/deep.rs +17 -0
  20. package/kit/gates/gate-not-weakened/red/suppress.go +5 -0
  21. package/kit/gates/gate-not-weakened/red/suppress.rs +3 -0
  22. package/kit/gates/lesson-has-outcome/check.sh +5 -1
  23. package/kit/gates/mcp-server-resolves/README.md +62 -0
  24. package/kit/gates/mcp-server-resolves/check.sh +110 -0
  25. package/kit/gates/mcp-server-resolves/gate.yml +18 -0
  26. package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
  27. package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
  28. package/kit/gates/protection-not-removed/README.md +67 -0
  29. package/kit/gates/protection-not-removed/check.sh +92 -0
  30. package/kit/gates/protection-not-removed/gate.yml +10 -0
  31. package/kit/gates/protection-not-removed/green/.aqk.yml +7 -0
  32. package/kit/gates/protection-not-removed/green/gates-declared.txt +4 -0
  33. package/kit/gates/protection-not-removed/red/.aqk.yml +7 -0
  34. package/kit/gates/protection-not-removed/red/gates-declared.txt +4 -0
  35. package/kit/gates/secrets-not-in-code/red/leak.go +9 -0
  36. package/kit/gates/secrets-not-in-code/red/leak.rs +5 -0
  37. package/kit/gates/todo-without-task/red/later.go +6 -0
  38. package/kit/gates/todo-without-task/red/later.rs +4 -0
  39. package/llms.txt +25 -4
  40. package/package.json +3 -6
  41. package/tool/commands/context.mjs +37 -6
  42. package/tool/commands/doctor.mjs +139 -16
  43. package/tool/commands/probe.mjs +228 -0
  44. package/tool/commands/project.mjs +18 -2
  45. package/tool/commands/prove.mjs +1 -0
  46. package/tool/commands/vitals.mjs +167 -0
  47. package/tool/i18n/en-docs.mjs +48 -0
  48. package/tool/i18n/en-gates.mjs +309 -0
  49. package/tool/i18n/en.mjs +26 -279
  50. package/tool/i18n/index.mjs +36 -3
  51. package/tool/i18n/ru-docs.mjs +48 -0
  52. package/tool/i18n/ru-gates.mjs +311 -0
  53. package/tool/i18n/ru.mjs +26 -278
  54. package/tool/lib/banner.mjs +59 -0
  55. package/tool/lib/brief.mjs +192 -0
  56. package/tool/lib/cadence.mjs +57 -0
  57. package/tool/lib/core.mjs +3 -0
  58. package/tool/lib/history.mjs +82 -0
  59. package/tool/lib/manifest.mjs +146 -15
  60. package/tool/lib/prove.mjs +11 -1
  61. package/tool/lib/repo.mjs +43 -3
  62. package/tool/program.mjs +33 -0
  63. package/tool/selfcheck/smoke/_fixture.mjs +89 -0
  64. package/tool/selfcheck/smoke/api-contract.test.mjs +79 -0
  65. package/tool/selfcheck/smoke/commit-report.test.mjs +47 -0
  66. package/tool/selfcheck/smoke/verdict.test.mjs +40 -0
  67. package/tool/selfcheck/smoke.sh +528 -6
  68. package/tool/selfcheck/units-banner.mjs +65 -0
  69. package/tool/selfcheck/units-brief.mjs +97 -0
  70. package/tool/selfcheck/units-cadence.mjs +69 -0
  71. package/tool/selfcheck/units-context.mjs +3 -1
  72. package/tool/selfcheck/units-level.mjs +147 -1
  73. package/tool/selfcheck/units-probe.mjs +100 -0
  74. package/tool/selfcheck/units-repo.mjs +164 -0
  75. package/tool/selfcheck/units-vitals.mjs +81 -0
  76. package/tool/selfcheck/units.mjs +3 -75
@@ -0,0 +1,228 @@
1
+ // tool/commands/probe.mjs — `aqk probe`: чего объявленные проверки НЕ видят.
2
+ //
3
+ // ЗАЧЕМ ЭТО ОТДЕЛЬНАЯ КОМАНДА. `doctor` отвечает «держит машина 21». Двадцать один из чего?
4
+ // Знаменателя нет: 21 — это то, что мы успели написать в каталог, а не то, что важно в этом
5
+ // проекте. `prove` доказывает, что гейт ловит брак НА СВОЁМ образце. Ни один из них не
6
+ // отвечает на вопрос владельца: «что у меня не прикрыто вообще».
7
+ //
8
+ // Замер, с которого команда началась, — на самом комплекте, 2026-09-09. Взят настоящий файл
9
+ // проекта, в копию подсажены проглоченная ошибка и отладочная печать, прогнаны ВСЕ 21
10
+ // сканирующих гейта из манифеста. Покраснело: ноль. У проекта с AQK-3 есть брак, невидимый
11
+ // всем его проверкам, — и узнать об этом было нечем.
12
+ //
13
+ // КАК УСТРОЕНО. Два источника, и оба — факты, а не наш вкус:
14
+ // 1. история репозитория: где брак ВОЗВРАЩАЕТСЯ (коммиты-починки, `history.mjs`);
15
+ // 2. красные образцы каталога: каждый доказан прогоном, каждый — настоящий брак.
16
+ // Образец кладётся во временный каталог по пути горячего файла, и по нему прогоняются
17
+ // ОБЪЯВЛЕННЫЕ гейты проекта. Никто не покраснел — класс не прикрыт, и это доказано, а не
18
+ // выведено из списка.
19
+ //
20
+ // ЧЕГО КОМАНДА НЕ ДЕЛАЕТ. Не трогает рабочее дерево: проба живёт в каталоге mkdtemp и
21
+ // удаляется. Не меняет манифест. Не роняет прогон: код возврата всегда 0 — это осмотр, а
22
+ // не порог. Порог — у `doctor --run --min`.
23
+ import { spawnSync } from "node:child_process";
24
+ import { mkdtemp, mkdir, copyFile, rm, readdir, writeFile, readFile } from "node:fs/promises";
25
+ import { tmpdir } from "node:os";
26
+ import { join, dirname, extname } from "node:path";
27
+ import { readManifest } from "../lib/manifest.mjs";
28
+ import { commandFor } from "../lib/prove.mjs";
29
+ import { fixHotspots, probeVerdict } from "../lib/history.mjs";
30
+ import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
31
+ import { CWD, GATES_SRC, TARGET_DIR, c, SELF, exists } from "../lib/core.mjs";
32
+ import { probeState, probeEvery, PROBE_EVERY } from "../lib/cadence.mjs";
33
+ import { L } from "../i18n/index.mjs";
34
+
35
+ // Тот же набор расширений, что у привязки доказательства к дифу. Список один на программу:
36
+ // второй через месяц разошёлся бы с первым.
37
+ const CODE_EXT = new Set([
38
+ "c", "cjs", "cpp", "cs", "css", "go", "h", "java", "js", "json", "jsx", "kt", "mjs", "mts",
39
+ "php", "pl", "py", "rb", "rs", "scala", "sh", "sql", "swift", "ts", "tsx", "vue",
40
+ ]);
41
+
42
+ const isCode = (p) =>
43
+ CODE_EXT.has(extname(p).slice(1).toLowerCase()) &&
44
+ !/(^|\/)gates\/[^/]+\/(red|green)(\/|$)/.test(p);
45
+
46
+ // Мелкий клон истории не содержит. `fetch-depth: 2` в конвейере — обычная настройка, и на нём
47
+ // рейтинг починок пуст ВСЕГДА. Сказать там «коммитов-починок не найдено» значит выдать
48
+ // отсутствие данных за факт о репозитории: та же подмена, что «зелено, потому что не
49
+ // проверялось». Найдено собственным конвейером 2026-09-09.
50
+ function isShallow() {
51
+ const r = spawnSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: CWD, encoding: "utf8" });
52
+ return r.status === 0 && String(r.stdout || "").trim() === "true";
53
+ }
54
+
55
+ // История берётся одним вызовом: тема коммита и его файлы. Слияния исключены — в них файлы
56
+ // второй ветки, а починку делали не в них.
57
+ function gitLog(limit) {
58
+ const r = spawnSync(
59
+ "git",
60
+ ["log", "--no-merges", `--max-count=${limit}`, "--format=%s", "--name-only"],
61
+ { cwd: CWD, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
62
+ );
63
+ return r.status === 0 ? r.stdout || "" : null;
64
+ }
65
+
66
+ // Сколько коммитов в репозитории сейчас. Единица каденции — коммиты, а не сутки: месяц без
67
+ // работы перепроверять незачем, а сто коммитов за день — надо.
68
+ function commitCount() {
69
+ const r = spawnSync("git", ["rev-list", "--count", "HEAD"], { cwd: CWD, encoding: "utf8" });
70
+ if (r.status !== 0) return null;
71
+ const n = Number(String(r.stdout || "").trim());
72
+ return Number.isFinite(n) ? n : null;
73
+ }
74
+
75
+ const MARK = () => join(CWD, TARGET_DIR, "last-probe.md");
76
+
77
+ // Отметка о прошлой пробе. Формат человеческий намеренно: файл читают глазами и агентом,
78
+ // а не только программой. Разбирается одна строка — та, что несёт число коммитов.
79
+ async function readMark() {
80
+ try {
81
+ const text = await readFile(MARK(), "utf8");
82
+ const m = /^at:\s*(\d+)/m.exec(text);
83
+ return m ? { at: Number(m[1]), text } : {};
84
+ } catch { return null; }
85
+ }
86
+
87
+ async function writeMark(now, blind, lines) {
88
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
89
+ const body = [
90
+ "# Проба покрытия — что объявленные проверки НЕ видят",
91
+ "",
92
+ `at: ${now === null ? "?" : now}`,
93
+ `blind: ${blind}`,
94
+ "",
95
+ ...lines,
96
+ "",
97
+ "Файл эфемерный: его переписывает каждая проба. В .gitignore его стоит держать самому.",
98
+ ].join("\n");
99
+ await writeFile(MARK(), body + "\n", "utf8");
100
+ }
101
+
102
+ // Гейты, которым можно подставить каталог. Команда записи каталога кончается каталогом
103
+ // проверки; написанная руками — чем угодно, и подставлять там некуда. Ровно то же правило,
104
+ // по которому `prove` объявляет запись недоказуемой, а не сломанной.
105
+ function scanningGates(man) {
106
+ const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
107
+ return Object.entries(gates)
108
+ .map(([name, raw]) => [name, String(raw || "").trim()])
109
+ .filter(([, cmd]) => cmd && /(\.|\.\/)$/.test(cmd));
110
+ }
111
+
112
+ // Красный образец записи, подходящий по расширению горячего файла. Расширение обязано
113
+ // совпадать: питоновский образец в проекте на TypeScript не проверит ничего, а покажет
114
+ // «не прикрыто» — ложная тревога того же класса, что молчащий гейт, только наоборот.
115
+ async function redSampleFor(entry, ext) {
116
+ const dir = join(GATES_SRC, entry, "red");
117
+ if (!(await exists(dir))) return null;
118
+ let names = [];
119
+ try { names = await readdir(dir); } catch { return null; }
120
+ const hit = names.find((n) => extname(n).toLowerCase() === ext);
121
+ return hit ? join(dir, hit) : null;
122
+ }
123
+
124
+ // Проба: временный каталог, в нём образец по пути горячего файла. Путь сохраняется целиком —
125
+ // правила, привязанные к путям (`.aqkignore`, исключения гейтов), обязаны действовать так же,
126
+ // как в настоящем репозитории. Без этого проба отвечала бы про несуществующее место.
127
+ async function buildProbe(relPath, sample) {
128
+ const root = await mkdtemp(join(tmpdir(), "aqk-probe-"));
129
+ const dest = join(root, relPath);
130
+ await mkdir(dirname(dest), { recursive: true });
131
+ await copyFile(sample, dest);
132
+ // Переносится ТОЛЬКО .aqkignore: правила, привязанные к путям, обязаны действовать так же,
133
+ // как в настоящем репозитории. Манифест НЕ переносится намеренно — иначе записи, читающие
134
+ // `.aqk.yml` (`gates-are-runnable`, `gate-has-samples`, `protection-not-removed`), краснеют
135
+ // на том, что в пробе нет объявленных ими файлов, и проба объявляет класс прикрытым, хотя
136
+ // на подсаженный брак не отреагировал никто. Ошибка в сторону «прикрыто» — это тишина,
137
+ // а тишина здесь и есть предмет спора. Поймано первым же прогоном на своём репозитории.
138
+ if (await exists(join(CWD, ".aqkignore"))) {
139
+ await copyFile(join(CWD, ".aqkignore"), join(root, ".aqkignore"));
140
+ }
141
+ return root;
142
+ }
143
+
144
+ function runGates(gates, dir) {
145
+ const out = [];
146
+ for (const [name, cmd] of gates) {
147
+ const r = spawnSync(commandFor(cmd, dir), { shell: true, cwd: CWD, encoding: "utf8", timeout: 120000 });
148
+ out.push({ name, code: r.status === null ? 2 : r.status });
149
+ }
150
+ return out;
151
+ }
152
+
153
+ // `auto` — проба запущена САМА, по каденции, из `doctor --run`. Тогда она короче и говорит
154
+ // вслух, почему случилась: команда, возникшая без спроса, обязана объяснить себя, иначе её
155
+ // читают как сбой.
156
+ async function cmdProbe(args, { auto = false } = {}) {
157
+ const P = L.probe;
158
+ const topArg = Number(args[args.indexOf("--top") + 1]);
159
+ const TOP = args.includes("--top") && Number.isFinite(topArg) && topArg > 0 ? topArg : (auto ? 3 : 5);
160
+
161
+ console.log(c.bold(`\n${P.title}\n`));
162
+
163
+ const man = await readManifest();
164
+ const gates = scanningGates(man);
165
+ if (!gates.length) { console.log(c.yellow(` ${P.noGates(`${SELF} add <имя>`)}\n`)); return; }
166
+
167
+ const raw = gitLog(2000);
168
+ if (raw === null) { console.log(c.yellow(` ${P.noGit}\n`)); return; }
169
+ const hot = fixHotspots(raw, { isCode }).slice(0, TOP);
170
+ if (!hot.length) { console.log(c.yellow(` ${isShallow() ? P.shallow : P.noFixes}\n`)); return; }
171
+
172
+ // Записи каталога, применимые к ЭТОМУ репозиторию. Показывать пробы записей, которые
173
+ // проекту не подходят, значит советовать закрыть дыру, которой нет.
174
+ const facts = await detectFacts();
175
+ const catalog = await readCatalog();
176
+ const entries = catalog.filter((e) => triggerVerdict(e, facts).applies);
177
+
178
+ console.log(c.dim(` ${P.method(hot.length, entries.length)}\n`));
179
+
180
+ let blind = 0;
181
+ for (const { path: rel, fixes } of hot) {
182
+ console.log(` ${c.bold(rel)} ${c.dim(P.fixes(fixes))}`);
183
+ const ext = extname(rel).toLowerCase();
184
+ let probed = 0;
185
+
186
+ for (const e of entries) {
187
+ const sample = await redSampleFor(e.slug, ext);
188
+ if (!sample) continue;
189
+ probed++;
190
+ const dir = await buildProbe(rel, sample);
191
+ let results;
192
+ try { results = runGates(gates, dir); } finally { await rm(dir, { recursive: true, force: true }); }
193
+ const verdict = probeVerdict(results);
194
+ const caught = results.filter((r) => r.code === 1).map((r) => r.name);
195
+ if (verdict === "caught") {
196
+ console.log(` ${c.green("✔")} ${e.intent.padEnd(48)} ${c.dim(P.caught(caught.join(", ")))}`);
197
+ } else if (verdict === "blind") {
198
+ blind++;
199
+ console.log(` ${c.red("✘")} ${e.intent.padEnd(48)} ${c.red(P.blind)}`);
200
+ console.log(c.dim(` ${P.install(`${SELF} add ${e.slug}`)}`));
201
+ } else {
202
+ console.log(` ${c.dim("~")} ${c.dim(e.intent.padEnd(48))} ${c.dim(P.unknown)}`);
203
+ }
204
+ }
205
+ if (!probed) console.log(c.dim(` ${P.noSampleFor(ext || "—")}`));
206
+ }
207
+
208
+ console.log(blind ? c.yellow(`\n ${P.summaryBlind(blind)}\n`) : c.green(`\n ${P.summaryClean}\n`));
209
+
210
+ // Отметка нужна не для отчёта, а для КАДЕНЦИИ: по ней следующий прогон поймёт, что пора.
211
+ // Без неё команда снова становится тем, о чём надо вспомнить.
212
+ await writeMark(commitCount(), blind, hot.map(({ path: p2, fixes }) => `- ${p2} (${P.fixes(fixes)})`));
213
+ }
214
+
215
+ // Состояние пробы для тех, кто только ПОКАЗЫВАЕТ его: прогон и блок для агента.
216
+ //
217
+ // Порог берётся из манифеста (`probe: 250`), умолчание — PROBE_EVERY. Непонятое значение не
218
+ // подменяется умолчанием молча: в манифесте было бы написано одно, а происходило бы другое.
219
+ // Возвращается пометка `badEvery`, и вызывающий говорит о ней вслух.
220
+ async function probeStatus() {
221
+ const man = await readManifest();
222
+ const every = probeEvery(man);
223
+ if (every === null) return { state: "unknown", behind: null, badEvery: String(man?.probe) };
224
+ if (every === 0) return { state: "off", behind: null };
225
+ return probeState(await readMark(), commitCount(), every);
226
+ }
227
+
228
+ export { cmdProbe, probeStatus, scanningGates, isCode };
@@ -8,6 +8,7 @@ import {
8
8
  CWD, PKG_ROOT, DOCS_SRC, RULES_SRC, TARGET_DIR, MANIFEST, SELF, REPO_URL, c, exists, die,
9
9
  copyDir, writeIfAbsent, FEEDBACK_MARK, docPath } from "../lib/core.mjs";
10
10
  import { AGENTS_MD, CLAUDE_MD, MANIFEST_YML } from "../lib/templates.mjs";
11
+ import { banner } from "../lib/banner.mjs";
11
12
  import { readManifest } from "../lib/manifest.mjs";
12
13
  import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
13
14
  import { installGate } from "./gates.mjs";
@@ -40,7 +41,9 @@ async function cmdInit(args) {
40
41
  const claude = join(CWD, "CLAUDE.md");
41
42
  track(await writeIfAbsent(claude, CLAUDE_MD, { force }), claude);
42
43
 
43
- console.log(c.bold("\naqk init\n"));
44
+ // Заставка в начале init — первая встреча человека с комплектом. Второй раз он увидит её
45
+ // только если сам спросит `--version`: то, что видишь тридцатый раз, перестаёт читаться.
46
+ console.log(`\n${banner()}\n`);
44
47
  if (created.length) {
45
48
  console.log(c.green(` ${L.init.created(created.length)}`));
46
49
  for (const f of created.slice(0, 8)) console.log(` ${f}`);
@@ -96,7 +99,20 @@ ${c.bold(L.feedback.title)}
96
99
  ${url}/issues/new
97
100
  ${c.dim(` ${L.feedback.once}`)}
98
101
  `);
99
- await writeIfAbsent(FEEDBACK_MARK, "shown\n", { force: false });
102
+ // Пометка «уже показывали» удобство, а не работа команды. Домашнего каталога может не быть
103
+ // записываемым вовсе: в контейнере, запущенном `--user 1001:127`, у этого uid нет записи в
104
+ // /etc/passwd, `homedir()` даёт «/», и запись падает с EACCES на `/.config`. До 2026-09-09
105
+ // это роняло ВЕСЬ `init` — то есть любого, кто набрал команду из нашей же документации по
106
+ // docker. Локально не воспроизводилось случайно: uid разработчика 1000 совпадает с
107
+ // пользователем `node` в образе, у которого дом есть. Нашёл конвейер, где uid 1001.
108
+ //
109
+ // Молча глотать нельзя — это то, что красит наш же swallowed-error. Поэтому вслух: не
110
+ // запомнили, покажем снова. Установка при этом доходит до конца.
111
+ try {
112
+ await writeIfAbsent(FEEDBACK_MARK, "shown\n", { force: false });
113
+ } catch {
114
+ console.log(c.dim(` ${L.feedback.notRemembered}`));
115
+ }
100
116
  }
101
117
 
102
118
  function findJournal() {
@@ -17,6 +17,7 @@ function line(r) {
17
17
  r.why === "no-samples" ? P.noSamples
18
18
  : r.why === "other-recipe" ? P.otherRecipe(r.forRecipe.lang)
19
19
  : r.why === "no-target" ? P.noTarget
20
+ : r.why === "needs-program" ? P.needsProgram(r.missing.join(", "))
20
21
  : P.empty;
21
22
  return ` ${c.dim("~")} ${c.dim(pad)} ${c.dim(why)}`;
22
23
  }
@@ -0,0 +1,167 @@
1
+ // tool/commands/vitals.mjs — «всё ли у самого комплекта подключено».
2
+ //
3
+ // ЗАЧЕМ ОТДЕЛЬНАЯ КОМАНДА. `doctor` смотрит на РЕПОЗИТОРИЙ, `prove` — на гейты, `context` —
4
+ // на состояние. На саму обвязку не смотрит никто: стоят ли инструменты, которых требуют
5
+ // объявленные гейты; прописан ли хук в `.git/hooks` НА САМОМ ДЕЛЕ, а не только в конфиге;
6
+ // получает ли агент состояние. Сегодня это выясняется красным гейтом посреди коммита — в
7
+ // худший момент из возможных, когда человек занят другим и просто выключит проверку.
8
+ //
9
+ // ЧЕТЫРЕ СОСТОЯНИЯ, А НЕ ДВА, И КАЖДОЕ ЗАРАБОТАНО.
10
+ // ✔ подключено;
11
+ // ✘ СЛОМАНО — объявленный гейт не состоится: нет инструмента, потеряна строка манифеста;
12
+ // · не подключено, и это выбор — хука pre-commit нет, потому что гоняют в конвейере;
13
+ // ~ посмотреть не смогли.
14
+ //
15
+ // Первая версия ставила `✘` хуку, которого нет. На нашем же репозитории вышло два креста за
16
+ // сознательное решение: pre-commit локально мы не ставим, проверки идут в CI. Команда, которая
17
+ // кричит «сломано» про выбор, — ровно та, которую выключают в первый день, и вместе с ней
18
+ // перестают читать настоящие отказы. Кода возврата касается только `✘`.
19
+ import { readFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+ import { CWD, MANIFEST, SELF, c, exists } from "../lib/core.mjs";
22
+ import { readManifest, unparsedLines, gateRequires } from "../lib/manifest.mjs";
23
+ import { whichSync } from "../lib/repo.mjs";
24
+ import { updateWanted } from "../lib/brief.mjs";
25
+ import { L } from "../i18n/index.mjs";
26
+
27
+ // Чистая функция: на входе факты, на выходе строки. Отделена от чтения диска намеренно —
28
+ // «неизвестно» проверяется перебором случаев, а не прогоном, потому что случай «не смогли
29
+ // посмотреть» на исправной машине не воспроизвести.
30
+ function vitalsRows(f) {
31
+ const t = L.vitals;
32
+ const missing = (f.tools || []).filter((x) => !x.found);
33
+ const rows = [
34
+ {
35
+ key: "tools",
36
+ ok: (f.tools || []).length === 0 ? null : missing.length === 0,
37
+ detail: missing.length
38
+ ? t.toolsMissing(missing.map((x) => `${x.prog} (${x.gate})`).join(", "))
39
+ : t.toolsOk((f.tools || []).length),
40
+ },
41
+ {
42
+ key: "manifest",
43
+ ok: f.unparsed > 0 ? false : true,
44
+ detail: f.unparsed > 0 ? t.manifestBad(f.unparsed) : t.manifestOk,
45
+ },
46
+ {
47
+ key: "preCommit",
48
+ ok: f.preCommit === null ? null : f.preCommit ? true : "no",
49
+ detail: f.preCommit === null ? t.unknownHook : f.preCommit ? t.preCommitOk : t.preCommitNo,
50
+ },
51
+ {
52
+ key: "sessionHook",
53
+ ok: f.sessionHook === null ? null : f.sessionHook ? true : "no",
54
+ detail: f.sessionHook === null ? t.unknownHook : f.sessionHook ? t.sessionOk : t.sessionNo(`${SELF} context --install`),
55
+ },
56
+ ];
57
+ // Устаревшая версия — не отказ: человек мог закрепить её сознательно, и ронять за это нельзя.
58
+ //
59
+ // СОСТОЯНИЙ ТРИ, А НЕ ДВА. Реестр может ответить ошибкой — не упасть, а вернуть не-200; тогда
60
+ // `latest` пустой, и прежняя ветка печатала «свежая». Посмотреть не смогли, а сказали «всё
61
+ // хорошо»: тот самый грех, против которого написан весь комплект, у него самого. Найдено
62
+ // аудитом фич 2026-09-09.
63
+ if (f.version) {
64
+ const latest = String(f.version.latest || "");
65
+ rows.push({
66
+ key: "version",
67
+ ok: null,
68
+ detail: !latest
69
+ ? t.versionUnknown(f.version.current)
70
+ : latest !== f.version.current
71
+ ? t.versionOld(latest, f.version.current)
72
+ : t.versionOk(f.version.current),
73
+ });
74
+ }
75
+ return rows;
76
+ }
77
+
78
+ function vitalsVerdict(rows) {
79
+ return rows.some((r) => r.ok === false) ? 1 : 0;
80
+ }
81
+
82
+ // Первое слово команды — та программа, без которой гейт не состоится. Обёртки снимаются:
83
+ // `bash x.sh` требует bash, а не x.sh; `npx --yes knip@6` требует npx.
84
+ function progOf(cmd) {
85
+ const parts = String(cmd || "").trim().split(/\s+/);
86
+ return parts[0] || "";
87
+ }
88
+
89
+ async function cmdVitals() {
90
+ const man = await readManifest();
91
+ const gates = man?.gates && typeof man.gates === "object" && !Array.isArray(man.gates) ? man.gates : {};
92
+
93
+ const seen = new Map();
94
+ for (const [gate, cmd] of Object.entries(gates)) {
95
+ const prog = progOf(cmd);
96
+ if (!prog || seen.has(prog)) continue;
97
+ seen.set(prog, { gate, prog, found: Boolean(whichSync(prog)) });
98
+ }
99
+
100
+ // Первого слова мало. Запись каталога бывает обёрткой: команда начинается с `bash`, который
101
+ // есть всегда, а работать без `slopcheck` или `zizmor` она не может — и `doctor --run`
102
+ // краснеет там, где `vitals` печатал «все инструменты на месте». Ровно тот разрыв, ради
103
+ // закрытия которого эта команда и заведена. Программа названа в `requires:` записи.
104
+ // Найдено 2026-09-09 сверкой вывода двух команд на одном репозитории.
105
+ const samplesDir = typeof man?.samples === "string" ? man.samples.trim() : "";
106
+ for (const gate of Object.keys(gates)) {
107
+ const missing = await gateRequires(samplesDir, gate, whichSync);
108
+ for (const prog of missing || []) {
109
+ if (seen.has(prog)) continue;
110
+ seen.set(prog, { gate, prog, found: false });
111
+ }
112
+ }
113
+
114
+ let unparsed = 0;
115
+ try { unparsed = unparsedLines(await readFile(join(CWD, MANIFEST), "utf8")).length; } catch { /* манифеста нет */ }
116
+
117
+ // Хук pre-commit проверяется в `.git/hooks`, а НЕ в `.pre-commit-config.yaml`. Запись в
118
+ // конфиге — это намерение; сработает только то, что лежит в самом гите. Ровно та разница,
119
+ // ради которой весь комплект: объявлено и работает — разные утверждения.
120
+ let preCommit = null;
121
+ const hook = join(CWD, ".git", "hooks", "pre-commit");
122
+ if (await exists(join(CWD, ".git"))) {
123
+ preCommit = false;
124
+ if (await exists(hook)) {
125
+ try { preCommit = /pre-commit|aqk/i.test(await readFile(hook, "utf8")); } catch { preCommit = null; }
126
+ }
127
+ }
128
+
129
+ let sessionHook = null;
130
+ const settings = join(CWD, ".claude", "settings.json");
131
+ if (await exists(join(CWD, ".claude"))) {
132
+ sessionHook = false;
133
+ if (await exists(settings)) {
134
+ try {
135
+ const s = JSON.parse(await readFile(settings, "utf8"));
136
+ sessionHook = JSON.stringify(s?.hooks?.SessionStart || []).includes("context");
137
+ } catch { sessionHook = null; }
138
+ }
139
+ }
140
+
141
+ let version = null;
142
+ if (updateWanted()) {
143
+ try {
144
+ const { PKG_ROOT } = await import("../lib/core.mjs");
145
+ const current = JSON.parse(await readFile(join(PKG_ROOT, "package.json"), "utf8")).version || "";
146
+ const r = await fetch("https://registry.npmjs.org/agent-quality-kit/latest", {
147
+ signal: AbortSignal.timeout(3000),
148
+ headers: { accept: "application/vnd.npm.install-v1+json" },
149
+ });
150
+ version = { current, latest: r.ok ? String((await r.json()).version || "") : "" };
151
+ } catch { /* сети нет — строку про версию просто не покажем */ }
152
+ }
153
+
154
+ const rows = vitalsRows({ tools: [...seen.values()], unparsed, preCommit, sessionHook, version });
155
+ console.log(c.bold(`\n ${L.vitals.title}\n`));
156
+ for (const r of rows) {
157
+ const mark = r.ok === true ? c.green("✔")
158
+ : r.ok === false ? c.red("✘")
159
+ : r.ok === "no" ? c.yellow("·")
160
+ : c.dim("~");
161
+ console.log(` ${mark} ${L.vitals.names[r.key].padEnd(22)} ${r.ok === false ? r.detail : c.dim(r.detail)}`);
162
+ }
163
+ console.log("");
164
+ process.exit(vitalsVerdict(rows));
165
+ }
166
+
167
+ export { cmdVitals, vitalsRows, vitalsVerdict };
@@ -7,6 +7,47 @@
7
7
  // the second for one second in a terminal.
8
8
 
9
9
  const enDocs = {
10
+ // Блок vitals переехал сюда 2026-09-09 по той же причине, что и brief: терминальный
11
+ // каталог снова перерос 500 строк, и поймал это наш же file-size-limit.
12
+ vitals: {
13
+ title: "Is what the kit runs on actually wired up",
14
+ names: {
15
+ tools: "gate tools",
16
+ manifest: "manifest parsed",
17
+ preCommit: "pre-commit hook",
18
+ sessionHook: "state to the agent",
19
+ version: "version",
20
+ vitals: "is what the kit runs on wired up: tools, hooks, freshness",
21
+ },
22
+ toolsOk: (n) => `all ${n} present`,
23
+ toolsMissing: (l) => `NOT FOUND: ${l} — those gates will not happen`,
24
+ manifestOk: "parsed in full",
25
+ manifestBad: (n) => `${n} lines were not parsed and HAVE NO EFFECT — details in doctor`,
26
+ unknownHook: "could not look — unknown, not \"no\"",
27
+ preCommitOk: "wired in .git/hooks",
28
+ preCommitNo: "not in .git/hooks: a config entry is an intent, not a guard",
29
+ sessionOk: "the SessionStart hook hands the state to the agent",
30
+ sessionNo: (cmd) => `the agent gets no state: ${cmd}`,
31
+ versionOk: (v) => `${v}, current`,
32
+ versionUnknown: (v) => `${v}, could not reach the registry — freshness unknown`,
33
+ versionOld: (l, cur) => `${l} is out, you have ${cur}`,
34
+ },
35
+ // Блок краткого вывода переехал сюда 2026-09-09: терминальный каталог снова перерос
36
+ // 500 строк, и поймал это наш же file-size-limit. Шов по смыслу условен — это всё-таки
37
+ // терминал, — но предел настоящий, а делить пополам хуже, чем делить по соседству.
38
+ brief: {
39
+ name: "AQK",
40
+ held: (n) => `holds ${n}`,
41
+ todo: (n) => `not installed ${n}`,
42
+ levelUnknown: "level unknown",
43
+ red: (l) => ` RED: ${l}`,
44
+ advise: (slug, why) => ` ↑ install: ${slug} — ${why}`,
45
+ adviseOff: (cmd, env) => ` not needed: ${cmd}, or ${env}`,
46
+ update: (l, c, how) => `version ${l} is out, you have ${c} — update: ${how}`,
47
+ updateHow: "npx agent-quality-kit@latest, or npm i -g agent-quality-kit",
48
+ updateHookHow: "pre-commit autoupdate",
49
+ updateOff: (env) => ` not needed: ${env}`,
50
+ },
10
51
  // THE STATE BLOCK — the one text of ours whose reader is a machine, not a person.
11
52
  // It goes into the agent's context via a SessionStart hook, so it is written as claims of
12
53
  // fact: no politeness, no preamble, every line either a fact or an honest "unknown".
@@ -26,6 +67,13 @@ const enDocs = {
26
67
  runRed: (when, names) => `Last run ${when} — RED: ${names}.`,
27
68
  andMore: (n) => `and ${n} more`,
28
69
  skipped: (n) => `Not run: ${n} — the tool is absent on this machine, their state is unknown.`,
70
+ probeNever: "No coverage probe has run — what is covered by nothing here is UNKNOWN. That is not \"covered\": `aqk probe`.",
71
+ probeBlind: (n, behind) =>
72
+ `Covered by nothing: ${n} defect classes in the places people most often come back to fix` +
73
+ (behind ? ` (the probe is ${behind} commits behind)` : "") + ". Details: `aqk probe`.",
74
+ probeClean: (behind) =>
75
+ "Coverage probe: in the places probed, every applicable class is caught by something" +
76
+ (behind ? ` (${behind} commits behind)` : "") + ".",
29
77
  ratchets: (list) => `Ratchets: ${list}. The list may only get shorter, never longer.`,
30
78
  where: (entry) => `The rulebook: ${entry}. What proves a diff: \`aqk report --since main\`.`,
31
79
  mapTitle: "WHAT THIS TOOL CAN DO. The full list of commands — not a retelling, the same list\nthe help is built from:",