agent-quality-kit 0.14.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.
package/README.md CHANGED
@@ -107,6 +107,8 @@ aqk context the repository state in one block, for an agent's contex
107
107
  aqk prompt one task to paste into an agent: what to fix, in order, and the
108
108
  command that proves each item done
109
109
  aqk vitals is what the kit runs on wired up: gate tools, hooks, freshness
110
+ aqk feedback feedback to the author: a report from the last run and probe,
111
+ plus a prefilled link. No paths, no code; nothing is sent for you
110
112
  aqk doctor --run --brief one line on success, the whole run on failure — for hooks
111
113
  aqk context --full the same plus the command map and the rulebook verbatim (~7000
112
114
  tokens against ~500: the price of an agent that does not guess)
@@ -351,7 +353,7 @@ Already using [pre-commit](https://pre-commit.com)? Three lines in the file you
351
353
  ```yaml
352
354
  repos:
353
355
  - repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit
354
- rev: v0.14.0
356
+ rev: v0.15.0
355
357
  hooks:
356
358
  - id: aqk # runs what the repository declares; blocks below AQK-1
357
359
  # - id: aqk-doctor # read-only: the level and what is missing, blocks nothing
@@ -372,7 +374,7 @@ layer AQK adds.
372
374
  [![on the GitHub Marketplace](https://img.shields.io/badge/GitHub%20Marketplace-Agent%20Quality%20Kit-2ea44f?logo=github)](https://github.com/marketplace/actions/agent-quality-kit-aqk)
373
375
 
374
376
  ```yaml
375
- - uses: arsen-ask-lx/Agent_Quality_Kit@v0.14.0
377
+ - uses: arsen-ask-lx/Agent_Quality_Kit@v0.15.0
376
378
  with:
377
379
  min: 1 # the build fails below AQK-1, or if any declared gate failed
378
380
  ```
@@ -431,12 +433,34 @@ files the diff touched:
431
433
  aqk doctor --run --since main # only what this branch introduced
432
434
  ```
433
435
 
434
- Three outcomes, all of them said out loud. Findings inside the diff — red, as usual. Findings only
435
- outside it — green, with the number that was hidden, never a silent "all clear". And a gate whose
436
+ Four outcomes, all of them said out loud. Findings inside the diff — red, as usual. Findings only
437
+ outside it — green, with the number that was hidden, never a silent "all clear". A gate whose
436
438
  output carries no paths at all (a commit-message check, a CI-config check) **cannot** be narrowed:
437
- it stays red, and says why. Calling it green because there was nothing to narrow would be exactly
439
+ it stays red, and says why. And a gate that **could not run at all** no tool, an unexpected exit
440
+ code, killed by a signal — is never narrowed by the diff: a failure has no place in the code, only
441
+ itself. Calling either of the last two green because there was nothing to narrow would be exactly
438
442
  the silence this tool exists to remove.
439
443
 
444
+ ### The only payment: one answer
445
+
446
+ The kit is free and collects nothing about you: it makes exactly one outgoing request — asking the
447
+ npm registry whether a newer version exists. The payment is different: **one answer to the author**.
448
+ So once per project, and only when there is something to tell, `doctor` or the agent block prints a
449
+ line like "AQK could not check `smoke`; that is the most valuable thing to tell the author". Then:
450
+
451
+ ```bash
452
+ aqk feedback # builds the message and hands you a prefilled link — you send it, not us
453
+ ```
454
+
455
+ The message carries: version, level, stack, what went red, what the kit could not check, which
456
+ defect classes nobody catches here. **No paths, no code, no repository name** — you see every
457
+ character you send. No GitHub? Forward the text as is. Not interested at all? `AQK_FEEDBACK=0`.
458
+
459
+ Somewhere to say it in your own words:
460
+ [where the kit was wrong](https://github.com/arsen-ask-lx/Agent_Quality_Kit/discussions/90) ·
461
+ [what check is missing](https://github.com/arsen-ask-lx/Agent_Quality_Kit/discussions/91) ·
462
+ [show your manifest](https://github.com/arsen-ask-lx/Agent_Quality_Kit/discussions/92).
463
+
440
464
  Every `doctor --run` rewrites `.aqk/last-run.md` — a short report of what actually ran and how
441
465
  long it took. The list of gates in the manifest says nothing about how many of them are alive
442
466
  right now; the report does. The file is ephemeral — keep it in your own `.gitignore`.
package/README.ru.md CHANGED
@@ -109,6 +109,8 @@ aqk context состояние репозитория одним б
109
109
  aqk prompt одно задание для агента: что починить, по порядку, и у каждого
110
110
  пункта команда, которая докажет «готово»
111
111
  aqk vitals подключено ли то, чем комплект работает: инструменты, хуки, свежесть
112
+ aqk feedback отзыв автору: отчёт из последнего прогона и пробы плюс готовая
113
+ ссылка. Без путей и без кода; ничего не отправляет само
112
114
  aqk doctor --run --brief одна строка на успехе, весь прогон при провале — для хуков
113
115
  aqk context --full то же плюс карта команд и свод правил дословно (≈7000 токенов
114
116
  против ≈500 — плата за то, чтобы агент не догадывался)
@@ -355,7 +357,7 @@ aqk badge --check # в конвейере: код 1 в тот день, ког
355
357
  ```yaml
356
358
  repos:
357
359
  - repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit
358
- rev: v0.14.0
360
+ rev: v0.15.0
359
361
  hooks:
360
362
  - id: aqk # запускает объявленное; роняет коммит ниже AQK-1
361
363
  # - id: aqk-doctor # только осмотр: уровень и чего не хватает, ничего не роняет
@@ -374,7 +376,7 @@ repos:
374
376
  [![в GitHub Marketplace](https://img.shields.io/badge/GitHub%20Marketplace-Agent%20Quality%20Kit-2ea44f?logo=github)](https://github.com/marketplace/actions/agent-quality-kit-aqk)
375
377
 
376
378
  ```yaml
377
- - uses: arsen-ask-lx/Agent_Quality_Kit@v0.14.0
379
+ - uses: arsen-ask-lx/Agent_Quality_Kit@v0.15.0
378
380
  with:
379
381
  min: 1 # сборка падает ниже AQK-1 или если упал любой объявленный гейт
380
382
  ```
@@ -432,11 +434,34 @@ aqk ratchet no-print-in-prod # старое — долг, новое не пу
432
434
  aqk doctor --run --since main # только то, что внесла эта ветка
433
435
  ```
434
436
 
435
- Три исхода, и все три названы вслух. Находки внутри дифа — красный, как обычно. Находки только
436
- снаружи — зелёный, с числом того, что скрыто, а не молчаливое «всё чисто». А гейт, в выводе
437
+ Четыре исхода, и все четыре названы вслух. Находки внутри дифа — красный, как обычно. Находки
438
+ только снаружи — зелёный, с числом того, что скрыто, а не молчаливое «всё чисто». Гейт, в выводе
437
439
  которого путей нет вовсе (проверка сообщения коммита, проверка конфига конвейера), сузиться
438
- **не может**: он остаётся красным и говорит почему. Назвать его зелёным потому, что сужать было
439
- нечего,ровно та тишина, ради устранения которой этот инструмент и написан.
440
+ **не может**: он остаётся красным и говорит почему. А гейт, который вообще **не смог
441
+ отработать**нет инструмента, неожиданный код возврата, убит сигналом, дифом не сужается
442
+ никогда: у сбоя нет места в коде, есть только сам сбой. Назвать любой из двух последних зелёным
443
+ потому, что сужать было нечего, — ровно та тишина, ради устранения которой этот инструмент и
444
+ написан.
445
+
446
+ ### Единственная плата — один ответ
447
+
448
+ Комплект бесплатный и ничего о вас не собирает: исходящий запрос у него ровно один — спросить
449
+ реестр npm, не вышла ли версия свежее. Плата другая: **один ответ автору**. Поэтому один раз на
450
+ проект — и только когда есть что рассказать — `doctor` или блок для агента печатают строку вроде
451
+ «AQK не смог проверить `smoke`; автору это ценнее всего». Дальше:
452
+
453
+ ```bash
454
+ aqk feedback # соберёт письмо и даст готовую ссылку — отправляете вы, не мы
455
+ ```
456
+
457
+ В письме: версия, уровень, стек, что покраснело, чего комплект не смог проверить, какие классы
458
+ брака здесь не ловит никто. **Ни путей, ни кода, ни имени репозитория** — вы видите глазами всё,
459
+ что отправляете. Не нужен GitHub — перешлите текст как есть. Не нужно вовсе: `AQK_FEEDBACK=0`.
460
+
461
+ Где рассказать словами:
462
+ [где комплект соврал](https://github.com/arsen-ask-lx/Agent_Quality_Kit/discussions/90) ·
463
+ [какой проверки не хватает](https://github.com/arsen-ask-lx/Agent_Quality_Kit/discussions/91) ·
464
+ [покажите свой манифест](https://github.com/arsen-ask-lx/Agent_Quality_Kit/discussions/92).
440
465
 
441
466
  Каждый `doctor --run` перезаписывает `.aqk/last-run.md` — короткий отчёт, что из объявленного
442
467
  реально сработало и за сколько. Список гейтов в манифесте молчит о том, сколько из них живы
package/llms.txt CHANGED
@@ -76,7 +76,7 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
76
76
  the files `init` writes are owned by root, so you cannot edit your own manifest. Debian-based
77
77
  on purpose: the gates are `sh`, `grep`, `awk`, `find` — under alpine's busybox they behave
78
78
  differently, and an image where the gates behave differently is worse than no image
79
- - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.14.0` with `min: 1`
79
+ - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.15.0` with `min: 1`
80
80
  (https://github.com/marketplace/actions/agent-quality-kit-aqk)
81
81
 
82
82
  ## What makes it different
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-quality-kit",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Turns the rules an agent is supposed to follow into commands with exit codes, and reports which of them actually run. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,14 +21,14 @@
21
21
  // а агент примет его за утверждение. Поэтому каждое незнание называется словом: прогона не было —
22
22
  // так и написано, прогон устарел — тоже, инструмента нет — тоже.
23
23
  import { readFile, writeFile, mkdir } from "node:fs/promises";
24
- import { spawnSync } from "node:child_process";
25
24
  import { join } from "node:path";
26
25
  import { CWD, TARGET_DIR, SELF, c, exists, commandRows, preCommitHook } from "../lib/core.mjs";
26
+ import { maybeAsk } from "./feedback.mjs";
27
27
  import { readManifest, assessLevel, coversOf } from "../lib/manifest.mjs";
28
28
  import { detectFacts, readCatalog } from "../lib/repo.mjs";
29
29
  import { catalogBuckets, startWith, blindAdvice } from "../lib/advice.mjs";
30
30
  import { proposeGates, readAdoptFiles } from "../lib/adopt.mjs";
31
- import { declaredGates } from "../lib/run.mjs";
31
+ import { declaredGates, readRun } from "../lib/run.mjs";
32
32
  import { probeStatus } from "./probe.mjs";
33
33
  import { L } from "../i18n/index.mjs";
34
34
 
@@ -73,11 +73,17 @@ function contextBlock(state, T = L.context) {
73
73
  out.push(T.runNone);
74
74
  } else {
75
75
  const red = state.run.red || [];
76
- const shown = red.slice(0, MAX_RED);
77
- const names = red.length > MAX_RED
78
- ? `${shown.join(", ")} — ${T.andMore(red.length - MAX_RED)}`
79
- : shown.join(", ");
80
- out.push(red.length ? T.runRed(state.run.when, names) : T.runClean(state.run.when));
76
+ const cannot = state.run.cannot || [];
77
+ const short = (list) => (list.length > MAX_RED
78
+ ? `${list.slice(0, MAX_RED).join(", ")} — ${T.andMore(list.length - MAX_RED)}`
79
+ : list.join(", "));
80
+ if (red.length) out.push(T.runRed(state.run.when, short(red)));
81
+ // «НЕ СМОГЛИ ПРОВЕРИТЬ» — ОТДЕЛЬНОЙ СТРОКОЙ, И ЧИСТО ТОЛЬКО КОГДА ОБА СПИСКА ПУСТЫ.
82
+ // Гейт, который не сумел отработать, не находка о коде: агент, прочитавший его как находку,
83
+ // пойдёт чинить исправный файл. А если бы он не попал НИКУДА, прогон, где всё сломалось,
84
+ // читался бы как «чисто» — та же тишина, только внутри блока, который читает машина.
85
+ if (cannot.length) out.push(T.runCannot(short(cannot)));
86
+ if (!red.length && !cannot.length) out.push(T.runClean(state.run.when));
81
87
  if (state.run.stale) out.push(T.runStale(state.run.when));
82
88
  if (state.run.skipped) out.push(T.skipped(state.run.skipped));
83
89
  }
@@ -148,20 +154,13 @@ function contextBlock(state, T = L.context) {
148
154
  // чем промолчать: он пойдёт его читать и получит пустоту вместо правил. Замерено на шести
149
155
  // чужих проектах: на flask блок писал «Свод правил: AGENTS.md», которого там нет.
150
156
  if (state.entryExists !== false) out.push("", T.where(state.entry || "AGENTS.md"));
157
+ // ПРОСЬБА ОБ ОТЗЫВЕ — последней строкой и только при содержании (feedback.mjs). Последней
158
+ // потому, что это единственная строка блока, которая не про состояние проекта: ставить её
159
+ // выше значило бы отодвинуть работой то, ради чего блок и читают.
160
+ if (state.ask) out.push("", state.ask);
151
161
  return out;
152
162
  }
153
163
 
154
- // Разбор отчёта прошлого прогона. Формат кладёт сам `doctor` в .aqk/last-run.md; читаем его,
155
- // а не запускаем гейты заново: хук обязан укладываться в секунду-две, а прогон у нас идёт минуту.
156
- function parseLastRun(text) {
157
- if (!text) return null;
158
- const when = (text.match(/^# aqk doctor --run — (.+)$/m) || [])[1] || "";
159
- const red = [];
160
- for (const m of text.matchAll(/^✘ ([^\s—]+)/gm)) red.push(m[1]);
161
- const skipped = (text.match(/^~ /gm) || []).length;
162
- return { when: when.trim(), red, skipped, stale: false };
163
- }
164
-
165
164
  // Правила и их арбитры: отметка `<!-- aqk: имя -->` рядом с правилом. `человек` — честное
166
165
  // признание, что машина этого не держит; так его и считаем, отдельно от машинных.
167
166
  function countArbiters(text, humanWords) {
@@ -173,16 +172,6 @@ function countArbiters(text, humanWords) {
173
172
  return { total: marks.length, machine: marks.length - human, human };
174
173
  }
175
174
 
176
- // Прогон старше последнего коммита описывает не тот код, что лежит перед агентом. Молча выдать
177
- // его за свежий — соврать: именно так «зелёный месяц назад» превращается в «зелёный сейчас».
178
- function runIsStale(when) {
179
- if (!when) return false;
180
- const r = spawnSync("git", ["log", "-1", "--format=%cI"], { cwd: CWD, encoding: "utf8" });
181
- if (r.status !== 0 || !r.stdout) return false;
182
- const commit = Date.parse(r.stdout.trim());
183
- const run = Date.parse(when.replace(" ", "T"));
184
- return Number.isFinite(commit) && Number.isFinite(run) && run < commit;
185
- }
186
175
 
187
176
 
188
177
  // УСТАНОВКА ХУКА — отдельной командой, а не частью `init`, и это решение, а не лень. Комплект
@@ -260,16 +249,6 @@ async function installHook(full = false) {
260
249
  console.log(c.dim(` ${T.hookWhat}`));
261
250
  }
262
251
 
263
- // Прошлый прогон — из отчёта, который кладёт `doctor --run`. Отдельной функцией: его читают и
264
- // `context`, и `prompt`, и два разбора одного файла разошлись бы.
265
- async function readRun() {
266
- const lastRun = join(CWD, TARGET_DIR, "last-run.md");
267
- if (!(await exists(lastRun))) return null;
268
- const run = parseLastRun(await readFile(lastRun, "utf8"));
269
- if (run) run.stale = runIsStale(run.when);
270
- return run;
271
- }
272
-
273
252
  // Что советовать — теми же функциями, что у `doctor`: корзины каталога, «начните с трёх», совет
274
253
  // под язык, чужие проверки проекта. Одно место на `context` и `prompt`: второй расчёт того же
275
254
  // самого разошёлся бы с первым. Класс из пробы, чей гейт уже стоит, в совет не идёт — ставить
@@ -364,10 +343,27 @@ async function cmdContext(args = []) {
364
343
  next = nextSteps({ init: !man, adopt, blind, start });
365
344
  } catch { /* не посчитали — блок скажет остальное; выдумывать шаги нельзя */ }
366
345
 
346
+ // ЕДИНСТВЕННАЯ ПЛАТА ЗА КОМПЛЕКТ — один ответ автору, и просит о нём агент: он читает этот
347
+ // блок каждую сессию и передаёт человеку то, что в нём написано. Замер 2026-09-14: тысяча
348
+ // скачиваний в неделю и ноль отзывов за всё время — просьба печаталась только при `init`, то
349
+ // есть до того, как комплект сделал хоть что-то.
350
+ //
351
+ // ТРИ УСЛОВИЯ, И ВСЕ ТРИ ОБЯЗАТЕЛЬНЫ: не выключено человеком, не просили на этом проекте
352
+ // раньше, и ЕСТЬ О ЧЁМ рассказать. Без третьего это «оставьте отзыв» — шум, а шум выключают
353
+ // вместе с хуком, в котором он приехал.
354
+ //
355
+ // ЕДИНСТВЕННАЯ ЗАПИСЬ НА ДИСК В ЭТОЙ КОМАНДЕ, кроме `--install`. Без отметки просьба
356
+ // повторялась бы каждую сессию: красный гейт живёт в проекте днями, а блок читается заново
357
+ // при каждом запуске агента и после каждого сжатия контекста.
358
+ const ask = await maybeAsk({
359
+ cannot: run?.cannot || [], red: run?.red || [],
360
+ blind: (probe?.classes || []).map((b) => b.slug),
361
+ }, portableSelf(SELF), { agent: true });
362
+
367
363
  console.log(contextBlock({
368
364
  entry, entryExists: rules !== null, level, rules, run, ratchets, probe, full: fullPart,
369
- next, when: { hook: await preCommitHook(CWD) },
365
+ next, when: { hook: await preCommitHook(CWD) }, ask,
370
366
  }).join("\n"));
371
367
  }
372
368
 
373
- export { cmdContext, contextBlock, nextSteps, parseLastRun, countArbiters, withHook, hasOurHook, portableSelf, readRun, readAdvice };
369
+ export { cmdContext, contextBlock, nextSteps, countArbiters, withHook, hasOurHook, portableSelf, readAdvice };
@@ -4,6 +4,7 @@ import { readFile, mkdir, writeFile } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die, RUNTIME_FILES } from "../lib/core.mjs";
7
+ import { maybeAsk } from "./feedback.mjs";
7
8
  import { cmdProbe, probeStatus } from "./probe.mjs";
8
9
  import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, layoutChecks, unparsedLines } from "../lib/manifest.mjs";
9
10
  import { proveGates } from "../lib/prove.mjs";
@@ -12,34 +13,9 @@ import { reportBaseline, reportCatalog } from "./doctor-catalog.mjs";
12
13
  import { L } from "../i18n/index.mjs";
13
14
  import { countArbiters } from "./context.mjs";
14
15
  import { beginBrief, finishBrief } from "../lib/brief.mjs";
15
- import { declaredGates, sinceRef, runGates, progress, listArg } from "../lib/run.mjs";
16
+ import { declaredGates, sinceRef, runGates, progress, listArg, writeRunReport } from "../lib/run.mjs";
16
17
  import { autoProbeAllowed, levelLimits } from "../lib/cadence.mjs";
17
18
 
18
- // Короткий отчёт «что из этого реально брали» — не для человека, а для агента в следующей
19
- // сессии и для самого владельца: список объявленных гейтов молчит о том, сколько из них
20
- // действительно стоят и работают именно СЕЙЧАС. Перезаписывается каждым прогоном, не копится:
21
- // история — дело git-лога коммитов с этим отчётом, если владелец решит его коммитить.
22
- async function writeRunReport({ version, reached, results, skipped = [] }) {
23
- const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
24
- const ok = results.filter((r) => r.ok).length;
25
- const lines = [
26
- `# ${L.report.title} — ${stamp}`,
27
- version ? `${L.report.version}: ${version}` : null,
28
- `${L.report.level}: AQK-${reached < 0 ? L.doctor.levelNone : reached}`,
29
- "",
30
- ...results.map((r) => `${r.ok ? "✔" : "✘"} ${r.name} — ${r.secs}s${r.ok ? "" : ` (${r.note || L.doctor.exitCode(r.code)})`}`),
31
- // Пропущенные по --skip/--only — строкой «~»: блок для агента читает их как «не запускались»,
32
- // а не как зелёные. Молчание о них прочиталось бы как «проверено».
33
- ...skipped.map((n) => `~ ${n} — ${L.report.skippedBySelect}`),
34
- "",
35
- L.report.summary(ok, results.length),
36
- ].filter((l) => l !== null);
37
-
38
- const dst = join(CWD, TARGET_DIR, "last-run.md");
39
- await mkdir(join(CWD, TARGET_DIR), { recursive: true });
40
- await writeFile(dst, lines.join("\n") + "\n", "utf8");
41
- }
42
-
43
19
  // ПРОБА ЗАПУСКАЕТСЯ САМА, раз в сто коммитов, — кроме конвейера (там это минуты сюрпризом в
44
20
  // быстрой проверке, отзыв с живого проекта 2026-09-11). Не влияет на код возврата никогда: это
45
21
  // осмотр, а не порог. Отдельной функцией: внутри прогона эта лесенка дала вложенность 6, и наш же
@@ -229,6 +205,7 @@ async function cmdDoctor() {
229
205
  const gates = declaredGates(man);
230
206
  let gateFailed = 0;
231
207
  let failedNames = [];
208
+ let cannotNames = [];
232
209
  let skippedNames = [];
233
210
  if (wantRun) {
234
211
  // --jobs N: сколько гейтов одновременно. Без флага — по одному, как было: чужие гейты бывают
@@ -254,6 +231,7 @@ async function cmdDoctor() {
254
231
  // стоят дороже. Не влияет на код возврата НИКОГДА — это осмотр, а не порог.
255
232
  // Выключается AQK_PROBE=0 — у всего, что случается само, обязан быть выключатель.
256
233
  if (!brief && process.env.AQK_PROBE !== "0") await autoProbe(brief);
234
+ cannotNames = run.results.filter((r) => r.cannot).map((r) => r.name);
257
235
  } else if (gates.length) {
258
236
  console.log(
259
237
  c.yellow(` ${L.doctor.declaredNotRun(gates.length)}`) +
@@ -261,6 +239,20 @@ async function cmdDoctor() {
261
239
  );
262
240
  }
263
241
 
242
+ // ЕДИНСТВЕННАЯ ПЛАТА ЗА КОМПЛЕКТ — один ответ автору. Человеку говорим здесь, агенту — в
243
+ // блоке `context`; текст и решение «есть ли о чём просить» одни на оба места (feedback.mjs),
244
+ // отметка одна на проект (ask.mjs): кто первым дошёл, тот и спросил, второй раз не спрашивает
245
+ // никто. В кратком режиме молчим — там ворота коммита, и лишняя строка там дороже всего.
246
+ const askText = brief ? null : await maybeAsk({
247
+ cannot: cannotNames,
248
+ red: failedNames.filter((n) => !cannotNames.includes(n)),
249
+ blind: (probe?.classes || []).map((b) => b.slug),
250
+ }, SELF);
251
+ if (askText) {
252
+ for (const l of askText.split("\n")) console.log(c.dim(` ${l}`));
253
+ console.log("");
254
+ }
255
+
264
256
  // Код возврата — для конвейера. Порог задаётся так: aqk doctor --min 1
265
257
  const minIdx = process.argv.indexOf("--min");
266
258
  const min = minIdx > -1 ? Number(process.argv[minIdx + 1]) : null;
@@ -0,0 +1,157 @@
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 { PKG_ROOT, SELF, REPO_URL, c, stateDirs } from "../lib/core.mjs";
21
+ import { askAllowed, markAsked } from "../lib/ask.mjs";
22
+ import { join } from "node:path";
23
+ import { readManifest, assessLevel } from "../lib/manifest.mjs";
24
+ import { detectFacts } from "../lib/repo.mjs";
25
+ import { declaredGates, readRun } from "../lib/run.mjs";
26
+ import { probeStatus } from "./probe.mjs";
27
+ import { L } from "../i18n/index.mjs";
28
+
29
+ // О ЧЁМ ПРОСИТЬ — чистая функция от состояния. Порядок не по нашему удобству, а по ценности
30
+ // ответа для того, кто чинит комплект:
31
+ // 1. НЕ СМОГЛИ ПРОВЕРИТЬ — отказ самого прибора. Это жалоба, а жалоба даётся людям легче
32
+ // похвалы, и она же показывает, где инструмент врёт. Дороже всего остального.
33
+ // 2. СЛЕПОЙ КЛАСС — проба подсадила брак, и его не поймал никто. Рассказ об этом проверяет
34
+ // главное наше утверждение: что проба находит настоящие дыры, а не выдуманные.
35
+ // 3. КРАСНЫЙ ГЕЙТ — комплект поймал то, ради чего его ставят. Момент пользы, но самый частый,
36
+ // поэтому последний.
37
+ // Ничего из перечисленного нет — просьбы нет вовсе. «Оставьте отзыв» без содержания это шум,
38
+ // а шум выключают вместе с хуком, в котором он приехал.
39
+ function feedbackAsk(state = {}) {
40
+ const pick = (kind, list) => (list && list.length ? { reason: kind, names: [...list] } : null);
41
+ return pick("cannot", state.cannot) || pick("blind", state.blind) || pick("red", state.red) || null;
42
+ }
43
+
44
+ // ОТЧЁТ. Каждая строка — либо факт, либо слово «неизвестно»: пустое место в письме читается как
45
+ // «всё хорошо» ровно так же, как пустой вывод проверки, и это тот же порок, только у нас самих.
46
+ //
47
+ // Слепые классы называются именем класса, без файла. Файл знает проба («blind-class: slug path»), и
48
+ // соблазн положить его сюда велик — он объясняет находку. Нельзя: путь внутри чужого
49
+ // репозитория рассказывает о чужом проекте больше, чем его владелец собирался рассказать.
50
+ function reportText(state = {}, T = L.feedback.report) {
51
+ const u = T.unknown;
52
+ const list = (xs) => (xs && xs.length ? xs.join(", ") : T.none);
53
+ return [
54
+ T.title,
55
+ "",
56
+ T.env(state.version || u, state.node || u, state.platform || u),
57
+ T.level(state.level === null || state.level === undefined ? u : `AQK-${state.level}`),
58
+ T.stack(state.langs && state.langs.length ? state.langs.join(", ") : u),
59
+ T.gates(state.gates === undefined ? u : state.gates, list(state.red), list(state.cannot)),
60
+ // `null` — проба не делалась: это НЕИЗВЕСТНО, а не «нет». Пустой список означал бы, что
61
+ // проба прошла и ничего не нашла, — и автор письма прочитал бы чужое незнание как чистоту.
62
+ T.blind(state.blind === null || state.blind === undefined
63
+ ? u
64
+ : list(state.blind.map((b) => (typeof b === "string" ? b : b.slug)))),
65
+ "",
66
+ T.say,
67
+ "",
68
+ T.mark(state.version || u),
69
+ ];
70
+ }
71
+
72
+ // КАК ЭТО ЗВУЧИТ — один текст на два места: `context` кладёт его агенту, `doctor` печатает
73
+ // человеку. Собери строку в каждом месте отдельно — и через месяц они разойдутся, как разошлись
74
+ // бы `context` и `prompt` без общего `readAdvice`.
75
+ //
76
+ // АГЕНТУ — ОТДЕЛЬНАЯ ОГОВОРКА, и она не вежливость. Строка ложится в контекст ЧУЖОГО агента, а
77
+ // он исполняет написанное: без «скажи один раз и не настаивай» это реклама в чужом окне, и хук,
78
+ // которым она приехала, снесут в первый же день вместе со всей затеей. Человеку вместо неё —
79
+ // как выключить: у всего, что случается само, обязан быть выключатель.
80
+ function askLine(ask, self = "aqk", { agent = false } = {}, T = L.feedback.ask) {
81
+ if (!ask || !ask.reason) return null;
82
+ const say = T[ask.reason];
83
+ if (!say) return null;
84
+ // Перенос, а не пробел: команда обязана оставаться ПОСЛЕДНЕЙ в своей строке — иначе
85
+ // следующее слово приклеится к ней при копировании, и человек выполнит не то.
86
+ return `${say(ask.names.join(", "), `${self} feedback`)}\n${agent ? T.agent : T.once}`;
87
+ }
88
+
89
+ // Выключатель — тот же, что у совета (AQK_ADVICE=0), пробы (AQK_PROBE=0) и проверки версии
90
+ // (AQK_UPDATE=0). Молчаливой просьбы, которую нельзя отменить, у нас не будет.
91
+ function feedbackWanted(env = process.env) {
92
+ return String(env.AQK_FEEDBACK || "") !== "0";
93
+ }
94
+
95
+ // ОДНА ПРОСЬБА НА ПРОЕКТ — и решение, и ограничитель, и отметка здесь. `doctor` и `context`
96
+ // только печатают: разведи это по двум командам, и они разойдутся в условиях, а человек получит
97
+ // просьбу дважды. Кто первым дошёл, тот и спросил.
98
+ //
99
+ // Ничего не роняет: просьба об одолжении не имеет права стоить человеку прогона.
100
+ async function maybeAsk(state, self, { agent = false } = {}) {
101
+ if (!feedbackWanted()) return null;
102
+ try {
103
+ const dirs = stateDirs();
104
+ if (!(await askAllowed("value", dirs))) return null;
105
+ const line = askLine(feedbackAsk(state), self, { agent });
106
+ if (line) await markAsked("value", dirs);
107
+ return line;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // Предзаполненная ссылка. Параметры `title` и `body` — документация GitHub («Creating an issue
114
+ // from a URL query», сверено 2026-09-14). Кодируется ВСЁ: в теле переносы строк, решётки и
115
+ // пробелы, и незакодированная ссылка обрывается на первом же из них — а всё после решётки
116
+ // браузер считает якорем и не передаёт вовсе.
117
+ function issueUrl(repo, title, body) {
118
+ const q = `title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
119
+ return `${String(repo).replace(/\/+$/, "")}/issues/new?${q}`;
120
+ }
121
+
122
+ async function cmdFeedback() {
123
+ const T = L.feedback;
124
+ const man = await readManifest();
125
+ let version = "";
126
+ try { version = JSON.parse(await readFile(join(PKG_ROOT, "package.json"), "utf8")).version || ""; } catch { /* версия просто не покажется */ }
127
+
128
+ const run = await readRun();
129
+ let facts = null;
130
+ try { facts = await detectFacts(man); } catch { /* стек не определили — скажем «неизвестно» */ }
131
+ let level = null;
132
+ if (man?.aqk) { try { level = (await assessLevel(man, null)).reached; } catch { /* уровень не посчитали */ } }
133
+ // Классы известны только когда проба ДЕЙСТВИТЕЛЬНО проходила. «Никогда», «выключена» и «не
134
+ // знаем» — это null, то есть «неизвестно»: см. договор в reportText.
135
+ let blind = null;
136
+ try {
137
+ const st = await probeStatus();
138
+ if (st.state === "fresh" || st.state === "stale") blind = st.classes || [];
139
+ } catch { /* пробы не было — так и скажем */ }
140
+
141
+ const lines = reportText({
142
+ version, node: process.version, platform: process.platform, level,
143
+ langs: facts?.langs ? [...facts.langs] : [],
144
+ gates: declaredGates(man).length,
145
+ red: run?.red || [], cannot: run?.cannot || [], blind,
146
+ });
147
+ const body = lines.join("\n");
148
+ console.log(`\n${body}\n`);
149
+ console.log(c.bold(` ${T.how}`));
150
+ console.log(` ${issueUrl(REPO_URL, T.issueTitle, body)}\n`);
151
+ console.log(c.dim(` ${T.nothingSent}`));
152
+ console.log(c.dim(` ${T.orPaste(`${SELF} feedback`)}\n`));
153
+ // Код возврата всегда 0: команда, которая просит об одолжении и роняет при этом конвейер, —
154
+ // последнее, что человек стерпит.
155
+ }
156
+
157
+ export { cmdFeedback, feedbackAsk, reportText, issueUrl, askLine, feedbackWanted, maybeAsk };
@@ -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
 
@@ -63,6 +63,7 @@ const enDocs = {
63
63
  "No run has been made — which checks are red is UNKNOWN. This is not \"clean\": `aqk doctor --run`.",
64
64
  runStale: (when) =>
65
65
  `The last run ${when} is OLDER than the last commit — it describes different code.`,
66
+ runCannot: (names) => `COULD NOT CHECK (the checks themselves failed — not findings about the code): ${names}`,
66
67
  runClean: (when) => `Last run ${when} — nothing red.`,
67
68
  runRed: (when, names) => `Last run ${when} — RED: ${names}.`,
68
69
  andMore: (n) => `and ${n} more`,
@@ -185,6 +185,33 @@ 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.",
198
+ },
199
+ how: "Sending is one click (a prefilled issue opens; the text stays editable):",
200
+ issueTitle: "Feedback on the kit",
201
+ nothingSent: "Nothing was sent: the kit makes no outgoing requests other than the version check.",
202
+ orPaste: (cmd) => `No GitHub? Forward the text above as is — all of it comes from ${cmd}`,
203
+ report: {
204
+ title: "### Feedback on the kit",
205
+ unknown: "unknown",
206
+ none: "none",
207
+ env: (v, node, os) => `version: ${v} · node: ${node} · system: ${os}`,
208
+ level: (x) => `level: ${x}`,
209
+ stack: (x) => `stack: ${x}`,
210
+ gates: (n, red, cannot) => `gates declared: ${n} · red: ${red} · could not check: ${cannot}`,
211
+ blind: (x) => `classes nobody catches here: ${x}`,
212
+ say: "What you would say in your own words (one line — the most useful part of the whole message):",
213
+ mark: (v) => `<!-- collected by "aqk feedback" ${v}: no paths, no code, no repository name -->`,
214
+ },
188
215
  },
189
216
 
190
217
  note: {