agent-quality-kit 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -18
- package/README.ru.md +91 -19
- package/kit/docs/ai/index.md +1 -0
- package/kit/docs/ai/operational-gates.md +275 -0
- package/kit/gates/_target.sh +53 -0
- package/kit/gates/ci-actually-fails/check.sh +18 -3
- package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +10 -0
- package/kit/gates/entry-commands-exist/check.sh +88 -12
- package/kit/gates/hook-actually-fires/README.md +12 -0
- package/kit/gates/hook-actually-fires/check.sh +66 -6
- package/kit/gates/hook-actually-fires/gate.yml +2 -2
- package/kit/gates/hook-actually-fires/green/.claude/hooks/auto-format.sh +3 -0
- package/kit/gates/hook-actually-fires/green/.claude/hooks/block-dangerous.sh +3 -0
- package/kit/gates/hook-actually-fires/green/.claude/hooks/done.sh +3 -0
- package/kit/gates/hook-actually-fires/green/.claude/hooks/idle.sh +3 -0
- package/kit/gates/hook-actually-fires/green/.claude/hooks/prompt.sh +3 -0
- package/kit/gates/hook-actually-fires/green/.claude/hooks/session.mjs +1 -0
- package/kit/gates/hook-actually-fires/green/.claude/hooks/stop-gate.sh +3 -0
- package/kit/gates/hook-actually-fires/green/.claude/settings.json +12 -0
- package/kit/gates/test-not-adjusted/README.md +31 -0
- package/llms.txt +26 -7
- package/package.json +1 -1
- package/tool/commands/context.mjs +39 -41
- package/tool/commands/doctor-catalog.mjs +35 -10
- package/tool/commands/doctor.mjs +18 -26
- package/tool/commands/feedback.mjs +231 -0
- package/tool/commands/gates.mjs +12 -6
- package/tool/commands/project.mjs +11 -13
- package/tool/commands/prompt.mjs +2 -1
- package/tool/commands/report.mjs +19 -3
- package/tool/commands/vitals.mjs +9 -3
- package/tool/i18n/en-docs.mjs +19 -2
- package/tool/i18n/en-gates.mjs +35 -0
- package/tool/i18n/en.mjs +29 -2
- package/tool/i18n/ru-docs.mjs +18 -2
- package/tool/i18n/ru-gates.mjs +36 -0
- package/tool/i18n/ru.mjs +27 -2
- package/tool/lib/adopt.mjs +58 -4
- package/tool/lib/ask.mjs +118 -0
- package/tool/lib/brief.mjs +17 -38
- package/tool/lib/core.mjs +49 -12
- package/tool/lib/execution.mjs +32 -1
- package/tool/lib/gate-worker.mjs +4 -1
- package/tool/lib/manifest.mjs +39 -13
- package/tool/lib/prove.mjs +3 -3
- package/tool/lib/run.mjs +142 -10
- package/tool/program.mjs +6 -0
- package/tool/selfcheck/smoke/_fixture.mjs +13 -1
- package/tool/selfcheck/smoke/fail-closed.test.mjs +96 -1
- package/tool/selfcheck/smoke/feedback-send.test.mjs +87 -0
- package/tool/selfcheck/smoke/first-run.test.mjs +67 -3
- package/tool/selfcheck/smoke/preflight.test.mjs +83 -0
- package/tool/selfcheck/smoke/verdict.test.mjs +50 -4
- package/tool/selfcheck/smoke/version-sync.test.mjs +140 -0
- package/tool/selfcheck/smoke.sh +106 -4
- 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-execution.mjs +37 -1
- package/tool/selfcheck/units-feedback.mjs +137 -0
- package/tool/selfcheck/units-level.mjs +41 -1
- package/tool/selfcheck/units-repo.mjs +75 -0
- package/tool/selfcheck/units-vitals.mjs +27 -0
- package/kit/gates/entry-links-exist/README.md +0 -27
- package/kit/gates/entry-links-exist/check.sh +0 -33
- package/kit/gates/entry-links-exist/gate.yml +0 -17
- package/kit/gates/entry-links-exist/green/AGENTS.md +0 -10
- package/kit/gates/entry-links-exist/green/rules/general.md +0 -3
- package/kit/gates/entry-links-exist/red/AGENTS.md +0 -3
- package/kit/gates/no-phantom-package/README.md +0 -84
- package/kit/gates/no-phantom-package/check.sh +0 -168
- package/kit/gates/no-phantom-package/gate.yml +0 -20
- package/kit/gates/no-phantom-package/green/AGENTS.md +0 -15
- package/kit/gates/no-phantom-package/red/AGENTS.md +0 -15
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
|
-
# Хук, который не сработает никогда: имя события с опечаткой,
|
|
3
|
-
#
|
|
2
|
+
# Хук, который не сработает никогда: имя события с опечаткой, `matcher` на событии, которое его
|
|
3
|
+
# не поддерживает, либо команда, указывающая на файл, которого нет.
|
|
4
|
+
#
|
|
5
|
+
# ТРЕТИЙ КЛАСС ДОБАВЛЕН 2026-09-14, И НАШЁЛ ЕГО ЧУЖОЙ ИНСТРУМЕНТ. `agnix`, прогнанный по нашему
|
|
6
|
+
# же репозиторию, сказал «Script file not found» о ШЕСТИ хуках в нашем ЗЕЛЁНОМ образце: гейт с
|
|
7
|
+
# именем `hook-actually-fires` говорил «чисто» о настройке, где ни один хук сработать не мог.
|
|
8
|
+
# Это второй раз за неделю, когда соседский инструмент находит у нас то, чего не видят наши же
|
|
9
|
+
# проверки, — и ровно тот класс, ради которого написан весь комплект.
|
|
4
10
|
#
|
|
5
11
|
# ЗАЧЕМ. Человек заводит хук, чтобы машина держала то, что он держать не может: не дать
|
|
6
12
|
# сделать force-push, отформатировать после правки, не отпустить работу с красным линтером.
|
|
@@ -117,6 +123,10 @@ for F in "$DIR/.claude/settings.json" "$DIR/.claude/settings.local.json" \
|
|
|
117
123
|
# означают «всё» — ровно то, что и происходит на событии без поддержки matcher, то
|
|
118
124
|
# есть автор не обманут. Замер по 39 чужим настройкам: без этого сужения гейт краснел
|
|
119
125
|
# на четырёх, и все четыре были «matcher»: "" либо "*", то есть шум.
|
|
126
|
+
if (awaitCmd) {
|
|
127
|
+
awaitCmd = 0
|
|
128
|
+
printf "@CMD@%d@%s\n", cmdLine, pending
|
|
129
|
+
}
|
|
120
130
|
if (awaitMatcher) {
|
|
121
131
|
awaitMatcher = 0
|
|
122
132
|
if (pending != "" && pending != "*" && pending != ".*")
|
|
@@ -130,6 +140,9 @@ for F in "$DIR/.claude/settings.json" "$DIR/.claude/settings.local.json" \
|
|
|
130
140
|
if (c == ":") {
|
|
131
141
|
key = pending; keyLine = pendingLine
|
|
132
142
|
if (ev != "" && key == "matcher" && (ev in NOMATCHER)) { awaitMatcher = 1; matcherLine = keyLine; matcherEv = ev }
|
|
143
|
+
# Команда хука. Существование файла проверяет ОБОЛОЧКА, а не awk: у awk нет способа
|
|
144
|
+
# спросить файловую систему, не вызывая внешний процесс на каждую строку.
|
|
145
|
+
if (key == "command") { awaitCmd = 1; cmdLine = keyLine }
|
|
133
146
|
continue
|
|
134
147
|
}
|
|
135
148
|
# ЛЮБОЙ структурный символ снимает ожидание значения matcher. Без этого нестроковое
|
|
@@ -137,7 +150,7 @@ for F in "$DIR/.claude/settings.json" "$DIR/.claude/settings.local.json" \
|
|
|
137
150
|
# следующая строка документа — обычно ключ «hooks» — печаталась как значение matcher.
|
|
138
151
|
# Найдено код-ревью 2026-09-07.
|
|
139
152
|
if (c == "{" || c == "[") {
|
|
140
|
-
awaitMatcher = 0
|
|
153
|
+
awaitMatcher = 0; awaitCmd = 0
|
|
141
154
|
depth++
|
|
142
155
|
if (c == "{" && rootIsHooks == 1 && depth == 1 && hooksDepth == -1) hooksDepth = 1
|
|
143
156
|
else if (c == "{" && key == "hooks" && hooksDepth == -1) hooksDepth = depth
|
|
@@ -145,12 +158,12 @@ for F in "$DIR/.claude/settings.json" "$DIR/.claude/settings.local.json" \
|
|
|
145
158
|
key = ""; continue
|
|
146
159
|
}
|
|
147
160
|
if (c == "}" || c == "]") {
|
|
148
|
-
awaitMatcher = 0
|
|
161
|
+
awaitMatcher = 0; awaitCmd = 0
|
|
149
162
|
if (depth == evDepth) { ev = ""; evDepth = -1 }
|
|
150
163
|
if (depth == hooksDepth) hooksDepth = -1
|
|
151
164
|
depth--; key = ""; continue
|
|
152
165
|
}
|
|
153
|
-
if (c == ",") { awaitMatcher = 0; key = ""; continue }
|
|
166
|
+
if (c == ",") { awaitMatcher = 0; awaitCmd = 0; key = ""; continue }
|
|
154
167
|
}
|
|
155
168
|
}
|
|
156
169
|
' "$F" 2>/tmp/.hookerr.$$)
|
|
@@ -160,6 +173,52 @@ for F in "$DIR/.claude/settings.json" "$DIR/.claude/settings.local.json" \
|
|
|
160
173
|
# Разделяем их кодом возврата: «не смогли разобрать» — это 2, а не молчаливый ноль.
|
|
161
174
|
if [ "$CODE" -ne 0 ] || [ -n "$E" ]; then
|
|
162
175
|
ERR="$ERR$F: разобрать не удалось${E:+ — }$E
|
|
176
|
+
"
|
|
177
|
+
fi
|
|
178
|
+
# КОМАНДЫ ХУКОВ — отдельной дорожкой: awk отдал их помеченными строками, файловую систему
|
|
179
|
+
# спрашивает оболочка.
|
|
180
|
+
#
|
|
181
|
+
# ГРАНИЦА НАМЕРЕННО УЗКАЯ. Красим только ОТНОСИТЕЛЬНЫЙ путь со слэшем: `.claude/hooks/x.sh`,
|
|
182
|
+
# `scripts/guard.sh`. Программа из PATH (`npx`, `prettier`, `bash -c …`) нам не видна — у неё
|
|
183
|
+
# нет пути, и «не нашли» означало бы обвинение по догадке. Абсолютный путь пропускаем: он
|
|
184
|
+
# относится к чужой машине, а не к репозиторию. Из двух ошибок здесь выбирается молчание:
|
|
185
|
+
# ложное обвинение выключает гейт целиком.
|
|
186
|
+
CMDS=$(printf '%s\n' "$RES" | grep '^@CMD@' || true)
|
|
187
|
+
RES=$(printf '%s\n' "$RES" | grep -v '^@CMD@' || true)
|
|
188
|
+
if [ -n "$CMDS" ]; then
|
|
189
|
+
MISS=$(printf '%s\n' "$CMDS" | while IFS= read -r L; do
|
|
190
|
+
[ -z "$L" ] && continue
|
|
191
|
+
LN=$(printf '%s' "$L" | cut -d@ -f3)
|
|
192
|
+
CMD=$(printf '%s' "$L" | cut -d@ -f4-)
|
|
193
|
+
# Переменная окружения, которой Claude Code называет корень проекта, — это и есть DIR.
|
|
194
|
+
CMD=$(printf '%s' "$CMD" | sed 's|\${CLAUDE_PROJECT_DIR}/*||g; s|\$CLAUDE_PROJECT_DIR/*||g')
|
|
195
|
+
# КАВЫЧКИ СНИМАЮТСЯ ПОСЛЕ ПЕРЕМЕННОЙ И ДО РАЗБОРА НА СЛОВА. Найдено замером 2026-09-15 по
|
|
196
|
+
# `kupzed/catatz`: у них `node "$CLAUDE_PROJECT_DIR/.claude/hooks/adapter.mjs"` — путь
|
|
197
|
+
# лежит ВНУТРИ кавычек вместе с переменной. Переменную мы снимали, кавычки оставались, и
|
|
198
|
+
# файл искался по имени с кавычками. Пять «пропавших» хуков, и все пять на месте (200).
|
|
199
|
+
# Письмо по такой находке было бы неправдой — а писать мы собирались именно по ним.
|
|
200
|
+
CMD=$(printf '%s' "$CMD" | tr -d '"'"'"'"')
|
|
201
|
+
# Первое слово — программа. Если это запускалка, файл стоит вторым.
|
|
202
|
+
P=$(printf '%s' "$CMD" | awk "{print \$1}")
|
|
203
|
+
case "$P" in
|
|
204
|
+
bash|sh|node|python|python3|ruby|perl|deno|bun) P=$(printf '%s' "$CMD" | awk "{print \$2}") ;;
|
|
205
|
+
esac
|
|
206
|
+
case "$P" in
|
|
207
|
+
# ПЕРЕМЕННАЯ, КОТОРУЮ МЫ НЕ РАСКРЫВАЕМ, — повод молчать. Замер 2026-09-15 по
|
|
208
|
+
# `Aurealibe/claude-config`: `${CLAUDE_PLUGIN_ROOT:-.}/.claude/hooks/session-start`.
|
|
209
|
+
# Значение задаётся снаружи, проверить путь на диске нельзя, и объявить его пропавшим
|
|
210
|
+
# значит обвинить по догадке. CLAUDE_PROJECT_DIR — исключение: он и есть корень, и
|
|
211
|
+
# снимается выше.
|
|
212
|
+
*'$'*) continue ;;
|
|
213
|
+
# ДОМАШНИЙ КАТАЛОГ — НЕ РЕПОЗИТОРИЙ. Найдено 2026-09-15 на `Dynokostya/just-works`:
|
|
214
|
+
# `bash ~/.claude/statusline-command.sh` — хук пользовательского уровня, он живёт у
|
|
215
|
+
# человека, а не в проекте. Проверить его на диске проекта нельзя.
|
|
216
|
+
'~'*) continue ;;
|
|
217
|
+
""|-*|/*) continue ;;
|
|
218
|
+
*/*) [ -e "$DIR/$P" ] || printf "%s:%s: команда хука указывает на «%s» — такого файла в проекте нет, хук не сработает никогда\n" "$F" "$LN" "$P" ;;
|
|
219
|
+
esac
|
|
220
|
+
done)
|
|
221
|
+
[ -n "$MISS" ] && OUT="$OUT$MISS
|
|
163
222
|
"
|
|
164
223
|
fi
|
|
165
224
|
[ -n "$RES" ] && OUT="$OUT$RES
|
|
@@ -178,6 +237,7 @@ fi
|
|
|
178
237
|
ALL=$(printf '%s' "$OUT" | grep -v '^$')
|
|
179
238
|
[ -z "$ALL" ] && exit 0
|
|
180
239
|
printf '%s\n' "$ALL"
|
|
181
|
-
echo " почини: сверь имя события с https://code.claude.com/docs/en/hooks
|
|
240
|
+
echo " почини: сверь имя события с https://code.claude.com/docs/en/hooks, убери matcher там, где его нет,"
|
|
241
|
+
echo " и верни на место файл, на который указывает команда, — либо убери сам хук."
|
|
182
242
|
echo " хук с неверным именем не вызывается и об этом не сообщается — защита существует только на бумаге."
|
|
183
243
|
exit 1
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
intent: хук агента правда срабатывает — имя события известно, matcher не игнорируется
|
|
2
|
-
intent_en: an agent hook actually fires — the event name is real
|
|
1
|
+
intent: хук агента правда срабатывает — имя события известно, matcher не игнорируется молча, файл команды существует
|
|
2
|
+
intent_en: an agent hook actually fires — the event name is real, the matcher is not silently ignored, and the command's file exists
|
|
3
3
|
|
|
4
4
|
# Только там, где агента настраивали. В проекте без `.claude/settings.json` проверять нечего,
|
|
5
5
|
# а запись, показанная не тому, стоит доверия всему каталогу.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// Образец: путь лежит внутри кавычек вместе с переменной, и файл существует.
|
|
@@ -77,3 +77,34 @@ deletes or disables the checkwash job disarms it in the same diff»* — это
|
|
|
77
77
|
коммитами. `red/` — код изменён на неверный, три утверждения заменены на `is not None`.
|
|
78
78
|
`green/` — код и тест выросли вместе: добавлена функция и тест к ней, ни одно утверждение не
|
|
79
79
|
убрано и не ослаблено.
|
|
80
|
+
|
|
81
|
+
## Известное ложное срабатывание: литерал `"\n"` читается как имя теста
|
|
82
|
+
|
|
83
|
+
**Цена записи, названная вслух.** `checkwash` принимает строковый литерал `"\n"` за ИМЯ
|
|
84
|
+
тестового юнита. Файл, где таких литералов два и больше, при ЛЮБОМ сдвиге строк получает
|
|
85
|
+
`TEST_DISABLED — test unit disappeared` уровня **high** — даже если тронут один комментарий и
|
|
86
|
+
ни одно утверждение не изменено.
|
|
87
|
+
|
|
88
|
+
Воспроизведение, шесть строк (проверено 2026-09-16 на 0.2.13 и 0.3.4 — в обеих одинаково):
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
import test from "node:test";
|
|
92
|
+
test("единственный", (t) => {
|
|
93
|
+
const a = "x\ny".split("\n").slice(-1).join("\n");
|
|
94
|
+
const b = "p\nq".split("\n").slice(-1).join("\n");
|
|
95
|
+
t.assert.ok(a && b);
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Коммит «before» с этим файлом, коммит «after» с добавленной строкой комментария — и
|
|
100
|
+
`checkwash check HEAD~1..HEAD` даёт `verdict: block`, `unit: "\n"`.
|
|
101
|
+
|
|
102
|
+
`split("\n")` — обычнейшая конструкция в тестах, работающих с выводом команды. Мы наступили на
|
|
103
|
+
это на собственном `tool/selfcheck/smoke/verdict.test.mjs`: гейт покраснел на добавленном
|
|
104
|
+
комментарии. У себя обошли устранением настоящего повтора (хвост вывода вынесен в функцию
|
|
105
|
+
фикстуры), но **это обход, а не лечение**: чинить может только автор инструмента.
|
|
106
|
+
|
|
107
|
+
**Что это значит для того, кто ставит запись.** Приговор `TEST_DISABLED` на файле, где
|
|
108
|
+
утверждения не менялись, стоит перепроверить глазами: посмотрите, есть ли в нём литералы
|
|
109
|
+
`"\n"` и совпадает ли имя «исчезнувшего юнита» с настоящим именем теста. Имя вида `\n` — признак
|
|
110
|
+
этого дефекта, а не подгонки теста. Остальные двадцать детекторов инструмента он не затрагивает.
|
package/llms.txt
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# AQK — Agent Quality Kit
|
|
2
2
|
|
|
3
|
-
> A
|
|
4
|
-
>
|
|
5
|
-
>
|
|
6
|
-
>
|
|
7
|
-
>
|
|
8
|
-
>
|
|
3
|
+
> A CLI that answers one question: can the checks this repository declares actually fail? A check
|
|
4
|
+
> that cannot fail looks exactly like a check that passes — `|| true`, `continue-on-error`, a
|
|
5
|
+
> linter aimed at an empty directory, a test with no assertion, a hook nobody installed. AQK plants
|
|
6
|
+
> a known defect into a COPY of the project and reports which declared guard noticed and which
|
|
7
|
+
> stayed silent. It distinguishes three outcomes and never collapses them into two: clean ·
|
|
8
|
+
> finding · COULD NOT CHECK. The third means the check itself failed; treating it as either of the
|
|
9
|
+
> others is how a repository ends up protected by checks that cannot go red. Every rule the project
|
|
10
|
+
> promises becomes a command with an exit code, so a machine holds the promise instead of somebody's
|
|
11
|
+
> attention. Levels AQK-0..3 measure equipment, not quality, and are computed by a run — never by a
|
|
12
|
+
> questionnaire and never by a model's opinion.
|
|
9
13
|
|
|
10
14
|
Vendor-neutral: works with any coding agent (Claude Code, Codex, Cursor, Gemini CLI, GitHub
|
|
11
15
|
Copilot, Windsurf, Aider, OpenCode) and with no AI at all. It calls no vendor API and needs no
|
|
@@ -50,6 +54,18 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
|
|
|
50
54
|
ground rules on top, at most five items by weight (red gates, defects the probe planted and no
|
|
51
55
|
check caught, the project's own checks to declare, rules Claude Code cannot see, catalog
|
|
52
56
|
entries to propose), and for every item the command that proves it done. Nothing is run
|
|
57
|
+
- Send the author one answer — the only payment the kit asks: `npx agent-quality-kit feedback`
|
|
58
|
+
prints a short report built from the last run and the probe (version, level, stack, what went
|
|
59
|
+
red, what it COULD NOT CHECK, which defect classes nothing here catches) with **no paths, no
|
|
60
|
+
code and no repository name**, plus a prefilled link. `feedback --send "your words"` posts it in
|
|
61
|
+
one command using the human's own `gh` account, as a comment in an open discussion. **Without
|
|
62
|
+
the flag nothing ever leaves the machine**, and a machine holds that claim, not this sentence.
|
|
63
|
+
An agent must show the text and ask before adding the flag
|
|
64
|
+
- Install it into Claude Code as a plugin, from the kit's own marketplace, with nobody's approval
|
|
65
|
+
to wait for: `/plugin marketplace add arsen-ask-lx/Agent_Quality_Kit` then
|
|
66
|
+
`/plugin install aqk@agent-quality-kit`. The session-start hook puts the repository state into
|
|
67
|
+
the agent's context before its first action; two skills cover "can these checks fail" and "what
|
|
68
|
+
to fix first". A thin wrapper over the same CLI — no logic of its own
|
|
53
69
|
- Check that the kit's own wiring is actually connected: `npx agent-quality-kit vitals` — are the
|
|
54
70
|
tools the declared gates need installed, is the hook present in `.git/hooks` (a line in the
|
|
55
71
|
config is an intention, not a guard), does the agent receive the state, is the version current.
|
|
@@ -76,7 +92,7 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
|
|
|
76
92
|
the files `init` writes are owned by root, so you cannot edit your own manifest. Debian-based
|
|
77
93
|
on purpose: the gates are `sh`, `grep`, `awk`, `find` — under alpine's busybox they behave
|
|
78
94
|
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.
|
|
95
|
+
- As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.16.0` with `min: 1`
|
|
80
96
|
(https://github.com/marketplace/actions/agent-quality-kit-aqk)
|
|
81
97
|
|
|
82
98
|
## What makes it different
|
|
@@ -95,6 +111,9 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
|
|
|
95
111
|
- `.aqk.yml` — the manifest: entry, rules, docs, lang, gates as commands, covers (what a
|
|
96
112
|
declared gate already holds, so it is not reported as debt), samples, ratchets, lessons
|
|
97
113
|
- `.aqkignore` — paths the scanning checks must not read (brought-in code, vendored, generated)
|
|
114
|
+
- `.aqk/last-run.md` — the short report of the last run, with three marks and not two: `✔` clean,
|
|
115
|
+
`✘` a finding, `?` COULD NOT CHECK. Readers (the context block, the task list, the feedback
|
|
116
|
+
report) must never collapse the third into either of the others
|
|
98
117
|
|
|
99
118
|
## Documentation
|
|
100
119
|
|
package/package.json
CHANGED
|
@@ -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
|
|
77
|
-
const
|
|
78
|
-
? `${
|
|
79
|
-
:
|
|
80
|
-
|
|
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
|
// самого разошёлся бы с первым. Класс из пробы, чей гейт уже стоит, в совет не идёт — ставить
|
|
@@ -278,7 +257,9 @@ async function readAdvice(man, probe) {
|
|
|
278
257
|
const facts = await detectFacts(man);
|
|
279
258
|
const catalog = await readCatalog();
|
|
280
259
|
const { todo } = catalogBuckets(catalog, facts, coversOf(man).covered);
|
|
281
|
-
|
|
260
|
+
// Чужие проверки — и тогда, когда гейты уже объявлены: отсев идёт по КОМАНДЕ внутри
|
|
261
|
+
// `proposeGates`, а не по факту «манифест не пуст» (замер 2026-09-16, см. doctor-catalog).
|
|
262
|
+
const adopt = proposeGates(await readAdoptFiles(CWD), declaredGates(man).map(([, cmd]) => cmd));
|
|
282
263
|
const blind = (probe?.classes || [])
|
|
283
264
|
.filter((b) => !facts.gateKeys.includes(b.slug))
|
|
284
265
|
.map((b) => ({ ...b, command: blindAdvice(catalog.find((r) => r.slug === b.slug), facts, {}).command }));
|
|
@@ -364,10 +345,27 @@ async function cmdContext(args = []) {
|
|
|
364
345
|
next = nextSteps({ init: !man, adopt, blind, start });
|
|
365
346
|
} catch { /* не посчитали — блок скажет остальное; выдумывать шаги нельзя */ }
|
|
366
347
|
|
|
348
|
+
// ЕДИНСТВЕННАЯ ПЛАТА ЗА КОМПЛЕКТ — один ответ автору, и просит о нём агент: он читает этот
|
|
349
|
+
// блок каждую сессию и передаёт человеку то, что в нём написано. Замер 2026-09-14: тысяча
|
|
350
|
+
// скачиваний в неделю и ноль отзывов за всё время — просьба печаталась только при `init`, то
|
|
351
|
+
// есть до того, как комплект сделал хоть что-то.
|
|
352
|
+
//
|
|
353
|
+
// ТРИ УСЛОВИЯ, И ВСЕ ТРИ ОБЯЗАТЕЛЬНЫ: не выключено человеком, не просили на этом проекте
|
|
354
|
+
// раньше, и ЕСТЬ О ЧЁМ рассказать. Без третьего это «оставьте отзыв» — шум, а шум выключают
|
|
355
|
+
// вместе с хуком, в котором он приехал.
|
|
356
|
+
//
|
|
357
|
+
// ЕДИНСТВЕННАЯ ЗАПИСЬ НА ДИСК В ЭТОЙ КОМАНДЕ, кроме `--install`. Без отметки просьба
|
|
358
|
+
// повторялась бы каждую сессию: красный гейт живёт в проекте днями, а блок читается заново
|
|
359
|
+
// при каждом запуске агента и после каждого сжатия контекста.
|
|
360
|
+
const ask = await maybeAsk({
|
|
361
|
+
cannot: run?.cannot || [], red: run?.red || [],
|
|
362
|
+
blind: (probe?.classes || []).map((b) => b.slug),
|
|
363
|
+
}, portableSelf(SELF), { agent: true });
|
|
364
|
+
|
|
367
365
|
console.log(contextBlock({
|
|
368
366
|
entry, entryExists: rules !== null, level, rules, run, ratchets, probe, full: fullPart,
|
|
369
|
-
next, when: { hook: await preCommitHook(CWD) },
|
|
367
|
+
next, when: { hook: await preCommitHook(CWD) }, ask,
|
|
370
368
|
}).join("\n"));
|
|
371
369
|
}
|
|
372
370
|
|
|
373
|
-
export { cmdContext, contextBlock, nextSteps,
|
|
371
|
+
export { cmdContext, contextBlock, nextSteps, countArbiters, withHook, hasOurHook, portableSelf, readAdvice };
|
|
@@ -18,6 +18,34 @@ import { L } from "../i18n/index.mjs";
|
|
|
18
18
|
|
|
19
19
|
// Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
|
|
20
20
|
// место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
|
|
21
|
+
// ЧТО У ПРОЕКТА УЖЕ ЕСТЬ — отдельной функцией, а не ветками внутри вывода: выбор причины из
|
|
22
|
+
// трёх плюс перебор плюс два условия давали вложенность 6 при пределе 5, и наш же гейт это
|
|
23
|
+
// поймал. Печать — единственное, что здесь происходит; решение принято в `adopt.mjs`.
|
|
24
|
+
function weakSay(weak) {
|
|
25
|
+
return weak.kind === "off" ? L.doctor.weakOff(weak.text)
|
|
26
|
+
: weak.kind === "zero" ? L.doctor.weakZero()
|
|
27
|
+
: L.doctor.weakStub(weak.text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printAdopted(found) {
|
|
31
|
+
if (!found.length) return;
|
|
32
|
+
console.log(`\n ${c.bold(L.doctor.haveAlready(found.length))}`);
|
|
33
|
+
for (const g of found) {
|
|
34
|
+
// ВЫКЛЮЧЕННАЯ ПРОВЕРКА — ЭТО НАХОДКА, а не «посмотреть не смогли»: мы прочитали тело
|
|
35
|
+
// скрипта и знаем ответ. Поэтому крест, а не вопрос, и причина названа дословно — человек
|
|
36
|
+
// не поверит обвинению без строки, которую может найти у себя глазами.
|
|
37
|
+
console.log(` ${g.weak ? c.red("✘") : c.green("✔")} ${g.name.padEnd(12)} ${c.dim(`${g.cmd} ← ${g.source}`)}`);
|
|
38
|
+
if (g.weak) console.log(` ${c.red(weakSay(g.weak))}`);
|
|
39
|
+
}
|
|
40
|
+
// «Впишите в манифест» — ТОЛЬКО про рабочие. Строкой выше сказано, что слабую объявлять
|
|
41
|
+
// нельзя; предложить её тут же — это совет, противоречащий собственному предостережению,
|
|
42
|
+
// и человек послушает тот, что ближе к строке с командой.
|
|
43
|
+
const weak = found.filter((g) => g.weak).length;
|
|
44
|
+
const ok = found.filter((g) => !g.weak);
|
|
45
|
+
if (weak) console.log(c.dim(` ${L.doctor.weakHow(weak)}`));
|
|
46
|
+
if (ok.length) console.log(c.dim(` ${L.doctor.haveAlreadyHow(ok.map((g) => `${g.name}: "${g.cmd}"`).join(" "))}`));
|
|
47
|
+
}
|
|
48
|
+
|
|
21
49
|
async function reportBaseline(man, facts) {
|
|
22
50
|
const { readdir, readFile } = await import("node:fs/promises");
|
|
23
51
|
let files = [];
|
|
@@ -77,16 +105,13 @@ async function reportCatalog(man, facts, probe = null, verbose = true) {
|
|
|
77
105
|
// только СВОИ записи, а чужие проверки не читали вовсе. С точки зрения владельца это
|
|
78
106
|
// неправда, и первое, что он видел, было обвинением. Предлагаем, а не вписываем: гейт в
|
|
79
107
|
// чужом манифесте без спроса — наше решение в чужом файле.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
console.log(c.dim(` ${L.doctor.haveAlreadyHow(found.map((g) => `${g.name}: "${g.cmd}"`).join(" "))}`));
|
|
88
|
-
}
|
|
89
|
-
}
|
|
108
|
+
//
|
|
109
|
+
// ПОКАЗЫВАЕТСЯ И ТОГДА, КОГДА ГЕЙТЫ ОБЪЯВЛЕНЫ. Прежде здесь стояло `!declaredGates(man).length`:
|
|
110
|
+
// чужие проверки читались только у того, у кого гейтов нет вовсе. Замер 2026-09-16 по двенадцати
|
|
111
|
+
// репозиториям: у шести зрелых блок исчезал целиком, стоило объявить ОДИН гейт, и итог говорил
|
|
112
|
+
// «держит машина 0» проекту с настоящим pre-commit и Makefile. Чем больше человек настроил, тем
|
|
113
|
+
// меньше мы о нём знали. Повтора не будет: уже объявленную команду `proposeGates` отсеивает сам.
|
|
114
|
+
printAdopted(proposeGates(await readAdoptFiles(CWD), declaredGates(man).map(([, cmd]) => cmd)));
|
|
90
115
|
|
|
91
116
|
// ЧТО ВАШИ ПРОВЕРКИ ПРОПУСТИЛИ. Проба знала имена непойманных классов и писала в отметку одно
|
|
92
117
|
// число; человек в `doctor` не видел ничего. Это самое конкретное, что мы знаем о проекте, —
|
package/tool/commands/doctor.mjs
CHANGED
|
@@ -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;
|