agent-quality-kit 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +88 -18
  2. package/README.ru.md +91 -19
  3. package/kit/docs/ai/index.md +1 -0
  4. package/kit/docs/ai/operational-gates.md +275 -0
  5. package/kit/gates/_target.sh +53 -0
  6. package/kit/gates/ci-actually-fails/check.sh +18 -3
  7. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +10 -0
  8. package/kit/gates/entry-commands-exist/check.sh +88 -12
  9. package/kit/gates/hook-actually-fires/README.md +12 -0
  10. package/kit/gates/hook-actually-fires/check.sh +66 -6
  11. package/kit/gates/hook-actually-fires/gate.yml +2 -2
  12. package/kit/gates/hook-actually-fires/green/.claude/hooks/auto-format.sh +3 -0
  13. package/kit/gates/hook-actually-fires/green/.claude/hooks/block-dangerous.sh +3 -0
  14. package/kit/gates/hook-actually-fires/green/.claude/hooks/done.sh +3 -0
  15. package/kit/gates/hook-actually-fires/green/.claude/hooks/idle.sh +3 -0
  16. package/kit/gates/hook-actually-fires/green/.claude/hooks/prompt.sh +3 -0
  17. package/kit/gates/hook-actually-fires/green/.claude/hooks/session.mjs +1 -0
  18. package/kit/gates/hook-actually-fires/green/.claude/hooks/stop-gate.sh +3 -0
  19. package/kit/gates/hook-actually-fires/green/.claude/settings.json +12 -0
  20. package/kit/gates/test-not-adjusted/README.md +31 -0
  21. package/llms.txt +26 -7
  22. package/package.json +1 -1
  23. package/tool/commands/context.mjs +39 -41
  24. package/tool/commands/doctor-catalog.mjs +35 -10
  25. package/tool/commands/doctor.mjs +18 -26
  26. package/tool/commands/feedback.mjs +231 -0
  27. package/tool/commands/gates.mjs +12 -6
  28. package/tool/commands/project.mjs +11 -13
  29. package/tool/commands/prompt.mjs +2 -1
  30. package/tool/commands/report.mjs +19 -3
  31. package/tool/commands/vitals.mjs +9 -3
  32. package/tool/i18n/en-docs.mjs +19 -2
  33. package/tool/i18n/en-gates.mjs +35 -0
  34. package/tool/i18n/en.mjs +29 -2
  35. package/tool/i18n/ru-docs.mjs +18 -2
  36. package/tool/i18n/ru-gates.mjs +36 -0
  37. package/tool/i18n/ru.mjs +27 -2
  38. package/tool/lib/adopt.mjs +58 -4
  39. package/tool/lib/ask.mjs +118 -0
  40. package/tool/lib/brief.mjs +17 -38
  41. package/tool/lib/core.mjs +49 -12
  42. package/tool/lib/execution.mjs +32 -1
  43. package/tool/lib/gate-worker.mjs +4 -1
  44. package/tool/lib/manifest.mjs +39 -13
  45. package/tool/lib/prove.mjs +3 -3
  46. package/tool/lib/run.mjs +142 -10
  47. package/tool/program.mjs +6 -0
  48. package/tool/selfcheck/smoke/_fixture.mjs +13 -1
  49. package/tool/selfcheck/smoke/fail-closed.test.mjs +96 -1
  50. package/tool/selfcheck/smoke/feedback-send.test.mjs +87 -0
  51. package/tool/selfcheck/smoke/first-run.test.mjs +67 -3
  52. package/tool/selfcheck/smoke/preflight.test.mjs +83 -0
  53. package/tool/selfcheck/smoke/verdict.test.mjs +50 -4
  54. package/tool/selfcheck/smoke/version-sync.test.mjs +140 -0
  55. package/tool/selfcheck/smoke.sh +106 -4
  56. package/tool/selfcheck/units-ask.mjs +85 -0
  57. package/tool/selfcheck/units-brief.mjs +3 -13
  58. package/tool/selfcheck/units-context.mjs +2 -1
  59. package/tool/selfcheck/units-execution.mjs +37 -1
  60. package/tool/selfcheck/units-feedback.mjs +137 -0
  61. package/tool/selfcheck/units-level.mjs +41 -1
  62. package/tool/selfcheck/units-repo.mjs +75 -0
  63. package/tool/selfcheck/units-vitals.mjs +27 -0
  64. package/kit/gates/entry-links-exist/README.md +0 -27
  65. package/kit/gates/entry-links-exist/check.sh +0 -33
  66. package/kit/gates/entry-links-exist/gate.yml +0 -17
  67. package/kit/gates/entry-links-exist/green/AGENTS.md +0 -10
  68. package/kit/gates/entry-links-exist/green/rules/general.md +0 -3
  69. package/kit/gates/entry-links-exist/red/AGENTS.md +0 -3
  70. package/kit/gates/no-phantom-package/README.md +0 -84
  71. package/kit/gates/no-phantom-package/check.sh +0 -168
  72. package/kit/gates/no-phantom-package/gate.yml +0 -20
  73. package/kit/gates/no-phantom-package/green/AGENTS.md +0 -15
  74. package/kit/gates/no-phantom-package/red/AGENTS.md +0 -15
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env sh
2
- # Хук, который не сработает никогда: имя события с опечаткой, либо `matcher` на событии,
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 и убери matcher там, где его нет."
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 and the matcher is not silently ignored
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,3 @@
1
+ #!/usr/bin/env sh
2
+ # Образец: хук, который существует и потому может сработать.
3
+ exit 0
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+ # Образец: хук, который существует и потому может сработать.
3
+ exit 0
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+ # Образец: хук, который существует и потому может сработать.
3
+ exit 0
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+ # Образец: хук, который существует и потому может сработать.
3
+ exit 0
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+ # Образец: хук, который существует и потому может сработать.
3
+ exit 0
@@ -0,0 +1 @@
1
+ // Образец: путь лежит внутри кавычек вместе с переменной, и файл существует.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env sh
2
+ # Образец: хук, который существует и потому может сработать.
3
+ exit 0
@@ -69,6 +69,18 @@
69
69
  }
