agent-quality-kit 0.14.0 → 0.16.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 (74) hide show
  1. package/README.md +88 -18
  2. package/README.ru.md +91 -19
  3. package/kit/docs/ai/index.md +1 -0
  4. package/kit/docs/ai/operational-gates.md +275 -0
  5. package/kit/gates/_target.sh +53 -0
  6. package/kit/gates/ci-actually-fails/check.sh +18 -3
  7. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +10 -0
  8. package/kit/gates/entry-commands-exist/check.sh +88 -12
  9. package/kit/gates/hook-actually-fires/README.md +12 -0
  10. package/kit/gates/hook-actually-fires/check.sh +66 -6
  11. package/kit/gates/hook-actually-fires/gate.yml +2 -2
  12. package/kit/gates/hook-actually-fires/green/.claude/hooks/auto-format.sh +3 -0
  13. package/kit/gates/hook-actually-fires/green/.claude/hooks/block-dangerous.sh +3 -0
  14. package/kit/gates/hook-actually-fires/green/.claude/hooks/done.sh +3 -0
  15. package/kit/gates/hook-actually-fires/green/.claude/hooks/idle.sh +3 -0
  16. package/kit/gates/hook-actually-fires/green/.claude/hooks/prompt.sh +3 -0
  17. package/kit/gates/hook-actually-fires/green/.claude/hooks/session.mjs +1 -0
  18. package/kit/gates/hook-actually-fires/green/.claude/hooks/stop-gate.sh +3 -0
  19. package/kit/gates/hook-actually-fires/green/.claude/settings.json +12 -0
  20. package/kit/gates/test-not-adjusted/README.md +31 -0
  21. package/llms.txt +26 -7
  22. package/package.json +1 -1
  23. package/tool/commands/context.mjs +39 -41
  24. package/tool/commands/doctor-catalog.mjs +35 -10
  25. package/tool/commands/doctor.mjs +18 -26
  26. package/tool/commands/feedback.mjs +231 -0
  27. package/tool/commands/gates.mjs +12 -6
  28. package/tool/commands/project.mjs +11 -13
  29. package/tool/commands/prompt.mjs +2 -1
  30. package/tool/commands/report.mjs +19 -3
  31. package/tool/commands/vitals.mjs +9 -3
  32. package/tool/i18n/en-docs.mjs +19 -2
  33. package/tool/i18n/en-gates.mjs +35 -0
  34. package/tool/i18n/en.mjs +29 -2
  35. package/tool/i18n/ru-docs.mjs +18 -2
  36. package/tool/i18n/ru-gates.mjs +36 -0
  37. package/tool/i18n/ru.mjs +27 -2
  38. package/tool/lib/adopt.mjs +58 -4
  39. package/tool/lib/ask.mjs +118 -0
  40. package/tool/lib/brief.mjs +17 -38
  41. package/tool/lib/core.mjs +49 -12
  42. package/tool/lib/execution.mjs +32 -1
  43. package/tool/lib/gate-worker.mjs +4 -1
  44. package/tool/lib/manifest.mjs +39 -13
  45. package/tool/lib/prove.mjs +3 -3
  46. package/tool/lib/run.mjs +142 -10
  47. package/tool/program.mjs +6 -0
  48. package/tool/selfcheck/smoke/_fixture.mjs +13 -1
  49. package/tool/selfcheck/smoke/fail-closed.test.mjs +96 -1
  50. package/tool/selfcheck/smoke/feedback-send.test.mjs +87 -0
  51. package/tool/selfcheck/smoke/first-run.test.mjs +67 -3
  52. package/tool/selfcheck/smoke/preflight.test.mjs +83 -0
  53. package/tool/selfcheck/smoke/verdict.test.mjs +50 -4
  54. package/tool/selfcheck/smoke/version-sync.test.mjs +140 -0
  55. package/tool/selfcheck/smoke.sh +106 -4
  56. package/tool/selfcheck/units-ask.mjs +85 -0
  57. package/tool/selfcheck/units-brief.mjs +3 -13
  58. package/tool/selfcheck/units-context.mjs +2 -1
  59. package/tool/selfcheck/units-execution.mjs +37 -1
  60. package/tool/selfcheck/units-feedback.mjs +137 -0
  61. package/tool/selfcheck/units-level.mjs +41 -1
  62. package/tool/selfcheck/units-repo.mjs +75 -0
  63. package/tool/selfcheck/units-vitals.mjs +27 -0
  64. package/kit/gates/entry-links-exist/README.md +0 -27
  65. package/kit/gates/entry-links-exist/check.sh +0 -33
  66. package/kit/gates/entry-links-exist/gate.yml +0 -17
  67. package/kit/gates/entry-links-exist/green/AGENTS.md +0 -10
  68. package/kit/gates/entry-links-exist/green/rules/general.md +0 -3
  69. package/kit/gates/entry-links-exist/red/AGENTS.md +0 -3
  70. package/kit/gates/no-phantom-package/README.md +0 -84
  71. package/kit/gates/no-phantom-package/check.sh +0 -168
  72. package/kit/gates/no-phantom-package/gate.yml +0 -20
  73. package/kit/gates/no-phantom-package/green/AGENTS.md +0 -15
  74. package/kit/gates/no-phantom-package/red/AGENTS.md +0 -15
