agent-quality-kit 0.13.0 → 0.15.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 (57) hide show
  1. package/README.md +46 -8
  2. package/README.ru.md +49 -9
  3. package/kit/gates/api-contract-has-arbiter/README.md +16 -1
  4. package/kit/gates/api-contract-has-arbiter/check.sh +66 -23
  5. package/kit/gates/entry-commands-exist/README.md +64 -0
  6. package/kit/gates/entry-commands-exist/check.sh +110 -0
  7. package/kit/gates/entry-commands-exist/gate.yml +19 -0
  8. package/kit/gates/entry-commands-exist/green/AGENTS.md +13 -0
  9. package/kit/gates/entry-commands-exist/green/Makefile +6 -0
  10. package/kit/gates/entry-commands-exist/green/justfile +2 -0
  11. package/kit/gates/entry-commands-exist/green/package.json +10 -0
  12. package/kit/gates/entry-commands-exist/red/AGENTS.md +9 -0
  13. package/kit/gates/entry-commands-exist/red/Makefile +2 -0
  14. package/kit/gates/entry-commands-exist/red/package.json +9 -0
  15. package/llms.txt +5 -1
  16. package/package.json +1 -1
  17. package/tool/commands/context.mjs +59 -44
  18. package/tool/commands/doctor-catalog.mjs +222 -0
  19. package/tool/commands/doctor.mjs +46 -237
  20. package/tool/commands/feedback.mjs +157 -0
  21. package/tool/commands/learn.mjs +119 -19
  22. package/tool/commands/probe.mjs +4 -2
  23. package/tool/commands/project.mjs +11 -13
  24. package/tool/commands/prompt.mjs +70 -0
  25. package/tool/i18n/en-docs.mjs +1 -0
  26. package/tool/i18n/en-gates.mjs +27 -0
  27. package/tool/i18n/en.mjs +70 -3
  28. package/tool/i18n/index.mjs +42 -3
  29. package/tool/i18n/ru-docs.mjs +1 -0
  30. package/tool/i18n/ru-gates.mjs +28 -0
  31. package/tool/i18n/ru.mjs +81 -3
  32. package/tool/lib/annotate.mjs +66 -0
  33. package/tool/lib/ask.mjs +118 -0
  34. package/tool/lib/brief.mjs +17 -38
  35. package/tool/lib/cadence.mjs +30 -1
  36. package/tool/lib/core.mjs +8 -6
  37. package/tool/lib/gate-worker.mjs +4 -1
  38. package/tool/lib/repo.mjs +45 -4
  39. package/tool/lib/run.mjs +140 -9
  40. package/tool/program.mjs +10 -0
  41. package/tool/selfcheck/smoke/_fixture.mjs +8 -3
  42. package/tool/selfcheck/smoke/api-contract.test.mjs +37 -0
  43. package/tool/selfcheck/smoke/corpus.test.mjs +151 -0
  44. package/tool/selfcheck/smoke/fail-closed.test.mjs +96 -1
  45. package/tool/selfcheck/smoke/first-run.test.mjs +39 -0
  46. package/tool/selfcheck/smoke/verdict.test.mjs +41 -2
  47. package/tool/selfcheck/smoke.sh +4 -0
  48. package/tool/selfcheck/units-annotate.mjs +67 -0
  49. package/tool/selfcheck/units-ask.mjs +85 -0
  50. package/tool/selfcheck/units-brief.mjs +3 -13
  51. package/tool/selfcheck/units-cadence.mjs +26 -1
  52. package/tool/selfcheck/units-context.mjs +2 -1
  53. package/tool/selfcheck/units-feedback.mjs +137 -0
  54. package/tool/selfcheck/units-learn.mjs +32 -0
  55. package/tool/selfcheck/units-level.mjs +21 -1
  56. package/tool/selfcheck/units-prompt.mjs +106 -0
  57. package/tool/selfcheck/units-repo.mjs +20 -1
package/tool/i18n/ru.mjs CHANGED
@@ -10,12 +10,20 @@ import { templates } from "./templates-ru.mjs";
10
10
 
11
11
  import { ruGates } from "./ru-gates.mjs";
12
12
 