70
70
  ]
71
71
  }
72
+ ],
73
+ "SessionStart": [
74
+ {
75
+ "matcher": "startup|resume",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/session.mjs\"",
80
+ "timeout": 15
81
+ }
82
+ ]
83
+ }
72
84
  ]
73
85
  }
74
86
  }
@@ -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 standard and a CLI that check whether a repository is ready to have its code written by AI
4
- > coding agents. Every rule the project promises to follow becomes a command with an exit code,
5
- > so a machine holds the promise instead of somebody's attention. Reports a level from AQK-0 to
6
- > AQK-3, computed by a run never by a questionnaire and never by a model's opinion. One step
7
- > further than readiness scores: `probe` plants a known defect into a copy of the project and
8
- > checks whether the DECLARED guards go red. "Tests exist" and "tests catch" are different claims.
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.14.0` with `min: 1`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-quality-kit",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Turns the rules an agent is supposed to follow into commands with exit codes, and reports which of them actually run. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,14 +21,14 @@
21
21
  // а агент примет его за утверждение. Поэтому каждое незнание называется словом: прогона не было —
22
22
  // так и написано, прогон устарел — тоже, инструмента нет — тоже.
23
23
  import { readFile, writeFile, mkdir } from "node:fs/promises";
24
- import { spawnSync } from "node:child_process";
25
24
  import { join } from "node:path";
26
25
  import { CWD, TARGET_DIR, SELF, c, exists, commandRows, preCommitHook } from "../lib/core.mjs";
26
+ import { maybeAsk } from "./feedback.mjs";
27
27
  import { readManifest, assessLevel, coversOf } from "../lib/manifest.mjs";
28
28
  import { detectFacts, readCatalog } from "../lib/repo.mjs";
29
29
  import { catalogBuckets, startWith, blindAdvice } from "../lib/advice.mjs";
30
30
  import { proposeGates, readAdoptFiles } from "../lib/adopt.mjs";
31
- import { declaredGates } from "../lib/run.mjs";
31
+ import { declaredGates, readRun } from "../lib/run.mjs";
32
32
  import { probeStatus } from "./probe.mjs";
33
33
  import { L } from "../i18n/index.mjs";
34
34
 
@@ -73,11 +73,17 @@ function contextBlock(state, T = L.context) {
73
73
  out.push(T.runNone);
74
74
  } else {
75
75
  const red = state.run.red || [];
76
- const shown = red.slice(0, MAX_RED);
77
- const names = red.length > MAX_RED
78
- ? `${shown.join(", ")} — ${T.andMore(red.length - MAX_RED)}`
79
- : shown.join(", ");
80
- out.push(red.length ? T.runRed(state.run.when, names) : T.runClean(state.run.when));
76
+ const cannot = state.run.cannot || [];
77
+ const short = (list) => (list.length > MAX_RED
78
+ ? `${list.slice(0, MAX_RED).join(", ")} — ${T.andMore(list.length - MAX_RED)}`
79
+ : list.join(", "));
80
+ if (red.length) out.push(T.runRed(state.run.when, short(red)));
81
+ // «НЕ СМОГЛИ ПРОВЕРИТЬ» — ОТДЕЛЬНОЙ СТРОКОЙ, И ЧИСТО ТОЛЬКО КОГДА ОБА СПИСКА ПУСТЫ.
82
+ // Гейт, который не сумел отработать, не находка о коде: агент, прочитавший его как находку,
83
+ // пойдёт чинить исправный файл. А если бы он не попал НИКУДА, прогон, где всё сломалось,
84
+ // читался бы как «чисто» — та же тишина, только внутри блока, который читает машина.
85
+ if (cannot.length) out.push(T.runCannot(short(cannot)));
86
+ if (!red.length && !cannot.length) out.push(T.runClean(state.run.when));
81
87
  if (state.run.stale) out.push(T.runStale(state.run.when));
82
88
  if (state.run.skipped) out.push(T.skipped(state.run.skipped));
83
89
  }
@@ -148,20 +154,13 @@ function contextBlock(state, T = L.context) {
148
154
  // чем промолчать: он пойдёт его читать и получит пустоту вместо правил. Замерено на шести
149
155
  // чужих проектах: на flask блок писал «Свод правил: AGENTS.md», которого там нет.
150
156
  if (state.entryExists !== false) out.push("", T.where(state.entry || "AGENTS.md"));
157
+ // ПРОСЬБА ОБ ОТЗЫВЕ — последней строкой и только при содержании (feedback.mjs). Последней
158
+ // потому, что это единственная строка блока, которая не про состояние проекта: ставить её
159
+ // выше значило бы отодвинуть работой то, ради чего блок и читают.
160
+ if (state.ask) out.push("", state.ask);
151
161
  return out;
152
162
  }
153
163
 
154
- // Разбор отчёта прошлого прогона. Формат кладёт сам `doctor` в .aqk/last-run.md; читаем его,
155
- // а не запускаем гейты заново: хук обязан укладываться в секунду-две, а прогон у нас идёт минуту.
156
- function parseLastRun(text) {
157
- if (!text) return null;
158
- const when = (text.match(/^# aqk doctor --run — (.+)$/m) || [])[1] || "";
159
- const red = [];
160
- for (const m of text.matchAll(/^✘ ([^\s—]+)/gm)) red.push(m[1]);
161
- const skipped = (text.match(/^~ /gm) || []).length;
162
- return { when: when.trim(), red, skipped, stale: false };
163
- }
164
-
165
164
  // Правила и их арбитры: отметка `<!-- aqk: имя -->` рядом с правилом. `человек` — честное
166
165
  // признание, что машина этого не держит; так его и считаем, отдельно от машинных.
167
166
  function countArbiters(text, humanWords) {
@@ -173,16 +172,6 @@ function countArbiters(text, humanWords) {
173
172
  return { total: marks.length, machine: marks.length - human, human };
174
173
  }
175
174
 
176
- // Прогон старше последнего коммита описывает не тот код, что лежит перед агентом. Молча выдать
177
- // его за свежий — соврать: именно так «зелёный месяц назад» превращается в «зелёный сейчас».
178
- function runIsStale(when) {
179
- if (!when) return false;
180
- const r = spawnSync("git", ["log", "-1", "--format=%cI"], { cwd: CWD, encoding: "utf8" });
181
- if (r.status !== 0 || !r.stdout) return false;
182
- const commit = Date.parse(r.stdout.trim());
183
- const run = Date.parse(when.replace(" ", "T"));
184
- return Number.isFinite(commit) && Number.isFinite(run) && run < commit;
185
- }
186
175
 
187
176
 
188
177
  // УСТАНОВКА ХУКА — отдельной командой, а не частью `init`, и это решение, а не лень. Комплект
@@ -260,16 +249,6 @@ async function installHook(full = false) {
260
249
  console.log(c.dim(` ${T.hookWhat}`));
261
250
  }
262
251
 
263
- // Прошлый прогон — из отчёта, который кладёт `doctor --run`. Отдельной функцией: его читают и
264
- // `context`, и `prompt`, и два разбора одного файла разошлись бы.
265
- async function readRun() {
266
- const lastRun = join(CWD, TARGET_DIR, "last-run.md");
267
- if (!(await exists(lastRun))) return null;
268
- const run = parseLastRun(await readFile(lastRun, "utf8"));
269
- if (run) run.stale = runIsStale(run.when);
270
- return run;
271
- }
272
-
273
252
  // Что советовать — теми же функциями, что у `doctor`: корзины каталога, «начните с трёх», совет
274
253
  // под язык, чужие проверки проекта. Одно место на `context` и `prompt`: второй расчёт того же
275
254
  // самого разошёлся бы с первым. Класс из пробы, чей гейт уже стоит, в совет не идёт — ставить
@@ -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
- const adopt = declaredGates(man).length ? [] : proposeGates(await readAdoptFiles(CWD));
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, parseLastRun, countArbiters, withHook, hasOurHook, portableSelf, readRun, readAdvice };
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
- if (!declaredGates(man).length) {
81
- const found = proposeGates(await readAdoptFiles(CWD));
82
- if (found.length) {
83
- console.log(`\n ${c.bold(L.doctor.haveAlready(found.length))}`);
84
- for (const g of found) {
85
- console.log(` ${c.green("✔")} ${g.name.padEnd(12)} ${c.dim(`${g.cmd} ← ${g.source}`)}`);
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
+ // «держит машина проекту с настоящим pre-commit и Makefile. Чем больше человек настроил, тем
113
+ // меньше мы о нём знали. Повтора не будет: уже объявленную команду `proposeGates` отсеивает сам.
114
+ printAdopted(proposeGates(await readAdoptFiles(CWD), declaredGates(man).map(([, cmd]) => cmd)));
90
115
 
91
116
  // ЧТО ВАШИ ПРОВЕРКИ ПРОПУСТИЛИ. Проба знала имена непойманных классов и писала в отметку одно
92
117
  // число; человек в `doctor` не видел ничего. Это самое конкретное, что мы знаем о проекте, —
@@ -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;