@@ -0,0 +1,231 @@
1
+ // tool/commands/feedback.mjs — `aqk feedback`: единственная плата за комплект — один ответ.
2
+ //
3
+ // ЗАЧЕМ. Замер 2026-09-14: 1342 скачивания в неделю в npm и ни одного пользователя — версии
4
+ // качаются равномерно, включая прожившую двадцать пять минут, то есть это зеркала и сканеры. На
5
+ // GitHub за две недели семь уникальных посетителей, две звезды, ноль чужих комментариев за всё
6
+ // время. Обратной связи нет не потому, что люди молчат: просить мы не умеем. Единственная
7
+ // просьба печаталась при `init` — ДО того, как комплект сделал хоть что-то полезное, — и звала
8
+ // поставить звезду, то есть просила у человека, которому ещё ничего не дали.
9
+ //
10
+ // ЧТО ЗДЕСЬ ДРУГОЕ. Просим, только когда есть что рассказать, и рассказ уже собран: версия,
11
+ // уровень, стек, что покраснело, чего комплект НЕ СМОГ проверить, какие классы брака не ловит
12
+ // никто. Человеку остаётся одна строка своими словами.
13
+ //
14
+ // ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ. Ничего не отправляется само. Исходящий запрос у комплекта ровно
15
+ // один — про свежесть версии, он описан в README и SECURITY.md. Отчёт печатается и отдаётся
16
+ // человеку: он видит глазами всё, что отправляет. Ни путей, ни содержимого файлов, ни имени
17
+ // репозитория в отчёте нет — иначе первый же внимательный читатель назовёт это телеметрией,
18
+ // и будет прав.
19
+ import { readFile } from "node:fs/promises";
20
+ import { spawnSync } from "node:child_process";
21
+ import { PKG_ROOT, SELF, REPO_URL, c, stateDirs } from "../lib/core.mjs";
22
+ import { askAllowed, markAsked } from "../lib/ask.mjs";
23
+ import { join } from "node:path";
24
+ import { readManifest, assessLevel } from "../lib/manifest.mjs";
25
+ import { detectFacts } from "../lib/repo.mjs";
26
+ import { declaredGates, readRun } from "../lib/run.mjs";
27
+ import { probeStatus } from "./probe.mjs";
28
+ import { L } from "../i18n/index.mjs";
29
+
30
+ // О ЧЁМ ПРОСИТЬ — чистая функция от состояния. Порядок не по нашему удобству, а по ценности
31
+ // ответа для того, кто чинит комплект:
32
+ // 1. НЕ СМОГЛИ ПРОВЕРИТЬ — отказ самого прибора. Это жалоба, а жалоба даётся людям легче
33
+ // похвалы, и она же показывает, где инструмент врёт. Дороже всего остального.
34
+ // 2. СЛЕПОЙ КЛАСС — проба подсадила брак, и его не поймал никто. Рассказ об этом проверяет
35
+ // главное наше утверждение: что проба находит настоящие дыры, а не выдуманные.
36
+ // 3. КРАСНЫЙ ГЕЙТ — комплект поймал то, ради чего его ставят. Момент пользы, но самый частый,
37
+ // поэтому последний.
38
+ // Ничего из перечисленного нет — просьбы нет вовсе. «Оставьте отзыв» без содержания это шум,
39
+ // а шум выключают вместе с хуком, в котором он приехал.
40
+ function feedbackAsk(state = {}) {
41
+ const pick = (kind, list) => (list && list.length ? { reason: kind, names: [...list] } : null);
42
+ return pick("cannot", state.cannot) || pick("blind", state.blind) || pick("red", state.red) || null;
43
+ }
44
+
45
+ // ОТЧЁТ. Каждая строка — либо факт, либо слово «неизвестно»: пустое место в письме читается как
46
+ // «всё хорошо» ровно так же, как пустой вывод проверки, и это тот же порок, только у нас самих.
47
+ //
48
+ // Слепые классы называются именем класса, без файла. Файл знает проба («blind-class: slug path»), и
49
+ // соблазн положить его сюда велик — он объясняет находку. Нельзя: путь внутри чужого
50
+ // репозитория рассказывает о чужом проекте больше, чем его владелец собирался рассказать.
51
+ function reportText(state = {}, T = L.feedback.report) {
52
+ const u = T.unknown;
53
+ const list = (xs) => (xs && xs.length ? xs.join(", ") : T.none);
54
+ return [
55
+ T.title,
56
+ "",
57
+ T.env(state.version || u, state.node || u, state.platform || u),
58
+ T.level(state.level === null || state.level === undefined ? u : `AQK-${state.level}`),
59
+ T.stack(state.langs && state.langs.length ? state.langs.join(", ") : u),
60
+ T.gates(state.gates === undefined ? u : state.gates, list(state.red), list(state.cannot)),
61
+ // `null` — проба не делалась: это НЕИЗВЕСТНО, а не «нет». Пустой список означал бы, что
62
+ // проба прошла и ничего не нашла, — и автор письма прочитал бы чужое незнание как чистоту.
63
+ T.blind(state.blind === null || state.blind === undefined
64
+ ? u
65
+ : list(state.blind.map((b) => (typeof b === "string" ? b : b.slug)))),
66
+ "",
67
+ T.say,
68
+ "",
69
+ // Слова человека — туда же, где приглашение их написать. Пусто — остаётся приглашение:
70
+ // отправленное письмо без единой своей строки всё равно полезно, но сказать об этом надо.
71
+ ...(state.note ? [state.note, ""] : []),
72
+ T.mark(state.version || u),
73
+ ];
74
+ }
75
+
76
+ // КАК ЭТО ЗВУЧИТ — один текст на два места: `context` кладёт его агенту, `doctor` печатает
77
+ // человеку. Собери строку в каждом месте отдельно — и через месяц они разойдутся, как разошлись
78
+ // бы `context` и `prompt` без общего `readAdvice`.
79
+ //
80
+ // АГЕНТУ — ОТДЕЛЬНАЯ ОГОВОРКА, и она не вежливость. Строка ложится в контекст ЧУЖОГО агента, а
81
+ // он исполняет написанное: без «скажи один раз и не настаивай» это реклама в чужом окне, и хук,
82
+ // которым она приехала, снесут в первый же день вместе со всей затеей. Человеку вместо неё —
83
+ // как выключить: у всего, что случается само, обязан быть выключатель.
84
+ function askLine(ask, self = "aqk", { agent = false } = {}, T = L.feedback.ask) {
85
+ if (!ask || !ask.reason) return null;
86
+ const say = T[ask.reason];
87
+ if (!say) return null;
88
+ // Перенос, а не пробел: команда обязана оставаться ПОСЛЕДНЕЙ в своей строке — иначе
89
+ // следующее слово приклеится к ней при копировании, и человек выполнит не то.
90
+ return `${say(ask.names.join(", "), `${self} feedback`)}\n${agent ? T.agent : T.once}`;
91
+ }
92
+
93
+ // Выключатель — тот же, что у совета (AQK_ADVICE=0), пробы (AQK_PROBE=0) и проверки версии
94
+ // (AQK_UPDATE=0). Молчаливой просьбы, которую нельзя отменить, у нас не будет.
95
+ function feedbackWanted(env = process.env) {
96
+ return String(env.AQK_FEEDBACK || "") !== "0";
97
+ }
98
+
99
+ // ОДНА ПРОСЬБА НА ПРОЕКТ — и решение, и ограничитель, и отметка здесь. `doctor` и `context`
100
+ // только печатают: разведи это по двум командам, и они разойдутся в условиях, а человек получит
101
+ // просьбу дважды. Кто первым дошёл, тот и спросил.
102
+ //
103
+ // Ничего не роняет: просьба об одолжении не имеет права стоить человеку прогона.
104
+ async function maybeAsk(state, self, { agent = false } = {}) {
105
+ if (!feedbackWanted()) return null;
106
+ try {
107
+ const dirs = stateDirs();
108
+ if (!(await askAllowed("value", dirs))) return null;
109
+ const line = askLine(feedbackAsk(state), self, { agent });
110
+ if (line) await markAsked("value", dirs);
111
+ return line;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+ // ОТПРАВКА — ТОЛЬКО ПО ЯВНОМУ СЛОВУ, И СЛОВО ЭТО `--send`.
119
+ //
120
+ // Владелец 2026-09-14 спросил, нельзя ли отправлять отзыв «без согласия пользователя, чтобы
121
+ // агент мог быстро сообщить». Нельзя, и не из вежливости: в README и SECURITY.md написано, что
122
+ // исходящий запрос у комплекта ровно один — про версию. Инструмент, который втихую шлёт что-то
123
+ // из ЧУЖОГО репозитория, становится ровно тем, что мы критикуем, а наша аудитория — это те, кто
124
+ // проверяет инструменты на вранье. Одного внимательного читателя хватит.
125
+ //
126
+ // Поэтому согласие живёт в самом флаге: `--send` не набирают случайно, а агенту в умениях
127
+ // сказано показать текст человеку и спросить. Утверждение «без флага не уходит ничего» держит
128
+ // машина — smoke/feedback-send.test.mjs, — а не наше обещание в документации.
129
+ //
130
+ // ОТ ЧЬЕГО ИМЕНИ. От самого человека, его же `gh`. Своего сервера у нас нет и не будет: он
131
+ // означал бы приём чужих данных, а значит и ответственность за них.
132
+ const DISCUSSION = 90;
133
+
134
+ function sendWanted(argv = process.argv) {
135
+ return argv.includes("--send");
136
+ }
137
+
138
+ // Своя строка человека — самое ценное во всём письме. Берём всё, что не флаг.
139
+ function userNote(argv = process.argv) {
140
+ const i = argv.indexOf("feedback");
141
+ return (i === -1 ? [] : argv.slice(i + 1)).filter((a) => !a.startsWith("-")).join(" ").trim();
142
+ }
143
+
144
+ // Чем звать gh. Подменяется `AQK_GH` — тот же приём, что у `AQK_BASH` в execution.mjs: у всего,
145
+ // что мы решаем сами, обязан быть способ решить иначе. Через него же проверки подставляют
146
+ // поддельный gh и убеждаются, что без флага его не зовут вовсе.
147
+ function ghArgs(env = process.env) {
148
+ return String(env.AQK_GH || "gh").split(/\s+/).filter(Boolean);
149
+ }
150
+
151
+ // Запуск БЕЗ оболочки: в теле отзыва переносы строк, кавычки и обратные апострофы, и оболочка
152
+ // разобрала бы их как свои. Доводы уходят массивом — разбирать нечего.
153
+ function gh(args, timeout = 30000) {
154
+ const [bin, ...pre] = ghArgs();
155
+ return spawnSync(bin, [...pre, ...args], { encoding: "utf8", timeout });
156
+ }
157
+
158
+ const Q_ID = `query($o:String!,$n:String!){repository(owner:$o,name:$n){discussion(number:${DISCUSSION}){id}}}`;
159
+ const Q_ADD = "mutation($id:ID!,$body:String!){addDiscussionComment(input:{discussionId:$id,body:$body}){comment{url}}}";
160
+
161
+ // Три исхода, как везде: отправлено · не смогли и сказали почему · входа нет. Молчаливый отказ
162
+ // означал бы, что человек считает отзыв ушедшим, а его нет.
163
+ function postComment(body, repo) {
164
+ if (gh(["auth", "status"], 15000).status !== 0) return { ok: false, reason: "no-auth" };
165
+ const [owner, name] = repo;
166
+ const one = gh(["api", "graphql", "-f", `query=${Q_ID}`, "-f", `o=${owner}`, "-f", `n=${name}`]);
167
+ let id = "";
168
+ try { id = JSON.parse(one.stdout || "{}").data.repository.discussion.id; } catch { /* разберём ниже */ }
169
+ if (!id) return { ok: false, reason: "no-thread" };
170
+ const two = gh(["api", "graphql", "-f", `query=${Q_ADD}`, "-f", `id=${id}`, "-f", `body=${body}`]);
171
+ let url = "";
172
+ try { url = JSON.parse(two.stdout || "{}").data.addDiscussionComment.comment.url; } catch { /* разберём ниже */ }
173
+ return url ? { ok: true, url } : { ok: false, reason: "failed" };
174
+ }
175
+
176
+ // Предзаполненная ссылка. Параметры `title` и `body` — документация GitHub («Creating an issue
177
+ // from a URL query», сверено 2026-09-14). Кодируется ВСЁ: в теле переносы строк, решётки и
178
+ // пробелы, и незакодированная ссылка обрывается на первом же из них — а всё после решётки
179
+ // браузер считает якорем и не передаёт вовсе.
180
+ function issueUrl(repo, title, body) {
181
+ const q = `title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
182
+ return `${String(repo).replace(/\/+$/, "")}/issues/new?${q}`;
183
+ }
184
+
185
+ async function cmdFeedback() {
186
+ const T = L.feedback;
187
+ const man = await readManifest();
188
+ let version = "";
189
+ try { version = JSON.parse(await readFile(join(PKG_ROOT, "package.json"), "utf8")).version || ""; } catch { /* версия просто не покажется */ }
190
+
191
+ const run = await readRun();
192
+ let facts = null;
193
+ try { facts = await detectFacts(man); } catch { /* стек не определили — скажем «неизвестно» */ }
194
+ let level = null;
195
+ if (man?.aqk) { try { level = (await assessLevel(man, null)).reached; } catch { /* уровень не посчитали */ } }
196
+ // Классы известны только когда проба ДЕЙСТВИТЕЛЬНО проходила. «Никогда», «выключена» и «не
197
+ // знаем» — это null, то есть «неизвестно»: см. договор в reportText.
198
+ let blind = null;
199
+ try {
200
+ const st = await probeStatus();
201
+ if (st.state === "fresh" || st.state === "stale") blind = st.classes || [];
202
+ } catch { /* пробы не было — так и скажем */ }
203
+
204
+ const lines = reportText({
205
+ version, node: process.version, platform: process.platform, level,
206
+ langs: facts?.langs ? [...facts.langs] : [],
207
+ gates: declaredGates(man).length,
208
+ red: run?.red || [], cannot: run?.cannot || [], blind, note: userNote(),
209
+ });
210
+ const body = lines.join("\n");
211
+ console.log(`\n${body}\n`);
212
+
213
+ // ОТПРАВКА — ТОЛЬКО ПО ФЛАГУ. Без него ниже печатается ссылка, и это весь путь наружу.
214
+ if (sendWanted()) {
215
+ console.log(c.dim(` ${T.sending}`));
216
+ const repo = REPO_URL.replace(/^https?:\/\/github\.com\//, "").split("/");
217
+ const r = postComment(body, repo);
218
+ if (r.ok) { console.log(c.green(` ${T.sent(r.url)}\n`)); return; }
219
+ console.log(c.yellow(` ${T.sendFailed[r.reason] || T.sendFailed.failed}\n`));
220
+ }
221
+
222
+ console.log(c.bold(` ${T.how}`));
223
+ console.log(` ${issueUrl(REPO_URL, T.issueTitle, body)}\n`);
224
+ console.log(c.dim(` ${T.nothingSent}`));
225
+ console.log(c.dim(` ${T.sendHow(`${SELF} feedback --send`)}`));
226
+ console.log(c.dim(` ${T.orPaste(`${SELF} feedback`)}\n`));
227
+ // Код возврата всегда 0: команда, которая просит об одолжении и роняет при этом конвейер, —
228
+ // последнее, что человек стерпит.
229
+ }
230
+
231
+ export { cmdFeedback, feedbackAsk, reportText, issueUrl, askLine, feedbackWanted, maybeAsk };
@@ -15,7 +15,7 @@ import {
15
15
  } from "../lib/repo.mjs";
16
16
  import { GATE_YML_TEMPLATE, CHECK_SH_TEMPLATE, README_TEMPLATE } from "../lib/templates.mjs";
17
17
  import { L } from "../i18n/index.mjs";
18
- import { gateCommand } from "../lib/execution.mjs";
18
+ import { gateCommand, gateTimeout } from "../lib/execution.mjs";
19
19
 
20
20
  // Ставит гейт из каталога в проект. Проверка КОПИРУЕТСЯ в репозиторий, а не остаётся
21
21
  // ссылкой в пакет: при установке через npx пакет временный, и завтра команда в манифесте
@@ -43,7 +43,11 @@ async function installGate(slug, man, facts) {
43
43
 
44
44
  // Общий список исключений едет вместе с проверкой: без него она читает окружение и
45
45
  // зависимости, и человек получает тысячу чужих нарушений вместо сотни своих.
46
- for (const helper of ["_skip.sh", "_native.sh"]) {
46
+ // `_target.sh` добавлен 2026-09-15 вместе с общим швом «судим только то, что можем разрешить».
47
+ // Без него скопированный гейт выходит с кодом 2 — это честно, но у человека он не работает.
48
+ // Поймано нашим же смоуком в день правки: список помощников тут — единственное место, и
49
+ // забыть его значит выпустить гейт, который у чужого проекта не запускается вовсе.
50
+ for (const helper of ["_skip.sh", "_native.sh", "_target.sh"]) {
47
51
  const from = join(GATES_SRC, helper);
48
52
  if (await exists(from)) await copyFile(from, join(CWD, PROJECT_GATES, helper));
49
53
  }
@@ -170,8 +174,10 @@ async function cmdNew(args) {
170
174
  // зовут установленные записи каталога. В самом комплекте оригинал уже лежит на месте (../),
171
175
  // в чужом проекте его никто не клал, пока не было ни одной установленной записи через `add`.
172
176
  if (!inKit) {
173
- const skipSrc = join(GATES_SRC, "_skip.sh");
174
- if (await exists(skipSrc)) await copyFile(skipSrc, join(CWD, PROJECT_GATES, "_skip.sh"));
177
+ for (const helper of ["_skip.sh", "_target.sh"]) {
178
+ const from = join(GATES_SRC, helper);
179
+ if (await exists(from)) await copyFile(from, join(CWD, PROJECT_GATES, helper));
180
+ }
175
181
  }
176
182
 
177
183
  console.log(c.bold(`\naqk new ${slug}\n`));
@@ -231,7 +237,7 @@ async function cmdRatchet(args) {
231
237
 
232
238
  // Снимок текущих нарушений — это и есть долг. Ключ без номера строки: правка соседней
233
239
  // строки не должна читаться как новое нарушение.
234
- const r = spawnSync(gateCommand(inner), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
240
+ const r = spawnSync(gateCommand(inner), { shell: true, cwd: CWD, encoding: "utf8", timeout: gateTimeout().ms });
235
241
  if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {
236
242
  die(L.ratchet.notRunnable(slug, inner));
237
243
  }
@@ -439,7 +445,7 @@ async function cmdWhy(args) {
439
445
 
440
446
  // --- 3. объявлен: спрашиваем у него самого ---------------------------------
441
447
  console.log(c.dim(` ${L.why.declaredAs(cmd)}`));
442
- const r = spawnSync(gateCommand(String(cmd)), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
448
+ const r = spawnSync(gateCommand(String(cmd)), { shell: true, cwd: CWD, encoding: "utf8", timeout: gateTimeout().ms });
443
449
  const ci = await runsInCi(slug, String(cmd));
444
450
 
445
451
  if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {
@@ -6,7 +6,8 @@ import { spawnSync } from "node:child_process";
6
6
  import { join, dirname, relative } from "node:path";
7
7
  import {
8
8
  CWD, PKG_ROOT, DOCS_SRC, RULES_SRC, TARGET_DIR, MANIFEST, SELF, REPO_URL, c, exists, die,
9
- copyDir, writeIfAbsent, FEEDBACK_MARK, docPath, ensureIgnored } from "../lib/core.mjs";
9
+ copyDir, writeIfAbsent, stateDirs, docPath, ensureIgnored } from "../lib/core.mjs";
10
+ import { askAllowed, markAsked } from "../lib/ask.mjs";
10
11
  import { AGENTS_MD, CLAUDE_MD, MANIFEST_YML } from "../lib/templates.mjs";
11
12
  import { banner } from "../lib/banner.mjs";
12
13
  import { readManifest } from "../lib/manifest.mjs";
@@ -95,7 +96,10 @@ ${c.dim(L.init.burned(`${SELF} note "…"`))}
95
96
  // Ничего не постится само: ссылки печатаются, дальше решает человек. Обратная связь важнее
96
97
  // звезды, но без звезды меньше шансов, что кто-то вообще дойдёт до фидбека.
97
98
  async function maybeAskFeedback() {
98
- if (await exists(FEEDBACK_MARK)) return;
99
+ // Ограничитель — общий на все обращения комплекта (ask.mjs). Вид `install` разовый и живёт
100
+ // в доме пользователя: второй init в другом репозитории на том же компьютере молчит.
101
+ const dirs = stateDirs();
102
+ if (!(await askAllowed("install", dirs))) return;
99
103
  const url = REPO_URL;
100
104
  console.log(`
101
105
  ${c.bold(L.feedback.title)}
@@ -104,20 +108,14 @@ ${c.bold(L.feedback.title)}
104
108
  ${url}/issues/new
105
109
  ${c.dim(` ${L.feedback.once}`)}
106
110
  `);
107
- // Пометка «уже показывали» — удобство, а не работа команды. Домашнего каталога может не быть
108
- // записываемым вовсе: в контейнере, запущенном `--user 1001:127`, у этого uid нет записи в
109
- // /etc/passwd, `homedir()` даёт «/», и запись падает с EACCES на `/.config`. До 2026-09-09
110
- // это роняло ВЕСЬ `init` — то есть любого, кто набрал команду из нашей же документации по
111
- // docker. Локально не воспроизводилось случайно: uid разработчика 1000 совпадает с
112
- // пользователем `node` в образе, у которого дом есть. Нашёл конвейер, где uid 1001.
111
+ // Пометка «уже показывали» — удобство, а не работа команды: `markAsked` не бросает, а
112
+ // возвращает, записалось ли. Дом бывает недоступен для записи контейнере с `--user
113
+ // 1001:127` у этого uid нет записи в /etc/passwd, homedir() даёт «/»), и до 2026-09-09 это
114
+ // роняло ВЕСЬ `init` — то есть любого, кто набрал команду из нашей же документации по docker.
113
115
  //
114
116
  // Молча глотать нельзя — это то, что красит наш же swallowed-error. Поэтому вслух: не
115
117
  // запомнили, покажем снова. Установка при этом доходит до конца.
116
- try {
117
- await writeIfAbsent(FEEDBACK_MARK, "shown\n", { force: false });
118
- } catch {
119
- console.log(c.dim(` ${L.feedback.notRemembered}`));
120
- }
118
+ if (!(await markAsked("install", dirs))) console.log(c.dim(` ${L.feedback.notRemembered}`));
121
119
  }
122
120
 
123
121
  function findJournal() {
@@ -15,7 +15,8 @@
15
15
  import { readManifest } from "../lib/manifest.mjs";
16
16
  import { claudeShimFor } from "../lib/repo.mjs";
17
17
  import { CWD, SELF } from "../lib/core.mjs";
18
- import { readRun, readAdvice, portableSelf } from "./context.mjs";
18
+ import { readAdvice, portableSelf } from "./context.mjs";
19
+ import { readRun } from "../lib/run.mjs";
19
20
  import { probeStatus } from "./probe.mjs";
20
21
  import { L } from "../i18n/index.mjs";
21
22
 
@@ -215,11 +215,27 @@ async function cmdReport() {
215
215
 
216
216
  const text = lines.join("\n") + "\n";
217
217
  const dst = join(CWD, TARGET_DIR, "report.md");
218
- await mkdir(join(CWD, TARGET_DIR), { recursive: true });
219
- await writeFile(dst, text, "utf8");
218
+
219
+ // ОТЧЁТ ЭТО ЧТЕНИЕ, И ПАДАТЬ ЕМУ НЕ НА ЧЕМ. Разбор чужой интеграции 2026-09-16: в рабочей
220
+ // области, где `.aqk/` не создать, команда падала с EROFS, не напечатав ни строки. То есть
221
+ // побочное действие, о котором не просили, отменяло то, ради чего команду звали. Такие
222
+ // области бывают не по недосмотру: read-only контейнер, чужой конвейер, каталог под ревью.
223
+ //
224
+ // Молчать о несохранённом тоже нельзя: человек, увидевший отчёт, вправе считать, что файл
225
+ // лежит на диске, — если ему не сказали иначе. Тот же принцип, что и везде: не смогли —
226
+ // назови, а не выдавай за сделанное.
227
+ let saveError = null;
228
+ try {
229
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
230
+ await writeFile(dst, text, "utf8");
231
+ } catch (e) {
232
+ saveError = e?.code || String(e?.message || e);
233
+ }
220
234
 
221
235
  console.log("\n" + text);
222
- console.log(c.dim(` ${L.report2.saved(join(TARGET_DIR, "report.md"))}\n`));
236
+ console.log(saveError
237
+ ? c.yellow(` ${L.report2.notSaved(join(TARGET_DIR, "report.md"), saveError)}\n`)
238
+ : c.dim(` ${L.report2.saved(join(TARGET_DIR, "report.md"))}\n`));
223
239
 
224
240
  // Код возврата — для конвейера и для агента: отчёт с красным гейтом не должен читаться
225
241
  // как «всё в порядке» только потому, что команда выполнилась.
@@ -45,10 +45,16 @@ function vitalsRows(f) {
45
45
  ok: f.unparsed > 0 ? false : true,
46
46
  detail: f.unparsed > 0 ? t.manifestBad(f.unparsed) : t.manifestOk,
47
47
  },
48
+ // ЧЕТЫРЕ ОТВЕТА ПРО ХУК. «Хук чужой» — не «хука нет»: в одном случае ставить нечего, в
49
+ // другом дописать строку в уже стоящий. Совет разный, значит и строка разная. Ни то ни
50
+ // другое не отказ: AQK могут сознательно гонять в конвейере.
48
51
  {
49
52
  key: "preCommit",
50
- ok: f.preCommit === null ? null : f.preCommit ? true : "no",
51
- detail: f.preCommit === null ? t.unknownHook : f.preCommit ? t.preCommitOk : t.preCommitNo,
53
+ ok: f.preCommit === null ? null : f.preCommit === true ? true : "no",
54
+ detail: f.preCommit === null ? t.unknownHook
55
+ : f.preCommit === true ? t.preCommitOk
56
+ : f.preCommit === "other" ? t.preCommitOther
57
+ : t.preCommitNo,
52
58
  },
53
59
  {
54
60
  key: "sessionHook",
@@ -110,7 +116,7 @@ async function cmdVitals() {
110
116
  // Найдено 2026-09-09 сверкой вывода двух команд на одном репозитории.
111
117
  const samplesDir = typeof man?.samples === "string" ? man.samples.trim() : "";
112
118
  for (const gate of Object.keys(gates)) {
113
- const missing = await gateRequires(samplesDir, gate, whichSync);
119
+ const missing = await gateRequires(man, samplesDir, gate, whichSync);
114
120
  for (const prog of missing || []) {
115
121
  if (seen.has(prog)) continue;
116
122
  seen.set(prog, { gate, prog, found: false });
@@ -26,6 +26,9 @@ const enDocs = {
26
26
  unknownHook: "could not look — unknown, not \"no\"",
27
27
  preCommitOk: "wired in .git/hooks",
28
28
  preCommitNo: "not in .git/hooks: a config entry is an intent, not a guard",
29
+ // A hook IS there, but not ours: nothing to install, something to add — a different
30
+ // remedy, hence a different line. Until 2026-09-16 this case printed as "wired".
31
+ preCommitOther: "a .git/hooks hook exists, but AQK is not called from it — check .pre-commit-config.yaml or the hook body",
29
32
  sessionOk: "the SessionStart hook hands the state to the agent",
30
33
  sessionNo: (cmd) => `the agent gets no state: ${cmd}`,
31
34
  versionOk: (v) => `${v}, current`,
@@ -63,6 +66,7 @@ const enDocs = {
63
66
  "No run has been made — which checks are red is UNKNOWN. This is not \"clean\": `aqk doctor --run`.",
64
67
  runStale: (when) =>
65
68
  `The last run ${when} is OLDER than the last commit — it describes different code.`,
69
+ runCannot: (names) => `COULD NOT CHECK (the checks themselves failed — not findings about the code): ${names}`,
66
70
  runClean: (when) => `Last run ${when} — nothing red.`,
67
71
  runRed: (when, names) => `Last run ${when} — RED: ${names}.`,
68
72
  andMore: (n) => `and ${n} more`,
@@ -82,7 +86,16 @@ const enDocs = {
82
86
  nextTitle: "Next, in order — computed from this repository, not generic advice:",
83
87
  nextStep: {
84
88
  init: () => "Set up the standard: `aqk init` — without .aqk.yml `aqk add` refuses.",
85
- adopt: (s) => `Declare the checks this project already has: in .aqk.yml, under gates: ${s.gates.map((g) => `${g.name}: "${g.cmd}"`).join(", ")}. Then \`aqk doctor --run\`.`,
89
+ // Слабые НЕ ПОПАДАЮТ в «впиши в манифест»: агент исполняет написанное, и объявленная
90
+ // проверка с `|| true` — это дыра, за которую с этого дня ручается машина.
91
+ adopt: (s) => {
92
+ const ok = s.gates.filter((g) => !g.weak);
93
+ const weak = s.gates.filter((g) => g.weak);
94
+ const out = [];
95
+ if (ok.length) out.push(`Declare the checks this project already has: in .aqk.yml, under gates: ${ok.map((g) => `${g.name}: "${g.cmd}"`).join(", ")}. Then \`aqk doctor --run\`.`);
96
+ if (weak.length) out.push(`These cannot go red as written — ${weak.map((g) => `${g.cmd} (${g.weak.text})`).join(", ")} — so do not declare them yet: tell the owner and fix the command first.`);
97
+ return out.join(" ");
98
+ },
86
99
  blind: (s) => `The "${s.slug}" defect the probe planted in ${s.file} was NOT caught by your checks.` +
87
100
  (s.command ? ` Catch it now: \`${s.command}\`.` : "") + ` Keep it caught: \`aqk add ${s.slug}\`.`,
88
101
  start: (s) => `Install ${s.slug}: \`aqk add ${s.slug}\`` + (s.command ? ` (one line, no kit needed: \`${s.command}\`)` : "") +
@@ -92,7 +105,9 @@ const enDocs = {
92
105
  whenTitle: "When to do what:",
93
106
  whenCommit: (hook) => hook === true
94
107
  ? "Before a commit → the `.git/hooks/pre-commit` hook runs the checks itself; do not bypass it (`--no-verify`)."
95
- : "Before a commit → `aqk doctor --run --since main`: there is no pre-commit hook, it will not happen by itself.",
108
+ : hook === "other"
109
+ ? "Before a commit → `aqk doctor --run --since main`: a hook is installed in `.git/hooks`, but AQK is not called from it — the run will not happen by itself."
110
+ : "Before a commit → `aqk doctor --run --since main`: there is no pre-commit hook, it will not happen by itself.",
96
111
  whenRules: [
97
112
  "Added or changed a check → `aqk prove`: the gate must go red on its own red sample, otherwise it checks nothing.",
98
113
  "Writing a rule into the rulebook → put the arbiter mark next to it, `<!-- aqk: gate-name -->`; no gate — `<!-- aqk: human -->`, which is an admission, not a check.",
@@ -154,6 +169,7 @@ const enDocs = {
154
169
  whyTitle: "Why this matters — briefly",
155
170
  whyNothing: "nothing to add: everything applicable is in place",
156
171
  saved: (path) => `Saved: ${path}`,
172
+ notSaved: (f, why) => `could not save to ${f} (${why}) — the report is above, there is no file on disk. The command only reports state, so a failed write does not fail it.`,
157
173
  docs: {
158
174
  baseline: "the minimum a project needs, independent of language",
159
175
  readyMade: "the map of off-the-shelf rules: look for a ready one before writing your own",
@@ -217,6 +233,7 @@ const enDocs = {
217
233
  ],
218
234
  report: {
219
235
  skippedBySelect: "not run (by --only/--skip), state unknown",
236
+ notWritten: (f, why) => `could not write the run report to ${f} (${why}) — the gates did run, but the next session and the hook will read this as "no run happened".`,
220
237
  title: "aqk doctor --run",
221
238
  version: "version",
222
239
  level: "level",
@@ -185,6 +185,41 @@ export const enGates = {
185
185
  issue: "Found a bug or it did not fit — open an issue; both are the most useful feedback there is.",
186
186
  notRemembered: "could not remember this (home is not writable) — the message will appear again",
187
187
  once: "This message is shown once: it will not appear again on this machine.",
188
+
189
+ ask: {
190
+ cannot: (names, cmd) =>
191
+ `AQK could not check: ${names}. That is the most valuable thing to tell the author — ${cmd}`,
192
+ blind: (names, cmd) =>
193
+ `The probe found defect classes nobody catches here: ${names}. Does that match your experience? — ${cmd}`,
194
+ red: (names, cmd) =>
195
+ `The kit caught: ${names}. One line to the author — did it help: ${cmd}`,
196
+ once: "Asked once per project; switch it off with AQK_FEEDBACK=0",
197
+ agent: "Say this to the human in one sentence, and do not insist if they do not answer. If they want to send it, add `--send`; never add the flag on your own.",
198
+ },
199
+ sending: "sending on your behalf — with your own gh account…",
200
+ sent: (url) => `sent: ${url}\n That is the whole price of this kit. Thank you.`,
201
+ sendFailed: {
202
+ "no-auth": "could not send: gh is missing or you are not logged in (`gh auth login`). The link below always works.",
203
+ "no-thread": "could not send: the discussion was not found. Use the link below.",
204
+ failed: "could not send. The link below always works.",
205
+ },
206
+ sendHow: (cmd) => `Send it in one command, with your own GitHub account: ${cmd}`,
207
+ how: "Sending is one click (a prefilled issue opens; the text stays editable):",
208
+ issueTitle: "Feedback on the kit",
209
+ nothingSent: "Nothing was sent: the kit makes no outgoing requests other than the version check.",
210
+ orPaste: (cmd) => `No GitHub? Forward the text above as is — all of it comes from ${cmd}`,
211
+ report: {
212
+ title: "### Feedback on the kit",
213
+ unknown: "unknown",
214
+ none: "none",
215
+ env: (v, node, os) => `version: ${v} · node: ${node} · system: ${os}`,
216
+ level: (x) => `level: ${x}`,
217
+ stack: (x) => `stack: ${x}`,
218
+ gates: (n, red, cannot) => `gates declared: ${n} · red: ${red} · could not check: ${cannot}`,
219
+ blind: (x) => `classes nobody catches here: ${x}`,
220
+ say: "What you would say in your own words (one line — the most useful part of the whole message):",
221
+ mark: (v) => `<!-- collected by "aqk feedback" ${v}: no paths, no code, no repository name -->`,
222
+ },
188
223
  },
189
224
 
190
225
  note: {
package/tool/i18n/en.mjs CHANGED
@@ -50,6 +50,8 @@ export const en = {
50
50
  blob: "assemble the guides into a single GOD_AI.md",
51
51
  learn: "rule candidates from local transcripts: said out loud, never written down",
52
52
  context: "the project state in one block — for an agent's context, not for reading",
53
+ feedback: "a report on how the kit worked plus a prefilled link — the only payment it asks",
54
+ feedbackSend: "send the feedback in one command — with your own gh account; without the flag nothing leaves",
53
55
  vitals: "is what the kit runs on wired up: gate tools, hooks, version freshness",
54
56
  prompt: "one task for the agent: what to fix, in order, and how to prove it is done",
55
57
  contextInstall: "the same in full — the map and the rulebook — installed as a hook",
@@ -152,7 +154,17 @@ export const en = {
152
154
  startHook: "Running these by hand is a one-off. To have them run before every push: pre-commit (repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit, hooks aqk / aqk-doctor), or a plain .git/hooks/pre-push.",
153
155
  haveAlready: (n) => `Checks you ALREADY have (${n}) — found in your own files, not invented:`,
154
156
  haveAlreadyHow: (line) => `declare them and a machine holds them, not your attention. In .aqk.yml, under gates: ${line}`,
155
- total: "Total:",
157
+ // Проверка, которая у проекта ЕСТЬ и провалиться НЕ МОЖЕТ. Самое ценное, что мы знаем о
158
+ // чужом репозитории в первые пять секунд, — и до 2026-09-14 мы об этом молчали.
159
+ weakOff: (text) => `cannot fail: the verdict is swallowed right in the script — «${text}»`,
160
+ weakZero: () => "cannot fail: --exit-zero tells the tool to exit 0 whatever it finds",
161
+ weakStub: (text) => `proves nothing: the whole script is a printout — «${text}»`,
162
+ weakHow: (n) => `${n} of them cannot go red. Declaring a check that cannot fail only makes the ` +
163
+ "silence machine-readable — fix the command first, then declare it.",
164
+ // Counted over the AQK CATALOGUE, and the line says so: a project gate named its own way
165
+ // closes none of our entries by name, and "held by a machine 0" read as a verdict on a
166
+ // repository with thirty checks of its own. Measured 2026-09-16 across twelve repositories.
167
+ total: "AQK catalogue totals:",
156
168
  totalHeld: (n) => `held by a machine ${n}`,
157
169
  totalTodo: (n) => `applicable but not installed ${n}`,
158
170
  totalSkip: (n) => `hidden ${n}`,
@@ -167,7 +179,12 @@ export const en = {
167
179
  `advisory and red: ${names.join(", ")}. These are switched-off checks: ` +
168
180
  `either fix them and drop them from advisory, or admit the rule does not exist.`,
169
181
  runHeading: "Running the declared gates",
182
+ timeoutBadEnv: (raw, secs) => `AQK_GATE_TIMEOUT="${raw}" is not a number of seconds above zero; waiting ${secs}s as before. Zero and garbage mean "wait forever" to spawnSync, and a hung gate looks exactly like a working one.`,
170
183
  timeout: "did not finish within 5 minutes",
184
+ cannotCheck: (why) => `could not check: ${why}`,
185
+ whySpawn: (code) => `failed to start${code ? ` (${code})` : ""}`,
186
+ whySignal: (sig) => `killed by signal ${sig || "?"}`,
187
+ whyExit: (code) => `exit ${code} — for this command that is a failure, not a finding`,
171
188
  running: (i, n) => `[${i}/${n}] running…`,
172
189
  proving: "checking that the gates catch defects on their own samples…",
173
190
  exitCode: (code) => `exit ${code}`,
@@ -289,7 +306,17 @@ export const en = {
289
306
  missed: ({ slug, file }, s) => `Gate \`${slug}\` is installed but missed the defect the probe planted into \`${file}\`. Find out why — a common cause is that the gate does not look at this file type or folder; \`${s} probe\` has the details. Done — \`${s} probe\` no longer names this class.`,
290
307
  red: (name, s) => `Gate \`${name}\` is red. Run \`${s} doctor --run --only ${name}\`, read the findings and fix the code. Done — that command passes.`,
291
308
  blind: ({ slug, file, command }, s) => `The probe planted a \`${slug}\` defect into \`${file}\` and the project's checks did not notice. Add a check: \`${s} add ${slug}\`${command ? ` (the same as one line, without the kit: \`${command}\`)` : ""}. Done — \`${s} doctor --run --only ${slug}\` passes and \`${s} probe\` no longer names this class.`,
292
- adopt: (gates, s) => `The project already has its own checks: ${gates.map((g) => `\`${g.cmd}\` (${g.source})`).join(", ")}. Declare them under gates: in .aqk.yml — ${gates.map((g) => `\`${g.name}: "${g.cmd}"\``).join(", ")}. Done — \`${s} doctor --run\` runs them.`,
309
+ // СЛАБЫЕ ОТДЕЛЯЮТСЯ ОТ РАБОЧИХ, и это не украшение вывода. Агент исполняет написанное:
310
+ // велеть ему «впиши эти проверки в манифест», когда одна из них `… || true`, значит руками
311
+ // агента превратить дыру в зелёную галочку — и с этого дня её будет держать машина.
312
+ adopt: (gates, s) => {
313
+ const weak = gates.filter((g) => g.weak);
314
+ const ok = gates.filter((g) => !g.weak);
315
+ const out = [];
316
+ if (ok.length) out.push(`The project already has its own checks: ${ok.map((g) => `\`${g.cmd}\` (${g.source})`).join(", ")}. Declare them under gates: in .aqk.yml — ${ok.map((g) => `\`${g.name}: "${g.cmd}"\``).join(", ")}. Done — \`${s} doctor --run\` runs them.`);
317
+ if (weak.length) out.push(`These CANNOT go red as written: ${weak.map((g) => `\`${g.cmd}\` — ${g.weak.text}`).join(", ")}. Fix the command before declaring it: a declared check that cannot fail turns a hole into a green tick, and from then on a machine vouches for it. This is the owner's call, not yours — say what you found.`);
318
+ return out.join(" ");
319
+ },
293
320
  shim: {
294
321
  missing: (s) => `Claude Code is set up here, but the rules live in AGENTS.md — it only reads CLAUDE.md. Create a CLAUDE.md with the single line \`@AGENTS.md\`. Done — \`${s} doctor\` no longer warns about it.`,
295
322
  noImport: (s) => `CLAUDE.md does not import AGENTS.md — Claude Code only sees CLAUDE.md. Add the line \`@AGENTS.md\` to CLAUDE.md (mentioning the file in prose does not load it). Done — \`${s} doctor\` no longer warns about it.`,