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/tool/i18n/en.mjs CHANGED
@@ -50,6 +50,7 @@ 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",
53
54
  vitals: "is what the kit runs on wired up: gate tools, hooks, version freshness",
54
55
  prompt: "one task for the agent: what to fix, in order, and how to prove it is done",
55
56
  contextInstall: "the same in full — the map and the rulebook — installed as a hook",
@@ -168,6 +169,10 @@ export const en = {
168
169
  `either fix them and drop them from advisory, or admit the rule does not exist.`,
169
170
  runHeading: "Running the declared gates",
170
171
  timeout: "did not finish within 5 minutes",
172
+ cannotCheck: (why) => `could not check: ${why}`,
173
+ whySpawn: (code) => `failed to start${code ? ` (${code})` : ""}`,
174
+ whySignal: (sig) => `killed by signal ${sig || "?"}`,
175
+ whyExit: (code) => `exit ${code} — for this command that is a failure, not a finding`,
171
176
  running: (i, n) => `[${i}/${n}] running…`,
172
177
  proving: "checking that the gates catch defects on their own samples…",
173
178
  exitCode: (code) => `exit ${code}`,
@@ -65,6 +65,7 @@ const ruDocs = {
65
65
  "Прогон не делался — какие проверки красные, НЕИЗВЕСТНО. Это не «чисто»: `aqk doctor --run`.",
66
66
  runStale: (when) =>
67
67
  `Последний прогон ${when} СТАРЕЕ последнего коммита — он описывает не тот код, что здесь.`,
68
+ runCannot: (names) => `НЕ СМОГЛИ ПРОВЕРИТЬ (сбой самих проверок, а не находки о коде): ${names}`,
68
69
  runClean: (when) => `Последний прогон ${when} — красных нет.`,
69
70
  runRed: (when, names) => `Последний прогон ${when} — КРАСНЫЕ: ${names}.`,
70
71
  andMore: (n) => `и ещё ${n}`,
@@ -187,6 +187,34 @@ export const ruGates = {
187
187
  issue: "Нашёл баг или не подошло — заведи Issue, самая полезная обратная связь: и то и другое.",
188
188
  notRemembered: "запомнить не удалось (дом недоступен для записи) — сообщение появится снова",
189
189
  once: "Это разовое сообщение: больше не покажется на этой машине.",
190
+
191
+ // Просьба ПО ДЕЛУ: у неё всегда есть содержание, иначе её нет вовсе.
192
+ ask: {
193
+ cannot: (names, cmd) =>
194
+ `AQK не смог проверить: ${names}. Это самое ценное, что можно рассказать автору — ${cmd}`,
195
+ blind: (names, cmd) =>
196
+ `Проба нашла классы брака, которые здесь не ловит никто: ${names}. Совпало с вашим опытом? — ${cmd}`,
197
+ red: (names, cmd) =>
198
+ `Комплект поймал: ${names}. Одна строка автору — помогло или нет: ${cmd}`,
199
+ once: "Спрашиваем один раз на проект; выключить: AQK_FEEDBACK=0",
200
+ agent: "Скажи это человеку одной фразой и не настаивай, если он не ответил.",
201
+ },
202
+ how: "Отправить — одно нажатие (откроется готовая задача, текст можно править):",
203
+ issueTitle: "Отзыв о работе комплекта",
204
+ nothingSent: "Ничего не отправлено: комплект не делает исходящих запросов, кроме проверки версии.",
205
+ orPaste: (cmd) => `Не хочется GitHub — перешлите текст выше как есть: он весь собран ${cmd}`,
206
+ report: {
207
+ title: "### Отзыв о комплекте",
208
+ unknown: "неизвестно",
209
+ none: "нет",
210
+ env: (v, node, os) => `версия: ${v} · node: ${node} · система: ${os}`,
211
+ level: (x) => `уровень: ${x}`,
212
+ stack: (x) => `стек: ${x}`,
213
+ gates: (n, red, cannot) => `гейтов объявлено: ${n} · красных: ${red} · не смогли проверить: ${cannot}`,
214
+ blind: (x) => `классы, которые здесь не ловит никто: ${x}`,
215
+ say: "Что сказать своими словами (одна строка — самое полезное во всём письме):",
216
+ mark: (v) => `<!-- собрано «aqk feedback» ${v}: без путей, без кода, без имени репозитория -->`,
217
+ },
190
218
  },
191
219
 
192
220
  note: {
package/tool/i18n/ru.mjs CHANGED
@@ -53,6 +53,7 @@ export const ru = {
53
53
  blob: "собрать методички в один файл GOD_AI.md",
54
54
  learn: "кандидаты в правила из локальной переписки: сказано вслух и не записано",
55
55
  context: "состояние проекта одним блоком — для контекста агента, а не для чтения",
56
+ feedback: "отчёт о работе комплекта и готовая ссылка — единственная плата за него",
56
57
  vitals: "подключено ли то, чем комплект работает: инструменты гейтов, хуки, свежесть версии",
57
58
  prompt: "одно задание для агента: что починить, по порядку, и чем доказать, что готово",
58
59
  contextInstall: "то же самое, но целиком — карта и свод правил — и хуком в контекст",
@@ -174,6 +175,12 @@ export const ru = {
174
175
  `либо почини и убери из advisory, либо признай, что правила нет.`,
175
176
  runHeading: "Прогон объявленных гейтов",
176
177
  timeout: "не уложился в 5 минут",
178
+ // «Не смогли проверить» — третье состояние, и оно обязано звучать иначе, чем находка:
179
+ // «код 2» человек читает как приговор коду, а это приговор запуску.
180
+ cannotCheck: (why) => `не смогли проверить: ${why}`,
181
+ whySpawn: (code) => `запустить не удалось${code ? ` (${code})` : ""}`,
182
+ whySignal: (sig) => `убит сигналом ${sig || "?"}`,
183
+ whyExit: (code) => `код ${code} — у этой команды это сбой, а не находка`,
177
184
  running: (i, n) => `[${i}/${n}] идёт…`,
178
185
  proving: "проверяю, что гейты ловят брак на своих образцах…",
179
186
  exitCode: (code) => `код ${code}`,
@@ -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 };
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";
@@ -86,6 +87,7 @@ function commandRows(L) {
86
87
  { name: "prompt", args: "", text: h.prompt },
87
88
  { name: "badge", args: "", text: h.badge },
88
89
  { name: "vitals", args: "", text: h.vitals },
90
+ { name: "feedback", args: "", text: h.feedback },
89
91
  { name: "version", args: "", text: h.version },
90
92
  ];
91
93
  }
@@ -105,10 +107,9 @@ const RATCHET_DIR = "ratchets";
105
107
  // обёртка плюс реестр, и разносить их по разным каталогам значит прятать половину механизма.
106
108
  const RATCHET_LIB = `${RATCHET_DIR}/_ratchet.sh`;
107
109
 
108
- // Отметка «просьбу про звезду уже показали»вне репозитория, в доме пользователя. Внутри
109
- // .aqk/ она либо закоммитится в чужой проект как наш мусор, либо пропадёт при init --force:
110
- // то и другое врёт о том, видел человек просьбу или нет.
111
- const FEEDBACK_MARK = join(homedir(), ".config", "aqk", "feedback-shown");
110
+ // Служебный каталог этого проекта и дом пользователя два места, где комплект держит
111
+ // состояние. Кто и как часто туда пишет, решает `ask.mjs`; здесь только адреса.
112
+ const stateDirs = () => ({ project: join(CWD, TARGET_DIR), home: homedir() });
112
113
 
113
114
  // Путь, попадающий в ДОКУМЕНТ, всегда пишется через «/». `relative()` отдаёт разделитель
114
115
  // платформы, и на Windows склейка методичек и отчёт получались с «kit\\docs» вместо «kit/docs»:
@@ -148,7 +149,7 @@ async function writeIfAbsent(path, content, { force }) {
148
149
  // читателей. Отзыв с живого проекта 2026-09-11: `.aqk/last-run.md` однажды закоммитили, и каждый
149
150
  // `make check` оставлял изменённый файл. Целиком `.aqk/` не игнорируется: методички и правила в
150
151
  // нём — содержимое проекта.
151
- 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];
152
153
  const L_IGNORE_NOTE = LANG === "en"
153
154
  ? "# aqk: this machine's state — rewritten by every run, it does not belong in git"
154
155
  : "# aqk: состояние этой машины — переписывается каждым прогоном, в git ему не место";
@@ -185,5 +186,5 @@ export {
185
186
  copyDir, writeIfAbsent,
186
187
  PKG_ROOT, CWD, DOCS_SRC, RULES_SRC, TARGET_DIR, docPath,
187
188
  MANIFEST, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB,
188
- 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,
189
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
  });
package/tool/lib/run.mjs CHANGED
@@ -10,13 +10,14 @@
10
10
  // контексту». Ровно то, о чём предупреждает совет самого гейта.
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { existsSync } from "node:fs";
13
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
13
14
  import { join } from "node:path";
14
15
  import { Worker } from "node:worker_threads";
15
16
  import { scopeOutput, splitAdvice, changedFiles } from "./scope.mjs";
16
- import { CWD, c, die } from "./core.mjs";
17
+ import { CWD, TARGET_DIR, c, die, exists } from "./core.mjs";
17
18
  import { advisorySet } from "./manifest.mjs";
18
19
  import { L } from "../i18n/index.mjs";
19
- import { gateCommand } from "./execution.mjs";
20
+ import { gateCommand, classify, findingCodes } from "./execution.mjs";
20
21
  import { annotations } from "./annotate.mjs";
21
22
 
22
23
 
@@ -168,10 +169,46 @@ async function runGates(man, opts = {}) {
168
169
  // Без этого «готово = доказано» остаётся правилом, за которым следит только человек.
169
170
  const outAll = `${r.stdout || ""}${r.stderr || ""}`.slice(0, 200000);
170
171
 
171
- if (r.error && r.error.code === "ETIMEDOUT") {
172
- console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.timeout)}`);
173
- failed++;
174
- results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout, out: outAll });
172
+ // ИСХОД ЗАПУСКА ДО РАЗБОРА ВЫВОДА И ДО СУЖЕНИЯ. Состояний три, а не два: clean · finding
173
+ // · infra_error. Знание о кодах живёт рядом с инструментом (`execution.mjs`): у vulture
174
+ // находка это 3, у pylint — битовая маска, а у незнакомой программы находка только 1.
175
+ //
176
+ // ЗАЧЕМ ЗДЕСЬ. Прежде прогон ловил один лишь ETIMEDOUT, а любой другой ненулевой код шёл в
177
+ // разбор находок — и `--since` фильтровал его ПО ПУТЯМ. Гейт, который НЕ СМОГ отработать,
178
+ // называл путь вне дифа и печатался зелёным: проверка сломалась, прогон сказал «чисто».
179
+ // Найдено внешним разбором 2026-09-13 (аудит Runcap), воспроизведено проверкой
180
+ // `fail-closed`. Сужать дифом можно только НАХОДКУ: у сбоя нет места в коде, которое он
181
+ // называет, — есть только сам сбой.
182
+ const verdict = classify(r, findingCodes(String(cmd).trim().split(/\s+/)[0]));
183
+ if (verdict.state === "infra_error") {
184
+ const why =
185
+ verdict.reason === "timeout" ? L.doctor.timeout
186
+ : verdict.reason === "spawn_error" ? L.doctor.whySpawn(r.error?.code || "")
187
+ : verdict.reason === "signal" ? L.doctor.whySignal(r.signal)
188
+ : L.doctor.whyExit(verdict.code);
189
+ // Совещательный не роняет прогон НИКОГДА — в том числе своим сбоем: список `advisory:`
190
+ // означает «эта проверка не имеет права останавливать работу», и причина остановки тут
191
+ // ни при чём. Но НАЗВАН он обязан быть: «не смогли» и «чисто» неразличимы только там,
192
+ // где о них молчат. Раньше таймаут ронял прогон и у совещательного — тот же класс, что
193
+ // измеренный 2026-09-09 случай с гейтом без путей в выводе.
194
+ const adv = advisory.has(name);
195
+ const note = L.doctor.cannotCheck(why);
196
+ const paint = adv ? c.yellow : c.red;
197
+ console.log(` ${adv ? c.yellow("!") : c.red("✘")} ${name.padEnd(14)} ${paint(note)} ${c.dim(`· ${secs}s · ${cmd}`)}`);
198
+ // Вывод сбоя показывается тоже. «Не смогли проверить: код 127» без строки
199
+ // «command not found: ruff» не говорит, ЧТО чинить, — а чинить тут надо инструмент,
200
+ // и первые строки обычно и есть его жалоба.
201
+ for (const line of outAll.trim().split("\n").filter(Boolean).slice(0, 3)) {
202
+ console.log(c.dim(` ${line.slice(0, 100)}`));
203
+ }
204
+ if (!adv) failed++;
205
+ // В pull request это ОБЩАЯ пометка гейта, а не пометка у строки файла: `shown` — то, что
206
+ // прогон показал, и у сбоя это причина, а не путь. Иначе «не смогли проверить» повисло бы
207
+ // на первом файле, который гейт успел назвать перед падением, — то есть на невиновном.
208
+ // Сырой `out` не трогаем: по нему считается покрытие дифа.
209
+ // `cannot` — не украшение: по нему отчёт прогона отличает сбой от находки одним знаком,
210
+ // а блок для агента и просьба об отзыве читают это из файла, ничего не запуская.
211
+ results.push({ name, cmd, ok: false, cannot: true, secs, code: verdict.code, advisory: adv, note, out: outAll, shown: note });
175
212
  continue;
176
213
  }
177
214
  const code = r.status;
@@ -280,4 +317,80 @@ async function runGates(man, opts = {}) {
280
317
  return { failed, ran: gates.length, results, advisoryFailed, skipped: sel.skipped };
281
318
  }
282
319
 
283
- export { declaredGates, sinceRef, runGates, progress, selectGates, listArg };
320
+
321
+ // ─────────────────────────────────────────────────────────────────────────────
322
+ // ОТЧЁТ ПРОГОНА: пишется здесь же, где прогон, и читается здесь же. Раньше `doctor` его ПИСАЛ,
323
+ // а `context` РАЗБИРАЛ — два файла, которые друг о друге не знают, держали один формат. Третий
324
+ // знак («?» — не смогли проверить) пришлось заводить в обоих, и это ровно тот случай, когда
325
+ // одно знание живёт в двух местах: правишь одно, второе молча расходится.
326
+ //
327
+ // Читателей у отчёта трое — блок для агента, задание и просьба об отзыве, — и ни один не
328
+ // запускает гейты заново: хук обязан укладываться в секунду, а прогон идёт минуту.
329
+ // Короткий отчёт «что из этого реально брали» — не для человека, а для агента в следующей
330
+ // сессии и для самого владельца: список объявленных гейтов молчит о том, сколько из них
331
+ // действительно стоят и работают именно СЕЙЧАС. Перезаписывается каждым прогоном, не копится:
332
+ // история — дело git-лога коммитов с этим отчётом, если владелец решит его коммитить.
333
+ async function writeRunReport({ version, reached, results, skipped = [] }) {
334
+ const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
335
+ const ok = results.filter((r) => r.ok).length;
336
+ const lines = [
337
+ `# ${L.report.title} — ${stamp}`,
338
+ version ? `${L.report.version}: ${version}` : null,
339
+ `${L.report.level}: AQK-${reached < 0 ? L.doctor.levelNone : reached}`,
340
+ "",
341
+ // ТРИ ЗНАКА, А НЕ ДВА: ✔ прошло · ✘ находка · ? НЕ СМОГЛИ ПРОВЕРИТЬ. Отчёт читает не только
342
+ // человек: из него блок для агента и просьба об отзыве узнают, что случилось, ничего не
343
+ // запуская. Слив «не смогли» с находкой означал бы, что агент чинит код там, где сломан
344
+ // инструмент, — и никогда не узнает, что инструмент сломан.
345
+ ...results.map((r) => `${r.ok ? "✔" : r.cannot ? "?" : "✘"} ${r.name} — ${r.secs}s${r.ok ? "" : ` (${r.note || L.doctor.exitCode(r.code)})`}`),
346
+ // Пропущенные по --skip/--only — строкой «~»: блок для агента читает их как «не запускались»,
347
+ // а не как зелёные. Молчание о них прочиталось бы как «проверено».
348
+ ...skipped.map((n) => `~ ${n} — ${L.report.skippedBySelect}`),
349
+ "",
350
+ L.report.summary(ok, results.length),
351
+ ].filter((l) => l !== null);
352
+
353
+ const dst = join(CWD, TARGET_DIR, "last-run.md");
354
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
355
+ await writeFile(dst, lines.join("\n") + "\n", "utf8");
356
+ }
357
+
358
+ // Разбор отчёта прошлого прогона. Формат кладёт сам `doctor` в .aqk/last-run.md; читаем его,
359
+ // а не запускаем гейты заново: хук обязан укладываться в секунду-две, а прогон у нас идёт минуту.
360
+ function parseLastRun(text) {
361
+ if (!text) return null;
362
+ const when = (text.match(/^# aqk doctor --run — (.+)$/m) || [])[1] || "";
363
+ const red = [];
364
+ for (const m of text.matchAll(/^✘ ([^\s—]+)/gm)) red.push(m[1]);
365
+ // «Не смогли проверить» — свой знак и свой список. Гейт, который не сумел отработать, не
366
+ // находка о коде: агент, прочитавший его как находку, пойдёт чинить исправный файл, а
367
+ // сломанный инструмент останется сломанным. Слить их в один список было бы той же тишиной,
368
+ // только наоборот.
369
+ const cannot = [];
370
+ for (const m of text.matchAll(/^\? ([^\s—]+)/gm)) cannot.push(m[1]);
371
+ const skipped = (text.match(/^~ /gm) || []).length;
372
+ return { when: when.trim(), red, cannot, skipped, stale: false };
373
+ }
374
+
375
+ // Прогон старше последнего коммита описывает не тот код, что лежит перед агентом. Молча выдать
376
+ // его за свежий — соврать: именно так «зелёный месяц назад» превращается в «зелёный сейчас».
377
+ function runIsStale(when) {
378
+ if (!when) return false;
379
+ const r = spawnSync("git", ["log", "-1", "--format=%cI"], { cwd: CWD, encoding: "utf8" });
380
+ if (r.status !== 0 || !r.stdout) return false;
381
+ const commit = Date.parse(r.stdout.trim());
382
+ const run = Date.parse(when.replace(" ", "T"));
383
+ return Number.isFinite(commit) && Number.isFinite(run) && run < commit;
384
+ }
385
+
386
+ // Прошлый прогон — из отчёта, который кладёт `doctor --run`. Отдельной функцией: его читают и
387
+ // `context`, и `prompt`, и два разбора одного файла разошлись бы.
388
+ async function readRun() {
389
+ const lastRun = join(CWD, TARGET_DIR, "last-run.md");
390
+ if (!(await exists(lastRun))) return null;
391
+ const run = parseLastRun(await readFile(lastRun, "utf8"));
392
+ if (run) run.stale = runIsStale(run.when);
393
+ return run;
394
+ }
395
+
396
+ export { declaredGates, sinceRef, runGates, progress, selectGates, listArg, writeRunReport, parseLastRun, readRun };
package/tool/program.mjs CHANGED
@@ -30,6 +30,7 @@ import { cmdProbe } from "./commands/probe.mjs";
30
30
  import { cmdContext } from "./commands/context.mjs";
31
31
  import { cmdPrompt } from "./commands/prompt.mjs";
32
32
  import { cmdVitals } from "./commands/vitals.mjs";
33
+ import { cmdFeedback } from "./commands/feedback.mjs";
33
34
 
34
35
  // Разбор аргументов выполняется только при запуске файла как программы. При импорте —
35
36
  // а так его читают модульные проверки tool/selfcheck/units.mjs — CLI запускаться не должен.
@@ -127,6 +128,11 @@ if (IS_MAIN) {
127
128
  case "badge":
128
129
  await cmdBadge(rest);
129
130
  break;
131
+ // Собирает отчёт о работе комплекта и даёт готовую ссылку. Ничего не отправляет: исходящий
132
+ // запрос у комплекта ровно один — про свежесть версии.
133
+ case "feedback":
134
+ await cmdFeedback();
135
+ break;
130
136
  default: {
131
137
  // Ширина колонки считается, а не подбирается пробелами: строки в двух языках разной
132
138
  // длины, и вручную выровненная справка на втором языке разъезжается.