13
+ // «(коммитов с тех пор: 3)» — без склонений: число рядом со словом в родительном падеже
14
+ // читается одинаково при любом числе.
15
+ const ago = (n) => (n === null || n === undefined ? "" : ` (коммитов с тех пор: ${n})`);
16
+
13
17
  export const ru = {
14
18
  learn: {
15
19
  title: "Сказано вслух и не записано",
16
20
  noLogs: (p) => `логов этого проекта нет: ${p}\n Команда читает переписку Claude Code на этой машине. Пусто — значит здесь не работали.`,
17
- counted: (s, typed, said, fresh) =>
18
- `сессий: ${s} · напечатано человеком: ${typed} · похоже на наставление: ${said} · нет в точке входа: ${fresh}`,
21
+ counted: (s, typed, said, fresh, again) =>
22
+ `сессий: ${s} · напечатано человеком: ${typed} · похоже на наставление: ${said} · нет в точке входа: ${fresh} · повторено: ${again}`,
23
+ ruleTitle: "Записано, а поправлять всё равно приходится:",
24
+ ruleHow: "Правило стоит в своде, а вы повторяете его агенту снова — текстом оно не держится. Ему нужен сторож-машина: aqk find \"…\" или aqk new <имя>.",
25
+ repeatTitle: "Повторено — вы говорили это не в первый раз («я же говорил», «опять», «снова»):",
26
+ restTitle: "Остальное, похожее на правило и не записанное:",
19
27
  nothing: "всё, что похоже на правило, уже стоит в точке входа",
20
28
  andMore: (n) => `… и ещё ${n}`,
21
29
  warn:
@@ -45,7 +53,9 @@ export const ru = {
45
53
  blob: "собрать методички в один файл GOD_AI.md",
46
54
  learn: "кандидаты в правила из локальной переписки: сказано вслух и не записано",
47
55
  context: "состояние проекта одним блоком — для контекста агента, а не для чтения",
56
+ feedback: "отчёт о работе комплекта и готовая ссылка — единственная плата за него",
48
57
  vitals: "подключено ли то, чем комплект работает: инструменты гейтов, хуки, свежесть версии",
58
+ prompt: "одно задание для агента: что починить, по порядку, и чем доказать, что готово",
49
59
  contextInstall: "то же самое, но целиком — карта и свод правил — и хуком в контекст",
50
60
  report: "обязательная форма отчёта: что стоит, что нет, что не прочитано; --since <ссылка> — ещё и чем доказан диф",
51
61
  badge: "значок уровня для README — и проверка, что он не врёт",
@@ -74,6 +84,15 @@ export const ru = {
74
84
  coversCantCheck: (entry, gate) => `заявку «${gate} держит ${entry}» проверить не умею: линтер гейта не распознан или правил записи для него нет — принято на слово`,
75
85
  selectUnknown: (names, groups) => `--only/--skip: не знаю «${names}» — это не гейт из gates: и не группа из groups:${groups ? ` (группы: ${groups})` : ""}. Прогон всего подряд вместо пропуска был бы неправдой, поэтому стоп.`,
76
86
  selectSkipped: (names) => `не запускались (по --only/--skip): ${names} — их состояние неизвестно, это не «зелёные»`,
87
+ // Claude Code читает CLAUDE.md, а не AGENTS.md — документация Claude Code, раздел «AGENTS.md».
88
+ claudeShim: {
89
+ missing: "Claude Code здесь настроен (.claude/), а свод лежит в AGENTS.md — он его не читает. Почини: CLAUDE.md с одной строкой «@AGENTS.md».",
90
+ noImport: "CLAUDE.md не подключает AGENTS.md — Claude Code видит только CLAUDE.md. Почини: строка «@AGENTS.md» в CLAUDE.md (упоминание словами не загружает файл).",
91
+ },
92
+ annotDropped: (n, more) => `пометок в pull request: ${n}, ещё ${more} не показано — GitHub принимает около десяти за шаг; все находки — в логе выше`,
93
+ heldQuiet: (n, cmd) => `держит машина: ${n} — поимённо: ${cmd}`,
94
+ skipQuiet: (n, cmd) => `не применимо к этому репозиторию: ${n} — поимённо и почему: ${cmd}`,
95
+ passedQuiet: (n) => `прошли ещё ${n} — поимённо: --verbose`,
77
96
  jobsBad: (v) => `--jobs ждёт целое число от 1: «${v}» не подходит. Прогон по одному под видом параллельного был бы неправдой, поэтому стоп.`,
78
97
  rulesByHuman: (total, machine, human) =>
79
98
  `правил в точке входа: ${total}. Сторож — ЧЕЛОВЕК у ${human}, машина у ${machine}.`,
@@ -96,6 +115,19 @@ export const ru = {
96
115
  toReach: (n) => `Чтобы достичь AQK-${n}:`,
97
116
  gives: (what) => `Что это даст: ${what}`,
98
117
  allDone: "Все ступени пройдены.",
118
+ // Чего уровень не доказывает. Ступени меряют оснащённость: гейты показаны на образцах
119
+ // каталога. Про файлы проекта знает только проба — строка говорит ровно то, что знает она.
120
+ limitsTitle: "Уровень — это оснащённость, а не надёжность. Чего он не доказывает:",
121
+ limitsProbe: {
122
+ never: (_, cmd) => `брак в ваших файлах: проба не запускалась — ${cmd}`,
123
+ off: () => "брак в ваших файлах: проба выключена (probe: 0) — ловят ли его проверки, неизвестно",
124
+ blind: ({ names, behind }) => `брак в ваших файлах: проба${ago(behind)} НЕ поймала — ${names.join(", ")}`,
125
+ partial: ({ caught, unknown, behind }, cmd) => `брак в ваших файлах: проба${ago(behind)} — поймано классов ${caught}, у ${unknown} поимка не доказана (${cmd})`,
126
+ caught: ({ caught, behind }) => `брак в ваших файлах: проба${ago(behind)} поймала все ${caught} подсаженных классов — только тех, что есть в каталоге`,
127
+ nothing: ({ behind }, cmd) => `брак в ваших файлах: проба${ago(behind)} ничего не подсадила — ${cmd}`,
128
+ old: ({ behind }, cmd) => `брак в ваших файлах: проба была${ago(behind)}, итог покажет ${cmd}`,
129
+ },
130
+ limitsCi: "конвейер: прошёл ли он, отсюда не видно — проверяется только, что гейты в нём объявлены",
99
131
 
100
132
  gatesHeading: "Гейты",
101
133
  langs: "языки",
@@ -143,6 +175,12 @@ export const ru = {
143
175
  `либо почини и убери из advisory, либо признай, что правила нет.`,
144
176
  runHeading: "Прогон объявленных гейтов",
145
177
  timeout: "не уложился в 5 минут",
178
+ // «Не смогли проверить» — третье состояние, и оно обязано звучать иначе, чем находка:
179
+ // «код 2» человек читает как приговор коду, а это приговор запуску.
180
+ cannotCheck: (why) => `не смогли проверить: ${why}`,
181
+ whySpawn: (code) => `запустить не удалось${code ? ` (${code})` : ""}`,
182
+ whySignal: (sig) => `убит сигналом ${sig || "?"}`,
183
+ whyExit: (code) => `код ${code} — у этой команды это сбой, а не находка`,
146
184
  running: (i, n) => `[${i}/${n}] идёт…`,
147
185
  proving: "проверяю, что гейты ловят брак на своих образцах…",
148
186
  exitCode: (code) => `код ${code}`,
@@ -222,7 +260,7 @@ export const ru = {
222
260
  has_agent_entry: ["свода для агента здесь нет", "свод для агента есть"],
223
261
  has_ui: ["не видно стилей и компонентов интерфейса", "интерфейс есть: стили или компоненты"],
224
262
  has_mcp: ["агенту здесь не подключали внешних инструментов через MCP", "MCP-серверы объявлены"],
225
- has_api_spec: ["не видно спецификации API", "спецификация API есть"],
263
+ has_api_spec: ["не видно договора API: ни файла OpenAPI, ни tRPC, ts-rest или провайдера типов Fastify", "договор API есть: файл спецификации или схемы в коде"],
226
264
  },
227
265
  },
228
266
 
@@ -249,5 +287,45 @@ export const ru = {
249
287
  alreadyDeclared: "уже объявлен",
250
288
  },
251
289
 
290
+ // Задание агенту одним текстом (`aqk prompt`). У КАЖДОГО пункта хвост «Готово — …» с
291
+ // командой-арбитром: пункт без неё агент закроет словами «сделал» — это сторожит модульная проверка.
292
+ prompt: {
293
+ title: "# Задание: довести проверки проекта до рабочих",
294
+ intro: "Составлено AQK по состоянию репозитория. Выполняй пункты по порядку.",
295
+ rulesTitle: "## Правила",
296
+ rules: [
297
+ "Команды бери только из этого задания и из репозитория. Не выдумывай.",
298
+ "Сначала запусти проверку пункта и убедись, что она красная. Потом чини. Готово — когда та же команда зелёная.",
299
+ "Чини код, а не проверку: не ослабляй порог, не добавляй исключений, не выключай гейт. Считаешь проверку неправой — остановись и спроси владельца.",
300
+ "Решение, которое может принять только владелец (что ставить в проект, какие правила вводить), — спроси, а не угадывай.",
301
+ "Меняй только то, что нужно для этих пунктов.",
302
+ ],
303
+ tasksTitle: "## Что сделать",
304
+ empty: "Делать нечего: красных гейтов нет, проба ничего не нашла, ставить нечего. Всё равно выполни проверку ниже.",
305
+ done: "Готово —",
306
+ item: {
307
+ init: (s) => `Заведи манифест: \`${s} init\` — без него остальные команды отказывают. Готово — \`${s} doctor\` показывает уровень.`,
308
+ runNone: (s) => `Прогона ещё не было. Запусти \`${s} doctor --run\` и почини то, что покраснеет, по одному гейту: \`${s} doctor --run --only <имя>\`. Готово — прогон зелёный.`,
309
+ runStale: (s, when) => `Прогон ${when} старше последнего коммита — список красных ниже может быть про другой код. Запусти \`${s} doctor --run\` заново. Готово — у тебя свежий итог, и пункты ниже сверены с ним.`,
310
+ missed: ({ slug, file }, s) => `Гейт \`${slug}\` стоит, но пропустил брак, который проба подсадила в \`${file}\`. Разберись почему — частая причина: гейт не смотрит этот тип файлов или каталог; подробности даст \`${s} probe\`. Готово — \`${s} probe\` больше не называет этот класс.`,
311
+ red: (name, s) => `Гейт \`${name}\` красный. Запусти \`${s} doctor --run --only ${name}\`, прочитай находки и почини код. Готово — эта команда зелёная.`,
312
+ blind: ({ slug, file, command }, s) => `Проба подсадила брак класса \`${slug}\` в \`${file}\`, и проверки проекта его не заметили. Поставь проверку: \`${s} add ${slug}\`${command ? ` (то же одной строкой, без комплекта: \`${command}\`)` : ""}. Готово — \`${s} doctor --run --only ${slug}\` проходит, а \`${s} probe\` больше не называет этот класс.`,
313
+ adopt: (gates, s) => `У проекта уже есть свои проверки: ${gates.map((g) => `\`${g.cmd}\` (${g.source})`).join(", ")}. Впиши их в gates: манифеста .aqk.yml — ${gates.map((g) => `\`${g.name}: "${g.cmd}"\``).join(", ")}. Готово — \`${s} doctor --run\` их запускает.`,
314
+ shim: {
315
+ missing: (s) => `Claude Code здесь настроен, а правила лежат в AGENTS.md — он читает только CLAUDE.md. Создай CLAUDE.md с одной строкой \`@AGENTS.md\`. Готово — \`${s} doctor\` об этом больше не предупреждает.`,
316
+ noImport: (s) => `CLAUDE.md не подключает AGENTS.md — Claude Code видит только CLAUDE.md. Добавь в CLAUDE.md строку \`@AGENTS.md\` (упоминание словами файл не загружает). Готово — \`${s} doctor\` об этом больше не предупреждает.`,
317
+ },
318
+ // Запись каталога — предложение, а не факт о проекте: ставить или нет, решает владелец.
319
+ // Красный гейт и брак из пробы — факты, там «почини»; здесь — «предложи».
320
+ start: ({ slug, intent, command }, s) => `Предложи владельцу проверку \`${slug}\`${intent ? ` — ${intent}` : ""}. Согласен — \`${s} add ${slug}\`${command ? ` (то же одной строкой, без комплекта: \`${command}\`)` : ""}. Краснеет на существующем коде — не глуши её, покажи находки владельцу. Готово — \`${s} doctor --run --only ${slug}\` проходит, а \`${s} prove\` показывает её доказанной.`,
321
+ },
322
+ more: (n, s) => `И ещё ${n} — полный список: \`${s} doctor\`. Сначала закончи эти.`,
323
+ verifyTitle: "## Как проверить, что готово",
324
+ verify: (s) => [
325
+ `\`${s} doctor --run\` — прогон зелёный.`,
326
+ `\`${s} prove\` — ни один гейт не сломан: каждый краснеет на своём красном образце.`,
327
+ "В отчёте назови эти команды и их итог. «Выглядит рабочим» — не готово.",
328
+ ],
329
+ },
252
330
  ...ruGates,
253
331
  };
@@ -0,0 +1,66 @@
1
+ // tool/lib/annotate.mjs — находки упавших гейтов пометками GitHub Actions.
2
+ //
3
+ // ЗАЧЕМ. Находка видна только в логе конвейера, куда почти никто не заглядывает. Строка
4
+ // `::error file=…,line=…,title=…::…` в выводе шага становится красной пометкой у строки файла
5
+ // прямо в pull request. Идея — из разбора AgentLint (research/competitors/agentlint-0xmariowu.md);
6
+ // формат — документация GitHub «Workflow commands»; экранирование — официальный @actions/core
7
+ // (packages/core/src/command.ts, escapeData и escapeProperty), а не их код.
8
+ //
9
+ // ЧТО НЕ ДЕЛАЕТСЯ НИКОГДА: пометка не меняет вердикт. Это печать того же, что уже решил прогон.
10
+ import { splitAdvice, normPath } from "./scope.mjs";
11
+
12
+ const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
13
+
14
+ // Потолок. В документации GitHub лимита не нашли; в обсуждении сообщества 2020 года
15
+ // (github.com/orgs/community/discussions/26680): «10 warning annotations and 10 error annotations
16
+ // per step». Прогон `doctor` — один шаг, поэтому потолок на весь прогон, а остаток — числом в лог.
17
+ const LIMIT = 10;
18
+
19
+ // Путь с расширением или с косой чертой — как в scope.mjs. Две формы строки находки: «файл:строка:
20
+ // текст» (grep -n, линтеры, иногда ещё «:колонка») и «файл: текст» (наши записи каталога).
21
+ const PATH = "(?:\\.\\/)?((?:[\\w.@+-]+\\/)*[\\w.@+-]+\\.[A-Za-z][A-Za-z0-9]{0,9})";
22
+ const WITH_LINE = new RegExp(`^${PATH}:(\\d+)(?::\\d+)?:\\s*([\\s\\S]*)$`);
23
+ const NO_LINE = new RegExp(`^${PATH}:\\s+([\\s\\S]+)$`);
24
+
25
+ function locate(raw) {
26
+ const plain = String(raw).replace(ANSI, "").trim();
27
+ if (/^(почини|fix)\s*:/i.test(plain)) return null;
28
+ let m = WITH_LINE.exec(plain);
29
+ if (m) return { file: normPath(m[1]), line: Number(m[2]), message: m[3] || plain };
30
+ m = NO_LINE.exec(plain);
31
+ if (m) return { file: normPath(m[1]), line: null, message: m[2] };
32
+ return null;
33
+ }
34
+
35
+ const escData = (s) => String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
36
+ const escProp = (s) => escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
37
+
38
+ // results — те же записи, что возвращает runGates: { name, ok, advisory, out, shown }. Берётся
39
+ // `shown` — показанное прогоном после сужения по дифу; сырой `out` — только если его нет. exists(путь) —
40
+ // есть ли файл в репозитории: пометка на несуществующий файл GitHub вешает на `.github`, и
41
+ // человек ищет то, чего нет. Такая находка идёт общей пометкой гейта без файла.
42
+ function annotations(results, { exists = () => true, limit = LIMIT } = {}) {
43
+ const lines = [];
44
+ const used = { error: 0, warning: 0 };
45
+ let dropped = 0;
46
+ const emit = (level, title, message, loc = null) => {
47
+ if (used[level] >= limit) { dropped++; return; }
48
+ used[level]++;
49
+ const props = loc ? [`file=${escProp(loc.file)}`, ...(loc.line ? [`line=${loc.line}`] : [])] : [];
50
+ props.push(`title=${escProp(title)}`);
51
+ lines.push(`::${level} ${props.join(",")}::${escData(message)}`);
52
+ };
53
+ for (const r of results || []) {
54
+ if (r.ok) continue;
55
+ const level = r.advisory ? "warning" : "error";
56
+ const title = `aqk: ${r.name}`;
57
+ const { findings, advice } = splitAdvice(String(r.shown ?? r.out ?? "").split("\n").filter((l) => l.trim()));
58
+ const fix = advice.length ? ` — ${String(advice[0]).replace(ANSI, "").trim()}` : "";
59
+ const located = findings.map(locate).filter((f) => f && exists(f.file));
60
+ for (const f of located) emit(level, title, `${f.message}${fix}`, f);
61
+ if (!located.length) emit(level, title, `${String(findings[0] || r.note || "").replace(ANSI, "").trim()}${fix}`);
62
+ }
63
+ return { lines, dropped };
64
+ }
65
+
66
+ export { locate, annotations };
@@ -0,0 +1,118 @@
1
+ // tool/lib/ask.mjs — КОГДА КОМПЛЕКТ ОБРАЩАЕТСЯ К ЧЕЛОВЕКУ, и как он помнит, что уже обращался.
2
+ //
3
+ // ЗАЧЕМ ОТДЕЛЬНЫЙ МОДУЛЬ. Знание «мы это показывали» жило в комплекте дважды и по-разному:
4
+ // · `~/.config/aqk/feedback-shown` — файл-флаг, раз на машину: заведён в `core.mjs`,
5
+ // прочитан в `project.mjs`;
6
+ // · `.aqk/advice-shown`, `.aqk/update-checked` — отметка временем плюс `adviceDue()` на
7
+ // сутки: заведены и прочитаны в `brief.mjs`.
8
+ // Одно решение — «не долби человека» — в двух местах и в двух форматах. Третье обращение
9
+ // завело бы третий формат, и дальше они расходятся молча.
10
+ //
11
+ // ЧТО ЗДЕСЬ ЕСТЬ И ЧЕГО НЕТ. Здесь только ОГРАНИЧИТЕЛЬ: можно ли сейчас обратиться и чем это
12
+ // запомнить. Чего здесь нет — текста обращения и решения, есть ли о чём говорить: текст живёт
13
+ // в каталогах строк, решение — у того, кто знает состояние проекта. Иначе модуль про «когда»
14
+ // начал бы меняться вместе с каждой правкой формулировки.
15
+ //
16
+ // ЛИСТ ДЕРЕВА: импортируются только встроенные модули Node. Каталоги передаются вызывающим, а
17
+ // не берутся из `core.mjs`, — иначе получилось бы кольцо: `core.mjs` берёт отсюда список
18
+ // служебных файлов для `.gitignore`.
19
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+
22
+ const DAY = 24 * 60 * 60 * 1000;
23
+
24
+ // ТАБЛИЦА ВИДОВ. Вид объявляет две вещи, и обе нельзя угадать по имени:
25
+ // where — «project» (отметка про ЭТОТ репозиторий, лежит в его служебном каталоге) или
26
+ // «home» (отметка про ЭТУ МАШИНУ, лежит в доме пользователя);
27
+ // every — через сколько можно повторить; `null` означает «никогда», а не «очень нескоро».
28
+ //
29
+ // Имена файлов — прежние, до единой буквы: человек, у которого отметка уже лежит, не должен
30
+ // получить обращение заново только потому, что мы переставили код.
31
+ const ASKS = {
32
+ // Совет про непоставленную запись каталога — в короткой строке хука.
33
+ advice: { where: "project", file: "advice-shown", every: DAY },
34
+ // Проверка свежести версии — единственный исходящий запрос комплекта.
35
+ update: { where: "project", file: "update-checked", every: DAY },
36
+ // Просьба об отзыве после установки — раз на машину: второй `init` в другом репозитории на
37
+ // том же компьютере её не повторяет.
38
+ install: { where: "home", file: "feedback-shown", every: null },
39
+ // Просьба об отзыве ПО ДЕЛУ — раз на проект. Объявлена здесь до первого использования
40
+ // намеренно: строка в `.gitignore` обязана появиться РАНЬШЕ, чем файл будет записан, иначе
41
+ // отметка уедет в чужой коммит у всех, кто поставил комплект между двумя выпусками.
42
+ value: { where: "project", file: "feedback-asked", every: null },
43
+ };
44
+
45
+ // Опечатка в виде обращения обязана падать. Оба молчаливых умолчания неверны в половине
46
+ // случаев: «показывать всегда» превращает ограничитель в шум, «не показывать» — выключает
47
+ // обращение навсегда, и никто об этом не узнает.
48
+ function askKind(kind) {
49
+ const a = ASKS[kind];
50
+ if (!a) throw new Error(`неизвестный вид обращения: ${kind}. Известны: ${Object.keys(ASKS).join(", ")}`);
51
+ return a;
52
+ }
53
+
54
+ // Служебные файлы ЭТОГО проекта — для `.gitignore`. Домашние сюда не идут: их git не видит.
55
+ const ASK_FILES = Object.values(ASKS).filter((a) => a.where === "project").map((a) => a.file);
56
+
57
+ // `project` — служебный каталог репозитория (тот самый `.aqk`), `home` — дом пользователя.
58
+ // Каталог передаётся целиком, а не собирается здесь: имя `.aqk` знает `core.mjs`, и второй
59
+ // его экземпляр однажды разошёлся бы с первым.
60
+ function askFile(kind, { project, home } = {}) {
61
+ const a = askKind(kind);
62
+ return a.where === "home" ? join(String(home ?? ""), ".config", "aqk", a.file) : join(String(project ?? ""), a.file);
63
+ }
64
+
65
+ // Пора ли обращаться. `stamp` — содержимое отметки или null, если её нет.
66
+ //
67
+ // Разовое обращение: отметка есть — значит было, и повторить нельзя никогда, что бы в ней ни
68
+ // лежало. Старый формат хранил слово «shown», а не дату: читать его как испорченную дату и
69
+ // показывать заново значило бы повторить просьбу у всех, кто поставил комплект раньше.
70
+ //
71
+ // Суточное: «не знаем, когда показывали» и «показывали давно» — одно и то же решение, показать.
72
+ // Молчать из-за нечитаемого файла состояния значит потерять обращение навсегда и не сказать
73
+ // почему.
74
+ function askDue(kind, stamp, now = Date.now()) {
75
+ const a = askKind(kind);
76
+ if (stamp === null || stamp === undefined || String(stamp).trim() === "") return true;
77
+ if (a.every === null) return false;
78
+ const t = Date.parse(String(stamp).trim());
79
+ if (!Number.isFinite(t)) return true;
80
+ return now - t >= a.every;
81
+ }
82
+
83
+ // Отметка с диска: строка или null. Файла нет, каталог не читается, прав не хватило — всё это
84
+ // «не показывали»: потерять обращение из-за нечитаемой отметки дешевле, чем молчать.
85
+ async function readStamp(kind, dirs) {
86
+ try {
87
+ return (await readFile(askFile(kind, dirs), "utf8")).trim();
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // Запомнить обращение. Возвращает true, если записали, — СЛОВО О НЕУДАЧЕ ГОВОРИТ ВЫЗЫВАЮЩИЙ:
94
+ // у совета повтор это мелочь, а у разовой просьбы — та же просьба завтра, и человеку надо
95
+ // сказать, почему она вернулась. Дом бывает недоступен для записи: в контейнере, запущенном
96
+ // `--user 1001:127`, у этого uid нет записи в /etc/passwd, `homedir()` даёт «/», и запись
97
+ // падает на `/.config`. До 2026-09-09 это роняло весь `init` — то есть любого, кто набрал
98
+ // команду из нашей же документации по docker.
99
+ async function markAsked(kind, dirs, now = new Date()) {
100
+ const path = askFile(kind, dirs);
101
+ try {
102
+ await mkdir(join(path, ".."), { recursive: true });
103
+ await writeFile(path, `${now.toISOString()}\n`, "utf8");
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ // Пора ли обращаться, с чтением отметки. Две трети вызывающих хотят именно этого; чистый
111
+ // `askDue` остаётся для перебора случаев, которых на диске не бывает.
112
+ async function askAllowed(kind, dirs, now = Date.now()) {
113
+ return askDue(kind, await readStamp(kind, dirs), now);
114
+ }
115
+
116
+ // Наружу — то, что зовут снаружи. `readStamp` внутренний: снаружи спрашивают «пора ли»,
117
+ // а не «что лежит в файле», и лишний экспорт читается как часть договора.
118
+ export { ASKS, ASK_FILES, askDue, askFile, markAsked, askAllowed };
@@ -1,6 +1,7 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { CWD, PKG_ROOT, TARGET_DIR, SELF, c } from "./core.mjs";
3
+ import { PKG_ROOT, SELF, c, stateDirs } from "./core.mjs";
4
+ import { askAllowed, markAsked } from "./ask.mjs";
4
5
  import { L } from "../i18n/index.mjs";
5
6
  import { canDrawArt } from "./banner.mjs";
6
7
  // tool/lib/brief.mjs — короткая строка присутствия для прогона в хуке.
@@ -19,10 +20,6 @@ import { canDrawArt } from "./banner.mjs";
19
20
  // то, что видишь тридцатый раз, перестаёт читаться — и пролистывается вместе с настоящими
20
21
  // находками, стоящими рядом.
21
22
 
22
- // Сутки. Не «раз в прогон» и не «раз в неделю»: за сутки человек успевает забыть, но не успевает
23
- // устать. Число здесь спорное — важно, что ограничитель есть и он машинный.
24
- const ADVICE_EVERY_MS = 24 * 60 * 60 * 1000;
25
-
26
23
  // ЗНАЧОК ПРИСУТСТВИЯ — здесь, а не в каталогах строк. Символ один на оба языка, и держать его
27
24
  // в двух местах значит однажды получить разные значки в ru и en: то же правило, по которому у
28
25
  // нас один свод правил на две точки входа. Выбран владельцем из пятидесяти семи вариантов.
@@ -41,16 +38,6 @@ function briefLine(state, L, env = process.env) {
41
38
  return `${head}\n${t.red(state.red.join(", "))}`;
42
39
  }
43
40
 
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
41
  // Первая из непоставленных, а не «самая важная»: важность мы не считаем, а порядок каталога
55
42
  // осмыслен — записи в нём лежат от общего к частному. Выдавать порядок за приоритет нельзя.
56
43
  function pickAdvice(todo = []) {
@@ -112,9 +99,7 @@ function beginBrief() {
112
99
 
113
100
  // Печать краткого итога. Совет — не чаще раза в сутки и с явным способом отказаться: то, что
114
101
  // видишь тридцатый раз, перестаёт читаться и пролистывается вместе с настоящими находками рядом.
115
- // Отметка времени лежит в .aqk/ и попадает в .gitignore при `init` (RUNTIME_FILES в core.mjs):
116
- // это состояние машины, а не проекта. Раньше здесь было написано «.aqk/ в .gitignore» — а
117
- // `init` туда ничего не клал, и на живом проекте служебный файл уехал в коммит.
102
+ // Сам ограничитель и отметка — в `ask.mjs`, общие на все обращения комплекта к человеку.
118
103
  async function finishBrief(buf, state, todoRecs, ok) {
119
104
  if (!buf) return;
120
105
  buf.restore();
@@ -129,18 +114,16 @@ async function finishBrief(buf, state, todoRecs, ok) {
129
114
  if (!ok) { console.log(buf.lines.join("\n")); return; }
130
115
 
131
116
  if (process.env.AQK_ADVICE === "0" || !state.todo) return;
132
- const stampFile = join(CWD, TARGET_DIR, "advice-shown");
133
- let last = null;
134
- try { last = (await readFile(stampFile, "utf8")).trim(); } catch { /* не показывали ещё */ }
135
- if (!adviceDue(last)) return;
117
+ // Ограничитель общий на все обращения комплекта к человеку (ask.mjs): совет, проверка
118
+ // версии и просьба об отзыве считают «уже показывали» одним кодом и одним форматом.
119
+ const dirs = stateDirs();
120
+ if (!(await askAllowed("advice", dirs))) return;
136
121
  const advice = pickAdvice(todoRecs);
137
122
  if (!advice) return;
138
123
  console.log(c.dim(L.brief.advise(advice.slug, advice.intent || "")));
139
124
  console.log(c.dim(L.brief.adviseOff(`${SELF} why ${advice.slug}`, "AQK_ADVICE=0")));
140
- try {
141
- await mkdir(join(CWD, TARGET_DIR), { recursive: true });
142
- await writeFile(stampFile, new Date().toISOString(), "utf8");
143
- } catch { /* не смогли записать отметку — совет повторится, это не беда */ }
125
+ // Не записалось — совет повторится завтра, и это не беда: молчать об этом человеку незачем.
126
+ await markAsked("advice", dirs);
144
127
  }
145
128
 
146
129
  // Спрашивает реестр npm о своей версии. РАЗ В СУТКИ, НЕ В КОНВЕЙЕРЕ, С ТАЙМАУТОМ, И МОЛЧА
@@ -152,10 +135,8 @@ async function finishBrief(buf, state, todoRecs, ok) {
152
135
  // и вместе с ним всё остальное, что печатает эта строка.
153
136
  async function maybeUpdateNotice() {
154
137
  if (!updateWanted()) return;
155
- const stamp = join(CWD, TARGET_DIR, "update-checked");
156
- let last = null;
157
- try { last = (await readFile(stamp, "utf8")).trim(); } catch { /* ещё не спрашивали */ }
158
- if (!adviceDue(last)) return;
138
+ const dirs = stateDirs();
139
+ if (!(await askAllowed("update", dirs))) return;
159
140
 
160
141
  let current = "";
161
142
  try { current = JSON.parse(await readFile(join(PKG_ROOT, "package.json"), "utf8")).version || ""; } catch { return; }
@@ -163,10 +144,7 @@ async function maybeUpdateNotice() {
163
144
  // ОТМЕТКА СТАВИТСЯ ДО ЗАПРОСА, а не после удачного ответа. Сперва было наоборот, и замер
164
145
  // показал цену: человек без сети платил бы ожиданием на КАЖДОМ коммите, а не раз в сутки.
165
146
  // Из двух ошибок выбрана дешёвая: пропущенное за день уведомление против ежедневного стопора.
166
- try {
167
- await mkdir(join(CWD, TARGET_DIR), { recursive: true });
168
- await writeFile(stamp, new Date().toISOString(), "utf8");
169
- } catch { /* не смогли записать — спросим ещё раз, это не беда */ }
147
+ await markAsked("update", dirs);
170
148
 
171
149
  let latest = "";
172
150
  try {
@@ -189,6 +167,7 @@ async function maybeUpdateNotice() {
189
167
  if (notice) console.log(c.dim(notice));
190
168
  }
191
169
 
192
- // Наружу — только то, что зовут снаружи. `cmpVer` и `ADVICE_EVERY_MS` внутренние: экспорт,
193
- // который никто не импортирует, читается как часть договора и мешает менять внутренности.
194
- export { briefLine, adviceDue, pickAdvice, updateNotice, updateWanted, beginBrief, finishBrief };
170
+ // Наружу — только то, что зовут снаружи. `cmpVer` внутренний: экспорт, который никто не
171
+ // импортирует, читается как часть договора и мешает менять внутренности. Ограничитель обращений
172
+ // уехал целиком в `ask.mjs` вместе с проверками, которые его сторожили.
173
+ export { briefLine, pickAdvice, updateNotice, updateWanted, beginBrief, finishBrief };
@@ -99,4 +99,33 @@ function autoProbeAllowed({ brief = false, env = process.env } = {}) {
99
99
  return !(env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI || env.BUILDKITE || env.JENKINS_URL);
100
100
  }
101
101
 
102
- export { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan, autoProbeAllowed };
102
+ // Сколько классов проба поймала и у скольких поимка не доказана. Старая отметка чисел не несёт —
103
+ // null: «не знаем», а не «ноль пойманных».
104
+ function parseCounts(text) {
105
+ const caught = /^caught:[ \t]*(\d+)/m.exec(String(text || ""));
106
+ const unknown = /^unknown:[ \t]*(\d+)/m.exec(String(text || ""));
107
+ return caught && unknown ? { caught: Number(caught[1]), unknown: Number(unknown[1]) } : null;
108
+ }
109
+
110
+ // Чего уровень НЕ доказывает — строкой под ним. Отзыв с живого проекта 2026-09-11: «AQK-3, All
111
+ // levels reached» при конвейере, который ни разу не запускался. Ступени меряют оснащённость:
112
+ // гейты показаны на образцах КАТАЛОГА. Ловят ли они брак в файлах ПРОЕКТА, знает только проба —
113
+ // и строка говорит ровно то, что знает она. Непойманное идёт первым, даже если пойманного
114
+ // больше: «четыре из пяти» глаз читает как «всё хорошо».
115
+ //
116
+ // Планку ступеней не подняли намеренно: отметка пробы лежит в .gitignore, в конвейере её нет, и
117
+ // «AQK-2 только после пробы» уронило бы уровень и `badge --check` у всех разом.
118
+ function levelLimits(st) {
119
+ if (st?.state === "off") return { kind: "off" };
120
+ const behind = Number.isFinite(st?.behind) ? st.behind : null;
121
+ if (st?.classes?.length) return { kind: "blind", names: st.classes.map((x) => x.slug), behind };
122
+ if (st?.counts) {
123
+ const { caught, unknown } = st.counts;
124
+ if (unknown > 0) return { kind: "partial", caught, unknown, behind };
125
+ return caught > 0 ? { kind: "caught", caught, behind } : { kind: "nothing", behind };
126
+ }
127
+ if (st?.ran) return { kind: "old", behind };
128
+ return { kind: "never" };
129
+ }
130
+
131
+ export { probeDue, probeState, probeEvery, PROBE_EVERY, blindLines, parseBlind, parseRan, autoProbeAllowed, parseCounts, levelLimits };
package/tool/lib/core.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  // размер файла. Зависимостей по-прежнему нет ни одной: только встроенные модули Node.
6
6
 
7
7
  import { LANG } from "../i18n/index.mjs";
8
+ import { ASK_FILES } from "./ask.mjs";
8
9
  import { access, readdir, mkdir, copyFile, writeFile } from "node:fs/promises";
9
10
  import { constants } from "node:fs";
10
11
  import { fileURLToPath } from "node:url";
@@ -83,8 +84,10 @@ function commandRows(L) {
83
84
  { name: "learn", args: "", text: h.learn },
84
85
  { name: "context", args: "", text: h.context },
85
86
  { name: "context", args: "--full --install", text: h.contextInstall },
87
+ { name: "prompt", args: "", text: h.prompt },
86
88
  { name: "badge", args: "", text: h.badge },
87
89
  { name: "vitals", args: "", text: h.vitals },
90
+ { name: "feedback", args: "", text: h.feedback },
88
91
  { name: "version", args: "", text: h.version },
89
92
  ];
90
93
  }
@@ -104,10 +107,9 @@ const RATCHET_DIR = "ratchets";
104
107
  // обёртка плюс реестр, и разносить их по разным каталогам значит прятать половину механизма.
105
108
  const RATCHET_LIB = `${RATCHET_DIR}/_ratchet.sh`;
106
109
 
107
- // Отметка «просьбу про звезду уже показали»вне репозитория, в доме пользователя. Внутри
108
- // .aqk/ она либо закоммитится в чужой проект как наш мусор, либо пропадёт при init --force:
109
- // то и другое врёт о том, видел человек просьбу или нет.
110
- const FEEDBACK_MARK = join(homedir(), ".config", "aqk", "feedback-shown");
110
+ // Служебный каталог этого проекта и дом пользователя два места, где комплект держит
111
+ // состояние. Кто и как часто туда пишет, решает `ask.mjs`; здесь только адреса.
112
+ const stateDirs = () => ({ project: join(CWD, TARGET_DIR), home: homedir() });
111
113
 
112
114
  // Путь, попадающий в ДОКУМЕНТ, всегда пишется через «/». `relative()` отдаёт разделитель
113
115
  // платформы, и на Windows склейка методичек и отчёт получались с «kit\\docs» вместо «kit/docs»:
@@ -147,7 +149,7 @@ async function writeIfAbsent(path, content, { force }) {
147
149
  // читателей. Отзыв с живого проекта 2026-09-11: `.aqk/last-run.md` однажды закоммитили, и каждый
148
150
  // `make check` оставлял изменённый файл. Целиком `.aqk/` не игнорируется: методички и правила в
149
151
  // нём — содержимое проекта.
150
- const RUNTIME_FILES = ["last-run.md", "last-probe.md", "advice-shown", "update-checked"];
152
+ const RUNTIME_FILES = ["last-run.md", "last-probe.md", ...ASK_FILES];
151
153
  const L_IGNORE_NOTE = LANG === "en"
152
154
  ? "# aqk: this machine's state — rewritten by every run, it does not belong in git"
153
155
  : "# aqk: состояние этой машины — переписывается каждым прогоном, в git ему не место";
@@ -184,5 +186,5 @@ export {
184
186
  copyDir, writeIfAbsent,
185
187
  PKG_ROOT, CWD, DOCS_SRC, RULES_SRC, TARGET_DIR, docPath,
186
188
  MANIFEST, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB,
187
- SELF, REPO_URL, c, exists, die, FEEDBACK_MARK, commandRows, preCommitHook, RUNTIME_FILES, ensureIgnored,
189
+ SELF, REPO_URL, c, exists, die, stateDirs, commandRows, preCommitHook, RUNTIME_FILES, ensureIgnored,
188
190
  };
@@ -12,7 +12,10 @@ import { gateCommand } from "./execution.mjs";
12
12
  parentPort.on("message", ({ id, cmd, cwd, timeout }) => {
13
13
  const r = spawnSync(gateCommand(cmd), { shell: true, cwd, encoding: "utf8", timeout });
14
14
  parentPort.postMessage({
15
- id, status: r.status, stdout: r.stdout || "", stderr: r.stderr || "",
15
+ // `signal` передаётся наравне со статусом: по нему исход отличает наш срок (SIGTERM) от
16
+ // чужого убийства. Без него параллельный прогон объяснял бы сбой иначе, чем одиночный, —
17
+ // а одно и то же событие обязано называться одним словом в обоих.
18
+ id, status: r.status, signal: r.signal || null, stdout: r.stdout || "", stderr: r.stderr || "",
16
19
  error: r.error ? { code: r.error.code || String(r.error.message || r.error) } : null,
17
20
  });
18
21
  });