agent-quality-kit 0.2.4 → 0.3.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 (37) hide show
  1. package/README.md +118 -94
  2. package/README.ru.md +178 -0
  3. package/kit/gates/_skip.sh +31 -3
  4. package/kit/gates/commit-explains-itself/gate.yml +1 -0
  5. package/kit/gates/complexity-limit/gate.yml +1 -0
  6. package/kit/gates/dead-code/gate.yml +1 -0
  7. package/kit/gates/deps-are-pinned/gate.yml +1 -0
  8. package/kit/gates/duplicate-code/gate.yml +1 -0
  9. package/kit/gates/entry-links-exist/gate.yml +1 -0
  10. package/kit/gates/file-size-limit/gate.yml +1 -0
  11. package/kit/gates/gate-has-samples/gate.yml +1 -0
  12. package/kit/gates/gates-are-runnable/gate.yml +1 -0
  13. package/kit/gates/gates-run-in-ci/gate.yml +1 -0
  14. package/kit/gates/lesson-has-outcome/gate.yml +1 -0
  15. package/kit/gates/no-print-in-prod/check.sh +3 -1
  16. package/kit/gates/no-print-in-prod/gate.yml +1 -0
  17. package/kit/gates/secrets-not-in-code/gate.yml +1 -0
  18. package/kit/gates/swallowed-error/gate.yml +1 -0
  19. package/kit/gates/todo-without-task/gate.yml +1 -0
  20. package/package.json +2 -1
  21. package/tool/commands/doctor.mjs +38 -41
  22. package/tool/commands/gates.mjs +103 -109
  23. package/tool/commands/project.mjs +84 -85
  24. package/tool/commands/report.mjs +194 -0
  25. package/tool/i18n/en.mjs +423 -0
  26. package/tool/i18n/index.mjs +33 -0
  27. package/tool/i18n/ru.mjs +424 -0
  28. package/tool/i18n/templates-en.mjs +164 -0
  29. package/tool/i18n/templates-ru.mjs +170 -0
  30. package/tool/lib/core.mjs +5 -1
  31. package/tool/lib/manifest.mjs +12 -31
  32. package/tool/lib/repo.mjs +45 -21
  33. package/tool/lib/templates.mjs +38 -182
  34. package/tool/program.mjs +31 -9
  35. package/tool/selfcheck/gates.sh +7 -1
  36. package/tool/selfcheck/smoke.sh +97 -0
  37. package/tool/selfcheck/units.mjs +80 -5
@@ -5,13 +5,14 @@ import { readdir, mkdir, writeFile, readFile } from "node:fs/promises";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { join, dirname, relative } from "node:path";
7
7
  import {
8
- CWD, PKG_ROOT, DOCS_SRC, RULES_SRC, TARGET_DIR, MANIFEST, SELF, REPO, c, exists, die,
8
+ CWD, PKG_ROOT, DOCS_SRC, RULES_SRC, TARGET_DIR, MANIFEST, SELF, REPO_URL, c, exists, die,
9
9
  copyDir, writeIfAbsent, FEEDBACK_MARK,
10
10
  } from "../lib/core.mjs";
11
11
  import { AGENTS_MD, CLAUDE_MD, MANIFEST_YML } from "../lib/templates.mjs";
12
12
  import { readManifest } from "../lib/manifest.mjs";
13
13
  import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
14
14
  import { installGate } from "./gates.mjs";
15
+ import { L } from "../i18n/index.mjs";
15
16
 
