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 +29 -5
- package/README.ru.md +31 -6
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/tool/commands/context.mjs +36 -40
- package/tool/commands/doctor.mjs +18 -26
- package/tool/commands/feedback.mjs +157 -0
- package/tool/commands/project.mjs +11 -13
- package/tool/commands/prompt.mjs +2 -1
- package/tool/i18n/en-docs.mjs +1 -0
- package/tool/i18n/en-gates.mjs +27 -0
- package/tool/i18n/en.mjs +5 -0
- package/tool/i18n/ru-docs.mjs +1 -0
- package/tool/i18n/ru-gates.mjs +28 -0
- package/tool/i18n/ru.mjs +7 -0
- package/tool/lib/ask.mjs +118 -0
- package/tool/lib/brief.mjs +17 -38
- package/tool/lib/core.mjs +7 -6
- package/tool/lib/gate-worker.mjs +4 -1
- package/tool/lib/run.mjs +120 -7
- package/tool/program.mjs +6 -0
- package/tool/selfcheck/smoke/fail-closed.test.mjs +96 -1
- package/tool/selfcheck/units-ask.mjs +85 -0
- package/tool/selfcheck/units-brief.mjs +3 -13
- package/tool/selfcheck/units-context.mjs +2 -1
- package/tool/selfcheck/units-feedback.mjs +137 -0
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}`,
|
package/tool/i18n/ru-docs.mjs
CHANGED
|
@@ -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}`,
|
package/tool/i18n/ru-gates.mjs
CHANGED
|
@@ -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}`,
|
package/tool/lib/ask.mjs
ADDED
|
@@ -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 };
|
package/tool/lib/brief.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFile
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
156
|
-
|
|
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
|
-
|
|
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`
|
|
193
|
-
//
|
|
194
|
-
|
|
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
|
-
//
|
|
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",
|
|
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,
|
|
189
|
+
SELF, REPO_URL, c, exists, die, stateDirs, commandRows, preCommitHook, RUNTIME_FILES, ensureIgnored,
|
|
189
190
|
};
|
package/tool/lib/gate-worker.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
// длины, и вручную выровненная справка на втором языке разъезжается.
|