16
17
  async function cmdInit(args) {
17
18
  const force = args.includes("--force");
@@ -21,7 +22,7 @@ async function cmdInit(args) {
21
22
  const track = (ok, path) => (ok ? created : skipped).push(relative(CWD, path));
22
23
 
23
24
  if (!(await exists(DOCS_SRC))) {
24
- die(`Не найден корпус методичек: ${DOCS_SRC}\nПохоже, пакет установлен не полностью.`);
25
+ die(L.init.noDocs(DOCS_SRC));
25
26
  }
26
27
 
27
28
  const refs = await copyDir(DOCS_SRC, join(CWD, TARGET_DIR, "docs"), { force });
@@ -42,14 +43,14 @@ async function cmdInit(args) {
42
43
 
43
44
  console.log(c.bold("\naqk init\n"));
44
45
  if (created.length) {
45
- console.log(c.green(` создано (${created.length}):`));
46
+ console.log(c.green(` ${L.init.created(created.length)}`));
46
47
  for (const f of created.slice(0, 8)) console.log(` ${f}`);
47
- if (created.length > 8) console.log(c.dim(` … и ещё ${created.length - 8}`));
48
+ if (created.length > 8) console.log(c.dim(` ${L.init.andMore(created.length - 8)}`));
48
49
  }
49
50
  if (skipped.length) {
50
- console.log(c.yellow(`\n уже были на месте, не тронуты (${skipped.length}):`));
51
+ console.log(c.yellow(`\n ${L.init.kept(skipped.length)}`));
51
52
  for (const f of skipped) console.log(` ${f}`);
52
- console.log(c.dim(` перезаписать: ${SELF} init --force`));
53
+ console.log(c.dim(` ${L.init.overwrite(`${SELF} init --force`)}`));
53
54
  }
54
55
 
55
56
  // Путь ищем, а не пишем: раскладка внутри .aqk — дело владельца комплекта, и жёстко
@@ -58,18 +59,18 @@ async function cmdInit(args) {
58
59
  for (const f of created) if (f.endsWith("project-baseline.md")) baseline = f;
59
60
 
60
61
  console.log(`
61
- ${c.bold("Что дальше — по порядку:")}
62
-
63
- 1. Открой ${c.bold("AGENTS.md")} и заполни раздел «Команды». Команда, которую нельзя
64
- скопировать и выполнить, — не команда, а пожелание.
65
- 2. Прочитай ${c.bold(baseline)} — это обязательный минимум
66
- проекта без привязки к языку. Пройди сверху вниз и отметь, чего нет.
67
- 3. Заполни ${c.bold(".aqk.yml")} — гейты, образцы, журнал. Уровень соответствия AQK
68
- считается по нему: ${c.bold(`${SELF} doctor`)}.
69
- 4. Поднимайся по ступеням ${c.bold("по одной")}. Гейт стережёт существующий артефакт:
70
- проверка на код, которого ещё нет, — мёртвое правило.
71
-
72
- ${c.dim(`Обжёгся на чём-то — запиши: ${SELF} note "что случилось"`)}
62
+ ${c.bold(L.init.nextTitle)}
63
+
64
+ 1. ${L.init.n1a} ${c.bold("AGENTS.md")} ${L.init.n1b}
65
+ ${L.init.n1c}
66
+ 2. ${L.init.n2a} ${c.bold(baseline)} ${L.init.n2b}
67
+ ${L.init.n2c}
68
+ 3. ${L.init.n3a} ${c.bold(".aqk.yml")} ${L.init.n3b}
69
+ ${L.init.n3c(c.bold(`${SELF} doctor`))}
70
+ 4. ${L.init.n4a} ${c.bold(L.init.n4b)}${L.init.n4c}
71
+ ${L.init.n4d}
72
+
73
+ ${c.dim(L.init.burned(`${SELF} note ""`))}
73
74
  `);
74
75
  await maybeAskFeedback();
75
76
  }
@@ -80,13 +81,13 @@ ${c.dim(`Обжёгся на чём-то — запиши: ${SELF} note "что
80
81
  // звезды, но без звезды меньше шансов, что кто-то вообще дойдёт до фидбека.
81
82
  async function maybeAskFeedback() {
82
83
  if (await exists(FEEDBACK_MARK)) return;
83
- const url = `https://github.com/${REPO.replace(/^github:/, "")}`;
84
+ const url = REPO_URL;
84
85
  console.log(`
85
- ${c.bold("Если пригодилось:")}
86
- Поставь звезду — ${url}
87
- Нашёл баг или не подошло — заведи Issue, самая полезная обратная связь: и то и другое.
86
+ ${c.bold(L.feedback.title)}
87
+ ${L.feedback.star(url)}
88
+ ${L.feedback.issue}
88
89
  ${url}/issues/new
89
- ${c.dim(" Это разовое сообщение: больше не покажется на этой машине.")}
90
+ ${c.dim(` ${L.feedback.once}`)}
90
91
  `);
91
92
  await writeIfAbsent(FEEDBACK_MARK, "shown\n", { force: false });
92
93
  }
@@ -107,19 +108,34 @@ function findJournal() {
107
108
 
108
109
  async function cmdNote(args) {
109
110
  const title = args.find((a) => !a.startsWith("--"));
110
- if (!title) die(`Нужен заголовок: ${SELF} note "что произошло"`);
111
+ if (!title) die(L.note.needTitle(`${SELF} note ""`));
112
+
113
+ // Сначала журнал ЭТОГО проекта: `lessons:` в манифесте — это и есть ответ на вопрос «куда
114
+ // складывать уроки», данный владельцем репозитория. Пока команда его игнорировала, она
115
+ // требовала клон нашего репозитория и писала урок туда — то есть в чужой проект. Найдено
116
+ // первым же чужим прогоном: человек завёл журнал руками, потому что команда не сработала.
117
+ const man = await readManifest();
118
+ const own = String(man?.lessons || "").trim();
119
+ let home = null;
120
+ let journal = null;
121
+ if (own && !/^https?:/i.test(own)) {
122
+ const dir = join(CWD, own);
123
+ if (await exists(dir)) {
124
+ home = CWD;
125
+ journal = join(dir, "README.md");
126
+ // Журнал объявлен, но файла нет — заводим, а не отказываем: пустой журнал это норма
127
+ // первого дня, и отказ на нём отучает пользоваться командой.
128
+ if (!(await exists(journal))) await writeFile(journal, `# ${L.note.journalTitle}\n`, "utf8");
129
+ }
130
+ }
111
131
 
112
- const home = findJournal();
113
132
  if (!home) {
114
- die(`Клон журнала не найден.
115
- Сделай один раз:
116
- git clone https://github.com/arsen-ask-lx/Agent_Quality_Kit.git ~/projects/aqk
117
- или укажи путь: export AQK_HOME=/путь/к/aqk`);
133
+ home = findJournal();
134
+ if (!home) die(L.note.noJournal(REPO_URL));
135
+ journal = join(home, "incidents", "README.md");
136
+ if (!(await exists(journal))) die(L.note.journalMissing(journal));
118
137
  }
119
138
 
120
- const journal = join(home, "incidents", "README.md");
121
- if (!(await exists(journal))) die(`Журнал не найден: ${journal}`);
122
-
123
139
  let body = "";
124
140
  if (!process.stdin.isTTY) {
125
141
  body = await new Promise((res) => {
@@ -131,14 +147,7 @@ async function cmdNote(args) {
131
147
  }
132
148
 
133
149
  if (!body.trim()) {
134
- die(`Тело записи пустое. Передай его на стандартный ввод, например:
135
-
136
- aqk note "заголовок" <<'EOF'
137
- **Класс:** гейт молчал
138
- **Что случилось.** ...
139
- **Чем это стоило.** ...
140
- **Вывод.** 🔧 ...
141
- EOF`);
150
+ die(L.note.emptyBody);
142
151
  }
143
152
 
144
153
  // ГЕЙТ. Урок без вывода — это история, а не урок: в следующий раз обожжёмся так же.
@@ -146,18 +155,12 @@ async function cmdNote(args) {
146
155
  // тексте не значит вывод, если рядом нет одной из трёх настоящих отметок. Запись проходила бы
147
156
  // здесь и тут же краснела на doctor --run — разные требования в двух местах одного правила.
148
157
  if (!/[✅🔧📜👤]/.test(body)) {
149
- die(
150
- "В записи нет отметки решения. Урок без вывода — это история, а не урок.\n" +
151
- "Припиши одну из трёх:\n" +
152
- " ✅ **Стало гейтом:** <имя записи каталога>\n" +
153
- " 🔧 **Стало правкой оснастки:** <что именно изменено>\n" +
154
- " 👤 **Гейтом не станет:** <почему>"
155
- );
158
+ die(L.note.noOutcome);
156
159
  }
157
160
 
158
161
  const date = new Date().toISOString().slice(0, 10);
159
- const project = CWD.split("/").filter(Boolean).pop() || "неизвестно";
160
- const entry = `\n## ${date} — ${title}\n\n**Проект:** ${project}\n\n${body.trim()}\n`;
162
+ const project = CWD.split("/").filter(Boolean).pop() || L.note.unknownProject;
163
+ const entry = `\n## ${date} — ${title}\n\n**${L.note.projectField}:** ${project}\n\n${body.trim()}\n`;
161
164
 
162
165
  const prev = await readFile(journal, "utf8");
163
166
  await writeFile(journal, prev + entry, "utf8");
@@ -168,8 +171,8 @@ async function cmdNote(args) {
168
171
  const pushed = run("push", "-q");
169
172
  console.log(
170
173
  pushed.status === 0
171
- ? c.green(`Записано и отправлено: ${title}`)
172
- : c.yellow(`Записано локально, push не прошёл. Отправить: git -C ${home} push`)
174
+ ? c.green(L.note.pushed(title))
175
+ : c.yellow(L.note.localOnly(`git -C ${home} push`))
173
176
  );
174
177
  }
175
178
 
@@ -179,13 +182,10 @@ async function cmdNote(args) {
179
182
 
180
183
  async function cmdBlob() {
181
184
  const dir = join(PKG_ROOT, "kit", "docs");
182
- if (!(await exists(dir))) die(`Не найдены методички: ${dir}`);
185
+ if (!(await exists(dir))) die(L.blob.noDocs(dir));
183
186
 
184
187
  const stamp = new Date().toISOString().slice(0, 10);
185
- let out =
186
- `<!-- СОБРАНО КОМАНДОЙ aqk blob ${stamp} из kit/docs. Не править руками:\n` +
187
- ` правки затрёт следующая сборка. Источник — отдельные файлы. -->\n\n` +
188
- `# AQK — методички одним файлом\n`;
188
+ let out = L.blob.header(stamp);
189
189
 
190
190
  // Методички могут лежать в подпапках — обходим дерево, порядок стабильный.
191
191
  const found = [];
@@ -199,7 +199,7 @@ async function cmdBlob() {
199
199
  await walk(dir);
200
200
 
201
201
  for (const full of found) {
202
- out += `\n\n${"=".repeat(78)}\n<!-- источник: ${relative(PKG_ROOT, full)} -->\n${"=".repeat(78)}\n\n`;
202
+ out += `\n\n${"=".repeat(78)}\n<!-- ${L.blob.source(relative(PKG_ROOT, full))} -->\n${"=".repeat(78)}\n\n`;
203
203
  // Ссылки на соседние файлы в склейке ведут в никуда: соседей рядом больше нет,
204
204
  // все они внутри этого же текста. Оставляем подпись, снимаем разметку.
205
205
  const body = (await readFile(full, "utf8")).replace(
@@ -212,10 +212,9 @@ async function cmdBlob() {
212
212
  const dst = join(CWD, "GOD_AI.md");
213
213
  await writeFile(dst, out, "utf8");
214
214
  console.log(
215
- `\n ${c.green("✔")} GOD_AI.md — ${found.length} файлов, ` +
216
- `${Math.round(Buffer.byteLength(out) / 1024)} КБ\n`
215
+ `\n ${c.green("✔")} ${L.blob.done(found.length, Math.round(Buffer.byteLength(out) / 1024))}\n`
217
216
  );
218
- console.log(c.dim(" Собирается заново каждой командой. Править надо оригиналы в kit/docs.\n"));
217
+ console.log(c.dim(` ${L.blob.rebuilt}\n`));
219
218
  }
220
219
 
221
220
 
@@ -234,19 +233,19 @@ async function cmdStart(args) {
234
233
  if (!man) {
235
234
  await cmdInit(args.filter((a) => a !== "--force"));
236
235
  man = await readManifest();
237
- if (!man) die(`Не получилось разложить комплект. Начни с ${SELF} init`);
236
+ if (!man) die(L.start.initFailed(`${SELF} init`));
238
237
  }
239
238
 
240
239
  const facts = await detectFacts(man);
241
240
  console.log(c.bold("\naqk start\n"));
242
241
 
243
242
  if (facts.files > 30 && !force) {
244
- console.log(c.yellow(` В репозитории уже ${facts.files} файлов кода — это другой сценарий.\n`));
245
- console.log(` ${c.bold(`${SELF} doctor`)} осмотрит, что есть, и разделит записи на три списка:`);
246
- console.log(c.dim(" держит машина · применимо и не поставлено · не применимо и почему.\n"));
247
- console.log(c.dim(` Всё равно поставить сторожей дня 0: ${SELF} start --force`));
248
- console.log(c.dim(" Готовься к красному: сторож, поставленный на живой код, краснеет на нём весь."));
249
- console.log(c.dim(` Это лечится храповиком — ${SELF} ratchet <имя>, — а не отключением.\n`));
243
+ console.log(c.yellow(` ${L.start.tooManyFiles(facts.files)}\n`));
244
+ console.log(` ${L.start.useDoctor(c.bold(`${SELF} doctor`))}`);
245
+ console.log(c.dim(` ${L.start.threeLists}\n`));
246
+ console.log(c.dim(` ${L.start.anyway(`${SELF} start --force`)}`));
247
+ console.log(c.dim(` ${L.start.expectRed}`));
248
+ console.log(c.dim(` ${L.start.expectRedFix(`${SELF} ratchet ${L.help.name}`)}\n`));
250
249
  return;
251
250
  }
252
251
 
@@ -280,35 +279,35 @@ async function cmdStart(args) {
280
279
  }
281
280
 
282
281
  if (put.length) {
283
- console.log(c.green(` Поставлено сторожей дня 0: ${put.length}\n`));
282
+ console.log(c.green(` ${L.start.installed(put.length)}\n`));
284
283
  for (const [slug, , intent] of put) console.log(` ${c.green("✔")} ${slug.padEnd(22)} ${c.dim(intent)}`);
285
- console.log(c.dim("\n Долга нет: на пустом проекте им нечего пропускать. Тот же сторож, поставленный"));
286
- console.log(c.dim(" через полгода, покраснел бы на всём старом коде — и его бы выключили.\n"));
284
+ console.log(c.dim(`\n ${L.start.noDebt1}`));
285
+ console.log(c.dim(` ${L.start.noDebt2}\n`));
287
286
  } else {
288
- console.log(c.dim(" Все применимые записи уже объявлены.\n"));
287
+ console.log(c.dim(` ${L.start.allDeclared}\n`));
289
288
  }
290
289
  if (skipped.length) {
291
- console.log(c.dim(" Не применимо пока:"));
290
+ console.log(c.dim(` ${L.start.notYet}`));
292
291
  for (const [slug, why] of skipped.slice(0, 6)) console.log(c.dim(` ${slug.padEnd(22)} ${why}`));
293
- console.log(c.dim(" Появится признак — запись покажется сама.\n"));
292
+ console.log(c.dim(` ${L.start.notYetWhy}\n`));
294
293
  }
295
294
 
296
295
  // --- порядок работы --------------------------------------------------------
297
- console.log(`${c.bold("Порядок, в котором это делают:")}
296
+ console.log(`${c.bold(L.start.orderTitle)}
298
297
 
299
- 1. ${c.bold("Задача словами.")} Что и кому, без единого технического слова.
300
- ${c.dim("Пока задача не описана словами, любая архитектура защищает неизвестно что.")}
301
- 2. ${c.bold("Ограничения.")} Сроки, деньги, нагрузка, чем нельзя пользоваться.
302
- ${c.dim("Ограничения выбирают решение куда чаще, чем вкус: без них выбирают вкусом.")}
303
- 3. ${c.bold("Сайзинг.")} Сколько данных, запросов, людей — числами, хотя бы порядком.
304
- ${c.dim("Число отделяет «нужна очередь» от «хватит таблицы». Без него спорят словами.")}
305
- 4. ${c.bold("Архитектура.")} И только теперь — из первых трёх, а не до них.
298
+ 1. ${c.bold(L.start.o1)} ${L.start.o1What}
299
+ ${c.dim(L.start.o1Why)}
300
+ 2. ${c.bold(L.start.o2)} ${L.start.o2What}
301
+ ${c.dim(L.start.o2Why)}
302
+ 3. ${c.bold(L.start.o3)} ${L.start.o3What}
303
+ ${c.dim(L.start.o3Why)}
304
+ 4. ${c.bold(L.start.o4)} ${L.start.o4What}
306
305
 
307
- ${c.dim("Этот порядок программа не проверяет: он в .aqk/docs/, и агент может его")}
308
- ${c.dim("проигнорировать. Машина держит другое — сторожей выше. Разница между")}
309
- ${c.dim("мягким и жёстким тут ровно такая: текст просят, команду выполняют.")}
306
+ ${c.dim(L.start.softNote1)}
307
+ ${c.dim(L.start.softNote2)}
308
+ ${c.dim(L.start.softNote3)}
310
309
 
311
- ${c.bold("Дальше:")} ${c.bold(`${SELF} doctor --run`)} ${c.dim("— прогнать всё, что объявлено")}
310
+ ${c.bold(L.start.next)} ${c.bold(`${SELF} doctor --run`)} ${c.dim(L.start.nextWhy)}
312
311
  `);
313
312
  await maybeAskFeedback();
314
313
  }
@@ -0,0 +1,194 @@
1
+ // tool/commands/report.mjs — обязательная форма отчёта после работы с комплектом.
2
+ //
3
+ // ЗАЧЕМ ОТДЕЛЬНАЯ КОМАНДА, А НЕ ПРАВИЛО В ТЕКСТЕ. Первый чужой прогон дал отчёт, в котором
4
+ // «12 гейтов зелёные» стояло рядом с тем фактом, что все 12 встали на слабейший рецепт, а
5
+ // половина методичек, на которые указал сам `init`, не была прочитана. Агент не соврал — он
6
+ // пересказал по памяти то, что счёл главным. Пересказ по памяти всегда выбирает удобное.
7
+ //
8
+ // Поэтому отчёт СОБИРАЕТСЯ ПРОГОНОМ. Всё, что в нём написано, — результат запуска здесь и
9
+ // сейчас: какой рецепт реально выбран, что зелёное, что красное, что применимо и не стоит,
10
+ // какие документы комплект велел прочитать и лежат ли они на диске.
11
+ //
12
+ // Чего команда НЕ умеет и не делает вид, что умеет: проверить, что документ прочитали.
13
+ // Она печатает список — дальше отвечает человек или агент. Разница между «не прочитал» и
14
+ // «прочитал и решил не применять» машине не видна, и притворяться иначе было бы враньём.
15
+
16
+ import { mkdir, writeFile, readdir, readFile } from "node:fs/promises";
17
+ import { join, relative } from "node:path";
18
+ import { CWD, TARGET_DIR, SELF, c, exists } from "../lib/core.mjs";
19
+ import { readManifest, assessLevel } from "../lib/manifest.mjs";
20
+ import { detectFacts, readCatalog, triggerVerdict, whichSync } from "../lib/repo.mjs";
21
+ import { runGates, declaredGates } from "./doctor.mjs";
22
+ import { L } from "../i18n/index.mjs";
23
+
24
+ // Каким рецептом стоит гейт: родным инструментом или переносимой проверкой. Именно это
25
+ // различие потерялось в первом чужом отчёте, и именно оно решает, что гейт на самом деле ловит.
26
+ function recipeKind(cmd, rec) {
27
+ const recipes = rec?.recipes && typeof rec.recipes === "object" ? rec.recipes : {};
28
+ const norm = (x) => String(x || "").replace(/\{gate\}|\{dir\}/g, "").replace(/\s+/g, " ").trim();
29
+ const c0 = norm(cmd);
30
+ for (const [lang, r] of Object.entries(recipes)) {
31
+ if (lang === "any") continue;
32
+ const prog = String(r).trim().split(/\s+/)[0];
33
+ if (c0.includes(norm(r)) || c0.startsWith(prog)) return { native: true, how: lang, prog };
34
+ }
35
+ // Родной рецепт есть в записи, но стоит переносимый — назвать это вслух: разница измерима.
36
+ const alt = Object.entries(recipes).filter(([k]) => k !== "any");
37
+ return { native: false, alternatives: alt.map(([lang, r]) => [lang, String(r).trim().split(/\s+/)[0]]) };
38
+ }
39
+
40
+ // Документы, на которые комплект указывает сам. Список не выдуман здесь: это те же файлы,
41
+ // которые `init` кладёт в проект и на которые ссылается его же вывод.
42
+ const REQUIRED_DOCS = [
43
+ ["project-baseline.md", "baseline"],
44
+ ["ready-made-rules.md", "readyMade"],
45
+ ["general.md", "rulesGeneral"],
46
+ ["testing.md", "rulesTesting"],
47
+ ["security.md", "rulesSecurity"],
48
+ ];
49
+
50
+ // Путь ИЩЕМ, а не пишем. Жёстко вписанный `.aqk/docs/project-baseline.md` уже соврал в первой
51
+ // же версии этой команды: файл лежит в подпапке `ai/`, и отчёт сообщил «не разложен» о том,
52
+ // что разложено. Раскладка внутри .aqk — дело владельца комплекта, а не наше знание.
53
+ async function findDoc(name) {
54
+ const root = join(CWD, TARGET_DIR);
55
+ if (!(await exists(root))) return null;
56
+ const walk = async (dir) => {
57
+ let entries = [];
58
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return null; }
59
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
60
+ const full = join(dir, e.name);
61
+ if (e.isDirectory()) { const hit = await walk(full); if (hit) return hit; }
62
+ else if (e.name === name) return relative(CWD, full);
63
+ }
64
+ return null;
65
+ };
66
+ return walk(root);
67
+ }
68
+
69
+ async function cmdReport() {
70
+ const man = await readManifest();
71
+ if (!man) {
72
+ console.log(c.red(`\n ${L.report2.noManifest(`${SELF} init`)}\n`));
73
+ process.exit(1);
74
+ }
75
+
76
+ const { reached } = await assessLevel(man);
77
+ const facts = await detectFacts(man);
78
+ const catalog = await readCatalog();
79
+ const bySlug = Object.fromEntries(catalog.map((r) => [r.slug, r]));
80
+ const declared = declaredGates(man);
81
+
82
+ // Прогон, а не чтение манифеста: «объявлен» и «работает» — разные утверждения, и весь
83
+ // смысл этой команды в том, чтобы в отчёт попало второе.
84
+ const run = declared.length ? runGates(man) : { results: [], failed: 0 };
85
+
86
+ const held = [], broken = [], todo = [], skip = [];
87
+ for (const res of run.results) {
88
+ const rec = bySlug[res.name];
89
+ const kind = recipeKind(res.cmd, rec);
90
+ (res.ok ? held : broken).push({ ...res, kind, rec });
91
+ }
92
+ for (const rec of catalog) {
93
+ if (declared.some(([n]) => n === rec.slug)) continue;
94
+ const v = triggerVerdict(rec, facts);
95
+ if (!v.applies) { skip.push([rec, v.why]); continue; }
96
+ // Запись применима, но её родного инструмента нет — это другая причина, чем «не поставили».
97
+ const recipes = rec.recipes && typeof rec.recipes === "object" ? rec.recipes : {};
98
+ const needs = recipes.any ? null : Object.values(recipes).map((r) => String(r).trim().split(/\s+/)[0]).find((prog) => !whichSync(prog));
99
+ todo.push([rec, needs]);
100
+ }
101
+
102
+ const lines = [];
103
+ const say = (s = "") => { lines.push(s); };
104
+
105
+ say(`# ${L.report2.title} — ${new Date().toISOString().slice(0, 16).replace("T", " ")}`);
106
+ say("");
107
+ say(`${L.report2.level}: AQK-${reached < 0 ? L.doctor.levelNone : reached}`);
108
+ say("");
109
+
110
+ say(`## ${L.report2.holdsTitle}`);
111
+ say("");
112
+ if (!held.length && !broken.length) say(`- ${L.report2.nothingRuns}`);
113
+ for (const g of held) {
114
+ const how = g.kind.native ? L.report2.native(g.kind.prog) : L.report2.portable;
115
+ say(`- ✅ ${g.name} — ${how}, ${g.secs}s`);
116
+ }
117
+ for (const g of broken) {
118
+ const how = g.kind.native ? L.report2.native(g.kind.prog) : L.report2.portable;
119
+ say(`- ❌ ${g.name} — ${how}, ${g.note || L.doctor.exitCode(g.code)}`);
120
+ }
121
+ // Гейт стоит на переносимом, хотя родной инструмент есть в системе: слабее, чем возможно.
122
+ const weaker = [...held, ...broken].filter(
123
+ (g) => !g.kind.native && (g.kind.alternatives || []).some(([, prog]) => whichSync(prog))
124
+ );
125
+ if (weaker.length) {
126
+ say("");
127
+ say(`### ${L.report2.weakerTitle}`);
128
+ say("");
129
+ for (const g of weaker) {
130
+ const have = (g.kind.alternatives || []).filter(([, prog]) => whichSync(prog)).map(([, prog]) => prog);
131
+ say(`- ⚠️ ${g.name} — ${L.report2.weaker(have.join(", "))}`);
132
+ }
133
+ }
134
+
135
+ say("");
136
+ say(`## ${L.report2.missingTitle}`);
137
+ say("");
138
+ if (!todo.length) say(`- ${L.report2.nothingMissing}`);
139
+ for (const [rec, needs] of todo) {
140
+ say(`- ❌ ${rec.slug} — ${needs ? L.report2.needsTool(needs) : L.report2.notInstalled}`);
141
+ }
142
+ if (skip.length) {
143
+ say("");
144
+ say(`### ${L.report2.hiddenTitle}`);
145
+ say("");
146
+ for (const [rec, why] of skip) say(`- ⬜ ${rec.slug} — ${why}`);
147
+ }
148
+
149
+ say("");
150
+ say(`## ${L.report2.readTitle}`);
151
+ say("");
152
+ for (const [name, key] of REQUIRED_DOCS) {
153
+ const found = await findDoc(name);
154
+ say(`- ${found ? "📖" : "⬜"} ${found || name} — ${L.report2.docs[key]}`);
155
+ }
156
+ say("");
157
+ say(`> ${L.report2.readWarn}`);
158
+
159
+ say("");
160
+ say(`## ${L.report2.ignoreTitle}`);
161
+ say("");
162
+ // Читаем тем же способом, каким это делает _skip.sh: комментарии и пустые строки прочь.
163
+ let ignored = [];
164
+ try {
165
+ ignored = (await readFile(join(CWD, ".aqkignore"), "utf8"))
166
+ .split("\n").map((l) => l.replace(/#.*$/, "").trim()).filter(Boolean);
167
+ } catch { /* файла нет — это норма, а не ошибка */ }
168
+ if (!ignored.length) say(`- ✅ ${L.report2.ignoreNone}`);
169
+ for (const pat of ignored) say(`- 🚫 ${pat}`);
170
+ if (ignored.length) {
171
+ say("");
172
+ say(`> ${L.report2.ignoreWarn}`);
173
+ }
174
+
175
+ say("");
176
+ say(`## ${L.report2.whyTitle}`);
177
+ say("");
178
+ for (const [rec] of todo.slice(0, 6)) say(`- **${rec.slug}** — ${rec.intent || ""}`);
179
+ if (!todo.length) say(`- ${L.report2.whyNothing}`);
180
+
181
+ const text = lines.join("\n") + "\n";
182
+ const dst = join(CWD, TARGET_DIR, "report.md");
183
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
184
+ await writeFile(dst, text, "utf8");
185
+
186
+ console.log("\n" + text);
187
+ console.log(c.dim(` ${L.report2.saved(join(TARGET_DIR, "report.md"))}\n`));
188
+
189
+ // Код возврата — для конвейера и для агента: отчёт с красным гейтом не должен читаться
190
+ // как «всё в порядке» только потому, что команда выполнилась.
191
+ process.exit(broken.length ? 1 : 0);
192
+ }
193
+
194
+ export { cmdReport };